@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.
- package/CHANGELOG.md +14 -0
- package/dist/chat/agent-timeline.d.ts +29 -0
- package/dist/chat/agent-timeline.js +77 -53
- 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/user-message.js +1 -1
- package/dist/chat.d.ts +3 -2
- package/dist/chat.js +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/run/inline-thinking-item.js +1 -1
- package/dist/run/run-row-shell.d.ts +4 -3
- package/dist/run/run-row-shell.js +12 -13
- package/package.json +1 -1
- package/src/chat/agent-timeline.tsx +98 -70
- package/src/chat/index.ts +14 -0
- package/src/chat/message-author.test.tsx +2 -2
- 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/chat/user-message.tsx +1 -1
- package/src/run/inline-thinking-item.tsx +1 -1
- package/src/run/inline-tool-item.test.tsx +1 -1
- package/src/run/run-row-shell.test.tsx +1 -1
- package/src/run/run-row-shell.tsx +18 -16
|
@@ -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
|
|
157
|
+
export type AgentTimelineStepKind = "user" | "prose" | "tool" | "card";
|
|
158
|
+
type StepKind = AgentTimelineStepKind;
|
|
158
159
|
|
|
159
|
-
|
|
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
|
-
<
|
|
409
|
-
{renderList.map((item, index) =>
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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-
|
|
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
|
-
</
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
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
|
);
|
|
@@ -57,7 +57,7 @@ describe("InlineToolItem", () => {
|
|
|
57
57
|
expect(container.querySelector(".tangle-text-shimmer")).not.toBeNull();
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
it("
|
|
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("
|
|
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
|
});
|