feat(web): animate structured session interactions

This commit is contained in:
Miguel Palhas
2026-09-06 22:49:10 +01:00
parent 9f82400f0a
commit 8d0886f9b7
5 changed files with 194 additions and 37 deletions
+31 -2
View File
@@ -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<HTMLElement>(".st-tool-group");
expect(groups).toHaveLength(1);
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("2 tool calls");
expect(groups[0].querySelector<HTMLElement>(".st-tool-group-body")!.hidden).toBe(true);
expect(groups[0].querySelector<HTMLElement>(".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();
});
+55 -10
View File
@@ -148,6 +148,7 @@ export function createStructuredConnection(
const rowEls = new Map<string, HTMLElement>();
const expandedRowIds = new Set<string>();
const expandedGroupIds = new Set<string>();
const enteringRowIds = new Set<string>();
const groupEls = new Map<string, HTMLElement>();
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<HTMLButtonElement>(".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();
},
+8 -3
View File
@@ -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<HTMLButtonElement>(".st-tool-group-head")!;
const body = group.querySelector<HTMLElement>(".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<HTMLButtonElement>(".st-tool-head");
toolHeads[1].click();
+40 -16
View File
@@ -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<AcpToolCallContent, { type: "content" }> => 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);
+60 -6
View File
@@ -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;
}