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" } }) }, transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }) },
state: idleState, state: idleState,
}); });
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.classList).toContain("st-row-enter");
ws.emit({ ws.emit({
type: "projection_update", type: "projection_update",
transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial done" } }) }, 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"); const rows = conn.el.querySelectorAll(".st-row-agent");
expect(rows).toHaveLength(1); expect(rows).toHaveLength(1);
expect(rows[0].classList).not.toContain("st-row-enter");
expect(conn.el.textContent).toContain("partial done"); 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 expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
conn.dispose(); conn.dispose();
@@ -357,7 +359,7 @@ describe("structured connection: data flow", () => {
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group"); const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
expect(groups).toHaveLength(1); expect(groups).toHaveLength(1);
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("2 tool calls"); 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(); expect(conn.el.querySelector('[data-row-id="tool:3"]')?.closest(".st-tool-group")).toBeNull();
conn.dispose(); conn.dispose();
}); });
@@ -1289,7 +1291,7 @@ describe("structured connection: transcript scrolling", () => {
it("keeps pinned appends and patches at the bottom after layout", () => { it("keeps pinned appends and patches at the bottom after layout", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
const { values } = metrics(conn, 600, 200); const { scroll, values } = metrics(conn, 600, 200);
ws.open(); ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
runFrames(); runFrames();
@@ -1305,6 +1307,7 @@ describe("structured connection: transcript scrolling", () => {
}); });
runFrames(); runFrames();
expect(values.top).toBe(520); expect(values.top).toBe(520);
expect(scroll.style.scrollBehavior).toBe("smooth");
values.height = 780; values.height = 780;
ws.emit({ ws.emit({
@@ -1319,6 +1322,32 @@ describe("structured connection: transcript scrolling", () => {
}); });
runFrames(); runFrames();
expect(values.top).toBe(580); 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(); conn.dispose();
}); });
+55 -10
View File
@@ -148,6 +148,7 @@ export function createStructuredConnection(
const rowEls = new Map<string, HTMLElement>(); const rowEls = new Map<string, HTMLElement>();
const expandedRowIds = new Set<string>(); const expandedRowIds = new Set<string>();
const expandedGroupIds = new Set<string>(); const expandedGroupIds = new Set<string>();
const enteringRowIds = new Set<string>();
const groupEls = new Map<string, HTMLElement>(); const groupEls = new Map<string, HTMLElement>();
let state: ReducedState = { turn: idleTurn() }; let state: ReducedState = { turn: idleTurn() };
let pendingApprovalRowId: string | null = null; let pendingApprovalRowId: string | null = null;
@@ -171,7 +172,20 @@ export function createStructuredConnection(
let scrollWorkActive = true; let scrollWorkActive = true;
let parkedScrollTop: number | null = null; let parkedScrollTop: number | null = null;
let scrollFrame: 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 { function cancelScrollFrame(): void {
if (scrollFrame !== null) window.cancelAnimationFrame(scrollFrame); if (scrollFrame !== null) window.cancelAnimationFrame(scrollFrame);
@@ -179,15 +193,17 @@ export function createStructuredConnection(
pendingScroll = null; pendingScroll = null;
} }
function beginTranscriptLayout(forceBottom = false): void { function beginTranscriptLayout(forceBottom = false, smooth = false): void {
if (!scrollWorkActive || disposed) return; if (!scrollWorkActive || disposed) return;
if (pendingScroll) { if (pendingScroll) {
if (forceBottom) pendingScroll.follow = true; if (forceBottom) pendingScroll.follow = true;
if (smooth) pendingScroll.smooth = true;
return; return;
} }
pendingScroll = { pendingScroll = {
top: scrollEl.scrollTop, top: scrollEl.scrollTop,
follow: forceBottom || followingBottom, follow: forceBottom || followingBottom,
smooth,
}; };
} }
@@ -198,19 +214,30 @@ export function createStructuredConnection(
const pending = pendingScroll; const pending = pendingScroll;
pendingScroll = null; pendingScroll = null;
if (!pending || disposed || !scrollWorkActive) return; if (!pending || disposed || !scrollWorkActive) return;
scrollEl.scrollTop = pending.follow const target = pending.follow
? Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight) ? Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight)
: pending.top; : 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 { function updateTranscriptLayout(update: () => void, forceBottom = false, smooth = false): void {
beginTranscriptLayout(forceBottom); beginTranscriptLayout(forceBottom, smooth);
update(); update();
finishTranscriptLayout(); finishTranscriptLayout();
} }
function onTranscriptScroll(): void { function onTranscriptScroll(): void {
if (smoothFollowing) return;
const distance = scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop; const distance = scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop;
followingBottom = distance <= FOLLOW_BOTTOM_THRESHOLD_PX; followingBottom = distance <= FOLLOW_BOTTOM_THRESHOLD_PX;
cancelScrollFrame(); cancelScrollFrame();
@@ -218,12 +245,19 @@ export function createStructuredConnection(
function onTranscriptClick(event: Event): void { function onTranscriptClick(event: Event): void {
if ((event.target as Element | null)?.closest(".st-tool-head, .st-tool-group-head")) { 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("scroll", onTranscriptScroll);
scrollEl.addEventListener("click", onTranscriptClick, true); scrollEl.addEventListener("click", onTranscriptClick, true);
scrollEl.addEventListener("wheel", stopSmoothFollow, { passive: true });
scrollEl.addEventListener("touchstart", stopSmoothFollow, { passive: true });
function lastRowIdOfKind(kind: TranscriptRowKind): string | null { function lastRowIdOfKind(kind: TranscriptRowKind): string | null {
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id; 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) => { (expanded) => {
if (expanded) expandedRowIds.add(row.id); if (expanded) expandedRowIds.add(row.id);
else expandedRowIds.delete(row.id); else expandedRowIds.delete(row.id);
finishTranscriptLayout(); settleDisclosureLayout();
}, },
); );
if (!el) return null; 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") { if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
expandedRowIds.delete(row.id); expandedRowIds.delete(row.id);
} }
@@ -328,9 +363,12 @@ export function createStructuredConnection(
(expanded) => { (expanded) => {
if (expanded) expandedGroupIds.add(groupId); if (expanded) expandedGroupIds.add(groupId);
else expandedGroupIds.delete(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); groupEls.set(groupId, groupEl);
children.push(groupEl); children.push(groupEl);
continue; continue;
@@ -348,6 +386,7 @@ export function createStructuredConnection(
(child, index) => !child.classList.contains("st-turn-meta") || index === children.length - 1, (child, index) => !child.classList.contains("st-turn-meta") || index === children.length - 1,
); );
rowsEl.replaceChildren(...visibleChildren); rowsEl.replaceChildren(...visibleChildren);
enteringRowIds.clear();
if (restoreToolFocus && focusedRowId) { if (restoreToolFocus && focusedRowId) {
rowEls.get(focusedRowId)?.querySelector<HTMLButtonElement>(".st-tool-head")?.focus(); rowEls.get(focusedRowId)?.querySelector<HTMLButtonElement>(".st-tool-head")?.focus();
} else if (restoreGroupFocus && focusedGroupId) { } else if (restoreGroupFocus && focusedGroupId) {
@@ -375,6 +414,7 @@ export function createStructuredConnection(
if (!delta) return; if (!delta) return;
updateTranscriptLayout(() => { updateTranscriptLayout(() => {
if (delta.type === "append") { if (delta.type === "append") {
if (receivedSnapshot) enteringRowIds.add(delta.row.id);
rowIndex.set(delta.row.id, rows.length); rowIndex.set(delta.row.id, rows.length);
rows.push(delta.row); rows.push(delta.row);
} else if (delta.type === "patch") { } else if (delta.type === "patch") {
@@ -395,7 +435,7 @@ export function createStructuredConnection(
} }
rebuildTranscript(); rebuildTranscript();
syncAvailableCommands(); syncAvailableCommands();
}); }, false, true);
} }
function sessionConfigOptions(): AcpSessionConfigOption[] { function sessionConfigOptions(): AcpSessionConfigOption[] {
@@ -710,7 +750,7 @@ export function createStructuredConnection(
input.style.overflowY = "hidden"; input.style.overflowY = "hidden";
input.style.height = `${Math.min(input.scrollHeight, 160)}px`; input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
if (input.scrollHeight > 160) input.style.overflowY = "auto"; if (input.scrollHeight > 160) input.style.overflowY = "auto";
}); }, false, true);
} }
input.addEventListener("input", () => { input.addEventListener("input", () => {
autoResize(); autoResize();
@@ -919,6 +959,7 @@ export function createStructuredConnection(
pendingScroll = { pendingScroll = {
top: parkedScrollTop ?? scrollEl.scrollTop, top: parkedScrollTop ?? scrollEl.scrollTop,
follow: followingBottom, follow: followingBottom,
smooth: false,
}; };
parkedScrollTop = null; parkedScrollTop = null;
finishTranscriptLayout(); finishTranscriptLayout();
@@ -934,6 +975,7 @@ export function createStructuredConnection(
parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop; parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop;
scrollWorkActive = false; scrollWorkActive = false;
cancelScrollFrame(); cancelScrollFrame();
stopSmoothFollow();
cancelFocusFrame(); cancelFocusFrame();
}, },
focusTerminal() { focusTerminal() {
@@ -950,6 +992,7 @@ export function createStructuredConnection(
mountToken++; mountToken++;
scrollWorkActive = false; scrollWorkActive = false;
cancelScrollFrame(); cancelScrollFrame();
stopSmoothFollow();
cancelFocusFrame(); cancelFocusFrame();
cancelReconnect(); cancelReconnect();
closeCurrentSocket(); closeCurrentSocket();
@@ -957,6 +1000,8 @@ export function createStructuredConnection(
expandedGroupIds.clear(); expandedGroupIds.clear();
scrollEl.removeEventListener("scroll", onTranscriptScroll); scrollEl.removeEventListener("scroll", onTranscriptScroll);
scrollEl.removeEventListener("click", onTranscriptClick, true); scrollEl.removeEventListener("click", onTranscriptClick, true);
scrollEl.removeEventListener("wheel", stopSmoothFollow);
scrollEl.removeEventListener("touchstart", stopSmoothFollow);
document.removeEventListener("pointerdown", closeConfigOnOutsidePress); document.removeEventListener("pointerdown", closeConfigOnOutsidePress);
el.remove(); el.remove();
}, },
+8 -3
View File
@@ -158,7 +158,10 @@ describe("buildTranscriptRow: tool calls", () => {
expect(head.getAttribute("aria-expanded")).toBe("true"); expect(head.getAttribute("aria-expanded")).toBe("true");
expect(out.textContent).toContain("match found"); expect(out.textContent).toContain("match found");
head.click(); 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", () => { 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 head = group.querySelector<HTMLButtonElement>(".st-tool-group-head")!;
const body = group.querySelector<HTMLElement>(".st-tool-group-body")!; const body = group.querySelector<HTMLElement>(".st-tool-group-body")!;
expect(head.textContent).toContain("2 tool calls"); 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(); 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); expect(onExpanded).toHaveBeenCalledWith(true);
const toolHeads = group.querySelectorAll<HTMLButtonElement>(".st-tool-head"); const toolHeads = group.querySelectorAll<HTMLButtonElement>(".st-tool-head");
toolHeads[1].click(); toolHeads[1].click();
+40 -16
View File
@@ -269,6 +269,19 @@ function appendToolDetail(container: HTMLElement, label: string, text: string):
container.appendChild(detail); 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 — /** `+added -removed` across every diff block in a tool call's content —
* real derived data, never a fabricated duration. */ * real derived data, never a fabricated duration. */
function toolDiffMeta(content: AcpToolCallContent[]): string { function toolDiffMeta(content: AcpToolCallContent[]): string {
@@ -331,17 +344,21 @@ function buildToolRow(
} }
card.appendChild(head); 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; out.hidden = true;
let outBuilt = false; let outBuilt = false;
const buildOut = () => { const buildOut = () => {
if (outBuilt) return; if (outBuilt) return;
outBuilt = true; outBuilt = true;
const { command, input } = toolInput(tool); const { command, input } = toolInput(tool);
if (command) appendToolDetail(out, "Command", command); if (command) appendToolDetail(outInner, "Command", command);
if (input !== null) appendToolDetail(out, "Input", input); if (input !== null) appendToolDetail(outInner, "Input", input);
const rawOutput = printable(tool.rawOutput); const rawOutput = printable(tool.rawOutput);
if (rawOutput !== null) appendToolDetail(out, "Output", rawOutput); if (rawOutput !== null) appendToolDetail(outInner, "Output", rawOutput);
const texts = content const texts = content
.filter((c): c is Extract<AcpToolCallContent, { type: "content" }> => c.type === "content") .filter((c): c is Extract<AcpToolCallContent, { type: "content" }> => c.type === "content")
.map((c) => contentBlockText(c.content)) .map((c) => contentBlockText(c.content))
@@ -351,28 +368,31 @@ function buildToolRow(
const textPanel = el("div", "st-tool-text"); const textPanel = el("div", "st-tool-text");
textPanel.textContent = contentText; textPanel.textContent = contentText;
makeCopyable(textPanel, contentText, "output"); makeCopyable(textPanel, contentText, "output");
out.appendChild(textPanel); outInner.appendChild(textPanel);
} }
for (const block of content) { 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") { if (block.type === "terminal") {
const note = el("div", "st-tool-text"); const note = el("div", "st-tool-text");
note.textContent = "[embedded terminal]"; note.textContent = "[embedded terminal]";
out.appendChild(note); outInner.appendChild(note);
} }
} }
if (out.children.length === 0) out.hidden = true;
}; };
card.appendChild(out); card.appendChild(out);
const setExpanded = (nextExpanded: boolean) => { const setExpanded = (nextExpanded: boolean, animate = false) => {
if (nextExpanded) buildOut(); if (nextExpanded) buildOut();
if (out.children.length === 0) nextExpanded = false; if (outInner.children.length === 0) nextExpanded = false;
head.setAttribute("aria-expanded", String(nextExpanded)); 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", () => { head.addEventListener("click", () => {
setExpanded(head.getAttribute("aria-expanded") !== "true"); setExpanded(head.getAttribute("aria-expanded") !== "true", true);
onExpandedChange?.(head.getAttribute("aria-expanded") === "true"); onExpandedChange?.(head.getAttribute("aria-expanded") === "true");
}); });
setExpanded(expanded); setExpanded(expanded);
@@ -408,13 +428,17 @@ export function buildToolGroup(
.join(" · "); .join(" · ");
head.append(glyph, title, names); head.append(glyph, title, names);
const body = el("div", "st-tool-group-body"); const body = el("div", "st-tool-group-body st-disclosure");
body.hidden = !expanded; const bodyClip = el("div", "st-disclosure-clip");
body.append(...children); const bodyInner = el("div", "st-disclosure-inner");
bodyInner.append(...children);
bodyClip.appendChild(bodyInner);
body.appendChild(bodyClip);
setDisclosure(body, expanded);
head.addEventListener("click", () => { head.addEventListener("click", () => {
expanded = !expanded; expanded = !expanded;
head.setAttribute("aria-expanded", String(expanded)); head.setAttribute("aria-expanded", String(expanded));
body.hidden = !expanded; setDisclosure(body, expanded, true);
onExpandedChange?.(expanded); onExpandedChange?.(expanded);
}); });
wrap.append(head, body); wrap.append(head, body);
+60 -6
View File
@@ -2601,6 +2601,7 @@ input.leaf-rename:focus {
gap: var(--sp-2); gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3); padding: var(--sp-2) var(--sp-3);
min-width: 0; min-width: 0;
transition: background var(--dur-fast) var(--ease-out);
} }
.st-tool-head:hover { .st-tool-head:hover {
@@ -2650,11 +2651,38 @@ input.leaf-rename:focus {
} }
.st-tool-out { .st-tool-out {
border-top: 1px solid var(--border); border-top: 1px solid transparent;
padding: var(--sp-2) var(--sp-3); }
.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; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--sp-2); gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
} }
/* `display: flex` above outranks the UA sheet's `[hidden]` (same pitfall as /* `display: flex` above outranks the UA sheet's `[hidden]` (same pitfall as
@@ -2723,6 +2751,7 @@ input.leaf-rename:focus {
gap: var(--sp-2); gap: var(--sp-2);
padding: 0; padding: 0;
min-width: 0; min-width: 0;
transition: color var(--dur-fast) var(--ease-out);
} }
.st-tool-group-head:hover { .st-tool-group-head:hover {
@@ -2753,12 +2782,16 @@ input.leaf-rename:focus {
} }
.st-tool-group-body { .st-tool-group-body {
padding-top: var(--sp-2); padding-top: 0;
display: flex;
flex-direction: column;
} }
.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; padding: var(--sp-1) 0;
} }
@@ -2766,6 +2799,21 @@ input.leaf-rename:focus {
display: none; 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 { .st-diff-block {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -3134,10 +3182,16 @@ input.leaf-rename:focus {
.act, .act,
.st-copyable, .st-copyable,
.st-copyable::after, .st-copyable::after,
.st-disclosure,
.st-tool-head,
.st-tool-group-glyph { .st-tool-group-glyph {
transition-duration: 0.01ms; transition-duration: 0.01ms;
} }
.st-row-enter {
animation: none;
}
.structured-activity-pulse { .structured-activity-pulse {
animation: none; animation: none;
} }