From 3349c63ffad7efd6503b265e736f0dc19eb4c288 Mon Sep 17 00:00:00 2001 From: Ron Date: Thu, 30 Jul 2026 02:10:04 -0700 Subject: [PATCH 1/8] =?UTF-8?q?feat(webui):=20agentic=20activity=20&=20str?= =?UTF-8?q?eaming=20UX=20=E2=80=94=20NEAR=20process=20indicator=20+=20desi?= =?UTF-8?q?gn=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the agent-activity / streaming UX redesign. - docs/design/agent-activity-streaming{.md,/mockup.html}: approved design reference — the interactive mockup plus an implementation brief (component map, the new SSE payloads to build, and rollout order). - NearProcessIndicator: the branded live "working" indicator — the canonical NEAR "N" mark (from provider-logos.tsx) with a brand-blue (#0091fd) light chasing its spine while working, resolving to a solid glyph when done. Wired into TypingIndicator. - app.css: --near-blue token + motion-policy-compliant near-chase / near-pulse keyframes (class-scoped !important so they outrank the universal animation:none, suppressed under prefers-reduced-motion). Presentation-only; no backend/SSE changes. Verified: npm run typecheck, lint, and test (899 tests incl. 2 new) all pass. Co-Authored-By: Claude Opus 4.8 --- .../components/near-process-indicator.test.ts | 114 +++ .../components/near-process-indicator.tsx | 75 ++ .../chat/components/typing-indicator.tsx | 12 +- .../frontend/src/styles/app.css | 52 ++ docs/design/agent-activity-streaming.md | 82 ++ .../agent-activity-streaming/mockup.html | 788 ++++++++++++++++++ 6 files changed, 1114 insertions(+), 9 deletions(-) create mode 100644 crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.test.ts create mode 100644 crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.tsx create mode 100644 docs/design/agent-activity-streaming.md create mode 100644 docs/design/agent-activity-streaming/mockup.html diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.test.ts b/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.test.ts new file mode 100644 index 00000000000..92d5c8708c2 --- /dev/null +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.test.ts @@ -0,0 +1,114 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "vitest"; +import vm from "node:vm"; + +const CLIP_ID = "near-clip-test"; + +// Load the presentational component the same way connection-status.test.ts does: +// strip imports, expose the function, and run it in a VM with a mocked React so +// `React.useId()` returns a deterministic clipPath id. The vm-tsx-setup shim +// transpiles the JSX into inspectable `{ type, props, children }` nodes. +function loadNearProcessIndicator() { + const source = readFileSync( + new URL("./near-process-indicator.tsx", import.meta.url), + "utf8", + ); + const body = source + .split("\n") + .filter((line) => !line.startsWith("import ")) + .join("\n") + .replace( + "export function NearProcessIndicator", + "function NearProcessIndicator", + ); + const context = { + React: { useId: () => CLIP_ID }, + globalThis: {}, + }; + vm.runInNewContext( + `${body}\nglobalThis.__testExports = { NearProcessIndicator };`, + context, + ); + return context.globalThis.__testExports.NearProcessIndicator; +} + +function findNode(value, predicate, seen = new Set()) { + if (!value || typeof value !== "object" || seen.has(value)) return null; + seen.add(value); + if (Array.isArray(value)) { + for (const candidate of value) { + const match = findNode(candidate, predicate, seen); + if (match) return match; + } + return null; + } + if (predicate(value)) return value; + for (const key of ["children", "values"]) { + const candidates = Array.isArray(value[key]) ? value[key] : []; + for (const candidate of candidates) { + const match = findNode(candidate, predicate, seen); + if (match) return match; + } + } + return null; +} + +const byClass = (name) => (node) => node.props?.className === name; +const includesClass = (fragment) => (node) => + typeof node.props?.className === "string" && + node.props.className.includes(fragment); + +test("NearProcessIndicator working state chases the NEAR spine with elapsed time", () => { + const NearProcessIndicator = loadNearProcessIndicator(); + const rendered = NearProcessIndicator({ + state: "working", + label: "Working…", + elapsed: "0:03", + }); + + assert.match(rendered.props.className, /\bnear-process\b/); + assert.match(rendered.props.className, /\bis-busy\b/); + + // The canonical NEAR glyph is filled and dimmed while working. + const base = findNode(rendered, byClass("near-base")); + assert.notEqual(base, null, "base glyph should render"); + assert.ok(base.props.d.startsWith("M21.443"), "base uses the NEAR mark path"); + assert.equal(base.props.opacity, 0.24); + + // The comet only exists while working, clipped to the glyph via the useId ref. + const comet = findNode(rendered, byClass("near-comet")); + assert.notEqual(comet, null, "comet should render while working"); + assert.ok(comet.props.d.startsWith("M2.6 22.2"), "comet rides the N spine"); + const clipGroup = findNode(rendered, (node) => node.props?.["clip-path"]); + assert.equal(clipGroup.props["clip-path"], `url(#${CLIP_ID})`); + + // Label is the strong text color; elapsed is shown beside it. + const label = findNode(rendered, includesClass("text-[var(--v2-text-strong)]")); + assert.notEqual(label, null, "working label uses strong text color"); + assert.equal(label.children[0], "Working…"); + const elapsed = findNode(rendered, (node) => node.children?.[0] === "0:03"); + assert.notEqual(elapsed, null, "elapsed should render while working"); +}); + +test("NearProcessIndicator done state is a solid, static glyph with a muted label", () => { + const NearProcessIndicator = loadNearProcessIndicator(); + const rendered = NearProcessIndicator({ state: "done", label: "Done" }); + + assert.match(rendered.props.className, /\bis-done\b/); + + const base = findNode(rendered, byClass("near-base")); + assert.ok(base.props.d.startsWith("M21.443"), "base uses the NEAR mark path"); + assert.equal(base.props.opacity, 1, "glyph is solid when done"); + + assert.equal( + findNode(rendered, byClass("near-comet")), + null, + "comet is hidden when done", + ); + + const label = findNode(rendered, includesClass("text-[var(--v2-text-muted)]")); + assert.notEqual(label, null, "done label uses the muted text color"); + assert.equal(label.children[0], "Done"); +}); diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.tsx b/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.tsx new file mode 100644 index 00000000000..e4c76571952 --- /dev/null +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.tsx @@ -0,0 +1,75 @@ +import React from "react"; + +// The canonical NEAR "N" mark on a 0 0 24 24 viewBox, copied verbatim from +// onboarding/provider-logos.tsx so the process indicator uses the exact brand +// glyph. Filled brand-blue as `.near-base`, and reused as the clip that keeps +// the chasing comet inside the letterform. +const NEAR_GLYPH = + "M21.443 0c-.89 0-1.714.46-2.18 1.218l-5.017 7.448a.533.533 0 0 0 .792.7l4.938-4.282a.2.2 0 0 1 .334.151v13.41a.2.2 0 0 1-.354.128L5.03.905A2.555 2.555 0 0 0 3.078 0h-.521A2.557 2.557 0 0 0 0 2.557v18.886a2.557 2.557 0 0 0 4.736 1.338l5.017-7.448a.533.533 0 0 0-.792-.7l-4.938 4.283a.2.2 0 0 1-.333-.152V5.352a.2.2 0 0 1 .354-.128l14.924 17.87c.486.574 1.2.905 1.952.906h.521A2.558 2.558 0 0 0 24 21.445V2.557A2.558 2.558 0 0 0 21.443 0Z"; + +// The comet rides the N's spine: down the left stroke, up the diagonal, up the +// right stroke. A thick round stroke clipped to the glyph fill reads as a light +// travelling the vector path. Verbatim from the approved design mockup. +const NEAR_SPINE = "M2.6 22.2 L2.6 2.4 L21.4 21.6 L21.4 1.8"; + +type NearProcessIndicatorProps = { + state: "working" | "done"; + label: string; + elapsed?: string; +}; + +// Presentational live/working indicator: the NEAR mark with a light chasing its +// spine while the agent works, resolving to a solid brand-blue glyph when done. +// Left-aligned icon + label, no container box (see the "live status line" in the +// agent-activity mockup). Animation + `--near-blue` live in styles/app.css. +export function NearProcessIndicator({ + state, + label, + elapsed, +}: NearProcessIndicatorProps) { + const clipId = React.useId(); + const working = state === "working"; + + return ( +
+ + + {label} + + {working && elapsed ? ( + + {elapsed} + + ) : null} +
+ ); +} diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/typing-indicator.tsx b/crates/ironclaw_webui/frontend/src/pages/chat/components/typing-indicator.tsx index 6b460253ca5..3ab32cf642e 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/components/typing-indicator.tsx +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/typing-indicator.tsx @@ -1,17 +1,11 @@ +import { NearProcessIndicator } from "./near-process-indicator"; export function TypingIndicator() { return (
-
-
- - - -
+
+
diff --git a/crates/ironclaw_webui/frontend/src/styles/app.css b/crates/ironclaw_webui/frontend/src/styles/app.css index ac4fb3f4411..98b8bb6d251 100644 --- a/crates/ironclaw_webui/frontend/src/styles/app.css +++ b/crates/ironclaw_webui/frontend/src/styles/app.css @@ -70,6 +70,10 @@ --v2-accent-soft: rgba(76, 167, 230, 0.12); --v2-accent-text: #10649e; + /* NEAR AI primary brand blue (near.ai/brand) — the process indicator mark. + A brand constant, defined once and inherited by every theme. */ + --near-blue: #0091fd; + /* Semantic colours */ --v2-positive-soft: rgba(32, 210, 154, 0.13); --v2-positive-text: #087a5d; @@ -394,9 +398,57 @@ input:focus, select:focus, textarea:focus { animation: v2-spin 0.8s linear infinite !important; } +/* NEAR "N" process indicator (see pages/chat/components/near-process-indicator.tsx). + Another intentional motion-policy exception: while the agent is working, a + light chases the N's spine (`near-chase`) and the glyph does a quick spin + + heartbeat (`near-pulse`). Both use a class selector + !important so they + outrank the universal `animation: none` rule, and both are suppressed under + prefers-reduced-motion below. */ +.near-process-icon { + color: var(--near-blue); + overflow: visible; + transform-box: fill-box; + transform-origin: center; +} +.near-process-icon .near-base { fill: currentColor; } +.near-process-icon .near-comet { + fill: none; + stroke: currentColor; + stroke-width: 7; + stroke-linecap: round; + stroke-linejoin: round; + /* Short lit segment (~15% of the ~66px spine) + long gap so a single comet + travels the mark; the keyframe shifts one full period for a seamless loop. */ + stroke-dasharray: 10 56; + stroke-dashoffset: 0; + filter: drop-shadow(0 0 1.5px currentColor); +} +.near-process.is-busy .near-process-icon { + animation: near-pulse 2s ease-in-out infinite !important; +} +.near-process.is-busy .near-process-icon .near-comet { + animation: near-chase 1.1s linear infinite !important; +} +@keyframes near-chase { + to { stroke-dashoffset: -66; } +} +@keyframes near-pulse { + 0% { transform: rotate(0deg) scale(1); } + 22% { transform: rotate(360deg) scale(1); } + 50% { transform: rotate(360deg) scale(1); } + 58% { transform: rotate(360deg) scale(1.28); } + 66% { transform: rotate(360deg) scale(0.98); } + 74% { transform: rotate(360deg) scale(1.16); } + 82% { transform: rotate(360deg) scale(1); } + 100% { transform: rotate(360deg) scale(1); } +} + @media (prefers-reduced-motion: reduce) { .v2-typing-dot { animation: none !important; } .v2-spin { animation: none !important; } + .near-process.is-busy .near-process-icon, + .near-process.is-busy .near-process-icon .near-comet { animation: none !important; } + .near-process .near-comet { display: none !important; } } /* ── Markdown body ───────────────────────────────────────────────── */ diff --git a/docs/design/agent-activity-streaming.md b/docs/design/agent-activity-streaming.md new file mode 100644 index 00000000000..c40e41a6f6b --- /dev/null +++ b/docs/design/agent-activity-streaming.md @@ -0,0 +1,82 @@ +# Agentic Activity & Streaming UX — webui_v2 + +**Status:** design approved; implementation in progress (this is the foundation PR). +**Interactive mockup:** [`mockup.html`](./agent-activity-streaming/mockup.html) — open in a +browser to see the full behavior (press **Play**; toggle **Compare / Proposed · focus**, +**Flags**, and the theme). Also published for review at +`claude.ai/code/artifact/92974a1f-d2b7-46d9-8927-bc882adcc495`. + +> File/symbol references are a point-in-time trace of `webui_v2`. Verify against live code +> before relying on them — prefer the codebase knowledge graph / `openwiki/`. + +## Goal + +Bring the chat's agent-activity + streaming experience up to (and past) Claude Desktop +**Cowork** and **OpenWorker**: replace the raw `Activity — N tools` + JSON disclosures with +**narrated activity**, a **branded live process indicator**, a persistent **Plan / Context** +rail, and IronClaw-native surfaces (safety-approval gate, connector cards, background-job, +multi-channel handoff) — all on the real `--v2-*` tokens. + +## Where this lives + +SPA: `crates/ironclaw_webui/frontend/src` (React 19 + TypeScript + Tailwind v4; tokens in +`styles/app.css`). Backend edge: `crates/ironclaw_webui/src/webui_v2/{schema.rs,handlers.rs}`. +Producer/mapping: `crates/ironclaw_reborn_composition/src/projection/live_progress.rs`. + +Chat components (`pages/chat/`): `chat.tsx`, `components/message-list.tsx`, +`message-bubble.tsx` (incl. `ThinkingDisclosure`), `tool-activity.tsx`, `activity-run.tsx`, +`typing-indicator.tsx`, `plan-card.tsx`, `approval-card.tsx`, `auth-*-card.tsx`, +`onboarding-pairing-card.tsx`, `chat-input.tsx`. + +## Design decisions (from the mockup) + +- **Process indicator** — the NEAR "N" mark (`provider-logos.tsx` canonical path) rendered + filled, with a glowing brand-blue (`#0091FD`) light that **chases through the interior along + the glyph's spine**, plus a quick spin + heartbeat pulse. It settles to a **solid `#0091FD`** + mark when the turn completes. It sits pinned at the **bottom** of the thread (Claude-Desktop + style) as the live status ("Thinking… 0:12" → "Worked for 12s"). +- **Activity trail** — thinking narration + tool activities stream as a trail. A **"N steps ⌄" + toggle** sits at the **top** (below the prompt) and collapses/expands the trail; it's collapsed + by default once the turn is done. Grouped runs ("Explored your setup", "Pulled …") collapse to + their summary once the agent moves past them; **Connect/Approve cards stay expanded**. +- **Composer** — input over an action row: agent-**mode selector** (Discuss / Ask for approval / + Full access + "Send approvals to Inbox"), mic, attach, and a **Run ⌘↵** button that is + disabled until there's input. +- **Cards** — connector, approval, and artifact cards share one neutral card family (no + warning-yellow on approval). The final response surfaces the produced **artifact as a compact + reference card**. +- **Rail** — a floating (borderless) Plan / Context panel that appears only once the run has + telemetry; its column collapses while hidden. +- **Theme + motion** — both light and dark off `--v2-*`; honor the near-static motion policy + (only whitelisted keyframes animate; all suppressed under `prefers-reduced-motion`). + +## What already ships in v2 (recompose, no backend change) + +Thinking narration (`ProductProjectionItem::Thinking`); grouped tool run with expandable params +(`WebChatV2Event::CapabilityActivity` + `activity-run.tsx`); the **safety-approval gate** +(`WebChatV2Event::Gate` + `approval-card.tsx` — a surface Cowork has no equivalent for); the +**connector / auth card** (`WebChatV2Event::AuthRequired`); text streaming / final +(`ModelTextDelta` → `Text` / `final_reply`); run status; skill activation. + +## Needs a new payload (follow-up Rust work + tests) + +1. **Per-tool duration** — the UI renders `{ms}` but `toolCardFromActivity/Preview` hardcode + `null`; add `started_at`/`completed_at` (or `duration_ms`) to `CapabilityActivityView` and the + `CapabilityCompleted` milestone. +2. **Live Plan / todo tracker** — no plan event exists (`plan-card.tsx` is fed by a client-side + mock); add a `PlanUpdate { steps: [{id,title,status}] }` projection item + producing milestone. +3. **Elapsed timer** — only point timestamps stream; emit a run/step start timestamp. +4. **Background-job on the turn stream** — jobs are REST-polled (`ProcessStatus` Running→Completed); + bridge job status into the projection for the inline job chip. +5. **Multi-channel handoff** — new UI + action (no event today). + +Each of the above follows `.claude/rules/gateway-events.md` (durable event → projection → stream +→ frontend reconcile) with the required tests. + +## Rollout + +- **This PR (foundation):** the design reference above + the `NearProcessIndicator` component + (the branded live indicator), wired into `TypingIndicator`. Presentation-only, no backend change. +- **Next:** activity-trail consolidation (top toggle + bottom status), composer mode selector, + card-family unification + artifact card, floating rail. +- **Then:** the five new SSE payloads, one focused PR each, each with tests through the caller. diff --git a/docs/design/agent-activity-streaming/mockup.html b/docs/design/agent-activity-streaming/mockup.html new file mode 100644 index 00000000000..f9734146b46 --- /dev/null +++ b/docs/design/agent-activity-streaming/mockup.html @@ -0,0 +1,788 @@ +IronClaw · Agent Activity & Streaming — Design Mockup + + +
+
+
+
IronClaw · webui_v2 · Design session
+

Agent activity, narrated

+

A working mockup of the streaming & agent-process experience for the Reborn chat. Built on the real --v2-* tokens. Press play to watch the same task run in today's raw view versus a narrated, task-telemetry view that recomposes what v2 already ships and extends it with IronClaw-native surfaces.

+
+ +
+ +
+
+ + +
+ + + + + +
0 / 10
+
+ +
+ How to read the flags: + shipped in v2 component already exists + recompose exists, needs new placement + new event needs a stream payload + ironclaw-native beyond Cowork +
+ +
+ +
+
Todayas recorded · gateway chat
+
+
+
Set up a daily digest of yesterday's Slack activity, DMs, and reminders.
+
+ +
+
+ Activity — 3 tools +
+
extension_searchsucceeded
{ "query": "slack" }
+
skill_listsucceeded
{}
+
list_dirsucceeded
{ "path": "/workspace" }
+
+
+
+
I don't see a Slack extension available in the catalog. Let me search for it more broadly, since we need Slack integration to pull activity and DMs.
12:05 AM
+ +
+
+ Activity — 5 tools +
+
registry_searchsucceeded
{ "q": "slack messaging" }
+
secrets_listsucceeded
{}
+
+
+
+
No Slack extension is installed yet, and there are no outbound delivery targets configured. Let me check if there's a Slack-related extension available to install.
12:05 AM
+ +
+
+ Activity — 8 tools +
skill_installsucceeded
{ "name": "commitment-triage" }
+
+
+
Alright — here's the situation.
There's no Slack integration installed and no outbound delivery target yet, so I can't pull Slack messages on my own. I can still set up a digest that runs through this chat:
  • a workspace project to track daily digests,
  • a recurring trigger that fires at 9 AM,
  • and digest content saved to memory.
12:06 AM
+
+
+
Ask for follow-up changes
+
+
+
+
+ + +
+
Proposednarrated · task telemetry
+
+
+
Set up a daily digest of yesterday's Slack activity, DMs, and reminders.
+ +
+ +
+
+ shipped in v2 +
Thinking
I'll need Slack access and somewhere to store the digest. Let me check what's already wired before I promise anything.
+
+ +
+ recompose +
+
Explored your setup3 tools · 0.9s
+
+
Searched the extension catalogextension_search · query:"slack"0.4s
+
Listed installed skillsskill_list0.2s
+
Read your workspacelist_dir · /workspace0.3s
+
+
+
+ +
Thinking
No Slack connector yet — I'll surface it so you can connect in one click, then keep going.
+ +
+ shipped in v2 +
+
Connect Slack
credential · slack_bot_token
+
Read messages, DMs and channels so I can pull yesterday's activity.not connected
+
+
+ +
+ recompose +
Pulled yesterday's Slack activity42 messages · 1.2s
+
Read channels & DMsslack.conversations.history · since:yesterday1.2s
+
+
+ +
+ shipped in v2 · beats Cowork +
+
Approve a scheduled automation
safety gate · reversible
Risk · low
+
+
create_automation · “Daily Slack digest” · every day 08:00 (local) · delivers to this chat
+
+ + + +
+
+
+
+ +
+ native · new event +
+ +
Composing today's digestjob · digest_2f9c · attempt 1
+ running +
+
+
+
+ +
Done — your daily digest is live.
Slack's connected, and I pulled 42 messages from yesterday. Here's what I set up:
  • Daily Slack digest — runs every morning at 8:00, groups by channel and flags direct mentions,
  • folds in the reminders you've saved to memory,
  • and delivers right here by default.
Prefer it somewhere else each morning?
12:06 AM
+ +
Working…0:01
+
+ +
+
+ +
+
+
+ + +
+
+ + + +
+
+
+ + +
+
+
+ +
+

What this argues. Both panes run the identical task. Left is faithful to your recording — raw Activity — N tools disclosures over JSON, a static Working pill, one column. Right keeps every honest detail one disclosure away, but leads with plain-language narration, a live verb + elapsed status, a persistent Plan / Context rail, and IronClaw's safety + connector + handoff surfaces woven into the stream.

+

Bind-today — already in the webui_v2 stream: the Thinking narration (Thinking item), the grouped tool run with expandable params (CapabilityActivity + activity-run.tsx), the safety-approval gate (Gate event + approval-card.tsx — a surface Cowork has no equivalent for), and the connector / auth card (AuthRequired, with oauth / manual-token / pairing kinds).

+

Needs a new payload (the real backend gaps): per-tool durations — the UI already renders {ms} but CapabilityActivityView hardcodes null; the live Plan tracker — no plan/todo event exists yet (PlanCard is a client-side mock); the elapsed timer — only point timestamps are emitted; and the background-job chip + multi-channel handoff — jobs are REST-polled (Running→Completed), not on the turn SSE. The status verb is already derivable from ProgressUpdateView.kind + WorkSummary phase.

+
+
+ + From e8a56ea48e36cdd4608330b12d214167c1025fb9 Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Thu, 30 Jul 2026 23:44:55 +0300 Subject: [PATCH 2/8] fix(webui): address CodeRabbit review and CI (#6901) --- .../tests/webui_v2_serve.rs | 46 +++++++++++----- .../components/near-process-indicator.test.ts | 21 ++++---- .../components/near-process-indicator.tsx | 29 +++------- .../frontend/src/styles/app.css | 54 +++++++++++-------- .../agent-activity-streaming/mockup.html | 1 + 5 files changed, 83 insertions(+), 68 deletions(-) diff --git a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs index b23694eb107..8c65e98fe40 100644 --- a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs +++ b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs @@ -2656,28 +2656,50 @@ async fn static_i18n_module_guards_locale_race_and_clears_failed_pack_cache() { } #[tokio::test] -async fn static_typing_dot_animation_respects_reduced_motion() { +async fn static_near_process_animation_respects_reduced_motion() { // Content-shape regression guard for the typing-indicator animation - // contract (PR #4493 review): `.v2-typing-dot` is the single - // intentional animation exception, so it must animate by default and - // be suppressed under `prefers-reduced-motion: reduce`. A behavioral - // check that the dot computes to `animation: none` via the emulated - // media query needs a browser (`getComputedStyle`), which this - // workspace's Rust/oneshot harness cannot drive; that belongs in the - // deferred e2e scaffold. + // contract: the NEAR glyph and its comet animate while the assistant is + // working, and both animations are suppressed under + // `prefers-reduced-motion: reduce`. let body = served_app_stylesheet().await; assert!( - body.contains("animation:1.4s ease-in-out infinite v2-typing-bounce"), - "typing dots must animate by default", + body.contains("animation:2s ease-in-out infinite near-pulse"), + "the working NEAR glyph must pulse by default", + ); + assert!( + body.contains(".near-process.is-busy .near-base{opacity:.24}"), + "the working NEAR glyph must be dimmed beneath the comet", + ); + assert!( + body.contains(".near-process-label{color:var(--v2-text-muted);font-weight:400}"), + "the completed-state label must use muted presentation", + ); + assert!( + body.contains( + ".near-process.is-busy .near-process-label{color:var(--v2-text-strong);\ + font-weight:560}" + ), + "the working-state label must use strong presentation", + ); + assert!( + body.contains("animation:1.1s linear infinite near-chase"), + "the working NEAR comet must chase by default", ); assert!( body.contains("@media (prefers-reduced-motion:reduce)"), "stylesheet must carry a reduced-motion opt-out block", ); assert!( - body.contains(".v2-typing-dot,.v2-spin{animation:none"), - "the typing dot must be suppressed under prefers-reduced-motion: reduce", + body.contains( + ".near-process.is-busy .near-process-icon,.near-process.is-busy \ + .near-process-icon .near-comet{animation:none" + ), + "the NEAR glyph and comet must stop under prefers-reduced-motion: reduce", + ); + assert!( + body.contains(".near-process .near-comet{display:none"), + "the comet must be hidden under prefers-reduced-motion: reduce", ); } diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.test.ts b/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.test.ts index 92d5c8708c2..8ea7daa11bc 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.test.ts +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.test.ts @@ -56,9 +56,6 @@ function findNode(value, predicate, seen = new Set()) { } const byClass = (name) => (node) => node.props?.className === name; -const includesClass = (fragment) => (node) => - typeof node.props?.className === "string" && - node.props.className.includes(fragment); test("NearProcessIndicator working state chases the NEAR spine with elapsed time", () => { const NearProcessIndicator = loadNearProcessIndicator(); @@ -71,11 +68,11 @@ test("NearProcessIndicator working state chases the NEAR spine with elapsed time assert.match(rendered.props.className, /\bnear-process\b/); assert.match(rendered.props.className, /\bis-busy\b/); - // The canonical NEAR glyph is filled and dimmed while working. + // The canonical NEAR glyph is rendered while working; the busy-state class + // scopes its dimmed presentation in app.css. const base = findNode(rendered, byClass("near-base")); assert.notEqual(base, null, "base glyph should render"); assert.ok(base.props.d.startsWith("M21.443"), "base uses the NEAR mark path"); - assert.equal(base.props.opacity, 0.24); // The comet only exists while working, clipped to the glyph via the useId ref. const comet = findNode(rendered, byClass("near-comet")); @@ -84,12 +81,13 @@ test("NearProcessIndicator working state chases the NEAR spine with elapsed time const clipGroup = findNode(rendered, (node) => node.props?.["clip-path"]); assert.equal(clipGroup.props["clip-path"], `url(#${CLIP_ID})`); - // Label is the strong text color; elapsed is shown beside it. - const label = findNode(rendered, includesClass("text-[var(--v2-text-strong)]")); - assert.notEqual(label, null, "working label uses strong text color"); + // State-scoped CSS makes the working label strong; elapsed is shown beside it. + const label = findNode(rendered, byClass("near-process-label")); + assert.notEqual(label, null, "working label uses the shared label class"); assert.equal(label.children[0], "Working…"); - const elapsed = findNode(rendered, (node) => node.children?.[0] === "0:03"); + const elapsed = findNode(rendered, byClass("near-process-elapsed")); assert.notEqual(elapsed, null, "elapsed should render while working"); + assert.equal(elapsed.children[0], "0:03"); }); test("NearProcessIndicator done state is a solid, static glyph with a muted label", () => { @@ -100,7 +98,6 @@ test("NearProcessIndicator done state is a solid, static glyph with a muted labe const base = findNode(rendered, byClass("near-base")); assert.ok(base.props.d.startsWith("M21.443"), "base uses the NEAR mark path"); - assert.equal(base.props.opacity, 1, "glyph is solid when done"); assert.equal( findNode(rendered, byClass("near-comet")), @@ -108,7 +105,7 @@ test("NearProcessIndicator done state is a solid, static glyph with a muted labe "comet is hidden when done", ); - const label = findNode(rendered, includesClass("text-[var(--v2-text-muted)]")); - assert.notEqual(label, null, "done label uses the muted text color"); + const label = findNode(rendered, byClass("near-process-label")); + assert.notEqual(label, null, "done label uses the shared label class"); assert.equal(label.children[0], "Done"); }); diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.tsx b/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.tsx index e4c76571952..9ed510ba845 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.tsx +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.tsx @@ -9,8 +9,8 @@ const NEAR_GLYPH = // The comet rides the N's spine: down the left stroke, up the diagonal, up the // right stroke. A thick round stroke clipped to the glyph fill reads as a light -// travelling the vector path. Verbatim from the approved design mockup. -const NEAR_SPINE = "M2.6 22.2 L2.6 2.4 L21.4 21.6 L21.4 1.8"; +// travelling the vector path. The geometry matches the approved design mockup. +const NEAR_SPINE = "M2.6 22.2V2.4L21.4 21.6V1.8"; type NearProcessIndicatorProps = { state: "working" | "done"; @@ -31,14 +31,9 @@ export function NearProcessIndicator({ const working = state === "working"; return ( -
+
- - {label} - + {label} {working && elapsed ? ( - - {elapsed} - + {elapsed} ) : null}
); diff --git a/crates/ironclaw_webui/frontend/src/styles/app.css b/crates/ironclaw_webui/frontend/src/styles/app.css index 5b2a5a1adfb..c4d89eae295 100644 --- a/crates/ironclaw_webui/frontend/src/styles/app.css +++ b/crates/ironclaw_webui/frontend/src/styles/app.css @@ -374,24 +374,10 @@ input:focus, select:focus, textarea:focus { transition: none !important; } -/* The typing indicator is the single intentional exception: the three dots - loop to signal the assistant is working. The class selector outranks the - universal `*` rule above (both !important), so the animation survives. - Suppressed again under prefers-reduced-motion to respect that opt-out. */ -@keyframes v2-typing-bounce { - 0%, 80%, 100% { transform: translateY(0); opacity: 0.45; } - 40% { transform: translateY(-4px); opacity: 1; } -} -.v2-typing-dot { - animation: v2-typing-bounce 1.4s ease-in-out infinite !important; -} -.v2-typing-dot:nth-child(2) { animation-delay: 0.15s !important; } -.v2-typing-dot:nth-child(3) { animation-delay: 0.3s !important; } - -/* Refresh / in-flight spinner. Like the typing dots, this is an intentional - exception to the static-motion policy above: the class selector + !important - outranks the universal `animation: none` rule so a loading control can spin. - Suppressed under prefers-reduced-motion. */ +/* Refresh / in-flight spinner. This is an intentional exception to the + static-motion policy above: the class selector + !important outranks the + universal `animation: none` rule so a loading control can spin. Suppressed + under prefers-reduced-motion. */ @keyframes v2-spin { to { transform: rotate(360deg); } } @@ -405,16 +391,41 @@ input:focus, select:focus, textarea:focus { heartbeat (`near-pulse`). Both use a class selector + !important so they outrank the universal `animation: none` rule, and both are suppressed under prefers-reduced-motion below. */ +.near-process { + display: flex; + align-items: center; + gap: 9px; + padding: 6px 2px; + font-size: 13px; +} .near-process-icon { color: var(--near-blue); overflow: visible; transform-box: fill-box; transform-origin: center; + width: 18px; + height: 18px; + flex: none; +} +.near-process-label { + color: var(--v2-text-muted); + font-weight: 400; +} +.near-process.is-busy .near-process-label { + color: var(--v2-text-strong); + font-weight: 560; +} +.near-process-elapsed { + color: var(--v2-text-faint); + font-family: var(--font-mono); + font-size: 11.5px; + font-variant-numeric: tabular-nums; } -.near-process-icon .near-base { fill: currentColor; } +.near-process-icon .near-base { fill: currentcolor; } +.near-process.is-busy .near-base { opacity: 0.24; } .near-process-icon .near-comet { fill: none; - stroke: currentColor; + stroke: currentcolor; stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; @@ -422,7 +433,7 @@ input:focus, select:focus, textarea:focus { travels the mark; the keyframe shifts one full period for a seamless loop. */ stroke-dasharray: 10 56; stroke-dashoffset: 0; - filter: drop-shadow(0 0 1.5px currentColor); + filter: drop-shadow(0 0 1.5px currentcolor); } .near-process.is-busy .near-process-icon { animation: near-pulse 2s ease-in-out infinite !important; @@ -445,7 +456,6 @@ input:focus, select:focus, textarea:focus { } @media (prefers-reduced-motion: reduce) { - .v2-typing-dot { animation: none !important; } .v2-spin { animation: none !important; } .near-process.is-busy .near-process-icon, .near-process.is-busy .near-process-icon .near-comet { animation: none !important; } diff --git a/docs/design/agent-activity-streaming/mockup.html b/docs/design/agent-activity-streaming/mockup.html index f9734146b46..4b2b49eac72 100644 --- a/docs/design/agent-activity-streaming/mockup.html +++ b/docs/design/agent-activity-streaming/mockup.html @@ -1,3 +1,4 @@ + IronClaw · Agent Activity & Streaming — Design Mockup