diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index 14173225a..d04334a2e 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -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 > * + * { diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 278ee2711..06c3e2732 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -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 && } - setFinding(false)} - /> + + setFinding(false)} + /> + {view ? (
Subagents take their instructions from the agent that started them. @@ -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, 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 (
-
- - {working ? ( - - ) : ( - - )} - {working ? "Thinking" : "Reasoning"} - + + {working ? ( + + ) : ( + + )} + {working ? "Thinking" : "Reasoning"} + + } + >
-
+
); 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 ( -
- - - {working && } - {parts.join(" · ")} - {added > 0 && +{added}} - {removed > 0 && −{removed}} - + + + {working && } + {parts.join(" · ")} + {added > 0 && +{added}} + {removed > 0 && −{removed}} + + } + >
{row.entries.map(({ index, entry }) => entry.type === "tool" ? ( @@ -1400,10 +1473,38 @@ const ProcessRun = memo(function ProcessRun({ ), )}
-
+ ); }); +/** 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 ( +
setExpanded(e.currentTarget.open)}> + {summary} + {(expanded || open || always) && children} +
+ ); +} + const ToolEntry = memo(function ToolEntry({ task, entry, @@ -1443,13 +1544,18 @@ const ToolEntry = memo(function ToolEntry({ ); return (
-
- - - {head} - + + + {head} + + } + >
{entry.output}
-
+
); }); @@ -1532,22 +1638,30 @@ function SubagentCard({
)} {entry.prompt && ( -
- - Task - + + Task + + } + >
{entry.prompt}
-
+ )} {entry.reply && ( -
- - Result - + + Result + + } + >
-
+ )} ); diff --git a/src/features/agents/sessionState.test.tsx b/src/features/agents/sessionState.test.tsx index be2c13f7e..9d69661d9 100644 --- a/src/features/agents/sessionState.test.tsx +++ b/src/features/agents/sessionState.test.tsx @@ -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(); 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(); + 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(); }); });