@djangocfg/widget-chat 0.1.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/package.json +95 -0
  4. package/src/composer/composer.tsx +100 -0
  5. package/src/composer/context.ts +33 -0
  6. package/src/composer/controls/button.tsx +104 -0
  7. package/src/composer/controls/language-menu.tsx +123 -0
  8. package/src/composer/controls/send.tsx +83 -0
  9. package/src/composer/controls/think.tsx +50 -0
  10. package/src/composer/controls/tools.tsx +130 -0
  11. package/src/composer/controls/tooltip.tsx +10 -0
  12. package/src/composer/controls/voice.tsx +266 -0
  13. package/src/composer/field/field.tsx +140 -0
  14. package/src/composer/field/input.tsx +214 -0
  15. package/src/composer/field/keys.ts +44 -0
  16. package/src/composer/field/use-autosize.ts +134 -0
  17. package/src/composer/focus-field.ts +27 -0
  18. package/src/composer/index.ts +86 -0
  19. package/src/composer/stories/field.tsx +112 -0
  20. package/src/composer/stories/harness.tsx +127 -0
  21. package/src/composer/tools/menu.tsx +129 -0
  22. package/src/composer/tools/rows.tsx +180 -0
  23. package/src/composer/tools/use-menu-nav.ts +66 -0
  24. package/src/composer/tools/use-recent-tools.ts +30 -0
  25. package/src/composer/tray/banner.tsx +157 -0
  26. package/src/composer/tray/command-progress.tsx +116 -0
  27. package/src/composer/tray/picked-tools.tsx +104 -0
  28. package/src/composer/tray/queue.tsx +202 -0
  29. package/src/composer/tray/use-replace-text.ts +58 -0
  30. package/src/composer/use-draft-persistence.ts +79 -0
  31. package/src/content/index.ts +16 -0
  32. package/src/content/media-content.tsx +38 -0
  33. package/src/content/response.tsx +221 -0
  34. package/src/core/adapter.ts +29 -0
  35. package/src/core/capabilities.ts +286 -0
  36. package/src/core/chunks.ts +66 -0
  37. package/src/core/content/incomplete-math.ts +118 -0
  38. package/src/core/content/index.ts +9 -0
  39. package/src/core/draft-storage.ts +62 -0
  40. package/src/core/events.ts +88 -0
  41. package/src/core/focus/index.ts +10 -0
  42. package/src/core/focus/roving.ts +56 -0
  43. package/src/core/group-parts.ts +80 -0
  44. package/src/core/index.ts +34 -0
  45. package/src/core/links/index.ts +33 -0
  46. package/src/core/links/prose.ts +191 -0
  47. package/src/core/links/scheme.ts +98 -0
  48. package/src/core/links/tool-id.ts +26 -0
  49. package/src/core/media/allowed-src.ts +138 -0
  50. package/src/core/media/block.ts +254 -0
  51. package/src/core/media/index.ts +22 -0
  52. package/src/core/media/kinds.ts +85 -0
  53. package/src/core/outbound.ts +86 -0
  54. package/src/core/parts.ts +169 -0
  55. package/src/core/queue/index.ts +15 -0
  56. package/src/core/queue/queue.ts +100 -0
  57. package/src/core/recall/boundary.ts +77 -0
  58. package/src/core/recall/from-transcript.ts +47 -0
  59. package/src/core/recall/index.ts +23 -0
  60. package/src/core/recall/ladder.ts +171 -0
  61. package/src/core/reload-decision.ts +36 -0
  62. package/src/core/scroll/index.ts +17 -0
  63. package/src/core/scroll/missed.ts +106 -0
  64. package/src/core/scroll/sent.ts +54 -0
  65. package/src/core/text-replace.ts +20 -0
  66. package/src/core/tool-name.ts +31 -0
  67. package/src/core/tool-picks-set.ts +48 -0
  68. package/src/core/tool-picks.ts +142 -0
  69. package/src/core/tool-prompt.ts +47 -0
  70. package/src/core/tools/capability.ts +34 -0
  71. package/src/core/tools/catalogue.ts +109 -0
  72. package/src/core/tools/index.ts +17 -0
  73. package/src/core/tools/layout.ts +44 -0
  74. package/src/core/tools/nav.ts +32 -0
  75. package/src/core/tools/rank.ts +60 -0
  76. package/src/core/tools/recent.ts +39 -0
  77. package/src/core/transcript.ts +301 -0
  78. package/src/core/voice/earcons.ts +58 -0
  79. package/src/core/voice/index.ts +26 -0
  80. package/src/core/voice/language-search.ts +62 -0
  81. package/src/core/voice/languages.ts +267 -0
  82. package/src/core/voice/resolve-language.ts +93 -0
  83. package/src/core/voice/session.ts +196 -0
  84. package/src/core/voice/web-speech.ts +170 -0
  85. package/src/i18n/index.ts +87 -0
  86. package/src/i18n/locales/ar.ts +97 -0
  87. package/src/i18n/locales/da.ts +90 -0
  88. package/src/i18n/locales/de.ts +90 -0
  89. package/src/i18n/locales/en.ts +90 -0
  90. package/src/i18n/locales/es.ts +90 -0
  91. package/src/i18n/locales/fr.ts +90 -0
  92. package/src/i18n/locales/it.ts +90 -0
  93. package/src/i18n/locales/ja.ts +83 -0
  94. package/src/i18n/locales/ko.ts +83 -0
  95. package/src/i18n/locales/nl.ts +90 -0
  96. package/src/i18n/locales/no.ts +90 -0
  97. package/src/i18n/locales/pl.ts +96 -0
  98. package/src/i18n/locales/ptBR.ts +90 -0
  99. package/src/i18n/locales/ru.ts +96 -0
  100. package/src/i18n/locales/sv.ts +90 -0
  101. package/src/i18n/locales/tr.ts +89 -0
  102. package/src/i18n/locales/zh.ts +83 -0
  103. package/src/i18n/plural.ts +62 -0
  104. package/src/i18n/types.ts +257 -0
  105. package/src/index.ts +18 -0
  106. package/src/primitives/index.ts +43 -0
  107. package/src/primitives/message.tsx +85 -0
  108. package/src/primitives/part-registry.tsx +44 -0
  109. package/src/primitives/stories-harness.tsx +118 -0
  110. package/src/primitives/suggestions.tsx +112 -0
  111. package/src/primitives/tool-call.tsx +145 -0
  112. package/src/primitives/tooltip.tsx +48 -0
  113. package/src/primitives/transcript.tsx +285 -0
  114. package/src/primitives/use-dismiss.ts +100 -0
  115. package/src/primitives/use-missed-count.ts +30 -0
  116. package/src/primitives/use-overflows.ts +69 -0
  117. package/src/primitives/use-return-on-send.ts +29 -0
  118. package/src/primitives/use-roving-focus.ts +69 -0
  119. package/src/primitives/use-select-all-transcript.ts +111 -0
  120. package/src/primitives/use-stick-to-bottom.ts +83 -0
  121. package/src/primitives/use-turn-activity.ts +25 -0
  122. package/src/store/context.ts +28 -0
  123. package/src/store/hooks.ts +215 -0
  124. package/src/store/index.ts +31 -0
  125. package/src/store/provider.tsx +97 -0
  126. package/src/store/store.ts +394 -0
  127. package/src/store/subscribers.ts +55 -0
  128. package/src/store/surfaces.ts +174 -0
  129. package/src/store/use-draft.ts +168 -0
  130. package/src/store/use-queue.ts +61 -0
  131. package/src/store/use-recall.ts +264 -0
  132. package/src/store/use-reload-on-revision.ts +67 -0
  133. package/src/store/use-speech-language.ts +84 -0
  134. package/src/store/use-voice.ts +116 -0
  135. package/src/styles/block.css +128 -0
  136. package/src/styles/composer.css +449 -0
  137. package/src/styles/index.css +47 -0
  138. package/src/styles/markdown.css +368 -0
  139. package/src/styles/surface.css +102 -0
  140. package/src/styles/tool.css +128 -0
  141. package/src/styles/turn-mark.css +97 -0
  142. package/src/testing/index.ts +15 -0
  143. package/src/testing/mock-adapter.ts +69 -0
  144. package/src/testing/mock-tools.ts +101 -0
  145. package/src/testing/mock-voice.ts +81 -0
  146. package/src/ui/chat-composer.tsx +286 -0
  147. package/src/ui/chat-reply-chip.tsx +42 -0
  148. package/src/ui/chat-suggestions.tsx +87 -0
  149. package/src/ui/chat-transcript.tsx +157 -0
  150. package/src/ui/chat.tsx +197 -0
  151. package/src/ui/collapsible-body.tsx +180 -0
  152. package/src/ui/collapsible-row.tsx +81 -0
  153. package/src/ui/day-divider.tsx +49 -0
  154. package/src/ui/index.ts +54 -0
  155. package/src/ui/jump-to-latest.tsx +60 -0
  156. package/src/ui/link-chip.tsx +73 -0
  157. package/src/ui/message-actions.tsx +163 -0
  158. package/src/ui/message-quote.tsx +47 -0
  159. package/src/ui/message.tsx +295 -0
  160. package/src/ui/pending.tsx +85 -0
  161. package/src/ui/renderers/data.tsx +63 -0
  162. package/src/ui/renderers/reasoning.tsx +73 -0
  163. package/src/ui/renderers/tool.tsx +176 -0
  164. package/src/ui/turn-mark.tsx +31 -0
  165. package/src/utils/day-heading.ts +46 -0
  166. package/src/utils/error-message.ts +10 -0
  167. package/src/utils/index.ts +12 -0
  168. package/src/utils/markdown-text.ts +175 -0
  169. package/src/widgets/index.ts +14 -0
  170. package/src/widgets/media.tsx +239 -0
@@ -0,0 +1,295 @@
1
+ import { cn } from "@djangocfg/ui-core/lib";
2
+ import { formatDayOfMonth } from "@djangocfg/widget-kit";
3
+ import { MessageQuote } from "./message-quote";
4
+
5
+ import type { ChatMessage } from "../core/parts";
6
+ import { useChatCopy } from "../i18n";
7
+ import { Message as HeadlessMessage, MessageContent as HeadlessMessageContent } from "../primitives";
8
+ import { usePreviousMessageTime } from "../store";
9
+ import { dayHeadingFor } from "../utils";
10
+ import { CollapsibleRow } from "./collapsible-row";
11
+ import { DayDivider } from "./day-divider";
12
+
13
+ /**
14
+ * The styled message row: a bubble for the user, open text for the reply.
15
+ *
16
+ * That asymmetry is the whole design, and it is Vercel's — verified against
17
+ * `@sources/vercel-ai-elements/.../message.tsx`, which bubbles `is-user` only
18
+ * and leaves `is-assistant` as bare `text-foreground`. It is right for a reason
19
+ * worth stating: a bubble is a frame, and a frame around a long reply that
20
+ * contains code, a diff and a plan competes with the content it holds. The user
21
+ * turn is short and needs to be findable while scanning; the reply is the
22
+ * product and gets the page.
23
+ *
24
+ * Role is declared ONCE, on the wrapper, as `is-user` / `is-assistant`, and
25
+ * every descendant reads it through `group-[.is-user]:`. The alternative —
26
+ * passing `from` down to each styled piece — is the shape that grows a prop per
27
+ * level, which is exactly what the 83-prop predecessor did.
28
+ *
29
+ * We differ from Vercel in three places, each measured rather than preferred:
30
+ *
31
+ * 1. **No `from` prop.** Their `<Message from={...}>` exists because their
32
+ * message is a prop; ours comes from the store and already carries `role`.
33
+ * A prop here would be a second writer for a value we hold.
34
+ * 2. **The measure is on the reply TEXT, not the container.** Long prose needs
35
+ * a line length near 72ch to stay readable, but a diff, a plan or a table
36
+ * needs the full column. Capping the container would cap the widgets too, so
37
+ * the cap lives on `MessageProse`, which a part renderer wraps around text
38
+ * and around nothing else.
39
+ * 3. **`min-w-0` + `break-words` on the bubble.** A pasted absolute path is one
40
+ * unbreakable token; without these it distends the flex column and pushes
41
+ * the transcript sideways. Users paste paths constantly.
42
+ * 4. **The bubble carries its own cap**, in `ch` — the reply's unit, so the two
43
+ * measures are comparable. `%` would re-couple it to the container and `px`
44
+ * breaks when the host resizes the chat text.
45
+ */
46
+
47
+ export interface ChatMessageViewProps {
48
+ readonly id: string;
49
+ readonly className?: string;
50
+ /**
51
+ * Rendered under the parts, inside the frame — the action row's seam.
52
+ *
53
+ * A slot rather than an `actions` prop threaded through: WHICH actions exist
54
+ * is the host's, and passing them through here would make this component a
55
+ * courier for a list it never reads. The host receives the message it is
56
+ * acting on, which is everything an action needs to be built.
57
+ */
58
+ readonly footer?: (message: ChatMessage) => React.ReactNode;
59
+ /**
60
+ * Rendered ABOVE the row, outside the frame — the speaker's name line.
61
+ *
62
+ * Above and outside deliberately: attribution is a caption on a turn, not a
63
+ * change to the turn's frame. A room needs to say who is talking; a
64
+ * one-to-one chat's speaker is the contact on screen, so its host passes
65
+ * nothing and the row is byte-identical to before this slot existed.
66
+ *
67
+ * A slot for the same reason `footer` is one: WHO a speaker is resolves
68
+ * against a roster this package does not hold, and returning null for a turn
69
+ * that wears no line is the host's call.
70
+ */
71
+ readonly header?: (message: ChatMessage) => React.ReactNode;
72
+ }
73
+
74
+ /**
75
+ * A row in the transcript, resolved from the store by id.
76
+ *
77
+ * `<Transcript>{(id) => <ChatMessageView id={id} />}</Transcript>` is the whole
78
+ * wiring. The headless `<Message>` does the lookup and the memoization; this
79
+ * adds the frame.
80
+ */
81
+ export function ChatMessageView({ id, className, footer, header }: ChatMessageViewProps) {
82
+ return (
83
+ <HeadlessMessage id={id}>
84
+ {(message, parts) => (
85
+ <>
86
+ <MessageDayDivider id={id} createdAt={message.createdAt} />
87
+ {header?.(message)}
88
+ {/* Above the row, with the speaker line: a quote says what this turn
89
+ answers, which is context for the row rather than part of it. */}
90
+ <MessageQuote message={message} />
91
+ <CollapsibleRow
92
+ message={message}
93
+ parts={parts}
94
+ className={className}
95
+ footer={footer?.(message)}
96
+ />
97
+ </>
98
+ )}
99
+ </HeadlessMessage>
100
+ );
101
+ }
102
+
103
+ /**
104
+ * The day heading above a row, when that row opens a new calendar day.
105
+ *
106
+ * Rendered from INSIDE the row rather than from the transcript's map: the list
107
+ * subscribes to ids alone so a token re-renders one component, and reading
108
+ * every message's time up there would re-render the whole list per token.
109
+ *
110
+ * The clock is read once per row and not memoized on purpose — a transcript
111
+ * open across midnight relabels on its next render, which is the honest answer
112
+ * and costs nothing next to what a timer would.
113
+ */
114
+ function MessageDayDivider({ id, createdAt }: { id: string; createdAt: number }) {
115
+ const copy = useChatCopy().transcript;
116
+ const previous = usePreviousMessageTime(id);
117
+ const heading = dayHeadingFor(createdAt, previous, Date.now());
118
+ if (heading === undefined) return null;
119
+ const label =
120
+ heading === "today"
121
+ ? copy.today
122
+ : heading === "yesterday"
123
+ ? copy.yesterday
124
+ : formatDayOfMonth(createdAt);
125
+ // `sv-SE` is ISO-8601 by locale, which is what `<time datetime>` requires —
126
+ // and it is a FORMAT choice, never the reader's locale.
127
+ const dateTime = new Date(createdAt).toLocaleDateString("sv-SE");
128
+ return <DayDivider label={label} dateTime={dateTime} />;
129
+ }
130
+
131
+ export interface ChatMessageFrameProps {
132
+ readonly message: ChatMessage;
133
+ readonly className?: string;
134
+ readonly children: React.ReactNode;
135
+ /**
136
+ * Rendered after the bubble, inside the group.
137
+ *
138
+ * Inside the group because the action row reveals on `group-hover`; outside
139
+ * the bubble because a control sitting on the user's background would read as
140
+ * part of what they said, and because the bubble's `select-text` would put a
141
+ * button's label into a copied turn.
142
+ */
143
+ readonly footer?: React.ReactNode;
144
+ /**
145
+ * The footer's opposite end — a second POSITION, not a second slot's worth of
146
+ * chrome.
147
+ *
148
+ * One position forces everything into one row in source order, so a control
149
+ * that belongs beside the actions rather than after them has nowhere to go.
150
+ * Taken from the reference's `MessageToolbar`, which is a `justify-between`
151
+ * row the host fills from either end.
152
+ *
153
+ * Absent by default, and when absent the footer keeps its own alignment
154
+ * rather than being centred by an empty sibling.
155
+ */
156
+ readonly footerEnd?: React.ReactNode;
157
+ }
158
+
159
+ /**
160
+ * The frame around already-rendered parts.
161
+ *
162
+ * Split from the lookup because a subagent's messages live inside a tool part,
163
+ * not in the store — the same reason `MessageContent` exists beside `Message`
164
+ * one layer down. A nested thread gets the same bubble without a second,
165
+ * subtly-different implementation.
166
+ *
167
+ * `system` shares the assistant's open-text treatment: it is not the user
168
+ * speaking, and giving it a third visual identity would ask the reader to learn
169
+ * one more thing for a role they see rarely.
170
+ */
171
+ export function ChatMessageFrame({
172
+ message,
173
+ className,
174
+ children,
175
+ footer,
176
+ footerEnd,
177
+ }: ChatMessageFrameProps) {
178
+ const isUser = message.role === "user";
179
+ const failed = message.status.state === "failed";
180
+ const hasToolbar = footer !== undefined || footerEnd !== undefined;
181
+ // Both ends filled: hold them apart. One end: send it to the speaker's side.
182
+ //
183
+ // The single-end case cannot be left unset. The footer does carry a
184
+ // `justify-end` of its own, but it is a 28px-wide box and justifying inside
185
+ // it moves nothing — the 768px toolbar row is what actually places it, and
186
+ // unset means `normal`, which is the left edge. So a user's copy control sat
187
+ // under the far side of the transcript from the bubble it copies.
188
+ const bothEnds = footer !== undefined && footerEnd !== undefined;
189
+ const toolbarJustify = bothEnds
190
+ ? "justify-between"
191
+ : isUser
192
+ ? "justify-end"
193
+ : "justify-start";
194
+
195
+ return (
196
+ <div
197
+ // The anchor a jump-to-message resolves. A data attribute rather than an
198
+ // `id`: ids are document-global and a transcript is not the only thing on
199
+ // the page. See `ChatSurfaces.revealMessage`.
200
+ data-message-id={message.id}
201
+ data-role={message.role}
202
+ data-status={message.status.state}
203
+ className={cn(
204
+ // `select-none` here, `select-text` on each part below. Both halves are
205
+ // load-bearing: the padding and the gap BETWEEN two messages must not
206
+ // be selectable, or a drag bridges one turn into the next and a copy
207
+ // returns two speakers fused into one block. Putting `select-text` on
208
+ // the bubble instead makes its own padding selectable and fuses them
209
+ // exactly the same way.
210
+ "group flex w-full max-w-full select-none flex-col gap-2",
211
+ isUser ? "is-user items-end" : "is-assistant items-start",
212
+ className,
213
+ )}
214
+ >
215
+ <div
216
+ className={cn(
217
+ // min-w-0 is what lets a long token wrap instead of widening the row.
218
+ //
219
+ // NO `text-sm` here, deliberately. It looks like a harmless default
220
+ // and is not: `sm` is the CONTROL scale, and setting it on the row
221
+ // breaks inheritance for everything below — so a host that sizes its
222
+ // chat surface finds the transcript ignoring it while the composer
223
+ // obeys, which is the two-scales defect the size token exists to
224
+ // prevent. Measured: surface 15px, this row 12px, in the same chat.
225
+ //
226
+ // The row inherits. A host that wants a size sets it on the surface,
227
+ // once, for both.
228
+ "flex min-w-0 max-w-full flex-col gap-2",
229
+ // Selection opts back in on the CONTENT column, and it is `*:` — every
230
+ // direct child, whatever the registry rendered — rather than a list of
231
+ // known branches. The archive's regression is the reason: for a long
232
+ // time only its markdown leaf opted in, so tool calls, their output,
233
+ // typed blocks and attachments could not be copied AT ALL. A registry
234
+ // makes that failure certain, because the parts are not ours to
235
+ // enumerate. Containment is unchanged: the column's own padding and
236
+ // the `gap-2` between parts stay dead, so a drag still cannot bridge
237
+ // two messages.
238
+ "[&>*]:select-text",
239
+ // `w-fit` keeps a short turn short, the cap bounds the long end —
240
+ // either alone gets one of the two shapes wrong. Narrower than the
241
+ // reply's 72ch, so gutter separates ask from answer at any viewport.
242
+ "group-[.is-user]:w-fit group-[.is-user]:max-w-[52ch]",
243
+ "group-[.is-user]:rounded-2xl group-[.is-user]:bg-secondary",
244
+ "group-[.is-user]:px-4 group-[.is-user]:py-2.5 group-[.is-user]:text-secondary-foreground",
245
+ "group-[.is-user]:break-words",
246
+ // The reply is not a bubble: full column, page text colour.
247
+ "group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground",
248
+ failed && "border border-destructive/40 bg-destructive/5 px-3 py-2",
249
+ )}
250
+ >
251
+ {children}
252
+ </div>
253
+ {/*
254
+ The toolbar sits in flow under the bubble, and most of the height it
255
+ takes is given back here rather than by floating it: an overlaid row
256
+ does not read as belonging to the message it acts on.
257
+
258
+ `-mb-2` (8px), NOT the toolbar's full 28px. Giving back the whole height
259
+ made the row's box end ABOVE the toolbar, so the transcript's gap was
260
+ measured from there and the toolbar overlapped the next message by 2px
261
+ wherever no speaker line stood between them — closer to the turn it does
262
+ not act on than to the one it does. 8px is the largest give-back that
263
+ keeps every toolbar nearer its own text; measured, not chosen.
264
+
265
+ `gap-2` above adds 8px between the bubble and the toolbar, which stays —
266
+ the toolbar must not touch the bubble.
267
+ */}
268
+ {hasToolbar && (
269
+ <div className={cn("-mb-2 flex w-full items-center gap-4", toolbarJustify)}>
270
+ {footer}
271
+ {footerEnd}
272
+ </div>
273
+ )}
274
+ </div>
275
+ );
276
+ }
277
+
278
+ /**
279
+ * The readable measure for model prose.
280
+ *
281
+ * ~72ch, applied by the TEXT part renderer — never by the row. A plan, a diff
282
+ * or a table registered for its own part type keeps the full column, because
283
+ * truncating a table to prose width makes it unreadable in a different way.
284
+ *
285
+ * `max-w-none` on the user side: a bubble is already narrow, and a second cap
286
+ * inside it produces a ragged short column against a wide bubble edge.
287
+ */
288
+ export function MessageProse({ children, className }: {
289
+ readonly children: React.ReactNode;
290
+ readonly className?: string;
291
+ }) {
292
+ return (
293
+ <div className={cn("max-w-[72ch] group-[.is-user]:max-w-none", className)}>{children}</div>
294
+ );
295
+ }
@@ -0,0 +1,85 @@
1
+ import { memo, useEffect, useRef, useState } from "react";
2
+
3
+ import { TurnMark } from "./turn-mark";
4
+
5
+ export interface PendingIndicatorProps {
6
+ /**
7
+ * What the wait is for — "Thinking…", say. Optional: the dots alone already
8
+ * say "something is happening", and a label is the host's copy.
9
+ */
10
+ readonly label?: string;
11
+ readonly className?: string;
12
+ /**
13
+ * Rendered beside the label once the wait passes `elapsedAfterMs`, given the
14
+ * whole seconds waited.
15
+ *
16
+ * Absent ⇒ no counter, which is the right default: a number invites watching
17
+ * it. It earns its place only in the long tail, where an agent thinking for
18
+ * forty seconds is otherwise indistinguishable from a hung one.
19
+ */
20
+ readonly elapsed?: (seconds: number) => string;
21
+ /** How long to stay quiet before the counter appears. */
22
+ readonly elapsedAfterMs?: number;
23
+ }
24
+
25
+ const DEFAULT_ELAPSED_AFTER_MS = 5_000;
26
+
27
+ /**
28
+ * The "your turn was sent, nothing has come back yet" row.
29
+ *
30
+ * Carries `TurnMark` in its waiting state — the same shape that leads the reply
31
+ * once text starts and rests under it when the turn ends. Three bouncing dots
32
+ * lived here before, and their fault was being a dead end: they said "busy",
33
+ * then vanished, so the end of an answer was marked by nothing at all.
34
+ *
35
+ * `aria-live="off"` on the row: the transcript already announces turn start and
36
+ * end through one polite region, and a second live region here would make every
37
+ * reply be announced twice.
38
+ */
39
+ function PendingIndicatorRaw({
40
+ label,
41
+ className,
42
+ elapsed,
43
+ elapsedAfterMs = DEFAULT_ELAPSED_AFTER_MS,
44
+ }: PendingIndicatorProps) {
45
+ const seconds = useElapsedSeconds(elapsed !== undefined, elapsedAfterMs);
46
+
47
+ return (
48
+ <div className={className} aria-live="off">
49
+ <TurnMark state="waiting" />
50
+ {label === undefined ? null : <span>{label}</span>}
51
+ {seconds === null || elapsed === undefined ? null : (
52
+ <span className="tabular-nums">{elapsed(seconds)}</span>
53
+ )}
54
+ </div>
55
+ );
56
+ }
57
+
58
+ export const PendingIndicator = memo(PendingIndicatorRaw);
59
+
60
+ /**
61
+ * Whole seconds since this row mounted, or `null` while under the threshold.
62
+ *
63
+ * Mount time is the start, and that is exact rather than approximate: the row
64
+ * is rendered only while the turn is pending, so it mounts when the wait begins
65
+ * and unmounts on the first part.
66
+ *
67
+ * The interval only runs when a counter was asked for — an unused indicator
68
+ * must not tick once per second forever behind a background tab.
69
+ */
70
+ function useElapsedSeconds(enabled: boolean, afterMs: number): number | null {
71
+ const startedAt = useRef<number>(0);
72
+ const [seconds, setSeconds] = useState<number | null>(null);
73
+
74
+ useEffect(() => {
75
+ if (!enabled) return;
76
+ startedAt.current = Date.now();
77
+ const id = setInterval(() => {
78
+ const waited = Date.now() - startedAt.current;
79
+ setSeconds(waited < afterMs ? null : Math.floor(waited / 1_000));
80
+ }, 1_000);
81
+ return () => clearInterval(id);
82
+ }, [enabled, afterMs]);
83
+
84
+ return seconds;
85
+ }
@@ -0,0 +1,63 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { isMediaKind, readMediaBlock } from "../../core";
4
+ import { renderMediaContent } from "../../content";
5
+ import type { PartRenderProps } from "../../primitives";
6
+ import { useCapabilities } from "../../store";
7
+ import { MediaWidget, type MediaRendererProps } from "../../widgets";
8
+
9
+ /**
10
+ * Typed blocks the agent authored.
11
+ *
12
+ * ONE renderer for every `data` part, switching on `kind`, rather than one
13
+ * registry entry per kind. The registry is keyed on `part.type`, and `kind` is
14
+ * an open set a host grows. An unknown kind renders nothing, which is the
15
+ * honest outcome: a client one build behind its backend stays silent instead
16
+ * of drawing a placeholder for content it cannot read.
17
+ *
18
+ * The parsers own the degradation. Each takes `unknown` and returns null on a
19
+ * malformed payload, so nothing here guards a shape twice.
20
+ */
21
+ export function DataRenderer({ part }: PartRenderProps) {
22
+ const capabilities = useCapabilities();
23
+ const media = capabilities.media;
24
+ if (part.type !== "data") return null;
25
+
26
+ // The eleven media kinds arrive under their OWN kind name, not a `media`
27
+ // wrapper — a host passes a built-in block through whole.
28
+ if (isMediaKind(part.kind)) {
29
+ // Absent capability ⇒ nothing renders. A media src is a URL the model chose,
30
+ // and a host that cannot say which same-origin paths are its own has no way
31
+ // to gate the request the browser would make.
32
+ if (media === undefined) return null;
33
+ const block = readMediaBlock(part.data, media.sameOriginPrefixes);
34
+ // Gated centrally, once: a rejected src leaves `readMediaBlock` as null
35
+ // rather than reaching a renderer that would draw a broken element.
36
+ if (block === null) return null;
37
+ // The capability types these `unknown` because core imports no React; this
38
+ // is the React file that narrows them.
39
+ const renderPlayer = media.renderPlayer as
40
+ | ((b: typeof block) => ReactNode)
41
+ | undefined;
42
+ // Forwarded for the same reason as the player: a picture the agent produced
43
+ // must open in the host's viewer like every other image in the app.
44
+ const renderImage = media.renderImage as MediaRendererProps["renderImage"];
45
+ // Supplied by the package, NOT by the capability beside it. The player and
46
+ // the viewer are the host's because only the host owns them; a diagram is
47
+ // drawn from the block's own text by a renderer this package depends on, so
48
+ // routing it through the capability would ask every host to opt in to a
49
+ // decision none of them can make differently. That asymmetry is deliberate:
50
+ // a capability answers "does this host have one", and here the answer
51
+ // cannot vary.
52
+ return (
53
+ <MediaWidget
54
+ block={block}
55
+ renderContent={renderMediaContent}
56
+ renderPlayer={renderPlayer}
57
+ renderImage={renderImage}
58
+ />
59
+ );
60
+ }
61
+
62
+ return null;
63
+ }
@@ -0,0 +1,73 @@
1
+ import { ChevronRight } from "lucide-react";
2
+
3
+ import { formatDurationMs } from "@djangocfg/widget-kit";
4
+
5
+ import { useChatCopy, useChatLanguage, plural } from "../../i18n";
6
+ import type { PartRenderProps } from "../../primitives";
7
+
8
+ /**
9
+ * The model's thinking, as a disclosure that states which state it is in.
10
+ *
11
+ * Three states, because the block has three genuinely different things to say
12
+ * and one label for all of them says none:
13
+ *
14
+ * - **arriving** — "Reasoning…", and open, because the thought is the only
15
+ * thing on screen and hiding it leaves the turn looking stalled;
16
+ * - **settled** — "Reasoning", collapsed, with the duration when the engine
17
+ * measured one;
18
+ * - **counted** — no words at all, so the token figure IS the summary.
19
+ *
20
+ * The last one is why this renders on a part with an empty `text`: a provider
21
+ * can ship the thinking block with its words stripped, and a block keyed on
22
+ * text alone would render nothing on the engine most likely to be running.
23
+ */
24
+ export function ReasoningRenderer({ part }: PartRenderProps) {
25
+ // Before the early return: hooks cannot be conditional, and a renderer is
26
+ // called for every part type.
27
+ const copy = useChatCopy().message;
28
+ const language = useChatLanguage();
29
+ if (part.type !== "reasoning") return null;
30
+
31
+ const streaming = part.streaming === true;
32
+ const hasText = part.text !== "";
33
+ // Nothing to disclose and nothing to count: an empty box promising a thought.
34
+ if (!hasText && part.tokens === undefined && !streaming) return null;
35
+
36
+ const summary = streaming
37
+ ? copy.reasoningLive
38
+ : hasText
39
+ ? withDuration(copy.reasoning, part.elapsedMs)
40
+ : plural(copy.reasoningTokens, part.tokens ?? 0, language);
41
+
42
+ return (
43
+ // Open while it streams: the thought is the only visible progress, and a
44
+ // collapsed one reads as a stalled turn. Sealed blocks collapse, because by
45
+ // then the answer is what the reader wants.
46
+ <details
47
+ className="chat-disclosure text-muted-foreground text-[length:var(--chat-text-small)]"
48
+ open={streaming}
49
+ >
50
+ <summary className="cursor-pointer items-center gap-1">
51
+ <span className="chat-disclosure-chevron">
52
+ <ChevronRight className="size-3.5" aria-hidden />
53
+ </span>
54
+ {summary}
55
+ </summary>
56
+ {hasText ? <div className="pt-1 whitespace-pre-wrap">{part.text}</div> : null}
57
+ </details>
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Append the measured duration, when there is one.
63
+ *
64
+ * Formatting is `lib-utils`' `formatDurationMs`, not a local rule: that module
65
+ * records why one spelling matters — a formatter per surface makes the same
66
+ * fact read differently depending on which pane is open. An absent or
67
+ * non-positive measurement leaves the label alone rather than printing a zero,
68
+ * which would claim the model thought for no time at all.
69
+ */
70
+ function withDuration(label: string, elapsedMs: number | undefined): string {
71
+ if (elapsedMs === undefined || elapsedMs <= 0) return label;
72
+ return `${label} · ${formatDurationMs(elapsedMs)}`;
73
+ }