diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/lib/chat.test.ts b/crates/ironclaw_webui/frontend/src/pages/chat/lib/chat.test.ts
index 98bf359f391..60cfb1aaf82 100644
--- a/crates/ironclaw_webui/frontend/src/pages/chat/lib/chat.test.ts
+++ b/crates/ironclaw_webui/frontend/src/pages/chat/lib/chat.test.ts
@@ -1,29 +1,19 @@
// @ts-nocheck
import assert from "node:assert/strict";
-import { readFileSync } from "node:fs";
import { test } from "vitest";
import vm from "node:vm";
import { channelConnectionDisplayName } from "../../../lib/channel-connection-events";
+import { componentSourceForTest } from "../../../lib/vm-component-harness";
+import "../../../test/vm-tsx-setup";
import { channelConnectionFromGate } from "./gates";
import { messageBelongsToActiveRun } from "./message-types";
function chatSourceForTest() {
- const source = readFileSync(new URL("../chat.tsx", import.meta.url), "utf8");
- const lines = [];
- let skippingImport = false;
- for (const line of source.split("\n")) {
- if (!skippingImport && line.startsWith("import ")) {
- skippingImport = !line.trimEnd().endsWith(";");
- continue;
- }
- if (skippingImport) {
- skippingImport = !line.trimEnd().endsWith(";");
- continue;
- }
- lines.push(line.replace("export function Chat", "function Chat"));
- }
- return `${lines.join("\n")}\nglobalThis.__testExports = { Chat };`;
+ return componentSourceForTest(
+ new URL("../chat.tsx", import.meta.url),
+ "Chat",
+ );
}
function findComponent(node, component) {
@@ -359,7 +349,7 @@ test("Chat shows typing indicator before assistant text streams", () => {
assert.ok(findComponent(tree, components.TypingIndicator));
});
-test("Chat hides typing indicator once the active run streams assistant text", () => {
+test("Chat keeps typing indicator while the active run streams assistant text", () => {
const { tree, components } = renderChat({
hookState: {
messages: [
@@ -392,7 +382,7 @@ test("Chat hides typing indicator once the active run streams assistant text", (
},
});
- assert.equal(findComponent(tree, components.TypingIndicator), null);
+ assert.ok(findComponent(tree, components.TypingIndicator));
});
test("Chat keeps typing indicator when streamed text belongs to another run", () => {
diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.test.ts b/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.test.ts
index c1abe93b476..c6c3b2f2968 100644
--- a/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.test.ts
+++ b/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.test.ts
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { test } from "vitest";
import { groupMessages } from "./message-groups";
+import { messagesFromTimeline } from "./history-messages";
test("groupMessages: live and final replies share a stable render key", () => {
const live = groupMessages([
@@ -38,6 +39,40 @@ test("groupMessages: live and final replies share a stable render key", () => {
assert.equal(refreshed[0].id, live[0].id);
});
+test("groupMessages: finalized runs keep a durable completion indicator", () => {
+ const messages = messagesFromTimeline([
+ {
+ message_id: "user-1",
+ kind: "user",
+ status: "submitted",
+ content: "Check the catalog",
+ created_at: "2026-07-31T09:00:00Z",
+ updated_at: "2026-07-31T09:00:00Z",
+ turn_run_id: "run-1",
+ },
+ {
+ message_id: "assistant-1",
+ kind: "assistant",
+ status: "finalized",
+ content: "Done.",
+ created_at: "2026-07-31T09:00:03Z",
+ updated_at: "2026-07-31T09:00:12Z",
+ turn_run_id: "run-1",
+ },
+ ]);
+ const grouped = groupMessages(messages);
+
+ assert.deepEqual(
+ grouped.map((item) => item.type),
+ ["message", "message", "run-completion"],
+ );
+ assert.deepEqual(grouped[2], {
+ type: "run-completion",
+ id: "run-completion-run-1",
+ durationSeconds: 12,
+ });
+});
+
test("groupMessages: consecutive tool_activity messages collapse into one run", () => {
const grouped = groupMessages([
{ id: "a", role: "tool_activity", toolName: "read" },
diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.ts b/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.ts
index 0e8460d12d0..1e5e805b3f7 100644
--- a/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.ts
+++ b/crates/ironclaw_webui/frontend/src/pages/chat/lib/message-groups.ts
@@ -9,6 +9,7 @@ export function groupMessages(messages) {
);
const orderedMessages =
moveDelayedActivityBeforeAssistantBoundary(renderableMessages);
+ const runStartedAtById = new Map();
const items = [];
for (let index = 0; index < orderedMessages.length; index += 1) {
@@ -19,7 +20,7 @@ export function groupMessages(messages) {
const boundary = orderedMessages[index + 1 + activity.length];
if (activity.length > 0 && (!boundary || boundary.role === "user")) {
appendActivityRun(items, activity);
- appendMessage(items, msg);
+ appendMessage(items, msg, runStartedAtById);
index += activity.length;
continue;
}
@@ -32,7 +33,7 @@ export function groupMessages(messages) {
continue;
}
- appendMessage(items, msg);
+ appendMessage(items, msg, runStartedAtById);
}
return items;
@@ -103,8 +104,33 @@ function appendActivityRun(items, activity) {
});
}
-function appendMessage(items, message) {
+function appendMessage(items, message, runStartedAtById) {
items.push({ type: "message", id: messageRenderKey(message), message });
+ const runId = turnRunIdForMessage(message);
+ const completedAt = timestampMs(message.timestamp);
+ if (message?.role === "user") {
+ if (runId && completedAt !== null) {
+ runStartedAtById.set(runId, completedAt);
+ }
+ return;
+ }
+ if (!isFinalAssistantReply(message)) return;
+
+ const startedAt = runStartedAtById.get(runId);
+ if (
+ !runId ||
+ startedAt === undefined ||
+ completedAt === null ||
+ completedAt < startedAt
+ ) {
+ return;
+ }
+
+ items.push({
+ type: "run-completion",
+ id: `run-completion-${runId}`,
+ durationSeconds: Math.max(1, Math.round((completedAt - startedAt) / 1000)),
+ });
}
function messageRenderKey(message) {
diff --git a/crates/ironclaw_webui/frontend/src/styles/app.css b/crates/ironclaw_webui/frontend/src/styles/app.css
index 96574f1cd3c..c4d89eae295 100644
--- a/crates/ironclaw_webui/frontend/src/styles/app.css
+++ b/crates/ironclaw_webui/frontend/src/styles/app.css
@@ -71,6 +71,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;
@@ -370,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); }
}
@@ -395,9 +385,81 @@ 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 {
+ 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.is-busy .near-base { opacity: 0.24; }
+.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..2bbf8c3791f
--- /dev/null
+++ b/docs/design/agent-activity-streaming.md
@@ -0,0 +1,84 @@
+# 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).
+
+> 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; and a durable
+completed-run duration derived from the run-scoped user/final transcript timestamps.
+
+## 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. **Live elapsed timer** — the completed duration can use durable transcript timestamps, but
+ a precise ticking live timer still needs a run/step start timestamp on the stream.
+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 activity cohort (foundation):** the design reference above + the `NearProcessIndicator` component
+ (the branded live indicator), wired into `TypingIndicator` and retained as a static
+ “Worked for Ns” line after completion. This cohort is presentation-only, with 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..4f30bfc5e58
--- /dev/null
+++ b/docs/design/agent-activity-streaming/mockup.html
@@ -0,0 +1,792 @@
+
+
+
+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
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Send approvals to InboxApprovals & questions go to the Inbox; the agent keeps working.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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.
+
+
+
+
diff --git a/tests/dockerfile_runtime_home.rs b/tests/dockerfile_runtime_home.rs
index 7572122bf9a..de33088532a 100644
--- a/tests/dockerfile_runtime_home.rs
+++ b/tests/dockerfile_runtime_home.rs
@@ -153,7 +153,22 @@ fn reborn_dockerfile_keeps_bundled_skills_in_build_context() {
#[test]
fn reborn_dockerfile_uses_feature_matched_cache_and_loopback_default() {
let dockerfile = read_repo_file("Dockerfile");
-
+ let chef_stage = dockerfile
+ .split_once(" AS chef\n")
+ .and_then(|(_, remainder)| remainder.split_once("\nFROM "))
+ .map(|(stage, _)| stage)
+ .expect("Dockerfile must define a chef stage");
+ let cargo_config_copy = chef_stage
+ .find("COPY .cargo/config.toml .cargo/config.toml")
+ .expect("chef stage must copy the repo-wide Cargo configuration");
+ let first_cargo_command = chef_stage
+ .find("cargo ")
+ .expect("chef stage must install cargo-chef");
+
+ assert!(
+ cargo_config_copy < first_cargo_command,
+ "chef stage must copy the Cargo network configuration before running Cargo"
+ );
assert!(
dockerfile.contains(
"cargo chef cook \\\n --profile dist \\\n --package ironclaw \\\n --recipe-path recipe.json"