@tangle-network/ui 11.46.0 → 11.48.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.
@@ -154,9 +154,11 @@ const TONE_STYLES: Record<AgentTimelineTone, { card: string; text: string; suppo
154
154
  * most, a tool sequence — and prose beside it — sits tightest, cards and
155
155
  * consecutive paragraphs fall between.
156
156
  */
157
- type StepKind = "user" | "prose" | "tool" | "card";
157
+ export type AgentTimelineStepKind = "user" | "prose" | "tool" | "card";
158
+ type StepKind = AgentTimelineStepKind;
158
159
 
159
- function stepKind(item: AgentTimelineItem): StepKind {
160
+ /** What a row is, for the gap above the next one. */
161
+ export function agentTimelineStepKind(item: AgentTimelineItem): AgentTimelineStepKind {
160
162
  if (item.kind === "message") return item.role === "user" ? "user" : "prose";
161
163
  if (item.kind === "status" || item.kind === "artifact") return "card";
162
164
  return "tool";
@@ -282,6 +284,87 @@ function ArtifactCard({ item }: { item: AgentTimelineArtifactItem }) {
282
284
  );
283
285
  }
284
286
 
287
+ /** The transcript column every timeline sits in: centred, at the reading width. */
288
+ export function AgentTimelineColumn({ className, children }: { className?: string; children?: ReactNode }) {
289
+ return <div className={cn("mx-auto flex w-full max-w-5xl flex-col px-4 py-4", className)}>{children}</div>;
290
+ }
291
+
292
+ export interface AgentTimelineStepProps {
293
+ item: AgentTimelineItem;
294
+ /** The kind of the row above, which sets the gap; omit for the first row. */
295
+ previous?: AgentTimelineStepKind;
296
+ /** Draw the message's author line (the first prose of a turn). */
297
+ headed?: boolean;
298
+ renderToolActions?: (part: ToolPart) => ReactNode;
299
+ viewerId?: string;
300
+ }
301
+
302
+ /**
303
+ * One row of the timeline with the gap its neighbour pair calls for. A
304
+ * consumer that windows a long transcript renders these inside one
305
+ * `AgentTimelineColumn`, so the rows keep the column's rhythm and edge.
306
+ */
307
+ export function AgentTimelineStep({ item, previous, headed, renderToolActions, viewerId }: AgentTimelineStepProps) {
308
+ const kind = agentTimelineStepKind(item);
309
+ return (
310
+ <div data-timeline-step={kind} className={cn(spacingBefore(previous, kind), kind === "tool" && ROW_BLEED)}>
311
+ <TimelineItem item={item} headed={headed} renderToolActions={renderToolActions} viewerId={viewerId} />
312
+ </div>
313
+ );
314
+ }
315
+
316
+ function TimelineItem({ item, headed, renderToolActions, viewerId }: Omit<AgentTimelineStepProps, "previous">): ReactNode {
317
+ if (item.kind === "message") {
318
+ return item.role === "user" ? (
319
+ <UserMessage content={item.content} timestamp={item.timestamp} author={item.author} viewerId={viewerId} />
320
+ ) : (
321
+ <AssistantMessage item={item} author={headed ? item.author : undefined} />
322
+ );
323
+ }
324
+
325
+ if (item.kind === "tool") {
326
+ return (
327
+ <ToolCallStep
328
+ type={item.call.type}
329
+ label={item.call.label}
330
+ status={item.call.status}
331
+ detail={item.call.detail}
332
+ output={item.call.output}
333
+ duration={item.call.duration}
334
+ part={item.part}
335
+ actions={item.part ? renderToolActions?.(item.part) : undefined}
336
+ />
337
+ );
338
+ }
339
+
340
+ if (item.kind === "tool_group") {
341
+ return (
342
+ <ToolCallGroup title={item.title}>
343
+ {item.calls.map((call, callIndex) => {
344
+ const part = item.parts?.[callIndex];
345
+ return (
346
+ <ToolCallStep
347
+ key={call.id}
348
+ type={call.type}
349
+ label={call.label}
350
+ status={call.status}
351
+ detail={call.detail}
352
+ output={call.output}
353
+ duration={call.duration}
354
+ part={part}
355
+ actions={part ? renderToolActions?.(part) : undefined}
356
+ />
357
+ );
358
+ })}
359
+ </ToolCallGroup>
360
+ );
361
+ }
362
+
363
+ if (item.kind === "status") return <StatusCard item={item} />;
364
+ if (item.kind === "artifact") return <ArtifactCard item={item} />;
365
+ return item.content;
366
+ }
367
+
285
368
  /**
286
369
  * AgentTimeline — unified mixed-content transcript for agent-backed sandbox
287
370
  * sessions. Renders messages, tool rows, status cards, and artifact handoffs
@@ -352,84 +435,29 @@ export function AgentTimeline({
352
435
  speakingAgent = item.author?.id;
353
436
  }
354
437
 
355
- const renderItem = (item: AgentTimelineItem): ReactNode => {
356
- if (item.kind === "message") {
357
- return item.role === "user" ? (
358
- <UserMessage content={item.content} timestamp={item.timestamp} author={item.author} viewerId={viewerId} />
359
- ) : (
360
- <AssistantMessage item={item} author={headedAgentMessages.has(item.id) ? item.author : undefined} />
361
- );
362
- }
363
-
364
- if (item.kind === "tool") {
365
- return (
366
- <ToolCallStep
367
- type={item.call.type}
368
- label={item.call.label}
369
- status={item.call.status}
370
- detail={item.call.detail}
371
- output={item.call.output}
372
- duration={item.call.duration}
373
- part={item.part}
374
- actions={item.part ? renderToolActions?.(item.part) : undefined}
375
- />
376
- );
377
- }
378
-
379
- if (item.kind === "tool_group") {
380
- return (
381
- <ToolCallGroup title={item.title}>
382
- {item.calls.map((call, callIndex) => {
383
- const part = item.parts?.[callIndex];
384
- return (
385
- <ToolCallStep
386
- key={call.id}
387
- type={call.type}
388
- label={call.label}
389
- status={call.status}
390
- detail={call.detail}
391
- output={call.output}
392
- duration={call.duration}
393
- part={part}
394
- actions={part ? renderToolActions?.(part) : undefined}
395
- />
396
- );
397
- })}
398
- </ToolCallGroup>
399
- );
400
- }
401
-
402
- if (item.kind === "status") return <StatusCard item={item} />;
403
- if (item.kind === "artifact") return <ArtifactCard item={item} />;
404
- return item.content;
405
- };
406
-
407
438
  return (
408
- <div className={cn("mx-auto flex w-full max-w-5xl flex-col px-4 py-4", className)}>
409
- {renderList.map((item, index) => {
410
- const kind = stepKind(item);
411
- const prev = index > 0 ? stepKind(renderList[index - 1]) : undefined;
412
- return (
413
- <div
414
- key={item.id}
415
- data-timeline-step={kind}
416
- className={cn(spacingBefore(prev, kind), kind === "tool" && ROW_BLEED)}
417
- >
418
- {renderItem(item)}
419
- </div>
420
- );
421
- })}
439
+ <AgentTimelineColumn className={className}>
440
+ {renderList.map((item, index) => (
441
+ <AgentTimelineStep
442
+ key={item.id}
443
+ item={item}
444
+ previous={index > 0 ? agentTimelineStepKind(renderList[index - 1]) : undefined}
445
+ headed={headedAgentMessages.has(item.id)}
446
+ renderToolActions={renderToolActions}
447
+ viewerId={viewerId}
448
+ />
449
+ ))}
422
450
  {collapsible ? (
423
451
  <button
424
452
  type="button"
425
453
  onClick={() => setExpanded((value) => !value)}
426
- className="mt-2 self-start text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
454
+ className="mt-2 self-start text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
427
455
  >
428
456
  {collapsed
429
457
  ? `Show ${hiddenCount} more step${hiddenCount === 1 ? "" : "s"}`
430
458
  : "Show less"}
431
459
  </button>
432
460
  ) : null}
433
- </div>
461
+ </AgentTimelineColumn>
434
462
  );
435
463
  }
package/src/chat/index.ts CHANGED
@@ -3,11 +3,25 @@ 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 {
9
18
  AgentTimeline,
19
+ AgentTimelineColumn,
20
+ AgentTimelineStep,
21
+ agentTimelineStepKind,
10
22
  type AgentTimelineProps,
23
+ type AgentTimelineStepProps,
24
+ type AgentTimelineStepKind,
11
25
  type AgentTimelineItem,
12
26
  type AgentTimelineMessageItem,
13
27
  type AgentTimelineToolItem,
@@ -28,8 +28,8 @@ describe("a transcript without authors", () => {
28
28
  })
29
29
 
30
30
  it("renders UserMessage actions and application notes as before", () => {
31
- expect(html(<UserMessage content="Can I break my lease early?" actions={<button type="button">Copy</button>} />)).toMatchInlineSnapshot(`"<div class="flex justify-end"><div class="group flex min-w-0 max-w-[78%] flex-col items-end gap-2"><div class="relative w-full min-w-0 rounded-2xl bg-foreground px-4 py-3 text-background"><div class="break-words whitespace-pre-wrap tangle-transcript-text text-[length:var(--transcript-font-size)] leading-[var(--transcript-line-height)]">Can I break my lease early?</div></div><div class="flex flex-wrap items-center justify-end gap-1.5 text-xs text-muted-foreground"><button type="button">Copy</button></div></div></div>"`)
32
- expect(html(<UserMessage parts={segmentedParts} actions={<button type="button">Copy</button>} />)).toMatchInlineSnapshot(`"<div class="space-y-2"><div class="flex justify-end"><div class="group flex min-w-0 max-w-[78%] flex-col items-end gap-2"><div class="relative w-full min-w-0 rounded-2xl bg-foreground px-4 py-3 text-background"><div class="break-words whitespace-pre-wrap tangle-transcript-text text-[length:var(--transcript-font-size)] leading-[var(--transcript-line-height)]">I signed the lease in March.</div></div></div></div><div role="note" aria-label="Application note" data-synthetic-text="" class="min-w-0 rounded-md border border-dashed border-border bg-muted/30 px-3 py-2 text-sm text-muted-foreground"><div class="mb-1 font-medium">Application note</div><div class="break-words whitespace-pre-wrap">Lease uploaded: lease.pdf</div></div><div class="flex justify-end"><div class="group flex min-w-0 max-w-[78%] flex-col items-end gap-2"><div class="relative w-full min-w-0 rounded-2xl bg-foreground px-4 py-3 text-background"><div class="break-words whitespace-pre-wrap tangle-transcript-text text-[length:var(--transcript-font-size)] leading-[var(--transcript-line-height)]">Here it is.</div></div></div></div><div class="flex flex-wrap items-center justify-end gap-1.5 text-xs text-muted-foreground"><button type="button">Copy</button></div></div>"`)
31
+ expect(html(<UserMessage content="Can I break my lease early?" actions={<button type="button">Copy</button>} />)).toMatchInlineSnapshot(`"<div class="flex justify-end"><div class="group flex min-w-0 max-w-[78%] flex-col items-end gap-2"><div class="relative w-full min-w-0 rounded-2xl bg-foreground px-4 py-3 text-background"><div class="break-words whitespace-pre-wrap tangle-transcript-text text-[length:var(--transcript-font-size)] leading-[var(--transcript-line-height)]">Can I break my lease early?</div></div><div class="flex flex-wrap items-center justify-end gap-1.5 text-sm text-muted-foreground"><button type="button">Copy</button></div></div></div>"`)
32
+ expect(html(<UserMessage parts={segmentedParts} actions={<button type="button">Copy</button>} />)).toMatchInlineSnapshot(`"<div class="space-y-2"><div class="flex justify-end"><div class="group flex min-w-0 max-w-[78%] flex-col items-end gap-2"><div class="relative w-full min-w-0 rounded-2xl bg-foreground px-4 py-3 text-background"><div class="break-words whitespace-pre-wrap tangle-transcript-text text-[length:var(--transcript-font-size)] leading-[var(--transcript-line-height)]">I signed the lease in March.</div></div></div></div><div role="note" aria-label="Application note" data-synthetic-text="" class="min-w-0 rounded-md border border-dashed border-border bg-muted/30 px-3 py-2 text-sm text-muted-foreground"><div class="mb-1 font-medium">Application note</div><div class="break-words whitespace-pre-wrap">Lease uploaded: lease.pdf</div></div><div class="flex justify-end"><div class="group flex min-w-0 max-w-[78%] flex-col items-end gap-2"><div class="relative w-full min-w-0 rounded-2xl bg-foreground px-4 py-3 text-background"><div class="break-words whitespace-pre-wrap tangle-transcript-text text-[length:var(--transcript-font-size)] leading-[var(--transcript-line-height)]">Here it is.</div></div></div></div><div class="flex flex-wrap items-center justify-end gap-1.5 text-sm text-muted-foreground"><button type="button">Copy</button></div></div>"`)
33
33
  })
34
34
 
35
35
  it("renders UserMessage as before", () => {
@@ -15,20 +15,21 @@ export function isViewerMessage(author: ChatAuthor | undefined, viewerId: string
15
15
  return author === undefined || author.id === viewerId;
16
16
  }
17
17
 
18
- export interface MessageAuthorProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
18
+ export interface MessageAuthorProps extends HTMLAttributes<HTMLDivElement> {
19
19
  author: ChatAuthor;
20
20
  }
21
21
 
22
22
  /**
23
23
  * The line above a message in a transcript with several participants: the
24
- * author's avatar, name and, when given, their role as a tag.
24
+ * author's avatar, name and, when given, their role as a tag. Children follow
25
+ * on the same line, such as a responder's handle and state.
25
26
  *
26
27
  * The avatar's tone comes from the author's id, so a participant keeps one
27
28
  * colour for the whole thread and in every product. Tones can repeat between
28
29
  * participants, so the name is always shown beside it. The avatar repeats the
29
30
  * visible name and is hidden from assistive technology.
30
31
  */
31
- export function MessageAuthor({ author, className, ...props }: MessageAuthorProps) {
32
+ export function MessageAuthor({ author, className, children, ...props }: MessageAuthorProps) {
32
33
  return (
33
34
  <div data-message-author="" className={cn("flex min-w-0 max-w-full items-center gap-2", className)} {...props}>
34
35
  <IconTile
@@ -47,6 +48,7 @@ export function MessageAuthor({ author, className, ...props }: MessageAuthorProp
47
48
  {author.role}
48
49
  </Tag>
49
50
  ) : null}
51
+ {children}
50
52
  </div>
51
53
  );
52
54
  }
@@ -0,0 +1,95 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { partitionBySpeaker, SpeakerHeader } from './speaker'
3
+ import { TRANSCRIPT_TEXT } from './transcript-text'
4
+
5
+ type Part =
6
+ | { type: 'speaker'; id: string; name: string; handle?: string; state?: 'answering' | 'done' | 'failed' }
7
+ | { type: 'text'; text: string; speaker?: string }
8
+ | { type: 'tool'; tool: string; status: string; speaker?: string }
9
+ | { type: 'notice'; text: string; speaker?: string }
10
+
11
+ /** One turn's parts drawn the way a product draws them: a header per responder, its parts under it. */
12
+ function Turn({ parts, live = false }: { parts: Part[]; live?: boolean }) {
13
+ return (
14
+ <div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-4 py-6">
15
+ <p className={`ml-auto w-fit max-w-[78%] rounded-2xl bg-foreground px-4 py-3 text-background ${TRANSCRIPT_TEXT}`}>
16
+ @writer @research-lead plan the launch announcement for the roster feature.
17
+ </p>
18
+ {partitionBySpeaker(parts).map(({ speaker, parts: own }) => (
19
+ <section key={speaker?.id ?? 'turn'} className="flex flex-col gap-2">
20
+ {speaker ? <SpeakerHeader speaker={speaker} live={live} /> : null}
21
+ {own.map((part, index) =>
22
+ part.type === 'text' ? (
23
+ <p key={index} className={TRANSCRIPT_TEXT}>{part.text}</p>
24
+ ) : part.type === 'tool' ? (
25
+ <p key={index} className="font-mono text-muted-foreground text-sm">{part.tool} · {part.status}</p>
26
+ ) : part.type === 'notice' ? (
27
+ <p key={index} className="text-[var(--surface-danger-text)] text-sm">{part.text}</p>
28
+ ) : null,
29
+ )}
30
+ </section>
31
+ ))}
32
+ </div>
33
+ )
34
+ }
35
+
36
+ const meta: Meta<typeof Turn> = {
37
+ title: 'Chat/Speakers',
38
+ component: Turn,
39
+ parameters: { layout: 'fullscreen' },
40
+ decorators: [
41
+ (Story) => (
42
+ <div className="min-h-screen bg-[var(--bg-root)]">
43
+ <Story />
44
+ </div>
45
+ ),
46
+ ],
47
+ }
48
+
49
+ export default meta
50
+ type Story = StoryObj<typeof Turn>
51
+
52
+ /**
53
+ * A live turn as the stream delivers it: both responders announced first, then
54
+ * their output interleaved, each part tagged with its responder. The Writer has
55
+ * reported its end; the Research Lead is still answering.
56
+ */
57
+ export const AnsweringAtOnce: Story = {
58
+ args: {
59
+ live: true,
60
+ parts: [
61
+ { type: 'speaker', id: 'ap_writer', name: 'Writer', handle: 'writer' },
62
+ { type: 'speaker', id: 'ap_research', name: 'Research Lead', handle: 'research-lead' },
63
+ { type: 'text', speaker: 'ap_writer', text: 'Draft headline: "Every agent in your workspace, one @ away."' },
64
+ { type: 'tool', speaker: 'ap_research', tool: 'search_repo', status: 'running' },
65
+ { type: 'text', speaker: 'ap_writer', text: 'Subhead: address any agent from any thread, and let it run on a schedule while you sleep.' },
66
+ { type: 'speaker', id: 'ap_writer', name: 'Writer', handle: 'writer', state: 'done' },
67
+ { type: 'text', speaker: 'ap_research', text: 'Checking the roster and the mention routes before naming risks.' },
68
+ ],
69
+ },
70
+ }
71
+
72
+ /** A stored row: a speaker part before each answer. The Research Lead failed and keeps its header and its notice. */
73
+ export const StoredWithFailure: Story = {
74
+ args: {
75
+ parts: [
76
+ { type: 'speaker', id: 'ap_writer', name: 'Writer', handle: 'writer', state: 'done' },
77
+ { type: 'text', text: 'Draft headline: "Every agent in your workspace, one @ away."' },
78
+ { type: 'speaker', id: 'ap_research', name: 'Research Lead', handle: 'research-lead', state: 'failed' },
79
+ { type: 'tool', tool: 'search_repo', status: 'error' },
80
+ { type: 'notice', text: 'Research Lead could not answer: the model provider refused the request (429).' },
81
+ ],
82
+ },
83
+ }
84
+
85
+ /** Worst case for a phone: a long name and handle while answering. The name and handle truncate; the state stays whole. */
86
+ export const LongNames: Story = {
87
+ args: {
88
+ live: true,
89
+ parts: [
90
+ { type: 'speaker', id: 'ap_compliance', name: 'Regional Compliance and Disclosure Reviewer', handle: 'regional-compliance-disclosure-reviewer' },
91
+ { type: 'text', text: 'Reading the disclosure rules for each launch region.' },
92
+ { type: 'speaker', id: 'ap_q', name: 'Q', state: 'failed' },
93
+ ],
94
+ },
95
+ }
@@ -0,0 +1,81 @@
1
+ import { render, screen } from "@testing-library/react"
2
+ import { describe, expect, it } from "vitest"
3
+ import { MessageAuthor } from "./message-author"
4
+ import { partitionBySpeaker, SpeakerHeader, type Speaker } from "./speaker"
5
+
6
+ type Part = { type: string; id?: string; name?: string; handle?: string; state?: Speaker["state"]; text?: string; speaker?: string }
7
+
8
+ const writer = { type: "speaker", id: "ap_writer", name: "Writer", handle: "writer" }
9
+ const research = { type: "speaker", id: "ap_research", name: "Research Lead", handle: "research-lead" }
10
+ const text = (value: string, speaker?: string): Part => ({ type: "text", text: value, ...(speaker ? { speaker } : {}) })
11
+
12
+ /** Each section as `[speaker id or null, its texts]`. */
13
+ const shape = (parts: Part[]) =>
14
+ partitionBySpeaker(parts).map((section) => [section.speaker?.id ?? null, section.parts.map((part) => part.text)])
15
+
16
+ describe("partitionBySpeaker", () => {
17
+ it("splits a stored row at its speaker parts, in stored order", () => {
18
+ expect(shape([writer, text("headline"), text("subhead"), research, text("risks")])).toEqual([
19
+ ["ap_writer", ["headline", "subhead"]],
20
+ ["ap_research", ["risks"]],
21
+ ])
22
+ })
23
+
24
+ it("groups a live stream by each part's speaker tag, not by where it arrived", () => {
25
+ const live = [writer, research, text("headline", "ap_writer"), text("searching", "ap_research"), text("subhead", "ap_writer"), text("risks", "ap_research")]
26
+ expect(shape(live)).toEqual([
27
+ ["ap_writer", ["headline", "subhead"]],
28
+ ["ap_research", ["searching", "risks"]],
29
+ ])
30
+ })
31
+
32
+ it("takes a responder's end state from a later speaker part, without a second section", () => {
33
+ const sections = partitionBySpeaker<Part>([writer, research, text("headline", "ap_writer"), { ...writer, state: "done" }, text("risks", "ap_research")])
34
+ expect(sections.map((section) => [section.speaker?.name, section.speaker?.state])).toEqual([["Writer", "done"], ["Research Lead", undefined]])
35
+ expect(sections[1]?.parts.map((part) => part.text)).toEqual(["risks"])
36
+ })
37
+
38
+ it("keeps parts no speaker owns, first, and drops none", () => {
39
+ expect(shape([text("preamble"), writer, text("headline"), text("stray", "ap_nobody")])).toEqual([
40
+ [null, ["preamble", "stray"]],
41
+ ["ap_writer", ["headline"]],
42
+ ])
43
+ expect(shape([text("one agent"), text("one answer")])).toEqual([[null, ["one agent", "one answer"]]])
44
+ expect(shape([])).toEqual([[null, []]])
45
+ })
46
+ })
47
+
48
+ describe("SpeakerHeader", () => {
49
+ it("names the responder in its own element, with its handle, answering while the turn runs", () => {
50
+ const { container } = render(<SpeakerHeader speaker={{ id: "ap_writer", name: "Writer", handle: "writer" }} live />)
51
+ expect(screen.getByText(/^Writer$/)).toBeInTheDocument()
52
+ expect(screen.getByText("@writer")).toBeInTheDocument()
53
+ expect(screen.getByText("Answering")).toBeInTheDocument()
54
+ expect(container.querySelector("[data-speaker-state]")?.getAttribute("data-speaker-state")).toBe("answering")
55
+ })
56
+
57
+ it("says nothing more once the responder has ended, even while others answer", () => {
58
+ const { container } = render(<SpeakerHeader speaker={{ id: "ap_writer", name: "Writer", state: "done" }} live />)
59
+ expect(screen.queryByText("Answering")).not.toBeInTheDocument()
60
+ expect(screen.queryByText("Failed")).not.toBeInTheDocument()
61
+ expect(container.querySelector("[data-speaker-state]")?.getAttribute("data-speaker-state")).toBe("done")
62
+ })
63
+
64
+ it("shows a failed responder as failed while the turn still runs", () => {
65
+ render(<SpeakerHeader speaker={{ id: "ap_research", name: "Research Lead", state: "failed" }} live />)
66
+ expect(screen.getByText("Failed")).toBeInTheDocument()
67
+ expect(screen.queryByText("Answering")).not.toBeInTheDocument()
68
+ })
69
+
70
+ it("treats a responder without an end state on a finished turn as done", () => {
71
+ const { container } = render(<SpeakerHeader speaker={{ id: "ap_writer", name: "Writer" }} />)
72
+ expect(container.querySelector("[data-speaker-state]")?.getAttribute("data-speaker-state")).toBe("done")
73
+ })
74
+
75
+ it("draws the author line every participant gets, so an agent keeps its tone", () => {
76
+ const avatar = (node: HTMLElement) => node.querySelector("[aria-hidden]")!.className
77
+ const header = render(<SpeakerHeader speaker={{ id: "ap_writer", name: "Writer" }} />).container
78
+ const author = render(<MessageAuthor author={{ id: "ap_writer", name: "Writer" }} />).container
79
+ expect(avatar(header)).toBe(avatar(author))
80
+ })
81
+ })
@@ -0,0 +1,110 @@
1
+ import type { HTMLAttributes } from "react";
2
+ import { StatusPill } from "../primitives/status-pill";
3
+ import { MessageAuthor } from "./message-author";
4
+
5
+ /** Where one responder is in its answer. */
6
+ export type SpeakerState = "answering" | "done" | "failed";
7
+
8
+ /**
9
+ * A responder of a turn addressed to several agents. In the turn's parts it is
10
+ * a `{ type: "speaker", id, name, handle?, state? }` part written before the
11
+ * responder's answer.
12
+ */
13
+ export interface Speaker {
14
+ /** The agent's stable id. It keys the avatar's tone and a live part's `speaker` tag. */
15
+ id: string;
16
+ name: string;
17
+ /** What a person types after `@` to address the agent. */
18
+ handle?: string;
19
+ /** Set once the responder ended; absent while it answers and on rows written before responders reported an end. */
20
+ state?: SpeakerState;
21
+ }
22
+
23
+ export type SpeakerPart = Speaker & { type: "speaker" };
24
+
25
+ /** One responder's share of a turn: its speaker part and the parts it wrote. */
26
+ export interface SpeakerSection<T> {
27
+ /** The latest speaker part for this responder; null for the parts no speaker owns. */
28
+ speaker: (T & SpeakerPart) | null;
29
+ parts: T[];
30
+ }
31
+
32
+ function isSpeakerPart<T>(part: T): part is T & SpeakerPart {
33
+ const record = part as { type?: unknown; id?: unknown; name?: unknown } | null;
34
+ return !!record && record.type === "speaker" && typeof record.id === "string" && typeof record.name === "string";
35
+ }
36
+
37
+ /**
38
+ * Splits one assistant turn's parts by the agent that wrote them.
39
+ *
40
+ * A stored row holds a speaker part before each responder's answer, so a part
41
+ * belongs to the speaker part before it. A live stream announces every
42
+ * responder first and then interleaves their output, so a part names its owner
43
+ * in `speaker` (the responder's id), which wins over position. A later speaker
44
+ * part with an id already seen replaces that responder's speaker part, which is
45
+ * how its end state arrives, and never opens a second section.
46
+ *
47
+ * Sections keep the order speakers were first announced. Parts no speaker owns
48
+ * come first, in a section whose `speaker` is null; a turn without speaker
49
+ * parts is that one section. No part is dropped, copied or reordered within its
50
+ * section.
51
+ */
52
+ export function partitionBySpeaker<T extends object>(parts: readonly T[]): SpeakerSection<T>[] {
53
+ const sections = new Map<string, SpeakerSection<T>>();
54
+ const unowned: T[] = [];
55
+ let current: SpeakerSection<T> | undefined;
56
+ for (const part of parts) {
57
+ if (isSpeakerPart(part)) {
58
+ current = sections.get(part.id);
59
+ if (current) current.speaker = part;
60
+ else sections.set(part.id, (current = { speaker: part, parts: [] }));
61
+ continue;
62
+ }
63
+ const tag = (part as { speaker?: unknown }).speaker;
64
+ const owner = typeof tag === "string" ? sections.get(tag) : current;
65
+ (owner?.parts ?? unowned).push(part);
66
+ }
67
+ const owned = [...sections.values()];
68
+ return unowned.length > 0 || owned.length === 0 ? [{ speaker: null, parts: unowned }, ...owned] : owned;
69
+ }
70
+
71
+ export interface SpeakerHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
72
+ speaker: Speaker;
73
+ /** The turn is still running, so a responder that has not reported an end is answering. */
74
+ live?: boolean;
75
+ }
76
+
77
+ /**
78
+ * The line above one responder's answer: the author line every participant
79
+ * gets, then the handle a person types to address the agent, then its state.
80
+ *
81
+ * The state comes from the speaker's own end state, else from `live`. An
82
+ * answering responder shows the status grammar's open ring with the word, a
83
+ * failed one its cross with the word, and a finished one nothing, as a settled
84
+ * run row does. `data-speaker-state` carries all three.
85
+ */
86
+ export function SpeakerHeader({ speaker, live = false, ...props }: SpeakerHeaderProps) {
87
+ const state: SpeakerState = speaker.state ?? (live ? "answering" : "done");
88
+ return (
89
+ <MessageAuthor
90
+ author={{ id: speaker.id, name: speaker.name }}
91
+ data-speaker-state={state}
92
+ {...props}
93
+ >
94
+ {speaker.handle ? (
95
+ <span className="min-w-0 truncate text-muted-foreground text-sm" title={`@${speaker.handle}`}>
96
+ @{speaker.handle}
97
+ </span>
98
+ ) : null}
99
+ {state === "answering" ? (
100
+ <StatusPill tone="running" bare size="md" className="shrink-0 py-0">
101
+ Answering
102
+ </StatusPill>
103
+ ) : state === "failed" ? (
104
+ <StatusPill tone="danger" bare size="md" className="shrink-0 py-0">
105
+ Failed
106
+ </StatusPill>
107
+ ) : null}
108
+ </MessageAuthor>
109
+ );
110
+ }
@@ -31,7 +31,7 @@ function formatTime(date: Date): string {
31
31
 
32
32
  function MessageActions({ children, align = "end" }: { children: ReactNode; align?: "start" | "end" }) {
33
33
  return (
34
- <div className={`flex flex-wrap items-center ${align === "end" ? "justify-end" : "justify-start"} gap-1.5 text-xs text-muted-foreground`}>
34
+ <div className={`flex flex-wrap items-center ${align === "end" ? "justify-end" : "justify-start"} gap-1.5 text-sm text-muted-foreground`}>
35
35
  {children}
36
36
  </div>
37
37
  );
@@ -80,7 +80,7 @@ export const InlineThinkingItem = memo(
80
80
  </div>
81
81
  </ExpandableContent>
82
82
  ) : (
83
- <div className="px-3 py-2.5 text-xs text-muted-foreground">
83
+ <div className="px-3 py-2.5 text-sm text-muted-foreground">
84
84
  No reasoning text provided.
85
85
  </div>
86
86
  )}
@@ -57,7 +57,7 @@ describe("InlineToolItem", () => {
57
57
  expect(container.querySelector(".tangle-text-shimmer")).not.toBeNull();
58
58
  });
59
59
 
60
- it("keeps the red dot and the collapsed error on failure", () => {
60
+ it("marks a failure on its glyph and keeps the collapsed error", () => {
61
61
  const { container } = render(
62
62
  <InlineToolItem
63
63
  part={part({ status: "error", error: "ENOENT: no such file", output: undefined })}
@@ -40,7 +40,7 @@ describe("RunRowShell", () => {
40
40
  expect(container.querySelector("[data-run-row-status]")).toBeNull();
41
41
  });
42
42
 
43
- it("renders the red dot and the collapsed error on error", () => {
43
+ it("marks a failure on the row's own glyph, with the collapsed error under it", () => {
44
44
  const { container } = renderShell("error", {
45
45
  collapsedError: "exit code 1",
46
46
  });