Skip to content

Commit f403b23

Browse files
authored
Add Shift+Tab shortcut to toggle composer plan mode (#186)
- add Shift+Tab keyboard handling in composer to switch chat/plan modes - refactor mode toggle into shared callback used by hotkey and button - add browser test to verify toggling only works when composer is focused
1 parent 39c71a0 commit f403b23

2 files changed

Lines changed: 91 additions & 5 deletions

File tree

apps/web/src/components/ChatView.browser.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,23 @@ async function waitForElement<T extends Element>(
392392
return element;
393393
}
394394

395+
async function waitForComposerEditor(): Promise<HTMLElement> {
396+
return waitForElement(
397+
() => document.querySelector<HTMLElement>('[contenteditable="true"]'),
398+
"Unable to find composer editor.",
399+
);
400+
}
401+
402+
async function waitForInteractionModeButton(expectedLabel: "Chat" | "Plan"): Promise<HTMLButtonElement> {
403+
return waitForElement(
404+
() =>
405+
Array.from(document.querySelectorAll("button")).find(
406+
(button) => button.textContent?.trim() === expectedLabel,
407+
) as HTMLButtonElement | null,
408+
`Unable to find ${expectedLabel} interaction mode button.`,
409+
);
410+
}
411+
395412
async function waitForImagesToLoad(scope: ParentNode): Promise<void> {
396413
const images = Array.from(scope.querySelectorAll("img"));
397414
if (images.length === 0) {
@@ -785,4 +802,69 @@ describe("ChatView timeline estimator parity (full app)", () => {
785802
await mounted.cleanup();
786803
}
787804
});
805+
806+
it("toggles plan mode with Shift+Tab only while the composer is focused", async () => {
807+
const mounted = await mountChatView({
808+
viewport: DEFAULT_VIEWPORT,
809+
snapshot: createSnapshotForTargetUser({
810+
targetMessageId: "msg-user-target-hotkey" as MessageId,
811+
targetText: "hotkey target",
812+
}),
813+
});
814+
815+
try {
816+
const initialModeButton = await waitForInteractionModeButton("Chat");
817+
expect(initialModeButton.title).toContain("enter plan mode");
818+
819+
window.dispatchEvent(
820+
new KeyboardEvent("keydown", {
821+
key: "Tab",
822+
shiftKey: true,
823+
bubbles: true,
824+
cancelable: true,
825+
}),
826+
);
827+
await waitForLayout();
828+
829+
expect((await waitForInteractionModeButton("Chat")).title).toContain("enter plan mode");
830+
831+
const composerEditor = await waitForComposerEditor();
832+
composerEditor.focus();
833+
composerEditor.dispatchEvent(
834+
new KeyboardEvent("keydown", {
835+
key: "Tab",
836+
shiftKey: true,
837+
bubbles: true,
838+
cancelable: true,
839+
}),
840+
);
841+
842+
await vi.waitFor(
843+
async () => {
844+
expect((await waitForInteractionModeButton("Plan")).title).toContain(
845+
"return to normal chat mode",
846+
);
847+
},
848+
{ timeout: 8_000, interval: 16 },
849+
);
850+
851+
composerEditor.dispatchEvent(
852+
new KeyboardEvent("keydown", {
853+
key: "Tab",
854+
shiftKey: true,
855+
bubbles: true,
856+
cancelable: true,
857+
}),
858+
);
859+
860+
await vi.waitFor(
861+
async () => {
862+
expect((await waitForInteractionModeButton("Chat")).title).toContain("enter plan mode");
863+
},
864+
{ timeout: 8_000, interval: 16 },
865+
);
866+
} finally {
867+
await mounted.cleanup();
868+
}
869+
});
788870
});

apps/web/src/components/ChatView.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1618,6 +1618,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
16181618
threadId,
16191619
],
16201620
);
1621+
const toggleInteractionMode = useCallback(() => {
1622+
handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
1623+
}, [handleInteractionModeChange, interactionMode]);
16211624

16221625
const persistThreadSettingsForNextTurn = useCallback(
16231626
async (input: {
@@ -3215,6 +3218,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
32153218
key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
32163219
event: KeyboardEvent,
32173220
) => {
3221+
if (key === "Tab" && event.shiftKey) {
3222+
toggleInteractionMode();
3223+
return true;
3224+
}
3225+
32183226
const { trigger } = resolveActiveComposerTrigger();
32193227
const menuIsActive = composerMenuOpenRef.current || trigger !== null;
32203228

@@ -3579,11 +3587,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
35793587
className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
35803588
size="sm"
35813589
type="button"
3582-
onClick={() =>
3583-
void handleInteractionModeChange(
3584-
interactionMode === "plan" ? "default" : "plan",
3585-
)
3586-
}
3590+
onClick={toggleInteractionMode}
35873591
title={
35883592
interactionMode === "plan"
35893593
? "Plan mode — click to return to normal chat mode"

0 commit comments

Comments
 (0)