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.
This commit is contained in:
Peter Steinberger
2026-07-12 11:37:27 -07:00
committed by GitHub
parent a425807c31
commit 5bb35b47da
4 changed files with 262 additions and 46 deletions
@@ -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<RenderMessageGroupOptions>) => {
const container = document.createElement("div");
+50 -8
View File
@@ -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`
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">${icons.claw}</div>
<div class="chat-bubble chat-reading-indicator ${stance}" aria-hidden="true">${icons.claw}</div>
`;
}
@@ -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`
<div class="chat-group assistant">
${renderChatAvatar("assistant", assistant, undefined, basePath, authToken)}
<div class="chat-group assistant ${indicatorOnly ? "chat-group--working" : ""}">
${avatar}
<div class="chat-group-messages">
${parts.map((part) =>
part.kind === "reading-indicator"
? renderReadingIndicatorBubble()
? renderReadingIndicatorBubble(part.key)
: renderGroupedMessage(
{
role: "assistant",
+18 -1
View File
@@ -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;
}
}
+147 -34
View File
@@ -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;
}
}