diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 21fd799812e4..ae5e1ff2be7d 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -161,6 +161,7 @@ import { RevealedMarkdown } from "./conversation/RevealedMarkdown"; import { transcriptRepositories, type RepositoryCache } from "./conversation/transcriptRepositories"; import { WriteKeys, type GitWriteOp } from "./changes/gitWrite"; import { ToolRows, TurnFooter, WorkedFor } from "./conversation/TurnRows"; +import { foldToggleAt, holdFold, keepFold, type FoldAnchor } from "./conversation/foldAnchor"; import { EditedFilesCard } from "./conversation/EditedFilesCard"; import { loadHighlightedCode } from "./conversation/CodeBlock"; import { loadEditDiffHunks } from "./conversation/EditDiff"; @@ -1951,6 +1952,9 @@ export function VirtualTranscript({ // while the pointer rests on the same row. const pointedRows = useRef(undefined); const scrolledTo = useRef({ top: 0, atLatest: false }); + /// The disclosure line the reader just clicked, held where they clicked it while its fold plays + /// (conversation/foldAnchor.ts). + const foldAnchor = useRef(undefined); /// Where the reader last saw it rest, measured in view; undefined until it is. const seenAnchor = useRef(undefined); /// When the reader last moved the transcript themselves (wheel, pointer, touch or key). @@ -2273,7 +2277,16 @@ export function VirtualTranscript({ if (rewrapped) reflowing.current = true; else if (rows.length !== oldRows.length || rows.some((row, index) => row !== oldRows[index])) reflowing.current = false; - if (old && node) { + // A disclosure the reader clicked stays where they clicked it while what it opens lays out: this + // pass neither follows the latest row nor keeps the top row, it puts the clicked line back. + const folding = Boolean(old && node && foldAnchor.current && keepFold(foldAnchor.current, node)); + if (!folding) foldAnchor.current = undefined; + if (folding && node) { + // What opened pushed the latest row out of view: the view is the reader's now, not following. + if (followingRef.current && node.scrollTop < layout.totalHeight - node.clientHeight - FOLLOW_BOTTOM_DEADBAND_PX) + follow(false); + glideHold.current = undefined; + } else if (old && node) { // Content that shrank under the viewport has already clamped the live offset to // the new end; the offset recorded before this commit is where the reader was. // A clamp lands exactly on the scroller's own end, which rounds the layout's @@ -2651,8 +2664,12 @@ export function VirtualTranscript({ // A layout-driven scroll keeps the previous offset; a reader scroll changes it. The latter is // also what the jsdom behavior tests dispatch without a wheel event, so do not classify every // programmatic-looking event as layout work. + // A held disclosure (foldAnchor) keeps the view where it is, not at the latest row. const layoutDriven = - followingRef.current && programmatic && Math.abs(next - scrolledTo.current.top) <= FOLLOW_BOTTOM_DEADBAND_PX; + followingRef.current && + programmatic && + !foldAnchor.current && + Math.abs(next - scrolledTo.current.top) <= FOLLOW_BOTTOM_DEADBAND_PX; if (layoutDriven) { const latest = Math.max(0, layout.totalHeight - node.clientHeight); const settled = settleFollowBottom(node, latest); @@ -2728,6 +2745,23 @@ export function VirtualTranscript({ }; const inputs = ["wheel", "pointerdown", "touchstart", "keydown"] as const; for (const name of inputs) node?.addEventListener(name, reader, { passive: true }); + // A click on a disclosure line holds it where it is (foldAnchor.ts), before the row's own handler + // opens it. A glide in flight lands first, so the line is held where it rests. + const holdClicked = (event: MouseEvent) => { + const toggle = node ? foldToggleAt(event.target, node) : undefined; + if (!toggle) return; + for (const animation of thread.current?.getAnimations?.() ?? []) animation.finish(); + foldAnchor.current = holdFold(toggle); + }; + // The reader scrolling lets go of it. + const release = (event: Event) => { + const key = (event as KeyboardEvent).key; + if (key === "Enter" || key === " ") return; + foldAnchor.current = undefined; + }; + const releases = ["wheel", "touchstart", "keydown"] as const; + node?.addEventListener("click", holdClicked, { capture: true }); + for (const name of releases) node?.addEventListener(name, release, { passive: true }); // The wheel over Jump to latest scrolls the transcript (wheelPassThrough.ts). // The chat's own transcript only (a replay's sits in its own screen). const main = node?.closest(".acpmux-stage")?.parentElement?.closest(".acpmux-main"); @@ -2735,6 +2769,8 @@ export function VirtualTranscript({ return () => { clearTimeout(settle.current); for (const name of inputs) node?.removeEventListener(name, reader); + node?.removeEventListener("click", holdClicked, { capture: true }); + for (const name of releases) node?.removeEventListener(name, release); removeWheel?.(); }; }, []); diff --git a/webviews/src/agent-session/acpmux/conversation/CommandRow.tsx b/webviews/src/agent-session/acpmux/conversation/CommandRow.tsx index 86ef16809bae..0957047da026 100644 --- a/webviews/src/agent-session/acpmux/conversation/CommandRow.tsx +++ b/webviews/src/agent-session/acpmux/conversation/CommandRow.tsx @@ -11,6 +11,7 @@ import { ShellBlock } from "./ShellBlock"; import { Check, ChevronRight, Spinner, TerminalSquare } from "./icons"; import { isFailed, isRunning, toolDuration } from "./toolGroups"; import { useTranscriptDisclosure } from "./transcriptDisclosure"; +import { useFold } from "./fold"; export function CommandRow({ item }: { item: AcpmuxActivity }) { const t = useT(); @@ -20,6 +21,7 @@ export function CommandRow({ item }: { item: AcpmuxActivity }) { useMemo(() => [item], [item]), () => setOpen(true), ); + const fold = useFold(open); const detailsId = useId(); const running = isRunning(tool); const failed = isFailed(tool); @@ -66,13 +68,15 @@ export function CommandRow({ item }: { item: AcpmuxActivity }) { className={`cv-tool__chevron cv-rotor${open ? " is-open" : " is-hover"}`} /> - {open && ( - + {fold.present && ( +
+ +
)} ); diff --git a/webviews/src/agent-session/acpmux/conversation/McpCallRows.tsx b/webviews/src/agent-session/acpmux/conversation/McpCallRows.tsx index b1cf437ed20f..2ad3e95b1802 100644 --- a/webviews/src/agent-session/acpmux/conversation/McpCallRows.tsx +++ b/webviews/src/agent-session/acpmux/conversation/McpCallRows.tsx @@ -1,7 +1,7 @@ // Calls to one MCP tool, drawn as one line that says what they did ("Sent 4 messages to // Coordination hub"). Several calls open to a row each; a call opens to its request and response, -// pretty-printed (mcpCalls.ts). Nothing of a call's JSON is drawn until it is opened, and opening -// draws it at once, with no animation. +// pretty-printed (mcpCalls.ts). Nothing of a call's JSON is drawn until it is opened; opening and +// closing play the transcript's fold (fold.ts). import { useFindReveal } from "./chatFind"; import { useContext, useId, useMemo, useState } from "react"; import { useT } from "../i18n"; @@ -14,6 +14,7 @@ import { isFailed, isRunning } from "./toolGroups"; import { OutputMore, useOutput } from "./toolOutput"; import { ToolImages } from "./ToolRow"; import { useTranscriptDisclosure } from "./transcriptDisclosure"; +import { useFold } from "./fold"; export function McpCallRows({ items, disclosureKey }: { items: readonly AcpmuxActivity[]; disclosureKey: string }) { return items.length === 1 ? ( @@ -28,6 +29,7 @@ function McpGroupRow({ items, disclosureKey }: { items: readonly AcpmuxActivity[ const names = useContext(McpNamesContext); const [open, setOpen] = useTranscriptDisclosure(disclosureKey); useFindReveal(items, () => setOpen(true)); + const fold = useFold(open); const detailsId = useId(); const calls = useMemo(() => items.map((item) => mcpCall(item.tool!)!), [items]); const running = items.some((item) => isRunning(item.tool!)); @@ -54,11 +56,13 @@ function McpGroupRow({ items, disclosureKey }: { items: readonly AcpmuxActivity[ className={`cv-tool__chevron cv-rotor${open ? " is-open" : " is-hover"}`} /> - {open && ( -
- {items.map((item, index) => ( - - ))} + {fold.present && ( +
+
+ {items.map((item, index) => ( + + ))} +
)} @@ -77,6 +81,7 @@ function McpCallRow({ item }: { item: AcpmuxActivity }) { setReveal((count) => count + 1); }, ); + const fold = useFold(open); const detailsId = useId(); const tool = item.tool!; const call = useMemo(() => mcpCall(tool)!, [tool]); @@ -108,7 +113,11 @@ function McpCallRow({ item }: { item: AcpmuxActivity }) { /> {images.length > 0 && } - {open && } + {fold.present && ( +
+ +
+ )} ); } diff --git a/webviews/src/agent-session/acpmux/conversation/ThoughtRow.test.tsx b/webviews/src/agent-session/acpmux/conversation/ThoughtRow.test.tsx index d19ce42d2641..e4373779f09b 100644 --- a/webviews/src/agent-session/acpmux/conversation/ThoughtRow.test.tsx +++ b/webviews/src/agent-session/acpmux/conversation/ThoughtRow.test.tsx @@ -14,7 +14,6 @@ import { JSDOM } from "jsdom"; import type { AcpmuxActivity } from "../model"; import { THOUGHT_FOLD_MIN_PX, - THOUGHT_FOLD_MS, THOUGHT_LIVE_PX, ThoughtRow, thoughtDuration, @@ -23,6 +22,7 @@ import { type ThoughtFoldThreshold, type ThoughtHeader, } from "./ThoughtRow"; +import { foldMotion } from "./fold"; let dom: JSDOM | undefined; let root: Root | undefined; @@ -30,6 +30,9 @@ let container: HTMLElement; const scope = globalThis as Record; const saved = new Map(); const layout = { ...thoughtLayout }; +const motion = { ...foldMotion }; +/// The fold's duration token as the page resolves it (conversation.css `--cv-fold-duration`). +const FOLD_MS = 190; /// The thought's drawn height, the transcript viewport's, the Debug toggle and Reduce Motion. let height = 0; let viewport = 600; @@ -81,7 +84,9 @@ beforeEach(() => { thoughtLayout.height = () => height; thoughtLayout.viewport = () => viewport; thoughtLayout.enabled = () => enabled; - thoughtLayout.reducedMotion = () => reduce; + foldMotion.reducedMotion = () => reduce; + foldMotion.duration = () => FOLD_MS; + foldMotion.easing = () => "ease-out"; container = dom.window.document.getElementById("root")!; }); @@ -89,6 +94,7 @@ afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; Object.assign(thoughtLayout, layout); + Object.assign(foldMotion, motion); dom?.window.close(); dom = undefined; for (const [key, value] of saved) { @@ -289,16 +295,17 @@ test("a thought the reader opened stays open as new content arrives and after it expect(body()?.textContent).toBe(LONG); }); -test("the reader's toggle animates the height over 200 ms, closing after the text slides away", () => { +test("the reader's toggle plays the shared fold, height and opacity, closing after the text slides away", () => { height = 900; render(thought(), false, "animated:0"); click(); expect(animations.length).toBe(1); - expect(animations[0]!.options.duration).toBe(THOUGHT_FOLD_MS); - expect(animations[0]!.keyframes[0]).toEqual({ height: "0px" }); + expect(animations[0]!.options.duration).toBe(FOLD_MS); + expect(animations[0]!.keyframes[0]).toEqual({ height: "0px", opacity: 0 }); + expect(animations[0]!.keyframes.at(-1)!.opacity).toBe(1); click(); expect(animations.length).toBe(2); - expect(animations[1]!.keyframes.at(-1)).toEqual({ height: "0px" }); + expect(animations[1]!.keyframes.at(-1)).toEqual({ height: "0px", opacity: 0 }); // The text stays drawn while it closes, then goes. expect(body()).not.toBeNull(); animations[1]!.finish(); diff --git a/webviews/src/agent-session/acpmux/conversation/ThoughtRow.tsx b/webviews/src/agent-session/acpmux/conversation/ThoughtRow.tsx index 12b474568c3c..1c8b3988ebfd 100644 --- a/webviews/src/agent-session/acpmux/conversation/ThoughtRow.tsx +++ b/webviews/src/agent-session/acpmux/conversation/ThoughtRow.tsx @@ -3,7 +3,7 @@ import type { AcpmuxActivity } from "../model"; import { useT } from "../i18n"; import { useFindReveal } from "./chatFind"; import { ChevronRight } from "./icons"; -import { isAgentMotionOff } from "../../shared/motion"; +import { useFold } from "./fold"; import { formatDuration } from "./turns"; /// With the live viewport off, a done thought folds once it is taller than the transcript's @@ -14,8 +14,6 @@ export const THOUGHT_FOLD_MIN_PX = 400; /// A long thought still streaming holds a viewport this tall: seven of its 20px lines, the 8.75rem /// Codex gives a live reasoning trace. conversation.css caps `.is-live` at the same. export const THOUGHT_LIVE_PX = 140; -/// How long the reader's open or close takes (AI Elements' Trace: `transition-[height] duration-200`). -export const THOUGHT_FOLD_MS = 200; /// What the row reads from the page; tests swap these. export const thoughtLayout = { @@ -44,8 +42,6 @@ export const thoughtLayout = { contentHeight: (node: HTMLElement) => node.scrollHeight, /// The transcript scroller's height (VirtualTranscript's `.acpmux-scroll`). viewport: (node: HTMLElement) => node.closest(".acpmux-scroll")?.clientHeight ?? 0, - reducedMotion: () => - typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true, }; export type ThoughtHeader = "duration" | "preview"; @@ -77,7 +73,7 @@ export function thoughtPreview(text: string): string { /// folded line reads "Thought for 12s" (an undated thought, or one under a second, reads /// "Thought"), or with the Debug choice "preview", "Thought" and its first line. The reader's /// toggle always wins over the fold (`choice ?? !folds`). The fold snaps; the reader's toggle -/// animates the height, except under Reduce Motion. +/// plays the transcript's fold (fold.ts), except under Reduce Motion. export function ThoughtRow({ item, id, @@ -92,9 +88,7 @@ export function ThoughtRow({ const enabled = thoughtLayout.enabled(); const [long, setLong] = useState(false); const [choice, setChoice] = useState(() => (id ? choices.get(id) : undefined)); - const [closing, setClosing] = useState(false); const full = useRef(null); - const body = useRef(null); const toggled = useRef(false); const bodyId = useId(); const folds = enabled && long && !streaming; @@ -102,6 +96,8 @@ export function ThoughtRow({ const [tall, setTall] = useState(false); const clipped = liveViewport && tall; const open = choice ?? !folds; + // Only the reader's toggle plays the fold; the automatic fold, and Find opening it, snap. + const fold = useFold(open, () => toggled.current); const choose = (next: boolean) => { setChoice(next); if (id) choices.set(id, next); @@ -128,21 +124,9 @@ export function ThoughtRow({ setLong(drawn > limit); if (liveViewport) setTall(drawn > THOUGHT_LIVE_PX); }, [enabled, text, liveViewport]); + // After the fold's own layout effect has read it. useLayoutEffect(() => { - if (!toggled.current) return; toggled.current = false; - const node = body.current; - if (isAgentMotionOff() || !node || typeof node.animate !== "function" || thoughtLayout.reducedMotion()) { - setClosing(false); - return; - } - for (const running of node.getAnimations?.() ?? []) running.cancel(); - const height = `${node.scrollHeight}px`; - const animation = node.animate(open ? [{ height: "0px" }, { height }] : [{ height }, { height: "0px" }], { - duration: THOUGHT_FOLD_MS, - easing: "ease-out", - }); - if (!open) animation.onfinish = () => setClosing(false); }, [open]); if (!text) return null; if (!folds) @@ -162,7 +146,6 @@ export function ThoughtRow({ ms !== undefined && ms >= 1000 ? t("tools.thoughtFor", { time: formatDuration(ms) }) : t("tools.thought"); const toggle = () => { toggled.current = true; - if (open && !thoughtLayout.reducedMotion() && !isAgentMotionOff()) setClosing(true); choose(!open); }; return ( @@ -188,9 +171,11 @@ export function ThoughtRow({ className={`cv-tool__chevron cv-rotor${open ? " is-open" : " is-hover"}`} /> - {(open || closing) && ( -
- {text} + {fold.present && ( +
+
+ {text} +
)} diff --git a/webviews/src/agent-session/acpmux/conversation/ToolGroupRow.tsx b/webviews/src/agent-session/acpmux/conversation/ToolGroupRow.tsx index 3005f103cb73..46a68a143671 100644 --- a/webviews/src/agent-session/acpmux/conversation/ToolGroupRow.tsx +++ b/webviews/src/agent-session/acpmux/conversation/ToolGroupRow.tsx @@ -12,6 +12,7 @@ import { ChevronRight, Magnifier, OpenBook, Pencil, Spinner, TerminalSquare } fr import { isFailed, isRunning, toolGroupLabel, type ToolGroupKind } from "./toolGroups"; import { useT } from "../i18n"; import { useTranscriptDisclosure } from "./transcriptDisclosure"; +import { useFold } from "./fold"; function groupIcon(kind: ToolGroupKind): ReactNode { switch (kind) { @@ -45,6 +46,7 @@ export function ToolGroupRow({ const t = useT(); const [open, setOpen] = useTranscriptDisclosure(disclosureKey, kind !== "edits"); const [all, setAll] = useTranscriptDisclosure(`${disclosureKey}:all`); + const fold = useFold(open); const hidden = kind === "edits" || all ? 0 : Math.max(0, items.length - GROUP_TAIL); useFindReveal(items, () => setOpen(true)); // A match in a call behind "+K more" shows the whole group. @@ -91,22 +93,24 @@ export function ToolGroupRow({ className={`cv-tool__chevron cv-rotor${open ? " is-open" : " is-hover"}`} /> - {open && ( -
- {hidden > 0 && ( - - )} - {kind === "commands" - ? shown.map((item, index) => ) - : [ - ...files.map((file) => ), - // Reads and searches, and edits that carried no diff (a delete, a move), as rows. - ...shown - .filter((item) => !files.length || !item.tool!.diffs?.length) - .map((item, index) => ), - ]} + {fold.present && ( +
+
+ {hidden > 0 && ( + + )} + {kind === "commands" + ? shown.map((item, index) => ) + : [ + ...files.map((file) => ), + // Reads and searches, and edits that carried no diff (a delete, a move), as rows. + ...shown + .filter((item) => !files.length || !item.tool!.diffs?.length) + .map((item, index) => ), + ]} +
)} diff --git a/webviews/src/agent-session/acpmux/conversation/ToolRow.tsx b/webviews/src/agent-session/acpmux/conversation/ToolRow.tsx index 45c236f8aa46..5451cf24d391 100644 --- a/webviews/src/agent-session/acpmux/conversation/ToolRow.tsx +++ b/webviews/src/agent-session/acpmux/conversation/ToolRow.tsx @@ -20,6 +20,7 @@ import { BackgroundHint } from "./BackgroundHint"; import { ShellBlock } from "./ShellBlock"; import { OutputMore, useOutput } from "./toolOutput"; import { cleanText } from "./mcpCalls"; +import { useFold } from "./fold"; import { ChevronRight, Globe, Magnifier, OpenBook, Pencil, TerminalSquare, ToolGroup } from "./icons"; /// ACP tool kinds (`ToolKind`) to row glyphs. @@ -58,11 +59,12 @@ export function ToolRow({ item }: { item: AcpmuxActivity }) { setReveal((count) => count + 1); }, ); + const fold = useFold(open); const detailsId = useId(); const tool = item.tool!; const hasDiff = Boolean(tool.diffs?.length); - // Diffed only while open: a closed edit row costs nothing on each transcript update. - const files = useMemo(() => (open && hasDiff ? toolFiles([tool]) : []), [open, hasDiff, tool]); + // Diffed only while drawn (open, or closing): a closed edit row costs nothing on each transcript update. + const files = useMemo(() => (fold.present && hasDiff ? toolFiles([tool]) : []), [fold.present, hasDiff, tool]); const cwd = useContext(SessionCwdContext); const label = toolLabel(tool, item.text, (command) => t("tools.ranCommand", { command }), cwd); const running = tool.status === "pending" || tool.status === "in_progress"; @@ -106,17 +108,21 @@ export function ToolRow({ item }: { item: AcpmuxActivity }) {
{content}
)} {images.length > 0 && } - {open && shell && ( - - )} - {open && !shell && files.length > 0 && ( -
- {files.map((file) => ( - - ))} + {fold.present && (shell || files.length > 0 || body) && ( +
+ {shell ? ( + + ) : files.length > 0 ? ( +
+ {files.map((file) => ( + + ))} +
+ ) : ( + + )}
)} - {open && !shell && files.length === 0 && body && } ); } diff --git a/webviews/src/agent-session/acpmux/conversation/ToolRun.tsx b/webviews/src/agent-session/acpmux/conversation/ToolRun.tsx index 1ae161398984..77409259f878 100644 --- a/webviews/src/agent-session/acpmux/conversation/ToolRun.tsx +++ b/webviews/src/agent-session/acpmux/conversation/ToolRun.tsx @@ -7,6 +7,7 @@ import type { AcpmuxActivity } from "../model"; import { ChevronRight, Globe, Magnifier, Pencil, TerminalSquare, ToolGroup } from "./icons"; import { toolRunCategories, toolRunSummary, type ToolRunCategory } from "./toolRunSummary"; import { useTranscriptDisclosure } from "./transcriptDisclosure"; +import { useFold } from "./fold"; /// The summary's glyph: its leading category's, with searches and reads both as the magnifier. function runIcon(category: ToolRunCategory | undefined): ReactNode { @@ -37,6 +38,7 @@ export function ToolRun({ const t = useT(); const [open, setOpen] = useTranscriptDisclosure(disclosureKey); useFindReveal(items, () => setOpen(true)); + const fold = useFold(open); const list = useId(); return ( <> @@ -57,9 +59,11 @@ export function ToolRun({ className={`cv-tool__chevron cv-rotor${open ? " is-open" : " is-hover"}`} /> - {open && ( -
- {children} + {fold.present && ( +
+
+ {children} +
)} diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index 9dded8f4febe..2f29e74ed62e 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -7,6 +7,10 @@ their own cast. Percentages are measured against #f8f8f3 on #272823. */ :root { + /* The fold: a collapsible's body opening or closing, and its chevron turning (fold.ts). The host's + move duration (--agent-motion-move, AgentPaneTheme.swift: ~190ms, 0 under Reduce Motion or with + animation off), the token the newtab-resize-anim lane's resizes share; 180ms without a host. */ + --cv-fold-duration: var(--agent-motion-move, 180ms); --cv-text: var(--agent-text); --cv-text-muted: var(--agent-muted); /* "Worked for", tool verbs */ --cv-text-dim: var(--agent-soft); /* progress rows */ @@ -1449,16 +1453,22 @@ pre.cv-math-source { .cv-iconbtn:focus:not(:focus-visible) { outline: none; } -/* One right chevron, turned a quarter when open; a message head's shows on hover. */ +/* One right chevron, turned a quarter when open; a message head's shows on hover. It turns with the + fold it opens (fold.ts), on the same token. */ .cv-rotor { flex: none; } /* With Reduce Motion on, the chevron snaps to its new angle. */ @media (prefers-reduced-motion: no-preference) { .cv-rotor { - transition: transform 150ms ease-out; + transition: transform var(--cv-fold-duration) var(--agent-ease-out, ease-out); } } +/* A collapsible's body (fold.ts sizes it while it opens or closes): its own block, so the body's + margins stay inside the height the fold animates instead of popping in at its ends. */ +.cv-fold { + display: flow-root; +} .cv-rotor.is-open { transform: rotate(90deg); } diff --git a/webviews/src/agent-session/acpmux/conversation/fold.ts b/webviews/src/agent-session/acpmux/conversation/fold.ts new file mode 100644 index 000000000000..c5e9c4587343 --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/fold.ts @@ -0,0 +1,139 @@ +// The transcript's collapsibles (tool groups, tool and command rows, MCP calls, a folded thought, a +// settled run) open and close in one motion: the body's height and opacity, over the shared fold +// duration, easing out, while the chevron turns with it (conversation.css `.cv-rotor`). Reduce +// Motion, the host's animation switches (`data-agent-motion="off"`, a 0 ms token) and a page with no +// Web Animations open and close at once. The clicked line itself never moves: VirtualTranscript +// holds it in place while the fold plays (`FoldAnchor`, App.tsx). +import { useLayoutEffect, useRef, useState, type RefObject } from "react"; +import { isAgentMotionOff } from "../../shared/motion"; + +/// The fold's duration token. conversation.css sets it from the host's move duration +/// (`--agent-motion-move`: about 190 ms, 0 under Reduce Motion or with animation off), so the +/// transcript paces with the chrome's resizes and moves, and the chevron reads the same token. +export const FOLD_DURATION_VAR = "--cv-fold-duration"; +/// The token's value when the page has none (a test DOM, a stylesheet that failed to load). +export const FOLD_FALLBACK_MS = 180; + +/// A CSS time ("190ms", ".19s") in milliseconds; undefined when it is not one. +export function cssTimeMs(value: string): number | undefined { + const match = /^\s*(-?[\d.]+)(ms|s)\s*$/.exec(value); + if (!match) return undefined; + const amount = Number(match[1]); + if (!Number.isFinite(amount)) return undefined; + return Math.max(0, match[2] === "s" ? amount * 1000 : amount); +} + +/// `node`'s computed style, from its own window (a test DOM has no global one). +function styleOf(node: Element): CSSStyleDeclaration | undefined { + return node.ownerDocument.defaultView?.getComputedStyle(node); +} + +/// What the fold reads from the page; tests swap these. +export const foldMotion = { + /// The fold's duration on `node`, in ms. + duration: (node: Element): number => { + const value = styleOf(node)?.getPropertyValue(FOLD_DURATION_VAR) ?? ""; + return cssTimeMs(value) ?? FOLD_FALLBACK_MS; + }, + /// The pane's ease-out curve (`--agent-ease-out`), as the rest of the transcript's motion uses. + easing: (node: Element): string => styleOf(node)?.getPropertyValue("--agent-ease-out").trim() || "ease-out", + reducedMotion: (): boolean => + typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true, +}; + +/// Whether a fold may move at all now (before its body is drawn). +function motionAllowed(): boolean { + return !isAgentMotionOff() && !foldMotion.reducedMotion(); +} + +/// How long a fold on `node` plays, or 0 when it applies at once. +export function foldMs(node: HTMLElement | null): number { + if (!node || typeof node.animate !== "function" || !motionAllowed()) return 0; + return foldMotion.duration(node); +} + +const always = () => true; + +/// Holds `node` shut (height and opacity 0, its body clipped), or with `undefined`, lets it take its +/// own size again. +function pin(node: HTMLElement, value: 0 | undefined): void { + node.style.height = value === undefined ? "" : "0px"; + node.style.opacity = value === undefined ? "" : "0"; + node.style.overflow = value === undefined ? "" : "hidden"; +} + +export type Fold = { + /// Draw the body: while open, and while it closes. + present: boolean; + /// The body's box (`.cv-fold`), which the motion sizes. + ref: RefObject; +}; + +/// A collapsible's body: `present` while it is open or closing, and the ref for its `.cv-fold` box. +/// Each change of `open` after the first render plays the fold when `animates()` says so (a folded +/// thought plays only the reader's toggle, not its automatic fold). A fold that turns back midway +/// starts from where it was drawn. +export function useFold(open: boolean, animates: () => boolean = always): Fold { + const ref = useRef(null); + const [closing, setClosing] = useState(false); + const [shownOpen, setShownOpen] = useState(open); + if (shownOpen !== open) { + setShownOpen(open); + // A body that slides away stays drawn until it has; one that does not goes now. + setClosing(!open && motionAllowed() && animates()); + } + const running = useRef(undefined); + // The `open` the box was last drawn for: the first render, and a StrictMode re-run, play nothing. + const played = useRef(open); + useLayoutEffect(() => { + if (played.current === open) return; + played.current = open; + const node = ref.current; + // A box a close left pinned shut (below) opens from its own size again. + if (node) pin(node, undefined); + const ms = animates() ? foldMs(node) : 0; + if (!node || ms <= 0) { + running.current?.cancel(); + running.current = undefined; + setClosing(false); + return; + } + // From where the box is drawn now: midway through the other fold, else closed or open. An open + // ends at the height measured here and then lets go of it (no fill), so the body takes its own + // height again, content that loaded meanwhile included. + const turning = running.current !== undefined; + const fromHeight = turning || !open ? node.getBoundingClientRect().height : 0; + const fromOpacity = turning ? Number(styleOf(node)?.opacity ?? 0) || 0 : open ? 0 : 1; + running.current?.cancel(); + const toHeight = open ? node.scrollHeight : 0; + node.style.overflow = "hidden"; + const animation = node.animate( + [ + { height: `${fromHeight}px`, opacity: fromOpacity }, + { height: `${toHeight}px`, opacity: open ? 1 : 0 }, + ], + { duration: ms, easing: foldMotion.easing(node) }, + ); + running.current = animation; + animation.onfinish = () => { + if (running.current !== animation) return; + running.current = undefined; + if (open) { + node.style.overflow = ""; + return; + } + // The animation lets go of the box as it ends, and React removes the body only on its next + // commit, after that frame paints: pinned shut until then, the body never flashes open. + pin(node, 0); + setClosing(false); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- only a change of `open` plays the fold + }, [open]); + useLayoutEffect( + () => () => { + running.current?.cancel(); + }, + [], + ); + return { present: open || closing, ref }; +} diff --git a/webviews/src/agent-session/acpmux/conversation/foldAnchor.ts b/webviews/src/agent-session/acpmux/conversation/foldAnchor.ts new file mode 100644 index 000000000000..623cd3e90059 --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/foldAnchor.ts @@ -0,0 +1,47 @@ +// Opening or closing anything in the transcript never moves the reader's view: the line they +// clicked (a tool group, a tool, command or MCP call, a folded thought, a settled run, "Worked for", +// a subagent group, a failure's Details) stays where it was on screen while what it opens or closes +// lays out and its fold plays (fold.ts). VirtualTranscript holds it (App.tsx): each placement pass +// during the hold moves the offset by however far the line moved, and none of them follows the +// latest row meanwhile. +// +// Followed to the latest row, the opened body grows down from the clicked line, so the view no +// longer shows the latest row and stops following it (Jump to latest shows); a body that still +// leaves the latest row in view keeps following. Closing at the transcript's end is the one place the +// line can move: with nothing below to fill the view, the browser clamps the offset and the rows +// above come down by what closed, as they do in every scroller. +import { foldMs } from "./fold"; + +/// The disclosures a click holds in place. +export const FOLD_TOGGLE = "button.is-toggle[aria-expanded], button.cv-subagents[aria-expanded], summary"; +/// How long a hold outlasts its fold: the last frame's measure, and rows a "Worked for" inserts. +export const FOLD_SETTLE_MS = 150; + +export type FoldAnchor = { + toggle: HTMLElement; + /// Where the line was drawn when clicked (its viewport top). + top: number; + /// When the hold ends (performance.now()). + until: number; +}; + +/// The disclosure line `target` is on, inside one of `scroller`'s rows; undefined for anything else. +export function foldToggleAt(target: EventTarget | null, scroller: HTMLElement): HTMLElement | undefined { + const element = target && typeof (target as Element).closest === "function" ? (target as Element) : null; + const toggle = element?.closest(FOLD_TOGGLE); + return toggle && scroller.contains(toggle) && toggle.closest(".acpmux-row") ? toggle : undefined; +} + +/// Holds `toggle` where it is drawn now, for its fold and a little after. +export function holdFold(toggle: HTMLElement, now = performance.now()): FoldAnchor { + return { toggle, top: toggle.getBoundingClientRect().top, until: now + foldMs(toggle) + FOLD_SETTLE_MS }; +} + +/// Puts the held line back where it was clicked by moving `scroller`'s offset. False once the hold +/// is over (its time is up, or the line left the transcript), and the view is the reader's again. +export function keepFold(anchor: FoldAnchor, scroller: HTMLElement, now = performance.now()): boolean { + if (now > anchor.until || !anchor.toggle.isConnected) return false; + const moved = anchor.toggle.getBoundingClientRect().top - anchor.top; + if (Math.abs(moved) >= 0.5) scroller.scrollTop += moved; + return true; +} diff --git a/webviews/src/agent-session/acpmux/conversation/transcript.gallery.ts b/webviews/src/agent-session/acpmux/conversation/transcript.gallery.ts index b2c44ce9c005..168a61f515f1 100644 --- a/webviews/src/agent-session/acpmux/conversation/transcript.gallery.ts +++ b/webviews/src/agent-session/acpmux/conversation/transcript.gallery.ts @@ -139,6 +139,21 @@ const openGroupedShells = async (ctx: PlayContext) => { await ctx.waitFor(() => Boolean(ctx.document.querySelector(".cv-shell__exit"))); }; +/// Clicks the disclosure line `selector` finds and fails when the line moved once its fold settled: +/// opening or closing never moves the reader's view (foldAnchor.ts, Leo 2026-10-20). +const clickHoldsLine = async (ctx: PlayContext, selector: string, expanded: boolean) => { + const top = () => ctx.find({ selector }).getBoundingClientRect().top; + const before = top(); + await ctx.click({ selector }); + await ctx.waitFor( + () => + ctx.find({ selector }).getAttribute("aria-expanded") === String(expanded) && + [...ctx.document.querySelectorAll(".cv-fold")].every((fold) => fold.getAnimations().length === 0), + ); + const moved = top() - before; + if (Math.abs(moved) > 1) throw new Error(`the clicked line moved ${moved.toFixed(1)}px`); +}; + const shellDisclosureChecks = { layoutShiftMax: { value: 0.25, @@ -1574,6 +1589,18 @@ export default agentPaneEntry({ snapshot: chat(groupedShellRows), play: openGroupedShells, }, + "fold-holds-clicked-line": { + note: "Play: at the latest row, open Worked for, then a command, then close it; each line stays where it was clicked while its fold plays (height and opacity, the chevron turning).", + checks: shellDisclosureChecks, + snapshot: chat([...longChatRows.slice(-12), ...groupedShellRows]), + play: async (ctx) => { + await clickHoldsLine(ctx, ".cv-worked", true); + await ctx.waitFor(() => ctx.document.querySelectorAll(".cv-command").length === 2); + await clickHoldsLine(ctx, ".cv-command", true); + await ctx.waitFor(() => Boolean(ctx.document.querySelector(".cv-shell__command"))); + await clickHoldsLine(ctx, ".cv-command", false); + }, + }, }, }); diff --git a/webviews/test/browser/transcript-follow.harness.tsx b/webviews/test/browser/transcript-follow.harness.tsx index 3da3115e098e..e2ef97b8a2d1 100644 --- a/webviews/test/browser/transcript-follow.harness.tsx +++ b/webviews/test/browser/transcript-follow.harness.tsx @@ -35,12 +35,23 @@ document.body.style.margin = "0"; Object.assign(root.style, { height: "100%", display: "flex", flexDirection: "column" }); const view = createRoot(root); -(window as unknown as { __showRows: (rows: AcpmuxRow[]) => void }).__showRows = (rows) => +// "Worked for" lines open and close as the pane's do: a click toggles the turn in `expanded`. +let shown: AcpmuxRow[] = []; +let expanded = new Set(); +const draw = () => view.render( {}} - expanded={new Set()} + onToggleActivity={(id) => { + expanded = new Set(expanded); + if (!expanded.delete(id)) expanded.add(id); + draw(); + }} + expanded={expanded} />, ); +(window as unknown as { __showRows: (rows: AcpmuxRow[]) => void }).__showRows = (rows) => { + shown = rows; + draw(); +}; diff --git a/webviews/test/transcript-fold-anchor-browser.test.ts b/webviews/test/transcript-fold-anchor-browser.test.ts new file mode 100644 index 000000000000..43270514d956 --- /dev/null +++ b/webviews/test/transcript-fold-anchor-browser.test.ts @@ -0,0 +1,193 @@ +// l10n-allow-file: test fixture prompts, answers and tool titles, not shipped UI. +// Opening or closing a tool call, or a turn's "Worked for", never moves the reader's view (Leo +// 2026-10-20), in a real layout: while the view follows a streaming reply, the clicked line stays +// on the pixel it was clicked at through every frame of its fold, and the reply streaming on below +// does not pull it away. The fold plays (a height animation on `.cv-fold`) except under Reduce Motion. +// +// Real Chromium from Playwright; CI fails the file when it cannot launch (requireBrowserLane). +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import path from "node:path"; +import { chromium, type Browser, type Page } from "playwright"; +import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; + +await requireBrowserLane("transcript-fold-anchor-browser.test.ts", async () => { + setDefaultTimeout(120_000); + try { + await (await chromium.launch({ headless: true })).close(); + } catch { + console.warn("transcript-fold-anchor-browser: skipping (run `bunx playwright install chromium`)"); + describe.skip("transcript fold anchor", () => test("chromium", () => {})); + return; + } + const { createServer } = await import("vite"); + const webviews = path.resolve(import.meta.dir, ".."); + let vite: Awaited> | undefined; + let browser: Browser | undefined; + beforeAll(async () => { + vite = await createServer({ + root: webviews, + configFile: path.join(webviews, "vite.config.ts"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", + }); + await vite.listen(); + await warmViteServer(vite, "/test/browser/transcript-follow.html"); + browser = await chromium.launch({ headless: true }); + }); + afterAll(async () => { + await browser?.close(); + await vite?.close(); + }); + + type Row = { id: string; kind: string; text?: string; version: number; at: number; [key: string]: unknown }; + let at = 0; + const row = (id: string, kind: string, text?: string, extra: Record = {}): Row => ({ + id, + kind, + text, + version: 1, + at: (at += 1_000), + ...extra, + }); + const output = (lines: number) => + Array.from({ length: lines }, (_, index) => `line ${index + 1}: the keeper trimmed the wick`).join("\n"); + const read = (id: string, lines = 30) => ({ + kind: "tool", + text: "", + tool: { id, title: `Read src/${id}.ts`, kind: "read", status: "completed", output: output(lines) }, + }); + const paragraphs = (count: number) => + Array.from( + { length: count }, + (_, index) => + `Paragraph ${index}. The keeper climbed to the gallery and wiped the salt from the lantern glass, ` + + "then watched the light sweep the water for a while before going back down.", + ).join("\n\n"); + /// A finished turn that worked first: its "Worked for" line opens to the calls. + const turn = (index: number): Row[] => [ + row(`t${index}-user`, "user", `Question ${index}`), + row(`t${index}-run`, "activity", undefined, { items: [read(`t${index}-a`), read(`t${index}-b`)] }), + row(`t${index}-answer`, "assistant", paragraphs(2)), + row(`t${index}-end`, "turnSummary", undefined, { status: "completed", durationMs: 9_000, toolCount: 2 }), + ]; + const done = Array.from({ length: 8 }, (_, index) => turn(index)).flat(); + /// The turn still running: a call with output, then the reply streaming under it. + const live = (version: number): Row[] => [ + row("live-user", "user", "Read the lantern log and summarize it."), + row("live-run", "activity", undefined, { items: [read("live-log", 40)] }), + row("live-answer", "assistant", paragraphs(version), { version, streaming: true }), + ]; + + const show = (page: Page, rows: Row[]) => + page.evaluate((shown) => (window as unknown as { __showRows(rows: unknown): void }).__showRows(shown), rows); + const settle = async (page: Page) => { + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await page.waitForTimeout(400); + }; + const atLatest = (page: Page) => + page.evaluate(() => { + const node = document.querySelector(".acpmux-scroll")!; + return node.scrollHeight - node.clientHeight - node.scrollTop <= 2; + }); + /// Marks the line `selector` finds last in the transcript, and returns where it is drawn. + const probe = (page: Page, selector: string) => + page.evaluate((query) => { + const lines = [...document.querySelectorAll(query)]; + const line = lines.at(-1)!; + line.dataset.probe = "true"; + const box = line.getBoundingClientRect(); + return { top: box.top, x: box.left + 24, y: box.top + box.height / 2 }; + }, selector); + /// Clicks the marked line, then follows it every frame for `ms`: how far it ever moved, and + /// whether a fold was playing at some frame. + const clickAndWatch = async (page: Page, ms = 450) => { + const point = await page.evaluate(() => { + const box = document.querySelector("[data-probe]")!.getBoundingClientRect(); + return { x: box.left + 24, y: box.top + box.height / 2, top: box.top }; + }); + const watching = page.evaluate( + ({ duration, from }) => + new Promise<{ moved: number; folded: boolean }>((resolve) => { + const start = performance.now(); + let moved = 0; + let folded = false; + const step = () => { + const line = document.querySelector("[data-probe]"); + if (line) moved = Math.max(moved, Math.abs(line.getBoundingClientRect().top - from)); + for (const fold of document.querySelectorAll(".cv-fold")) + if (fold.getAnimations().length > 0) folded = true; + if (performance.now() - start < duration) requestAnimationFrame(step); + else resolve({ moved, folded }); + }; + requestAnimationFrame(step); + }), + { duration: ms, from: point.top }, + ); + await page.mouse.click(point.x, point.y); + return { ...(await watching), top: point.top }; + }; + const probedTop = (page: Page) => + page.evaluate(() => document.querySelector("[data-probe]")!.getBoundingClientRect().top); + const open = async (reducedMotion: "reduce" | "no-preference") => { + const { port } = vite!.httpServer!.address() as { port: number }; + const page = await browser!.newPage({ viewport: { width: 900, height: 600 } }); + await page.emulateMedia({ reducedMotion }); + await page.goto(`http://127.0.0.1:${port}/test/browser/transcript-follow.html`); + await page.waitForFunction(() => "__showRows" in window); + return page; + }; + + test("a tool call opened while the reply is followed stays on its pixel, and the reply streaming on leaves it there", async () => { + const page = await open("no-preference"); + await show(page, [...done, ...live(1)]); + await settle(page); + expect(await atLatest(page)).toBe(true); + await probe(page, `[data-row-id="live-run"] button.cv-tool.is-toggle[aria-expanded="false"]`); + + const opened = await clickAndWatch(page); + expect(await page.locator("[data-probe]").getAttribute("aria-expanded")).toBe("true"); + expect(opened.folded).toBe(true); + expect(opened.moved).toBeLessThanOrEqual(1); + // The output opened below the line, out past the view's end: the view stopped following. + expect(await atLatest(page)).toBe(false); + + // The reply streams on; the reader's place holds. + for (let version = 2; version < 6; version += 1) { + await show(page, [...done, ...live(version)]); + await settle(page); + } + expect(Math.abs((await probedTop(page)) - opened.top)).toBeLessThanOrEqual(1); + + // Closed again, away from the end, it stays where it is too. + const closed = await clickAndWatch(page); + expect(await page.locator("[data-probe]").getAttribute("aria-expanded")).toBe("false"); + expect(closed.moved).toBeLessThanOrEqual(1); + await page.close(); + }); + + test("a finished turn's Worked for opened at the latest row stays on its pixel", async () => { + const page = await open("no-preference"); + await show(page, done); + await settle(page); + expect(await atLatest(page)).toBe(true); + await probe(page, `[data-row-id="worked-t7-user"] button.cv-worked`); + const opened = await clickAndWatch(page); + expect(await page.locator("[data-probe]").getAttribute("aria-expanded")).toBe("true"); + expect(opened.moved).toBeLessThanOrEqual(1); + expect(await page.locator('[data-row-id="t7-run"]').count()).toBe(1); + await page.close(); + }); + + test("under Reduce Motion a tool call opens at once, and still stays on its pixel", async () => { + const page = await open("reduce"); + await show(page, [...done, ...live(1)]); + await settle(page); + await probe(page, `[data-row-id="live-run"] button.cv-tool.is-toggle[aria-expanded="false"]`); + const opened = await clickAndWatch(page, 250); + expect(await page.locator("[data-probe]").getAttribute("aria-expanded")).toBe("true"); + expect(opened.folded).toBe(false); + expect(opened.moved).toBeLessThanOrEqual(1); + await page.close(); + }); +}); diff --git a/webviews/test/transcript-fold-anchor.test.tsx b/webviews/test/transcript-fold-anchor.test.tsx new file mode 100644 index 000000000000..fc8e3f3c612f --- /dev/null +++ b/webviews/test/transcript-fold-anchor.test.tsx @@ -0,0 +1,192 @@ +// l10n-allow-file: test fixture prompts, answers and tool titles, not shipped UI. +// Opening or closing anything in the transcript never moves the reader's view (Leo 2026-10-20: +// "Expanding/collapsing any tool call or collapsible must NOT scroll the user's view: anchor the +// clicked row in place"). Followed to the latest row, opening the last turn's "Worked for" used to +// put the view at the new latest row, so the line the reader clicked jumped up by everything it +// opened. jsdom has no layout: a row is drawn at its translateY, and an element in it at the row's +// top less the scroller's offset. +import { afterAll, expect, test } from "bun:test"; +import { JSDOM, VirtualConsole } from "jsdom"; +import type { AcpmuxRow } from "../src/agent-session/acpmux/model"; +import { turnView, WORKED } from "../src/agent-session/acpmux/conversation/turns"; + +const dom = new JSDOM("
", { + url: "http://localhost/", + pretendToBeVisual: true, + virtualConsole: new VirtualConsole(), +}); +const scope = globalThis as Record; +const replacements: Record = { + window: dom.window, + document: dom.window.document, + navigator: dom.window.navigator, + HTMLElement: dom.window.HTMLElement, + Element: dom.window.Element, + customElements: dom.window.customElements, + Node: dom.window.Node, + MutationObserver: dom.window.MutationObserver, + IntersectionObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), + IS_REACT_ACT_ENVIRONMENT: true, +}; +// Pierre's registered elements read their own DOM constructors at import time. +for (const key of Object.getOwnPropertyNames(dom.window)) + if (/^(HTML|SVG|CSS|Shadow|Document|Mutation)/.test(key) && !(key in scope)) + replacements[key] = (dom.window as unknown as Record)[key]; +const saved = Object.keys(replacements).map((key) => [key, key in scope, scope[key]] as const); +Object.assign(scope, replacements); + +const VIEWPORT = 600; +Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", { configurable: true, get: () => VIEWPORT }); +Object.defineProperty(dom.window.HTMLElement.prototype, "clientWidth", { configurable: true, get: () => 760 }); +const offsets = new WeakMap(); +Object.defineProperty(dom.window.HTMLElement.prototype, "scrollTop", { + configurable: true, + get(this: object) { + return offsets.get(this) ?? 0; + }, + set(this: object, value: number) { + offsets.set(this, value); + }, +}); +const scroller = () => dom.window.document.querySelector(".acpmux-scroll")!; +/// Where an element in a row is drawn in the viewport: its row's place less the offset. Rows report +/// no drawn height (0 is "not laid out"), so they place by their estimates. +dom.window.HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) { + const row = this.closest(".acpmux-row"); + const at = row ? Number(/translateY\(([-\d.]+)px\)/.exec(row.style.transform)?.[1] ?? 0) : 0; + const top = row ? at - (scroller()?.scrollTop ?? 0) : 0; + return { top, bottom: top, left: 0, right: 0, width: 0, height: 0, x: 0, y: top, toJSON() {} } as DOMRect; +}; + +// The page's clock, which the test moves on past a hold (foldAnchor.ts) instead of waiting it out. +const clock = performance.now.bind(performance); +let skew = 0; +performance.now = () => clock() + skew; + +const { act } = await import("react"); +const { createRoot } = await import("react-dom/client"); +const { VirtualTranscript } = await import("../src/agent-session/acpmux/App"); + +afterAll(() => { + performance.now = clock; + dom.window.close(); + for (const [key, had, value] of saved) { + if (had) scope[key] = value; + else delete scope[key]; + } +}); + +let at = 0; +const row = (id: string, kind: string, extra: Partial = {}): AcpmuxRow => ({ + id, + kind, + version: 1, + at: (at += 1_000), + ...extra, +}); +const call = (id: string) => ({ + kind: "tool", + text: "", + tool: { id, title: `Read src/${id}.ts`, kind: "read", status: "completed", output: `contents of ${id}` }, +}); +/// A turn that worked before it answered: its "Worked for" line opens to the calls. +const turn = (index: number) => [ + row(`t${index}-user`, "user", { text: `Question ${index}` }), + row(`t${index}-run`, "activity", { items: Array.from({ length: 8 }, (_, step) => call(`t${index}-${step}`)) }), + row(`t${index}-answer`, "assistant", { text: `Answer ${index}.\n\nWith a second paragraph so the turn has height.` }), + row(`t${index}-end`, "turnSummary", { status: "completed", toolCount: 8 }), +]; +const source = Array.from({ length: 12 }, (_, index) => turn(index)).flat(); +const workedId = (index: number) => `${WORKED}-t${index}-user`; + +/// The transcript as the pane draws it: a click on "Worked for" toggles it in `expanded`. +function mount(initial: Set) { + const root = createRoot(dom.window.document.getElementById("root")!); + let expanded = initial; + const render = () => + root.render( + { + const next = new Set(expanded); + if (next.has(id)) next.delete(id); + else next.add(id); + expanded = next; + render(); + }} + />, + ); + return { root, render }; +} +const line = (index: number) => + dom.window.document.querySelector(`[data-row-id="${workedId(index)}"] button.cv-worked`)!; +const latest = () => + parseFloat(dom.window.document.querySelector(".acpmux-spacer")!.style.height) - VIEWPORT; + +test("opening the last turn's Worked for while the view follows the latest row leaves the line where it was clicked", async () => { + const { root, render } = mount(new Set()); + try { + await act(async () => render()); + // Followed: the view rests at the latest row, the last turn's line in view. + expect(scroller().scrollTop).toBe(latest()); + const before = line(11).getBoundingClientRect().top; + expect(before).toBeGreaterThan(0); + expect(before).toBeLessThan(VIEWPORT); + const end = latest(); + + await act(async () => line(11).click()); + expect(line(11).getAttribute("aria-expanded")).toBe("true"); + // The calls opened under the line; the line did not move, and the view no longer sits at the + // (now further down) latest row. + expect(line(11).getBoundingClientRect().top).toBe(before); + expect(latest()).toBeGreaterThan(end); + expect(scroller().scrollTop).toBeLessThan(latest()); + + // A snapshot after the hold (here the same rows drawn again) does not pull the view back down. + skew += 10_000; + await act(async () => render()); + expect(line(11).getBoundingClientRect().top).toBe(before); + } finally { + await act(async () => root.unmount()); + } +}); + +test("closing an open Worked for scrolled up the transcript leaves its line where it was clicked", async () => { + const { root, render } = mount(new Set([workedId(8)])); + /// The reader scrolls to `top` (a wheel, then the browser's scroll event). + const scrollTo = async (top: number) => { + await act(async () => { + scroller().dispatchEvent(new dom.window.Event("wheel")); + scroller().scrollTop = top; + scroller().dispatchEvent(new dom.window.Event("scroll")); + }); + }; + try { + await act(async () => render()); + // Up the transcript until turn 8's open line is drawn, then with it 200 px down the view. + for (let top = latest(); !line(8) && top > 0; top -= VIEWPORT / 2) await scrollTo(top); + await scrollTo(scroller().scrollTop + line(8).getBoundingClientRect().top - 200); + const before = line(8).getBoundingClientRect().top; + expect(before).toBe(200); + expect(scroller().scrollTop).toBeLessThan(latest() - VIEWPORT); + + await act(async () => line(8).click()); + expect(line(8).getAttribute("aria-expanded")).toBe("false"); + expect(line(8).getBoundingClientRect().top).toBe(before); + } finally { + await act(async () => root.unmount()); + } +}); diff --git a/webviews/test/transcript-fold-motion.test.tsx b/webviews/test/transcript-fold-motion.test.tsx new file mode 100644 index 000000000000..6beaa04d0e02 --- /dev/null +++ b/webviews/test/transcript-fold-motion.test.tsx @@ -0,0 +1,277 @@ +// l10n-allow-file: test fixture tool titles and output, not shipped UI. +// Every collapsible in the transcript opens and closes in one motion (Leo 2026-10-20): its body's +// height and opacity over the shared fold token (`--cv-fold-duration`, the host's move duration), +// easing out, the chevron turning on the same token, and at once under Reduce Motion or with the +// pane's animation off. A body that closes stays drawn until it has slid away. +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import type { AcpmuxActivity } from "../src/agent-session/acpmux/model"; +import { cssTimeMs, FOLD_DURATION_VAR, foldMotion } from "../src/agent-session/acpmux/conversation/fold"; +import { CommandRow } from "../src/agent-session/acpmux/conversation/CommandRow"; +import { McpCallRows } from "../src/agent-session/acpmux/conversation/McpCallRows"; +import { ToolGroupRow } from "../src/agent-session/acpmux/conversation/ToolGroupRow"; +import { ToolRow } from "../src/agent-session/acpmux/conversation/ToolRow"; +import { ToolRun } from "../src/agent-session/acpmux/conversation/ToolRun"; +import { resetTranscriptDisclosure } from "../src/agent-session/acpmux/conversation/transcriptDisclosure"; + +let dom: JSDOM | undefined; +let root: Root | undefined; +let container: HTMLElement; +const scope = globalThis as Record; +const saved = new Map(); +const motion = { ...foldMotion }; +/// The fold token as the page resolves it, and Reduce Motion. +let token = 190; +let reduce = false; +/// `ends` runs the animation's onfinish as the browser does, outside React: React commits what it +/// sets only after the caller's `act` (the frame the animation let go of the box paints first). +type Animated = { keyframes: Keyframe[]; options: KeyframeAnimationOptions; finish: () => void; ends: () => void }; +let animations: Animated[] = []; + +beforeEach(() => { + dom = new JSDOM("
", { url: "http://localhost/" }); + for (const key of [ + "window", + "document", + "navigator", + "Node", + "HTMLElement", + "Element", + "localStorage", + "IS_REACT_ACT_ENVIRONMENT", + ]) + saved.set(key, scope[key]); + Object.assign(scope, { + window: dom.window, + document: dom.window.document, + navigator: dom.window.navigator, + Node: dom.window.Node, + HTMLElement: dom.window.HTMLElement, + Element: dom.window.Element, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + // JSDOM has no Web Animations: record each one, finished by the test. + (dom.window.HTMLElement.prototype as unknown as { animate: unknown }).animate = function ( + keyframes: Keyframe[], + options: KeyframeAnimationOptions, + ) { + const animation = { onfinish: null as null | (() => void), cancel() {} }; + animations.push({ + keyframes, + options, + finish: () => act(() => animation.onfinish?.()), + ends: () => animation.onfinish?.(), + }); + return animation; + }; + token = 190; + reduce = false; + animations = []; + foldMotion.duration = () => token; + foldMotion.easing = () => "cubic-bezier(0, 0, 0.58, 1)"; + foldMotion.reducedMotion = () => reduce; + resetTranscriptDisclosure(); + container = dom.window.document.getElementById("root")!; +}); + +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + root = undefined; + Object.assign(foldMotion, motion); + resetTranscriptDisclosure(); + dom?.window.close(); + dom = undefined; + for (const [key, value] of saved) { + if (value === undefined) delete scope[key]; + else scope[key] = value; + } + saved.clear(); +}); + +const tool = (id: string, extra: Record = {}): AcpmuxActivity => ({ + kind: "tool", + text: "", + tool: { id, title: `Read src/${id}.ts`, kind: "read", status: "completed", output: `contents of ${id}`, ...extra }, +}); + +/// Each collapsible, closed, and the body its toggle opens. +const collapsibles: Array<{ name: string; node: () => ReactNode; body: string }> = [ + { name: "a tool row", node: () => , body: ".cv-tool-output-card" }, + { + name: "a command row", + node: () => , + body: ".cv-shell", + }, + { + name: "an edits group", + node: () => ( + + ), + body: ".cv-tool-group", + }, + { + name: "an MCP call", + node: () => ( + + ), + body: ".cv-mcp-call", + }, + { + name: "a settled run", + node: () => ( + + + + ), + body: ".cv-tool-run", + }, +]; + +function render(node: ReactNode) { + act(() => { + root ??= createRoot(container); + root.render(node); + }); +} +const toggle = () => container.querySelector("button[aria-expanded]")!; +const click = () => act(() => toggle().click()); + +for (const { name, node, body } of collapsibles) { + test(`${name} opens and closes with the fold's height and opacity, over the shared token`, () => { + render(node()); + expect(container.querySelector(body)).toBeNull(); + click(); + expect(toggle().getAttribute("aria-expanded")).toBe("true"); + expect(container.querySelector(`.cv-fold ${body}`)).not.toBeNull(); + expect(animations.length).toBe(1); + const [opening] = animations; + expect(opening!.options.duration).toBe(token); + expect(opening!.options.easing).toBe("cubic-bezier(0, 0, 0.58, 1)"); + expect(opening!.keyframes[0]).toEqual({ height: "0px", opacity: 0 }); + expect(opening!.keyframes.at(-1)!.opacity).toBe(1); + // The chevron turns as it opens. + expect(toggle().querySelector(".cv-rotor")!.classList.contains("is-open")).toBe(true); + + click(); + expect(toggle().getAttribute("aria-expanded")).toBe("false"); + expect(animations.length).toBe(2); + expect(animations[1]!.options.duration).toBe(token); + expect(animations[1]!.keyframes.at(-1)).toEqual({ height: "0px", opacity: 0 }); + // Drawn while it slides away, then gone. + expect(container.querySelector(body)).not.toBeNull(); + animations[1]!.finish(); + expect(container.querySelector(body)).toBeNull(); + }); + + test(`${name} opens and closes at once under Reduce Motion`, () => { + reduce = true; + render(node()); + click(); + expect(container.querySelector(body)).not.toBeNull(); + click(); + expect(container.querySelector(body)).toBeNull(); + expect(animations).toEqual([]); + }); +} + +test("with the pane's animation off, or a 0 ms token, a fold applies at once", () => { + document.documentElement.dataset.agentMotion = "off"; + render(); + click(); + expect(container.querySelector(".cv-tool-output-card")).not.toBeNull(); + click(); + expect(container.querySelector(".cv-tool-output-card")).toBeNull(); + delete document.documentElement.dataset.agentMotion; + // The host sends 0 for the move duration under Reduce Motion and with animation off. + token = 0; + click(); + click(); + expect(container.querySelector(".cv-tool-output-card")).toBeNull(); + expect(animations).toEqual([]); +}); + +test("a fold turned back midway starts from where it is drawn", () => { + render(); + click(); + // Midway through opening, the reader closes it again. + const card = container.querySelector(".cv-fold")!; + card.getBoundingClientRect = () => ({ height: 42 }) as DOMRect; + click(); + expect(animations.length).toBe(2); + expect(animations[1]!.keyframes[0]!.height).toBe("42px"); + expect(animations[1]!.keyframes.at(-1)).toEqual({ height: "0px", opacity: 0 }); +}); + +test("a close holds its box shut until React removes the body, so it never flashes open", () => { + render(); + click(); + const box = container.querySelector(".cv-fold")!; + animations[0]!.finish(); + // An open lets go of the height it measured: the body takes its own, content that loaded since too. + expect(box.style.height).toBe(""); + expect(box.style.opacity).toBe(""); + expect(box.style.overflow).toBe(""); + click(); + act(() => { + animations[1]!.ends(); + // The animation has let go and React has not committed yet: the box is still drawn, pinned shut. + expect(box.isConnected).toBe(true); + expect(box.style.height).toBe("0px"); + expect(box.style.opacity).toBe("0"); + expect(box.style.overflow).toBe("hidden"); + }); + expect(container.querySelector(".cv-fold")).toBeNull(); +}); + +test("a fold reopened as its close ends opens from its own size, not the pinned one", () => { + render(); + click(); + animations[0]!.finish(); + click(); + const box = container.querySelector(".cv-fold")!; + // The reader opens it again in the same frame the close ends: React keeps the same box. + act(() => { + animations[1]!.ends(); + toggle().click(); + }); + expect(container.querySelector(".cv-fold")).toBe(box); + expect(animations.length).toBe(3); + expect(animations[2]!.keyframes[0]).toEqual({ height: "0px", opacity: 0 }); + expect(box.style.height).toBe(""); + expect(box.style.opacity).toBe(""); + animations[2]!.finish(); + expect(box.style.height).toBe(""); + expect(box.style.opacity).toBe(""); + expect(box.style.overflow).toBe(""); +}); + +test("the fold token is the host's move duration, and the chevron turns on it", () => { + const css = readFileSync( + path.resolve(import.meta.dir, "../src/agent-session/acpmux/conversation/conversation.css"), + "utf8", + ); + expect(FOLD_DURATION_VAR).toBe("--cv-fold-duration"); + expect(css).toMatch(/--cv-fold-duration:\s*var\(--agent-motion-move,\s*180ms\)/); + expect(css).toMatch(/\.cv-rotor\s*\{\s*transition:\s*transform var\(--cv-fold-duration\) var\(--agent-ease-out/); + // The chevron snaps under Reduce Motion: its transition is only under no-preference. + expect(css).toMatch( + /@media \(prefers-reduced-motion: no-preference\)\s*\{\s*\.cv-rotor\s*\{\s*transition: transform var\(--cv-fold-duration\)/, + ); + // The fold reads the token as the page resolves it. + expect(cssTimeMs("190ms")).toBe(190); + expect(cssTimeMs(" 0.18s")).toBe(180); + expect(cssTimeMs("0ms")).toBe(0); + expect(cssTimeMs("")).toBeUndefined(); +});