diff --git a/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts b/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts index 7bbf85f6b955..a8f521ca3f39 100644 --- a/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts +++ b/ui/src/e2e/chat-tool-turn-outcome.e2e.test.ts @@ -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(); + }); }); diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css index 45085fa0ab88..7883d21e77aa 100644 --- a/ui/src/styles/chat/tool-cards.css +++ b/ui/src/styles/chat/tool-cards.css @@ -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;