Compare commits

...
1 Commits
Author SHA1 Message Date
l0ng-ai 440f5384ad perf(agents): make switching to a long conversation tab cheap
A kept-alive agent tab is hidden with `content-visibility: hidden`, and WebKit
recomputes style for the whole subtree when it is shown again. With a long
conversation that is tens of thousands of nodes, so Ctrl+Tab (or any switch)
to it blocked the main thread for 200–500 ms.

- Folded sections (tool call runs, tool output, reasoning, subagent task and
  result) render their content only once opened, or while Find in
  Conversation is open so their text is still found.
- Conversation rows skip rendering while off screen (`content-visibility:
  auto`) once they have been laid out, keeping their remembered height so
  scrolling up doesn't jump; a width change lays them out again.
2026-11-09 11:00:54 +08:00
3 changed files with 195 additions and 46 deletions

No files matched your search

+5
View File
@@ -118,6 +118,11 @@
}
.conversationRow {
min-width: 0;
contain-intrinsic-size: auto 120px;
}
/* Laid out once, an off-screen row skips rendering at its remembered size (`useOffscreenRows`). */
.conversationRow[data-measured] {
content-visibility: auto;
}
/* Keep turns distinct and the work within a turn close to its author. */
.stream > * + * {
+159 -45
View File
@@ -1,15 +1,18 @@
/** An agent task as an editor tab: the conversation, a composer and the task's changed files. */
import {
createContext,
Fragment,
memo,
startTransition,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
type RefObject,
} from "react";
import clsx from "clsx";
@@ -189,16 +192,18 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
docked={docked}
/>
{view && <SubagentBar task={task} path={path} cards={cards} onNavigate={setPath} />}
<Stream
key={view ?? ""}
task={task}
entries={viewEntries ?? null}
subagent={view ? (cards.at(-1) ?? null) : null}
onOpenSubagent={openSubagent}
finding={finding && shown}
findFocus={findFocus}
onCloseFind={() => setFinding(false)}
/>
<FoldedContent.Provider value={finding && shown}>
<Stream
key={view ?? ""}
task={task}
entries={viewEntries ?? null}
subagent={view ? (cards.at(-1) ?? null) : null}
onOpenSubagent={openSubagent}
finding={finding && shown}
findFocus={findFocus}
onCloseFind={() => setFinding(false)}
/>
</FoldedContent.Provider>
{view ? (
<div className={styles.readOnly}>
<T>Subagents take their instructions from the agent that started them.</T>
@@ -457,6 +462,65 @@ function SubagentBar({
);
}
/**
* Lets the rows of a long conversation skip rendering while off screen (`data-measured`, see
* the CSS), so showing the tab again or restyling it costs what is on screen, not the whole
* history. A row skips only once it was laid out: it keeps its real height, and scrolling up
* doesn't jump (WebKit has no scroll anchoring). A new width lays them out again once it
* settles; Find in Conversation (`enabled` false) needs every row laid out.
*/
function useOffscreenRows(ref: RefObject<HTMLElement | null>, visible: boolean, enabled: boolean) {
// The width the marked rows were laid out at.
const width = useRef(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
const unmark = () => {
for (const row of el.querySelectorAll(`:scope > .${styles.conversationRow}[data-measured]`)) {
row.removeAttribute("data-measured");
}
};
if (!enabled) return unmark();
if (!visible) return;
let frame = 0;
let timer: number | undefined;
const mark = () => {
cancelAnimationFrame(frame);
// The next frame lays the new rows out (and remembers their size); mark them after it.
frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
for (const row of el.querySelectorAll(`:scope > .${styles.conversationRow}:not([data-measured])`)) {
row.setAttribute("data-measured", "");
}
});
});
};
const remeasure = () => {
if (el.clientWidth === width.current) return;
width.current = el.clientWidth;
unmark();
};
remeasure();
mark();
const rows = new MutationObserver(mark);
rows.observe(el, { childList: true });
const resize = new ResizeObserver(() => {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
remeasure();
mark();
}, 200);
});
resize.observe(el);
return () => {
cancelAnimationFrame(frame);
window.clearTimeout(timer);
rows.disconnect();
resize.disconnect();
};
}, [ref, visible, enabled]);
}
interface StreamProps {
task: AgentTask;
entries: AgentEntry[] | null;
@@ -540,6 +604,7 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
}, [start]);
// Hidden behind another tab, it has no layout to scroll; shown again, it follows the end again.
const visible = useEditorShown();
useOffscreenRows(ref, visible, !finding);
/** A diagram that drew after the last streamed chunk grows the conversation: keep following. */
const onDiagramDrawn = useCallback(() => {
const el = ref.current;
@@ -1015,19 +1080,23 @@ const Entry = memo(function Entry({
case "thought":
return (
<div className={styles.process}>
<details className={styles.thought}>
<summary>
{working ? (
<Spinner size={12} />
) : (
<Icon name="chevron-right" size={12} className={styles.thoughtChevron} />
)}
<T>{working ? "Thinking" : "Reasoning"}</T>
</summary>
<Fold
className={styles.thought}
summary={
<summary>
{working ? (
<Spinner size={12} />
) : (
<Icon name="chevron-right" size={12} className={styles.thoughtChevron} />
)}
<T>{working ? "Thinking" : "Reasoning"}</T>
</summary>
}
>
<div className={styles.thoughtBody}>
<Markdown text={entry.text} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
</div>
</details>
</Fold>
</div>
);
case "tool":
@@ -1383,14 +1452,18 @@ const ProcessRun = memo(function ProcessRun({
tools.length > 0 ? `${tools.length} ${tx(tools.length === 1 ? "tool call" : "tool calls")}` : null,
].filter(Boolean);
return (
<details className={clsx(styles.processRun, styles.process)}>
<summary className={styles.processSummary}>
<Icon name="chevron-right" size={12} className={styles.processChevron} />
{working && <Spinner size={12} />}
<span>{parts.join(" · ")}</span>
{added > 0 && <span className={styles.added}>+{added}</span>}
{removed > 0 && <span className={styles.removed}>−{removed}</span>}
</summary>
<Fold
className={clsx(styles.processRun, styles.process)}
summary={
<summary className={styles.processSummary}>
<Icon name="chevron-right" size={12} className={styles.processChevron} />
{working && <Spinner size={12} />}
<span>{parts.join(" · ")}</span>
{added > 0 && <span className={styles.added}>+{added}</span>}
{removed > 0 && <span className={styles.removed}>−{removed}</span>}
</summary>
}
>
<div className={styles.processBody}>
{row.entries.map(({ index, entry }) =>
entry.type === "tool" ? (
@@ -1400,10 +1473,38 @@ const ProcessRun = memo(function ProcessRun({
),
)}
</div>
</details>
</Fold>
);
});
/** Set while Find in Conversation is open: folded sections render their content, so it is found. */
const FoldedContent = createContext(false);
/**
* A collapsed section that renders its content only once opened: most of a long conversation
* stays folded, and keeping it out of the DOM keeps showing and restyling the tab cheap.
*/
function Fold({
className,
open,
summary,
children,
}: {
className?: string;
open?: boolean;
summary: ReactElement;
children: ReactNode;
}) {
const always = useContext(FoldedContent);
const [expanded, setExpanded] = useState(!!open);
return (
<details className={className} open={open || undefined} onToggle={(e) => setExpanded(e.currentTarget.open)}>
{summary}
{(expanded || open || always) && children}
</details>
);
}
const ToolEntry = memo(function ToolEntry({
task,
entry,
@@ -1443,13 +1544,18 @@ const ToolEntry = memo(function ToolEntry({
);
return (
<div className={styles.process}>
<details className={styles.toolDetails} open={liveTool(entry) || undefined}>
<summary className={styles.tool}>
<Icon name="chevron-right" size={12} className={styles.toolChevron} />
{head}
</summary>
<Fold
className={styles.toolDetails}
open={liveTool(entry)}
summary={
<summary className={styles.tool}>
<Icon name="chevron-right" size={12} className={styles.toolChevron} />
{head}
</summary>
}
>
<pre className={styles.output}>{entry.output}</pre>
</details>
</Fold>
</div>
);
});
@@ -1532,22 +1638,30 @@ function SubagentCard({
</div>
)}
{entry.prompt && (
<details className={styles.subagentSection}>
<summary>
<T>Task</T>
</summary>
<Fold
className={styles.subagentSection}
summary={
<summary>
<T>Task</T>
</summary>
}
>
<div className={styles.thoughtText}>{entry.prompt}</div>
</details>
</Fold>
)}
{entry.reply && (
<details className={styles.subagentSection}>
<summary>
<T>Result</T>
</summary>
<Fold
className={styles.subagentSection}
summary={
<summary>
<T>Result</T>
</summary>
}
>
<div className={styles.subagentReply}>
<AgentReply text={entry.reply} cwd={task.cwd} task={task} onDiagramDrawn={onDiagramDrawn} />
</div>
</details>
</Fold>
)}
</div>
);
+31 -1
View File
@@ -10,6 +10,7 @@ import { entriesKey, SESSION_KIND, sessionKey, type SubagentEntry } from "./mode
import { disconnect, useAgents } from "./store";
import { taskActions } from "./taskActions";
import { replyActions } from "./replyActions";
import { focusedSession } from "./sessionFocus";
import styles from "./Session.module.css";
import tagStyles from "@/ui/InlineTag.module.css";
@@ -243,8 +244,37 @@ describe("process folding", () => {
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const summary = await screen.findByText("Reasoning · 2 tool calls");
const fold = summary.closest("details")!;
expect(fold.open).toBe(false);
// Folded content is rendered once opened, a level at a time.
const open = (details: HTMLDetailsElement) => {
details.open = true;
fireEvent(details, new Event("toggle"));
};
expect(screen.queryByText("Reasoning")).toBeNull();
open(fold);
const thought = (await screen.findAllByText("Reasoning"))[0]!.closest("details")!;
expect(screen.queryByText("think one")).toBeNull();
open(thought);
expect(await screen.findByText("think one")).toBeInTheDocument();
});
});
describe("folded content", () => {
it("is rendered for Find in Conversation, so its text is found", async () => {
backend.entries.set("A", [
tool("one", { output: "needle in a tool output" }),
tool("two"),
{ type: "agent", text: "Answer A" },
]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const summary = await screen.findByText("2 tool calls");
expect(screen.queryByText("needle in a tool output")).toBeNull();
fireEvent.focus(summary);
act(() => focusedSession()!.openFind());
expect(await screen.findByText("needle in a tool output")).toBeInTheDocument();
expect(summary.closest("details")!.open).toBe(false);
expect(screen.getByText("think one")).toBeInTheDocument();
});
});