From 8d0886f9b730dfa925698094f0fbafbd12044114 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 6 Sep 2026 22:49:10 +0100 Subject: [PATCH] feat(web): animate structured session interactions --- web/src/structured.test.ts | 33 +++++++++++++++- web/src/structured.ts | 65 ++++++++++++++++++++++++++----- web/src/structuredRender.test.ts | 11 ++++-- web/src/structuredRender.ts | 56 +++++++++++++++++++-------- web/src/styles.css | 66 +++++++++++++++++++++++++++++--- 5 files changed, 194 insertions(+), 37 deletions(-) diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index f2309ac..5b86f04 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -325,6 +325,7 @@ describe("structured connection: data flow", () => { transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }) }, state: idleState, }); + expect(conn.el.querySelector('[data-row-id="agent:1"]')?.classList).toContain("st-row-enter"); ws.emit({ type: "projection_update", transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial done" } }) }, @@ -332,6 +333,7 @@ describe("structured connection: data flow", () => { }); const rows = conn.el.querySelectorAll(".st-row-agent"); expect(rows).toHaveLength(1); + expect(rows[0].classList).not.toContain("st-row-enter"); expect(conn.el.textContent).toContain("partial done"); expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it conn.dispose(); @@ -357,7 +359,7 @@ describe("structured connection: data flow", () => { const groups = conn.el.querySelectorAll(".st-tool-group"); expect(groups).toHaveLength(1); expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("2 tool calls"); - expect(groups[0].querySelector(".st-tool-group-body")!.hidden).toBe(true); + expect(groups[0].querySelector(".st-tool-group-body")!.getAttribute("aria-hidden")).toBe("true"); expect(conn.el.querySelector('[data-row-id="tool:3"]')?.closest(".st-tool-group")).toBeNull(); conn.dispose(); }); @@ -1289,7 +1291,7 @@ describe("structured connection: transcript scrolling", () => { it("keeps pinned appends and patches at the bottom after layout", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); - const { values } = metrics(conn, 600, 200); + const { scroll, values } = metrics(conn, 600, 200); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); runFrames(); @@ -1305,6 +1307,7 @@ describe("structured connection: transcript scrolling", () => { }); runFrames(); expect(values.top).toBe(520); + expect(scroll.style.scrollBehavior).toBe("smooth"); values.height = 780; ws.emit({ @@ -1319,6 +1322,32 @@ describe("structured connection: transcript scrolling", () => { }); runFrames(); expect(values.top).toBe(580); + expect(scroll.style.scrollBehavior).toBe("smooth"); + conn.dispose(); + }); + + it("keeps follow-to-bottom immediate when reduced motion is requested", () => { + vi.stubGlobal("matchMedia", () => ({ matches: true })); + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + const { scroll, values } = metrics(conn, 600, 200); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + runFrames(); + + values.height = 720; + ws.emit({ + type: "projection_update", + transcript: { + type: "append", + row: row("agent:1", "agent_text", { content: { type: "text", text: "quietly" } }), + }, + state: idleState, + }); + runFrames(); + + expect(values.top).toBe(520); + expect(scroll.style.scrollBehavior).toBe(""); conn.dispose(); }); diff --git a/web/src/structured.ts b/web/src/structured.ts index 8a7edd5..3d05a39 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -148,6 +148,7 @@ export function createStructuredConnection( const rowEls = new Map(); const expandedRowIds = new Set(); const expandedGroupIds = new Set(); + const enteringRowIds = new Set(); const groupEls = new Map(); let state: ReducedState = { turn: idleTurn() }; let pendingApprovalRowId: string | null = null; @@ -171,7 +172,20 @@ export function createStructuredConnection( let scrollWorkActive = true; let parkedScrollTop: number | null = null; let scrollFrame: number | null = null; - let pendingScroll: { top: number; follow: boolean } | null = null; + let pendingScroll: { top: number; follow: boolean; smooth: boolean } | null = null; + let smoothScrollTimer: number | null = null; + let smoothFollowing = false; + + function reduceMotion(): boolean { + return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + } + + function stopSmoothFollow(): void { + if (smoothScrollTimer !== null) window.clearTimeout(smoothScrollTimer); + smoothScrollTimer = null; + smoothFollowing = false; + scrollEl.style.removeProperty("scroll-behavior"); + } function cancelScrollFrame(): void { if (scrollFrame !== null) window.cancelAnimationFrame(scrollFrame); @@ -179,15 +193,17 @@ export function createStructuredConnection( pendingScroll = null; } - function beginTranscriptLayout(forceBottom = false): void { + function beginTranscriptLayout(forceBottom = false, smooth = false): void { if (!scrollWorkActive || disposed) return; if (pendingScroll) { if (forceBottom) pendingScroll.follow = true; + if (smooth) pendingScroll.smooth = true; return; } pendingScroll = { top: scrollEl.scrollTop, follow: forceBottom || followingBottom, + smooth, }; } @@ -198,19 +214,30 @@ export function createStructuredConnection( const pending = pendingScroll; pendingScroll = null; if (!pending || disposed || !scrollWorkActive) return; - scrollEl.scrollTop = pending.follow + const target = pending.follow ? Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight) : pending.top; + if (pending.follow && pending.smooth && !reduceMotion()) { + smoothFollowing = true; + scrollEl.style.scrollBehavior = "smooth"; + scrollEl.scrollTop = target; + if (smoothScrollTimer !== null) window.clearTimeout(smoothScrollTimer); + smoothScrollTimer = window.setTimeout(() => stopSmoothFollow(), 220); + } else { + stopSmoothFollow(); + scrollEl.scrollTop = target; + } }); } - function updateTranscriptLayout(update: () => void, forceBottom = false): void { - beginTranscriptLayout(forceBottom); + function updateTranscriptLayout(update: () => void, forceBottom = false, smooth = false): void { + beginTranscriptLayout(forceBottom, smooth); update(); finishTranscriptLayout(); } function onTranscriptScroll(): void { + if (smoothFollowing) return; const distance = scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop; followingBottom = distance <= FOLLOW_BOTTOM_THRESHOLD_PX; cancelScrollFrame(); @@ -218,12 +245,19 @@ export function createStructuredConnection( function onTranscriptClick(event: Event): void { if ((event.target as Element | null)?.closest(".st-tool-head, .st-tool-group-head")) { - beginTranscriptLayout(); + beginTranscriptLayout(false, true); } } + function settleDisclosureLayout(): void { + finishTranscriptLayout(); + window.setTimeout(() => updateTranscriptLayout(() => {}, false, true), 190); + } + scrollEl.addEventListener("scroll", onTranscriptScroll); scrollEl.addEventListener("click", onTranscriptClick, true); + scrollEl.addEventListener("wheel", stopSmoothFollow, { passive: true }); + scrollEl.addEventListener("touchstart", stopSmoothFollow, { passive: true }); function lastRowIdOfKind(kind: TranscriptRowKind): string | null { for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id; @@ -268,10 +302,11 @@ export function createStructuredConnection( (expanded) => { if (expanded) expandedRowIds.add(row.id); else expandedRowIds.delete(row.id); - finishTranscriptLayout(); + settleDisclosureLayout(); }, ); if (!el) return null; + if (enteringRowIds.has(row.id)) el.classList.add("st-row-enter"); if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") { expandedRowIds.delete(row.id); } @@ -328,9 +363,12 @@ export function createStructuredConnection( (expanded) => { if (expanded) expandedGroupIds.add(groupId); else expandedGroupIds.delete(groupId); - finishTranscriptLayout(); + settleDisclosureLayout(); }, ); + if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) { + groupEl.classList.add("st-row-enter"); + } groupEls.set(groupId, groupEl); children.push(groupEl); continue; @@ -348,6 +386,7 @@ export function createStructuredConnection( (child, index) => !child.classList.contains("st-turn-meta") || index === children.length - 1, ); rowsEl.replaceChildren(...visibleChildren); + enteringRowIds.clear(); if (restoreToolFocus && focusedRowId) { rowEls.get(focusedRowId)?.querySelector(".st-tool-head")?.focus(); } else if (restoreGroupFocus && focusedGroupId) { @@ -375,6 +414,7 @@ export function createStructuredConnection( if (!delta) return; updateTranscriptLayout(() => { if (delta.type === "append") { + if (receivedSnapshot) enteringRowIds.add(delta.row.id); rowIndex.set(delta.row.id, rows.length); rows.push(delta.row); } else if (delta.type === "patch") { @@ -395,7 +435,7 @@ export function createStructuredConnection( } rebuildTranscript(); syncAvailableCommands(); - }); + }, false, true); } function sessionConfigOptions(): AcpSessionConfigOption[] { @@ -710,7 +750,7 @@ export function createStructuredConnection( input.style.overflowY = "hidden"; input.style.height = `${Math.min(input.scrollHeight, 160)}px`; if (input.scrollHeight > 160) input.style.overflowY = "auto"; - }); + }, false, true); } input.addEventListener("input", () => { autoResize(); @@ -919,6 +959,7 @@ export function createStructuredConnection( pendingScroll = { top: parkedScrollTop ?? scrollEl.scrollTop, follow: followingBottom, + smooth: false, }; parkedScrollTop = null; finishTranscriptLayout(); @@ -934,6 +975,7 @@ export function createStructuredConnection( parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop; scrollWorkActive = false; cancelScrollFrame(); + stopSmoothFollow(); cancelFocusFrame(); }, focusTerminal() { @@ -950,6 +992,7 @@ export function createStructuredConnection( mountToken++; scrollWorkActive = false; cancelScrollFrame(); + stopSmoothFollow(); cancelFocusFrame(); cancelReconnect(); closeCurrentSocket(); @@ -957,6 +1000,8 @@ export function createStructuredConnection( expandedGroupIds.clear(); scrollEl.removeEventListener("scroll", onTranscriptScroll); scrollEl.removeEventListener("click", onTranscriptClick, true); + scrollEl.removeEventListener("wheel", stopSmoothFollow); + scrollEl.removeEventListener("touchstart", stopSmoothFollow); document.removeEventListener("pointerdown", closeConfigOnOutsidePress); el.remove(); }, diff --git a/web/src/structuredRender.test.ts b/web/src/structuredRender.test.ts index d1fbec6..ea8815a 100644 --- a/web/src/structuredRender.test.ts +++ b/web/src/structuredRender.test.ts @@ -158,7 +158,10 @@ describe("buildTranscriptRow: tool calls", () => { expect(head.getAttribute("aria-expanded")).toBe("true"); expect(out.textContent).toContain("match found"); head.click(); - expect(out.hidden).toBe(true); + expect(out.hidden).toBe(false); + expect(out.getAttribute("aria-hidden")).toBe("true"); + expect(out.inert).toBe(true); + expect(out.classList.contains("is-expanded")).toBe(false); }); it("does not expand when there is nothing to show", () => { @@ -196,9 +199,11 @@ describe("buildToolGroup", () => { const head = group.querySelector(".st-tool-group-head")!; const body = group.querySelector(".st-tool-group-body")!; expect(head.textContent).toContain("2 tool calls"); - expect(body.hidden).toBe(true); + expect(body.getAttribute("aria-hidden")).toBe("true"); + expect(body.inert).toBe(true); head.click(); - expect(body.hidden).toBe(false); + expect(body.getAttribute("aria-hidden")).toBe("false"); + expect(body.classList.contains("is-expanded")).toBe(true); expect(onExpanded).toHaveBeenCalledWith(true); const toolHeads = group.querySelectorAll(".st-tool-head"); toolHeads[1].click(); diff --git a/web/src/structuredRender.ts b/web/src/structuredRender.ts index d7b65bd..771d074 100644 --- a/web/src/structuredRender.ts +++ b/web/src/structuredRender.ts @@ -269,6 +269,19 @@ function appendToolDetail(container: HTMLElement, label: string, text: string): container.appendChild(detail); } +function setDisclosure(element: HTMLElement, expanded: boolean, animate = false): void { + element.hidden = false; + if (animate && expanded && !element.classList.contains("is-expanded")) { + // Establish the collapsed grid track before opening it in the same event + // turn. Without this read, a freshly lazy-built tool body has no painted + // starting state and the browser quite reasonably skips the transition. + void element.offsetHeight; + } + element.classList.toggle("is-expanded", expanded); + element.setAttribute("aria-hidden", String(!expanded)); + element.inert = !expanded; +} + /** `+added -removed` across every diff block in a tool call's content — * real derived data, never a fabricated duration. */ function toolDiffMeta(content: AcpToolCallContent[]): string { @@ -331,17 +344,21 @@ function buildToolRow( } card.appendChild(head); - const out = el("div", "st-tool-out"); + const out = el("div", "st-tool-out st-disclosure"); + const outClip = el("div", "st-disclosure-clip"); + const outInner = el("div", "st-disclosure-inner"); + outClip.appendChild(outInner); + out.appendChild(outClip); out.hidden = true; let outBuilt = false; const buildOut = () => { if (outBuilt) return; outBuilt = true; const { command, input } = toolInput(tool); - if (command) appendToolDetail(out, "Command", command); - if (input !== null) appendToolDetail(out, "Input", input); + if (command) appendToolDetail(outInner, "Command", command); + if (input !== null) appendToolDetail(outInner, "Input", input); const rawOutput = printable(tool.rawOutput); - if (rawOutput !== null) appendToolDetail(out, "Output", rawOutput); + if (rawOutput !== null) appendToolDetail(outInner, "Output", rawOutput); const texts = content .filter((c): c is Extract => c.type === "content") .map((c) => contentBlockText(c.content)) @@ -351,28 +368,31 @@ function buildToolRow( const textPanel = el("div", "st-tool-text"); textPanel.textContent = contentText; makeCopyable(textPanel, contentText, "output"); - out.appendChild(textPanel); + outInner.appendChild(textPanel); } for (const block of content) { - if (block.type === "diff") out.appendChild(buildDiffBlock(block)); + if (block.type === "diff") outInner.appendChild(buildDiffBlock(block)); if (block.type === "terminal") { const note = el("div", "st-tool-text"); note.textContent = "[embedded terminal]"; - out.appendChild(note); + outInner.appendChild(note); } } - if (out.children.length === 0) out.hidden = true; }; card.appendChild(out); - const setExpanded = (nextExpanded: boolean) => { + const setExpanded = (nextExpanded: boolean, animate = false) => { if (nextExpanded) buildOut(); - if (out.children.length === 0) nextExpanded = false; + if (outInner.children.length === 0) nextExpanded = false; head.setAttribute("aria-expanded", String(nextExpanded)); - out.hidden = !nextExpanded; + if (outInner.children.length === 0) { + out.hidden = true; + } else { + setDisclosure(out, nextExpanded, animate); + } }; head.addEventListener("click", () => { - setExpanded(head.getAttribute("aria-expanded") !== "true"); + setExpanded(head.getAttribute("aria-expanded") !== "true", true); onExpandedChange?.(head.getAttribute("aria-expanded") === "true"); }); setExpanded(expanded); @@ -408,13 +428,17 @@ export function buildToolGroup( .join(" · "); head.append(glyph, title, names); - const body = el("div", "st-tool-group-body"); - body.hidden = !expanded; - body.append(...children); + const body = el("div", "st-tool-group-body st-disclosure"); + const bodyClip = el("div", "st-disclosure-clip"); + const bodyInner = el("div", "st-disclosure-inner"); + bodyInner.append(...children); + bodyClip.appendChild(bodyInner); + body.appendChild(bodyClip); + setDisclosure(body, expanded); head.addEventListener("click", () => { expanded = !expanded; head.setAttribute("aria-expanded", String(expanded)); - body.hidden = !expanded; + setDisclosure(body, expanded, true); onExpandedChange?.(expanded); }); wrap.append(head, body); diff --git a/web/src/styles.css b/web/src/styles.css index 70b86b4..e07179f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2601,6 +2601,7 @@ input.leaf-rename:focus { gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); min-width: 0; + transition: background var(--dur-fast) var(--ease-out); } .st-tool-head:hover { @@ -2650,11 +2651,38 @@ input.leaf-rename:focus { } .st-tool-out { - border-top: 1px solid var(--border); - padding: var(--sp-2) var(--sp-3); + border-top: 1px solid transparent; +} + +.st-disclosure { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: + grid-template-rows var(--dur) var(--ease-out), + opacity var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) var(--ease-out); +} + +.st-disclosure.is-expanded { + grid-template-rows: 1fr; + opacity: 1; +} + +.st-disclosure-clip { + min-height: 0; + overflow: hidden; +} + +.st-tool-out.is-expanded { + border-top-color: var(--border); +} + +.st-tool-out .st-disclosure-inner { display: flex; flex-direction: column; gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); } /* `display: flex` above outranks the UA sheet's `[hidden]` (same pitfall as @@ -2723,6 +2751,7 @@ input.leaf-rename:focus { gap: var(--sp-2); padding: 0; min-width: 0; + transition: color var(--dur-fast) var(--ease-out); } .st-tool-group-head:hover { @@ -2753,12 +2782,16 @@ input.leaf-rename:focus { } .st-tool-group-body { - padding-top: var(--sp-2); - display: flex; - flex-direction: column; + padding-top: 0; } -.st-tool-group-body > .st-row-tool { +.st-tool-group-body .st-disclosure-inner { + display: flex; + flex-direction: column; + padding-top: var(--sp-2); +} + +.st-tool-group-body .st-disclosure-inner > .st-row-tool { padding: var(--sp-1) 0; } @@ -2766,6 +2799,21 @@ input.leaf-rename:focus { display: none; } +/* Live transcript rows resolve into the stream through a short occlusion, + matching data becoming concrete without replaying on token patches. */ +.st-row-enter { + animation: structured-row-enter var(--dur) var(--ease-out) both; +} + +@keyframes structured-row-enter { + from { + opacity: 0.2; + transform: translateY(3px); + clip-path: inset(0 0 18% 0); + filter: blur(1px); + } +} + .st-diff-block { display: flex; flex-direction: column; @@ -3134,10 +3182,16 @@ input.leaf-rename:focus { .act, .st-copyable, .st-copyable::after, + .st-disclosure, + .st-tool-head, .st-tool-group-glyph { transition-duration: 0.01ms; } + .st-row-enter { + animation: none; + } + .structured-activity-pulse { animation: none; }