Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1951,6 +1952,9 @@ export function VirtualTranscript({
// while the pointer rests on the same row.
const pointedRows = useRef<AcpmuxRow[] | undefined>(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<FoldAnchor | undefined>(undefined);
/// Where the reader last saw it rest, measured in view; undefined until it is.
const seenAnchor = useRef<ScrollAnchor | null | undefined>(undefined);
/// When the reader last moved the transcript themselves (wheel, pointer, touch or key).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -2728,13 +2745,32 @@ 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<HTMLElement>(".acpmux-main");
const removeWheel = node && main ? installWheelPassThrough(node, main) : undefined;
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?.();
};
}, []);
Expand Down
18 changes: 11 additions & 7 deletions webviews/src/agent-session/acpmux/conversation/CommandRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -66,13 +68,15 @@ export function CommandRow({ item }: { item: AcpmuxActivity }) {
className={`cv-tool__chevron cv-rotor${open ? " is-open" : " is-hover"}`}
/>
</button>
{open && (
<ShellBlock
id={detailsId}
command={tool.command}
output={tool.output?.replace(/\n$/, "")}
exitCode={tool.exitCode}
/>
{fold.present && (
<div ref={fold.ref} className="cv-fold">
<ShellBlock
id={detailsId}
command={tool.command}
output={tool.output?.replace(/\n$/, "")}
exitCode={tool.exitCode}
/>
</div>
)}
</>
);
Expand Down
25 changes: 17 additions & 8 deletions webviews/src/agent-session/acpmux/conversation/McpCallRows.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 ? (
Expand All @@ -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!));
Expand All @@ -54,11 +56,13 @@ function McpGroupRow({ items, disclosureKey }: { items: readonly AcpmuxActivity[
className={`cv-tool__chevron cv-rotor${open ? " is-open" : " is-hover"}`}
/>
</button>
{open && (
<div className="cv-tool-group" id={detailsId}>
{items.map((item, index) => (
<McpCallRow key={item.tool!.id || index} item={item} />
))}
{fold.present && (
<div ref={fold.ref} className="cv-fold">
<div className="cv-tool-group" id={detailsId}>
{items.map((item, index) => (
<McpCallRow key={item.tool!.id || index} item={item} />
))}
</div>
</div>
)}
</>
Expand All @@ -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]);
Expand Down Expand Up @@ -108,7 +113,11 @@ function McpCallRow({ item }: { item: AcpmuxActivity }) {
/>
</button>
{images.length > 0 && <ToolImages sources={images} />}
{open && <McpCallDetails id={detailsId} call={call} raw={raw} output={tool.output} reveal={reveal} />}
{fold.present && (
<div ref={fold.ref} className="cv-fold">
<McpCallDetails id={detailsId} call={call} raw={raw} output={tool.output} reveal={reveal} />
</div>
)}
</>
);
}
Expand Down
19 changes: 13 additions & 6 deletions webviews/src/agent-session/acpmux/conversation/ThoughtRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,13 +22,17 @@ import {
type ThoughtFoldThreshold,
type ThoughtHeader,
} from "./ThoughtRow";
import { foldMotion } from "./fold";

let dom: JSDOM | undefined;
let root: Root | undefined;
let container: HTMLElement;
const scope = globalThis as Record<string, unknown>;
const saved = new Map<string, unknown>();
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;
Expand Down Expand Up @@ -81,14 +84,17 @@ 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")!;
});

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) {
Expand Down Expand Up @@ -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();
Expand Down
35 changes: 10 additions & 25 deletions webviews/src/agent-session/acpmux/conversation/ThoughtRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand Down Expand Up @@ -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<HTMLElement>(".acpmux-scroll")?.clientHeight ?? 0,
reducedMotion: () =>
typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true,
};

export type ThoughtHeader = "duration" | "preview";
Expand Down Expand Up @@ -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,
Expand All @@ -92,16 +88,16 @@ export function ThoughtRow({
const enabled = thoughtLayout.enabled();
const [long, setLong] = useState(false);
const [choice, setChoice] = useState<boolean | undefined>(() => (id ? choices.get(id) : undefined));
const [closing, setClosing] = useState(false);
const full = useRef<HTMLSpanElement>(null);
const body = useRef<HTMLDivElement>(null);
const toggled = useRef(false);
const bodyId = useId();
const folds = enabled && long && !streaming;
const liveViewport = enabled && streaming && thoughtLayout.liveViewport();
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);
Expand All @@ -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)
Expand All @@ -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 (
Expand All @@ -188,9 +171,11 @@ export function ThoughtRow({
className={`cv-tool__chevron cv-rotor${open ? " is-open" : " is-hover"}`}
/>
</button>
{(open || closing) && (
<div ref={body} id={bodyId} className="cv-thought__text cv-thought__body selectable" dir="auto">
{text}
{fold.present && (
<div ref={fold.ref} className="cv-fold">
<div id={bodyId} className="cv-thought__text cv-thought__body selectable" dir="auto">
{text}
</div>
</div>
)}
</>
Expand Down
Loading