feat(web): animate structured session interactions
This commit is contained in:
@@ -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
@@ -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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user