@tangle-network/ui 11.48.0 → 11.50.1

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/chat/agent-timeline.js +4 -3
  3. package/dist/chat/speaker.d.ts +3 -3
  4. package/dist/chat/speaker.js +12 -18
  5. package/dist/index.d.ts +4 -3
  6. package/dist/index.js +4 -3
  7. package/dist/markdown/code-block.js +1 -1
  8. package/dist/presentation.d.ts +12 -0
  9. package/dist/presentation.js +85 -1
  10. package/dist/primitives/metric-strip.d.ts +2 -2
  11. package/dist/primitives/page-shell.d.ts +6 -3
  12. package/dist/primitives/page-shell.js +3 -60
  13. package/dist/primitives/progress.d.ts +8 -0
  14. package/dist/primitives/progress.js +8 -2
  15. package/dist/primitives/settings.d.ts +28 -1
  16. package/dist/primitives/settings.js +78 -29
  17. package/dist/primitives/status-pill.js +11 -2
  18. package/dist/primitives.d.ts +3 -3
  19. package/dist/primitives.js +3 -3
  20. package/dist/run/expanded-tool-detail.js +12 -4
  21. package/dist/run/run-group.js +21 -9
  22. package/dist/run/run-phase-list.js +5 -8
  23. package/dist/run/status-meta.d.ts +25 -0
  24. package/dist/run/status-meta.js +105 -0
  25. package/dist/run.d.ts +2 -1
  26. package/dist/run.js +2 -1
  27. package/package.json +2 -2
  28. package/src/chat/agent-timeline.test.tsx +9 -0
  29. package/src/chat/agent-timeline.tsx +4 -3
  30. package/src/chat/speaker.test.tsx +3 -1
  31. package/src/chat/speaker.tsx +13 -14
  32. package/src/markdown/code-block.tsx +2 -1
  33. package/src/markdown/markdown.stories.tsx +11 -0
  34. package/src/presentation.ts +68 -0
  35. package/src/primitives/index.ts +2 -1
  36. package/src/primitives/metric-strip.tsx +2 -2
  37. package/src/primitives/page-shell.tsx +9 -58
  38. package/src/primitives/progress.stories.tsx +10 -0
  39. package/src/primitives/progress.tsx +13 -2
  40. package/src/primitives/settings.stories.tsx +94 -3
  41. package/src/primitives/settings.tsx +80 -19
  42. package/src/primitives/status-pill.tsx +8 -1
  43. package/src/run/expanded-tool-detail.tsx +4 -10
  44. package/src/run/index.ts +8 -0
  45. package/src/run/run-group.tsx +8 -16
  46. package/src/run/run-phase-list.stories.tsx +19 -0
  47. package/src/run/run-phase-list.test.tsx +9 -1
  48. package/src/run/run-phase-list.tsx +3 -3
  49. package/src/run/status-meta.tsx +92 -0
  50. package/src/styles/presentation.css +3 -3
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { CircleAlert, Ellipsis, Lock } from "lucide-react";
3
+ import { CircleAlert, Ellipsis, Lock, TriangleAlert, type LucideIcon } from "lucide-react";
4
4
  import * as React from "react";
5
5
  import { cn } from "../lib/utils";
6
6
  import { Button } from "./button";
@@ -9,6 +9,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
9
9
  import { fieldMessageId } from "./field";
10
10
  import { InfoTip } from "./info-tip";
11
11
  import { Label } from "./label";
12
+ import { StatusGlyph, type StatusTone } from "./status-pill";
12
13
 
13
14
  /*
14
15
  * Settings rows and lists answer to their section card's width, not the
@@ -21,25 +22,46 @@ import { Label } from "./label";
21
22
  */
22
23
 
23
24
  const SCROLL_OFFSET = "scroll-mt-[calc(var(--shell-header-height)_+_1rem)]";
25
+ const GUTTER = "px-5 py-4 @max-[34rem]/settings-section:px-4";
24
26
  const DIVIDED = "not-first:border-t not-first:border-[color:var(--border-subtle)]";
25
27
  const ERROR_TEXT = "text-[color:var(--surface-danger-text)]";
26
28
 
27
- function ErrorLine({ id, className, children }: { id?: string; className?: string; children: React.ReactNode }) {
29
+ interface LineProps { id?: string; role?: "status" | "alert"; className?: string; children: React.ReactNode }
30
+
31
+ function GlyphLine({ icon: Icon, className, children, ...props }: LineProps & { icon: LucideIcon }) {
28
32
  return (
29
- <div id={id} role="alert" className={cn("flex items-start gap-1.5 text-sm leading-5 [overflow-wrap:anywhere]", ERROR_TEXT, className)}>
30
- <CircleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
33
+ <div {...props} className={cn("flex items-start gap-1.5 text-sm leading-5 [overflow-wrap:anywhere]", className)}>
34
+ <Icon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
31
35
  <div className="min-w-0">{children}</div>
32
36
  </div>
33
37
  );
34
38
  }
35
39
 
36
- function LockLine({ id, className, children }: { id?: string; className?: string; children: React.ReactNode }) {
37
- return (
38
- <div id={id} className={cn("flex items-start gap-1.5 text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]", className)}>
39
- <Lock aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
40
- <div className="min-w-0">{children}</div>
41
- </div>
42
- );
40
+ const ErrorLine = ({ className, ...props }: LineProps) => <GlyphLine icon={CircleAlert} role="alert" className={cn(ERROR_TEXT, className)} {...props} />;
41
+ const LockLine = ({ className, ...props }: LineProps) => <GlyphLine icon={Lock} className={cn("text-muted-foreground", className)} {...props} />;
42
+
43
+ // A long identifier (a URL, an address, an event name) is one unit: it moves to the next line
44
+ // whole, and only one wider than its column breaks, at its own punctuation, where a style guide
45
+ // breaks a URL: after a slash run or `@`, before `. - _ ? # & =`. A mid-word break is the last resort.
46
+ const TOKEN_BREAK = /(?<=[/@])(?=[^/])|(?<=[\p{L}\p{N}])(?=[.\-_?#&=][\p{L}\p{N}])/u;
47
+
48
+ function breakTokens(node: React.ReactNode): React.ReactNode {
49
+ if (typeof node === "string") {
50
+ if (!TOKEN_BREAK.test(node)) return node;
51
+ return node.split(/(\s+)/).map((word, index) => {
52
+ const parts = word.split(TOKEN_BREAK);
53
+ return parts.length < 2 ? word : (
54
+ <span key={index} className="inline-block max-w-full [text-decoration:inherit]">
55
+ {parts.map((part, at) => <React.Fragment key={at}>{at > 0 && <wbr />}{part}</React.Fragment>)}
56
+ </span>
57
+ );
58
+ });
59
+ }
60
+ if (Array.isArray(node)) return React.Children.map(node, breakTokens);
61
+ if (React.isValidElement<{ children?: React.ReactNode }>(node) && node.props.children != null && typeof node.props.children !== "function") {
62
+ return React.cloneElement(node, undefined, breakTokens(node.props.children));
63
+ }
64
+ return node;
43
65
  }
44
66
 
45
67
  export type SettingsRowLayout = "inline" | "compact" | "stacked";
@@ -123,7 +145,8 @@ export function SettingsRow(props: SettingsRowProps) {
123
145
  id={id}
124
146
  data-layout={layout}
125
147
  className={cn(
126
- "grid min-w-0 items-center gap-x-8 gap-y-3 px-5 py-4 @max-[34rem]/settings-section:px-4",
148
+ "grid min-w-0 items-center gap-x-8 gap-y-3",
149
+ GUTTER,
127
150
  SCROLL_OFFSET,
128
151
  DIVIDED,
129
152
  ROW_COLUMNS[hasValue && layout === "compact" ? "inline" : layout],
@@ -150,7 +173,7 @@ export function SettingsRow(props: SettingsRowProps) {
150
173
  </div>
151
174
  {(hasValue || (control != null && control !== false)) && (
152
175
  <div className={cn("flex min-w-0 items-center gap-2", ROW_CONTROL[hasValue && layout === "compact" ? "inline" : layout])}>
153
- {hasValue && <span data-slot="settings-value" className={cn("min-w-0 text-foreground text-sm [overflow-wrap:anywhere] @min-[34rem]/settings-section:text-right", mono && "font-mono")}>{value}</span>}
176
+ {hasValue && <span data-slot="settings-value" className={cn("min-w-0 text-foreground text-sm [overflow-wrap:anywhere] @min-[34rem]/settings-section:text-right", mono && "font-mono")}>{breakTokens(value)}</span>}
154
177
  {control}
155
178
  </div>
156
179
  )}
@@ -194,7 +217,7 @@ export function SettingsSection({
194
217
  data-tone={tone}
195
218
  className={cn("@container/settings-section min-w-0", SCROLL_OFFSET, danger && "border-[color:var(--surface-danger-border)]")}
196
219
  >
197
- <div className="flex items-start justify-between gap-4 px-5 py-4 @max-[34rem]/settings-section:flex-wrap @max-[34rem]/settings-section:px-4">
220
+ <div className={cn("flex items-start justify-between gap-4 @max-[34rem]/settings-section:flex-wrap", GUTTER)}>
198
221
  <div className="min-w-0">
199
222
  <div className="flex items-center gap-1.5">
200
223
  <CardTitle as="h2" id={titleId} className={cn("leading-6 [overflow-wrap:anywhere]", danger && ERROR_TEXT)}>{title}</CardTitle>
@@ -216,6 +239,40 @@ export function SettingsSection({
216
239
  );
217
240
  }
218
241
 
242
+ const NOTE_TONE = {
243
+ default: "text-foreground",
244
+ muted: "text-muted-foreground",
245
+ warning: "text-[color:var(--surface-warning-text)]",
246
+ danger: ERROR_TEXT,
247
+ } as const;
248
+
249
+ const NOTE_GLYPH: Partial<Record<keyof typeof NOTE_TONE, LucideIcon>> = { warning: TriangleAlert, danger: CircleAlert };
250
+
251
+ export interface SettingsNoteProps {
252
+ /**
253
+ * `default` keeps the body ink, for a figure such as a MetricStrip or an inline form.
254
+ * `muted` is an empty list or a loading line. `warning` (a notice) and `danger` (a refusal)
255
+ * lead with a glyph, so the tone is not carried by colour alone.
256
+ */
257
+ tone?: keyof typeof NOTE_TONE;
258
+ /** `status` announces a change politely; `alert` announces a refusal at once. */
259
+ role?: "status" | "alert";
260
+ children?: React.ReactNode;
261
+ }
262
+
263
+ /**
264
+ * A line or block of a {@link SettingsSection} that is not a row: an empty list, a loading line,
265
+ * a notice, a refusal or a figure. It takes the rows' gutters and divider, so it lines up with
266
+ * the rows and lists around it.
267
+ */
268
+ export function SettingsNote({ tone = "default", role, children }: SettingsNoteProps) {
269
+ const className = cn("min-w-0", GUTTER, DIVIDED, NOTE_TONE[tone]);
270
+ const glyph = NOTE_GLYPH[tone];
271
+ return glyph
272
+ ? <GlyphLine icon={glyph} role={role} className={className}>{children}</GlyphLine>
273
+ : <div role={role} className={cn("text-sm leading-5 [overflow-wrap:anywhere]", className)}>{children}</div>;
274
+ }
275
+
219
276
  /** Track widths for a {@link SettingsList}. Every slot its items use needs a column, except status and menu, which have fallbacks. */
220
277
  export interface SettingsListColumns {
221
278
  /** A logo or avatar column, such as `"36px"`. */
@@ -310,6 +367,8 @@ export interface SettingsListItemProps {
310
367
  meta?: React.ReactNode;
311
368
  /** The item's state as plain text, or a control that sets it (a role select). */
312
369
  status?: React.ReactNode;
370
+ /** The state's tone, drawn as a glyph before it in the tone's ink; the state's text keeps its colour. */
371
+ tone?: StatusTone;
313
372
  /** Shown under the meta in danger text and announced. */
314
373
  error?: React.ReactNode;
315
374
  /** The one verb the item needs at rest. It is drawn as an outline button, so it never reads as the state beside it. */
@@ -359,7 +418,7 @@ const NARROW_STATUS = "@max-[34rem]/settings-section:col-start-[main] @max-[34re
359
418
  * that name. Without a status column the state follows the meta, and without
360
419
  * a menu column the ⋯ menu ends the action cell.
361
420
  */
362
- export function SettingsListItem({ leading, title, meta, status, error, action, menu }: SettingsListItemProps) {
421
+ export function SettingsListItem({ leading, title, meta, status, tone, error, action, menu }: SettingsListItemProps) {
363
422
  const columns = React.useContext(SettingsListContext);
364
423
  const titleId = React.useId();
365
424
  const reasonId = `${titleId}-reason`;
@@ -376,6 +435,8 @@ export function SettingsListItem({ leading, title, meta, status, error, action,
376
435
  {action.label}
377
436
  </Button>
378
437
  );
438
+ const state = tone && status != null ? <><StatusGlyph tone={tone} /><span className="min-w-0">{status}</span></> : status;
439
+ const toned = tone && "flex items-center gap-1.5";
379
440
  const actionCell = (actionButton || (menuNode && !columns.menu)) && (
380
441
  <>{actionButton}{!columns.menu && menuNode}</>
381
442
  );
@@ -392,17 +453,17 @@ export function SettingsListItem({ leading, title, meta, status, error, action,
392
453
  id={titleId}
393
454
  className="font-medium text-sm leading-5 text-foreground [overflow-wrap:anywhere] [&_a]:rounded-sm [&_a]:text-foreground [&_a]:underline-offset-4 [&_a:hover]:underline [&_a:focus-visible]:outline-2 [&_a:focus-visible]:outline-offset-2 [&_a:focus-visible]:outline-[var(--focus-border)]"
394
455
  >
395
- {title}
456
+ {breakTokens(title)}
396
457
  </div>
397
- {meta != null && <div className="text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]">{meta}</div>}
398
- {status != null && !columns.status && <div className="mt-1 text-sm leading-5 text-muted-foreground">{status}</div>}
458
+ {meta != null && <div className="text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]">{breakTokens(meta)}</div>}
459
+ {status != null && !columns.status && <div className={cn("mt-1 text-sm leading-5 text-muted-foreground", toned)}>{state}</div>}
399
460
  {locked && <LockLine id={reasonId} className="mt-1">{action?.disabledReason}</LockLine>}
400
461
  </div>
401
462
  {error != null && (
402
463
  <ErrorLine className="col-start-[main] row-start-2 mt-1 @max-[34rem]/settings-section:col-end-[-1] @max-[34rem]/settings-section:row-start-3">{error}</ErrorLine>
403
464
  )}
404
465
  {status != null && columns.status && (
405
- <div className={cn("col-start-[status] row-start-1 min-w-0 text-sm leading-5 text-muted-foreground", NARROW_STATUS)}>{status}</div>
466
+ <div className={cn("col-start-[status] row-start-1 min-w-0 text-sm leading-5 text-muted-foreground", toned, NARROW_STATUS)}>{state}</div>
406
467
  )}
407
468
  {actionCell && (
408
469
  <div className="col-start-[action] row-start-1 flex min-w-0 items-center justify-end gap-1">{actionCell}</div>
@@ -170,6 +170,13 @@ function ToneGlyph({ tone }: { tone: StatusTone }) {
170
170
  }
171
171
  }
172
172
 
173
+ const GLYPH = "inline-flex [&>svg]:size-3 [&>svg]:shrink-0";
174
+
175
+ /** The tone's glyph in the tone's ink, before a label that keeps its own colour, as `bare` draws it. */
176
+ export function StatusGlyph({ tone }: { tone: StatusTone }) {
177
+ return <span aria-hidden="true" className={cn(GLYPH, TONE_TEXT[tone])}><ToneGlyph tone={tone} /></span>;
178
+ }
179
+
173
180
  const StatusPill = React.forwardRef<HTMLSpanElement, StatusPillProps>(
174
181
  ({ className, tone, children, icon, bare = false, size = "sm", ...props }, ref) => (
175
182
  <span
@@ -186,7 +193,7 @@ const StatusPill = React.forwardRef<HTMLSpanElement, StatusPillProps>(
186
193
  >
187
194
  {/* In `bare` mode the tone rides on the glyph alone, so it is scoped to
188
195
  the wrapper the glyph sits in rather than applied to the whole pill. */}
189
- <span aria-hidden="true" className={cn("inline-flex [&>svg]:size-3 [&>svg]:shrink-0", bare && TONE_TEXT[tone])}>
196
+ <span aria-hidden="true" className={cn(GLYPH, bare && TONE_TEXT[tone])}>
190
197
  {icon ?? <ToneGlyph tone={tone} />}
191
198
  </span>
192
199
  {children}
@@ -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
+ }
@@ -28,7 +28,7 @@
28
28
  .tangle-reading-header { grid-column: 1 / -1; min-width: 0; }
29
29
  /* PageShell measure="settings". The frame is the query container, so the layout follows the content area's width, not the viewport's.
30
30
  Below 60rem the navigation is a sticky tab strip whose track is the divider; from 60rem it is a sticky 13rem column beside a 48rem article.
31
- Without a nav the page is the 48rem column alone. */
31
+ Without a nav the page is the 48rem column alone. A host whose page scrolls under its own sticky header sets --tangle-settings-sticky-top. */
32
32
  .tangle-settings-frame { container: tangle-settings / inline-size; width: 100%; min-width: 0; }
33
33
  .tangle-reading-shell[data-measure="settings"] { --tangle-settings-gutter: 1.5rem; grid-template-columns: minmax(0,1fr); gap: 0; max-width: calc(13rem + 2.5rem + 48rem + 4rem); padding: 1.5rem var(--tangle-settings-gutter) 3rem; }
34
34
  /* Without a nav the shell is the column and its gutters, so the centred shell centres the column and its header. */
@@ -37,7 +37,7 @@
37
37
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-header > * { margin-bottom: 0; }
38
38
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-outline { display: none; }
39
39
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-article { max-width: 48rem; display: flex; flex-direction: column; gap: 1.5rem; }
40
- .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav { position: sticky; top: 0; z-index: 3; display: flex; align-items: flex-end; gap: 0.25rem; height: var(--shell-header-height); max-height: none; margin: 0 calc(-1 * var(--tangle-settings-gutter)) 1.25rem; padding: 0 var(--tangle-settings-gutter); overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: none; background: hsl(var(--background)); box-shadow: inset 0 -1px 0 hsl(var(--border)); }
40
+ .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav { position: sticky; top: var(--tangle-settings-sticky-top, 0px); z-index: 3; display: flex; align-items: flex-end; gap: 0.25rem; height: var(--shell-header-height); max-height: none; margin: 0 calc(-1 * var(--tangle-settings-gutter)) 1.25rem; padding: 0 var(--tangle-settings-gutter); overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: none; background: hsl(var(--background)); box-shadow: inset 0 -1px 0 hsl(var(--border)); }
41
41
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav::-webkit-scrollbar { display: none; }
42
42
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav > :is(ul, ol) { display: flex; flex: none; align-items: flex-end; gap: 0.25rem; margin: 0; padding: 0; list-style: none; }
43
43
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a { flex: none; display: flex; align-items: center; gap: 0.5rem; min-height: var(--control-height-lg, 2.75rem); padding: 0 0.75rem; color: hsl(var(--muted-foreground)); font-size: var(--font-size-label, 0.875rem); font-weight: 500; line-height: 1.25rem; white-space: nowrap; text-decoration: none; transition: color var(--motion-control), background-color var(--motion-control), box-shadow var(--motion-control); }
@@ -55,7 +55,7 @@
55
55
  .tangle-reading-shell[data-measure="settings"] { --tangle-settings-gutter: 2rem; grid-template-columns: minmax(0,48rem); column-gap: 2.5rem; align-items: start; padding: 2rem; }
56
56
  .tangle-reading-shell[data-measure="settings"]:has(> .tangle-reading-nav) { grid-template-columns: 13rem minmax(0,48rem); }
57
57
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-header { margin-bottom: 1.75rem; }
58
- .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav { top: 2rem; flex-direction: column; align-items: stretch; gap: 2px; height: auto; margin: 0; padding: 0; overflow: visible; background: none; box-shadow: none; }
58
+ .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav { top: calc(var(--tangle-settings-sticky-top, 0px) + 2rem); flex-direction: column; align-items: stretch; gap: 2px; height: auto; margin: 0; padding: 0; overflow: visible; background: none; box-shadow: none; }
59
59
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav > :is(ul, ol) { flex-direction: column; align-items: stretch; gap: 2px; }
60
60
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a { min-height: var(--control-height, 2.25rem); gap: 0.625rem; padding: 0 0.625rem; border-radius: var(--radius-md); }
61
61
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a > svg { display: block; flex: none; width: 1rem; height: 1rem; }