@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,394 @@
1
+ /**
2
+ * The store: transcript state plus the turn lifecycle.
3
+ *
4
+ * Hand-rolled on `useSyncExternalStore` rather than pulled from zustand — the
5
+ * whole surface is five methods and a subscription, and the package's point is
6
+ * to be dependency-light enough that a consumer can read it end to end. The
7
+ * core reducer already owns every state transition; this only owns WHEN they
8
+ * run and WHO is told.
9
+ *
10
+ * Per-message subscription is the load-bearing part. A transcript that
11
+ * re-renders every row on every token is the difference between a chat that
12
+ * feels instant and one that stutters at fifty messages, and it cannot be
13
+ * retrofitted — it decides the shape of the selectors.
14
+ */
15
+
16
+ import type { ChatAdapter } from "../core/adapter";
17
+ import type { StreamChunk } from "../core/chunks";
18
+ import type { ChatMessage, MessageStatus } from "../core/parts";
19
+ import { buildOutbound, isEmpty, type Draft } from "../core/outbound";
20
+ import {
21
+ dequeue,
22
+ editQueued,
23
+ emptyQueue,
24
+ enqueue,
25
+ promoteQueued,
26
+ removeQueued,
27
+ type QueueState,
28
+ } from "../core/queue";
29
+ import {
30
+ appendUserMessage,
31
+ emptyTranscript,
32
+ reduce,
33
+ selectMessages,
34
+ type TranscriptState,
35
+ } from "../core/transcript";
36
+ import { messageOf } from "../utils";
37
+ import { createSubscribers, type Listener } from "./subscribers";
38
+
39
+ export interface ChatState {
40
+ readonly transcript: TranscriptState;
41
+ /** Prompts written while a turn was in flight, oldest first. */
42
+ readonly queue: QueueState;
43
+ /** Non-null while a turn is in flight; aborting it is the only way to stop. */
44
+ readonly inFlight: AbortController | null;
45
+ /**
46
+ * Whether the host's history fetch has answered.
47
+ *
48
+ * Starts `false`: a store with no messages and no answer yet is LOADING, and
49
+ * defaulting the other way is what let the empty state paint during a fetch.
50
+ * A host that never seeds history calls `setHistoryLoaded(true)` once.
51
+ */
52
+ readonly historyLoaded: boolean;
53
+ }
54
+
55
+
56
+ export interface ChatStore {
57
+ getState(): ChatState;
58
+ subscribe(listener: Listener): () => void;
59
+ /** Notified only when THIS message changes — the reason rows do not re-render. */
60
+ subscribeMessage(id: string, listener: Listener): () => void;
61
+ send(draft: Draft): Promise<void>;
62
+ /** Queue explicitly. `send` already queues when a turn is in flight. */
63
+ enqueue(draft: Draft): void;
64
+ removeQueued(id: string): void;
65
+ /** Replace a queued draft; editing it to empty removes the entry. */
66
+ editQueued(id: string, draft: Draft): void;
67
+ /**
68
+ * Take a queued prompt back out for editing — the ArrowUp affordance.
69
+ *
70
+ * REMOVES it, unlike recalling a sent prompt, which copies. A sent turn
71
+ * already happened and rewriting it is a different, explicit operation; a
72
+ * queued prompt has not been seen by anyone, so leaving a copy behind would
73
+ * send it twice — once as the entry still in the queue and once as the edit.
74
+ *
75
+ * Returns the draft so the caller can put it in the buffer, or `undefined` for
76
+ * an unknown id.
77
+ */
78
+ takeQueued(id: string): Draft | undefined;
79
+ /**
80
+ * Cut the current turn short and release ONE queued prompt immediately.
81
+ *
82
+ * Named by row, because the button sits beside a row and the drain always
83
+ * takes the head — so this promotes first. See `promoteQueued`.
84
+ *
85
+ * The prompt is not sent here. `stop()` aborts, and the turn's own `finally`
86
+ * releases the head; one owner for "what goes next" is what stops a forced
87
+ * release and a natural one from drifting. That block normally refuses to
88
+ * drain after an abort — deliberately, so Stop cannot start something — and
89
+ * this is the one intent that overrides it.
90
+ */
91
+ sendQueuedNow(id: string): void;
92
+ stop(): void;
93
+ /**
94
+ * Abandon what belongs to the room being left: aborts the in-flight turn and
95
+ * drops the queue. A turn and a queued prompt belong to the room they were
96
+ * written in, so carrying either across a switch streams a reply into a
97
+ * conversation that never asked for it. NOT the transcript — the caller
98
+ * reseeds immediately. `stop()` is not a substitute: it leaves the queue,
99
+ * which then drains into the wrong room.
100
+ */
101
+ leaveRoom(): void;
102
+ /** Seed from history. Replaces the transcript wholesale; not a merge. */
103
+ load(messages: readonly ChatMessage[]): void;
104
+ /**
105
+ * Whether the host's history fetch has answered.
106
+ *
107
+ * An empty transcript means two different things — a room with no messages,
108
+ * and a room whose messages are still in flight — and only the host knows
109
+ * which. Without this the empty state showed for the duration of every cold
110
+ * fetch: measured ~100ms on a room that then rendered 90 replies.
111
+ */
112
+ setHistoryLoaded(loaded: boolean): void;
113
+ }
114
+
115
+ export interface StoreOptions {
116
+ readonly adapter: ChatAdapter;
117
+ /** Injected so tests stay deterministic and history replay keeps real stamps. */
118
+ readonly makeId: () => string;
119
+ readonly now: () => number;
120
+ }
121
+
122
+ export function createChatStore(options: StoreOptions): ChatStore {
123
+ let state: ChatState = {
124
+ transcript: emptyTranscript,
125
+ queue: emptyQueue,
126
+ inFlight: null,
127
+ historyLoaded: false,
128
+ };
129
+ /**
130
+ * Set by `sendQueuedNow` so the abort it triggers still drains.
131
+ *
132
+ * NOT part of `ChatState`: it lives only between an abort and the `finally`
133
+ * that reads it, nothing renders from it, and putting it in the state would
134
+ * wake every subscriber for a flag none of them can see.
135
+ *
136
+ * Cleared where it is read, so an ordinary Stop that follows can never inherit
137
+ * a release the user did not ask for.
138
+ */
139
+ let releaseAfterAbort = false;
140
+ const { notify, subscribe, subscribeMessage } = createSubscribers();
141
+
142
+ function apply(next: ChatState, changed: Iterable<string>): void {
143
+ state = next;
144
+ notify(changed);
145
+ }
146
+
147
+ function applyChunk(chunk: StreamChunk): void {
148
+ const before = state.transcript;
149
+ const transcript = reduce(before, chunk);
150
+ if (transcript === before) return;
151
+
152
+ // Only the streaming message can change, so that is the only row to wake.
153
+ // Waking all of them here is precisely the stutter this store exists to
154
+ // avoid, and it would go unnoticed until the transcript got long.
155
+ const touched = transcript.streamingId ?? before.streamingId;
156
+ apply({ ...state, transcript }, touched === null ? [] : [touched]);
157
+ }
158
+
159
+ /**
160
+ * End a turn that failed, whether or not the assistant ever opened a message.
161
+ *
162
+ * The subtle case this exists for: `sendMessage()` rejecting BEFORE any
163
+ * `message-start`. Routing that through the reducer would be a no-op — there
164
+ * is no message to mark — so a connection refused, a 500, or a bad auth token
165
+ * left the transcript looking as if nothing had been sent. Silent failure is
166
+ * the worst outcome of the three; a visible failed turn is the point.
167
+ */
168
+ function fail(status: MessageStatus, userMessageId: string): void {
169
+ const streaming = state.transcript.streamingId;
170
+ if (streaming !== null) {
171
+ const message = state.transcript.byId[streaming];
172
+ if (message !== undefined) {
173
+ apply(
174
+ {
175
+ ...state,
176
+ transcript: {
177
+ ...state.transcript,
178
+ byId: { ...state.transcript.byId, [streaming]: { ...message, status } },
179
+ streamingId: null,
180
+ },
181
+ },
182
+ [streaming],
183
+ );
184
+ return;
185
+ }
186
+ }
187
+
188
+ // No assistant message exists, so the user's own turn carries the verdict.
189
+ const user = state.transcript.byId[userMessageId];
190
+ if (user === undefined) return;
191
+ apply(
192
+ {
193
+ ...state,
194
+ transcript: {
195
+ ...state.transcript,
196
+ byId: { ...state.transcript.byId, [userMessageId]: { ...user, status } },
197
+ },
198
+ },
199
+ [userMessageId],
200
+ );
201
+ }
202
+
203
+ const store: ChatStore = {
204
+ getState: () => state,
205
+
206
+ subscribe,
207
+ subscribeMessage,
208
+
209
+ setHistoryLoaded(loaded) {
210
+ if (state.historyLoaded === loaded) return;
211
+ apply({ ...state, historyLoaded: loaded }, []);
212
+ },
213
+
214
+ load(messages) {
215
+ const byId: Record<string, ChatMessage> = {};
216
+ for (const message of messages) byId[message.id] = message;
217
+ apply(
218
+ {
219
+ ...state,
220
+ transcript: { ids: messages.map((m) => m.id), byId, streamingId: null },
221
+ },
222
+ messages.map((m) => m.id),
223
+ );
224
+ },
225
+
226
+ stop() {
227
+ state.inFlight?.abort();
228
+ },
229
+
230
+ leaveRoom() {
231
+ // Both effects are synchronous and independent: `abort()` only signals,
232
+ // and the turn's `finally` runs a microtask later, by which time the queue
233
+ // is already empty. The drain is refused there anyway — `releaseAfterAbort`
234
+ // stays false, so leaving a room can never SEND one of its queued prompts.
235
+ if (state.queue !== emptyQueue) apply({ ...state, queue: emptyQueue }, []);
236
+ state.inFlight?.abort();
237
+ },
238
+
239
+ async send(draft) {
240
+ // Guarded here rather than in the composer so that every entry point —
241
+ // keyboard, button, a host calling `send` directly — obeys it.
242
+ if (isEmpty(draft)) return;
243
+
244
+ // One turn at a time: two streams into one message cannot be untangled,
245
+ // because no chunk carries enough identity to say which it belongs to.
246
+ //
247
+ // But a turn already in flight now QUEUES rather than returning. The bare
248
+ // `return` here discarded the prompt in silence — no error, no toast, the
249
+ // text simply gone — at exactly the moment our minutes-long agents make a
250
+ // user most likely to add something. The queue drains itself in `finally`.
251
+ if (state.inFlight !== null) {
252
+ apply({ ...state, queue: enqueue(state.queue, draft, options.makeId()) }, []);
253
+ return;
254
+ }
255
+
256
+ const outbound = buildOutbound(draft, options.makeId(), options.now());
257
+ const controller = new AbortController();
258
+
259
+ // Captured BEFORE the user's message is appended. `history` means prior
260
+ // turns; including the turn whose text rides beside it makes an adapter
261
+ // that concatenates the two send the message twice.
262
+ const history = selectMessages(state.transcript);
263
+
264
+ // The user's turn is authored, not streamed — it goes in whole. The
265
+ // assistant's message is opened later by the adapter's `message-start`,
266
+ // because only the adapter knows its id.
267
+ apply(
268
+ {
269
+ ...state,
270
+ transcript: appendUserMessage(state.transcript, outbound.message),
271
+ inFlight: controller,
272
+ },
273
+ [outbound.message.id],
274
+ );
275
+
276
+ try {
277
+ const stream = await options.adapter.sendMessage({
278
+ text: outbound.text,
279
+ history,
280
+ ...(draft.replyTo ? { replyTo: draft.replyTo } : {}),
281
+ signal: controller.signal,
282
+ });
283
+ const reader = stream.getReader();
284
+ // The loop races each read against the abort signal rather than
285
+ // checking it between reads. A transport that ignores the signal parks
286
+ // in `read()` forever, and a between-reads check never runs again — so
287
+ // Stop would look dead precisely on the stalled turn it exists for.
288
+ for (;;) {
289
+ const next = await Promise.race([reader.read(), aborted(controller.signal)]);
290
+ if (next === ABORTED) {
291
+ void reader.cancel().catch(() => undefined);
292
+ break;
293
+ }
294
+ if (next.done) break;
295
+ applyChunk(next.value);
296
+ }
297
+ // A stream that ends without a terminal frame is an interruption, not a
298
+ // completion: the reply may still have been persisted server-side, so
299
+ // the host reconciles rather than showing a permanent error. Routed
300
+ // through `fail` so a stream that closed before `message-start` is
301
+ // still visible rather than silently doing nothing.
302
+ if (state.transcript.streamingId !== null || controller.signal.aborted) {
303
+ fail({ state: "interrupted" }, outbound.message.id);
304
+ }
305
+ } catch (error) {
306
+ fail(
307
+ controller.signal.aborted
308
+ ? { state: "interrupted" }
309
+ : { state: "failed", message: messageOf(error) },
310
+ outbound.message.id,
311
+ );
312
+ } finally {
313
+ apply({ ...state, inFlight: null }, []);
314
+
315
+ // Drain the next queued prompt — unattended, because a queue that
316
+ // needed a "go" button would be a to-do list, not a queue.
317
+ //
318
+ // NOT after an abort. The user pressed Stop; sending the next prompt
319
+ // would make Stop start something, which is the opposite of what the
320
+ // control says. Their queue survives for when they choose to continue.
321
+ // Read and cleared together: a forced release is spent by the abort it
322
+ // was set for, so a later plain Stop cannot inherit it.
323
+ const forced = releaseAfterAbort;
324
+ releaseAfterAbort = false;
325
+
326
+ if (!controller.signal.aborted || forced) {
327
+ const { next, rest } = dequeue(state.queue);
328
+ if (next !== null) {
329
+ apply({ ...state, queue: rest }, []);
330
+ // `store.send`, never `this.send`: every consumer destructures
331
+ // (`const { send } = useChatStore()`), and a `this` that arrives
332
+ // undefined would throw here — inside a `finally`, where it would
333
+ // mask whatever the turn was actually doing.
334
+ //
335
+ // `void`: this send owns its own errors, and awaiting it would chain
336
+ // every queued turn onto the first one's promise, so an abort deep in
337
+ // the chain would settle a caller that is long gone.
338
+ void store.send(next.draft);
339
+ }
340
+ }
341
+ }
342
+ },
343
+
344
+ enqueue(draft) {
345
+ if (isEmpty(draft)) return;
346
+ apply({ ...state, queue: enqueue(state.queue, draft, options.makeId()) }, []);
347
+ },
348
+
349
+ removeQueued(id) {
350
+ apply({ ...state, queue: removeQueued(state.queue, id) }, []);
351
+ },
352
+
353
+ editQueued(id, draft) {
354
+ apply({ ...state, queue: editQueued(state.queue, id, draft) }, []);
355
+ },
356
+
357
+ takeQueued(id) {
358
+ const chosen = state.queue.items.find((item) => item.id === id);
359
+ if (chosen === undefined) return undefined;
360
+ apply({ ...state, queue: removeQueued(state.queue, id) }, []);
361
+ return chosen.draft;
362
+ },
363
+
364
+ sendQueuedNow(id) {
365
+ // Nothing in flight: the queue is not waiting on anything, so there is no
366
+ // turn to cut short and the abort path would do nothing at all. Release it
367
+ // directly instead of leaving the button dead.
368
+ if (state.inFlight === null) {
369
+ const taken = store.takeQueued(id);
370
+ if (taken !== undefined) void store.send(taken);
371
+ return;
372
+ }
373
+ // Promote BEFORE aborting: the drain takes the head, and the abort's
374
+ // `finally` can run as soon as the signal fires.
375
+ const promoted = promoteQueued(state.queue, id);
376
+ if (promoted === state.queue) return;
377
+ apply({ ...state, queue: promoted }, []);
378
+ releaseAfterAbort = true;
379
+ state.inFlight.abort();
380
+ },
381
+ };
382
+
383
+ return store;
384
+ }
385
+
386
+ /** Sentinel for "the signal won the race", distinguishable from a read result. */
387
+ const ABORTED = Symbol("aborted");
388
+
389
+ function aborted(signal: AbortSignal): Promise<typeof ABORTED> {
390
+ if (signal.aborted) return Promise.resolve(ABORTED);
391
+ return new Promise((resolve) => {
392
+ signal.addEventListener("abort", () => resolve(ABORTED), { once: true });
393
+ });
394
+ }
@@ -0,0 +1,55 @@
1
+ export type Listener = () => void;
2
+
3
+ /**
4
+ * Who gets told, and how narrowly.
5
+ *
6
+ * Split out of `store.ts` because it is the one part that touches no state at
7
+ * all: it holds two sets of callbacks and decides which of them to wake. The
8
+ * store owns WHEN a transition happens; this owns WHO hears about it.
9
+ *
10
+ * Per-message subscription is the load-bearing part, not an optimisation. A
11
+ * transcript that re-renders every row on every token is the difference between
12
+ * a chat that feels instant and one that stutters at fifty messages — and it
13
+ * cannot be retrofitted, because it decides the shape of the selectors.
14
+ */
15
+ export interface Subscribers {
16
+ /** Wake the whole-store listeners, plus the rows named in `changed`. */
17
+ readonly notify: (changed: Iterable<string>) => void;
18
+ /** Subscribe to any change. Returns the unsubscribe. */
19
+ readonly subscribe: (listener: Listener) => () => void;
20
+ /** Subscribe to ONE message's changes. Returns the unsubscribe. */
21
+ readonly subscribeMessage: (id: string, listener: Listener) => () => void;
22
+ }
23
+
24
+ export function createSubscribers(): Subscribers {
25
+ const listeners = new Set<Listener>();
26
+ const perMessage = new Map<string, Set<Listener>>();
27
+
28
+ return {
29
+ notify(changed) {
30
+ for (const listener of listeners) listener();
31
+ for (const id of changed) {
32
+ const set = perMessage.get(id);
33
+ if (set === undefined) continue;
34
+ for (const listener of set) listener();
35
+ }
36
+ },
37
+
38
+ subscribe(listener) {
39
+ listeners.add(listener);
40
+ return () => listeners.delete(listener);
41
+ },
42
+
43
+ subscribeMessage(id, listener) {
44
+ const set = perMessage.get(id) ?? new Set<Listener>();
45
+ set.add(listener);
46
+ perMessage.set(id, set);
47
+ return () => {
48
+ set.delete(listener);
49
+ // Dropped when the last listener for that id leaves, so a long session
50
+ // does not accumulate an empty Set per message ever rendered.
51
+ if (set.size === 0) perMessage.delete(id);
52
+ };
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,174 @@
1
+ import { createContext, useCallback, useContext, useRef } from "react";
2
+
3
+ /**
4
+ * Where the chat's two surfaces are in the DOM.
5
+ *
6
+ * Separate from the draft context on purpose: that one answers "what has been
7
+ * typed", this one "where is it being typed". A host needs the second to put
8
+ * the caret back after a modal closes, and to scope ⌘A to the conversation.
9
+ *
10
+ * Registration, not a ref prop. Threading refs out would put a prop on
11
+ * `Composer` and `Transcript` for one host's benefit, and both are deliberately
12
+ * narrow. The surfaces know where they are; this is how they say so.
13
+ *
14
+ * THE ALTERNATIVE THIS REPLACES, and why it had to go: the host resolved
15
+ * both by `document.querySelector` — the transcript by the class
16
+ * `.chat-surface`, the field by `textarea[aria-label="…"]` built from a
17
+ * TRANSLATED string. So renaming a layout class or retranslating "Message"
18
+ * silently broke focus, in one locale, with every test green.
19
+ */
20
+ export interface ChatSurfaces {
21
+ /** Put the caret in the composer, at the end of the draft. */
22
+ focusComposer(): void;
23
+ /** The scrolling transcript, for a selection scoped to the conversation. */
24
+ transcriptNode(): HTMLElement | null;
25
+ /**
26
+ * Append a mention to the draft and put the caret after it.
27
+ *
28
+ * Published by the composer: the draft lives in its state, so a transcript
29
+ * calling `useDraft` would write into one nothing sends.
30
+ * False when no composer published a handler — omit the affordance then.
31
+ */
32
+ mention(token: string): boolean;
33
+ /**
34
+ * Aim the draft at a message, or `undefined` to clear it.
35
+ *
36
+ * Separate from `mention`: quoting and addressing are different acts, and the
37
+ * contact card mentions without answering anything. False when no composer is
38
+ * listening.
39
+ */
40
+ replyTo(messageId: string | undefined): boolean;
41
+ /**
42
+ * Scroll a message into view and mark it briefly, so the eye can find it.
43
+ *
44
+ * False when the transcript has not mounted or the message is not in the
45
+ * loaded page — history is paged, so that is an ordinary answer and a caller
46
+ * can fall back (fetch, or say so) instead of scrolling nowhere.
47
+ */
48
+ revealMessage(messageId: string): boolean;
49
+ /** Called by the surfaces themselves. Not for hosts. */
50
+ registerComposer(node: HTMLFormElement | null): void;
51
+ registerTranscript(node: HTMLElement | null): void;
52
+ registerMention(handler: ((token: string) => void) | null): void;
53
+ registerReplyTo(handler: ((messageId: string | undefined) => void) | null): void;
54
+ }
55
+
56
+ export const ChatSurfacesContext = createContext<ChatSurfaces | null>(null);
57
+
58
+ /**
59
+ * The two surfaces, once they exist.
60
+ *
61
+ * Returns null outside a provider rather than throwing: a host asking where the
62
+ * composer is before one has mounted is the normal opening state, not an error.
63
+ */
64
+ export function useChatSurfaces(): ChatSurfaces | null {
65
+ return useContext(ChatSurfacesContext);
66
+ }
67
+
68
+ /** Builds the registry the provider hands down. Called once, at the root. */
69
+ export function useSurfacesRegistry(): ChatSurfaces {
70
+ const composer = useRef<HTMLFormElement | null>(null);
71
+ const transcript = useRef<HTMLElement | null>(null);
72
+ const mentionHandler = useRef<((token: string) => void) | null>(null);
73
+ const replyToHandler = useRef<((messageId: string | undefined) => void) | null>(null);
74
+
75
+ // Caret at the END: focus returning to a written draft means "keep typing",
76
+ // and position zero would type into the front of it.
77
+ const caretToEnd = useCallback(() => {
78
+ const field = composer.current?.querySelector("textarea");
79
+ if (!field) return false;
80
+ field.focus();
81
+ const end = field.value.length;
82
+ field.setSelectionRange(end, end);
83
+ return true;
84
+ }, []);
85
+
86
+ const focusComposer = useCallback(() => {
87
+ // Retried on the next frame when the composer is not mounted YET, which is
88
+ // the normal case rather than an error: the common caller is a modal
89
+ // handing the caret back as it closes, and the composer it is handing back
90
+ // to only re-appears on the render that follows.
91
+ if (!caretToEnd()) requestAnimationFrame(caretToEnd);
92
+ }, [caretToEnd]);
93
+
94
+ const transcriptNode = useCallback(() => transcript.current, []);
95
+
96
+ const registerComposer = useCallback((node: HTMLFormElement | null) => {
97
+ composer.current = node;
98
+ }, []);
99
+
100
+ const registerTranscript = useCallback((node: HTMLElement | null) => {
101
+ transcript.current = node;
102
+ }, []);
103
+
104
+ const registerMention = useCallback((handler: ((token: string) => void) | null) => {
105
+ mentionHandler.current = handler;
106
+ }, []);
107
+
108
+ const mention = useCallback(
109
+ (token: string) => {
110
+ if (mentionHandler.current === null) return false;
111
+ mentionHandler.current(token);
112
+ // Next frame, or `value.length` measures the draft before the append.
113
+ // Caret, never a selection: a mention is added in order to keep typing.
114
+ requestAnimationFrame(caretToEnd);
115
+ return true;
116
+ },
117
+ [caretToEnd],
118
+ );
119
+
120
+ const revealMessage = useCallback((messageId: string) => {
121
+ const root = transcript.current;
122
+ if (root === null) return false;
123
+ const target = root.querySelector<HTMLElement>(
124
+ `[data-message-id="${CSS.escape(messageId)}"]`,
125
+ );
126
+ if (target === null) return false;
127
+ target.scrollIntoView({ block: "center", behavior: "smooth" });
128
+ // A scroll alone lands the reader mid-transcript with nothing saying which
129
+ // row was meant. The class is removed on the way out so a second jump to the
130
+ // same row flashes again.
131
+ target.classList.remove("chat-revealed");
132
+ void target.offsetWidth;
133
+ target.classList.add("chat-revealed");
134
+ return true;
135
+ }, []);
136
+
137
+ const registerReplyTo = useCallback(
138
+ (handler: ((messageId: string | undefined) => void) | null) => {
139
+ replyToHandler.current = handler;
140
+ },
141
+ [],
142
+ );
143
+
144
+ const replyTo = useCallback(
145
+ (messageId: string | undefined) => {
146
+ if (replyToHandler.current === null) return false;
147
+ replyToHandler.current(messageId);
148
+ // Focus only when AIMING: clearing is a dismissal, and stealing the caret
149
+ // back would fight a reader who just put it somewhere else.
150
+ if (messageId !== undefined) requestAnimationFrame(caretToEnd);
151
+ return true;
152
+ },
153
+ [caretToEnd],
154
+ );
155
+
156
+ // A ref rather than a memo: the identity is load-bearing (a host may hold
157
+ // `focusComposer` in a dependency array), and `useMemo` is a hint React may
158
+ // discard. The callbacks above are already stable, so this never restages.
159
+ const api = useRef<ChatSurfaces | null>(null);
160
+ if (api.current === null) {
161
+ api.current = {
162
+ focusComposer,
163
+ transcriptNode,
164
+ mention,
165
+ replyTo,
166
+ revealMessage,
167
+ registerComposer,
168
+ registerTranscript,
169
+ registerMention,
170
+ registerReplyTo,
171
+ };
172
+ }
173
+ return api.current;
174
+ }