perf(agent-manager): optimize tab switching and context transition latency

This commit is contained in:
marius-kilocode
2026-08-17 09:45:41 +02:00
parent 4239f9d986
commit be8b85c8ce
8 changed files with 190 additions and 163 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Optimize Agent Manager tab switching and context transition latency
@@ -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 = () => {
</Show>
</div>
</Show>
<Show when={!contextEmpty()}>
<div class="am-chat-wrapper">
<ChatView
onSelectSession={(id) => {
if (addSessionToCurrentWorktree(id)) return
if (localSessionIDs().includes(id)) {
<div class="am-chat-wrapper" classList={{ "am-chat-wrapper-hidden": contextEmpty() }}>
<ChatView
onSelectSession={(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}
/>
<Show when={readOnly()}>
<div class="am-readonly-banner">
<Icon name="branch" size="small" />
<span class="am-readonly-text">{t("agentManager.session.readonly")}</span>
<Button
variant="secondary"
size="small"
onClick={() => {
if (!loaded()) return
const sid = session.currentSessionID()
if (!sid) return
metrics.track("open_session_locally", "readonly_banner")
openLocally(sid)
}}
>
{t("agentManager.session.openLocally")}
</Button>
<Button
variant="primary"
size="small"
onClick={() => {
if (!loaded()) return
const sid = session.currentSessionID()
if (!sid) return
metrics.track("promote_session", "readonly_banner")
vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid })
}}
>
{t("agentManager.session.openInWorktree")}
</Button>
</div>
</Show>
</div>
</Show>
}
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}
/>
<Show when={readOnly()}>
<div class="am-readonly-banner">
<Icon name="branch" size="small" />
<span class="am-readonly-text">{t("agentManager.session.readonly")}</span>
<Button
variant="secondary"
size="small"
onClick={() => {
if (!loaded()) return
const sid = session.currentSessionID()
if (!sid) return
metrics.track("open_session_locally", "readonly_banner")
openLocally(sid)
}}
>
{t("agentManager.session.openLocally")}
</Button>
<Button
variant="primary"
size="small"
onClick={() => {
if (!loaded()) return
const sid = session.currentSessionID()
if (!sid) return
metrics.track("promote_session", "readonly_banner")
vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid })
}}
>
{t("agentManager.session.openInWorktree")}
</Button>
</div>
</Show>
</div>
</div>
{/* One inspector host for all right-side modes. It stays
mounted while a side terminal is alive — hidden via
@@ -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;
@@ -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<T extends SessionLike> {
/** Select the Local context: restore its remembered tab or fall back to the first session/draft. */
export function selectLocalAction<T extends SessionLike>(deps: SelectionActionDeps<T>, 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<T extends SessionLike>(
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))
})
}
@@ -1221,26 +1221,21 @@ export const MessageList: Component<MessageListProps> = (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)
})
})
@@ -373,7 +373,12 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (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
@@ -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,
+24 -20
View File
@@ -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 (
<Switch>
<Match when={local.inactive}>{local.children}</Match>
<Match when={local.inactive || !local.value}>{/* kilocode_change */}{local.children}</Match>
<Match when={true}>
<KobalteTooltip
gutter={4}
@@ -138,24 +138,28 @@ export function Tooltip(props: TooltipProps) {
>
{local.children}
</KobalteTooltip.Trigger>
<KobalteTooltip.Portal>
<KobalteTooltip.Content
data-component="tooltip"
data-placement={props.placement}
data-force-open={local.forceOpen}
class={local.contentClass}
style={local.contentStyle}
onPointerDownOutside={(e) => {
if (ref === e.target || (e.target instanceof Node && ref?.contains(e.target))) {
justClickedTrigger = true
}
e.preventDefault()
}}
>
{local.value}
{/* <KobalteTooltip.Arrow data-slot="tooltip-arrow" /> */}
</KobalteTooltip.Content>
</KobalteTooltip.Portal>
{/* kilocode_change start - only mount portal and content when open or forced open to avoid style computation when hidden */}
<Show when={local.forceOpen || state.open}>
<KobalteTooltip.Portal>
<KobalteTooltip.Content
data-component="tooltip"
data-placement={props.placement}
data-force-open={local.forceOpen}
class={local.contentClass}
style={local.contentStyle}
onPointerDownOutside={(e) => {
if (ref === e.target || (e.target instanceof Node && ref?.contains(e.target))) {
justClickedTrigger = true
}
e.preventDefault()
}}
>
{local.value}
{/* <KobalteTooltip.Arrow data-slot="tooltip-arrow" /> */}
</KobalteTooltip.Content>
</KobalteTooltip.Portal>
</Show>
{/* kilocode_change end */}
</KobalteTooltip>
</Match>
</Switch>