@tangle-network/ui 11.47.0 → 11.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/run.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { RunStatusIcon, runStatus, statusMeta } from "./run/status-meta.js";
1
2
  import { ExpandedToolDetail } from "./run/expanded-tool-detail.js";
2
3
  import { RunRowShell, RunRowStatusDot } from "./run/run-row-shell.js";
3
4
  import { InlineToolItem } from "./run/inline-tool-item.js";
@@ -8,4 +9,4 @@ import { ToolCallFeed, parseToolEvent } from "./run/tool-call-feed.js";
8
9
  import { TaskList, showsTaskPriority, taskProgress, taskStatus } from "./run/task-list.js";
9
10
  import { ApprovalCard, ApprovalDiffSummary } from "./run/approval-card.js";
10
11
  import { RunPhaseList } from "./run/run-phase-list.js";
11
- export { ApprovalCard, ApprovalDiffSummary, ExpandedToolDetail, InlineThinkingItem, InlineToolItem, LiveDuration, RunGroup, RunPhaseList, RunRowShell, RunRowStatusDot, TaskList, ToolCallFeed, parseToolEvent, showsTaskPriority, taskProgress, taskStatus };
12
+ export { ApprovalCard, ApprovalDiffSummary, ExpandedToolDetail, InlineThinkingItem, InlineToolItem, LiveDuration, RunGroup, RunPhaseList, RunRowShell, RunRowStatusDot, RunStatusIcon, TaskList, ToolCallFeed, parseToolEvent, runStatus, showsTaskPriority, statusMeta, taskProgress, taskStatus };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.47.0",
3
+ "version": "11.49.0",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -166,7 +166,7 @@
166
166
  "peerDependencies": {
167
167
  "@hocuspocus/provider": ">=2.15.0 <5.0.0",
168
168
  "@nanostores/react": ">=0.8.4 <3.0.0",
169
- "@tangle-network/brand": "^1.29.0",
169
+ "@tangle-network/brand": "^1.30.0",
170
170
  "@tiptap/core": ">=3.20.4 <4.0.0",
171
171
  "@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
172
172
  "@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
package/src/chat/index.ts CHANGED
@@ -3,6 +3,15 @@ export { MessageList, type MessageListProps } from "./message-list";
3
3
  export { UserMessage, type UserMessageProps } from "./user-message";
4
4
  export { ChatMessage, type ChatMessageProps, type MessageRole } from "./chat-message";
5
5
  export { MessageAuthor, isViewerMessage, type MessageAuthorProps } from "./message-author";
6
+ export {
7
+ SpeakerHeader,
8
+ partitionBySpeaker,
9
+ type Speaker,
10
+ type SpeakerHeaderProps,
11
+ type SpeakerPart,
12
+ type SpeakerSection,
13
+ type SpeakerState,
14
+ } from "./speaker";
6
15
  export type { ChatAuthor } from "../types/message";
7
16
  export { ThinkingIndicator, type ThinkingIndicatorProps } from "./thinking-indicator";
8
17
  export {
@@ -15,20 +15,21 @@ export function isViewerMessage(author: ChatAuthor | undefined, viewerId: string
15
15
  return author === undefined || author.id === viewerId;
16
16
  }
17
17
 
18
- export interface MessageAuthorProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
18
+ export interface MessageAuthorProps extends HTMLAttributes<HTMLDivElement> {
19
19
  author: ChatAuthor;
20
20
  }
21
21
 
22
22
  /**
23
23
  * The line above a message in a transcript with several participants: the
24
- * author's avatar, name and, when given, their role as a tag.
24
+ * author's avatar, name and, when given, their role as a tag. Children follow
25
+ * on the same line, such as a responder's handle and state.
25
26
  *
26
27
  * The avatar's tone comes from the author's id, so a participant keeps one
27
28
  * colour for the whole thread and in every product. Tones can repeat between
28
29
  * participants, so the name is always shown beside it. The avatar repeats the
29
30
  * visible name and is hidden from assistive technology.
30
31
  */
31
- export function MessageAuthor({ author, className, ...props }: MessageAuthorProps) {
32
+ export function MessageAuthor({ author, className, children, ...props }: MessageAuthorProps) {
32
33
  return (
33
34
  <div data-message-author="" className={cn("flex min-w-0 max-w-full items-center gap-2", className)} {...props}>
34
35
  <IconTile
@@ -47,6 +48,7 @@ export function MessageAuthor({ author, className, ...props }: MessageAuthorProp
47
48
  {author.role}
48
49
  </Tag>
49
50
  ) : null}
51
+ {children}
50
52
  </div>
51
53
  );
52
54
  }
@@ -0,0 +1,95 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { partitionBySpeaker, SpeakerHeader } from './speaker'
3
+ import { TRANSCRIPT_TEXT } from './transcript-text'
4
+
5
+ type Part =
6
+ | { type: 'speaker'; id: string; name: string; handle?: string; state?: 'answering' | 'done' | 'failed' }
7
+ | { type: 'text'; text: string; speaker?: string }
8
+ | { type: 'tool'; tool: string; status: string; speaker?: string }
9
+ | { type: 'notice'; text: string; speaker?: string }
10
+
11
+ /** One turn's parts drawn the way a product draws them: a header per responder, its parts under it. */
12
+ function Turn({ parts, live = false }: { parts: Part[]; live?: boolean }) {
13
+ return (
14
+ <div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-4 py-6">
15
+ <p className={`ml-auto w-fit max-w-[78%] rounded-2xl bg-foreground px-4 py-3 text-background ${TRANSCRIPT_TEXT}`}>
16
+ @writer @research-lead plan the launch announcement for the roster feature.
17
+ </p>
18
+ {partitionBySpeaker(parts).map(({ speaker, parts: own }) => (
19
+ <section key={speaker?.id ?? 'turn'} className="flex flex-col gap-2">
20
+ {speaker ? <SpeakerHeader speaker={speaker} live={live} /> : null}
21
+ {own.map((part, index) =>
22
+ part.type === 'text' ? (
23
+ <p key={index} className={TRANSCRIPT_TEXT}>{part.text}</p>
24
+ ) : part.type === 'tool' ? (
25
+ <p key={index} className="font-mono text-muted-foreground text-sm">{part.tool} · {part.status}</p>
26
+ ) : part.type === 'notice' ? (
27
+ <p key={index} className="text-[var(--surface-danger-text)] text-sm">{part.text}</p>
28
+ ) : null,
29
+ )}
30
+ </section>
31
+ ))}
32
+ </div>
33
+ )
34
+ }
35
+
36
+ const meta: Meta<typeof Turn> = {
37
+ title: 'Chat/Speakers',
38
+ component: Turn,
39
+ parameters: { layout: 'fullscreen' },
40
+ decorators: [
41
+ (Story) => (
42
+ <div className="min-h-screen bg-[var(--bg-root)]">
43
+ <Story />
44
+ </div>
45
+ ),
46
+ ],
47
+ }
48
+
49
+ export default meta
50
+ type Story = StoryObj<typeof Turn>
51
+
52
+ /**
53
+ * A live turn as the stream delivers it: both responders announced first, then
54
+ * their output interleaved, each part tagged with its responder. The Writer has
55
+ * reported its end; the Research Lead is still answering.
56
+ */
57
+ export const AnsweringAtOnce: Story = {
58
+ args: {
59
+ live: true,
60
+ parts: [
61
+ { type: 'speaker', id: 'ap_writer', name: 'Writer', handle: 'writer' },
62
+ { type: 'speaker', id: 'ap_research', name: 'Research Lead', handle: 'research-lead' },
63
+ { type: 'text', speaker: 'ap_writer', text: 'Draft headline: "Every agent in your workspace, one @ away."' },
64
+ { type: 'tool', speaker: 'ap_research', tool: 'search_repo', status: 'running' },
65
+ { type: 'text', speaker: 'ap_writer', text: 'Subhead: address any agent from any thread, and let it run on a schedule while you sleep.' },
66
+ { type: 'speaker', id: 'ap_writer', name: 'Writer', handle: 'writer', state: 'done' },
67
+ { type: 'text', speaker: 'ap_research', text: 'Checking the roster and the mention routes before naming risks.' },
68
+ ],
69
+ },
70
+ }
71
+
72
+ /** A stored row: a speaker part before each answer. The Research Lead failed and keeps its header and its notice. */
73
+ export const StoredWithFailure: Story = {
74
+ args: {
75
+ parts: [
76
+ { type: 'speaker', id: 'ap_writer', name: 'Writer', handle: 'writer', state: 'done' },
77
+ { type: 'text', text: 'Draft headline: "Every agent in your workspace, one @ away."' },
78
+ { type: 'speaker', id: 'ap_research', name: 'Research Lead', handle: 'research-lead', state: 'failed' },
79
+ { type: 'tool', tool: 'search_repo', status: 'error' },
80
+ { type: 'notice', text: 'Research Lead could not answer: the model provider refused the request (429).' },
81
+ ],
82
+ },
83
+ }
84
+
85
+ /** Worst case for a phone: a long name and handle while answering. The name and handle truncate; the state stays whole. */
86
+ export const LongNames: Story = {
87
+ args: {
88
+ live: true,
89
+ parts: [
90
+ { type: 'speaker', id: 'ap_compliance', name: 'Regional Compliance and Disclosure Reviewer', handle: 'regional-compliance-disclosure-reviewer' },
91
+ { type: 'text', text: 'Reading the disclosure rules for each launch region.' },
92
+ { type: 'speaker', id: 'ap_q', name: 'Q', state: 'failed' },
93
+ ],
94
+ },
95
+ }
@@ -0,0 +1,81 @@
1
+ import { render, screen } from "@testing-library/react"
2
+ import { describe, expect, it } from "vitest"
3
+ import { MessageAuthor } from "./message-author"
4
+ import { partitionBySpeaker, SpeakerHeader, type Speaker } from "./speaker"
5
+
6
+ type Part = { type: string; id?: string; name?: string; handle?: string; state?: Speaker["state"]; text?: string; speaker?: string }
7
+
8
+ const writer = { type: "speaker", id: "ap_writer", name: "Writer", handle: "writer" }
9
+ const research = { type: "speaker", id: "ap_research", name: "Research Lead", handle: "research-lead" }
10
+ const text = (value: string, speaker?: string): Part => ({ type: "text", text: value, ...(speaker ? { speaker } : {}) })
11
+
12
+ /** Each section as `[speaker id or null, its texts]`. */
13
+ const shape = (parts: Part[]) =>
14
+ partitionBySpeaker(parts).map((section) => [section.speaker?.id ?? null, section.parts.map((part) => part.text)])
15
+
16
+ describe("partitionBySpeaker", () => {
17
+ it("splits a stored row at its speaker parts, in stored order", () => {
18
+ expect(shape([writer, text("headline"), text("subhead"), research, text("risks")])).toEqual([
19
+ ["ap_writer", ["headline", "subhead"]],
20
+ ["ap_research", ["risks"]],
21
+ ])
22
+ })
23
+
24
+ it("groups a live stream by each part's speaker tag, not by where it arrived", () => {
25
+ const live = [writer, research, text("headline", "ap_writer"), text("searching", "ap_research"), text("subhead", "ap_writer"), text("risks", "ap_research")]
26
+ expect(shape(live)).toEqual([
27
+ ["ap_writer", ["headline", "subhead"]],
28
+ ["ap_research", ["searching", "risks"]],
29
+ ])
30
+ })
31
+
32
+ it("takes a responder's end state from a later speaker part, without a second section", () => {
33
+ const sections = partitionBySpeaker<Part>([writer, research, text("headline", "ap_writer"), { ...writer, state: "done" }, text("risks", "ap_research")])
34
+ expect(sections.map((section) => [section.speaker?.name, section.speaker?.state])).toEqual([["Writer", "done"], ["Research Lead", undefined]])
35
+ expect(sections[1]?.parts.map((part) => part.text)).toEqual(["risks"])
36
+ })
37
+
38
+ it("keeps parts no speaker owns, first, and drops none", () => {
39
+ expect(shape([text("preamble"), writer, text("headline"), text("stray", "ap_nobody")])).toEqual([
40
+ [null, ["preamble", "stray"]],
41
+ ["ap_writer", ["headline"]],
42
+ ])
43
+ expect(shape([text("one agent"), text("one answer")])).toEqual([[null, ["one agent", "one answer"]]])
44
+ expect(shape([])).toEqual([[null, []]])
45
+ })
46
+ })
47
+
48
+ describe("SpeakerHeader", () => {
49
+ it("names the responder in its own element, with its handle, answering while the turn runs", () => {
50
+ const { container } = render(<SpeakerHeader speaker={{ id: "ap_writer", name: "Writer", handle: "writer" }} live />)
51
+ expect(screen.getByText(/^Writer$/)).toBeInTheDocument()
52
+ expect(screen.getByText("@writer")).toBeInTheDocument()
53
+ expect(screen.getByText("Answering")).toBeInTheDocument()
54
+ expect(container.querySelector("[data-speaker-state]")?.getAttribute("data-speaker-state")).toBe("answering")
55
+ })
56
+
57
+ it("says nothing more once the responder has ended, even while others answer", () => {
58
+ const { container } = render(<SpeakerHeader speaker={{ id: "ap_writer", name: "Writer", state: "done" }} live />)
59
+ expect(screen.queryByText("Answering")).not.toBeInTheDocument()
60
+ expect(screen.queryByText("Failed")).not.toBeInTheDocument()
61
+ expect(container.querySelector("[data-speaker-state]")?.getAttribute("data-speaker-state")).toBe("done")
62
+ })
63
+
64
+ it("shows a failed responder as failed while the turn still runs", () => {
65
+ render(<SpeakerHeader speaker={{ id: "ap_research", name: "Research Lead", state: "failed" }} live />)
66
+ expect(screen.getByText("Failed")).toBeInTheDocument()
67
+ expect(screen.queryByText("Answering")).not.toBeInTheDocument()
68
+ })
69
+
70
+ it("treats a responder without an end state on a finished turn as done", () => {
71
+ const { container } = render(<SpeakerHeader speaker={{ id: "ap_writer", name: "Writer" }} />)
72
+ expect(container.querySelector("[data-speaker-state]")?.getAttribute("data-speaker-state")).toBe("done")
73
+ })
74
+
75
+ it("draws the author line every participant gets, so an agent keeps its tone", () => {
76
+ const avatar = (node: HTMLElement) => node.querySelector("[aria-hidden]")!.className
77
+ const header = render(<SpeakerHeader speaker={{ id: "ap_writer", name: "Writer" }} />).container
78
+ const author = render(<MessageAuthor author={{ id: "ap_writer", name: "Writer" }} />).container
79
+ expect(avatar(header)).toBe(avatar(author))
80
+ })
81
+ })
@@ -0,0 +1,110 @@
1
+ import type { HTMLAttributes } from "react";
2
+ import { StatusPill } from "../primitives/status-pill";
3
+ import { MessageAuthor } from "./message-author";
4
+
5
+ /** Where one responder is in its answer. */
6
+ export type SpeakerState = "answering" | "done" | "failed";
7
+
8
+ /**
9
+ * A responder of a turn addressed to several agents. In the turn's parts it is
10
+ * a `{ type: "speaker", id, name, handle?, state? }` part written before the
11
+ * responder's answer.
12
+ */
13
+ export interface Speaker {
14
+ /** The agent's stable id. It keys the avatar's tone and a live part's `speaker` tag. */
15
+ id: string;
16
+ name: string;
17
+ /** What a person types after `@` to address the agent. */
18
+ handle?: string;
19
+ /** Set once the responder ended; absent while it answers and on rows written before responders reported an end. */
20
+ state?: SpeakerState;
21
+ }
22
+
23
+ export type SpeakerPart = Speaker & { type: "speaker" };
24
+
25
+ /** One responder's share of a turn: its speaker part and the parts it wrote. */
26
+ export interface SpeakerSection<T> {
27
+ /** The latest speaker part for this responder; null for the parts no speaker owns. */
28
+ speaker: (T & SpeakerPart) | null;
29
+ parts: T[];
30
+ }
31
+
32
+ function isSpeakerPart<T>(part: T): part is T & SpeakerPart {
33
+ const record = part as { type?: unknown; id?: unknown; name?: unknown } | null;
34
+ return !!record && record.type === "speaker" && typeof record.id === "string" && typeof record.name === "string";
35
+ }
36
+
37
+ /**
38
+ * Splits one assistant turn's parts by the agent that wrote them.
39
+ *
40
+ * A stored row holds a speaker part before each responder's answer, so a part
41
+ * belongs to the speaker part before it. A live stream announces every
42
+ * responder first and then interleaves their output, so a part names its owner
43
+ * in `speaker` (the responder's id), which wins over position. A later speaker
44
+ * part with an id already seen replaces that responder's speaker part, which is
45
+ * how its end state arrives, and never opens a second section.
46
+ *
47
+ * Sections keep the order speakers were first announced. Parts no speaker owns
48
+ * come first, in a section whose `speaker` is null; a turn without speaker
49
+ * parts is that one section. No part is dropped, copied or reordered within its
50
+ * section.
51
+ */
52
+ export function partitionBySpeaker<T extends object>(parts: readonly T[]): SpeakerSection<T>[] {
53
+ const sections = new Map<string, SpeakerSection<T>>();
54
+ const unowned: T[] = [];
55
+ let current: SpeakerSection<T> | undefined;
56
+ for (const part of parts) {
57
+ if (isSpeakerPart(part)) {
58
+ current = sections.get(part.id);
59
+ if (current) current.speaker = part;
60
+ else sections.set(part.id, (current = { speaker: part, parts: [] }));
61
+ continue;
62
+ }
63
+ const tag = (part as { speaker?: unknown }).speaker;
64
+ const owner = typeof tag === "string" ? sections.get(tag) : current;
65
+ (owner?.parts ?? unowned).push(part);
66
+ }
67
+ const owned = [...sections.values()];
68
+ return unowned.length > 0 || owned.length === 0 ? [{ speaker: null, parts: unowned }, ...owned] : owned;
69
+ }
70
+
71
+ export interface SpeakerHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
72
+ speaker: Speaker;
73
+ /** The turn is still running, so a responder that has not reported an end is answering. */
74
+ live?: boolean;
75
+ }
76
+
77
+ /**
78
+ * The line above one responder's answer: the author line every participant
79
+ * gets, then the handle a person types to address the agent, then its state.
80
+ *
81
+ * The state comes from the speaker's own end state, else from `live`. An
82
+ * answering responder shows the status grammar's open ring with the word, a
83
+ * failed one its cross with the word, and a finished one nothing, as a settled
84
+ * run row does. `data-speaker-state` carries all three.
85
+ */
86
+ export function SpeakerHeader({ speaker, live = false, ...props }: SpeakerHeaderProps) {
87
+ const state: SpeakerState = speaker.state ?? (live ? "answering" : "done");
88
+ return (
89
+ <MessageAuthor
90
+ author={{ id: speaker.id, name: speaker.name }}
91
+ data-speaker-state={state}
92
+ {...props}
93
+ >
94
+ {speaker.handle ? (
95
+ <span className="min-w-0 truncate text-muted-foreground text-sm" title={`@${speaker.handle}`}>
96
+ @{speaker.handle}
97
+ </span>
98
+ ) : null}
99
+ {state === "answering" ? (
100
+ <StatusPill tone="running" bare size="md" className="shrink-0 py-0">
101
+ Answering
102
+ </StatusPill>
103
+ ) : state === "failed" ? (
104
+ <StatusPill tone="danger" bare size="md" className="shrink-0 py-0">
105
+ Failed
106
+ </StatusPill>
107
+ ) : null}
108
+ </MessageAuthor>
109
+ );
110
+ }
@@ -1,11 +1,5 @@
1
1
  import { memo } from "react";
2
- import {
3
- ArrowRight,
4
- ArrowLeft,
5
- AlertCircle,
6
- Loader2,
7
- FileText,
8
- } from "lucide-react";
2
+ import { ArrowRight, ArrowLeft, FileText } from "lucide-react";
9
3
  import type { ToolPart } from "../types/parts";
10
4
  import { getToolDisplayMetadata } from "../utils/tool-display";
11
5
  import { CommandPreview } from "../tool-previews/command-preview";
@@ -16,8 +10,8 @@ import { GlobResultsPreview } from "../tool-previews/glob-results-preview";
16
10
  import { WebSearchPreview } from "../tool-previews/web-search-preview";
17
11
  import { QuestionPreview } from "../tool-previews/question-preview";
18
12
  import { DiffPreview } from "../tool-previews/diff-preview";
19
- import { cn } from "../lib/utils";
20
13
  import { ExpandableContent } from "./expandable-content";
14
+ import { RunStatusIcon } from "./status-meta";
21
15
 
22
16
  export interface ExpandedToolDetailProps {
23
17
  part: ToolPart;
@@ -169,7 +163,7 @@ export const ExpandedToolDetail = memo(({ part }: ExpandedToolDetailProps) => {
169
163
  {error && (
170
164
  <div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--surface-danger-border)]">
171
165
  <div className="flex items-center gap-2 border-b border-[var(--surface-danger-border)] bg-[var(--surface-danger-bg)] px-3 py-2">
172
- <AlertCircle className="h-3 w-3 text-[var(--surface-danger-text)]" />
166
+ <RunStatusIcon status="failed" aria-hidden className="size-3" />
173
167
  <span className="text-xs font-medium uppercase tracking-[0.08em] text-[var(--surface-danger-text)]">
174
168
  Error
175
169
  </span>
@@ -185,7 +179,7 @@ export const ExpandedToolDetail = memo(({ part }: ExpandedToolDetailProps) => {
185
179
  {/* Running state */}
186
180
  {(status === "pending" || status === "running") && (
187
181
  <div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-border bg-muted px-3 py-3 text-xs text-muted-foreground">
188
- <Loader2 className={cn("h-3 w-3 animate-spin text-primary")} />
182
+ <RunStatusIcon status="running" aria-hidden className="size-3" />
189
183
  Running…
190
184
  </div>
191
185
  )}
package/src/run/index.ts CHANGED
@@ -37,3 +37,11 @@ export {
37
37
  type ApprovalPlanStep,
38
38
  } from "./approval-card";
39
39
  export { RunPhaseList, type RunPhase } from "./run-phase-list";
40
+ export {
41
+ RunStatusIcon,
42
+ runStatus,
43
+ statusMeta,
44
+ type RunStatus,
45
+ type RunStatusIconProps,
46
+ type StatusMeta,
47
+ } from "./status-meta";
@@ -2,7 +2,6 @@ import { memo, useMemo, type ComponentType, type ReactNode } from "react";
2
2
  import * as Collapsible from "@radix-ui/react-collapsible";
3
3
  import {
4
4
  Bot,
5
- Loader2,
6
5
  ChevronDown,
7
6
  ChevronRight,
8
7
  Terminal,
@@ -13,7 +12,6 @@ import {
13
12
  Globe,
14
13
  ClipboardList,
15
14
  Settings,
16
- Sparkles,
17
15
  type LucideProps,
18
16
  } from "lucide-react";
19
17
  import { cn } from "../lib/utils";
@@ -24,6 +22,7 @@ import type { AgentBranding } from "../types/branding";
24
22
  import type { CustomToolRenderer } from "../types/tool-display";
25
23
  import { InlineToolItem } from "./inline-tool-item";
26
24
  import { InlineThinkingItem } from "./inline-thinking-item";
25
+ import { RunStatusIcon, statusMeta } from "./status-meta";
27
26
  import { Markdown } from "../markdown/markdown";
28
27
  import { SyntheticText } from "../chat/synthetic-text";
29
28
 
@@ -98,7 +97,7 @@ function AssistantShell({
98
97
  <span>{branding.label}</span>
99
98
  {isStreaming ? (
100
99
  <span className="inline-flex items-center gap-1.5">
101
- <Loader2 className="h-3 w-3 animate-spin" />
100
+ <RunStatusIcon status="running" aria-hidden className="size-3" />
102
101
  Thinking
103
102
  </span>
104
103
  ) : null}
@@ -418,7 +417,7 @@ export const RunGroup = memo(
418
417
  key={key}
419
418
  className="flex items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--md3-surface-container)] px-4 py-3 text-sm text-muted-foreground"
420
419
  >
421
- <Loader2 className="h-4 w-4 animate-spin text-primary" />
420
+ <RunStatusIcon status="running" aria-hidden />
422
421
  Building view…
423
422
  </div>
424
423
  );
@@ -484,17 +483,10 @@ export const RunGroup = memo(
484
483
  ) : null}
485
484
  <span className="ml-auto flex shrink-0 items-center gap-1.5">
486
485
  <CategoryBadges categories={stats.toolCategories} />
487
- {isStreaming ? (
488
- <span className="inline-flex items-center gap-1 rounded-full border border-[var(--border-accent)] bg-[var(--accent-surface-soft)] px-2 py-px text-[10px] font-semibold uppercase text-[var(--accent-text)]">
489
- <Loader2 className="h-2.5 w-2.5 animate-spin" />
490
- Running
491
- </span>
492
- ) : (
493
- <span className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-px text-[10px] font-semibold uppercase text-muted-foreground">
494
- <Sparkles className="h-2.5 w-2.5" />
495
- Done
496
- </span>
497
- )}
486
+ <span className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-px text-[10px] font-semibold uppercase text-muted-foreground">
487
+ <RunStatusIcon status={isStreaming ? "running" : "done"} aria-hidden className="size-3" />
488
+ {statusMeta(isStreaming ? "running" : "done").label}
489
+ </span>
498
490
  </span>
499
491
  </button>
500
492
  </Collapsible.Trigger>
@@ -542,7 +534,7 @@ export const RunGroup = memo(
542
534
  } else if (toolPart.state.status === "running") {
543
535
  node = (
544
536
  <div className="flex items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--md3-surface-container)] px-4 py-3 text-sm text-muted-foreground">
545
- <Loader2 className="h-4 w-4 animate-spin text-primary" />
537
+ <RunStatusIcon status="running" aria-hidden />
546
538
  Building view…
547
539
  </div>
548
540
  );
@@ -0,0 +1,19 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { RunPhaseList } from "./run-phase-list";
3
+
4
+ const meta: Meta<typeof RunPhaseList> = {
5
+ title: "Run/RunPhaseList",
6
+ component: RunPhaseList,
7
+ parameters: { layout: "padded" },
8
+ };
9
+ export default meta;
10
+
11
+ export const Phases: StoryObj<typeof RunPhaseList> = {
12
+ args: {
13
+ phases: [
14
+ { id: "find", title: "Find the hero source", summary: "Read 12 files · searched 3 times", status: "done", content: <p>12 reads</p> },
15
+ { id: "patch", title: "Patch the layout", summary: "2 edits", status: "error", content: <p>An edit failed</p> },
16
+ { id: "prove", title: "Prove it on a phone", summary: "Running the browser", status: "running", content: <p>Chromium at 390</p> },
17
+ ],
18
+ },
19
+ };
@@ -20,6 +20,14 @@ describe("RunPhaseList", () => {
20
20
  expect(screen.queryByText("step detail")).toBeNull();
21
21
  fireEvent.click(first);
22
22
  expect(screen.getByText("step detail")).toBeInTheDocument();
23
- expect(screen.getByLabelText("Had an error")).toBeInTheDocument();
23
+ expect(screen.getByRole("img", { name: "Failed" }).getAttribute("class")).toMatch(/circle-x/);
24
+ });
25
+
26
+ it("marks a running phase with the breathing status glyph, not a spinner", () => {
27
+ const { container } = render(
28
+ <RunPhaseList phases={[{ id: "a", title: "Prove it", status: "running", content: null }]} />,
29
+ );
30
+ expect(screen.getByRole("img", { name: "Running" }).getAttribute("data-motion")).toBe("essential");
31
+ expect(container.querySelector(".animate-spin")).toBeNull();
24
32
  });
25
33
  });
@@ -1,7 +1,8 @@
1
1
  import { useState, type ReactNode } from "react";
2
- import { ChevronRight, CircleAlert, Loader2 } from "lucide-react";
2
+ import { ChevronRight } from "lucide-react";
3
3
  import { focusRing } from "../lib/focus";
4
4
  import { cn } from "../lib/utils";
5
+ import { RunStatusIcon } from "./status-meta";
5
6
 
6
7
  export interface RunPhase {
7
8
  id: string;
@@ -37,8 +38,7 @@ function PhaseRow({ phase }: { phase: RunPhase }) {
37
38
  <span className="font-medium text-foreground">{phase.title}</span>
38
39
  {phase.summary && <span className="text-muted-foreground"> · {phase.summary}</span>}
39
40
  </span>
40
- {phase.status === "running" && <Loader2 aria-label="Running" className="mt-1 size-4 shrink-0 animate-spin text-muted-foreground" />}
41
- {phase.status === "error" && <CircleAlert aria-label="Had an error" className="mt-1 size-4 shrink-0 text-[var(--surface-danger-text)]" />}
41
+ {phase.status && phase.status !== "done" && <RunStatusIcon status={phase.status} className="mt-1" />}
42
42
  </button>
43
43
  {open && <div id={id} className="mb-2 ml-6 mt-1">{phase.content}</div>}
44
44
  </li>
@@ -0,0 +1,92 @@
1
+ import {
2
+ CircleCheck,
3
+ CircleDashed,
4
+ CircleDot,
5
+ CircleQuestionMark,
6
+ CircleStop,
7
+ CircleX,
8
+ Hand,
9
+ Hourglass,
10
+ type LucideIcon,
11
+ type LucideProps,
12
+ } from "lucide-react";
13
+ import { cn } from "../lib/utils";
14
+
15
+ /** The states an agent or a run reports. Every surface names them with `statusMeta`. */
16
+ export type RunStatus = "running" | "waiting" | "needs-you" | "done" | "failed" | "stopped" | "idle" | "unknown";
17
+
18
+ export interface StatusMeta {
19
+ /** The one word for the state. A qualifier such as "Shipped" follows Done; it never replaces it. */
20
+ label: string;
21
+ /** The lucide glyph, drawn on the 16px grid at stroke 1.75 (`RunStatusIcon` does both). */
22
+ icon: LucideIcon;
23
+ /** The Brand colour token for the glyph and any bare status text: `color: var(<token>)`. */
24
+ token: `--${string}`;
25
+ }
26
+
27
+ // Amber is attention, and only Needs you is attention: waiting on a peer, a
28
+ // stop and an idle agent are neutral. Running takes the info ink that
29
+ // StatusPill's running tone and the run-mix in-flight slice already use, not
30
+ // the accent, so a per-user accent can never make Running read as Done or
31
+ // Failed. Failed is always CircleX; the warning triangle is for warnings.
32
+ const META: Record<RunStatus, StatusMeta> = {
33
+ running: { label: "Running", icon: CircleDot, token: "--surface-info-text" },
34
+ waiting: { label: "Waiting", icon: Hourglass, token: "--surface-neutral-text" },
35
+ "needs-you": { label: "Needs you", icon: Hand, token: "--surface-warning-text" },
36
+ done: { label: "Done", icon: CircleCheck, token: "--surface-success-text" },
37
+ failed: { label: "Failed", icon: CircleX, token: "--surface-danger-text" },
38
+ stopped: { label: "Stopped", icon: CircleStop, token: "--surface-neutral-text" },
39
+ idle: { label: "Idle", icon: CircleDashed, token: "--surface-neutral-text" },
40
+ unknown: { label: "Unknown", icon: CircleQuestionMark, token: "--surface-neutral-text" },
41
+ };
42
+
43
+ // The vocabularies ui and its products already speak, so each maps here once.
44
+ const ALIASES: Record<string, RunStatus> = {
45
+ active: "running", in_progress: "running", "in-progress": "running", streaming: "running",
46
+ pending: "waiting", queued: "waiting", blocked: "waiting",
47
+ "attention-needed": "needs-you", needs_you: "needs-you", needs_input: "needs-you", awaiting_input: "needs-you",
48
+ completed: "done", complete: "done", success: "done", succeeded: "done", shipped: "done",
49
+ error: "failed", errored: "failed",
50
+ cancelled: "stopped", canceled: "stopped", aborted: "stopped",
51
+ };
52
+
53
+ /** Any status string as a `RunStatus`; a value nothing names reads as Unknown, never as a guess. */
54
+ export function runStatus(status: string): RunStatus {
55
+ const key = status.trim().toLowerCase();
56
+ if (Object.hasOwn(META, key)) return key as RunStatus;
57
+ return Object.hasOwn(ALIASES, key) ? ALIASES[key] : "unknown";
58
+ }
59
+
60
+ /** The label, glyph and colour token for a status, in every surface. */
61
+ export function statusMeta(status: RunStatus | (string & {})): StatusMeta {
62
+ return META[runStatus(status)];
63
+ }
64
+
65
+ export interface RunStatusIconProps extends Omit<LucideProps, "ref"> {
66
+ status: RunStatus | (string & {});
67
+ }
68
+
69
+ /**
70
+ * A status glyph on the icon grid, in its token colour and labelled with its
71
+ * word. Pass `aria-hidden` where the label is already printed beside it.
72
+ * Running breathes on `--motion-ambient`, the page's one loop period; the
73
+ * breathe is opacity only, so it is the essential motion reduced motion keeps.
74
+ */
75
+ export function RunStatusIcon({ status, className, style, ...props }: RunStatusIconProps) {
76
+ const key = runStatus(status);
77
+ const { label, icon: Icon, token } = META[key];
78
+ const running = key === "running";
79
+ return (
80
+ <Icon
81
+ role="img"
82
+ aria-label={label}
83
+ size={16}
84
+ strokeWidth={1.75}
85
+ data-run-status={key}
86
+ data-motion={running ? "essential" : undefined}
87
+ className={cn("shrink-0", running && "animate-[tangle-breathe_var(--motion-ambient)_infinite]", className)}
88
+ style={{ color: `var(${token})`, ...style }}
89
+ {...props}
90
+ />
91
+ );
92
+ }