diff --git a/app/src/components/notes/SendToAgentButton.test.tsx b/app/src/components/notes/SendToAgentButton.test.tsx index 7197aab..9373727 100644 --- a/app/src/components/notes/SendToAgentButton.test.tsx +++ b/app/src/components/notes/SendToAgentButton.test.tsx @@ -11,14 +11,22 @@ vi.mock("../../hooks/useTerminal", () => ({ })); const setActiveTabKey = vi.fn(); +const requestTerminalFocus = vi.fn(); const pushToast = vi.fn(); let projects: Project[] = []; vi.mock("../../store/appState", () => ({ useAppState: Object.assign( (selector: (s: unknown) => unknown) => - selector({ projects, setActiveTabKey, pushToast }), - { getState: () => ({ projects, setActiveTabKey, pushToast }) }, + selector({ projects, setActiveTabKey, requestTerminalFocus, pushToast }), + { + getState: () => ({ + projects, + setActiveTabKey, + requestTerminalFocus, + pushToast, + }), + }, ), terminalTabKey: (id: string) => `term:${id}`, })); @@ -149,4 +157,35 @@ describe("SendToAgentButton", () => { // overflow, so a downward menu at the bottom edge is invisible. await waitFor(() => expect(screen.getByRole("menu")).toHaveClass("bottom-full")); }); + + // Switching to the tab is not enough. When the dock is open beside the + // terminal it sends to, that terminal is already the active tab, so + // `setActiveTabKey` changes nothing and no effect re-runs — leaving focus on + // this button, one click short of the Enter the user came to press. + it("hands focus to the terminal so the next keystroke is Enter", async () => { + sessions = [session()]; + render(); + fireEvent.click(screen.getByRole("button", { name: /send to agent/i })); + + await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s1")); + }); + + it("leaves focus alone when the send failed", async () => { + sessions = [session()]; + sendInput.mockRejectedValueOnce(new Error("pty gone")); + render(); + fireEvent.click(screen.getByRole("button", { name: /send to agent/i })); + + await waitFor(() => expect(pushToast).toHaveBeenCalled()); + expect(requestTerminalFocus).not.toHaveBeenCalled(); + }); + + it("focuses the session picked from the menu, not the first one", async () => { + sessions = [session(), session({ id: "s2", sessionName: "review" })]; + render(); + fireEvent.click(screen.getByRole("button", { name: /send to agent/i })); + fireEvent.click(await screen.findByRole("menuitem", { name: "review" })); + + await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s2")); + }); }); \ No newline at end of file diff --git a/app/src/components/notes/SendToAgentButton.tsx b/app/src/components/notes/SendToAgentButton.tsx index 38e808e..9e1de82 100644 --- a/app/src/components/notes/SendToAgentButton.tsx +++ b/app/src/components/notes/SendToAgentButton.tsx @@ -36,13 +36,15 @@ export default function SendToAgentButton({ fullWidth = false, }: Props) { const { sessions, sendInput } = useTerminal(); - const { projects, setActiveTabKey, pushToast } = useAppState( - useShallow((s) => ({ - projects: s.projects, - setActiveTabKey: s.setActiveTabKey, - pushToast: s.pushToast, - })), - ); + const { projects, setActiveTabKey, requestTerminalFocus, pushToast } = + useAppState( + useShallow((s) => ({ + projects: s.projects, + setActiveTabKey: s.setActiveTabKey, + requestTerminalFocus: s.requestTerminalFocus, + pushToast: s.pushToast, + })), + ); const [menuOpen, setMenuOpen] = useState(false); const rootRef = useRef(null); @@ -86,6 +88,11 @@ export default function SendToAgentButton({ // A courtesy, not part of the send: if the tab cannot be focused the // text still went. setActiveTabKey(terminalTabKey(sessionId)); + // Switching tabs is not the same as taking focus, and when the dock is + // open beside the terminal it just sent to, that tab is already the + // active one — so nothing above moves the caret off this button. The + // note is sitting in the prompt waiting for Enter; put the user there. + requestTerminalFocus(sessionId); } catch (e) { pushToast({ kind: "error", @@ -94,7 +101,7 @@ export default function SendToAgentButton({ }); } }, - [body, sendInput, setActiveTabKey, pushToast], + [body, sendInput, setActiveTabKey, requestTerminalFocus, pushToast], ); const onClick = useCallback(() => { diff --git a/app/src/components/terminal/TerminalView.test.tsx b/app/src/components/terminal/TerminalView.test.tsx index 42284d0..b6a18bf 100644 --- a/app/src/components/terminal/TerminalView.test.tsx +++ b/app/src/components/terminal/TerminalView.test.tsx @@ -538,3 +538,59 @@ describe("TerminalView — reaching the URL prompt without a mouse", () => { expect(document.activeElement).toBe(before); }); }); + +describe("TerminalView — focus on request", () => { + /** Mount, then deliberately give focus away, so what the assertions below + * observe is the *request* taking effect and never the focus `active` + * already grants on mount. That distinction is the whole point: the notes + * dock sends to a terminal whose tab is already active, where nothing + * changes and no `active` effect re-runs. */ + async function mountAndBlur() { + const view = mountSession("claude"); + await act(async () => {}); + const elsewhere = document.createElement("button"); + document.body.appendChild(elsewhere); + elsewhere.focus(); + expect(document.activeElement).toBe(elsewhere); + return view; + } + + it("focuses the terminal named by the request", async () => { + const view = await mountAndBlur(); + + await act(async () => { + useAppState.getState().requestTerminalFocus("s1"); + }); + + expect(document.activeElement).toBe(helperTextarea(view.container)); + }); + + it("ignores a request meant for another session", async () => { + const view = await mountAndBlur(); + const before = document.activeElement; + + await act(async () => { + useAppState.getState().requestTerminalFocus("s2"); + }); + + expect(document.activeElement).toBe(before); + expect(document.activeElement).not.toBe(helperTextarea(view.container)); + }); + + it("clears the request, so a second send focuses again", async () => { + const view = await mountAndBlur(); + + await act(async () => { + useAppState.getState().requestTerminalFocus("s1"); + }); + expect(useAppState.getState().pendingTerminalFocus).toBeNull(); + + const elsewhere = document.querySelector("button"); + (elsewhere as HTMLButtonElement).focus(); + + await act(async () => { + useAppState.getState().requestTerminalFocus("s1"); + }); + expect(document.activeElement).toBe(helperTextarea(view.container)); + }); +}); \ No newline at end of file diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 1111ec6..d2b2740 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -731,6 +731,21 @@ export default function TerminalView({ sessionId, active }: Props) { } }, [active, gpuRenderingSetting]); + // Focus on demand, for the caller that cannot rely on the effect above. + // That one keys off `active`, so it covers switching *to* a terminal and + // nothing else — and the notes dock sends to the terminal already on screen, + // where `active` never changes. Consumed once and cleared, so asking twice + // for the same terminal works. + const pendingTerminalFocus = useAppState((s) => s.pendingTerminalFocus); + const clearPendingTerminalFocus = useAppState( + (s) => s.clearPendingTerminalFocus, + ); + useEffect(() => { + if (pendingTerminalFocus !== sessionId) return; + termRef.current?.focus(); + clearPendingTerminalFocus(); + }, [pendingTerminalFocus, sessionId, clearPendingTerminalFocus]); + // Auto-dismiss toast after 30 seconds — unless the user is standing in it. // A keyboard user who has just jumped into the toast is mid-decision, and // pulling it out from under them costs them the only route to finishing a diff --git a/app/src/store/appState.test.ts b/app/src/store/appState.test.ts index ddd9027..606680b 100644 --- a/app/src/store/appState.test.ts +++ b/app/src/store/appState.test.ts @@ -112,3 +112,27 @@ describe("toasts", () => { expect(toasts()).toHaveLength(2); }); }); + +describe("terminal focus requests", () => { + beforeEach(() => useAppState.setState({ pendingTerminalFocus: null })); + + const pending = () => useAppState.getState().pendingTerminalFocus; + + it("names the session that should take focus", () => { + useAppState.getState().requestTerminalFocus("s1"); + expect(pending()).toBe("s1"); + }); + + // Consumed once, exactly like `pendingHomeTab`. Without the clear, the + // second send to a terminal already holding the request would set the same + // value, no state would change, and no effect would re-run — which is the + // failure this whole mechanism exists to fix. + it("is cleared once consumed, so the same terminal can be asked again", () => { + useAppState.getState().requestTerminalFocus("s1"); + useAppState.getState().clearPendingTerminalFocus(); + expect(pending()).toBeNull(); + + useAppState.getState().requestTerminalFocus("s1"); + expect(pending()).toBe("s1"); + }); +}); diff --git a/app/src/store/appState.ts b/app/src/store/appState.ts index d3771da..d196b07 100644 --- a/app/src/store/appState.ts +++ b/app/src/store/appState.ts @@ -144,6 +144,21 @@ interface AppState { /** Consumed once by `ProjectHome`, then cleared. */ pendingHomeTab: { projectId: string; tab: string } | null; clearPendingHomeTab: () => void; + /** + * Ask a terminal to take keyboard focus. + * + * `TerminalView` already focuses when its tab *becomes* active, which covers + * switching to a terminal. It cannot cover being asked to focus the terminal + * that is already on screen — nothing changes, so no effect re-runs — and + * that is the ordinary case for the notes dock, which sits beside the + * terminal it sends to. + * + * Consumed once and cleared, like `pendingHomeTab`: holding the id would + * make a second request for the same terminal a no-op state write. + */ + pendingTerminalFocus: string | null; + requestTerminalFocus: (sessionId: string) => void; + clearPendingTerminalFocus: () => void; closeHomeTab: (projectId: string) => void; setActiveTabKey: (key: string) => void; cycleTab: (delta: number) => void; @@ -352,6 +367,9 @@ export const useAppState = create((set) => ({ }), pendingHomeTab: null, clearPendingHomeTab: () => set({ pendingHomeTab: null }), + pendingTerminalFocus: null, + requestTerminalFocus: (sessionId) => set({ pendingTerminalFocus: sessionId }), + clearPendingTerminalFocus: () => set({ pendingTerminalFocus: null }), closeHomeTab: (projectId) => set((state) => { const key = homeTabKey(projectId);