From 4810766d058f53084b8a6cb0fe4d1ff42973e89b Mon Sep 17 00:00:00 2001 From: Marius Date: Mon, 3 Aug 2026 16:34:47 +0200 Subject: [PATCH] fix(vscode): persist Agent Manager focus per session (#12801) * fix(vscode): persist Agent Manager focus per session Remember whether each session last focused the prompt or its right-side embedded terminal, and restore that focus owner when switching back. - Add a focus-owner map keyed by (context, session) in AgentManagerApp that records the last focused surface per session. - Make side-terminal creation explicit: / focus the new terminal, while and context switches do not. - Suppress the PromptInput draft-change focus dispatch when the target session's remembered owner is the terminal. - Side terminals opt out of auto-focus on activation via a new prop so context switches don't steal the cursor. - Replace the unconditional prompt dispatch with a restore-focus path that follows the saved owner. - Add regression tests distinguishing explicit vs background side creates, and a changeset. * fix(vscode): address Agent Manager focus review * fix(vscode): self-heal stale Agent Manager focus * fix(vscode): align Agent Manager focus visibility --- .changeset/quiet-terminals-focus.md | 5 ++ .../unit/agent-manager-terminal-state.test.ts | 20 ++++- .../agent-manager/AgentManagerApp.tsx | 83 ++++++++++++++++++- .../agent-manager/terminal/TerminalTab.tsx | 6 +- .../agent-manager/terminal/render.tsx | 1 + .../agent-manager/terminal/state.ts | 24 +++--- .../src/components/chat/ChatView.tsx | 4 + .../src/components/chat/PromptInput.tsx | 16 +++- 8 files changed, 138 insertions(+), 21 deletions(-) create mode 100644 .changeset/quiet-terminals-focus.md diff --git a/.changeset/quiet-terminals-focus.md b/.changeset/quiet-terminals-focus.md new file mode 100644 index 00000000000..22715b263ae --- /dev/null +++ b/.changeset/quiet-terminals-focus.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve prompt or Agent Manager terminal focus independently for each session when switching sessions. diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts index 0cb69375c38..92b0efd7c1f 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-state.test.ts @@ -22,6 +22,7 @@ function scene(initial: string | null = LOCAL) { shown: [] as string[], errors: 0, running: [] as Array<{ contextKey: string; terminalId: string }>, + sideFocus: [] as boolean[], } const tabs = () => state.current().map((term) => term.id) const handlers = createTerminalHandlers({ @@ -49,17 +50,18 @@ function scene(initial: string | null = LOCAL) { }, showError: () => events.errors++, postMessage: (message) => posted.push(message as Record), + onSideCreated: (_contextKey, _terminalId, focus) => events.sideFocus.push(focus), onScriptRunning: (contextKey, terminalId) => events.running.push({ contextKey, terminalId }), }) return { state, selection, setSelection, posted, events, handlers, dispatch } } -function createdSide(createId: string, terminalId: string, title = "Terminal 1") { +function createdSide(createId: string, terminalId: string, title = "Terminal 1", worktreeId: string | null = null) { return { type: "agentManager.terminal.created", createId, placement: "side", - worktreeId: null, + worktreeId, terminalId, title, wsUrl: `ws://${terminalId}`, @@ -344,6 +346,20 @@ describe("Agent Manager terminal state", () => { placement: "side", worktreeId: "wt-1", }) + const createId = String(item.posted[0]!.createId) + expect(item.dispatch(createdSide(createId, "terminal:side", "Terminal 1", "wt-1"))).toBe(true) + expect(item.events.sideFocus).toEqual([false]) + dispose() + }) + }) + + it("focuses a side terminal only when the user explicitly opens it", () => { + createRoot((dispose) => { + const item = scene() + item.handlers.addSide() + const createId = String(item.posted[0]!.createId) + expect(item.dispatch(createdSide(createId, "terminal:side"))).toBe(true) + expect(item.events.sideFocus).toEqual([true]) dispose() }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 5b5849734a5..2ea4b0ddaa3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -380,6 +380,74 @@ const AgentManagerContent: Component = () => { return sel === null ? null : nsKey(sel) }) + type FocusOwner = "prompt" | { terminal: string } + const focusMemory = new Map() + const focusKey = () => { + const context = terms.sideKey() + const sessionID = session.currentSessionID() ?? activePendingId() ?? "new" + return `${context}:${sessionID}` + } + const forgetSessionFocus = (sessionID: string) => { + for (const key of focusMemory.keys()) if (key.endsWith(`:${sessionID}`)) focusMemory.delete(key) + } + const forgetContextFocus = (context: string) => { + for (const key of focusMemory.keys()) if (key.startsWith(`${context}:`)) focusMemory.delete(key) + } + const forgetTerminalFocus = (terminalID: string) => { + for (const [key, owner] of focusMemory) { + if (owner !== "prompt" && owner.terminal === terminalID) focusMemory.delete(key) + } + } + const rememberPromptFocus = (focused: boolean) => { + if (focused) focusMemory.set(focusKey(), "prompt") + } + const terminalVisible = () => sidePanel() === "terminal" && !history() && !reviewActive() + const focusOnDraftChange = () => { + const key = focusKey() + const owner = focusMemory.get(key) + if (!owner || owner === "prompt") return true + if (!terms.sidesForContext(terms.sideKey()).some((term) => term.id === owner.terminal)) { + focusMemory.delete(key) + return true + } + return terminalVisible() ? false : true + } + const restoreFocus = () => { + const key = focusKey() + const owner = focusMemory.get(key) + if (owner && owner !== "prompt") { + const context = terms.sideKey() + const terminal = terms.sidesForContext(context).find((term) => term.id === owner.terminal) + if (terminal && terminalVisible()) { + terms.setSideActive(context, terminal.id) + terms.requestFocus(terminal.id) + return + } + if (!terminal) focusMemory.delete(key) + } + window.dispatchEvent(new Event("focusPrompt")) + } + createEffect( + on( + () => terms.focusedId(), + (id) => { + if (!id) return + const key = terms.contextFor(id) + if (!key || !terms.sidesForContext(key).some((term) => term.id === id)) return + focusMemory.set(focusKey(), { terminal: id }) + }, + { defer: true }, + ), + ) + createEffect( + on( + focusKey, + (_key, previous) => { + if (previous !== undefined) queueMicrotask(restoreFocus) + }, + { defer: true }, + ), + ) // Ambient setup reveal restores the panel after success unless the user engaged. const ambientSetup = createAmbientSetup({ terms, @@ -1192,7 +1260,7 @@ const AgentManagerContent: Component = () => { const onWindowFocus = () => { document.body.style.pointerEvents = "" document.body.style.overflow = "" - window.dispatchEvent(new Event("focusPrompt")) + restoreFocus() } window.addEventListener("focus", onWindowFocus) @@ -1238,7 +1306,9 @@ const AgentManagerContent: Component = () => { // Mark sessions loaded as soon as the session context receives data (even if empty) const unsubSessions = vscode.onMessage((msg) => { if (msg.type === "sessionsLoaded" && !sessionsLoaded()) setSessionsLoaded(true) - if (msg.type === "agentManager.sessionClosed") handleCloseTab(msg.sessionId, false) + if (msg.type === "agentManager.sessionClosed") { + handleCloseTab(msg.sessionId, false) + } }) const unsubRun = vscode.onMessage((msg) => applyRunStatus(msg, { ensure: (id) => registry.ensure(id), active: () => registry.active() }), @@ -1255,13 +1325,14 @@ const AgentManagerContent: Component = () => { showToast({ variant: "error", title: t("agentManager.terminal.errorTitle"), description: message }), postMessage: (message) => vscode.postMessage(message as never), onCreated: (contextKey, terminalId) => appendToTabOrder(contextKey, terminalId), - onSideCreated: (contextKey, terminalId) => { + onSideCreated: (contextKey, terminalId, focus) => { // Focus only when the user is still looking at this panel — // a slow create landing after a mode switch must not steal it. - if (sidePanel() === "terminal" && !history() && !reviewActive() && terms.sideKey() === contextKey) { + if (focus && sidePanel() === "terminal" && !history() && !reviewActive() && terms.sideKey() === contextKey) { terms.requestFocus(terminalId) } }, + onSideClosed: (_contextKey, terminalId) => forgetTerminalFocus(terminalId), onScriptRunning: (contextKey, terminalId) => { if (terms.sideKey() !== contextKey) return // Setup output is informational: reveal without stealing focus, and @@ -1806,6 +1877,7 @@ const AgentManagerContent: Component = () => { // Second press/click: execute the delete if (pendingDelete() === worktreeId) { cancelPendingDelete() + forgetContextFocus(nsKey(worktreeId)) setBusyWorktrees((prev) => new Map([...prev, [wt.id, { reason: "deleting" as const }]])) vscode.postMessage({ type: "agentManager.deleteWorktree", worktreeId: wt.id }) if (selection() === wt.id) { @@ -1945,6 +2017,7 @@ const AgentManagerContent: Component = () => { session.clearCurrentSession() } } + forgetSessionFocus(sessionId) if (pending || localSet().has(sessionId)) { setLocalSessionIDs((prev) => prev.filter((id) => id !== sessionId)) } @@ -2507,6 +2580,8 @@ const AgentManagerContent: Component = () => { continueInWorktree={selection() === LOCAL} promptBoxId={`agent-manager:${selection() ?? "unassigned"}`} pendingSessionID={selection() === LOCAL ? activePendingId() : undefined} + focusOnDraftChange={focusOnDraftChange} + onFocusChange={rememberPromptFocus} />
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx index a991c5e256a..8ae231cacb4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx @@ -38,6 +38,9 @@ interface Props { * an xterm re-paint when the slot transitions back to visible after * sitting behind an occluding layer. */ active: boolean + /** Side terminals only repaint on activation; focus is restored explicitly + * when that context's remembered focus owner is the terminal. */ + focusOnActivate?: boolean /** Serial of the latest explicit focus request for this terminal * (`state.focusRequest()`), consumed so re-requesting focus on an * already-visible terminal still re-focuses it. */ @@ -479,7 +482,8 @@ export const TerminalTab: Component = (props) => { createEffect(() => { const now = props.active const serial = props.focusSerial ?? 0 - if (now && (!wasActive || serial !== focusSerial)) scheduleRepaint(true) + if (now && (!wasActive || serial !== focusSerial)) + scheduleRepaint((serial > 0 && serial !== focusSerial) || props.focusOnActivate !== false) if (!now && wasActive) term.blur() wasActive = now focusSerial = serial diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx index b6fc189ca40..fa83bd7e939 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx @@ -153,6 +153,7 @@ export function renderSideTerminalLayer(props: { terminalId={term.id} wsUrl={term.wsUrl} active={active()} + focusOnActivate={false} focusSerial={focusSerial(props.state, term.id)} font={term.font} status={() => props.state.scriptStatus(term.id)} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts index caa4e6e53b7..c14f02c1153 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/state.ts @@ -64,6 +64,7 @@ export interface TerminalFocusRequest { * Multiple creates can be in flight for the same context at once. */ interface SideRequest { contextKey: string + focus: boolean } export interface TerminalStateControls { @@ -144,7 +145,7 @@ export interface TerminalStateControls { /** Request ids of the in-flight side-terminal creates for a context. */ pendingSide(contextKey: string): boolean /** Mark a side-terminal create as in flight for a context. */ - beginSide(contextKey: string, createId: string): void + beginSide(contextKey: string, createId: string, focus?: boolean): void /** Settle a create request; returns it so the caller can validate. */ completeSide(createId: string): SideRequest | undefined } @@ -527,8 +528,8 @@ export function createTerminalState(selection: Accessor): Termina const pendingSide = (key: string) => (pending()[key]?.length ?? 0) > 0 - const beginSide = (key: string, createId: string) => { - requests.set(createId, { contextKey: key }) + const beginSide = (key: string, createId: string, focus = false) => { + requests.set(createId, { contextKey: key, focus }) setPending((prev) => ({ ...prev, [key]: [...(prev[key] ?? []), createId] })) } @@ -637,13 +638,13 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { }) } - const createSide = () => { + const createSide = (focus = false) => { const key = deps.state.sideKey() // The wire protocol speaks plain worktree ids; `sideKey` is the // project-namespaced state key and must not leak into the message. const sel = deps.getSelection() const id = newId() - deps.state.beginSide(key, id) + deps.state.beginSide(key, id, focus) deps.postMessage({ type: "agentManager.terminal.create", createId: id, @@ -659,7 +660,7 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { */ const addSide = () => { deps.onShowSide(deps.state.sideKey()) - createSide() + createSide(true) } /** Ensure the current context has a terminal without changing panel mode. */ @@ -684,7 +685,8 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) { deps.state.requestFocus(active) return } - ensureSide() + if (deps.state.pendingSide(key)) return + createSide(true) } const closeTerminal = (terminalId: string) => { @@ -826,11 +828,11 @@ export interface TerminalMessageHandlerDeps { */ onCreated?: (contextKey: string, terminalId: string) => void /** Side terminal for a context finished creating. */ - onSideCreated?: (contextKey: string, terminalId: string) => void + onSideCreated?: (contextKey: string, terminalId: string, focus: boolean) => void /** Side terminal create failed for a context. */ onSideError?: (contextKey: string) => void /** Side terminal was closed (locally or by the extension). */ - onSideClosed?: (contextKey: string) => void + onSideClosed?: (contextKey: string, terminalId: string) => void /** A newly hydrated running script terminal belongs to the selected context. */ onScriptRunning?: (contextKey: string, terminalId: string) => void /** The destination setting changed (live settings sync). */ @@ -865,7 +867,7 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) { deps.state.add(key === LOCAL ? null : key, term) // The newest terminal becomes the visible one in its panel. deps.state.setSideActive(key, msg.terminalId) - deps.onSideCreated?.(key, msg.terminalId) + deps.onSideCreated?.(key, msg.terminalId, request.focus) return } deps.state.add(key === LOCAL ? null : key, term) @@ -901,7 +903,7 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) { if (msg.type === "agentManager.terminal.closed") { const removed = deps.state.remove(msg.terminalId) if (deps.state.activeId() === msg.terminalId) deps.state.setActiveId(undefined) - if (removed?.placement === "side") deps.onSideClosed?.(removed.contextKey) + if (removed?.placement === "side") deps.onSideClosed?.(removed.contextKey, msg.terminalId) return true } if (msg.type === "agentManager.terminal.error") { diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 6e9ca82692e..f0d82488ca0 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -40,6 +40,8 @@ interface ChatViewProps { continueInWorktree?: boolean promptBoxId?: string pendingSessionID?: string + focusOnDraftChange?: () => boolean + onFocusChange?: (focused: boolean) => void emptyState?: () => JSX.Element } @@ -385,6 +387,8 @@ export const ChatView: Component = (props) => { questioning={questioning} boxId={props.promptBoxId} pendingSessionID={pendingSessionID()} + focusOnDraftChange={props.focusOnDraftChange} + onFocusChange={props.onFocusChange} />
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index d63b0d6a219..86a684b7404 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -110,6 +110,10 @@ interface PromptInputProps { questioning?: () => boolean boxId?: string pendingSessionID?: string + /** Agent Manager can suppress automatic prompt focus when this session last + * used its side terminal instead. Other callers retain the old behavior. */ + focusOnDraftChange?: () => boolean + onFocusChange?: (focused: boolean) => void } function MentionItemContent(props: { item: MentionResult }) { @@ -381,7 +385,7 @@ export const PromptInput: Component = (props) => { textareaRef.scrollTop = scroll if (highlightRef) highlightRef.scrollTop = scroll } - window.dispatchEvent(new Event("focusPrompt")) + if (props.focusOnDraftChange?.() ?? true) window.dispatchEvent(new Event("focusPrompt")) }), ) @@ -1406,8 +1410,14 @@ export const PromptInput: Component = (props) => { onKeyUp={syncGhost} onPaste={handlePaste} onClick={syncGhost} - onFocus={syncGhost} - onBlur={syncGhost} + onFocus={() => { + syncGhost() + props.onFocusChange?.(true) + }} + onBlur={() => { + syncGhost() + props.onFocusChange?.(false) + }} onSelect={() => { syncGhost() if (textareaRef) mention.snapSelection(textareaRef)