From be8b85c8ce5222edd94d9fa5b8cbe11136e06d5a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 09:45:41 +0200 Subject: [PATCH] perf(agent-manager): optimize tab switching and context transition latency --- .../optimize-agent-manager-tab-latency.md | 5 + .../agent-manager/AgentManagerApp.tsx | 147 +++++++++--------- .../agent-manager/agent-manager.css | 4 + .../agent-manager/selection-actions.ts | 57 ++++--- .../src/components/chat/MessageList.tsx | 33 ++-- .../src/components/chat/PromptInput.tsx | 26 ++-- .../webview-ui/src/context/session.tsx | 37 +++-- packages/ui/src/components/tooltip.tsx | 44 +++--- 8 files changed, 190 insertions(+), 163 deletions(-) create mode 100644 .changeset/optimize-agent-manager-tab-latency.md diff --git a/.changeset/optimize-agent-manager-tab-latency.md b/.changeset/optimize-agent-manager-tab-latency.md new file mode 100644 index 0000000000..b82b17ab26 --- /dev/null +++ b/.changeset/optimize-agent-manager-tab-latency.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Optimize Agent Manager tab switching and context transition latency diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 8b2742313e..29f979dd05 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1,6 +1,7 @@ /** @jsxImportSource solid-js */ import { + batch, For, Show, createSignal, @@ -1990,14 +1991,16 @@ const AgentManagerContent: Component = () => { } const selectSessionTab = (id: string, pending: boolean) => { - setReviewActive(false) - if (pending) { - setActivePendingId(id) - session.clearCurrentSession() - } else { - setActivePendingId(undefined) - session.selectSession(id) - } + batch(() => { + setReviewActive(false) + if (pending) { + setActivePendingId(id) + session.clearCurrentSession() + } else { + setActivePendingId(undefined) + session.selectSession(id) + } + }) } const termHandlers = createTerminalHandlers({ state: terms, @@ -2512,75 +2515,73 @@ const AgentManagerContent: Component = () => { - -
- { - if (addSessionToCurrentWorktree(id)) return - if (localSessionIDs().includes(id)) { +
+ { + if (addSessionToCurrentWorktree(id)) return + if (localSessionIDs().includes(id)) { + session.selectSession(id) + if (selection() === null) setSelection(LOCAL) + requestChatFocus() + return + } + // Navigate to owning worktree instead of forcing into local mode + if (worktreeSessionIds().has(id)) { + const ms = managedSessions().find((s) => s.id === id) + if (ms?.worktreeId) { + selectWorktree(ms.worktreeId) session.selectSession(id) - if (selection() === null) setSelection(LOCAL) + setReviewActive(false) requestChatFocus() return } - // Navigate to owning worktree instead of forcing into local mode - if (worktreeSessionIds().has(id)) { - const ms = managedSessions().find((s) => s.id === id) - if (ms?.worktreeId) { - selectWorktree(ms.worktreeId) - session.selectSession(id) - setReviewActive(false) - requestChatFocus() - return - } - } - openLocally(id) - }} - onShowHistory={() => setHistory(true)} - onForkMessage={readOnly() ? undefined : handleForkSession} - onForkSession={readOnly() ? undefined : handleForkSession} - readonly={readOnly()} - continueInWorktree={selection() === LOCAL} - promptBoxId={`agent-manager:${selection() ?? "unassigned"}`} - deferFocusToQuestion={hasQuestionOption} - pendingSessionID={selection() === LOCAL ? activePendingId() : undefined} - focusOnDraftChange={focusOnDraftChange} - onFocusChange={rememberPromptFocus} - /> - -
- - {t("agentManager.session.readonly")} - - -
-
-
- + } + openLocally(id) + }} + onShowHistory={() => setHistory(true)} + onForkMessage={readOnly() ? undefined : handleForkSession} + onForkSession={readOnly() ? undefined : handleForkSession} + readonly={readOnly()} + continueInWorktree={selection() === LOCAL} + promptBoxId={`agent-manager:${selection() ?? "unassigned"}`} + deferFocusToQuestion={hasQuestionOption} + pendingSessionID={selection() === LOCAL ? activePendingId() : undefined} + focusOnDraftChange={focusOnDraftChange} + onFocusChange={rememberPromptFocus} + /> + +
+ + {t("agentManager.session.readonly")} + + +
+
+
{/* One inspector host for all right-side modes. It stays mounted while a side terminal is alive — hidden via diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 5017d37a66..ed8fd35d2e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -1771,6 +1771,10 @@ body.am-wt-dragging-active * { position: relative; } +.am-chat-wrapper.am-chat-wrapper-hidden { + display: none; +} + .am-readonly-banner { display: flex; align-items: center; diff --git a/packages/kilo-vscode/webview-ui/agent-manager/selection-actions.ts b/packages/kilo-vscode/webview-ui/agent-manager/selection-actions.ts index c3e3c1f2b4..ee6c17e107 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/selection-actions.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/selection-actions.ts @@ -6,6 +6,7 @@ * draft, terminal, or review) and falls back to the first available session. */ +import { batch } from "solid-js" import { LOCAL } from "./navigate" interface TermState { @@ -39,23 +40,28 @@ export interface SelectionActionDeps { /** Select the Local context: restore its remembered tab or fall back to the first session/draft. */ export function selectLocalAction(deps: SelectionActionDeps, locals: T[]): void { deps.saveTabMemory() - deps.setReviewActive(false) - deps.setSelection(LOCAL) deps.post({ type: "agentManager.requestRepoInfo" }) const remembered = deps.tabMemory()[LOCAL] - if (deps.terms.hasRemembered(deps.nsKey(LOCAL), remembered)) return deps.activateTerminal(remembered!) - deps.terms.setActiveId(undefined) - const target = remembered ? locals.find((s) => s.id === remembered) : undefined - const fallback = target ?? locals[0] - if (fallback && !deps.isPending(fallback.id)) { - deps.setActivePendingId(undefined) - deps.selectSession(fallback.id) - } else { - deps.setActivePendingId(fallback && deps.isPending(fallback.id) ? fallback.id : undefined) - deps.clearSession() - deps.post({ type: "agentManager.showExistingLocalTerminal" }) - } - deps.setReviewActive(deps.isReviewTab(remembered, LOCAL)) + batch(() => { + deps.setReviewActive(false) + deps.setSelection(LOCAL) + if (deps.terms.hasRemembered(deps.nsKey(LOCAL), remembered)) { + deps.activateTerminal(remembered!) + return + } + deps.terms.setActiveId(undefined) + const target = remembered ? locals.find((s) => s.id === remembered) : undefined + const fallback = target ?? locals[0] + if (fallback && !deps.isPending(fallback.id)) { + deps.setActivePendingId(undefined) + deps.selectSession(fallback.id) + } else { + deps.setActivePendingId(fallback && deps.isPending(fallback.id) ? fallback.id : undefined) + deps.clearSession() + deps.post({ type: "agentManager.showExistingLocalTerminal" }) + } + deps.setReviewActive(deps.isReviewTab(remembered, LOCAL)) + }) } /** Select a worktree: restore its remembered tab or fall back to its first session. */ @@ -65,13 +71,18 @@ export function selectWorktreeAction( sessions: T[], ): void { deps.saveTabMemory() - deps.setSelection(worktreeId) const remembered = deps.tabMemory()[worktreeId] - if (deps.terms.hasRemembered(deps.nsKey(worktreeId), remembered)) return deps.activateTerminal(remembered!) - deps.terms.setActiveId(undefined) - const target = remembered ? sessions.find((s) => s.id === remembered) : undefined - const fallback = target ?? sessions[0] - if (fallback) deps.selectSession(fallback.id) - else deps.resetSession() - deps.setReviewActive(deps.isReviewTab(remembered, worktreeId)) + batch(() => { + deps.setSelection(worktreeId) + if (deps.terms.hasRemembered(deps.nsKey(worktreeId), remembered)) { + deps.activateTerminal(remembered!) + return + } + deps.terms.setActiveId(undefined) + const target = remembered ? sessions.find((s) => s.id === remembered) : undefined + const fallback = target ?? sessions[0] + if (fallback) deps.selectSession(fallback.id) + else deps.resetSession() + deps.setReviewActive(deps.isReviewTab(remembered, worktreeId)) + }) } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index e5aa67b906..cfeff09f1e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -1221,26 +1221,21 @@ export const MessageList: Component = (props) => { const id = pendingRestore() if (!id || session.loading()) return turns().length - // Double-rAF: the first frame lets the browser paint the new DOM from - // the messagesLoaded batch. The second frame restores scroll position - // without forcing a synchronous layout reflow mid-paint. requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (pendingRestore() !== id) return - const el = scrollEl() - if (!el) return - const state = getScroll(id) - const anchor = resolveAnchor(state, keys()) - const handle = virtualizer() - if (state?.type === "anchor" && anchor && handle) { - handle.scrollToIndex(anchor.index, { offset: anchor.offset }) - autoScroll.pause() - maybeLoadOlder() - } else { - autoScroll.forceScrollToBottom() - } - setPendingRestore(undefined) - }) + if (pendingRestore() !== id) return + const el = scrollEl() + if (!el) return + const state = getScroll(id) + const anchor = resolveAnchor(state, keys()) + const handle = virtualizer() + if (state?.type === "anchor" && anchor && handle) { + handle.scrollToIndex(anchor.index, { offset: anchor.offset }) + autoScroll.pause() + maybeLoadOlder() + } else { + autoScroll.forceScrollToBottom() + } + setPendingRestore(undefined) }) }) 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 5a8255befe..5bb00170cf 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -373,7 +373,12 @@ export const PromptInput: Component = (props) => { createEffect( on(draftKey, (key, prev) => { if (prev !== undefined && prev !== key) { - saveDraft(prev, untrack(text), untrack(reviewComments), untrack(imageAttach.images)) + const val = untrack(text) + const comments = untrack(reviewComments) + const imgs = untrack(imageAttach.images) + if (val || comments.length > 0 || imgs.length > 0 || drafts.has(prev)) { + saveDraft(prev, val, comments, imgs) + } } const draft = drafts.get(key) ?? "" const pending = reviewDrafts.get(key) ?? [] @@ -406,14 +411,17 @@ export const PromptInput: Component = (props) => { createEffect(() => { const msgs = session.userMessages() if (msgs.length === 0) return - const texts = msgs.map((m) => { - const parts = session.getParts(m.id) - return parts - .filter((part): part is TextPart => part.type === "text") - .map((part) => partReview(part.metadata, part.text)?.body ?? part.text.replace(REVIEW_PREFIX, "")) - .join("") - }) - history.seed(texts) + const timer = setTimeout(() => { + const texts = msgs.map((m) => { + const parts = session.getParts(m.id) + return parts + .filter((part): part is TextPart => part.type === "text") + .map((part) => partReview(part.metadata, part.text)?.body ?? part.text.replace(REVIEW_PREFIX, "")) + .join("") + }) + history.seed(texts) + }, 100) + onCleanup(() => clearTimeout(timer)) }) // Focus textarea when any part of the app requests it diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index d8f0b44d24..9619a8299d 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -1489,12 +1489,11 @@ export const SessionProvider: ParentComponent = (props) => { setStore("messages", sessionID, reconcile(merged, { key: "id" })) } - for (const msg of messages) { + const cutoff = Math.max(0, messages.length - 15) + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]! const parts = msg.parts ?? [] if (mode === "reconcile" && store.parts[msg.id]) { - // Reconcile on a message already hydrated into the reactive store: - // write parts directly so visible turns pick up server corrections, - // but do not erase proven newer streamed text absent from a stale snapshot. const merged = mergeParts(store.parts[msg.id], parts, input.since ?? Number.POSITIVE_INFINITY) setStore("parts", msg.id, reconcile(merged, { key: "id" })) stash.remove(msg.id) @@ -1502,9 +1501,12 @@ export const SessionProvider: ParentComponent = (props) => { } if (parts.length > 0) { loadedParts[msg.id] = parts - // Stash parts outside the reactive store. They hydrate on demand - // when the virtualizer renders the corresponding turn. - stash.put(msg.id, parts) + if (i >= cutoff) { + setStore("parts", msg.id, parts) + stash.remove(msg.id) + } else { + stash.put(msg.id, parts) + } continue } if (mode === "reconcile") stash.remove(msg.id) @@ -2604,18 +2606,15 @@ export const SessionProvider: ParentComponent = (props) => { return } const ready = loaded().has(id) - // Reflect the selection locally and synchronously so the chat always tracks - // the sidebar/tab selection. These are local signals and need no backend, so - // they update even while disconnected. Bailing out here when not connected - // froze the chat on the previous session while the side diff (resolved from - // the worktree selection) still moved (the reported "only the diff changes"). - agentDrafts.prune(draftSessionID()) - setCloudPreviewId(null) - setCurrentSessionID(id) - setDraftSessionID(id) - setUserClearedSession(false) - setLoading(!ready) - if (!ready) patchPage(id, { loadingInitial: true, loadingOlder: false, before: undefined, hasMore: false }) + batch(() => { + agentDrafts.prune(draftSessionID()) + setCloudPreviewId(null) + setCurrentSessionID(id) + setDraftSessionID(id) + setUserClearedSession(false) + setLoading(!ready) + if (!ready) patchPage(id, { loadingInitial: true, loadingOlder: false, before: undefined, hasMore: false }) + }) // Only the message fetch needs the backend. Defer it while offline and let // the reconnect effect replay it. We defer even for cached sessions: the // load message is what re-focuses the backend (focusSession, contextSessionID, diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx index 6a2f4b55d4..06e8e7d8fe 100644 --- a/packages/ui/src/components/tooltip.tsx +++ b/packages/ui/src/components/tooltip.tsx @@ -1,5 +1,5 @@ import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip" -import { createEffect, Match, onCleanup, splitProps, Switch, type JSX } from "solid-js" +import { createEffect, Match, onCleanup, Show, splitProps, Switch, type JSX } from "solid-js" // kilocode_change import type { ComponentProps } from "solid-js" import { createStore } from "solid-js/store" @@ -103,7 +103,7 @@ export function Tooltip(props: TooltipProps) { return ( - {local.children} + {/* kilocode_change */}{local.children} {local.children} - - { - if (ref === e.target || (e.target instanceof Node && ref?.contains(e.target))) { - justClickedTrigger = true - } - e.preventDefault() - }} - > - {local.value} - {/* */} - - + {/* kilocode_change start - only mount portal and content when open or forced open to avoid style computation when hidden */} + + + { + if (ref === e.target || (e.target instanceof Node && ref?.contains(e.target))) { + justClickedTrigger = true + } + e.preventDefault() + }} + > + {local.value} + {/* */} + + + + {/* kilocode_change end */}