From 5bb35b47da3c2fc497f5db1e90159e8595f89bc1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Jul 2026 11:37:27 -0700 Subject: [PATCH] improve(ui): make the working claw punch instead of fade while the agent works (#105597) While the agent works with nothing streaming, the web chat's reading indicator now skips the assistant avatar and drops the opacity fade. The claw shadowboxes - jab, jab, cross - with the jaw snapping shut on impacts, a pow star on the cross, and a seeded per-run fighting style (orthodox, mirrored southpaw, fast flurry, rare haymaker). Reduced motion disables all of it; direct threads and narrow layouts that hide avatars keep their flat left edge. --- .../chat/components/chat-message.test.ts | 50 ++++- ui/src/pages/chat/components/chat-message.ts | 58 +++++- ui/src/styles/chat/grouped.css | 19 +- ui/src/styles/chat/tool-cards.css | 181 ++++++++++++++---- 4 files changed, 262 insertions(+), 46 deletions(-) diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index c46a2678edd2..d20f810ea74e 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -1083,17 +1083,61 @@ describe("grouped chat rendering", () => { ); }); - it("renders a reading-indicator-only run as one group with no footer", () => { + it("renders a reading-indicator-only run without avatar or footer", () => { const container = document.createElement("div"); render(renderStreamGroup([{ kind: "reading-indicator", key: "reading" }]), container); - expect(container.querySelectorAll(".chat-group.assistant")).toHaveLength(1); - expect(container.querySelectorAll(".chat-avatar.assistant")).toHaveLength(1); + const group = container.querySelector(".chat-group.assistant"); + expect(group).not.toBeNull(); + expect(group?.classList.contains("chat-group--working")).toBe(true); + // Working runs are pure claw: the avatar only arrives with stream text. + expect(container.querySelectorAll(".chat-avatar.assistant")).toHaveLength(0); expect(container.querySelector(".chat-reading-indicator")).not.toBeNull(); expect(container.querySelector(".chat-group-footer")).toBeNull(); }); + it("keeps the avatar once a stream part joins the reading indicator", () => { + const container = document.createElement("div"); + + render( + renderStreamGroup([ + { kind: "stream", key: "stream:s:live", text: "reply", startedAt: 10, isStreaming: true }, + { kind: "reading-indicator", key: "reading" }, + ]), + container, + ); + + const group = container.querySelector(".chat-group.assistant"); + expect(group?.classList.contains("chat-group--working")).toBe(false); + expect(container.querySelectorAll(".chat-avatar.assistant")).toHaveLength(1); + expect(container.querySelector(".chat-reading-indicator")).not.toBeNull(); + }); + + it("seeds a stable punch stance per reading-indicator key", () => { + const stanceFor = (key: string) => { + const container = document.createElement("div"); + render(renderStreamGroup([{ kind: "reading-indicator", key }]), container); + const bubble = container.querySelector(".chat-reading-indicator"); + return [...(bubble?.classList ?? [])].filter((cls) => + cls.startsWith("chat-reading-indicator--"), + ); + }; + + const first = stanceFor("stream:agent:main:pending"); + // Stable across re-renders: same key always fights the same style. + expect(stanceFor("stream:agent:main:pending")).toEqual(first); + // At most one stance modifier; orthodox is the unmarked default. + expect(first.length).toBeLessThanOrEqual(1); + for (const cls of first) { + expect([ + "chat-reading-indicator--southpaw", + "chat-reading-indicator--flurry", + "chat-reading-indicator--haymaker", + ]).toContain(cls); + } + }); + it("renders configured local user names", () => { const renderUser = (opts: Partial) => { const container = document.createElement("div"); diff --git a/ui/src/pages/chat/components/chat-message.ts b/ui/src/pages/chat/components/chat-message.ts index 5fbce8d842a5..4bb2130af6ff 100644 --- a/ui/src/pages/chat/components/chat-message.ts +++ b/ui/src/pages/chat/components/chat-message.ts @@ -583,12 +583,47 @@ type StreamGroupOptions = { authToken?: string | null; }; -function renderReadingIndicatorBubble() { - // Working claw: the brand pincer rests slightly open where the reply will - // materialize and pinches once per cycle (same gesture as the favicon - // mascot snap). aria-hidden; the composer sr-only run-status announces. +// One salt per page load so each run's fighter rerolls between visits while +// re-renders within a load stay stable for a given item key (same trick as +// the lobster pet's LOAD_SALT). +const PUNCH_SALT = Math.trunc(Math.random() * 0xffffffff); + +// Weighted fighting styles for the working claw; class suffixes map to the +// stance variants in styles/chat/tool-cards.css. Orthodox is the unmarked +// default; southpaw mirrors, flurry speeds the combo up, haymaker is the +// rare slow heavyweight with the big pow. +const PUNCH_STANCES: Array<[stance: string, weight: number]> = [ + ["", 47], + ["chat-reading-indicator--southpaw", 35], + ["chat-reading-indicator--flurry", 12], + ["chat-reading-indicator--haymaker", 6], +]; + +function punchStanceClass(key: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < key.length; i++) { + hash ^= key.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + const total = PUNCH_STANCES.reduce((sum, [, weight]) => sum + weight, 0); + let roll = ((((hash ^ PUNCH_SALT) >>> 0) % 1000) / 1000) * total; + for (const [stance, weight] of PUNCH_STANCES) { + roll -= weight; + if (roll <= 0) { + return stance; + } + } + return ""; +} + +function renderReadingIndicatorBubble(key: string) { + // Working claw: the brand pincer shadowboxes where the reply will + // materialize - jab, jab, cross with a pow on impact. The stance is seeded + // per item key so each run fights its own style but re-renders never + // flicker. aria-hidden; the composer sr-only run-status announces. + const stance = punchStanceClass(key); return html` - + `; } @@ -602,14 +637,21 @@ export function renderStreamGroup(parts: StreamGroupPart[], opts: StreamGroupOpt // is only the reading indicator has no timestamp and therefore no footer. const streamStarts = parts.flatMap((part) => (part.kind === "stream" ? [part.startedAt] : [])); const footerStartedAt = streamStarts.length > 0 ? Math.min(...streamStarts) : null; + // While the agent works with nothing streamed yet the run is pure claw: no + // avatar next to it - the punching pincer is the whole signal. The avatar + // arrives with the first stream part. + const indicatorOnly = parts.every((part) => part.kind === "reading-indicator"); + const avatar = indicatorOnly + ? nothing + : renderChatAvatar("assistant", assistant, undefined, basePath, authToken); return html` -
- ${renderChatAvatar("assistant", assistant, undefined, basePath, authToken)} +
+ ${avatar}
${parts.map((part) => part.kind === "reading-indicator" - ? renderReadingIndicatorBubble() + ? renderReadingIndicatorBubble(part.key) : renderGroupedMessage( { role: "assistant", diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index f756b8d3a162..e70fbfca4071 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -318,11 +318,22 @@ img.chat-avatar { box-shadow: none; } -/* Keep the working spark on the same left edge as the flat text column. */ +/* Keep the working claw on the same left edge as the flat text column. */ .chat-group.assistant .chat-bubble.chat-reading-indicator { padding: 10px 0; } +/* Working groups skip the avatar (the punching claw is the whole signal); + the inset keeps the claw on the text column so the reply materializes + exactly where it punched. Direct threads have no avatar indent to match. */ +.chat-group--working .chat-group-messages { + padding-left: 46px; /* .chat-avatar 36px + .chat-group gap 10px */ +} + +.chat-thread--direct .chat-group--working .chat-group-messages { + padding-left: 0; +} + /* Direct (1:1) threads drop avatars entirely: one agent plus one user makes the repeated identity icons pure decoration. Group threads (any labeled foreign sender) keep avatars as the always-visible identity marker. */ @@ -732,4 +743,10 @@ details.msg-meta:not([open]) .msg-meta__details { img.chat-avatar { display: none; } + + /* Avatars are hidden at this width, so the working group's avatar-column + inset would strand the claw 46px from where the reply text lands. */ + .chat-group--working .chat-group-messages { + padding-left: 0; + } } diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css index bcf9d0b81b34..1482294bdafc 100644 --- a/ui/src/styles/chat/tool-cards.css +++ b/ui/src/styles/chat/tool-cards.css @@ -1025,12 +1025,19 @@ color: inherit; } -/* Reading indicator = working claw: the brand pincer where the reply will - materialize while the agent works with nothing visibly streaming. Bubble - chrome and sizing come from .chat-bubble.chat-reading-indicator - (components.css). Filled silhouette; 18px because the claw is denser than - a star glyph. */ +/* Reading indicator = working claw: the brand pincer shadowboxes where the + reply will materialize while the agent works with nothing visibly + streaming. Bubble chrome and sizing come from + .chat-bubble.chat-reading-indicator (components.css). Filled silhouette; + 18px because the claw is denser than a star glyph. No opacity fade: the + claw stays solid and the motion carries the "working" signal. One cycle = + jab, jab, cross; the jaw snaps shut on every impact and a pow star pops on + the cross. --claw-cycle drives all three animations so stance variants + only retune the tempo. */ .chat-reading-indicator { + --claw-cycle: 2.4s; + + position: relative; display: inline-flex; align-items: center; color: var(--accent); @@ -1041,50 +1048,156 @@ height: 18px; fill: currentColor; stroke: none; - animation: chatWorkingClawBreath 2.8s ease-in-out infinite; - will-change: opacity; -} - -/* The upper jaw hinges at the palm joint. Rest is slightly open — the open - notch is what reads "claw" at 18px — and the pinch is a brief blip late in - the cycle, mirroring the favicon mascot's snap timing (mostly still). */ -.chat-reading-indicator svg .claw-icon__jaw { - transform-box: view-box; - transform-origin: 8.6px 11px; - transform: rotate(-8deg); - animation: chatWorkingClawSnap 2.8s ease-in-out infinite; + animation: chatWorkingClawCombo var(--claw-cycle) ease-out infinite; will-change: transform; } -@keyframes chatWorkingClawBreath { +/* The upper jaw hinges at the palm joint. Rest is slightly open — the open + notch is what reads "claw" at 18px — and the jaw snaps shut exactly on the + combo's impact frames (keyframe percentages must stay in lockstep with + chatWorkingClawCombo). */ +.chat-reading-indicator svg .claw-icon__jaw { + transform-box: view-box; + transform-origin: 8.6px 11px; + transform: rotate(-10deg); + animation: chatWorkingClawJaw var(--claw-cycle) ease-out infinite; + will-change: transform; +} + +/* Pow star: pops at the cross impact (46-58% of the combo cycle). */ +.chat-reading-indicator::after { + content: "✦"; + position: absolute; + right: -14px; + top: -2px; + font-size: 11px; + line-height: 1; + color: var(--accent); + opacity: 0; + animation: chatWorkingClawPow var(--claw-cycle) ease-out infinite; + pointer-events: none; +} + +/* Jab 1 at 12-16%, jab 2 at 26-30%, big wind-up, cross at 46-52%. */ +@keyframes chatWorkingClawCombo { 0%, - 100% { - opacity: 0.55; + 8% { + transform: translateX(0); } - 50% { - opacity: 1; + 12% { + transform: translateX(-2px); + } + + 16% { + transform: translateX(5px); + } + + 22%, + 26% { + transform: translateX(-2px); + } + + 30% { + transform: translateX(5px); + } + + 38% { + transform: translateX(0); + } + + 46% { + transform: translateX(-3px) rotate(-6deg); + } + + 52% { + transform: translateX(8px) rotate(4deg); + } + + 62%, + 100% { + transform: translateX(0); } } -@keyframes chatWorkingClawSnap { +@keyframes chatWorkingClawJaw { 0%, - 70%, + 8% { + transform: rotate(-10deg); + } + + 12% { + transform: rotate(-16deg); + } + + 16% { + transform: rotate(4deg); + } + + 22%, + 26% { + transform: rotate(-16deg); + } + + 30% { + transform: rotate(4deg); + } + + 38% { + transform: rotate(-10deg); + } + + 46% { + transform: rotate(-18deg); + } + + 52% { + transform: rotate(6deg); + } + + 62%, 100% { - transform: rotate(-8deg); + transform: rotate(-10deg); + } +} + +@keyframes chatWorkingClawPow { + 0%, + 46% { + opacity: 0; + transform: scale(0.4); } - 78% { - transform: rotate(3deg); + 52% { + opacity: 1; + transform: scale(1.2); } - 86% { - transform: rotate(-11deg); + 58%, + 100% { + opacity: 0; + transform: scale(1.5); } +} - 94% { - transform: rotate(-8deg); - } +/* Seeded stance variants (see punchStanceClass in chat-message.ts): the + southpaw fights mirrored, the flurry fighter runs the combo double-time, + the rare haymaker is a slow heavyweight with a bigger pow. */ +.chat-reading-indicator--southpaw { + transform: scaleX(-1); +} + +.chat-reading-indicator--flurry { + --claw-cycle: 1.3s; +} + +.chat-reading-indicator--haymaker { + --claw-cycle: 3.8s; +} + +.chat-reading-indicator--haymaker::after { + font-size: 15px; + right: -17px; } @media (prefers-reduced-motion: reduce) { @@ -1093,8 +1206,8 @@ animation: none; } - .chat-reading-indicator svg { - opacity: 0.85; + .chat-reading-indicator::after { + content: none; } }