mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
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:
committed by
GitHub
parent
a425807c31
commit
5bb35b47da
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user