diff --git a/Dockerfile b/Dockerfile index 9f61df319d8..245536a6d13 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,9 @@ FROM rust:1.96-bookworm@sha256:5e2214abe154fe26e39f64488952e5c991eeed1d6d6da7cc8 COPY --from=node_toolchain /usr/local/bin/node /usr/local/bin/node COPY --from=node_toolchain /usr/local/lib/node_modules/ /usr/local/lib/node_modules/ +WORKDIR /app +COPY .cargo/config.toml .cargo/config.toml + RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ && ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \ && ln -sf ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack \ @@ -28,8 +31,6 @@ RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ && corepack enable pnpm \ && cargo install --locked cargo-chef@0.1.77 -WORKDIR /app - FROM chef AS planner COPY Cargo.toml Cargo.lock ./ diff --git a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs index e27ac7a6eb4..84d55aff959 100644 --- a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs +++ b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs @@ -2719,28 +2719,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/chat.tsx b/crates/ironclaw_webui/frontend/src/pages/chat/chat.tsx index e7bcea35287..48f407e41b3 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/chat.tsx +++ b/crates/ironclaw_webui/frontend/src/pages/chat/chat.tsx @@ -25,7 +25,6 @@ import { matchCommand } from "./lib/chat-commands"; import { channelConnectionDisplayName } from "../../lib/channel-connection-events"; import { channelConnectionFromGate } from "./lib/gates"; import { NEW_DRAFT_KEY } from "./lib/draft-store"; -import { messageBelongsToActiveRun } from "./lib/message-types"; import { buildRuntimeContext } from "./lib/runtime-context"; import { buildScopedLogsPath } from "../logs/lib/logs-data"; import { useInterfacePreferences } from "../../lib/interface-preferences"; @@ -49,16 +48,6 @@ function pendingOnboardingLabel(onboarding) { return channelConnectionDisplayName(onboarding?.extensionName); } -function hasVisibleStreamingAssistantText(messages, activeRunId) { - return (messages || []).some((message) => - message?.role === "assistant" && - message.isFinalReply === false && - typeof message.content === "string" && - message.content.length > 0 && - messageBelongsToActiveRun(message, activeRunId) - ); -} - function cancellationFailureDiagnostic(error) { const status = error && @@ -155,14 +144,9 @@ export function Chat({ Boolean(activeThreadId) && Boolean(pendingOnboarding); const activeThreadIsProcessing = Boolean(activeThreadId) && isProcessing; const activeRunId = activeRun?.runId || null; - const streamingAssistantTextVisible = hasVisibleStreamingAssistantText( - messages, - activeRunId - ); const showTypingIndicator = activeThreadIsProcessing && - !activeThreadHasGate && - !streamingAssistantTextVisible; + !activeThreadHasGate; const hasMessages = messages.length > 0 || activeThreadIsProcessing || diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/chat-input.test.ts b/crates/ironclaw_webui/frontend/src/pages/chat/components/chat-input.test.ts new file mode 100644 index 00000000000..39938020c41 --- /dev/null +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/chat-input.test.ts @@ -0,0 +1,74 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { test } from "vitest"; +import vm from "node:vm"; +import { componentSourceForTest } from "../../../lib/vm-component-harness"; + +function chatInputSourceForTest() { + return componentSourceForTest( + new URL("./chat-input.tsx", import.meta.url), + "ChatInput", + ); +} + +function renderChatInput({ sendDisabled = true, statusText = "" } = {}) { + const context = { + globalThis: {}, + Icon() {}, + Button() {}, + React: { + useCallback: (callback) => callback, + useEffect: () => {}, + useRef: (initial) => ({ current: initial }), + useState: (initial) => [ + typeof initial === "function" ? initial() : initial, + () => {}, + ], + }, + authScope: () => "test-scope", + commandMenuMatches: () => [], + commandMenuToken: () => null, + getDraft: () => "", + getStagedAttachments: () => [], + INITIAL_COMMAND_MENU_SELECTION: { index: 0, dismissed: false }, + NEW_DRAFT_KEY: "new", + useAttachmentConfig: () => ({ accept: [] }), + useT: () => (key) => key, + }; + + vm.runInNewContext(chatInputSourceForTest(), context); + return context.globalThis.__testExports.ChatInput({ + onSend: () => {}, + disabled: false, + sendDisabled, + draftKey: "draft-key", + statusText, + }); +} + +function containsScalar(value, expected, seen = new Set()) { + if (value === expected) return true; + if (!value || typeof value !== "object" || seen.has(value)) return false; + seen.add(value); + return Object.values(value).some((child) => + Array.isArray(child) + ? child.some((item) => containsScalar(item, expected, seen)) + : containsScalar(child, expected, seen) + ); +} + +test("ChatInput only shows explicit composer status text while sending is disabled", () => { + const busyComposer = renderChatInput(); + assert.equal( + containsScalar(busyComposer, "chat.statusWorking"), + false, + "run progress belongs beside the thread indicator, not in the composer", + ); + + const gatedComposer = renderChatInput({ statusText: "Approval required" }); + assert.equal( + containsScalar(gatedComposer, "Approval required"), + true, + "approval and cooldown notices should remain visible in the composer", + ); +}); diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/chat-input.tsx b/crates/ironclaw_webui/frontend/src/pages/chat/components/chat-input.tsx index 2cb306f1f33..0d874ce5c5a 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/components/chat-input.tsx +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/chat-input.tsx @@ -830,11 +830,11 @@ export function ChatInput({ />
- {isSubmitDisabled && + {isSubmitDisabled && statusText && ( - {statusText || t("chat.statusWorking")} + {statusText} )}
diff --git a/crates/ironclaw_webui/frontend/src/pages/chat/components/message-list.tsx b/crates/ironclaw_webui/frontend/src/pages/chat/components/message-list.tsx index ee81a929312..9494d94f1ee 100644 --- a/crates/ironclaw_webui/frontend/src/pages/chat/components/message-list.tsx +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/message-list.tsx @@ -3,6 +3,7 @@ import { Link } from "react-router"; import { useT } from "../../../lib/i18n"; import { ActivityRun } from "./activity-run"; import { MessageBubble } from "./message-bubble"; +import { TypingIndicator } from "./typing-indicator"; import { Icon } from "../../../design-system/icons"; import { groupMessages } from "../lib/message-groups"; @@ -396,17 +397,25 @@ export function MessageList({ activeRunId={activeRunId} /> ) - : () + : item.type === "run-completion" + ? ( + + ) + : () )} {children}
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..3165e8cd836 --- /dev/null +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.test.ts @@ -0,0 +1,103 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { test } from "vitest"; +import vm from "node:vm"; +import { componentSourceForTest } from "../../../lib/vm-component-harness"; +import "../../../test/vm-tsx-setup"; + +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 context = { + React: { useId: () => CLIP_ID }, + globalThis: {}, + }; + vm.runInNewContext( + componentSourceForTest( + new URL("./near-process-indicator.tsx", import.meta.url), + "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; + +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 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"); + + // 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?.clipPath); + assert.equal(clipGroup.props.clipPath, `url(#${CLIP_ID})`); + + // 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, 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", () => { + 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( + findNode(rendered, byClass("near-comet")), + null, + "comet is hidden when done", + ); + + 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 new file mode 100644 index 00000000000..3c2a4e5bde9 --- /dev/null +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/near-process-indicator.tsx @@ -0,0 +1,60 @@ +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. The geometry matches the approved design mockup. +const NEAR_SPINE = "M2.6 22.2V2.4L21.4 21.6V1.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.test.ts b/crates/ironclaw_webui/frontend/src/pages/chat/components/typing-indicator.test.ts new file mode 100644 index 00000000000..cd68e992673 --- /dev/null +++ b/crates/ironclaw_webui/frontend/src/pages/chat/components/typing-indicator.test.ts @@ -0,0 +1,94 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { test } from "vitest"; +import vm from "node:vm"; +import { + componentProps, + componentSourceForTest, + findComponent, +} from "../../../lib/vm-component-harness"; + +function typingIndicatorSourceForTest() { + return componentSourceForTest( + new URL("./typing-indicator.tsx", import.meta.url), + "TypingIndicator", + ); +} + +test("TypingIndicator keeps the brief action label beside the working indicator", () => { + const components = { + NearProcessIndicator() {}, + }; + const context = { + ...components, + globalThis: {}, + }; + + vm.runInNewContext(typingIndicatorSourceForTest(), context); + const tree = context.globalThis.__testExports.TypingIndicator(); + const indicator = findComponent(tree, components.NearProcessIndicator); + + assert.deepEqual( + componentProps(indicator, components.NearProcessIndicator), + { + state: "working", + label: "Working…", + }, + ); +}); + +test("TypingIndicator keeps the static mark with elapsed time after completion", () => { + const components = { + NearProcessIndicator() {}, + }; + const context = { + ...components, + globalThis: {}, + }; + + vm.runInNewContext(typingIndicatorSourceForTest(), context); + const tree = context.globalThis.__testExports.TypingIndicator({ + state: "done", + durationSeconds: 12, + }); + const indicator = findComponent(tree, components.NearProcessIndicator); + + assert.deepEqual( + componentProps(indicator, components.NearProcessIndicator), + { + state: "done", + label: "Worked for 12s", + }, + ); +}); + +test.each([ + [60, "Worked for 00:01:00"], + [3_661, "Worked for 01:01:01"], +])( + "TypingIndicator formats completed runs of %i seconds as HH:MM:SS", + (durationSeconds, label) => { + const components = { + NearProcessIndicator() {}, + }; + const context = { + ...components, + globalThis: {}, + }; + + vm.runInNewContext(typingIndicatorSourceForTest(), context); + const tree = context.globalThis.__testExports.TypingIndicator({ + state: "done", + durationSeconds, + }); + const indicator = findComponent(tree, components.NearProcessIndicator); + + assert.deepEqual( + componentProps(indicator, components.NearProcessIndicator), + { + state: "done", + label, + }, + ); + }, +); 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..c38a67fd997 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,39 @@ +import { NearProcessIndicator } from "./near-process-indicator"; -export function TypingIndicator() { +type TypingIndicatorProps = + | { state?: "working"; durationSeconds?: never } + | { state: "done"; durationSeconds: number }; + +function formatDuration(durationSeconds: number): string { + if (durationSeconds < 60) { + return `${durationSeconds}s`; + } + + const hours = Math.floor(durationSeconds / 3_600); + const minutes = Math.floor((durationSeconds % 3_600) / 60); + const seconds = durationSeconds % 60; + + return [hours, minutes, seconds] + .map((part) => String(part).padStart(2, "0")) + .join(":"); +} + +export function TypingIndicator({ + state = "working", + durationSeconds, +}: TypingIndicatorProps = {}) { return (
-
-
- - - -
+
+
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
+
+
+ +
+ 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.

+
+
+ + 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"