mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-29 18:52:45 +08:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user