@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.
- package/CHANGELOG.md +28 -0
- package/dist/chat/agent-timeline.js +4 -3
- package/dist/chat/speaker.d.ts +3 -3
- package/dist/chat/speaker.js +12 -18
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/markdown/code-block.js +1 -1
- package/dist/presentation.d.ts +12 -0
- package/dist/presentation.js +85 -1
- package/dist/primitives/metric-strip.d.ts +2 -2
- package/dist/primitives/page-shell.d.ts +6 -3
- package/dist/primitives/page-shell.js +3 -60
- package/dist/primitives/progress.d.ts +8 -0
- package/dist/primitives/progress.js +8 -2
- package/dist/primitives/settings.d.ts +28 -1
- package/dist/primitives/settings.js +78 -29
- package/dist/primitives/status-pill.js +11 -2
- package/dist/primitives.d.ts +3 -3
- package/dist/primitives.js +3 -3
- package/dist/run/expanded-tool-detail.js +12 -4
- package/dist/run/run-group.js +21 -9
- package/dist/run/run-phase-list.js +5 -8
- package/dist/run/status-meta.d.ts +25 -0
- package/dist/run/status-meta.js +105 -0
- package/dist/run.d.ts +2 -1
- package/dist/run.js +2 -1
- package/package.json +2 -2
- package/src/chat/agent-timeline.test.tsx +9 -0
- package/src/chat/agent-timeline.tsx +4 -3
- package/src/chat/speaker.test.tsx +3 -1
- package/src/chat/speaker.tsx +13 -14
- package/src/markdown/code-block.tsx +2 -1
- package/src/markdown/markdown.stories.tsx +11 -0
- package/src/presentation.ts +68 -0
- package/src/primitives/index.ts +2 -1
- package/src/primitives/metric-strip.tsx +2 -2
- package/src/primitives/page-shell.tsx +9 -58
- package/src/primitives/progress.stories.tsx +10 -0
- package/src/primitives/progress.tsx +13 -2
- package/src/primitives/settings.stories.tsx +94 -3
- package/src/primitives/settings.tsx +80 -19
- package/src/primitives/status-pill.tsx +8 -1
- package/src/run/expanded-tool-detail.tsx +4 -10
- package/src/run/index.ts +8 -0
- package/src/run/run-group.tsx +8 -16
- package/src/run/run-phase-list.stories.tsx +19 -0
- package/src/run/run-phase-list.test.tsx +9 -1
- package/src/run/run-phase-list.tsx +3 -3
- package/src/run/status-meta.tsx +92 -0
- 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
|
-
|
|
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
|
|
30
|
-
<
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
|
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">{
|
|
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)}>{
|
|
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(
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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";
|
package/src/run/run-group.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
488
|
-
<
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
<
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
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; }
|