authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-03-06 14:55:24-08:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-06 14:55:24-08:00
logf403b23e9af62e31b05c3444c47fa8762fd82c46
treea0f1e5dcb0dd192a206788ace592b965a484c9ad
parent39c71a0394ac5fead439ac0cec4a89ef1f6b9389
signature Signed by PGP key B5690EEEBB952194

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

2 files changed, 91 insertions(+), 5 deletions(-)

apps/web/src/components/ChatView.browser.tsx+82
......@@ -392,6 +392,23 @@ async function waitForElement<T extends Element>(
392392 return element;
393393}
394394
395async function waitForComposerEditor(): Promise<HTMLElement> {
396 return waitForElement(
397 () => document.querySelector<HTMLElement>('[contenteditable="true"]'),
398 "Unable to find composer editor.",
399 );
400}
401
402async 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
395412async 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+9-5
......@@ -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"