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;