mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
perf(agent-manager): optimize tab switching and context transition latency
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user