From 09c0af504e3b83160e3a17345d5bdcbd74cd1a56 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:08:53 +0800 Subject: [PATCH] perf(agents): switch back to a long conversation without rebuilding it (#1710) Agent task tabs now stay mounted while another tab is active (the five most recently used per pane). A hidden one skips rendering with content-visibility: hidden, which keeps its layout and scroll offset, so showing it again costs a few milliseconds instead of building and laying out the whole conversation. Hidden tabs don't mark their task read and close Find. Thinking blocks and tool output now sit in a wrapper that skips off-screen layout like other entries; on
itself WebKit wouldn't expand them, so they were laid out in full on every resize and reopen. --- src/editor/core/view/EditorArea.module.css | 13 +++ src/editor/core/view/EditorArea.tsx | 46 +++++++++- src/editor/core/view/keptTabs.test.tsx | 98 ++++++++++++++++++++++ src/features/agents/Session.module.css | 5 +- src/features/agents/SessionEditor.tsx | 47 +++++++---- src/features/agents/index.ts | 2 + src/features/agents/sessionState.test.tsx | 15 ++++ src/workbench/contributions.ts | 12 +++ 8 files changed, 215 insertions(+), 23 deletions(-) create mode 100644 src/editor/core/view/keptTabs.test.tsx diff --git a/src/editor/core/view/EditorArea.module.css b/src/editor/core/view/EditorArea.module.css index 593cd1ac4..722677b2a 100644 --- a/src/editor/core/view/EditorArea.module.css +++ b/src/editor/core/view/EditorArea.module.css @@ -30,6 +30,19 @@ min-height: 0; min-width: 0; position: relative; + isolation: isolate; +} +/* + * Kept-alive tabs share the content box; hiding one only skips its rendering, not its layout. + * Nothing inherited changes (pointer-events, inert): that would restyle the whole conversation. + */ +.kept { + position: absolute; + inset: 0; +} +.keptHidden { + content-visibility: hidden; + z-index: -1; } .host { flex: 1; diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index 2dccfd9e4..ff0906f6b 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -19,11 +19,13 @@ import { getFileDecoration, prepareMenuGroups, textEditorWrapperFor, + EditorShownProvider, useContributions, useFileDecorations, useEditorDocumentRevision, useTextEditorWrappers, type EditorInput, + type EditorKindContribution, } from "@/workbench/contributions"; import { useDistractionFree } from "@/workbench/distractionFree"; import { EmptyEditor } from "@/workbench/EditorArea"; @@ -276,6 +278,7 @@ function PaneView({ paneId }: { paneId: string }) { if (!pane) return null; const active = pane.tabs.find((t) => t.id === pane.active) ?? null; + const kept = keptTabs(pane.tabs, pane.mru, kinds); const onFocus = () => { setContext("editorFocus", true); @@ -355,7 +358,14 @@ function PaneView({ paneId }: { paneId: string }) { aria-labelledby={active ? `editor-${paneId}-tab-${active.id}` : undefined} ref={contentRef} > - {active ? : } + {kept.map((tab) => ( + + ))} + {!active ? ( + + ) : ( + !kept.includes(active) && + )} {dropZone && @@ -370,7 +380,6 @@ function virtualTooltip(input: EditorInput): string | null { } function PaneContent({ paneId, tab, tabsVisible }: { paneId: string; tab: Tab; tabsVisible: boolean }) { - const kinds = useContributions((s) => s.editorKinds); const crumbs = !useDistractionFree((s) => s.active); const wrappers = useTextEditorWrappers((s) => s.wrappers); if (isTextInput(tab.input)) { @@ -402,12 +411,41 @@ function PaneContent({ paneId, tab, tabsVisible }: { paneId: string; tab: Tab; t ); } + return ; +} + +/** Tabs of a pane kept mounted while hidden: the most recently used ones of `keepAlive` kinds. */ +const KEPT_TABS = 5; + +function keptTabs(tabs: Tab[], mru: string[], kinds: Map): Tab[] { + const recent = mru + .map((id) => tabs.find((t) => t.id === id)) + .filter((t): t is Tab => !!t && !!kinds.get(t.input.kind)?.keepAlive) + .slice(0, KEPT_TABS); + // In tab order, so switching tabs doesn't move their DOM (which would lose scroll offsets). + return tabs.filter((t) => recent.includes(t)); +} + +/** + * `shown`: the pane's visible tab, which focusing the editor area reaches. A hidden `kept` tab + * keeps its layout (content-visibility), so showing it again needn't lay it out from scratch; + * its content can't be focused or hit while skipped. + */ +function KindEditor({ tab, shown, kept = false }: { tab: Tab; shown: boolean; kept?: boolean }) { + const kinds = useContributions((s) => s.editorKinds); const kind = kinds.get(tab.input.kind); if (!kind) return ; const Component = kind.component; return ( -
- +
+ + +
); } diff --git a/src/editor/core/view/keptTabs.test.tsx b/src/editor/core/view/keptTabs.test.tsx new file mode 100644 index 000000000..cff0d8a1b --- /dev/null +++ b/src/editor/core/view/keptTabs.test.tsx @@ -0,0 +1,98 @@ +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { registerEditorKind, type EditorInput } from "@/workbench/contributions"; +import { useTabs } from "../controller"; +import type { Tab } from "../model/tabs"; +import { EditorAreaView } from "./EditorArea"; + +vi.mock("../controller", async (original) => ({ + ...(await original()), + editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [] }), +})); +vi.mock("./TextEditorHost", () => ({ + TextEditorHost: () => , +})); +vi.mock("./FindBar", () => ({ FindBar: () => null })); +vi.mock("./Banner", () => ({ + EditorBanners: () => null, + VirtualBanner: () => null, +})); +vi.mock("./StatusItems", () => ({ CodeBreadcrumbs: () => null })); + +const mounts: string[] = []; +function Kept({ input }: { input: EditorInput }) { + const name = input.key; + useState(() => mounts.push(name)); + return
{name}
; +} +const disposers = [ + registerEditorKind({ + kind: "kept", + component: Kept, + title: (i) => i.key, + keepAlive: true, + }), + registerEditorKind({ kind: "plain", component: Kept, title: (i) => i.key }), +]; + +/** Shown, or kept mounted behind the shown tab (its rendering skipped). */ +const shown = (id: string) => !screen.getByTestId(id).closest("[aria-hidden]"); + +const tab = (id: string, kind: string): Tab => ({ + id, + input: { kind, key: id }, + preview: false, + pinned: false, +}); +const original = useTabs.getState(); + +function show(tabs: Tab[], active: string) { + act(() => + useTabs.setState((s) => { + const mru = [active, ...(s.panes.p1?.mru ?? []).filter((id) => id !== active && tabs.some((t) => t.id === id))]; + return { + layout: { kind: "pane", paneId: "p1" }, + panes: { p1: { id: "p1", tabs, active, mru } }, + activePane: "p1", + closed: [], + }; + }), + ); +} + +afterEach(() => { + cleanup(); + mounts.length = 0; + useTabs.setState(original, true); +}); +afterAll(() => disposers.forEach((d) => d())); + +describe("keep-alive editor kinds", () => { + it("keep their recent tabs mounted and hidden while another tab is active", () => { + const tabs = [tab("a", "kept"), tab("b", "kept"), tab("c", "plain")]; + show(tabs, "a"); + render(); + show(tabs, "b"); + show(tabs, "c"); + expect(shown("a")).toBe(false); + expect(shown("b")).toBe(false); + expect(shown("c")).toBe(true); + show(tabs, "a"); + show(tabs, "c"); + // Only the plain tab is built again; the kept ones are shown as they were. + expect(mounts).toEqual(["a", "b", "c", "c"]); + expect(screen.queryAllByTestId("c")).toHaveLength(1); + }); + + it("let the least recently used kept tabs go beyond the limit", () => { + const tabs = Array.from({ length: 7 }, (_, i) => tab(`k${i}`, "kept")); + show(tabs, "k0"); + render(); + for (const t of tabs) show(tabs, t.id); + expect(screen.queryByTestId("k0")).toBeNull(); + expect(screen.queryByTestId("k1")).toBeNull(); + expect(shown("k2")).toBe(false); + expect(shown("k6")).toBe(true); + }); +}); diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index 1aedb6341..823347725 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -157,7 +157,10 @@ .stream:focus { outline: none; } -/* Skip off-screen output layout; editable messages must repaint immediately when focus/content changes. */ +/* + * Skip off-screen output layout; editable messages must repaint immediately when focus/content changes. + * Thinking and tool output sit in a
: on
itself WebKit wouldn't expand them. + */ .stream > :not(.message):not(details) { content-visibility: auto; contain-intrinsic-block-size: auto 80px; diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 5b20670b7..b1475948f 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -1,7 +1,9 @@ /** An agent task as an editor tab: the conversation, a composer and the task's changed files. */ import { Fragment, + memo, startTransition, + useCallback, useEffect, useLayoutEffect, useMemo, @@ -15,7 +17,7 @@ import type { AgentChange, AgentEntry, AgentImage, AgentLimit, AgentRetry, Agent import { executeAction } from "@/platform/actions"; import { call, commands, onIdeEvent } from "@/platform/ipc"; import { basename, relativeDir, useProject } from "@/platform/project"; -import { editorService, notifications, type EditorInput } from "@/workbench/contributions"; +import { editorService, notifications, useEditorShown, type EditorInput } from "@/workbench/contributions"; import { Button, EmptyState, @@ -105,15 +107,17 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) { if (!useAgents.getState().entries.has(id)) void loadEntries(id).catch(() => undefined); void refreshChanges(id); }, [id]); + // A kept-alive tab stays mounted behind the shown one: it isn't read there, nor searched. + const shown = useEditorShown(); useEffect(() => { - if (!docked || !changesOpen) return markVisible(id); - }, [id, docked, changesOpen]); + if (shown && (!docked || !changesOpen)) return markVisible(id); + }, [id, docked, changesOpen, shown]); useEffect(() => { if (view && !useAgents.getState().entries.has(entriesKey(id, view))) { void loadEntries(id, view).catch(() => undefined); } }, [id, view]); - const openSubagent = (sub: string) => setPath((p) => [...p, sub]); + const openSubagent = useCallback((sub: string) => setPath((p) => [...p, sub]), []); const [finding, setFinding] = useState(false); const [findFocus, setFindFocus] = useState(0); const [session] = useState(() => ({ @@ -157,7 +161,7 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) { entries={viewEntries ?? null} subagent={view ? (cards.at(-1) ?? null) : null} onOpenSubagent={openSubagent} - finding={finding} + finding={finding && shown} findFocus={findFocus} onCloseFind={() => setFinding(false)} /> @@ -441,9 +445,11 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o useEffect(() => { if (start > 0) startTransition(() => setShown((n) => n + ENTRY_BATCH)); }, [start]); + // Hidden behind another tab, it has no layout to scroll; shown again, it follows the end again. + const visible = useEditorShown(); useLayoutEffect(() => { const el = ref.current; - if (!el || entries === null) return; + if (!el || entries === null || !visible) return; if (position.current.stick) el.scrollTop = el.scrollHeight; else if (!restored.current) el.scrollTop = position.current.top; restored.current = true; @@ -726,7 +732,8 @@ const TOOL_ICONS: Record = { think: "todo", }; -function Entry({ +/** Memoized: the earlier entries rendered behind a reopened conversation leave the rest alone. */ +const Entry = memo(function Entry({ task, entry, turn, @@ -747,12 +754,14 @@ function Entry({ return ; case "thought": return ( -
- - Thinking - -
{entry.text}
-
+
+
+ + Thinking + +
{entry.text}
+
+
); case "tool": return ; @@ -801,7 +810,7 @@ function Entry({
); } -} +}); function UserMessage({ task, @@ -1049,10 +1058,12 @@ function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract{head}; return ( -
- {head} -
{entry.output}
-
+
+
+ {head} +
{entry.output}
+
+
); } diff --git a/src/features/agents/index.ts b/src/features/agents/index.ts index 7b29e6cfd..83b03b812 100644 --- a/src/features/agents/index.ts +++ b/src/features/agents/index.ts @@ -73,6 +73,8 @@ export function activate(): () => void { icon: () => "toolwindow-agents", // Tabs of tasks deleted meanwhile are closed when the tasks load (`connect`). restorable: true, + // Rebuilding a long conversation on every switch back is slow. + keepAlive: true, }), registerEditorKind({ kind: HOME_KIND, diff --git a/src/features/agents/sessionState.test.tsx b/src/features/agents/sessionState.test.tsx index 4a8261400..3b060267a 100644 --- a/src/features/agents/sessionState.test.tsx +++ b/src/features/agents/sessionState.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { registerActions } from "@/platform/actions"; import { useProject } from "@/platform/project"; +import { EditorShownProvider } from "@/workbench/contributions"; import type { AgentChange, AgentEntry, AgentTask } from "@/bindings"; import { AgentsView } from "./AgentsView"; import { SessionEditor } from "./SessionEditor"; @@ -179,6 +180,20 @@ describe("a pending permission request in the conversation", () => { }); describe("session state across tasks and hosts", () => { + it("leaves a task unread while its kept-alive tab is hidden, and reads it once shown", () => { + vi.spyOn(document, "hasFocus").mockReturnValue(true); + useAgents.setState({ unread: new Set(["A"]) }); + const session = (shown: boolean) => ( + + + + ); + const { rerender } = render(session(false)); + expect(useAgents.getState().unread.has("A")).toBe(true); + rerender(session(true)); + expect(useAgents.getState().unread.has("A")).toBe(false); + }); + it("fetches a conversation once and shows it again from the store when its tab reopens", async () => { const entries = new Map(useAgents.getState().entries); entries.delete("A"); diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 8f09062c8..8ee878f1e 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -122,6 +122,12 @@ export interface EditorKindContribution { restorable?: boolean; /** Can remain visible in a window without a project (for example, an external Git log). */ standalone?: boolean; + /** + * Its recently used tabs stay mounted, hidden, while another tab is active: switching back + * shows them as they were instead of building them again (long agent conversations). A hidden + * tab keeps running; `useEditorShown` tells it whether it is the one shown. + */ + keepAlive?: boolean; /** Last tab showing this input closed (including split tabs and tab-limit eviction). */ onClose?(input: EditorInput): void; /** @@ -318,6 +324,12 @@ export const registerWorkbenchBanner = (c: WorkbenchBannerContribution) => regis export const registerToolWindow = (c: ToolWindowContribution) => register("toolWindows", c.id, c); export const registerStatusItem = (c: StatusItemContribution) => register("statusItems", c.id, c); export const registerOverlay = (c: OverlayContribution) => register("overlays", c.id, c); + +const EditorShown = createContext(true); +export const EditorShownProvider = EditorShown.Provider; +/** Whether this editor tab is the one its pane shows (a `keepAlive` tab stays mounted when it isn't). */ +export const useEditorShown = () => useContext(EditorShown); + export const registerEditorKind = (c: EditorKindContribution) => register("editorKinds", c.kind, c); export const registerTitleItem = (c: TitleItemContribution) => register("titleItems", c.id, c); export const registerServiceView = (c: ServiceViewContribution) => register("serviceViews", c.id, c);