Compare commits

...
Author SHA1 Message Date
l0ng-ai 1e3de07cbb perf(agents): switch back to a long conversation without rebuilding it
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 <details> itself WebKit wouldn't expand them,
so they were laid out in full on every resize and reopen.
2026-11-06 13:03:49 +08:00
8 changed files with 215 additions and 23 deletions

No files matched your search

@@ -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;
+42 -4
View File
@@ -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 ? <PaneContent paneId={paneId} tab={active} tabsVisible={!hidden} /> : <EmptyEditor />}
{kept.map((tab) => (
<KindEditor key={tab.id} tab={tab} shown={tab === active} kept />
))}
{!active ? (
<EmptyEditor />
) : (
!kept.includes(active) && <PaneContent paneId={paneId} tab={active} tabsVisible={!hidden} />
)}
{dropZone && <div className={clsx(styles.dropZone, styles[`drop-${dropZone}`])} aria-hidden="true" />}
</div>
</section>
@@ -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 <KindEditor key={tab.id} tab={tab} shown />;
}
/** 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<string, EditorKindContribution>): 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 <EmptyState icon="file" title={tx`No editor for “${tab.input.kind}”`} />;
const Component = kind.component;
return (
<div className={styles.kind} data-editor-focus="" tabIndex={-1} key={tab.id}>
<Component input={tab.input} />
<div
className={clsx(styles.kind, kept && styles.kept, !shown && styles.keptHidden)}
data-editor-focus={shown ? "" : undefined}
tabIndex={-1}
aria-hidden={shown ? undefined : true}
>
<EditorShownProvider value={shown}>
<Component input={tab.input} />
</EditorShownProvider>
</div>
);
}
+98
View File
@@ -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<object>()),
editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [] }),
}));
vi.mock("./TextEditorHost", () => ({
TextEditorHost: () => <input aria-label="Source" />,
}));
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 <div data-testid={name}>{name}</div>;
}
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(<EditorAreaView />);
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(<EditorAreaView />);
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);
});
});
+4 -1
View File
@@ -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 <div>: on <details> itself WebKit wouldn't expand them.
*/
.stream > :not(.message):not(details) {
content-visibility: auto;
contain-intrinsic-block-size: auto 80px;
+29 -18
View File
@@ -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<FocusedSession>(() => ({
@@ -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<string, string> = {
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 <Markdown text={entry.text} cwd={task.cwd} />;
case "thought":
return (
<details className={styles.thought}>
<summary>
<T>Thinking</T>
</summary>
<div className={styles.thoughtText}>{entry.text}</div>
</details>
<div>
<details className={styles.thought}>
<summary>
<T>Thinking</T>
</summary>
<div className={styles.thoughtText}>{entry.text}</div>
</details>
</div>
);
case "tool":
return <ToolEntry task={task} entry={entry} />;
@@ -801,7 +810,7 @@ function Entry({
</div>
);
}
}
});
function UserMessage({
task,
@@ -1049,10 +1058,12 @@ function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract<AgentEntry
);
if (!entry.output) return <div className={styles.tool}>{head}</div>;
return (
<details className={styles.toolDetails}>
<summary className={styles.tool}>{head}</summary>
<pre className={styles.output}>{entry.output}</pre>
</details>
<div>
<details className={styles.toolDetails}>
<summary className={styles.tool}>{head}</summary>
<pre className={styles.output}>{entry.output}</pre>
</details>
</div>
);
}
+2
View File
@@ -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,
+15
View File
@@ -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) => (
<EditorShownProvider value={shown}>
<SessionEditor input={input("A")} />
</EditorShownProvider>
);
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");
+12
View File
@@ -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);