@tangle-network/ui 11.24.0 → 11.26.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +22 -0
  3. package/dist/chat/agent-timeline.d.ts +13 -1
  4. package/dist/chat/agent-timeline.js +25 -4
  5. package/dist/chat/chat-message.d.ts +13 -1
  6. package/dist/chat/chat-message.js +9 -5
  7. package/dist/chat/message-author.d.ts +24 -0
  8. package/dist/chat/message-author.js +53 -0
  9. package/dist/chat/user-message.d.ts +12 -2
  10. package/dist/chat/user-message.js +52 -16
  11. package/dist/chat.d.ts +3 -1
  12. package/dist/chat.js +2 -1
  13. package/dist/editor/editor-peers.js +1 -21
  14. package/dist/files/file-card.d.ts +30 -0
  15. package/dist/files/file-card.js +162 -0
  16. package/dist/files/file-list.d.ts +25 -0
  17. package/dist/files/file-list.js +88 -0
  18. package/dist/files/file-preview.js +16 -62
  19. package/dist/files/pdf-loader.d.ts +22 -0
  20. package/dist/files/pdf-loader.js +82 -0
  21. package/dist/files/pdf-viewer.d.ts +32 -0
  22. package/dist/files/pdf-viewer.js +567 -0
  23. package/dist/files.d.ts +5 -1
  24. package/dist/files.js +5 -1
  25. package/dist/index.d.ts +9 -4
  26. package/dist/index.js +6 -1
  27. package/dist/lib/optional-peer.js +29 -0
  28. package/dist/primitives.d.ts +2 -2
  29. package/dist/types/message.d.ts +19 -0
  30. package/dist/types.d.ts +2 -2
  31. package/package.json +7 -2
  32. package/src/chat/agent-timeline.tsx +33 -3
  33. package/src/chat/chat-message.tsx +37 -10
  34. package/src/chat/index.ts +2 -0
  35. package/src/chat/message-author.stories.tsx +170 -0
  36. package/src/chat/message-author.test.tsx +189 -0
  37. package/src/chat/message-author.tsx +52 -0
  38. package/src/chat/user-message.tsx +63 -8
  39. package/src/editor/editor-peers.ts +3 -25
  40. package/src/files/file-artifact-pane.test.tsx +12 -2
  41. package/src/files/file-card.test.tsx +62 -0
  42. package/src/files/file-card.tsx +210 -0
  43. package/src/files/file-list.stories.tsx +121 -0
  44. package/src/files/file-list.test.tsx +81 -0
  45. package/src/files/file-list.tsx +167 -0
  46. package/src/files/file-preview.tsx +25 -94
  47. package/src/files/index.ts +9 -0
  48. package/src/files/pdf-loader.test.ts +130 -0
  49. package/src/files/pdf-loader.ts +169 -0
  50. package/src/files/pdf-viewer.stories.tsx +81 -0
  51. package/src/files/pdf-viewer.test.tsx +245 -0
  52. package/src/files/pdf-viewer.tsx +677 -0
  53. package/src/files/pdfjs-worker.d.ts +3 -0
  54. package/src/lib/optional-peer.ts +30 -0
  55. package/src/markdown/code-block.test.tsx +2 -1
  56. package/src/types/message.ts +20 -0
@@ -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="flex flex-wrap items-center justify-end gap-1.5 text-xs text-muted-foreground">
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() ? <UserBubble text={content} timestamp={timestamp} actions={actions} /> : null;
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 <UserBubble text={segments[0].text} timestamp={timestamp} actions={actions} />;
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
- <UserBubble key={segment.index} text={segment.text} timestamp={timestamp} />
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
  },
@@ -32,6 +32,9 @@ import type * as TiptapCollaborationCaret from "@tiptap/extension-collaboration-
32
32
  import type * as TiptapReact from "@tiptap/react";
33
33
  import type * as TiptapStarterKit from "@tiptap/starter-kit";
34
34
  import type * as Yjs from "yjs";
35
+ import { isMissingPeerError, rethrow } from "../lib/optional-peer";
36
+
37
+ export { isMissingPeerError };
35
38
 
36
39
  /** Namespaces the local markdown editor needs. */
37
40
  export interface DocumentEditorPeers {
@@ -89,31 +92,6 @@ export function isMissingEditorPeersError(error: unknown): boolean {
89
92
  return error instanceof Error && error.name === "MissingEditorPeersError";
90
93
  }
91
94
 
92
- /** The messages a bundler or a runtime gives for a module it cannot resolve. */
93
- const RESOLUTION_FAILURE =
94
- /could not resolve|cannot find (?:module|package)|can't resolve|failed to resolve|module not found/i;
95
-
96
- /**
97
- * True when the rejection says the package is not installed. Only such an
98
- * error gets the install list: a transient chunk-fetch failure that reads as
99
- * "install the peers" sends the reader to the wrong fix. An error this
100
- * predicate does not match keeps its own message, so it can only
101
- * under-report.
102
- */
103
- export function isMissingPeerError(error: unknown): boolean {
104
- return error instanceof Error && RESOLUTION_FAILURE.test(error.message);
105
- }
106
-
107
- /**
108
- * Hands an `import()` rejection on unchanged. esbuild reports an unresolvable
109
- * literal `import()` as a build error and defers it to run time only when the
110
- * call carries a `.catch()`, so every peer import attaches this handler. It
111
- * changes nothing at run time.
112
- */
113
- function rethrow(error: unknown): never {
114
- throw error;
115
- }
116
-
117
95
  /**
118
96
  * Every optional peer the `./editor` entry needs at run time. The loaders
119
97
  * import all but `@tiptap/core`, which the tiptap packages need in turn: a
@@ -2,6 +2,12 @@ import { describe, expect, it, vi } from "vitest";
2
2
  import { fireEvent, render, screen } from "@testing-library/react";
3
3
  import { FileArtifactPane } from "./file-artifact-pane";
4
4
  import { CSV_PREVIEW_ROW_LIMIT, parseCsv } from "./file-preview";
5
+ import { MissingPdfjsError, loadPdfjs } from "./pdf-loader";
6
+
7
+ vi.mock("./pdf-loader", async (importOriginal) => ({
8
+ ...(await importOriginal<typeof import("./pdf-loader")>()),
9
+ loadPdfjs: vi.fn(),
10
+ }));
5
11
 
6
12
  const BLOB_URL = "blob:https://app.tangle.tools/1f2e3d4c";
7
13
 
@@ -32,16 +38,20 @@ describe("FileArtifactPane previews", () => {
32
38
  expect(container.querySelector("img")).toHaveClass("max-w-none");
33
39
  });
34
40
 
35
- it("renders a PDF as an <object> of the blobUrl", () => {
41
+ it("renders a PDF in PdfViewer, and in an <object> of the blobUrl without pdfjs-dist", async () => {
42
+ vi.mocked(loadPdfjs).mockRejectedValue(new MissingPdfjsError());
43
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
36
44
  const { container } = render(
37
45
  <FileArtifactPane filename="report.pdf" blobUrl={BLOB_URL} mimeType="application/pdf" />,
38
46
  );
39
47
 
48
+ expect(screen.getByRole("status")).toHaveTextContent("Loading report.pdf");
49
+ expect(await screen.findByText(/does not include the page viewer/)).toBeInTheDocument();
40
50
  const viewer = container.querySelector("object");
41
- expect(viewer).not.toBeNull();
42
51
  expect(viewer).toHaveAttribute("data", BLOB_URL);
43
52
  expect(viewer).toHaveAttribute("type", "application/pdf");
44
53
  expect(viewer).toHaveAttribute("title", "report.pdf");
54
+ consoleError.mockRestore();
45
55
  });
46
56
 
47
57
  it("renders video and audio with native controls", () => {
@@ -0,0 +1,62 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { FileCard } from "./file-card";
4
+
5
+ const UPLOADED = new Date("2026-10-06T15:30:00Z");
6
+
7
+ describe("FileCard", () => {
8
+ it("names the file with its size, date and uploader", () => {
9
+ const { container } = render(
10
+ <FileCard
11
+ filename="Engagement letter.pdf"
12
+ size={2_516_582}
13
+ date={UPLOADED}
14
+ uploader={{ name: "Dana Ruiz", role: "Client" }}
15
+ />,
16
+ );
17
+
18
+ expect(screen.getByText("Engagement letter.pdf")).toBeInTheDocument();
19
+ expect(screen.getByText("2.4 MB")).toBeInTheDocument();
20
+ expect(container.querySelector("time")).toHaveAttribute("datetime", UPLOADED.toISOString());
21
+ expect(screen.getByText("Uploaded by Dana Ruiz (Client)")).toBeInTheDocument();
22
+ });
23
+
24
+ it("leaves out what it does not know", () => {
25
+ const { container } = render(<FileCard filename="notes.txt" date="not a date" />);
26
+ expect(container.querySelector("time")).toBeNull();
27
+ expect(screen.queryByText(/Uploaded by/)).toBeNull();
28
+ expect(screen.queryByRole("button")).toBeNull();
29
+ });
30
+
31
+ it("takes its icon tone from the file kind, with the MIME type outranking the name", () => {
32
+ const pdf = render(<FileCard filename="brief.pdf" />);
33
+ expect(pdf.container.querySelector("[aria-hidden='true']")?.className).toContain(
34
+ "--tone-orange-bg",
35
+ );
36
+ pdf.unmount();
37
+
38
+ const sheet = render(<FileCard filename="export" mimeType="text/csv" />);
39
+ expect(sheet.container.querySelector("[aria-hidden='true']")?.className).toContain(
40
+ "--tone-lime-bg",
41
+ );
42
+ });
43
+
44
+ it("offers Download and the host's own actions", () => {
45
+ const onDownload = vi.fn();
46
+ render(
47
+ <FileCard
48
+ filename="book.xlsx"
49
+ description="Download to open this workbook in a spreadsheet app."
50
+ onDownload={onDownload}
51
+ actions={<button type="button">Open</button>}
52
+ />,
53
+ );
54
+
55
+ expect(
56
+ screen.getByText("Download to open this workbook in a spreadsheet app."),
57
+ ).toBeInTheDocument();
58
+ fireEvent.click(screen.getByRole("button", { name: "Download" }));
59
+ expect(onDownload).toHaveBeenCalledTimes(1);
60
+ expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
61
+ });
62
+ });