@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,97 @@
1
+ /**
2
+ * The turn marker: one shape for the live turn, absent once it settles.
3
+ *
4
+ * Waiting and working differ in PACE, not in shape, so the moment the first
5
+ * token lands reads as the mark picking up speed rather than as one icon being
6
+ * swapped for another.
7
+ *
8
+ * A square that turns, rather than a pulse. A pulsing dot says "busy" and
9
+ * nothing else — every spinner on the web says that. Rotation has a direction,
10
+ * so a glance distinguishes "still going" from "stalled".
11
+ *
12
+ * Sized in `em` throughout, so it tracks whatever text it sits beside.
13
+ */
14
+
15
+ .turn-mark {
16
+ display: inline-block;
17
+ /* Slightly over cap height: the mark leads the line rather than sitting in
18
+ * it, and below ~0.7em the tumble reads as a jitter because the corners
19
+ * travel too few pixels to register as rotation. */
20
+ inline-size: 0.72em;
21
+ block-size: 0.72em;
22
+ /* Not a circle: the corners are what make the rotation visible at all. At
23
+ * this size a rotating circle is indistinguishable from a still one. */
24
+ border-radius: 0.14em;
25
+ /* `currentColor` — the mark takes the colour of the row it leads, so it reads
26
+ * as part of the line rather than as a badge dropped onto it. Motion is what
27
+ * marks it as live; colour would say the same thing twice, louder. */
28
+ background: currentColor;
29
+ /* The mark sits on the text baseline rather than the line box's middle, which
30
+ * is where a raw inline-block lands and reads as floating high. */
31
+ vertical-align: 0.02em;
32
+ }
33
+
34
+ /**
35
+ * Working — a continuous turn, never a rest.
36
+ *
37
+ * `linear`, and the keyframes carry no hold: any easing curve decelerates into
38
+ * the final frame and accelerates out of the first, which on a loop is a stop
39
+ * and a restart every cycle. That hitch is what reads as jerky at speed. An
40
+ * even angular rate is the only thing that loops seamlessly.
41
+ */
42
+ .turn-mark[data-state="working"] {
43
+ animation: turn-mark-tumble 1.6s linear infinite;
44
+ }
45
+
46
+ /**
47
+ * Waiting for the first token, when there is no text yet to lead.
48
+ *
49
+ * Dimmer and slower: nothing has been said, and a mark racing beside an empty
50
+ * line overstates what is happening. Same shape, so the transition into
51
+ * `working` is a change of pace rather than a swap.
52
+ */
53
+ .turn-mark[data-state="waiting"] {
54
+ animation: turn-mark-tumble 2.6s linear infinite;
55
+ opacity: 0.5;
56
+ }
57
+
58
+ /* Also drives the composer's recording glyph (`composer.css`) and the running
59
+ * tool marker (`tool.css`) — deleting this with the turn mark would silently
60
+ * still both. */
61
+ @keyframes turn-mark-tumble {
62
+ /* A FULL turn, not the quarter a square's symmetry would allow. A quarter
63
+ * needs the cycle to end where it began, so the loop seam falls inside the
64
+ * motion; over 360° every frame is mid-rotation and there is no seam to see.
65
+ *
66
+ * The scale breathes twice per turn — at 90° and 270°, where the square is
67
+ * back to square-on. It rides the rotation instead of interrupting it, so the
68
+ * mark has weight without any pause in the movement. */
69
+ 0% {
70
+ transform: rotate(0deg) scale(1);
71
+ }
72
+ 25% {
73
+ transform: rotate(90deg) scale(0.88);
74
+ }
75
+ 50% {
76
+ transform: rotate(180deg) scale(1);
77
+ }
78
+ 75% {
79
+ transform: rotate(270deg) scale(0.88);
80
+ }
81
+ 100% {
82
+ transform: rotate(360deg) scale(1);
83
+ }
84
+ }
85
+
86
+ @media (prefers-reduced-motion: reduce) {
87
+ /* Still, but not invisible: a turn is in flight either way, and the accent
88
+ * colour alone carries that once motion is off. */
89
+ .turn-mark[data-state="working"],
90
+ .turn-mark[data-state="waiting"] {
91
+ animation: none;
92
+ opacity: 1;
93
+ }
94
+ .turn-mark[data-state="waiting"] {
95
+ opacity: 0.5;
96
+ }
97
+ }
@@ -0,0 +1,15 @@
1
+ // Test doubles: mock adapter, chat factory, chunk builders.
2
+ //
3
+ // Exported under the "./testing" subpath so consumers can drive a chat in their
4
+ // own tests. Never re-exported from the package root.
5
+ //
6
+ // Stories use these too, and that is the reason they live in `src/` rather than
7
+ // beside the story files: a second scripted adapter inside the Storybook app
8
+ // would drift from the chunk vocabulary it imitates, and a story passing
9
+ // against a private mock proves nothing about the shipped adapter contract.
10
+
11
+ export { mockAdapter, tokenize } from "./mock-adapter";
12
+ export type { MockOptions } from "./mock-adapter";
13
+ export { mockVoice } from "./mock-voice";
14
+ export type { MockVoiceOptions } from "./mock-voice";
15
+ export { MOCK_TOOL_CATALOGUE, MOCK_TOOL_CATALOGUE_NO_STT } from "./mock-tools";
@@ -0,0 +1,69 @@
1
+ /**
2
+ * A scripted adapter for stories and tests.
3
+ *
4
+ * Shipped from `src/` rather than kept in a test file because stories need it
5
+ * too, and a second copy in the Storybook app would drift from the chunk
6
+ * vocabulary it mimics. It is the reference implementation of `ChatAdapter`:
7
+ * about forty lines, which is the argument that the one-method contract works.
8
+ */
9
+
10
+ import type { ChatAdapter, SendRequest } from "../core/adapter";
11
+ import type { StreamChunk } from "../core/chunks";
12
+
13
+ export interface MockOptions {
14
+ /** Chunks to replay per turn. A function receives the request. */
15
+ readonly script: readonly StreamChunk[] | ((request: SendRequest) => readonly StreamChunk[]);
16
+ /** Milliseconds between chunks. 0 replays synchronously. */
17
+ readonly delayMs?: number;
18
+ }
19
+
20
+ export function mockAdapter(options: MockOptions): ChatAdapter {
21
+ const { script, delayMs = 0 } = options;
22
+
23
+ return {
24
+ sendMessage(request) {
25
+ const chunks = typeof script === "function" ? script(request) : script;
26
+ let index = 0;
27
+
28
+ return Promise.resolve(
29
+ new ReadableStream<StreamChunk>({
30
+ async pull(controller) {
31
+ if (index >= chunks.length) {
32
+ controller.close();
33
+ return;
34
+ }
35
+ // Honour the signal between chunks so Stop works in a story the
36
+ // same way it works against a real transport.
37
+ if (request.signal.aborted) {
38
+ controller.close();
39
+ return;
40
+ }
41
+ if (delayMs > 0) await sleep(delayMs, request.signal);
42
+ const chunk = chunks[index];
43
+ index += 1;
44
+ if (chunk !== undefined) controller.enqueue(chunk);
45
+ },
46
+ }),
47
+ );
48
+ },
49
+ };
50
+ }
51
+
52
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
53
+ return new Promise((resolve) => {
54
+ const timer = setTimeout(resolve, ms);
55
+ signal.addEventListener("abort", () => {
56
+ clearTimeout(timer);
57
+ resolve();
58
+ }, { once: true });
59
+ });
60
+ }
61
+
62
+ /** Split text into token-sized deltas — what makes a story look like streaming. */
63
+ export function tokenize(text: string, partId = "t1"): StreamChunk[] {
64
+ const words = text.split(/(\s+)/).filter((w) => w !== "");
65
+ return [
66
+ { type: "text-start", partId },
67
+ ...words.map((delta): StreamChunk => ({ type: "text-delta", partId, delta })),
68
+ ];
69
+ }
@@ -0,0 +1,101 @@
1
+ import type { ToolCatalogEntry } from "../core";
2
+
3
+ /**
4
+ * A stand-in arsenal, shaped like the registry's projection.
5
+ *
6
+ * Ids, categories and keywords are shaped like the ones a server actually
7
+ * reports, so the filter is exercised against realistic strings rather than
8
+ * against the words a test author would have picked.
9
+ */
10
+ export const MOCK_TOOL_CATALOGUE: readonly ToolCatalogEntry[] = [
11
+ {
12
+ id: "transcribe_audio",
13
+ name: "Transcribe audio",
14
+ summary: "Read back what was said in a recording",
15
+ category: "stt",
16
+ keywords: ["stt", "speech", "transcribe", "audio", "voice", "recording", "listen", "said"],
17
+ icon: "mic",
18
+ // The one entry here that wants an object a sentence cannot carry, so the
19
+ // placeholder hint has something real to fire on.
20
+ needs: "file",
21
+ },
22
+ {
23
+ id: "read_text",
24
+ name: "Read an image",
25
+ summary: "Pull the text out of a screenshot or a scan",
26
+ category: "image",
27
+ keywords: ["ocr", "screenshot", "scan", "read"],
28
+ icon: "image",
29
+ },
30
+ {
31
+ id: "web_search",
32
+ name: "Search the web",
33
+ summary: "Look something up online",
34
+ category: "web",
35
+ keywords: ["search", "google", "lookup", "internet"],
36
+ icon: "globe",
37
+ },
38
+ {
39
+ id: "web_fetch",
40
+ name: "Open a page",
41
+ summary: "Fetch one URL and read it",
42
+ category: "web",
43
+ keywords: ["fetch", "url", "page", "browse"],
44
+ icon: "globe",
45
+ },
46
+ {
47
+ id: "read_document",
48
+ name: "Read a document",
49
+ summary: "Open an uploaded document and quote it",
50
+ category: "documents",
51
+ keywords: ["file", "open", "document", "text"],
52
+ icon: "file",
53
+ },
54
+ {
55
+ id: "write_document",
56
+ name: "Write a document",
57
+ summary: "Draft a new document from this conversation",
58
+ category: "documents",
59
+ keywords: ["file", "save", "create", "edit"],
60
+ icon: "file",
61
+ },
62
+ {
63
+ id: "list_documents",
64
+ name: "List documents",
65
+ summary: "See what has been uploaded to this conversation",
66
+ category: "documents",
67
+ keywords: ["ls", "library", "folder", "browse"],
68
+ icon: "file",
69
+ },
70
+ {
71
+ id: "generate_image",
72
+ name: "Generate an image",
73
+ summary: "Draw a picture from a description",
74
+ category: "generation",
75
+ keywords: ["draw", "picture", "render", "illustrate"],
76
+ icon: "image",
77
+ },
78
+ {
79
+ id: "run_code",
80
+ name: "Run code",
81
+ summary: "Execute a snippet in a sandbox and report what it printed",
82
+ category: "code",
83
+ keywords: ["python", "sandbox", "execute", "script"],
84
+ icon: "terminal",
85
+ },
86
+ {
87
+ id: "sandbox_info",
88
+ name: "Sandbox details",
89
+ summary: "Report the runtime and which packages are installed",
90
+ category: "code",
91
+ keywords: ["runtime", "system", "packages", "versions"],
92
+ icon: "terminal",
93
+ },
94
+ ];
95
+
96
+ /**
97
+ * The arsenal without speech, as a server with no STT engine reports it.
98
+ * Registration is conditional, so the catalogue differs per deployment.
99
+ */
100
+ export const MOCK_TOOL_CATALOGUE_NO_STT: readonly ToolCatalogEntry[] =
101
+ MOCK_TOOL_CATALOGUE.filter((entry) => entry.category !== "stt");
@@ -0,0 +1,81 @@
1
+ import type { VoiceCapability } from "../core";
2
+
3
+ export interface MockVoiceOptions {
4
+ /**
5
+ * The sentence to dictate, revealed word by word.
6
+ *
7
+ * Each step emits the phrase so far as an INTERIM result, exactly as a real
8
+ * engine does — it re-sends its whole current guess rather than the delta.
9
+ * That is the behaviour `VoiceSession` exists to absorb, so a mock that
10
+ * emitted deltas would make the story agree for the wrong reason.
11
+ */
12
+ readonly phrase?: string;
13
+ /** Delay between words. Slow enough that `Recording` is visible on screen. */
14
+ readonly delayMs?: number;
15
+ /**
16
+ * Fail instead of dictating — a denied microphone permission.
17
+ *
18
+ * The realistic failure: the user presses the mic and clicks "Block". It
19
+ * settles on the snapshot as `error`; nothing throws.
20
+ */
21
+ readonly failWith?: string;
22
+ }
23
+
24
+ /**
25
+ * A scripted `VoiceCapability` — no microphone, no Web Speech API.
26
+ *
27
+ * A story that needed real speech is a story nobody can run: it would demand a
28
+ * permission prompt, a quiet room, and an engine that Firefox does not have. So
29
+ * the mock owns the timing and the transcript, and the story asserts what the
30
+ * COMPOSER does with them.
31
+ */
32
+ export function mockVoice({
33
+ phrase = "summarise the last three replies",
34
+ delayMs = 260,
35
+ failWith,
36
+ }: MockVoiceOptions = {}): VoiceCapability {
37
+ let timer: ReturnType<typeof setTimeout> | undefined;
38
+ let abort: (() => void) | undefined;
39
+
40
+ return {
41
+ start: (onText, signal) =>
42
+ new Promise((resolve, reject) => {
43
+ if (failWith !== undefined) {
44
+ // Asynchronously, like a real permission denial: a synchronous throw
45
+ // would reject before the session has stored its controller, and the
46
+ // error would land on a session it can no longer match.
47
+ timer = setTimeout(() => reject(new Error(failWith)), delayMs);
48
+ return;
49
+ }
50
+
51
+ const words = phrase.split(" ");
52
+ let index = 0;
53
+
54
+ const step = () => {
55
+ if (signal.aborted) return;
56
+ index += 1;
57
+ const soFar = words.slice(0, index).join(" ");
58
+ if (index >= words.length) {
59
+ // The last emission COMMITS. Without a final result the session
60
+ // holds the whole sentence as interim, and a stop would still work
61
+ // but the story would never exercise the commit path.
62
+ onText(soFar, true);
63
+ resolve();
64
+ return;
65
+ }
66
+ onText(soFar, false);
67
+ timer = setTimeout(step, delayMs);
68
+ };
69
+
70
+ abort = () => resolve();
71
+ signal.addEventListener("abort", () => {
72
+ clearTimeout(timer);
73
+ abort?.();
74
+ });
75
+ timer = setTimeout(step, delayMs);
76
+ }),
77
+ stop: () => {
78
+ clearTimeout(timer);
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,286 @@
1
+ import { Brain, CornerDownLeft, Mic, Plus, Square, type LucideProps } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+
4
+ import {
5
+ Composer,
6
+ ComposerField,
7
+ ComposerPickedTools,
8
+ ComposerQueue,
9
+ ComposerSend,
10
+ ComposerThink,
11
+ ComposerTools,
12
+ ComposerVoice,
13
+ useDraftPersistence,
14
+ type AskReplace,
15
+ } from "../composer";
16
+ import { ChatReplyChip } from "./chat-reply-chip";
17
+ import { ChatSuggestions } from "./chat-suggestions";
18
+
19
+ export interface ChatComposerProps {
20
+ /** Sent prompts for ↑ recall, OLDEST first. Empty ⇒ the arrows never navigate. */
21
+ readonly history?: readonly string[];
22
+ /** The dictation language, when the host offers a picker. */
23
+ readonly speechLanguage?: {
24
+ readonly value: string;
25
+ readonly onChange: (language: string) => void;
26
+ };
27
+ /**
28
+ * Asked before a queued prompt interrupts the running turn.
29
+ *
30
+ * Passed through to `ComposerQueue`; the package acquires no dialog of its
31
+ * own. Absent ⇒ no interrupt control at all, because an unconfirmed one is
32
+ * the shape this must never ship.
33
+ */
34
+ readonly confirmSendQueuedNow?: () => Promise<boolean>;
35
+ /**
36
+ * Asked before a prompt taken back for editing overwrites a draft in progress.
37
+ *
38
+ * Three outcomes, not a confirm: "keep what I typed" and "keep both" are
39
+ * different answers, and a two-button dialog makes them the same one. Absent ⇒
40
+ * the take replaces the draft outright.
41
+ */
42
+ readonly askReplaceDraft?: AskReplace;
43
+ /**
44
+ * Room identity, to keep an unsent draft across a reload.
45
+ *
46
+ * Absent ⇒ no persistence, and the draft dies with the mount. The VALUE is
47
+ * the host's because this package has no notion of a room; two rooms given
48
+ * one string share a draft, which nothing here can detect.
49
+ */
50
+ readonly draftRoom?: string;
51
+ /** Extra controls, between the field and the mic. */
52
+ readonly toolbarEnd?: ReactNode;
53
+ /**
54
+ * Room-wide surfaces that belong ABOVE the field, inside the same column.
55
+ *
56
+ * An allowance banner and the agent's question describe the ROOM, not the
57
+ * draft, so they sit outside the box — but within the column and its
58
+ * positioning context, because a card that floats `bottom-full` grows upward
59
+ * from this slot's top edge. A host rendering them as a sibling of `<Chat>`
60
+ * puts them outside the reading column, at a different width from the
61
+ * conversation they belong to.
62
+ */
63
+ readonly above?: ReactNode;
64
+ /**
65
+ * Icon KEY → glyph for the empty room's starter rows.
66
+ *
67
+ * The host's, because the catalogue names glyphs as DATA — it crosses Go→TS
68
+ * and can name a picture but cannot be one — and this package ships no icon
69
+ * library. Absent, or an unmapped key, draws no glyph and the row still works.
70
+ */
71
+ readonly suggestionIcon?: (key: string) => ReactNode;
72
+ /**
73
+ * Icon KEY → glyph for a row in the `+` arsenal menu. Same seam, same reason.
74
+ */
75
+ readonly toolIcon?: (key: string) => ReactNode;
76
+ /**
77
+ * The heading for a tool CATEGORY key. Absent ⇒ the registry's raw key.
78
+ *
79
+ * The host's, because the keys are Go's (`stt`, `web`, `disk`) and their
80
+ * translations belong beside the catalogue that mints them: a mapping in this
81
+ * package would go stale the moment a new tool family is registered, and go
82
+ * stale SILENTLY — an unmapped key still renders.
83
+ */
84
+ readonly toolCategoryLabel?: (category: string) => string;
85
+ readonly className?: string;
86
+ }
87
+
88
+ /**
89
+ * Mounts the persistence effects, which need the draft from context. A component
90
+ * rather than a call in `ChatComposer`, because that body sits OUTSIDE
91
+ * `<Composer>` and `useComposer()` throws there. Renders nothing.
92
+ */
93
+ function DraftPersistence({ room }: { readonly room: string | undefined }) {
94
+ useDraftPersistence(room);
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * The composer, assembled.
100
+ *
101
+ * ONE assembly, so a surface cannot silently ship without the queue or the
102
+ * controls beside it — each of which was built, exported, and then left
103
+ * unrendered by a host that composed the parts by hand.
104
+ *
105
+ * The layout AND the copy are the package's; what a host varies is capabilities
106
+ * and data. Copy was the host's until adding one label proved to be a four-file
107
+ * change, and until a host assembled a translated string into a DOM selector —
108
+ * which broke the caret in one locale with types and tests green.
109
+ *
110
+ * `flex-wrap` with `items-end` rather than a measured grid: the controls share
111
+ * the field's row while the draft is short and wrap beneath it when it grows,
112
+ * which is layout doing the work with no measurement, no state and no frame lag.
113
+ */
114
+ export function ChatComposer({
115
+ history = [],
116
+ speechLanguage,
117
+ confirmSendQueuedNow,
118
+ askReplaceDraft,
119
+ draftRoom,
120
+ toolbarEnd,
121
+ above,
122
+ suggestionIcon,
123
+ toolIcon,
124
+ toolCategoryLabel,
125
+ className,
126
+ }: ChatComposerProps) {
127
+ return (
128
+ /*
129
+ The reading column and the positioning context, both owned HERE.
130
+
131
+ `chat-column` caps and centres each child so the composer and the
132
+ transcript agree on where the conversation is; the stylesheet used to ask
133
+ the HOST to put the same class on its own wrapper, which is a rule a host
134
+ can only forget silently — the two surfaces then sit at different widths
135
+ with nothing failing. `relative` anchors an ask card that grows upward
136
+ from this element's top edge (`bottom-full`); without a positioned
137
+ ancestor it resolves against the viewport and lands away from the
138
+ conversation it answers.
139
+
140
+ `pr-4` matches the padding the host drops on its right edge so the
141
+ transcript's scrollbar can reach the pane. Here rather than there because
142
+ only this element knows it is the composer's outer box.
143
+ */
144
+ <div className="chat-column chat-inset-r relative">
145
+ {above}
146
+ {/*
147
+ Part of the assembly, not a host slot — the same reason `ComposerQueue`
148
+ is: a surface composing this by hand ships without whichever piece it
149
+ forgot, and nothing fails. It is self-gating, so it costs every other
150
+ room one null. ABOVE the box because it describes the ROOM; the reply
151
+ chip sits inside it because it describes the DRAFT.
152
+ */}
153
+ <ChatSuggestions icon={suggestionIcon} />
154
+ <Composer
155
+ /*
156
+ Radius and inset are NOT here. They are one measurement, not two: the
157
+ outer radius has to equal the controls' plus the inset or the corners
158
+ stop running parallel, and a Tailwind pair states them as unrelated
159
+ numbers that drift apart on the next edit. `composer-box` derives one
160
+ from the other in the chat stylesheet, which also carries the
161
+ `:has(textarea[data-multiline])` rule — a selector that looks DOWN at a
162
+ descendant and acts on a different child, legible as CSS and unreadable
163
+ as a class list.
164
+ */
165
+ className={`composer-box flex cursor-text flex-wrap items-end gap-2 border border-border bg-background focus-within:border-ring ${className ?? ""}`}
166
+ >
167
+ <DraftPersistence room={draftRoom} />
168
+ <ChatReplyChip />
169
+ {/* Absent when empty, which is almost always. `w-full` for the same reason
170
+ the tray takes one: it describes the whole draft, not the field. */}
171
+ <ComposerQueue
172
+ confirmSendNow={confirmSendQueuedNow}
173
+ askReplace={askReplaceDraft}
174
+ className="w-full"
175
+ listClassName="flex flex-col gap-1"
176
+ // One class, styled in `styles/composer.css`. The row addresses its
177
+ // children by `data-action`, which a Tailwind arbitrary variant can only
178
+ // express as a quoted selector repeated per property — where a misplaced
179
+ // bracket reads as ordinary noise.
180
+ itemClassName="queued-row"
181
+ />
182
+ {/* BEFORE the field, where the reader starts. `composer-lead` is what
183
+ sends it DOWN with the other controls on a wrapped draft — the
184
+ stylesheet owns that, because a class list cannot say "while a
185
+ sibling's descendant is multiline". A `+`, not a wrench: any icon
186
+ naming a tool names the wrong one. */}
187
+ <div className="composer-lead order-1 flex min-w-0 shrink-0 items-center gap-1">
188
+ <ComposerTools icon={toolIcon} categoryLabel={toolCategoryLabel}>
189
+ <Plus className="size-4" aria-hidden />
190
+ </ComposerTools>
191
+ {/* Directly after the `+` that staged them, so a pick lands where the
192
+ user clicked. `min-w-0` lets it shrink rather than push the field
193
+ off the line; `tool-chips` carries the fades and hidden scrollbar. */}
194
+ <ComposerPickedTools
195
+ icon={toolIcon}
196
+ className="tool-chips flex min-w-0 gap-1 overflow-x-auto empty:hidden"
197
+ chipClassName="tool-chip"
198
+ />
199
+ </div>
200
+ <ComposerField
201
+ /*
202
+ `min-w-[60%]` on the WRAPPER, which is the element flex lays out.
203
+ Measured: without it the field held 83% of the composer and STOPPED —
204
+ a four-line draft wrapped inside 634px while 134px sat empty to its
205
+ right, because the controls never left the row for it to claim.
206
+ */
207
+ wrapperClassName="order-2 min-w-[60%]"
208
+ /*
209
+ `w-full`, NOT `flex-1`: the flex item is the positioned wrapper that
210
+ anchors the mention menu, and the textarea inside it is an ordinary
211
+ block, so `flex-1` here resolves against nothing and the box keeps a
212
+ textarea's intrinsic `cols` width.
213
+
214
+ The field reads at the TRANSCRIPT's size, from the same token. What
215
+ you type and what comes back are one conversation; two scales would
216
+ say otherwise.
217
+ */
218
+ /*
219
+ `pt-[3px] pb-[5px]`, not `py-1`: an OPTICAL correction, and the 8px
220
+ total is unchanged so the field still matches the 32px buttons beside
221
+ it. SF Pro's metrics are asymmetric — 10px ascent against 2px descent —
222
+ and a line box centres on those, not on cap height, so symmetric
223
+ padding rendered 10px of air above the glyphs and 7px below. Measured
224
+ by scanning the rendered ink, which is the only thing that sees this:
225
+ every box in the row already measured identical and bottom-aligned.
226
+ */
227
+ className="w-full resize-none bg-transparent pt-[3px] pb-[5px] text-[length:var(--chat-text)] leading-[var(--chat-leading)] outline-none placeholder:text-muted-foreground"
228
+ history={history}
229
+ />
230
+ {/*
231
+ The trailing controls; `+` leads the row rather than sitting here.
232
+ `flex-1`, not `w-full`: this SHARES the line with `+` while claiming the
233
+ rest of it, which keeps the send button at the far edge in both states.
234
+ */}
235
+ <div className="composer-controls order-3 flex flex-1 items-center gap-1">
236
+ {/*
237
+ `ml-auto` rather than `justify-end` on the row: `+` holds the left
238
+ edge, so the row cannot end-align as a whole — this group has to push
239
+ itself right against whatever leads it.
240
+ */}
241
+ <div className="ml-auto flex shrink-0 items-center gap-1">
242
+ {/* Anything a host puts here MUST render `ComposerButton`, including a
243
+ menu or popover trigger, which wraps it. These controls belong to
244
+ three packages and that component is the one place they agree on
245
+ size, radius and hover; `wide` is the variant that carries text. */}
246
+ {toolbarEnd}
247
+ {/* Renders nothing without a `think` capability. Only a third of the
248
+ catalogue accepts the request field behind it, so the host decides
249
+ per active model — an always-present toggle would be inert on the
250
+ rest, which reads as broken rather than unavailable. */}
251
+ <ComposerThink className="aria-pressed:text-primary">
252
+ {() => <Brain className="size-4" aria-hidden />}
253
+ </ComposerThink>
254
+ {/* Size, radius, hover and focus come from `ComposerButton`, which both
255
+ of these render.
256
+
257
+ Renders nothing without a `voice` capability — the host decides
258
+ whether a microphone exists at all.
259
+
260
+ `composer-mic` carries the whole recording appearance — the tumbling
261
+ glyph, the ring, and the red that must outrank hover. All three live
262
+ in `styles/composer.css`: an `aria-pressed:` utility ties with the
263
+ button's own `hover:text-foreground` on specificity, so source order
264
+ decided the colour and the glyph went white under the pointer. */}
265
+ <ComposerVoice className="composer-mic" language={speechLanguage}>
266
+ {(recording) => (recording ? <StopGlyph /> : <Mic className="size-4" aria-hidden />)}
267
+ </ComposerVoice>
268
+ {/* A GLYPH is required. Without children this renders an empty pill — a
269
+ button with no affordance and no indication that a turn can be
270
+ stopped. */}
271
+ <ComposerSend>
272
+ {(streaming) =>
273
+ streaming ? <StopGlyph /> : <CornerDownLeft className="size-4" aria-hidden />
274
+ }
275
+ </ComposerSend>
276
+ </div>
277
+ </div>
278
+ </Composer>
279
+ </div>
280
+ );
281
+ }
282
+
283
+ /** Filled, so "stop" reads as a state rather than an outline among outlines. */
284
+ function StopGlyph(props: LucideProps) {
285
+ return <Square className="size-3.5 fill-current" aria-hidden {...props} />;
286
+ }