@tangle-network/ui 11.24.0 → 11.25.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 +6 -0
- package/dist/chat/agent-timeline.d.ts +13 -1
- package/dist/chat/agent-timeline.js +25 -4
- package/dist/chat/chat-message.d.ts +13 -1
- package/dist/chat/chat-message.js +9 -5
- package/dist/chat/message-author.d.ts +24 -0
- package/dist/chat/message-author.js +53 -0
- package/dist/chat/user-message.d.ts +12 -2
- package/dist/chat/user-message.js +52 -16
- package/dist/chat.d.ts +3 -1
- package/dist/chat.js +2 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/types/message.d.ts +19 -0
- package/dist/types.d.ts +2 -2
- package/package.json +2 -2
- package/src/chat/agent-timeline.tsx +33 -3
- package/src/chat/chat-message.tsx +37 -10
- package/src/chat/index.ts +2 -0
- package/src/chat/message-author.stories.tsx +170 -0
- package/src/chat/message-author.test.tsx +189 -0
- package/src/chat/message-author.tsx +52 -0
- package/src/chat/user-message.tsx +63 -8
- package/src/markdown/code-block.test.tsx +2 -1
- package/src/types/message.ts +20 -0
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
* ChatMessage — one message in the conversation.
|
|
3
3
|
*
|
|
4
4
|
* Supports user messages (plain text) and assistant messages
|
|
5
|
-
* (rich markdown with inline tool call activity).
|
|
5
|
+
* (rich markdown with inline tool call activity). With `author` and
|
|
6
|
+
* `viewerId` it renders a transcript shared by several people: the reader's
|
|
7
|
+
* own messages stay at the end, everyone else's sit at the start under
|
|
8
|
+
* their author.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import { type ReactNode } from "react";
|
|
9
12
|
import { cn } from "../lib/utils";
|
|
10
13
|
import { Markdown } from "../markdown/markdown";
|
|
14
|
+
import type { ChatAuthor } from "../types/message";
|
|
15
|
+
import { isViewerMessage, MessageAuthor } from "./message-author";
|
|
11
16
|
|
|
12
17
|
export type MessageRole = "user" | "assistant" | "system";
|
|
13
18
|
|
|
@@ -27,6 +32,17 @@ export interface ChatMessageProps {
|
|
|
27
32
|
assistantLabel?: string;
|
|
28
33
|
/** Hide the role label row entirely */
|
|
29
34
|
hideRoleLabel?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Who wrote the message, when more than one person shares the transcript.
|
|
37
|
+
* It replaces the role label with the author's avatar, name and role,
|
|
38
|
+
* except on the reader's own messages, which keep `userLabel`.
|
|
39
|
+
*/
|
|
40
|
+
author?: ChatAuthor;
|
|
41
|
+
/**
|
|
42
|
+
* The reader's participant id. A user message whose `author` has another
|
|
43
|
+
* id is someone else's: it sits at the start, on a card, under its author.
|
|
44
|
+
*/
|
|
45
|
+
viewerId?: string;
|
|
30
46
|
}
|
|
31
47
|
|
|
32
48
|
export function ChatMessage({
|
|
@@ -39,23 +55,32 @@ export function ChatMessage({
|
|
|
39
55
|
userLabel = "You",
|
|
40
56
|
assistantLabel = "Agent",
|
|
41
57
|
hideRoleLabel,
|
|
58
|
+
author,
|
|
59
|
+
viewerId,
|
|
42
60
|
}: ChatMessageProps) {
|
|
43
61
|
const isUser = role === "user";
|
|
44
62
|
const isAssistant = role === "assistant";
|
|
63
|
+
const fromViewer = isUser && isViewerMessage(author, viewerId);
|
|
64
|
+
const fromOtherPerson = isUser && !fromViewer;
|
|
65
|
+
const shownAuthor = fromViewer ? undefined : author;
|
|
45
66
|
|
|
46
67
|
return (
|
|
47
68
|
<div
|
|
48
69
|
className={cn(
|
|
49
70
|
"flex flex-col gap-1",
|
|
50
|
-
|
|
71
|
+
fromViewer ? "items-end" : "items-start",
|
|
51
72
|
className,
|
|
52
73
|
)}
|
|
53
74
|
>
|
|
54
75
|
{!hideRoleLabel && (
|
|
55
|
-
<div className={cn("flex items-center gap-2 px-1",
|
|
56
|
-
|
|
57
|
-
{
|
|
58
|
-
|
|
76
|
+
<div className={cn("flex items-center gap-2 px-1", fromViewer && "flex-row-reverse")}>
|
|
77
|
+
{shownAuthor ? (
|
|
78
|
+
<MessageAuthor author={shownAuthor} />
|
|
79
|
+
) : (
|
|
80
|
+
<span className="font-medium text-foreground text-xs">
|
|
81
|
+
{isUser ? userLabel : assistantLabel}
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
59
84
|
{timestamp && (
|
|
60
85
|
<span className="text-muted-foreground text-xs">
|
|
61
86
|
{formatTime(timestamp)}
|
|
@@ -67,11 +92,13 @@ export function ChatMessage({
|
|
|
67
92
|
<div
|
|
68
93
|
className={cn(
|
|
69
94
|
"min-w-0 max-w-[85%] space-y-1",
|
|
70
|
-
|
|
95
|
+
fromViewer
|
|
71
96
|
? "rounded-[var(--radius-lg)] bg-foreground px-[var(--chat-message-px)] py-[var(--chat-message-py)] text-background"
|
|
72
|
-
:
|
|
73
|
-
? "text-foreground"
|
|
74
|
-
:
|
|
97
|
+
: fromOtherPerson
|
|
98
|
+
? "rounded-[var(--radius-lg)] border border-border bg-card px-[var(--chat-message-px)] py-[var(--chat-message-py)] text-foreground"
|
|
99
|
+
: isAssistant
|
|
100
|
+
? "text-foreground"
|
|
101
|
+
: "rounded-[var(--radius-lg)] border border-border bg-card px-[var(--chat-message-px)] py-[var(--chat-message-py)]",
|
|
75
102
|
)}
|
|
76
103
|
>
|
|
77
104
|
{isUser ? (
|
package/src/chat/index.ts
CHANGED
|
@@ -2,6 +2,8 @@ export { ChatContainer, type ChatContainerProps } from "./chat-container";
|
|
|
2
2
|
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
|
+
export { MessageAuthor, isViewerMessage, type MessageAuthorProps } from "./message-author";
|
|
6
|
+
export type { ChatAuthor } from "../types/message";
|
|
5
7
|
export { ThinkingIndicator, type ThinkingIndicatorProps } from "./thinking-indicator";
|
|
6
8
|
export {
|
|
7
9
|
AgentTimeline,
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { AgentTimeline, type AgentTimelineItem } from './agent-timeline'
|
|
3
|
+
import { ChatMessage } from './chat-message'
|
|
4
|
+
import { MessageAuthor } from './message-author'
|
|
5
|
+
import type { ChatAuthor } from '../types/message'
|
|
6
|
+
|
|
7
|
+
const client: ChatAuthor = { id: 'client-maria-lopez', name: 'Maria Lopez', role: 'Client' }
|
|
8
|
+
const attorney: ChatAuthor = { id: 'attorney-jane-doe', name: 'Jane Doe', role: 'Attorney' }
|
|
9
|
+
const agent: ChatAuthor = { id: 'agent-intake', name: 'Intake assistant', role: 'AI' }
|
|
10
|
+
|
|
11
|
+
const NOW = Date.now()
|
|
12
|
+
const t = (offsetMinutes: number) => new Date(NOW - offsetMinutes * 60 * 1000)
|
|
13
|
+
|
|
14
|
+
const intakeThread: AgentTimelineItem[] = [
|
|
15
|
+
{
|
|
16
|
+
id: 'm1',
|
|
17
|
+
kind: 'message',
|
|
18
|
+
role: 'user',
|
|
19
|
+
author: client,
|
|
20
|
+
timestamp: t(42),
|
|
21
|
+
content:
|
|
22
|
+
'My landlord kept my $2,400 security deposit and never sent an itemized list. I moved out on August 31. What can I do?',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'm2',
|
|
26
|
+
kind: 'message',
|
|
27
|
+
role: 'assistant',
|
|
28
|
+
author: agent,
|
|
29
|
+
content:
|
|
30
|
+
'In California a landlord has 21 days after move-out to return the deposit or send an itemized statement of deductions. That window closed on September 21.\n\nThis looks like a case an attorney should review, so I have shared your summary with Jane Doe.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'tool-1',
|
|
34
|
+
kind: 'tool',
|
|
35
|
+
call: {
|
|
36
|
+
id: 'tc-1',
|
|
37
|
+
type: 'write',
|
|
38
|
+
label: 'Write',
|
|
39
|
+
status: 'success',
|
|
40
|
+
detail: 'intake/deposit-dispute-summary.md',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'm3',
|
|
45
|
+
kind: 'message',
|
|
46
|
+
role: 'assistant',
|
|
47
|
+
author: agent,
|
|
48
|
+
content: 'The summary includes your lease dates and the deposit amount.',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'm4',
|
|
52
|
+
kind: 'message',
|
|
53
|
+
role: 'user',
|
|
54
|
+
author: attorney,
|
|
55
|
+
timestamp: t(30),
|
|
56
|
+
content: 'Hi Maria, I am Jane. Did you give the landlord a forwarding address in writing?',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'm5',
|
|
60
|
+
kind: 'message',
|
|
61
|
+
role: 'user',
|
|
62
|
+
author: client,
|
|
63
|
+
timestamp: t(27),
|
|
64
|
+
content: 'Yes, I emailed it to the property manager on August 28.',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'm6',
|
|
68
|
+
kind: 'message',
|
|
69
|
+
role: 'assistant',
|
|
70
|
+
author: agent,
|
|
71
|
+
content: 'I added the August 28 email to the case file.',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'm7',
|
|
75
|
+
kind: 'message',
|
|
76
|
+
role: 'user',
|
|
77
|
+
author: attorney,
|
|
78
|
+
timestamp: t(20),
|
|
79
|
+
content: 'Thanks. I will draft a demand letter today, and you will have it to review by 5pm.',
|
|
80
|
+
},
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
const meta: Meta<typeof AgentTimeline> = {
|
|
84
|
+
title: 'Chat/Multi-party transcript',
|
|
85
|
+
component: AgentTimeline,
|
|
86
|
+
parameters: {
|
|
87
|
+
layout: 'fullscreen',
|
|
88
|
+
},
|
|
89
|
+
decorators: [
|
|
90
|
+
(Story) => (
|
|
91
|
+
<div className="min-h-screen bg-[var(--bg-root)]">
|
|
92
|
+
<div className="mx-auto max-w-3xl">
|
|
93
|
+
<Story />
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
),
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default meta
|
|
101
|
+
type Story = StoryObj<typeof AgentTimeline>
|
|
102
|
+
|
|
103
|
+
/** The client's screen: their own messages at the end, the attorney and the agent at the start. */
|
|
104
|
+
export const ClientView: Story = {
|
|
105
|
+
args: { items: intakeThread, viewerId: client.id },
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The same thread on the attorney's screen. */
|
|
109
|
+
export const AttorneyView: Story = {
|
|
110
|
+
args: { items: intakeThread, viewerId: attorney.id },
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** No viewer, as in an audit log: every person is someone else, so every message is named. */
|
|
114
|
+
export const ObserverView: Story = {
|
|
115
|
+
args: { items: intakeThread },
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** `ChatMessage` with always-on labels: the reader keeps "You", everyone else is named. */
|
|
119
|
+
export const LabeledMessages: Story = {
|
|
120
|
+
render: () => (
|
|
121
|
+
<div className="space-y-4 p-4">
|
|
122
|
+
<ChatMessage role="user" author={client} viewerId={client.id} timestamp={t(42)} content="My landlord kept my $2,400 security deposit and never sent an itemized list." />
|
|
123
|
+
<ChatMessage role="assistant" author={agent} content="That window closed on September 21. I have shared your summary with Jane Doe." />
|
|
124
|
+
<ChatMessage role="user" author={attorney} viewerId={client.id} timestamp={t(30)} content="Hi Maria, I am Jane. Did you give the landlord a forwarding address in writing?" />
|
|
125
|
+
</div>
|
|
126
|
+
),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const longName: ChatAuthor = {
|
|
130
|
+
id: 'attorney-long',
|
|
131
|
+
name: 'Alexandria Montgomery-Vandenberghe Castellanos',
|
|
132
|
+
role: 'Senior supervising attorney, tenant rights',
|
|
133
|
+
}
|
|
134
|
+
const noRole: ChatAuthor = { id: 'guest-1', name: 'Q' }
|
|
135
|
+
const withImage: ChatAuthor = {
|
|
136
|
+
id: 'paralegal-sam',
|
|
137
|
+
name: 'Sam Okafor',
|
|
138
|
+
role: 'Paralegal',
|
|
139
|
+
avatarUrl:
|
|
140
|
+
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><rect width='20' height='20' fill='%236366f1'/><circle cx='10' cy='8' r='4' fill='white'/><rect x='4' y='13' width='12' height='7' rx='3' fill='white'/></svg>",
|
|
141
|
+
}
|
|
142
|
+
const brokenImage: ChatAuthor = { id: 'client-broken', name: 'Lee Park', role: 'Client', avatarUrl: 'https://invalid.example/avatar.png' }
|
|
143
|
+
|
|
144
|
+
/** Worst cases: a long name and role, a one-letter name with no role, an image, a failing image. */
|
|
145
|
+
export const EdgeCases: Story = {
|
|
146
|
+
render: () => (
|
|
147
|
+
<div className="space-y-6 p-4">
|
|
148
|
+
<div className="space-y-2">
|
|
149
|
+
<MessageAuthor author={longName} />
|
|
150
|
+
<MessageAuthor author={noRole} />
|
|
151
|
+
<MessageAuthor author={withImage} />
|
|
152
|
+
<MessageAuthor author={brokenImage} />
|
|
153
|
+
</div>
|
|
154
|
+
<AgentTimeline
|
|
155
|
+
className="px-0"
|
|
156
|
+
viewerId={client.id}
|
|
157
|
+
items={[
|
|
158
|
+
{
|
|
159
|
+
id: 'e1',
|
|
160
|
+
kind: 'message',
|
|
161
|
+
role: 'user',
|
|
162
|
+
author: longName,
|
|
163
|
+
content: 'Superlongunbrokenidentifierwithoutanyspaces_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
164
|
+
},
|
|
165
|
+
{ id: 'e2', kind: 'message', role: 'user', author: noRole, content: 'ok' },
|
|
166
|
+
]}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
),
|
|
170
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { render, screen, within } from "@testing-library/react"
|
|
2
|
+
import type { ReactElement } from "react"
|
|
3
|
+
import { describe, expect, it } from "vitest"
|
|
4
|
+
import type { ChatAuthor } from "../types/message"
|
|
5
|
+
import type { SessionPart } from "../types/parts"
|
|
6
|
+
import { AgentTimeline, type AgentTimelineItem } from "./agent-timeline"
|
|
7
|
+
import { ChatMessage } from "./chat-message"
|
|
8
|
+
import { isViewerMessage, MessageAuthor } from "./message-author"
|
|
9
|
+
import { UserMessage } from "./user-message"
|
|
10
|
+
|
|
11
|
+
function html(ui: ReactElement): string {
|
|
12
|
+
return render(ui).container.innerHTML
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const segmentedParts: SessionPart[] = [
|
|
16
|
+
{ type: "text", text: "I signed the lease in March." },
|
|
17
|
+
{ type: "text", text: "Lease uploaded: lease.pdf", synthetic: true },
|
|
18
|
+
{ type: "text", text: "Here it is." },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
// The snapshots below were recorded from the components before `author`
|
|
22
|
+
// existed. A transcript that names no author must keep that exact markup.
|
|
23
|
+
describe("a transcript without authors", () => {
|
|
24
|
+
it("renders ChatMessage as before", () => {
|
|
25
|
+
expect(html(<ChatMessage role="user" content="Can I break my lease early?" />)).toMatchInlineSnapshot(`"<div class="flex flex-col gap-1 items-end"><div class="flex items-center gap-2 px-1 flex-row-reverse"><span class="font-medium text-foreground text-xs">You</span></div><div class="min-w-0 max-w-[85%] space-y-1 rounded-[var(--radius-lg)] bg-foreground px-[var(--chat-message-px)] py-[var(--chat-message-py)] text-background"><div class="whitespace-pre-wrap text-[length:var(--font-size-base)] leading-[var(--line-height-base)]">Can I break my lease early?</div></div></div>"`)
|
|
26
|
+
expect(html(<ChatMessage role="assistant" content="It depends on the lease terms." />)).toMatchInlineSnapshot(`"<div class="flex flex-col gap-1 items-start"><div class="flex items-center gap-2 px-1"><span class="font-medium text-foreground text-xs">Agent</span></div><div class="min-w-0 max-w-[85%] space-y-1 text-foreground"><div class="tangle-prose max-w-none tangle-prose text-[length:var(--font-size-base)] leading-[var(--line-height-base)]"><p>It depends on the lease terms.</p></div></div></div>"`)
|
|
27
|
+
expect(html(<ChatMessage role="system" content="Session started." />)).toMatchInlineSnapshot(`"<div class="flex flex-col gap-1 items-start"><div class="flex items-center gap-2 px-1"><span class="font-medium text-foreground text-xs">Agent</span></div><div class="min-w-0 max-w-[85%] space-y-1 rounded-[var(--radius-lg)] border border-border bg-card px-[var(--chat-message-px)] py-[var(--chat-message-py)]"><div class="tangle-prose max-w-none tangle-prose text-[length:var(--font-size-base)] leading-[var(--line-height-base)]"><p>Session started.</p></div></div></div>"`)
|
|
28
|
+
})
|
|
29
|
+
|
|
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 text-[length:var(--font-size-base)] leading-[1.5]">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 text-[length:var(--font-size-base)] leading-[1.5]">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 text-[length:var(--font-size-base)] leading-[1.5]">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>"`)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("renders UserMessage as before", () => {
|
|
36
|
+
expect(html(<UserMessage content="Can I break my lease early?" />)).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 text-[length:var(--font-size-base)] leading-[1.5]">Can I break my lease early?</div></div></div></div>"`)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("renders AgentTimeline messages as before", () => {
|
|
40
|
+
expect(
|
|
41
|
+
html(
|
|
42
|
+
<AgentTimeline
|
|
43
|
+
items={[
|
|
44
|
+
{ id: "u1", kind: "message", role: "user", content: "Can I break my lease early?" },
|
|
45
|
+
{ id: "a1", kind: "message", role: "assistant", content: "It depends on the lease terms." },
|
|
46
|
+
]}
|
|
47
|
+
/>,
|
|
48
|
+
),
|
|
49
|
+
).toMatchInlineSnapshot(`"<div class="mx-auto flex w-full max-w-5xl flex-col px-4 py-4"><div data-timeline-step="user" class=""><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 text-[length:var(--font-size-base)] leading-[1.5]">Can I break my lease early?</div></div></div></div></div><div data-timeline-step="prose" class="mt-4"><div><div class="tangle-prose max-w-none tangle-prose text-[length:var(--font-size-base)] leading-[1.5]"><p>It depends on the lease terms.</p></div></div></div></div>"`)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const client: ChatAuthor = { id: "client-maria", name: "Maria Lopez", role: "Client" }
|
|
54
|
+
const attorney: ChatAuthor = { id: "attorney-jane", name: "Jane Doe", role: "Attorney" }
|
|
55
|
+
const agent: ChatAuthor = { id: "agent-intake", name: "Intake assistant", role: "AI" }
|
|
56
|
+
|
|
57
|
+
/** The bubble that holds a message's text. */
|
|
58
|
+
function bubbleOf(text: string): HTMLElement {
|
|
59
|
+
return screen.getByText(text).parentElement as HTMLElement
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("a viewerId without authors", () => {
|
|
63
|
+
it("changes nothing", () => {
|
|
64
|
+
expect(html(<UserMessage content="Hello" viewerId="client-maria" />)).toBe(html(<UserMessage content="Hello" />))
|
|
65
|
+
expect(html(<ChatMessage role="user" content="Hello" viewerId="client-maria" />)).toBe(
|
|
66
|
+
html(<ChatMessage role="user" content="Hello" />),
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe("isViewerMessage", () => {
|
|
72
|
+
it("treats an unauthored message as the reader's, and an authored one only when it is the viewer", () => {
|
|
73
|
+
expect(isViewerMessage(undefined, undefined)).toBe(true)
|
|
74
|
+
expect(isViewerMessage(undefined, "client-maria")).toBe(true)
|
|
75
|
+
expect(isViewerMessage(client, "client-maria")).toBe(true)
|
|
76
|
+
expect(isViewerMessage(attorney, "client-maria")).toBe(false)
|
|
77
|
+
expect(isViewerMessage(client, undefined)).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe("MessageAuthor", () => {
|
|
82
|
+
it("shows the name, the role as a tag, and initials in a decorative avatar", () => {
|
|
83
|
+
const { container } = render(<MessageAuthor author={attorney} />)
|
|
84
|
+
expect(screen.getByText("Jane Doe")).toBeInTheDocument()
|
|
85
|
+
expect(screen.getByText("Attorney")).toBeInTheDocument()
|
|
86
|
+
const avatar = container.querySelector("[aria-hidden]")
|
|
87
|
+
expect(avatar?.textContent).toBe("JD")
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("omits the tag when the author has no role", () => {
|
|
91
|
+
const { container } = render(<MessageAuthor author={{ id: "guest", name: "Guest" }} />)
|
|
92
|
+
expect(container.querySelectorAll("span")).toHaveLength(2)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("keys the avatar tone to the id, so two people with one name stay apart", () => {
|
|
96
|
+
const tones = (author: ChatAuthor) => render(<MessageAuthor author={author} />).container.querySelector("[aria-hidden]")!.className
|
|
97
|
+
const first = tones({ id: "client-maria", name: "Sam Lee" })
|
|
98
|
+
expect(tones({ id: "client-maria", name: "Sam Lee" })).toBe(first)
|
|
99
|
+
expect(tones({ id: "attorney-jane", name: "Sam Lee" })).not.toBe(first)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe("UserMessage with an author", () => {
|
|
104
|
+
it("renders the reader's own message exactly as an unauthored one", () => {
|
|
105
|
+
expect(html(<UserMessage content="Hello" author={client} viewerId={client.id} />)).toBe(html(<UserMessage content="Hello" />))
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("puts someone else's message at the start, on a card, under their name and role", () => {
|
|
109
|
+
const { container } = render(<UserMessage content="Did you give a forwarding address?" author={attorney} viewerId={client.id} />)
|
|
110
|
+
expect(container.firstElementChild?.className).toContain("justify-start")
|
|
111
|
+
expect(bubbleOf("Did you give a forwarding address?").className).toContain("bg-card")
|
|
112
|
+
expect(bubbleOf("Did you give a forwarding address?").className).not.toContain("bg-foreground")
|
|
113
|
+
expect(screen.getByText("Jane Doe")).toBeInTheDocument()
|
|
114
|
+
expect(screen.getByText("Attorney")).toBeInTheDocument()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("names every author when there is no viewer", () => {
|
|
118
|
+
render(<UserMessage content="Hello" author={client} />)
|
|
119
|
+
expect(screen.getByText("Maria Lopez")).toBeInTheDocument()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("names the author once above a message split by application notes", () => {
|
|
123
|
+
const { container } = render(<UserMessage parts={segmentedParts} author={attorney} viewerId={client.id} />)
|
|
124
|
+
expect(screen.getAllByText("Jane Doe")).toHaveLength(1)
|
|
125
|
+
expect(container.querySelectorAll("[data-message-author]")).toHaveLength(1)
|
|
126
|
+
expect(bubbleOf("Here it is.").className).toContain("bg-card")
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe("ChatMessage with an author", () => {
|
|
131
|
+
it("keeps the reader's label on their own message", () => {
|
|
132
|
+
render(<ChatMessage role="user" content="Hello" author={client} viewerId={client.id} />)
|
|
133
|
+
expect(screen.getByText("You")).toBeInTheDocument()
|
|
134
|
+
expect(screen.queryByText("Maria Lopez")).not.toBeInTheDocument()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("names someone else at the start of the row, on a card", () => {
|
|
138
|
+
const { container } = render(<ChatMessage role="user" content="Hello Maria" author={attorney} viewerId={client.id} />)
|
|
139
|
+
expect(container.firstElementChild?.className).toContain("items-start")
|
|
140
|
+
expect(screen.queryByText("You")).not.toBeInTheDocument()
|
|
141
|
+
expect(screen.getByText("Jane Doe")).toBeInTheDocument()
|
|
142
|
+
expect(bubbleOf("Hello Maria").className).toContain("bg-card")
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("names the agent in place of the assistant label", () => {
|
|
146
|
+
render(<ChatMessage role="assistant" content="Noted." author={agent} />)
|
|
147
|
+
expect(screen.queryByText("Agent")).not.toBeInTheDocument()
|
|
148
|
+
expect(screen.getByText("Intake assistant")).toBeInTheDocument()
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe("AgentTimeline with three participants", () => {
|
|
153
|
+
const thread: AgentTimelineItem[] = [
|
|
154
|
+
{ id: "m1", kind: "message", role: "user", author: client, content: "My landlord kept my deposit." },
|
|
155
|
+
{ id: "m2", kind: "message", role: "assistant", author: agent, content: "I shared your summary with Jane Doe." },
|
|
156
|
+
{
|
|
157
|
+
id: "t1",
|
|
158
|
+
kind: "tool",
|
|
159
|
+
call: { id: "c1", type: "write", label: "Write", status: "success", detail: "intake/summary.md" },
|
|
160
|
+
},
|
|
161
|
+
{ id: "m3", kind: "message", role: "assistant", author: agent, content: "The summary has your lease dates." },
|
|
162
|
+
{ id: "m4", kind: "message", role: "user", author: attorney, content: "Did you give a forwarding address?" },
|
|
163
|
+
{ id: "m5", kind: "message", role: "assistant", author: agent, content: "I added the email to the case file." },
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
it("sets the client's own words apart from the attorney's on the client's screen", () => {
|
|
167
|
+
render(<AgentTimeline items={thread} viewerId={client.id} />)
|
|
168
|
+
expect(bubbleOf("My landlord kept my deposit.").className).toContain("bg-foreground")
|
|
169
|
+
expect(screen.queryByText("Maria Lopez")).not.toBeInTheDocument()
|
|
170
|
+
expect(bubbleOf("Did you give a forwarding address?").className).toContain("bg-card")
|
|
171
|
+
expect(screen.getByText("Jane Doe")).toBeInTheDocument()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("mirrors the thread on the attorney's screen", () => {
|
|
175
|
+
render(<AgentTimeline items={thread} viewerId={attorney.id} />)
|
|
176
|
+
expect(bubbleOf("Did you give a forwarding address?").className).toContain("bg-foreground")
|
|
177
|
+
expect(bubbleOf("My landlord kept my deposit.").className).toContain("bg-card")
|
|
178
|
+
expect(screen.getByText("Maria Lopez")).toBeInTheDocument()
|
|
179
|
+
expect(screen.queryByText("Jane Doe")).not.toBeInTheDocument()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("names the agent once per turn, not after each tool row", () => {
|
|
183
|
+
const { container } = render(<AgentTimeline items={thread} viewerId={client.id} />)
|
|
184
|
+
const steps = Array.from(container.querySelectorAll("[data-timeline-step='prose']"))
|
|
185
|
+
const named = steps.map((step) => within(step as HTMLElement).queryByText("Intake assistant") !== null)
|
|
186
|
+
// m2 opens the agent's turn, m3 continues it after the tool row, m5 follows the attorney.
|
|
187
|
+
expect(named).toEqual([true, false, true])
|
|
188
|
+
})
|
|
189
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { IconTile } from "../primitives/icon-tile";
|
|
4
|
+
import { Tag } from "../primitives/tag";
|
|
5
|
+
import { toneFor } from "../primitives/tone";
|
|
6
|
+
import type { ChatAuthor } from "../types/message";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether the reader wrote a human message. A message without an author is
|
|
10
|
+
* the reader's, which keeps a two-party transcript as it was. A message with
|
|
11
|
+
* an author is the reader's only when that author is `viewerId`; with no
|
|
12
|
+
* `viewerId`, every authored message belongs to someone else.
|
|
13
|
+
*/
|
|
14
|
+
export function isViewerMessage(author: ChatAuthor | undefined, viewerId: string | undefined): boolean {
|
|
15
|
+
return author === undefined || author.id === viewerId;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MessageAuthorProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
19
|
+
author: ChatAuthor;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
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.
|
|
25
|
+
*
|
|
26
|
+
* The avatar's tone comes from the author's id, so a participant keeps one
|
|
27
|
+
* colour for the whole thread and in every product. Tones can repeat between
|
|
28
|
+
* participants, so the name is always shown beside it. The avatar repeats the
|
|
29
|
+
* visible name and is hidden from assistive technology.
|
|
30
|
+
*/
|
|
31
|
+
export function MessageAuthor({ author, className, ...props }: MessageAuthorProps) {
|
|
32
|
+
return (
|
|
33
|
+
<div data-message-author="" className={cn("flex min-w-0 max-w-full items-center gap-2", className)} {...props}>
|
|
34
|
+
<IconTile
|
|
35
|
+
size="xs"
|
|
36
|
+
src={author.avatarUrl}
|
|
37
|
+
name={author.name}
|
|
38
|
+
tone={toneFor(author.id)}
|
|
39
|
+
className="rounded-full"
|
|
40
|
+
/>
|
|
41
|
+
<span className="min-w-0 truncate font-medium text-foreground text-sm" title={author.name}>
|
|
42
|
+
{author.name}
|
|
43
|
+
</span>
|
|
44
|
+
{author.role ? (
|
|
45
|
+
// A long role truncates at half the row, so it never hides the name.
|
|
46
|
+
<Tag size="sm" emphasis="outline" className="max-w-[50%]">
|
|
47
|
+
{author.role}
|
|
48
|
+
</Tag>
|
|
49
|
+
) : null}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo, type ReactNode } from "react";
|
|
2
|
-
import type { SessionMessage } from "../types/message";
|
|
2
|
+
import type { ChatAuthor, SessionMessage } from "../types/message";
|
|
3
3
|
import type { SessionPart } from "../types/parts";
|
|
4
|
+
import { isViewerMessage, MessageAuthor } from "./message-author";
|
|
4
5
|
import { SyntheticText, userTextSegments } from "./synthetic-text";
|
|
5
6
|
|
|
6
7
|
export interface UserMessageProps {
|
|
@@ -11,15 +12,25 @@ export interface UserMessageProps {
|
|
|
11
12
|
content?: string;
|
|
12
13
|
timestamp?: Date;
|
|
13
14
|
actions?: ReactNode;
|
|
15
|
+
/**
|
|
16
|
+
* Who wrote the message, when more than one person shares the transcript.
|
|
17
|
+
* Omit it in a two-party chat: the message renders as the reader's.
|
|
18
|
+
*/
|
|
19
|
+
author?: ChatAuthor;
|
|
20
|
+
/**
|
|
21
|
+
* The reader's participant id. A message whose `author` has another id is
|
|
22
|
+
* someone else's: it sits at the start, on a card, under its author.
|
|
23
|
+
*/
|
|
24
|
+
viewerId?: string;
|
|
14
25
|
}
|
|
15
26
|
|
|
16
27
|
function formatTime(date: Date): string {
|
|
17
28
|
return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
|
18
29
|
}
|
|
19
30
|
|
|
20
|
-
function MessageActions({ children }: { children: ReactNode }) {
|
|
31
|
+
function MessageActions({ children, align = "end" }: { children: ReactNode; align?: "start" | "end" }) {
|
|
21
32
|
return (
|
|
22
|
-
<div className=
|
|
33
|
+
<div className={`flex flex-wrap items-center ${align === "end" ? "justify-end" : "justify-start"} gap-1.5 text-xs text-muted-foreground`}>
|
|
23
34
|
{children}
|
|
24
35
|
</div>
|
|
25
36
|
);
|
|
@@ -52,29 +63,73 @@ function UserBubble({ text, timestamp, actions }: {
|
|
|
52
63
|
);
|
|
53
64
|
}
|
|
54
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Another person's speech: start-aligned under its author, on a card rather
|
|
68
|
+
* than the reader's inverse fill, so "mine" and "theirs" differ before the
|
|
69
|
+
* names are read. The timestamp is revealed on the bubble's far side.
|
|
70
|
+
*/
|
|
71
|
+
function OtherPersonBubble({ text, timestamp, actions, author }: {
|
|
72
|
+
text: string;
|
|
73
|
+
timestamp?: Date;
|
|
74
|
+
actions?: ReactNode;
|
|
75
|
+
/** The author line; absent on a later segment of the same message. */
|
|
76
|
+
author?: ChatAuthor;
|
|
77
|
+
}) {
|
|
78
|
+
return (
|
|
79
|
+
<div className="flex justify-start">
|
|
80
|
+
<div className="group flex min-w-0 max-w-[78%] flex-col items-start gap-1.5">
|
|
81
|
+
{author ? <MessageAuthor author={author} /> : null}
|
|
82
|
+
<div className="relative min-w-0 max-w-full rounded-2xl border border-border bg-card px-4 py-3 text-foreground">
|
|
83
|
+
{timestamp ? (
|
|
84
|
+
<span
|
|
85
|
+
data-user-message-time=""
|
|
86
|
+
className="pointer-events-none absolute left-full top-1/2 ml-3 -translate-y-1/2 whitespace-nowrap text-[length:var(--font-size-xs)] text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
|
|
87
|
+
>
|
|
88
|
+
{formatTime(timestamp)}
|
|
89
|
+
</span>
|
|
90
|
+
) : null}
|
|
91
|
+
<div className="break-words whitespace-pre-wrap text-[length:var(--font-size-base)] leading-[1.5]">
|
|
92
|
+
{text}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
{actions ? <MessageActions align="start">{actions}</MessageActions> : null}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
55
101
|
/**
|
|
56
102
|
* User speech retains the inverse-theme bubble and direct-content contract.
|
|
57
103
|
* Application notes interrupt the bubble in source order, outside its attribution.
|
|
58
104
|
*/
|
|
59
105
|
export const UserMessage = memo(
|
|
60
|
-
({ message: _message, parts, content, timestamp, actions }: UserMessageProps) => {
|
|
106
|
+
({ message: _message, parts, content, timestamp, actions, author, viewerId }: UserMessageProps) => {
|
|
107
|
+
const other = isViewerMessage(author, viewerId) ? undefined : author;
|
|
108
|
+
// `headed` puts the author line on the first spoken segment only.
|
|
109
|
+
const bubble = (text: string, bubbleActions?: ReactNode, headed = true, key?: number) =>
|
|
110
|
+
other ? (
|
|
111
|
+
<OtherPersonBubble key={key} text={text} timestamp={timestamp} actions={bubbleActions} author={headed ? other : undefined} />
|
|
112
|
+
) : (
|
|
113
|
+
<UserBubble key={key} text={text} timestamp={timestamp} actions={bubbleActions} />
|
|
114
|
+
);
|
|
61
115
|
// Explicit direct content (including empty content) still overrides parts.
|
|
62
116
|
if (content != null) {
|
|
63
|
-
return content.trim() ?
|
|
117
|
+
return content.trim() ? bubble(content, actions) : null;
|
|
64
118
|
}
|
|
65
119
|
const segments = userTextSegments(parts ?? []);
|
|
66
120
|
if (segments.length === 0) return null;
|
|
67
121
|
if (segments.length === 1 && !segments[0].synthetic) {
|
|
68
|
-
return
|
|
122
|
+
return bubble(segments[0].text, actions);
|
|
69
123
|
}
|
|
124
|
+
const firstSpoken = segments.find((segment) => !segment.synthetic);
|
|
70
125
|
return (
|
|
71
126
|
<div className="space-y-2">
|
|
72
127
|
{segments.map((segment) => segment.synthetic ? (
|
|
73
128
|
<SyntheticText key={segment.index} text={segment.text} />
|
|
74
129
|
) : (
|
|
75
|
-
|
|
130
|
+
bubble(segment.text, undefined, segment === firstSpoken, segment.index)
|
|
76
131
|
))}
|
|
77
|
-
{actions ? <MessageActions>{actions}</MessageActions> : null}
|
|
132
|
+
{actions ? <MessageActions align={other ? "start" : "end"}>{actions}</MessageActions> : null}
|
|
78
133
|
</div>
|
|
79
134
|
);
|
|
80
135
|
},
|
|
@@ -17,7 +17,8 @@ describe("CodeBlock scoped semantic colors", () => {
|
|
|
17
17
|
it("keeps code readable before the renderer loads, then applies live semantic colors and line numbers", async () => {
|
|
18
18
|
const { container } = render(<CodeBlock code={'const message = "hello";'} language="javascript" showLineNumbers />);
|
|
19
19
|
expect(container.querySelector("pre code")?.textContent).toBe('const message = "hello";');
|
|
20
|
-
|
|
20
|
+
// The highlighter loads asynchronously; under a full parallel run it can take longer than waitFor's 1s default.
|
|
21
|
+
await waitFor(() => expect(container.innerHTML).toContain("var(--syntax-keyword, currentColor)"), { timeout: 10_000 });
|
|
21
22
|
expect(container.innerHTML).toContain("var(--syntax-string, currentColor)");
|
|
22
23
|
expect(container.innerHTML).toContain("var(--syntax-comment, currentColor)");
|
|
23
24
|
expect(container.querySelector(".react-syntax-highlighter-line-number")).not.toBeNull();
|