feat(ui): sweep a text wave across the active tool row while it runs (#104266)

This commit is contained in:
Peter Steinberger
2026-07-11 00:28:33 -07:00
committed by GitHub
parent 54eda58447
commit 7da12cbbe5
2 changed files with 141 additions and 0 deletions
@@ -175,4 +175,98 @@ describeControlUiE2e("Control UI autonomous tool-turn outcomes", () => {
await captureToolActivityProof(page, "parallel-multifile-expanded");
await context.close();
});
it("sweeps a text wave over the active tool row and stops it on the result", async () => {
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
historyMessages: [
{
role: "assistant",
content: [{ type: "text", text: "Ready for the running tool wave proof." }],
timestamp: Date.now(),
},
],
});
await page.goto(`${server.baseUrl}chat`);
await page.getByText("Ready for the running tool wave proof.").waitFor();
await page.locator(".agent-chat__input textarea").fill("run a long command");
await page.getByRole("button", { name: "Send message" }).click();
const send = await gateway.waitForRequest("chat.send");
const runId = (send.params as { idempotencyKey?: string }).idempotencyKey as string;
await gateway.emitGatewayEvent("agent", {
runId,
seq: 1,
stream: "tool",
ts: Date.now(),
sessionKey: "main",
data: {
toolCallId: "call-wave",
name: "exec",
phase: "start",
args: { command: "pnpm check:changed" },
},
});
// Start-phase sync is throttled and repaints on the next event, so follow
// with a delta (as real runs do) to surface the live card.
await page.waitForTimeout(200);
await gateway.emitGatewayEvent("chat", {
deltaText: "Working on it.",
message: {
content: [{ text: "Working on it.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
runId,
sessionKey: "main",
state: "delta",
});
await page.getByText("Working on it.").waitFor();
const runningRow = page.locator(".chat-tool-row--running");
await runningRow.waitFor();
// Visual-regression guard for the active-task text wave: the running
// command text must carry the glyph-clipped gradient animation.
const wave = await runningRow.locator(".chat-tool-row__cmd").evaluate((node) => {
const style = getComputedStyle(node);
return {
animationName: style.animationName,
backgroundClip: style.getPropertyValue("-webkit-background-clip") || style.backgroundClip,
color: style.color,
};
});
expect(wave.animationName).toBe("chatToolRowTextWave");
expect(wave.backgroundClip).toBe("text");
expect(wave.color).toBe("rgba(0, 0, 0, 0)");
await captureToolActivityProof(page, "tool-row-running-text-wave");
await gateway.emitGatewayEvent("agent", {
runId,
seq: 2,
stream: "tool",
ts: Date.now(),
sessionKey: "main",
data: {
toolCallId: "call-wave",
name: "exec",
phase: "result",
result: { text: "done" },
},
});
// The wave is a live-run marker only: the result event must end it and
// restore plain text color even though the run has not finished yet.
await expect.poll(() => page.locator(".chat-tool-row--running").count()).toBe(0);
const settled = await page
.locator(".chat-tool-row__cmd")
.first()
.evaluate((node) => {
const style = getComputedStyle(node);
return { animationName: style.animationName, color: style.color };
});
expect(settled.animationName).toBe("none");
expect(settled.color).not.toBe("rgba(0, 0, 0, 0)");
await context.close();
});
});
+47
View File
@@ -195,6 +195,53 @@
color: var(--accent-2, #14b8a6);
}
/* Active-task text wave: a subtle left-to-right color sweep across the
running row's primary text (same shimmer language as the skeleton/usage
loaders, clipped to glyphs). The band fades text toward --bg so it stays
correct in both themes. Scoped to no-preference so reduced-motion users
keep plain static text and syntax colors. */
@media (prefers-reduced-motion: no-preference) {
.chat-tool-row--running :is(
.chat-tool-msg-summary__label,
.chat-tool-row__title,
.chat-tool-row__verb,
.chat-tool-row__target,
.chat-tool-row__cmd:not(.chat-tool-row__cmd--secondary)
) {
background-image: linear-gradient(
90deg,
var(--text) 0%,
var(--text) 35%,
color-mix(in srgb, var(--text) 42%, var(--bg) 58%) 50%,
var(--text) 65%,
var(--text) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: chatToolRowTextWave 2.2s ease-in-out infinite;
}
/* Syntax-highlight tokens go transparent while running so the parent's
clipped gradient paints their glyphs; token colors return on completion. */
.chat-tool-row--running .chat-tool-row__cmd:not(.chat-tool-row__cmd--secondary) > span {
color: transparent;
}
}
/* Gradient is 2x element width with identical end stops, so one full period
per cycle wraps seamlessly and the band sweeps left to right. */
@keyframes chatToolRowTextWave {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
.chat-tool-row__spinner {
flex-shrink: 0;
margin-left: auto;