@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,285 @@
1
+ import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2
+
3
+ import {
4
+ useChatSurfaces,
5
+ useHistoryLoaded,
6
+ useMessage,
7
+ useMessageIds,
8
+ useTurn,
9
+ } from "../store";
10
+ import { useMissedCount } from "./use-missed-count";
11
+ import { useReturnOnSend } from "./use-return-on-send";
12
+ import { useRovingFocus } from "./use-roving-focus";
13
+ import { useStickToBottom } from "./use-stick-to-bottom";
14
+ import { useTurnActivity, type TurnActivity } from "./use-turn-activity";
15
+
16
+ export interface TranscriptProps {
17
+ /** Render one message. Called with the id; the row reads the message itself. */
18
+ readonly children: (id: string) => ReactNode;
19
+ /** Shown when there are no messages — an empty transcript is a real state. */
20
+ readonly empty?: ReactNode;
21
+ readonly className?: string;
22
+ /**
23
+ * The accessible name of the log. Copy is the host's — no locales here.
24
+ *
25
+ * Without it `role="log"` is an unnamed landmark: a screen reader user moving
26
+ * between regions hears "log" with nothing to distinguish it.
27
+ */
28
+ readonly label?: string;
29
+ /**
30
+ * What to say when a reply starts and when it ends — the ONLY two strings a
31
+ * screen reader hears per turn.
32
+ *
33
+ * Absent means no announcements at all, which is the correct default for a
34
+ * host that already announces elsewhere: a second live region would make
35
+ * every reply be read twice.
36
+ */
37
+ readonly announce?: {
38
+ readonly streaming: string;
39
+ readonly complete: string;
40
+ };
41
+ /**
42
+ * Rendered when the user has scrolled away from the bottom, with a callback
43
+ * that returns them to it.
44
+ *
45
+ * A render prop rather than a built-in button, because the control is styled
46
+ * and worded by the host — and because a transcript that decides its own
47
+ * chrome is how a primitive turns back into 83 props.
48
+ *
49
+ * `missed` is how many replies arrived since the reader left, or **null when
50
+ * there is nothing honest to show** — a fresh unpin, or a conversation opened
51
+ * already scrolled up. Null and 0 are different states; only a real arrival
52
+ * deserves a number, so a host must render no badge rather than an empty one.
53
+ */
54
+ readonly jumpToLatest?: (props: {
55
+ readonly onClick: () => void;
56
+ readonly missed: number | null;
57
+ }) => ReactNode;
58
+ /**
59
+ * Rendered after the last row for as long as a turn is in flight, given what
60
+ * that turn is currently doing.
61
+ *
62
+ * `waiting` — sent, nothing back yet. `working` — text is arriving. It ends at
63
+ * turn-end, so a host can carry one indicator across both phases instead of
64
+ * showing something during the silence and nothing during the answer.
65
+ *
66
+ * In the transcript rather than beside the composer because that is where the
67
+ * answer will appear, so the promise of one belongs there too. It also rides
68
+ * inside the scroll container, which is what makes the autoscroll treat it as
69
+ * new content and keep the view pinned to the bottom.
70
+ *
71
+ * Never a message: it has no id and never enters the store, so it cannot leak
72
+ * into stored history as a message nobody sent.
73
+ *
74
+ * ONE slot rather than one per phase, and ONE owner rather than a decision
75
+ * repeated in every row: a message that reports its own completion reports it
76
+ * forever, so the indicator stacks up once per reply on a single screen.
77
+ *
78
+ * A render prop, like `jumpToLatest`, because the copy and the chrome are the
79
+ * host's — this package carries no locales.
80
+ */
81
+ readonly pending?: (activity: TurnActivity) => ReactNode;
82
+ }
83
+
84
+ /**
85
+ * The message list.
86
+ *
87
+ * Not virtualized, deliberately. Virtualization fights streaming (heights
88
+ * change on every token), autoscroll, and find-in-page, and the transcripts
89
+ * a chat room shows are hundreds of messages, not tens of thousands. The
90
+ * measurement comes first: if a real transcript stutters, that is when this
91
+ * decision gets revisited — `@rules/architecture.md`.
92
+ */
93
+ export function Transcript({
94
+ children,
95
+ empty,
96
+ className,
97
+ label,
98
+ announce,
99
+ jumpToLatest,
100
+ pending,
101
+ }: TranscriptProps) {
102
+ const ids = useMessageIds();
103
+ const historyLoaded = useHistoryLoaded();
104
+ const activity = useTurnActivity();
105
+ const { ref, contentRef, onScroll, isPinned, scrollToBottom } = useStickToBottom(ids.length);
106
+ const missed = useMissedCount(ids, isPinned);
107
+ useReturnOnSend(ids, scrollToBottom);
108
+ const roving = useRovingFocus(ids.length);
109
+ const transition = useTurnAnnouncement(announce);
110
+ const waiting = activity !== null && pending !== undefined ? pending(activity) : null;
111
+ const surfaces = useChatSurfaces();
112
+ // Two owners of one node: the scroll machinery needs it, and a host needs it
113
+ // to scope a selection to the conversation. Both callbacks, so this composes
114
+ // without either learning about the other.
115
+ const attach = useCallback(
116
+ (node: HTMLDivElement | null) => {
117
+ ref(node);
118
+ surfaces?.registerTranscript(node);
119
+ },
120
+ [ref, surfaces],
121
+ );
122
+
123
+ // Two things suppress the empty state, both because it would be a LIE.
124
+ //
125
+ // `waiting`: a first turn sent into a fresh room is not an empty transcript,
126
+ // and "no messages yet" under a prompt the user just sent says the opposite
127
+ // of what happened.
128
+ //
129
+ // `historyLoaded`: an unanswered fetch is not an empty room. Without it a
130
+ // conversation with 90 replies opened on "no messages yet" for the duration
131
+ // of the load.
132
+ if (ids.length === 0 && empty !== undefined && waiting === null && historyLoaded) {
133
+ return <div className={className}>{empty}</div>;
134
+ }
135
+
136
+ return (
137
+ <>
138
+ {/*
139
+ Three elements: outer clips, scroller fills it, content grows.
140
+
141
+ Merging the outer two paints the scroller from its first frame, which is
142
+ at the TOP — the library cannot know the bottom before it measures. Cost
143
+ a ~33ms flash of old history on entering every room.
144
+ */}
145
+ <div className={`grid overflow-hidden ${className ?? ""}`}>
146
+ <div
147
+ ref={attach}
148
+ onScroll={onScroll}
149
+ onKeyDown={roving.onKeyDown}
150
+ className="h-full overflow-y-auto"
151
+ // `role="log"` names what this is and implies `aria-relevant="additions"`
152
+ // — additions only, never text mutations.
153
+ //
154
+ // Never spell `aria-relevant="additions text"` here: `text` asks the
155
+ // screen reader to announce changes to text NODES inside the region,
156
+ // and a streamed reply rewrites its text node on every delta, so the
157
+ // whole answer is read out token by token. None of the surveyed
158
+ // libraries sets `aria-relevant` at all — the announcement belongs on a
159
+ // separate node, below.
160
+ role="log"
161
+ aria-label={label}
162
+ aria-live="polite"
163
+ >
164
+ {/*
165
+ The measured element, and the one the reading column now caps.
166
+
167
+ The library needs a child whose height IS the content's, separate from
168
+ the scroller whose height is the viewport's — that difference is what
169
+ it observes. `chat-column` moves here with it: the rule caps direct
170
+ children, and the rows are this element's children now.
171
+ */}
172
+ {/* `pb-10` on the CONTENT, not the scroller: it must be part of the
173
+ measured height, or the follow stops a gap short of the composer.
174
+ The right inset keeps text off the scrollbar, which runs at the
175
+ pane's own edge; the host sizes it, so a phone can widen it to match
176
+ its other margins. */}
177
+ {/* The rhythm between messages is declared HERE, on the element that
178
+ owns the rows. Without it the only thing parting two rows is
179
+ whatever they happen to render: a speaker line is omitted for a
180
+ consecutive same-author turn, so those pairs sat flush while their
181
+ neighbours kept the line's 28px. Spacing that depends on optional
182
+ content is not spacing. */}
183
+ <div ref={contentRef} className="chat-column chat-inset-r flex flex-col gap-4 pt-4 pb-10">
184
+ {ids.map((id, index) => (
185
+ <div key={id} {...roving.itemProps(index)}>
186
+ <MessageRow id={id}>{children}</MessageRow>
187
+ </div>
188
+ ))}
189
+ {waiting}
190
+ </div>
191
+ </div>
192
+ </div>
193
+ {/*
194
+ Anchored to the SCROLLER's bottom, not the chat's: the chat's last grid
195
+ row is the composer, so a control positioned against it lands on top of
196
+ the input. `sticky` inside the scroll container would ride the content;
197
+ this sits in a zero-height strip at the scroller's own lower edge.
198
+
199
+ `pointer-events-none` on the strip so it never eats a click aimed at the
200
+ last message; the control inside opts back in.
201
+ */}
202
+ {!isPinned && jumpToLatest !== undefined && (
203
+ <div className="pointer-events-none relative h-0 [&>*]:pointer-events-auto">
204
+ {jumpToLatest({ onClick: scrollToBottom, missed })}
205
+ </div>
206
+ )}
207
+ <TurnAnnouncer text={transition} />
208
+ </>
209
+ );
210
+ }
211
+
212
+ /**
213
+ * The two words a screen reader hears per turn.
214
+ *
215
+ * Only TRANSITIONS — never the streamed content — so this can never spam.
216
+ * `role="status"` is an implicit polite, atomic live region: atomic means the
217
+ * whole node is re-read on change, which is right for a one-line status and
218
+ * wrong for a transcript.
219
+ *
220
+ * Always rendered, even when empty: a live region created at the moment of the
221
+ * event is not announced, because assistive tech must have been observing the
222
+ * node beforehand.
223
+ */
224
+ function TurnAnnouncer({ text }: { readonly text: string }) {
225
+ return (
226
+ <div
227
+ role="status"
228
+ style={{
229
+ position: "absolute",
230
+ width: 1,
231
+ height: 1,
232
+ margin: -1,
233
+ padding: 0,
234
+ overflow: "hidden",
235
+ // `clip` over `display:none` or `visibility:hidden`: those two remove
236
+ // the node from the accessibility tree entirely, taking the
237
+ // announcement with them.
238
+ clip: "rect(0 0 0 0)",
239
+ whiteSpace: "nowrap",
240
+ border: 0,
241
+ }}
242
+ >
243
+ {text}
244
+ </div>
245
+ );
246
+ }
247
+
248
+ /** The transition string for the current turn, or "" when there is nothing to say. */
249
+ function useTurnAnnouncement(announce: TranscriptProps["announce"]): string {
250
+ const { isStreaming } = useTurn();
251
+ const [text, setText] = useState("");
252
+ const wasStreaming = useRef(false);
253
+
254
+ useEffect(() => {
255
+ const was = wasStreaming.current;
256
+ wasStreaming.current = isStreaming;
257
+ // Only on a CHANGE. Re-running on every render would re-announce, and an
258
+ // atomic region re-reads its whole contents each time it is written.
259
+ if (announce === undefined || was === isStreaming) return;
260
+ // "complete" is not announced on mount — `was` starts false, so the first
261
+ // transition it can report is false→true.
262
+ setText(isStreaming ? announce.streaming : announce.complete);
263
+ }, [announce, isStreaming]);
264
+
265
+ return text;
266
+ }
267
+
268
+ interface MessageRowProps {
269
+ readonly id: string;
270
+ readonly children: (id: string) => ReactNode;
271
+ }
272
+
273
+ /**
274
+ * One row, memoized on its id.
275
+ *
276
+ * `useMessage(id)` subscribes to that message alone, so a token arriving in the
277
+ * last row re-renders one component. Without this pair the list re-renders per
278
+ * token and the cost grows with transcript length — the stutter that reads as
279
+ * "the chat is laggy" rather than as a rendering bug.
280
+ */
281
+ const MessageRow = memo(function MessageRow({ id, children }: MessageRowProps) {
282
+ const message = useMessage(id);
283
+ if (message === undefined) return null;
284
+ return <>{children(id)}</>;
285
+ });
@@ -0,0 +1,100 @@
1
+ import { useEffect, useRef, type RefObject } from "react";
2
+
3
+ export interface UseDismissOptions {
4
+ /** Nothing is listened for while this is false — a closed surface costs nothing. */
5
+ readonly open: boolean;
6
+ readonly onDismiss: () => void;
7
+ /**
8
+ * Elements that must NOT count as outside.
9
+ *
10
+ * Plural because a popover has at least two: the panel and the trigger that
11
+ * opened it. Omitting the trigger makes a press on it dismiss and re-open in
12
+ * the same gesture, so the surface never closes by its own button.
13
+ */
14
+ readonly refs: readonly RefObject<HTMLElement | null>[];
15
+ }
16
+
17
+ /** The smallest shape the rules need. Structural, so a test needs no DOM. */
18
+ interface NodeLike {
19
+ contains(other: unknown): boolean;
20
+ }
21
+ interface KeyLike {
22
+ readonly key: string;
23
+ stopPropagation(): void;
24
+ }
25
+
26
+ /**
27
+ * Is this press outside every element that counts as inside?
28
+ *
29
+ * Extracted so the rule can be tested without a DOM: the workspace has no
30
+ * jsdom, and the alternative — a regex over the hook's source — cannot tell
31
+ * `contains` on two refs from `contains` on one, which is the exact bug that
32
+ * makes a trigger dismiss and reopen in a single gesture.
33
+ */
34
+ export function pressIsOutside(
35
+ target: unknown,
36
+ refs: readonly { readonly current: NodeLike | null }[],
37
+ ): boolean {
38
+ return !refs.some((ref) => ref.current?.contains(target) === true);
39
+ }
40
+
41
+ /**
42
+ * Escape dismisses, and STOPS THERE.
43
+ *
44
+ * The `stopPropagation` is the load-bearing half. Chat panes and dialogs close
45
+ * on Escape too, so without it one press closes the menu AND whatever hosts it
46
+ * — the menu appears to work while quietly taking the surface behind it down.
47
+ * Returns whether the key dismissed, so the caller stays a one-liner.
48
+ */
49
+ export function escapeDismisses(event: KeyLike): boolean {
50
+ if (event.key !== "Escape") return false;
51
+ event.stopPropagation();
52
+ return true;
53
+ }
54
+
55
+ /**
56
+ * Outside press and Escape close a floating surface.
57
+ *
58
+ * A primitive rather than a per-menu handler: this package carries NO popover
59
+ * dependency, so every surface that floats has to implement these two rules, and
60
+ * a hand-written one degrades quietly. That already happened once — the language
61
+ * picker shipped as a plain element that only its own trigger could close, which
62
+ * looks identical in a screenshot and behaves like a trap.
63
+ *
64
+ * Two details beyond the pure rules above:
65
+ *
66
+ * - **`pointerdown`, not `click`.** A click fires after the press has already
67
+ * moved focus and possibly unmounted what was under the pointer, so the
68
+ * dismissal lands a frame late and can be swallowed by a re-render.
69
+ * - **Capture phase.** A row inside the surface may `stopPropagation` for its
70
+ * own reasons; dismissal must not depend on other handlers being polite.
71
+ */
72
+ export function useDismiss({ open, onDismiss, refs }: UseDismissOptions): void {
73
+ // Read in the listeners, so a changing callback or ref list never re-binds
74
+ // them mid-gesture — a re-bind between pointerdown and keydown drops events.
75
+ const dismiss = useRef(onDismiss);
76
+ dismiss.current = onDismiss;
77
+ const inside = useRef(refs);
78
+ inside.current = refs;
79
+
80
+ useEffect(() => {
81
+ if (!open) return undefined;
82
+
83
+ const onPointerDown = (event: PointerEvent): void => {
84
+ if (!pressIsOutside(event.target, inside.current)) return;
85
+ dismiss.current();
86
+ };
87
+
88
+ const onKeyDown = (event: KeyboardEvent): void => {
89
+ if (!escapeDismisses(event)) return;
90
+ dismiss.current();
91
+ };
92
+
93
+ document.addEventListener("pointerdown", onPointerDown, true);
94
+ document.addEventListener("keydown", onKeyDown, true);
95
+ return () => {
96
+ document.removeEventListener("pointerdown", onPointerDown, true);
97
+ document.removeEventListener("keydown", onKeyDown, true);
98
+ };
99
+ }, [open]);
100
+ }
@@ -0,0 +1,30 @@
1
+ import { useMemo, useRef } from "react";
2
+
3
+ import { MissedCount, missedRows } from "../core/scroll";
4
+ import { useChatStore } from "../store";
5
+
6
+ /**
7
+ * How many replies arrived while the reader was scrolled away.
8
+ *
9
+ * Reads roles imperatively off the store instead of subscribing to the message
10
+ * array. A `useMessages()` selector would re-render the transcript on every
11
+ * token — the exact cost the `useMessageIds()` / `useMessage(id)` split exists
12
+ * to avoid (`store/hooks.ts`). Here it is free: the count can only change when
13
+ * `ids` changes, and the transcript already re-renders then.
14
+ *
15
+ * @param ids the transcript's message ids, in order
16
+ * @param pinned whether the viewport is following the bottom
17
+ */
18
+ export function useMissedCount(ids: readonly string[], pinned: boolean): number | null {
19
+ const store = useChatStore();
20
+ const counter = useRef<MissedCount | null>(null);
21
+ counter.current ??= new MissedCount();
22
+
23
+ return useMemo(() => {
24
+ const missed = counter.current;
25
+ if (missed === null) return null;
26
+ const byId = store.getState().transcript.byId;
27
+ missed.observe(pinned, missedRows(ids, byId));
28
+ return missed.value();
29
+ }, [store, ids, pinned]);
30
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Does this element have more content than the height it is allowed?
3
+ *
4
+ * Answers the question by measuring the thing itself rather than by estimating
5
+ * from what is inside it. A message is an array of parts, not a string: an image
6
+ * or a table contributes almost no characters at a large height, and a code
7
+ * fence keeps its characters while losing its shape. Any character count is
8
+ * therefore a proxy for a proxy, and the element already knows the answer.
9
+ *
10
+ * Reports only. It never writes a height — the clamp is a CSS `max-height` the
11
+ * caller owns, which is what makes the measurement honest: while collapsed the
12
+ * element IS clipped, so `scrollHeight > clientHeight` is exactly the question.
13
+ */
14
+
15
+ import { useCallback, useLayoutEffect, useState, type RefObject } from "react";
16
+
17
+ /**
18
+ * @param ref the clamped element — the one carrying `max-height`.
19
+ * @param expanded whether the clamp is currently lifted. Load-bearing: see the
20
+ * collapse-first note below.
21
+ * @param deps a value that changes when the content does, so a streamed message
22
+ * re-measures as it grows.
23
+ */
24
+ export function useOverflows(
25
+ ref: RefObject<HTMLElement | null>,
26
+ expanded: boolean,
27
+ deps: unknown,
28
+ ): boolean {
29
+ const [overflows, setOverflows] = useState(false);
30
+
31
+ const measure = useCallback(() => {
32
+ const node = ref.current;
33
+ if (node === null) return;
34
+
35
+ // Collapse first. `scrollHeight` and `clientHeight` are equal while the
36
+ // clamp is lifted, so measuring in the expanded state reports "fits" for
37
+ // every message and the control disappears the moment it is used. The
38
+ // composer's autosize carries the same ordering for the same reason.
39
+ //
40
+ // Removing the property rather than setting a value returns the element to
41
+ // whatever the stylesheet says, so this never has to know the budget.
42
+ const lifted = expanded;
43
+ const previous = node.style.maxHeight;
44
+ if (lifted) node.style.maxHeight = "";
45
+
46
+ setOverflows(node.scrollHeight > node.clientHeight);
47
+
48
+ if (lifted) node.style.maxHeight = previous;
49
+ }, [ref, expanded]);
50
+
51
+ // A layout effect, not an effect: the trigger must be present in the same
52
+ // paint as the clipped text, or a long message shows one frame with no way to
53
+ // open it.
54
+ useLayoutEffect(measure, [measure, deps]);
55
+
56
+ // Content can also change height without the deps changing — a font loads, an
57
+ // image decodes, the window narrows and the text rewraps. The element itself
58
+ // is the only reliable signal for those.
59
+ useLayoutEffect(() => {
60
+ const node = ref.current;
61
+ if (node === null) return;
62
+ if (typeof ResizeObserver === "undefined") return;
63
+ const observer = new ResizeObserver(measure);
64
+ observer.observe(node);
65
+ return () => observer.disconnect();
66
+ }, [ref, measure]);
67
+
68
+ return overflows;
69
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Sending returns the reader to the bottom.
3
+ *
4
+ * The React binding only; the decision is `core/scroll`'s `isOwnSend`, tested
5
+ * without a DOM. What is here is the one thing that cannot be pure — remembering
6
+ * the previous list across renders.
7
+ */
8
+
9
+ import { useEffect, useRef } from "react";
10
+
11
+ import { isOwnSend } from "../core/scroll";
12
+ import { useChatStore } from "../store";
13
+
14
+ export function useReturnOnSend(ids: readonly string[], scrollToBottom: () => void): void {
15
+ const store = useChatStore();
16
+ // A ref, not state: nothing renders from it, and it must be read and written
17
+ // inside the same effect pass.
18
+ const seen = useRef<readonly string[] | null>(null);
19
+
20
+ useEffect(() => {
21
+ const previous = seen.current;
22
+ seen.current = ids;
23
+ // Read through the store rather than from a prop: the roles live beside the
24
+ // ids in one state, so passing them in would let a caller hand over a list
25
+ // and a role map that disagree.
26
+ const roleOf = (id: string) => store.getState().transcript.byId[id]?.role;
27
+ if (isOwnSend(previous, ids, roleOf)) scrollToBottom();
28
+ }, [ids, store, scrollToBottom]);
29
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The DOM half of roving tabindex. The index arithmetic lives in
3
+ * `core/focus/roving.ts`, where it is tested.
4
+ *
5
+ * Deliberately DOM-driven rather than list-driven: rows are rendered by the
6
+ * HOST, so this hook cannot know their shape. It reads the elements marked with
7
+ * `data-chat-item` at the moment a key is pressed, which also keeps it correct
8
+ * while the transcript streams.
9
+ */
10
+
11
+ import { useCallback, useState, type KeyboardEvent } from "react";
12
+
13
+ import { CHAT_ITEM_ATTR, clampIndex, isRovingKey, nextIndex } from "../core/focus";
14
+
15
+ export interface RovingFocus {
16
+ readonly onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
17
+ /** Spread onto each row. Only the active row is tabbable. */
18
+ readonly itemProps: (index: number) => {
19
+ readonly tabIndex: number;
20
+ readonly [CHAT_ITEM_ATTR]: string;
21
+ };
22
+ }
23
+
24
+ /**
25
+ * @param count how many rows there are. Needed because messages arrive while
26
+ * the user reads: an active index that outlives its row would leave EVERY row
27
+ * at `tabIndex={-1}`, so the transcript stops being reachable by Tab and
28
+ * nothing about it looks broken.
29
+ */
30
+ export function useRovingFocus(count: number): RovingFocus {
31
+ // State, not a ref: `tabIndex` is rendered output, so moving the active row
32
+ // has to re-render the rows. A ref would move focus once and leave every
33
+ // row's tabIndex frozen at its initial value.
34
+ const [active, setActive] = useState(0);
35
+ const current = clampIndex(active, count);
36
+
37
+ const onKeyDown = useCallback(
38
+ (event: KeyboardEvent<HTMLElement>) => {
39
+ if (!isRovingKey(event.key)) return;
40
+
41
+ const items = Array.from(
42
+ event.currentTarget.querySelectorAll<HTMLElement>(`[${CHAT_ITEM_ATTR}]`),
43
+ );
44
+ const next = nextIndex(event.key, clampIndex(active, items.length), items.length);
45
+ // `null` means the key moves nowhere — at the last row, ArrowDown must
46
+ // still scroll the PAGE, so it is left to the browser rather than
47
+ // swallowed by a preventDefault that changes nothing.
48
+ if (next === null) return;
49
+
50
+ const target = items[next];
51
+ if (target === undefined) return;
52
+
53
+ event.preventDefault();
54
+ setActive(next);
55
+ target.focus();
56
+ },
57
+ [active],
58
+ );
59
+
60
+ const itemProps = useCallback(
61
+ (index: number) => ({
62
+ tabIndex: index === current ? 0 : -1,
63
+ [CHAT_ITEM_ATTR]: "",
64
+ }),
65
+ [current],
66
+ );
67
+
68
+ return { onKeyDown, itemProps };
69
+ }