Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,41 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
}),
);

it.effect("compiles the checkpoint rewind double-Escape sequence", () =>
Effect.sync(() => {
const escapeShortcut = {
key: "escape",
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
modKey: false,
};
assert.deepEqual(
compileResolvedKeybindingRule({
key: "esc esc",
command: "checkpoint.rewind",
when: "!terminalFocus",
}),
{
command: "checkpoint.rewind",
shortcut: escapeShortcut,
sequence: [escapeShortcut, escapeShortcut],
whenAst: {
type: "not",
node: { type: "identifier", name: "terminalFocus" },
},
},
);
assert.isNull(
compileResolvedKeybindingRule({
key: "esc esc",
command: "terminal.toggle",
}),
);
}),
);

it.effect("encodes resolved plus-key shortcuts", () =>
Effect.gen(function* () {
const encoded = yield* encodeResolvedKeybindingFromConfig({
Expand All @@ -134,6 +169,27 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
}),
);

it.effect("encodes resolved double-Escape sequence shortcuts", () =>
Effect.gen(function* () {
const escapeShortcut = {
key: "escape",
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
modKey: false,
};
const encoded = yield* Schema.encodeEffect(ResolvedKeybindingFromConfig)({
command: "checkpoint.rewind",
shortcut: escapeShortcut,
sequence: [escapeShortcut, escapeShortcut],
});

assert.equal(encoded.key, "esc esc");
assert.equal(encoded.command, "checkpoint.rewind");
}),
);

it.effect("rejects invalid rules", () =>
Effect.sync(() => {
assert.isNull(
Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,19 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe(

encode: (resolved) =>
Effect.gen(function* () {
const key = encodeShortcut(resolved.shortcut);
const key =
resolved.sequence?.length === 2 &&
resolved.sequence.every(
(shortcut) =>
shortcut.key === "escape" &&
!shortcut.metaKey &&
!shortcut.ctrlKey &&
!shortcut.shiftKey &&
!shortcut.altKey &&
!shortcut.modKey,
)
? "esc esc"
: encodeShortcut(resolved.shortcut);
if (!key) {
return yield* Effect.fail(
new SchemaIssue.InvalidValue(Option.some(resolved), {
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/commandPaletteStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@ interface CommandPaletteOpenIntent {
interface CommandPaletteStore {
open: boolean;
openIntent: CommandPaletteOpenIntent | null;
checkpointRewindRequestId: number;
setOpen: (open: boolean) => void;
toggleOpen: () => void;
openAddProject: () => void;
openCheckpointRewind: () => void;
clearOpenIntent: () => void;
}

export const useCommandPaletteStore = create<CommandPaletteStore>((set) => ({
open: false,
openIntent: null,
checkpointRewindRequestId: 0,
setOpen: (open) => set({ open, ...(open ? {} : { openIntent: null }) }),
toggleOpen: () =>
set((state) => ({ open: !state.open, ...(state.open ? { openIntent: null } : {}) })),
Expand All @@ -28,5 +31,10 @@ export const useCommandPaletteStore = create<CommandPaletteStore>((set) => ({
requestId: (state.openIntent?.requestId ?? 0) + 1,
},
})),
openCheckpointRewind: () =>
set((state) => ({
open: false,
checkpointRewindRequestId: state.checkpointRewindRequestId + 1,
})),
clearOpenIntent: () => set({ openIntent: null }),
}));
179 changes: 179 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "../index.css";
import {
EventId,
ORCHESTRATION_WS_METHODS,
type CheckpointRef,
EnvironmentId,
type EnvironmentApi,
type MessageId,
Expand Down Expand Up @@ -381,6 +382,49 @@ function createSnapshotForTargetUser(options: {
};
}

function createSnapshotWithRewindCheckpoint(
options: {
sessionStatus?: OrchestrationSessionStatus;
} = {},
): OrchestrationReadModel {
const snapshot = createSnapshotForTargetUser({
targetMessageId: "msg-user-rewind-target" as MessageId,
targetText: "add persistent checkpoint rewind menu",
...(options.sessionStatus ? { sessionStatus: options.sessionStatus } : {}),
});
const thread = snapshot.threads[0];
if (!thread) {
throw new Error("Expected default thread.");
}

return {
...snapshot,
threads: [
{
...thread,
checkpoints: [
{
turnId: "turn-rewind-target" as TurnId,
checkpointTurnCount: 4,
checkpointRef: "refs/t3-checkpoints/thread-browser-test/4" as CheckpointRef,
status: "ready",
files: [
{
path: "apps/web/src/components/ChatView.tsx",
kind: "modified",
additions: 12,
deletions: 3,
},
],
assistantMessageId: "msg-assistant-3" as MessageId,
completedAt: isoAt(30),
},
],
},
],
};
}

function buildFixture(snapshot: OrchestrationReadModel): TestFixture {
return {
snapshot,
Expand Down Expand Up @@ -1226,6 +1270,18 @@ async function pressComposerKey(key: string): Promise<void> {
await waitForLayout();
}

async function pressGlobalEscape(): Promise<void> {
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Escape",
code: "Escape",
bubbles: true,
cancelable: true,
}),
);
await waitForLayout();
}

async function pressComposerUndo(): Promise<void> {
const composerEditor = await waitForComposerEditor();
const useMetaForMod = isMacPlatform(navigator.platform);
Expand Down Expand Up @@ -6150,6 +6206,129 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("opens checkpoint rewind from double Escape and dispatches the revert command", async () => {
const escapeShortcut = {
key: "escape",
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
modKey: false,
};
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotWithRewindCheckpoint(),
configureFixture: (nextFixture) => {
nextFixture.serverConfig = {
...nextFixture.serverConfig,
keybindings: [
{
command: "checkpoint.rewind",
shortcut: escapeShortcut,
sequence: [escapeShortcut, escapeShortcut],
whenAst: {
type: "not",
node: { type: "identifier", name: "terminalFocus" },
},
},
],
};
},
});

try {
await waitForServerConfigToApply();
await waitForComposerEditor();
await pressGlobalEscape();
await pressGlobalEscape();

await expect.element(page.getByText("Rewind checkpoint")).toBeVisible();
await expect.element(page.getByText("add persistent checkpoint rewind menu")).toBeVisible();

const restoreButton = await waitForButtonByText("Restore");
await restoreButton.click();

await vi.waitFor(() => {
const request = wsRequests.find(
(entry) =>
entry._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
entry.type === "thread.checkpoint.revert",
);
expect(request).toMatchObject({
threadId: THREAD_ID,
turnCount: 3,
});
});
expect(confirmSpy).not.toHaveBeenCalled();
} finally {
confirmSpy.mockRestore();
await mounted.cleanup();
}
});

it("closes checkpoint rewind when navigating to another thread", async () => {
const escapeShortcut = {
key: "escape",
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
modKey: false,
};
const secondThreadId = "thread-rewind-navigation-target" as ThreadId;
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: addThreadToSnapshot(createSnapshotWithRewindCheckpoint(), secondThreadId),
configureFixture: (nextFixture) => {
nextFixture.serverConfig = {
...nextFixture.serverConfig,
keybindings: [
{
command: "checkpoint.rewind",
shortcut: escapeShortcut,
sequence: [escapeShortcut, escapeShortcut],
whenAst: {
type: "not",
node: { type: "identifier", name: "terminalFocus" },
},
},
],
};
},
});

try {
await waitForServerConfigToApply();
await waitForComposerEditor();
await pressGlobalEscape();
await pressGlobalEscape();

await expect.element(page.getByText("Rewind checkpoint")).toBeVisible();
await expect.element(page.getByText("add persistent checkpoint rewind menu")).toBeVisible();

await mounted.router.navigate({
to: "/$environmentId/$threadId",
params: {
environmentId: LOCAL_ENVIRONMENT_ID,
threadId: secondThreadId,
},
});

await waitForURL(
mounted.router,
(path) => path === serverThreadPath(secondThreadId),
"Route should switch to the second server thread.",
);
await expect.element(page.getByText("Rewind checkpoint")).not.toBeInTheDocument();
await expect
.element(page.getByText("add persistent checkpoint rewind menu"))
.not.toBeInTheDocument();
} finally {
await mounted.cleanup();
}
});

it("shows a tooltip with the skill description when hovering a skill pill", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down
Loading
Loading