diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane/index.html b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane/index.html index 45cecbaab085..5701d40ed48a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane/index.html +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/agent-pane/index.html @@ -1994,51 +1994,51 @@ display: none; } } -.acpmux-shell{height:100%;display:flex;flex-direction:column;color:var(--agent-text);font:13px "Helvetica Neue", Arial, sans-serif;background:var(--agent-page-bg);overflow:hidden}.acpmux-header{display:flex;justify-content:space-between;align-items:center;padding:12px 18px;border-bottom:1px solid var(--agent-border)}.acpmux-title{font-size:14px}.acpmux-status{margin-left:8px;color:var(--agent-muted);font-size:11px}.acpmux-session,.acpmux-model,.acpmux-mode,.acpmux-effort{border:1px solid var(--agent-border);border-radius:999px;background:var(--agent-control);color:var(--agent-text);padding:5px 9px}.acpmux-scroll{flex:1;overflow:auto;contain:strict}.acpmux-spacer{position:relative;min-height:100%}.acpmux-thread{position:absolute;inset:0 0 auto;padding:16px max(18px,calc((100% - 760px)/2));}.acpmux-row{position:absolute;left:max(18px,calc((100% - 760px)/2));right:max(18px,calc((100% - 760px)/2));min-height:34px;contain:layout paint}.acpmux-user{display:flex;justify-content:flex-end}.acpmux-bubble,.acpmux-user-bubble{max-width:78%;padding:9px 12px;box-sizing:border-box;border-radius:16px;background:color-mix(in srgb,var(--agent-accent) 20%,transparent);white-space:pre-wrap}.acpmux-markdown{line-height:20px}.acpmux-markdown>*{margin:8px 0 0}.acpmux-markdown>:first-child{margin-top:0}.acpmux-markdown code{font:11.5px var(--font-mono)}.acpmux-markdown blockquote{padding-left:12px;border-left:2px solid var(--agent-border);color:var(--agent-soft)}.acpmux-markdown pre,.acpmux-activity-item pre{padding:9px;border-radius:8px;overflow:auto;background:var(--agent-input-bg);font:12px var(--font-mono)}.acpmux-summary,.acpmux-muted{color:var(--agent-muted);font-size:11px;padding:8px 0}.acpmux-activity-toggle{border:0;background:none;color:var(--agent-muted);padding:5px 0;cursor:pointer}.acpmux-activity-items{border-left:1px solid var(--agent-border);padding-left:12px;color:var(--agent-soft)}.acpmux-activity-item{padding:3px 0}.acpmux-glyph{display:inline-block;width:18px;color:var(--agent-accent)}.acpmux-permission{padding:0 max(18px,calc((100% - 760px)/2))}.acpmux-permission-card{padding:11px;border:1px solid var(--agent-accent);border-radius:12px;background:var(--agent-surface-elevated)}.acpmux-permission-buttons{display:flex;gap:7px;margin-top:9px}.acpmux-permission-buttons button,.acpmux-composer button{border:0;border-radius:999px;padding:7px 12px;background:var(--agent-accent);color:#fff}.acpmux-queue{display:flex;gap:6px;overflow:auto;padding:5px max(18px,calc((100% - 760px)/2));color:var(--agent-muted)}.acpmux-composer{display:flex;align-items:end;gap:8px;padding:10px max(18px,calc((100% - 760px)/2));border-top:1px solid var(--agent-border);background:var(--agent-surface)}.acpmux-chips{display:flex;gap:5px}.acpmux-composer textarea{flex:1;resize:none;border:1px solid var(--agent-border-strong);border-radius:15px;padding:10px;background:var(--agent-input-bg);color:var(--agent-text);font:inherit}.acpmux-cancel{background:var(--agent-control)!important;color:var(--agent-text)!important} +.acpmux-shell{height:100%;display:flex;flex-direction:column;color:var(--agent-text);font:13px "Helvetica Neue", Arial, sans-serif;background:var(--agent-page-bg);overflow:hidden}.acpmux-header{display:flex;justify-content:space-between;align-items:center;padding:12px 18px;border-bottom:1px solid var(--agent-border)}.acpmux-title{font-size:14px}.acpmux-status{margin-left:8px;color:var(--agent-muted);font-size:11px}.acpmux-session,.acpmux-model,.acpmux-mode,.acpmux-effort{border:1px solid var(--agent-border);border-radius:999px;background:var(--agent-control);color:var(--agent-text);padding:5px 9px}.acpmux-scroll{flex:1;overflow:auto;contain:strict}.acpmux-spacer{position:relative;min-height:100%}.acpmux-thread{position:absolute;inset:0 0 auto;padding:16px max(18px,calc((100% - 760px)/2));}.acpmux-row{position:absolute;left:max(18px,calc((100% - 760px)/2));right:max(18px,calc((100% - 760px)/2));min-height:34px;box-sizing:border-box;padding-bottom:8px;contain:layout paint}.acpmux-row.acpmux-user,.acpmux-row.acpmux-assistant{padding-bottom:16px}.acpmux-user{display:flex;justify-content:flex-end}.acpmux-bubble,.acpmux-user-bubble{max-width:78%;padding:9px 12px;box-sizing:border-box;border-radius:16px;background:color-mix(in srgb,var(--agent-accent) 20%,transparent);white-space:pre-wrap}.acpmux-markdown{line-height:20px}.acpmux-markdown>*{margin:8px 0 0}.acpmux-markdown>:first-child{margin-top:0}.acpmux-markdown code{font:11.5px var(--font-mono)}.acpmux-markdown blockquote{padding-left:12px;border-left:2px solid var(--agent-border);color:var(--agent-soft)}.acpmux-markdown pre,.acpmux-activity-item pre{padding:9px;border-radius:8px;overflow:auto;background:var(--agent-input-bg);font:12px var(--font-mono)}.acpmux-summary,.acpmux-muted{color:var(--agent-muted);font-size:11px;padding:8px 0}.acpmux-activity-toggle{border:0;background:none;color:var(--agent-muted);padding:5px 0;cursor:pointer}.acpmux-activity-items{border-left:1px solid var(--agent-border);padding-left:12px;color:var(--agent-soft)}.acpmux-activity-item{padding:3px 0}.acpmux-glyph{display:inline-block;width:18px;color:var(--agent-accent)}.acpmux-permission{padding:0 max(18px,calc((100% - 760px)/2))}.acpmux-permission-card{padding:11px;border:1px solid var(--agent-accent);border-radius:12px;background:var(--agent-surface-elevated)}.acpmux-permission-buttons{display:flex;gap:7px;margin-top:9px}.acpmux-permission-buttons button,.acpmux-composer button{border:0;border-radius:999px;padding:7px 12px;background:var(--agent-accent);color:#fff}.acpmux-queue{display:flex;gap:6px;overflow:auto;padding:5px max(18px,calc((100% - 760px)/2));color:var(--agent-muted)}.acpmux-composer{display:flex;align-items:end;gap:8px;padding:10px max(18px,calc((100% - 760px)/2));border-top:1px solid var(--agent-border);background:var(--agent-surface)}.acpmux-chips{display:flex;gap:5px}.acpmux-composer textarea{flex:1;resize:none;border:1px solid var(--agent-border-strong);border-radius:15px;padding:10px;background:var(--agent-input-bg);color:var(--agent-text);font:inherit}.acpmux-cancel{background:var(--agent-control)!important;color:var(--agent-text)!important}
diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index fa4fea2774cc..f7d2a9f25167 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -1,8 +1,8 @@ -import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; import type { Token } from "marked"; import { applyAgentTheme } from "../shared/theme"; -import { diffRows, layoutConversation, markdownBlocks, safeHref, transcriptRowWidth, visibleLayoutRange, type AcpmuxPermission, type AcpmuxRow, type AcpmuxSnapshot } from "./model"; +import { diffRows, layoutConversation, markdownBlocks, placeRows, safeHref, transcriptRowWidth, visibleLayoutRange, type AcpmuxPermission, type AcpmuxRow, type AcpmuxSnapshot } from "./model"; import { AcpmuxDirectClient, type AcpmuxHostConfig } from "./direct"; import { startMockHost } from "./mock"; import { createAcpmuxDebug, type AcpmuxDebug } from "./debug"; @@ -80,18 +80,26 @@ const EditedFilesRow = memo(function EditedFilesRow({ row }: RowProps) { const f const defaultRegistry: NativeRegistry = { user: MessageRow, assistant: MessageRow, activity: ToolActivityRow, editedFiles: EditedFilesRow, turnSummary: SummaryRow, notice: NoticeRow, plan: NoticeRow, typing: NoticeRow, permission: PermissionRow }; -function MeasuredCustomRow({ children, onHeight }: { children: React.ReactNode; onHeight: (height: number) => void }) { +/// A row's height as the page drew it, valid while the row's content version and width hold. +type DrawnHeight = { version: number; width: number; height: number }; +type ReportDrawn = (id: string, version: number, height: number) => void; + +/// One transcript row. It reports its drawn height before the frame paints whenever it mounts or +/// its content, width or expansion changes; the transcript's ResizeObserver reports later changes +/// (a font that loads, a custom renderer that grows). +function RowFrame({ row, kind, index, setSize, top, rowWidth, expanded, observer, report, children }: { row: AcpmuxRow; kind: string; index: number; setSize: number; top: number; rowWidth: number; expanded: boolean; observer: ResizeObserver | undefined; report: ReportDrawn; children: React.ReactNode }) { const ref = useRef(null); useLayoutEffect(() => { const node = ref.current; - if (!node) return; - const report = () => onHeight(node.getBoundingClientRect().height); - const observer = new ResizeObserver(report); + if (!node || !observer) return; observer.observe(node); - report(); - return () => observer.disconnect(); - }, [onHeight]); - return
}>{children}
; + return () => observer.unobserve(node); + }, [observer]); + useLayoutEffect(() => { + const node = ref.current; + if (node) report(row.id, row.version, node.getBoundingClientRect().height); + }, [row.id, row.version, rowWidth, expanded, report]); + return
{children}
; } /// Who spoke, for assistive technology: each article is one message in the transcript feed. @@ -99,6 +107,9 @@ const speaker = (kind: string) => kind === "user" ? "You" : kind === "assistant" const rowKind = (row: AcpmuxRow) => row.kind === "activity" && row.items?.some((item) => item.tool?.kind === "edit" || item.tool?.kind === "fileChange") ? "editedFiles" : row.kind; const currentRegistry = (): NativeRegistry => ({ ...defaultRegistry, ...(window.cmuxAcpmuxRegistry as unknown as NativeRegistry | undefined) }); +/// Where a scroller sits, read while its content still matches `totalHeight`. +const scrollPosition = (node: HTMLElement, totalHeight: number) => ({ top: node.scrollTop, atLatest: node.scrollTop >= totalHeight - node.clientHeight - 1 }); + /// Scroll steps of rows mounted ahead in the scroll direction, capped in viewports. /// A scroll commits from its event, a frame after the offset moved, so without the /// lead a fling shows a blank edge on every frame. @@ -112,20 +123,80 @@ export function VirtualTranscript({ rows, onToggleActivity, expanded, registry = const [height, setHeight] = useState(600); const ref = useRef(null); const [width, setWidth] = useState(760); - const [measuredHeights, setMeasuredHeights] = useState(new Map()); + // Rows place by their drawn height once drawn, and by the estimate until then. + const [drawn, setDrawn] = useState(new Map()); + const pendingDrawn = useRef(new Map()); + const rowWidthRef = useRef(transcriptRowWidth(width)); + rowWidthRef.current = transcriptRowWidth(width); + const rowsRef = useRef(rows); + rowsRef.current = rows; + const reportDrawn = useCallback((id, version, drawnHeight) => { + // Zero is a row not laid out (hidden, or no layout at all), not a height. + if (drawnHeight > 0) pendingDrawn.current.set(id, { version, width: rowWidthRef.current, height: drawnHeight }); + }, []); + // All of a commit's reports land in one update, before the frame paints. + const flushDrawn = useCallback(() => { + if (!pendingDrawn.current.size) return; + const updates = pendingDrawn.current; + pendingDrawn.current = new Map(); + setDrawn((current) => { + let next: Map | undefined; + for (const [id, entry] of updates) { + const old = current.get(id); + if (old && old.version === entry.version && old.width === entry.width && Math.abs(old.height - entry.height) < 0.5) continue; + next ??= new Map(current); + next.set(id, entry); + } + return next ?? current; + }); + }, []); + const observer = useMemo(() => typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver((entries?: ResizeObserverEntry[]) => { + for (const entry of entries ?? []) { + const target = entry.target as HTMLElement; + const row = rowsRef.current[Number(target.getAttribute("aria-posinset")) - 1]; + if (row && row.id === target.dataset.rowId) reportDrawn(row.id, row.version, target.getBoundingClientRect().height); + } + // A late size change (a font loading) must not paint a frame of overlap first. + flushSync(flushDrawn); + }), [reportDrawn, flushDrawn]); + useEffect(() => () => observer?.disconnect(), [observer]); + // Forget rows that left the transcript (a session switch, older history unloaded). + useEffect(() => { + const cache = measurementCache.current; + if (cache.size <= rows.length && drawn.size <= rows.length) return; + const ids = new Set(rows.map((row) => row.id)); + for (const id of cache.keys()) if (!ids.has(id)) cache.delete(id); + setDrawn((current) => { + if ([...current.keys()].every((id) => ids.has(id))) return current; + return new Map([...current].filter(([id]) => ids.has(id))); + }); + }, [rows, drawn]); + useLayoutEffect(flushDrawn); const didOpenAtLatest = useRef(false); const measurementCache = useRef(new Map()); useEffect(() => { const node = ref.current; if (!node) return; const observer = new ResizeObserver(() => { setHeight(node.clientHeight); setWidth(node.clientWidth); }); observer.observe(node); setWidth(node.clientWidth); return () => observer.disconnect(); }, []); const previousLayout = useRef | null>(null); - // Scroll frames re-render with the same rows; only rows, width, measured custom - // heights or the registry can move a row. + const scrolledTo = useRef({ top: 0, atLatest: false }); + // Scroll frames re-render with the same rows; only rows, width or the registry + // change an estimate. + const estimated = useMemo(() => { + const layoutStart = acpmuxPerf.enabled ? performance.now() : 0; + const layout = layoutConversation(rows, transcriptRowWidth(width), measurementCache.current, (row, rowWidth) => registry[rowKind(row)]?.measure?.(row, rowWidth)); + return { layout, ms: acpmuxPerf.enabled ? performance.now() - layoutStart : 0 }; + }, [rows, width, registry]); + // A row that draws moves only the rows below it: place them again, measuring none. const measured = useMemo(() => { const layoutStart = acpmuxPerf.enabled ? performance.now() : 0; - const layout = layoutConversation(rows, transcriptRowWidth(width), measurementCache.current, (row, rowWidth) => registry[rowKind(row)]?.measure?.(row, rowWidth) ?? measuredHeights.get(row.id)); + const rowWidth = transcriptRowWidth(width); + const layout = drawn.size === 0 ? estimated.layout : placeRows(estimated.layout, (index) => { + const known = drawn.get(rows[index].id); + return known && known.version === rows[index].version && known.width === rowWidth ? known.height : undefined; + }); return { layout, ms: acpmuxPerf.enabled ? performance.now() - layoutStart : 0 }; - }, [rows, width, measuredHeights, registry]); + }, [estimated, drawn, rows, width]); const layout = measured.layout; const reportedLayout = useRef(null); + const reportedEstimate = useRef(null); const lead = Math.min(Math.abs(scroll.delta) * SCROLL_LEAD_STEPS, height * MAX_SCROLL_LEAD_VIEWPORTS); const range = visibleLayoutRange(layout, scroll.delta < 0 ? scroll.top - lead : scroll.top, height + lead); useLayoutEffect(() => { @@ -135,16 +206,33 @@ export function VirtualTranscript({ rows, onToggleActivity, expanded, registry = // A memo hit spent no time in geometry this render. const freshLayout = reportedLayout.current !== measured; reportedLayout.current = measured; - const layoutMs = freshLayout ? measured.ms : 0; + const freshEstimate = reportedEstimate.current !== estimated; + reportedEstimate.current = estimated; + const layoutMs = (freshLayout ? measured.ms : 0) + (freshEstimate ? estimated.ms : 0); if (acpmuxPerf.enabled && freshLayout) acpmuxPerf.addLayout(layoutMs); if (acpmuxPerf.enabled && renderStart > 0) { const now = performance.now(); acpmuxPerf.commit(now - renderStart, layoutMs, acpmuxPerf.mountedTop, acpmuxPerf.mountedBottom, now); } }); useLayoutEffect(() => { const old = previousLayout.current; const node = ref.current; - if (old && node && old.tops.length === layout.tops.length && range.first > 0) { - const delta = layout.tops[range.first] - old.tops[range.first]; - if (Math.abs(delta) > 0.5) node.scrollTop += delta; + if (old && node && old.tops.length === layout.tops.length) { + // 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. + const live = node.scrollTop; + const clamped = live < scrolledTo.current.top - 0.5 && live >= layout.totalHeight - node.clientHeight - 1; + const top = clamped ? scrolledTo.current.top : live; + const atLatest = clamped ? scrolledTo.current.atLatest : top >= old.totalHeight - node.clientHeight - 1; + // At the first row nothing above can move it. + if (top > 0 && didOpenAtLatest.current && atLatest) { + // At the latest row: stay there as rows settle to their drawn heights. + const latest = Math.max(0, layout.totalHeight - node.clientHeight); + if (Math.abs(latest - node.scrollTop) > 0.5) node.scrollTop = latest; + } else if (top > 0) { + // Keep the row at the top of the viewport where it is as rows above it change height. + const anchor = visibleLayoutRange(old, top, 0, 0).first; + const delta = layout.tops[anchor] - old.tops[anchor]; + if (clamped || Math.abs(delta) > 0.5) node.scrollTop = top + delta; + } } // Runs on height too: rows that fit and then overflow on a height-only shrink keep the same memoized layout. if (!didOpenAtLatest.current && node && layout.totalHeight > node.clientHeight) { @@ -154,13 +242,14 @@ export function VirtualTranscript({ rows, onToggleActivity, expanded, registry = didOpenAtLatest.current = true; } previousLayout.current = layout; + if (node) scrolledTo.current = scrollPosition(node, layout.totalHeight); }, [layout, range.first, height]); // Commit before this frame paints; deferring to the next animation frame left the edge blank. // Each settled scroll's frame pacing goes to the host, which picks the pane's rendering rate. const pacing = useMemo(() => new ScrollPacing((intervals) => { callNative("pane.framePacing", { intervals }).catch(() => {}); }), []); useEffect(() => () => pacing.stop(), [pacing]); - const onScroll = (event: React.UIEvent) => { pacing.scrolled(); const next = event.currentTarget.scrollTop; flushSync(() => setScroll((current) => ({ top: next, delta: next - current.top }))); }; - return
{rows.slice(range.first, range.last).map((row, index) => { const absoluteIndex = range.first + index; const kind = rowKind(row); const Component = registry[kind] ?? NoticeRow; const rendered = ; return
{Component.measure || defaultRegistry[kind] ? rendered : setMeasuredHeights((current) => { if (current.get(row.id) === value) return current; const next = new Map(current); next.set(row.id, value); return next; })}>{rendered}}
; })}
; + const onScroll = (event: React.UIEvent) => { pacing.scrolled(); const next = event.currentTarget.scrollTop; scrolledTo.current = scrollPosition(event.currentTarget, layout.totalHeight); flushSync(() => setScroll((current) => ({ top: next, delta: next - current.top }))); }; + return
{rows.slice(range.first, range.last).map((row, index) => { const absoluteIndex = range.first + index; const kind = rowKind(row); const Component = registry[kind] ?? NoticeRow; const isExpanded = expanded.has(row.id); return ; })}
; } function PermissionCard({ permission }: { permission: AcpmuxPermission }) { return
{permission.title || "Permission required"}
{permission.options.map((option) => )}
; } diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts index 8d8d99d13e91..c2de47ab7645 100644 --- a/webviews/src/agent-session/acpmux/model.ts +++ b/webviews/src/agent-session/acpmux/model.ts @@ -108,10 +108,18 @@ export function visibleRowRange(rowCount: number, scrollTop: number, viewportHei return { first, last }; } +/// First-layout estimates for rows not yet drawn; a drawn row places by its drawn height. Each +/// includes the row's bottom padding (`.acpmux-row` in styles.css: 16px for messages, 8px else). function fallbackRowHeight(row: AcpmuxRow, width: number): number { const textLines = Math.max(1, Math.ceil((row.text?.length ?? 0) / Math.max(24, Math.floor(width / 8)))); - if (row.kind === "activity") return Math.max(46, 24 + (row.items?.length ?? 0) * 20); - if (row.kind === "turnSummary" || row.kind === "notice" || row.kind === "typing") return 32; + if (row.kind === "activity") { + // Collapsed tool calls, or the edited-files list (a title and one line per file). + const edits = row.items?.filter((item) => item.tool?.kind === "edit" || item.tool?.kind === "fileChange").length ?? 0; + return edits ? 8 + 16 * (1 + edits) : 34; + } + // Card padding and border, title, button row. + if (row.kind === "permission") return 87; + if (row.kind === "turnSummary" || row.kind === "notice" || row.kind === "plan" || row.kind === "typing") return 37; return 24 + chromeHeight(row) + textLines * MESSAGE_LINE_HEIGHT; } @@ -173,6 +181,8 @@ function measuredRowHeight(row: AcpmuxRow, width: number, cache: Map 64) entry.prepared.clear(); } if (entry.blocks.length === 0) return fallbackRowHeight(row, width); const contentWidth = Math.max(80, row.kind === "user" ? USER_BUBBLE_SHARE * width - USER_BUBBLE_SIDES : width); @@ -196,6 +206,22 @@ export function layoutConversation(rows: AcpmuxRow[], width: number, cache = new return { tops, heights, totalHeight: top }; } +/// Places rows again over `estimate`, taking a row's height from `heightAt` when it has one. +/// No row is measured, so this costs one pass over the rows' heights. +export function placeRows(estimate: ConversationLayout, heightAt: (index: number) => number | undefined): ConversationLayout { + const tops = new Float64Array(estimate.heights.length); + const heights = new Float64Array(estimate.heights.length); + let top = 0; + for (let index = 0; index < heights.length; index += 1) { + tops[index] = top; + const known = heightAt(index); + const height = known !== undefined && known > 0 ? known : estimate.heights[index]; + heights[index] = height; + top += height; + } + return { tops, heights, totalHeight: top }; +} + function upperBound(values: Float64Array, target: number): number { let low = 0; let high = values.length; diff --git a/webviews/src/agent-session/acpmux/styles.css b/webviews/src/agent-session/acpmux/styles.css index 76e7a2214cbf..a59e350a2b8e 100644 --- a/webviews/src/agent-session/acpmux/styles.css +++ b/webviews/src/agent-session/acpmux/styles.css @@ -1 +1 @@ -.acpmux-shell{height:100%;display:flex;flex-direction:column;color:var(--agent-text);font:13px "Helvetica Neue", Arial, sans-serif;background:var(--agent-page-bg);overflow:hidden}.acpmux-header{display:flex;justify-content:space-between;align-items:center;padding:12px 18px;border-bottom:1px solid var(--agent-border)}.acpmux-title{font-size:14px}.acpmux-status{margin-left:8px;color:var(--agent-muted);font-size:11px}.acpmux-session,.acpmux-model,.acpmux-mode,.acpmux-effort{border:1px solid var(--agent-border);border-radius:999px;background:var(--agent-control);color:var(--agent-text);padding:5px 9px}.acpmux-scroll{flex:1;overflow:auto;contain:strict}.acpmux-spacer{position:relative;min-height:100%}.acpmux-thread{position:absolute;inset:0 0 auto;padding:16px max(18px,calc((100% - 760px)/2));}.acpmux-row{position:absolute;left:max(18px,calc((100% - 760px)/2));right:max(18px,calc((100% - 760px)/2));min-height:34px;contain:layout paint}.acpmux-user{display:flex;justify-content:flex-end}.acpmux-bubble,.acpmux-user-bubble{max-width:78%;padding:9px 12px;box-sizing:border-box;border-radius:16px;background:color-mix(in srgb,var(--agent-accent) 20%,transparent);white-space:pre-wrap}.acpmux-markdown{line-height:20px}.acpmux-markdown>*{margin:8px 0 0}.acpmux-markdown>:first-child{margin-top:0}.acpmux-markdown code{font:11.5px var(--font-mono)}.acpmux-markdown blockquote{padding-left:12px;border-left:2px solid var(--agent-border);color:var(--agent-soft)}.acpmux-markdown pre,.acpmux-activity-item pre{padding:9px;border-radius:8px;overflow:auto;background:var(--agent-input-bg);font:12px var(--font-mono)}.acpmux-summary,.acpmux-muted{color:var(--agent-muted);font-size:11px;padding:8px 0}.acpmux-activity-toggle{border:0;background:none;color:var(--agent-muted);padding:5px 0;cursor:pointer}.acpmux-activity-items{border-left:1px solid var(--agent-border);padding-left:12px;color:var(--agent-soft)}.acpmux-activity-item{padding:3px 0}.acpmux-glyph{display:inline-block;width:18px;color:var(--agent-accent)}.acpmux-permission{padding:0 max(18px,calc((100% - 760px)/2))}.acpmux-permission-card{padding:11px;border:1px solid var(--agent-accent);border-radius:12px;background:var(--agent-surface-elevated)}.acpmux-permission-buttons{display:flex;gap:7px;margin-top:9px}.acpmux-permission-buttons button,.acpmux-composer button{border:0;border-radius:999px;padding:7px 12px;background:var(--agent-accent);color:#fff}.acpmux-queue{display:flex;gap:6px;overflow:auto;padding:5px max(18px,calc((100% - 760px)/2));color:var(--agent-muted)}.acpmux-composer{display:flex;align-items:end;gap:8px;padding:10px max(18px,calc((100% - 760px)/2));border-top:1px solid var(--agent-border);background:var(--agent-surface)}.acpmux-chips{display:flex;gap:5px}.acpmux-composer textarea{flex:1;resize:none;border:1px solid var(--agent-border-strong);border-radius:15px;padding:10px;background:var(--agent-input-bg);color:var(--agent-text);font:inherit}.acpmux-cancel{background:var(--agent-control)!important;color:var(--agent-text)!important} +.acpmux-shell{height:100%;display:flex;flex-direction:column;color:var(--agent-text);font:13px "Helvetica Neue", Arial, sans-serif;background:var(--agent-page-bg);overflow:hidden}.acpmux-header{display:flex;justify-content:space-between;align-items:center;padding:12px 18px;border-bottom:1px solid var(--agent-border)}.acpmux-title{font-size:14px}.acpmux-status{margin-left:8px;color:var(--agent-muted);font-size:11px}.acpmux-session,.acpmux-model,.acpmux-mode,.acpmux-effort{border:1px solid var(--agent-border);border-radius:999px;background:var(--agent-control);color:var(--agent-text);padding:5px 9px}.acpmux-scroll{flex:1;overflow:auto;contain:strict}.acpmux-spacer{position:relative;min-height:100%}.acpmux-thread{position:absolute;inset:0 0 auto;padding:16px max(18px,calc((100% - 760px)/2));}.acpmux-row{position:absolute;left:max(18px,calc((100% - 760px)/2));right:max(18px,calc((100% - 760px)/2));min-height:34px;box-sizing:border-box;padding-bottom:8px;contain:layout paint}.acpmux-row.acpmux-user,.acpmux-row.acpmux-assistant{padding-bottom:16px}.acpmux-user{display:flex;justify-content:flex-end}.acpmux-bubble,.acpmux-user-bubble{max-width:78%;padding:9px 12px;box-sizing:border-box;border-radius:16px;background:color-mix(in srgb,var(--agent-accent) 20%,transparent);white-space:pre-wrap}.acpmux-markdown{line-height:20px}.acpmux-markdown>*{margin:8px 0 0}.acpmux-markdown>:first-child{margin-top:0}.acpmux-markdown code{font:11.5px var(--font-mono)}.acpmux-markdown blockquote{padding-left:12px;border-left:2px solid var(--agent-border);color:var(--agent-soft)}.acpmux-markdown pre,.acpmux-activity-item pre{padding:9px;border-radius:8px;overflow:auto;background:var(--agent-input-bg);font:12px var(--font-mono)}.acpmux-summary,.acpmux-muted{color:var(--agent-muted);font-size:11px;padding:8px 0}.acpmux-activity-toggle{border:0;background:none;color:var(--agent-muted);padding:5px 0;cursor:pointer}.acpmux-activity-items{border-left:1px solid var(--agent-border);padding-left:12px;color:var(--agent-soft)}.acpmux-activity-item{padding:3px 0}.acpmux-glyph{display:inline-block;width:18px;color:var(--agent-accent)}.acpmux-permission{padding:0 max(18px,calc((100% - 760px)/2))}.acpmux-permission-card{padding:11px;border:1px solid var(--agent-accent);border-radius:12px;background:var(--agent-surface-elevated)}.acpmux-permission-buttons{display:flex;gap:7px;margin-top:9px}.acpmux-permission-buttons button,.acpmux-composer button{border:0;border-radius:999px;padding:7px 12px;background:var(--agent-accent);color:#fff}.acpmux-queue{display:flex;gap:6px;overflow:auto;padding:5px max(18px,calc((100% - 760px)/2));color:var(--agent-muted)}.acpmux-composer{display:flex;align-items:end;gap:8px;padding:10px max(18px,calc((100% - 760px)/2));border-top:1px solid var(--agent-border);background:var(--agent-surface)}.acpmux-chips{display:flex;gap:5px}.acpmux-composer textarea{flex:1;resize:none;border:1px solid var(--agent-border-strong);border-radius:15px;padding:10px;background:var(--agent-input-bg);color:var(--agent-text);font:inherit}.acpmux-cancel{background:var(--agent-control)!important;color:var(--agent-text)!important} diff --git a/webviews/src/agent-session/acpmux/transcript.test.tsx b/webviews/src/agent-session/acpmux/transcript.test.tsx index 5a25372101aa..2a3bb60e33c0 100644 --- a/webviews/src/agent-session/acpmux/transcript.test.tsx +++ b/webviews/src/agent-session/acpmux/transcript.test.tsx @@ -13,7 +13,7 @@ Object.assign(globals, { document: dom.window.document, navigator: dom.window.navigator, HTMLElement: dom.window.HTMLElement, - ResizeObserver: class { constructor(callback: () => void) { resizeCallbacks.push(callback); } observe() {} disconnect() {} }, + ResizeObserver: class { constructor(callback: () => void) { resizeCallbacks.push(callback); } observe() {} unobserve() {} disconnect() {} }, requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0) as unknown as number, cancelAnimationFrame: (handle: number) => clearTimeout(handle), IS_REACT_ACT_ENVIRONMENT: true, @@ -32,7 +32,9 @@ function fakeViewport(size: { width: number; height: number }) { const isScroller = (node: HTMLElement) => node.classList.contains("acpmux-scroll"); Object.defineProperty(prototype, "clientHeight", { configurable: true, get(this: HTMLElement) { return isScroller(this) ? size.height : 0; } }); Object.defineProperty(prototype, "clientWidth", { configurable: true, get(this: HTMLElement) { return isScroller(this) ? size.width : 0; } }); - Object.defineProperty(prototype, "scrollTop", { configurable: true, get(this: HTMLElement) { return offsets.get(this) ?? 0; }, set(this: HTMLElement, value: number) { offsets.set(this, value); } }); + // Like a browser, the offset clamps to the content once it lays out again. + const maximum = (node: HTMLElement) => Math.max(0, parseFloat(node.querySelector(".acpmux-spacer")?.style.height || "0") - size.height); + Object.defineProperty(prototype, "scrollTop", { configurable: true, get(this: HTMLElement) { const offset = Math.min(offsets.get(this) ?? 0, maximum(this)); offsets.set(this, offset); return offset; }, set(this: HTMLElement, value: number) { offsets.set(this, Math.max(0, Math.min(value, maximum(this)))); } }); return () => { for (const key of ["clientHeight", "clientWidth", "scrollTop"]) delete (prototype as unknown as Record)[key]; }; } @@ -182,6 +184,144 @@ describe("acpmux transcript accessibility", () => { }); }); +/// A seeded generator, so a failing shape reproduces. +function seeded(seed: number) { + let state = seed >>> 0; + return () => { state = (state * 1664525 + 1013904223) >>> 0; return state / 2 ** 32; }; +} + +/// Rows of every kind the transcript draws, in random markdown shapes. +function randomConversation(count: number, random: () => number): AcpmuxRow[] { + const pieces = ["A sentence with `code` in it.", "## Heading\nText under it.", "- one\n- [ ] two\n- three", "```\nlet x = 1\n```", "> quoted", "Line one\nline two", "**bold** and [a link](https://example.com)"]; + return Array.from({ length: count }, (_, index) => { + const pick = random(); + const text = Array.from({ length: 1 + Math.floor(random() * 4) }, () => pieces[Math.floor(random() * pieces.length)]!).join("\n\n"); + if (pick < 0.35) return { id: `r${index}`, version: 1, at: index, kind: "user", text }; + if (pick < 0.75) return { id: `r${index}`, version: 1, at: index, kind: "assistant", text }; + if (pick < 0.85) return { id: `r${index}`, version: 1, at: index, kind: "activity", toolCount: 2, items: [{ kind: "tool", text: "Read a file" }] } as AcpmuxRow; + if (pick < 0.92) return { id: `r${index}`, version: 1, at: index, kind: "permission", permission: { permissionId: `p${index}`, title: "Allow this?", options: [{ id: "allow", name: "Allow" }] } } as AcpmuxRow; + return { id: `r${index}`, version: 1, at: index, kind: "turnSummary", durationMs: 2000, toolCount: 1 }; + }); +} + +describe("acpmux measured rows", () => { + /// The layout estimates a row's height before it draws, and some shapes always draw taller + /// than any estimate (fonts, permission cards, expanded tool output). A row the page has drawn + /// must be placed by its drawn height, so no row runs under the next one. + test("drawn rows never overlap, whatever their shape", async () => { + const restore = fakeViewport({ width: 760, height: 600 }); + const random = seeded(16476); + const conversation = randomConversation(300, random); + // jsdom does no layout: each row draws at a height the estimator can't know. + const drawn = new Map(conversation.map((row) => [row.id, 30 + Math.round(random() * 220)])); + const prototype = dom.window.HTMLElement.prototype; + const original = prototype.getBoundingClientRect; + prototype.getBoundingClientRect = function (this: HTMLElement) { + const index = Number(this.getAttribute("aria-posinset")) - 1; + const height = this.classList.contains("acpmux-row") ? drawn.get(conversation[index]?.id ?? "") ?? 0 : 0; + return { x: 0, y: 0, top: 0, left: 0, right: 0, bottom: height, width: 0, height, toJSON() { return {}; } } as DOMRect; + }; + const root = createRoot(dom.window.document.getElementById("root")!); + const overlaps = () => { + const placed = [...dom.window.document.querySelectorAll(".acpmux-row")] + .map((article) => ({ index: Number(article.getAttribute("aria-posinset")) - 1, top: Number(/translateY\(([-\d.]+)px\)/.exec(article.style.transform)?.[1]) })) + .sort((a, b) => a.index - b.index); + const found: string[] = []; + for (let position = 1; position < placed.length; position += 1) { + const above = placed[position - 1]!; + const below = placed[position]!; + if (below.index !== above.index + 1) continue; + const bottom = above.top + drawn.get(conversation[above.index]!.id)!; + if (bottom > below.top + 0.5) found.push(`${conversation[above.index]!.id} ends at ${bottom}, ${conversation[below.index]!.id} starts at ${below.top}`); + } + return found; + }; + try { + await act(async () => root.render(createElement(VirtualTranscript, { rows: conversation, onToggleActivity: () => {}, expanded: new Set() }))); + expect(overlaps()).toEqual([]); + const scroller = dom.window.document.querySelector(".acpmux-scroll") as HTMLElement; + // Opened at the latest row, it stays there as the rows settle to their drawn heights. + const spacer = dom.window.document.querySelector(".acpmux-spacer") as HTMLElement; + expect(scroller.scrollTop).toBe(parseFloat(spacer.style.height) - 600); + for (const top of [0, 4000, 9000]) { + await act(async () => { scroller.scrollTop = top; scroller.dispatchEvent(new dom.window.Event("scroll")); }); + expect(overlaps()).toEqual([]); + } + // A row above the viewport that grows leaves the row at the viewport's top where it is. + const placed = () => [...dom.window.document.querySelectorAll(".acpmux-row")].map((article) => ({ article, index: Number(article.getAttribute("aria-posinset")) - 1, top: Number(/translateY\(([-\d.]+)px\)/.exec(article.style.transform)?.[1]) })); + const atTop = () => placed().filter((row) => row.top <= scroller.scrollTop).sort((a, b) => b.top - a.top)[0]!; + const anchor = atTop(); + const offset = scroller.scrollTop - anchor.top; + const above = placed().filter((row) => row.index < anchor.index).sort((a, b) => a.index - b.index)[0]!; + drawn.set(conversation[above.index]!.id, drawn.get(conversation[above.index]!.id)! + 100); + await act(async () => { for (const callback of resizeCallbacks) (callback as (entries: { target: Element }[]) => void)([{ target: above.article }]); }); + expect(atTop().index).toBe(anchor.index); + expect(scroller.scrollTop - atTop().top).toBe(offset); + expect(overlaps()).toEqual([]); + } finally { + await act(async () => root.unmount()); + prototype.getBoundingClientRect = original; + restore(); + } + }); + /// A fling mounts rows that have not drawn yet, and each one reports its height once. + /// Placing it must not measure every row of the conversation again. + test("a row's drawn height re-places the rows without measuring them again", async () => { + const restore = fakeViewport({ width: 760, height: 600 }); + let measures = 0; + const Plain = Object.assign(() => null, { measure: () => { measures += 1; return 50; } }); + const registry = { user: Plain, assistant: Plain } as never; + const prototype = dom.window.HTMLElement.prototype; + const original = prototype.getBoundingClientRect; + let drawnHeight = 0; + prototype.getBoundingClientRect = function (this: HTMLElement) { + const height = this.classList.contains("acpmux-row") && this.getAttribute("aria-posinset") === "200" ? drawnHeight : 0; + return { x: 0, y: 0, top: 0, left: 0, right: 0, bottom: height, width: 0, height, toJSON() { return {}; } } as DOMRect; + }; + const root = createRoot(dom.window.document.getElementById("root")!); + try { + await act(async () => root.render(createElement(VirtualTranscript, { rows, onToggleActivity: () => {}, expanded: new Set(), registry }))); + const spacer = dom.window.document.querySelector(".acpmux-spacer") as HTMLElement; + const estimated = parseFloat(spacer.style.height); + const afterOpen = measures; + drawnHeight = 90; + const latest = dom.window.document.querySelector('.acpmux-row[aria-posinset="200"]')!; + await act(async () => { for (const callback of resizeCallbacks) (callback as (entries: { target: Element }[]) => void)([{ target: latest }]); }); + expect(parseFloat(spacer.style.height)).toBe(estimated + 40); + expect(measures).toBe(afterOpen); + } finally { + await act(async () => root.unmount()); + prototype.getBoundingClientRect = original; + restore(); + } + }); + + /// Rows that draw shorter than estimated shrink the content under a viewport at the latest row, + /// and the browser clamps the offset before the layout effect sees it. + test("opened at the latest row, it stays there as rows draw shorter than estimated", async () => { + const restore = fakeViewport({ width: 760, height: 600 }); + const conversation: AcpmuxRow[] = Array.from({ length: 300 }, (_, index) => ({ id: `long-${index}`, version: 1, at: index, kind: "assistant", text: `${"word ".repeat(200)}${index}` })); + const prototype = dom.window.HTMLElement.prototype; + const original = prototype.getBoundingClientRect; + prototype.getBoundingClientRect = function (this: HTMLElement) { + const height = this.classList.contains("acpmux-row") ? 40 : 0; + return { x: 0, y: 0, top: 0, left: 0, right: 0, bottom: height, width: 0, height, toJSON() { return {}; } } as DOMRect; + }; + const root = createRoot(dom.window.document.getElementById("root")!); + try { + await act(async () => root.render(createElement(VirtualTranscript, { rows: conversation, onToggleActivity: () => {}, expanded: new Set() }))); + const scroller = dom.window.document.querySelector(".acpmux-scroll") as HTMLElement; + const spacer = dom.window.document.querySelector(".acpmux-spacer") as HTMLElement; + for (let frame = 0; frame < 3; frame += 1) await act(async () => { scroller.dispatchEvent(new dom.window.Event("scroll")); }); + expect(scroller.scrollTop).toBe(parseFloat(spacer.style.height) - 600); + } finally { + await act(async () => root.unmount()); + prototype.getBoundingClientRect = original; + restore(); + } + }); +}); + describe("acpmux renderer registry", () => { test("registering the same renderer again does not re-render the pane", async () => { const root = createRoot(dom.window.document.getElementById("root")!);