Skip to content
Open
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