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
This commit is contained in:
Marius
2026-08-03 10:34:47 -04:00
committed by GitHub
parent 68338122b2
commit 4810766d05
8 changed files with 138 additions and 21 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Preserve prompt or Agent Manager terminal focus independently for each session when switching sessions.
@@ -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<string, unknown>),
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()
})
})
@@ -380,6 +380,74 @@ const AgentManagerContent: Component = () => {
return sel === null ? null : nsKey(sel)
})
type FocusOwner = "prompt" | { terminal: string }
const focusMemory = new Map<string, FocusOwner>()
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}
/>
<Show when={readOnly()}>
<div class="am-readonly-banner">
@@ -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> = (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
@@ -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)}
@@ -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<string | null>): 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") {
@@ -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<ChatViewProps> = (props) => {
questioning={questioning}
boxId={props.promptBoxId}
pendingSessionID={pendingSessionID()}
focusOnDraftChange={props.focusOnDraftChange}
onFocusChange={props.onFocusChange}
/>
</Show>
</div>
@@ -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<PromptInputProps> = (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<PromptInputProps> = (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)