From 77cdfbcb06059d416378451b89fea11294e0598d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 24 Mar 2026 14:02:02 +0700 Subject: [PATCH] fix(app): sync keyboard pane focus with active panel (#141) * fix(app): move keyboard focus to target pane on keyboard navigation (#137) Add a pane focus registry so each panel type can expose its own focus callback. When keyboard shortcuts navigate between panes, the workspace screen calls the target panel's registered focus function, moving DOM focus to the correct input element. - Agent panels register a callback that focuses the message input - Terminal panels register a callback that triggers terminal focus - Panel-internal focus logic stays inside each panel type - No DOM selectors or panel knowledge in shared infrastructure * fix(app): simplify workspace pane focus handoff --- packages/app/src/panels/draft-panel.tsx | 12 ++- .../workspace/workspace-draft-agent-tab.tsx | 5 +- .../workspace-draft-pane-focus.test.ts | 31 ++++++ .../workspace/workspace-draft-pane-focus.ts | 6 ++ packages/app/src/utils/web-focus.test.ts | 99 +++++++++++++++++++ packages/app/src/utils/web-focus.ts | 4 +- 6 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 packages/app/src/screens/workspace/workspace-draft-pane-focus.test.ts create mode 100644 packages/app/src/screens/workspace/workspace-draft-pane-focus.ts create mode 100644 packages/app/src/utils/web-focus.test.ts diff --git a/packages/app/src/panels/draft-panel.tsx b/packages/app/src/panels/draft-panel.tsx index e96ea9ef67..5974097f66 100644 --- a/packages/app/src/panels/draft-panel.tsx +++ b/packages/app/src/panels/draft-panel.tsx @@ -17,8 +17,15 @@ function useDraftPanelDescriptor() { } function DraftPanel() { - const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } = - usePaneContext(); + const { + serverId, + workspaceId, + tabId, + target, + isPaneFocused, + openFileInWorkspace, + retargetCurrentTab, + } = usePaneContext(); invariant(target.kind === "draft", "DraftPanel requires draft target"); return ( @@ -27,6 +34,7 @@ function DraftPanel() { workspaceId={workspaceId} tabId={tabId} draftId={target.draftId} + isPaneFocused={isPaneFocused} onOpenWorkspaceFile={({ filePath }) => { openFileInWorkspace(filePath); }} diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index d5e40cb23d..4694c0c1c8 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -12,6 +12,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host- import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { Agent } from "@/stores/session-store"; import { encodeImages } from "@/utils/encode-images"; +import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import type { AgentCapabilityFlags, AgentSessionConfig, @@ -33,6 +34,7 @@ type WorkspaceDraftAgentTabProps = { workspaceId: string; tabId: string; draftId: string; + isPaneFocused: boolean; onCreated: (snapshot: AgentSnapshotPayload) => void; onOpenWorkspaceFile: (input: { filePath: string }) => void; }; @@ -42,6 +44,7 @@ export function WorkspaceDraftAgentTab({ workspaceId, tabId, draftId, + isPaneFocused, onCreated, onOpenWorkspaceFile, }: WorkspaceDraftAgentTabProps) { @@ -251,7 +254,7 @@ export function WorkspaceDraftAgentTab({ images={draftInput.images} onChangeImages={draftInput.setImages} clearDraft={draftInput.clear} - autoFocus={!isSubmitting} + autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })} onAddImages={handleAddImagesCallback} commandDraftConfig={draftCommandConfig} statusControls={{ diff --git a/packages/app/src/screens/workspace/workspace-draft-pane-focus.test.ts b/packages/app/src/screens/workspace/workspace-draft-pane-focus.test.ts new file mode 100644 index 0000000000..a486dcdba5 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-draft-pane-focus.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { shouldAutoFocusWorkspaceDraftComposer } from "./workspace-draft-pane-focus"; + +describe("shouldAutoFocusWorkspaceDraftComposer", () => { + it("focuses the draft composer when the pane is focused and idle", () => { + expect( + shouldAutoFocusWorkspaceDraftComposer({ + isPaneFocused: true, + isSubmitting: false, + }), + ).toBe(true); + }); + + it("does not focus the draft composer when the pane is unfocused", () => { + expect( + shouldAutoFocusWorkspaceDraftComposer({ + isPaneFocused: false, + isSubmitting: false, + }), + ).toBe(false); + }); + + it("does not focus the draft composer while the draft is submitting", () => { + expect( + shouldAutoFocusWorkspaceDraftComposer({ + isPaneFocused: true, + isSubmitting: true, + }), + ).toBe(false); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-draft-pane-focus.ts b/packages/app/src/screens/workspace/workspace-draft-pane-focus.ts new file mode 100644 index 0000000000..f9c14f0376 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-draft-pane-focus.ts @@ -0,0 +1,6 @@ +export function shouldAutoFocusWorkspaceDraftComposer(input: { + isPaneFocused: boolean; + isSubmitting: boolean; +}): boolean { + return input.isPaneFocused && !input.isSubmitting; +} diff --git a/packages/app/src/utils/web-focus.test.ts b/packages/app/src/utils/web-focus.test.ts new file mode 100644 index 0000000000..f513deaa18 --- /dev/null +++ b/packages/app/src/utils/web-focus.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { focusWithRetries } from "./web-focus"; + +describe("focusWithRetries", () => { + let frameQueue: FrameRequestCallback[] = []; + let originalRequestAnimationFrame: typeof requestAnimationFrame | undefined; + + beforeEach(() => { + frameQueue = []; + originalRequestAnimationFrame = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + frameQueue.push(callback); + return frameQueue.length; + }) as typeof requestAnimationFrame; + }); + + afterEach(() => { + if (originalRequestAnimationFrame) { + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + return; + } + + delete (globalThis as { requestAnimationFrame?: typeof requestAnimationFrame }) + .requestAnimationFrame; + }); + + function flushAnimationFrames(count: number): void { + for (let index = 0; index < count; index += 1) { + const callbacks = frameQueue; + frameQueue = []; + for (const callback of callbacks) { + callback(index); + } + } + } + + it("tries to focus immediately before waiting for animation frames", () => { + let focused = false; + const focus = vi.fn(() => { + focused = true; + }); + const onSuccess = vi.fn(); + + focusWithRetries({ + focus, + isFocused: () => focused, + onSuccess, + }); + + expect(focus).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(frameQueue).toHaveLength(0); + }); + + it("keeps retrying on later animation frames until focus succeeds", () => { + let focused = false; + let attempts = 0; + const focus = vi.fn(() => { + attempts += 1; + if (attempts >= 3) { + focused = true; + } + }); + const onSuccess = vi.fn(); + + focusWithRetries({ + focus, + isFocused: () => focused, + onSuccess, + }); + + expect(focus).toHaveBeenCalledTimes(1); + expect(onSuccess).not.toHaveBeenCalled(); + + flushAnimationFrames(2); + expect(focus).toHaveBeenCalledTimes(2); + expect(onSuccess).not.toHaveBeenCalled(); + + flushAnimationFrames(2); + expect(focus).toHaveBeenCalledTimes(3); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it("stops retrying after cancellation", () => { + const focus = vi.fn(); + + const cancel = focusWithRetries({ + focus, + isFocused: () => false, + }); + + expect(focus).toHaveBeenCalledTimes(1); + + cancel(); + flushAnimationFrames(4); + + expect(focus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/utils/web-focus.ts b/packages/app/src/utils/web-focus.ts index 8959619f2e..6b940afec8 100644 --- a/packages/app/src/utils/web-focus.ts +++ b/packages/app/src/utils/web-focus.ts @@ -40,9 +40,7 @@ export function focusWithRetries({ }); }; - requestAnimationFrame(() => { - requestAnimationFrame(tick); - }); + tick(); return () => { cancelled = true;