@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/CHANGELOG.md +12 -0
- package/dist/chat/message-author.d.ts +4 -3
- package/dist/chat/message-author.js +5 -3
- package/dist/chat/speaker.d.ts +57 -0
- package/dist/chat/speaker.js +84 -0
- package/dist/chat.d.ts +2 -1
- package/dist/chat.js +2 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- 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/index.ts +9 -0
- package/src/chat/message-author.tsx +5 -3
- package/src/chat/speaker.stories.tsx +95 -0
- package/src/chat/speaker.test.tsx +81 -0
- package/src/chat/speaker.tsx +110 -0
- 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/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.
|
|
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.
|
|
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
|
|
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
|
-
<
|
|
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
|
+
}
|