@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,111 @@
1
+ import { useEffect } from "react";
2
+
3
+ /**
4
+ * ⌘A selects the whole conversation instead of the whole page.
5
+ *
6
+ * The browser default selects the document, which here means the rail, the
7
+ * status bar and the composer alongside the turns — so the copy that follows is
8
+ * unusable and the user trims it by hand.
9
+ *
10
+ * Scoped to the TRANSCRIPT, and only when the caret is not in an editable:
11
+ * inside the composer ⌘A means "select my draft", which is the more specific
12
+ * intent and must win. `useHotkey`'s default policy would take it, because a
13
+ * modifier combo defaults to firing in inputs — hence the explicit `inInput`
14
+ * at the call site.
15
+ *
16
+ * The selection is built over the transcript's message NODES rather than the
17
+ * scroller, so the gaps between turns stay out of it. Those gaps are
18
+ * `select-none` on purpose (see `../ui/message.tsx`): including
19
+ * them fuses two speakers into one block on copy, which is exactly what this
20
+ * feature exists to avoid.
21
+ *
22
+ * It selects what is LOADED, which is the transcript the user is looking at.
23
+ * Fetching older turns first would make a keystroke perform a network read with
24
+ * no way to show progress.
25
+ *
26
+ * Nothing is written to the clipboard: ⌘A selects, and the ⌘C that follows is
27
+ * the user's own gesture.
28
+ */
29
+ export function selectTranscript(root: HTMLElement | null): boolean {
30
+ if (!root) return false;
31
+ const items = root.querySelectorAll("[data-chat-item]");
32
+ const first = items[0];
33
+ const last = items[items.length - 1];
34
+ if (!first || !last) return false;
35
+
36
+ const selection = window.getSelection();
37
+ if (!selection) return false;
38
+
39
+ const range = document.createRange();
40
+ range.setStartBefore(first);
41
+ range.setEndAfter(last);
42
+ selection.removeAllRanges();
43
+ selection.addRange(range);
44
+ return true;
45
+ }
46
+
47
+ /** True while the caret sits somewhere the user is typing. */
48
+ export function isEditableTarget(node: EventTarget | null): boolean {
49
+ return (
50
+ node instanceof HTMLElement &&
51
+ (node.isContentEditable ||
52
+ node instanceof HTMLInputElement ||
53
+ node instanceof HTMLTextAreaElement)
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Is this the keystroke, and is it ours to take?
59
+ *
60
+ * Split out so the decision is testable without a DOM: it reads only the
61
+ * modifier flags and whether the target is editable, and every way this
62
+ * feature can misbehave — eating the composer's ⌘A, firing on ⌘⇧A — is
63
+ * decided here.
64
+ */
65
+ export function claimsSelectAll(event: {
66
+ readonly key: string;
67
+ readonly metaKey: boolean;
68
+ readonly ctrlKey: boolean;
69
+ readonly altKey: boolean;
70
+ readonly shiftKey: boolean;
71
+ readonly editableTarget: boolean;
72
+ }): boolean {
73
+ if (event.key !== "a" || !(event.metaKey || event.ctrlKey)) return false;
74
+ // ⌘⌥A and ⌘⇧A are other commands, not a louder select-all.
75
+ if (event.altKey || event.shiftKey) return false;
76
+ // The composer's own ⌘A is the more specific intent and wins.
77
+ return !event.editableTarget;
78
+ }
79
+
80
+ /**
81
+ * Bind ⌘A to {@link selectTranscript} for as long as the surface is mounted.
82
+ *
83
+ * A plain listener rather than `useHotkey`: this must inspect the EVENT TARGET
84
+ * to leave the composer alone, and it must call `preventDefault` only when it
85
+ * actually took the selection — a conditional both of which the wrapper's
86
+ * declarative policy decides up front, before the target is known.
87
+ */
88
+ export function useSelectAllTranscript(
89
+ surface: () => HTMLElement | null,
90
+ enabled = true,
91
+ ): void {
92
+ useEffect(() => {
93
+ if (!enabled) return;
94
+ const onKeyDown = (event: KeyboardEvent) => {
95
+ const claim = claimsSelectAll({
96
+ key: event.key,
97
+ metaKey: event.metaKey,
98
+ ctrlKey: event.ctrlKey,
99
+ altKey: event.altKey,
100
+ shiftKey: event.shiftKey,
101
+ editableTarget: isEditableTarget(event.target),
102
+ });
103
+ if (!claim) return;
104
+ // Only claim the key once the selection succeeded, so a surface with no
105
+ // transcript still gets the browser's own select-all.
106
+ if (selectTranscript(surface())) event.preventDefault();
107
+ };
108
+ document.addEventListener("keydown", onKeyDown);
109
+ return () => document.removeEventListener("keydown", onKeyDown);
110
+ }, [surface, enabled]);
111
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Following the bottom, delegated to `use-stick-to-bottom`.
3
+ *
4
+ * This was ours, twice, and both versions had the same class of defect: an
5
+ * INSTANT write to `scrollTop` on every growth. A hard fling landing on the
6
+ * bottom edge then visibly shook — measured at 5–12 direction reversals with an
7
+ * 8–23px swing — because the write, the engine's own settle and the resulting
8
+ * scroll event chase each other inside one frame. Neither a tolerance around
9
+ * the target nor rounding the box's fractional height fixed it; a write-trap
10
+ * proved the hook was not even the one writing during the shake.
11
+ *
12
+ * The library answers it structurally rather than by tuning: it animates toward
13
+ * the bottom with a damped spring, so there is no single frame that jumps, and
14
+ * it remembers the position it last wrote (`ignoreScrollToTop`) so its own echo
15
+ * is never mistaken for the reader scrolling. It also suspends following while
16
+ * a text selection is in progress, which no amount of gesture-watching here
17
+ * would have covered.
18
+ *
19
+ * What stays ours is the SHAPE of the return value: `Transcript` consumes
20
+ * `{ ref, onScroll, isPinned, scrollToBottom }`, and keeping that means the
21
+ * primitive did not have to learn about a second element.
22
+ */
23
+
24
+ import { useCallback, type UIEvent } from "react";
25
+ import { useStickToBottom as useLibraryStickToBottom } from "use-stick-to-bottom";
26
+
27
+ export interface StickToBottom {
28
+ /** The SCROLLER. */
29
+ readonly ref: (node: HTMLDivElement | null) => void;
30
+ /** The element wrapping the rows, which is what actually changes height. */
31
+ readonly contentRef: (node: HTMLDivElement | null) => void;
32
+ readonly onScroll: (event: UIEvent<HTMLDivElement>) => void;
33
+ /** False while the user is reading history — render a "jump to latest" control. */
34
+ readonly isPinned: boolean;
35
+ readonly scrollToBottom: () => void;
36
+ }
37
+
38
+ /**
39
+ * @param revision unused, and kept only so callers need not change. The library
40
+ * watches the content element with a ResizeObserver, which sees streamed text
41
+ * grow a row as readily as it sees a row appear — the count was always a
42
+ * proxy for exactly that.
43
+ */
44
+ export function useStickToBottom(_revision: number): StickToBottom {
45
+ const instance = useLibraryStickToBottom({
46
+ // Growth is ANIMATED. `instant` here is what produced the shake: it lands
47
+ // the view on the target in one frame, so the engine's own settle reads as
48
+ // a bounce rather than as part of a motion.
49
+ resize: "smooth",
50
+ // Arrival is INSTANT, and the asymmetry is the point. Opening a room is not
51
+ // motion the reader is following — there is nothing on screen yet to track —
52
+ // so animating it scrolls a transcript they never asked to watch, every
53
+ // single time they switch. The bottom is simply where the room starts.
54
+ initial: "instant",
55
+ });
56
+
57
+ const { scrollRef, contentRef, isAtBottom, scrollToBottom } = instance;
58
+
59
+ const ref = useCallback(
60
+ (node: HTMLDivElement | null) => {
61
+ scrollRef(node);
62
+ },
63
+ [scrollRef],
64
+ );
65
+
66
+ const content = useCallback(
67
+ (node: HTMLDivElement | null) => {
68
+ contentRef(node);
69
+ },
70
+ [contentRef],
71
+ );
72
+
73
+ // The library listens on the element itself, so nothing has to happen here.
74
+ // Kept because `Transcript` passes it to the scroller and a missing handler
75
+ // would be a silent behaviour change rather than a type error.
76
+ const onScroll = useCallback(() => {}, []);
77
+
78
+ const jump = useCallback(() => {
79
+ void scrollToBottom();
80
+ }, [scrollToBottom]);
81
+
82
+ return { ref, contentRef: content, onScroll, isPinned: isAtBottom, scrollToBottom: jump };
83
+ }
@@ -0,0 +1,25 @@
1
+ import { useTurn } from "../store";
2
+
3
+ /**
4
+ * `waiting` — sent, nothing back yet. `working` — text is arriving.
5
+ * `null` — no turn in flight, so nothing to mark.
6
+ */
7
+ export type TurnActivity = "waiting" | "working" | null;
8
+
9
+ /**
10
+ * The live turn's activity, in one place.
11
+ *
12
+ * Derived from `useTurn`, never stored: the two flags it reads are themselves
13
+ * derived from the transcript, and a third copy could disagree with both about
14
+ * whether an answer has started.
15
+ *
16
+ * It exists so the trailing indicator has ONE owner. The alternative — each
17
+ * message row deciding from its own `status` — puts the answer in as many places
18
+ * as there are messages, and every settled row then answers "yes, I finished",
19
+ * which is how a marker ends up stacked once per reply on a single screen.
20
+ */
21
+ export function useTurnActivity(): TurnActivity {
22
+ const { isStreaming, isPending } = useTurn();
23
+ if (!isStreaming) return null;
24
+ return isPending ? "waiting" : "working";
25
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The single context.
3
+ *
4
+ * Capabilities ride here rather than being threaded as props, which is what
5
+ * lets `<Chat>` stay at seven props while a composer three levels down still
6
+ * knows which features exist. One context, not one per feature: a context per
7
+ * capability would put the count back on the same growth curve the props were
8
+ * on.
9
+ */
10
+
11
+ import { createContext } from "react";
12
+
13
+ import type { ChatCapabilities } from "../core/capabilities";
14
+ import type { ChatEvents } from "../core/events";
15
+ import type { ChatStore } from "./store";
16
+
17
+ export interface ChatContextValue {
18
+ readonly store: ChatStore;
19
+ readonly capabilities: ChatCapabilities;
20
+ /**
21
+ * Announcements, as opposed to state. The store answers "what is true now";
22
+ * this says "this just happened" — which a subscriber cannot reconstruct from
23
+ * a snapshot, because two identical states can be reached by different acts.
24
+ */
25
+ readonly events: ChatEvents;
26
+ }
27
+
28
+ export const ChatContext = createContext<ChatContextValue | null>(null);
@@ -0,0 +1,215 @@
1
+ /**
2
+ * React bindings for the store.
3
+ *
4
+ * The split is deliberate and is the whole performance story: the transcript
5
+ * subscribes to `useMessageIds()` and each row to `useMessage(id)`, so a token
6
+ * arriving in the last message re-renders one row rather than the list. MUI X
7
+ * settled on the same pair, and a single `useMessages()` returning the array
8
+ * cannot be optimised into it afterwards — every consumer would have to change.
9
+ */
10
+
11
+ import { useCallback, useContext, useMemo, useSyncExternalStore } from "react";
12
+
13
+ import type { ChatCapabilities } from "../core/capabilities";
14
+ import type { ChatEvents } from "../core/events";
15
+ import type { ChatMessage } from "../core/parts";
16
+ import { ChatContext, type ChatContextValue } from "./context";
17
+ import type { ChatState, ChatStore } from "./store";
18
+
19
+ /**
20
+ * Has the in-flight reply produced anything a reader can SEE yet?
21
+ *
22
+ * Two thresholds were tried and both were too early, each by one step:
23
+ *
24
+ * - `streamingId !== null` flips on `message-start`, which opens an EMPTY
25
+ * message;
26
+ * - a non-empty `parts` array flips on `text-start`, which appends a part
27
+ * whose text is `""`.
28
+ *
29
+ * Both leave a measurable silent gap where the indicator is gone and the reply
30
+ * has not appeared — the exact state it exists to prevent. So the question is
31
+ * asked of the CONTENT: a text or reasoning part counts once it has characters,
32
+ * and any other part counts as soon as it exists, because a tool call renders
33
+ * its own header immediately.
34
+ *
35
+ * Returns a boolean rather than the message: `useSyncExternalStore` compares by
36
+ * reference, and a selector minting an object per call re-renders forever.
37
+ */
38
+ function hasRenderedPart(state: ChatState): boolean {
39
+ const id = state.transcript.streamingId;
40
+ if (id === null) return false;
41
+ const parts = state.transcript.byId[id]?.parts ?? [];
42
+ return parts.some((part) =>
43
+ part.type === "text" || part.type === "reasoning" ? part.text !== "" : true,
44
+ );
45
+ }
46
+
47
+ function useChatContext(): ChatContextValue {
48
+ const value = useContext(ChatContext);
49
+ if (value === null) {
50
+ throw new Error("chat hooks must be used inside <ChatProvider>");
51
+ }
52
+ return value;
53
+ }
54
+
55
+ export function useChatStore(): ChatStore {
56
+ return useChatContext().store;
57
+ }
58
+
59
+ /**
60
+ * What this chat can do.
61
+ *
62
+ * A component asks for the capability it needs and renders nothing when it is
63
+ * absent — that check IS the feature flag. There is no second boolean that can
64
+ * disagree with it.
65
+ */
66
+ export function useCapabilities(): ChatCapabilities {
67
+ return useChatContext().capabilities;
68
+ }
69
+
70
+ /**
71
+ * The chat's event bus.
72
+ *
73
+ * Announcements, not state: subscribe in an effect, never read during render.
74
+ * What is TRUE comes from the store — a component that mirrors an event into
75
+ * its own state has made a second owner of a fact the store already holds.
76
+ *
77
+ * The identity is stable for the life of the provider, so an effect may depend
78
+ * on it without re-subscribing.
79
+ */
80
+ export function useChatEvents(): ChatEvents {
81
+ return useChatContext().events;
82
+ }
83
+
84
+ /**
85
+ * Message ids in order.
86
+ *
87
+ * The identity of the array matters: `useSyncExternalStore` compares by
88
+ * reference, so returning a fresh array per call would re-render the transcript
89
+ * on every token. The reducer keeps `ids` stable unless a message is added, so
90
+ * reading it directly is both correct and cheap.
91
+ */
92
+ export function useMessageIds(): readonly string[] {
93
+ const store = useChatStore();
94
+ return useSyncExternalStore(
95
+ store.subscribe,
96
+ useCallback(() => store.getState().transcript.ids, [store]),
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Whether the host's history fetch has answered.
102
+ *
103
+ * `false` with an empty transcript is LOADING, not empty — the distinction the
104
+ * empty state needs and cannot make from the message list alone.
105
+ */
106
+ export function useHistoryLoaded(): boolean {
107
+ const store = useChatStore();
108
+ return useSyncExternalStore(
109
+ store.subscribe,
110
+ useCallback(() => store.getState().historyLoaded, [store]),
111
+ );
112
+ }
113
+
114
+ /** One message, re-rendering only when that message changes. */
115
+ export function useMessage(id: string): ChatMessage | undefined {
116
+ const store = useChatStore();
117
+ const subscribe = useCallback(
118
+ (listener: () => void) => store.subscribeMessage(id, listener),
119
+ [store, id],
120
+ );
121
+ return useSyncExternalStore(
122
+ subscribe,
123
+ useCallback(() => store.getState().transcript.byId[id], [store, id]),
124
+ );
125
+ }
126
+
127
+ /**
128
+ * When the row BEFORE `id` was created, or `undefined` when it is the first.
129
+ *
130
+ * A number, not the message: a row needs its neighbour only to tell whether the
131
+ * calendar day changed, and subscribing to the whole message would re-render it
132
+ * on every token arriving in the row above.
133
+ *
134
+ * Read from the list rather than passed down, so the day heading survives the
135
+ * memo boundary that keeps a streaming transcript cheap.
136
+ */
137
+ export function usePreviousMessageTime(id: string): number | undefined {
138
+ const store = useChatStore();
139
+ return useSyncExternalStore(
140
+ store.subscribe,
141
+ useCallback(() => {
142
+ const { ids, byId } = store.getState().transcript;
143
+ const index = ids.indexOf(id);
144
+ if (index <= 0) return undefined;
145
+ return byId[ids[index - 1] as string]?.createdAt;
146
+ }, [store, id]),
147
+ );
148
+ }
149
+
150
+ /**
151
+ * Whether the row BEFORE `id` was spoken by the same author.
152
+ *
153
+ * A BOOLEAN, not the message, for the reason `usePreviousMessageTime` returns a
154
+ * number: a row needs its neighbour only to decide whether to repeat the name,
155
+ * and subscribing to the whole message would re-render it on every token
156
+ * arriving in the row above — the one cost the id/message split exists to avoid.
157
+ *
158
+ * False for the first row, and false whenever either side has no author: a
159
+ * system-authored line and a turn of yours both BREAK a run, so the next speaker
160
+ * is named again rather than reading as the previous one still talking.
161
+ */
162
+ export function usePreviousAuthorMatches(id: string): boolean {
163
+ const store = useChatStore();
164
+ return useSyncExternalStore(
165
+ store.subscribe,
166
+ useCallback(() => {
167
+ const { ids, byId } = store.getState().transcript;
168
+ const index = ids.indexOf(id);
169
+ if (index <= 0) return false;
170
+ const mine = byId[id]?.authorId;
171
+ const before = byId[ids[index - 1] as string]?.authorId;
172
+ return mine !== undefined && mine === before;
173
+ }, [store, id]),
174
+ );
175
+ }
176
+
177
+ export interface TurnState {
178
+ readonly isStreaming: boolean;
179
+ /**
180
+ * A turn is in flight and has produced NOTHING yet.
181
+ *
182
+ * Derived, never stored: a turn is in flight and the reply holds no part yet.
183
+ * A stored flag would be a second source of truth that can disagree with the
184
+ * transcript about whether the answer has started.
185
+ *
186
+ * The threshold is the first PART, not the opening of the message — see
187
+ * `hasRenderedPart`.
188
+ *
189
+ * The gap it names is the common case here, not an edge one — an agent may
190
+ * read files or start a subagent for many seconds before its first token, and
191
+ * during that time the transcript is otherwise silent.
192
+ */
193
+ readonly isPending: boolean;
194
+ readonly stop: () => void;
195
+ }
196
+
197
+ export function useTurn(): TurnState {
198
+ const store = useChatStore();
199
+ const inFlight = useSyncExternalStore(
200
+ store.subscribe,
201
+ useCallback(() => store.getState().inFlight, [store]),
202
+ );
203
+ const rendered = useSyncExternalStore(
204
+ store.subscribe,
205
+ useCallback(() => hasRenderedPart(store.getState()), [store]),
206
+ );
207
+ return useMemo(
208
+ () => ({
209
+ isStreaming: inFlight !== null,
210
+ isPending: inFlight !== null && !rendered,
211
+ stop: store.stop,
212
+ }),
213
+ [inFlight, rendered, store.stop],
214
+ );
215
+ }
@@ -0,0 +1,31 @@
1
+ export { createChatStore } from "./store";
2
+ export type { ChatState, ChatStore, StoreOptions } from "./store";
3
+ export { ChatContext } from "./context";
4
+ export type { ChatContextValue } from "./context";
5
+ export { ChatProvider } from "./provider";
6
+ export type { ChatProviderProps } from "./provider";
7
+ export { ChatSurfacesContext, useChatSurfaces, useSurfacesRegistry } from "./surfaces";
8
+ export { useReloadOnRevision } from "./use-reload-on-revision";
9
+ export type { ChatSurfaces } from "./surfaces";
10
+ export {
11
+ useCapabilities,
12
+ useChatEvents,
13
+ useChatStore,
14
+ useHistoryLoaded,
15
+ useMessage,
16
+ useMessageIds,
17
+ usePreviousMessageTime,
18
+ usePreviousAuthorMatches,
19
+ useTurn,
20
+ } from "./hooks";
21
+ export type { TurnState } from "./hooks";
22
+ export { useDraft } from "./use-draft";
23
+ export type { DraftApi } from "./use-draft";
24
+ export { useQueue } from "./use-queue";
25
+ export type { UseQueueResult } from "./use-queue";
26
+ export { useVoice } from "./use-voice";
27
+ export type { UseVoiceResult } from "./use-voice";
28
+ export { useSpeechLanguage } from "./use-speech-language";
29
+ export type { UseSpeechLanguageResult, SpeechLanguageOptions } from "./use-speech-language";
30
+ export { useRecall } from "./use-recall";
31
+ export type { UseRecallOptions, UseRecallResult } from "./use-recall";
@@ -0,0 +1,97 @@
1
+ import { useMemo, useRef, type ReactNode } from "react";
2
+
3
+ import type { ChatAdapter } from "../core/adapter";
4
+ import type { ChatCapabilities } from "../core/capabilities";
5
+ import { createChatEvents, type ChatEvents } from "../core/events";
6
+ import { ChatContext } from "./context";
7
+ import { createChatStore, type ChatStore } from "./store";
8
+ import { ChatSurfacesContext, useSurfacesRegistry } from "./surfaces";
9
+
10
+ export interface ChatProviderProps {
11
+ readonly adapter: ChatAdapter;
12
+ readonly capabilities?: ChatCapabilities;
13
+ /**
14
+ * Bring your own store to seed history or drive it from outside. Omit and one
15
+ * is created — the common path needs no lifecycle awareness at all.
16
+ */
17
+ readonly store?: ChatStore;
18
+ readonly children: ReactNode;
19
+ }
20
+
21
+ const noCapabilities: ChatCapabilities = {};
22
+
23
+ /**
24
+ * The escape hatch, and the seam every primitive resolves through.
25
+ *
26
+ * `<Chat>` renders this internally, so a host that outgrows the packaged layout
27
+ * drops to primitives without losing state wiring — the thing that usually
28
+ * forces a fork of the whole component.
29
+ */
30
+ export function ChatProvider({
31
+ adapter,
32
+ capabilities = noCapabilities,
33
+ store,
34
+ children,
35
+ }: ChatProviderProps) {
36
+ // The store is created ONCE and never re-created from a prop change.
37
+ //
38
+ // A `useMemo` keyed on [adapter, capabilities, store] reads as harmless and
39
+ // is not: `capabilities` is an object literal at every realistic call site
40
+ // (`<ChatProvider capabilities={{ think: … }}>`), so it is a new
41
+ // reference on every parent render, and a new store means an EMPTY
42
+ // transcript. The conversation would vanish mid-turn, with no error and
43
+ // nothing in the stack pointing here.
44
+ //
45
+ // A ref, not a memo: `useMemo` is a performance hint React may discard and
46
+ // recompute at will, so it is not a place to put something whose identity is
47
+ // load-bearing. This is the documented distinction, not a style preference.
48
+ //
49
+ // `crypto.randomUUID` and `Date.now` are injected here, at the edge, so the
50
+ // core stays clock-free and testable. This is the only place they appear.
51
+
52
+ // The LIVE adapter, re-read on every send rather than captured once. A host
53
+ // re-binds its adapter when the room changes (one transport per room), so a
54
+ // store holding the first one addresses the room that was
55
+ // open at mount: the start screen's first message landed in the previous
56
+ // conversation instead of creating one. Re-creating the store for it is the
57
+ // wrong fix — that is the defect the paragraph above guards against.
58
+ const liveAdapter = useRef(adapter);
59
+ liveAdapter.current = adapter;
60
+
61
+ const ownStore = useRef<ChatStore | null>(null);
62
+ if (ownStore.current === null && store === undefined) {
63
+ ownStore.current = createChatStore({
64
+ // Indirection, not the prop: the store keeps this object for its lifetime,
65
+ // so reading the ref at call time is what lets the adapter change under a
66
+ // transcript that must survive the change.
67
+ adapter: { sendMessage: (request) => liveAdapter.current.sendMessage(request) },
68
+ makeId: () => crypto.randomUUID(),
69
+ now: () => Date.now(),
70
+ });
71
+ }
72
+
73
+ // Per chat, and by ref for the same reason as the store: a listener that
74
+ // outlived a re-created bus would hold a subscription nothing ever emits to —
75
+ // silent, and indistinguishable from an event that simply did not happen.
76
+ const events = useRef<ChatEvents | null>(null);
77
+ events.current ??= createChatEvents();
78
+
79
+ // A caller that SWAPS its store prop means it: that is the one identity we
80
+ // must follow. Everything else is stable across renders.
81
+ const active = store ?? ownStore.current;
82
+ const value = useMemo(
83
+ () => ({ store: active as ChatStore, capabilities, events: events.current as ChatEvents }),
84
+ [active, capabilities],
85
+ );
86
+
87
+ // Where the surfaces are, for a host that must put the caret back or scope a
88
+ // selection. Provided here because it spans BOTH — the composer and the
89
+ // transcript — and a host asking is usually outside either.
90
+ const surfaces = useSurfacesRegistry();
91
+
92
+ return (
93
+ <ChatContext.Provider value={value}>
94
+ <ChatSurfacesContext.Provider value={surfaces}>{children}</ChatSurfacesContext.Provider>
95
+ </ChatContext.Provider>
96
+ );
97
+ }