@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Reforms.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # @djangocfg/widget-chat
2
+
3
+ The chat surface: transcript, composer, tool calls, streaming markdown.
4
+ Transport-agnostic — the host supplies an adapter and the package supplies
5
+ everything a chat looks like.
6
+
7
+ Text-only by design. There is no attaching, no pasting a file, no file part in
8
+ the transcript: a message is prose, plus whatever the model streams back.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pnpm add @djangocfg/widget-chat @djangocfg/ui-core
14
+ ```
15
+
16
+ ```tsx
17
+ import { Chat } from '@djangocfg/widget-chat';
18
+ import '@djangocfg/widget-chat/styles';
19
+
20
+ <Chat
21
+ adapter={myAdapter}
22
+ capabilities={{ tools: myTools, voice: myVoice }}
23
+ renderers={myRenderers}
24
+ />
25
+ ```
26
+
27
+ Every capability is optional and absent means absent, never stubbed: a chat with
28
+ none of them is a working read-and-send surface, not a degraded one. The nine
29
+ are `suggestions`, `tools`, `retry`, `cancelRun`, `voice`, `voiceSounds`,
30
+ `think`, `media` and `mentionNames`.
31
+
32
+ ## Layers
33
+
34
+ Layers import strictly downward and each is importable alone.
35
+
36
+ | Subpath | Layer | React? |
37
+ |---|---|---|
38
+ | `./core` | the model: transcript, parts, links, queue, recall, voice | no |
39
+ | `./store` | the store and its React bindings | yes |
40
+ | `./primitives` | unopinionated pieces and hooks | yes |
41
+ | `./composer` | the input surface | yes |
42
+ | `./content` | markdown rendering (streamdown + shiki + katex) | yes |
43
+ | `./ui` | the assembled components a host mounts | yes |
44
+ | `./widgets` | transcript blocks: media |
45
+ | `./utils`, `./i18n`, `./testing`, `./styles` | helpers, 17 locales, mocks, CSS |
46
+
47
+ **`./content` is not re-exported from the root, and that is the one deliberate
48
+ exclusion.** It pulls streamdown plus shiki and katex — a host rendering a
49
+ read-only transcript, or supplying its own markdown renderer, must not pay for a
50
+ syntax highlighter.
51
+
52
+ ## The test that decides ownership
53
+
54
+ > **A file belongs here when a second product would have to write it again,
55
+ > unchanged.**
56
+
57
+ Not "it is about chat". An adapter speaking one server's vocabulary is entirely
58
+ about chat and belongs to the HOST. A transcript shortcut every consumer needs
59
+ byte for byte belongs here.
60
+
61
+ Two corollaries, both argued the other way and settled by a defect:
62
+
63
+ * **Needing i18n does not make something the host's.** The package owns its
64
+ 17-locale dictionary — a typed `ChatCopy` object, not a `t(key)` resolver.
65
+ Copy used to arrive as props; that made adding one string a four-file
66
+ change, and it let a host assemble a translated string into a DOM selector,
67
+ breaking the composer's caret in one locale with types and tests green.
68
+ * **Needing a domain type DOES make it the host's.** Anything mapping a
69
+ server's rows stays outside, because holding it here would point this
70
+ package at a wire contract — the arrow the structure exists to avoid.
71
+
72
+ ## Why it exists
73
+
74
+ The predecessor was 947 lines with **83 near-all-optional props** plus 16 hooks
75
+ — a combinatorial explosion nobody could reason about. So: **new capability adds
76
+ a part type, a capability, or a widget — never a prop on `<Chat>`**, whose count
77
+ is gated by a test.
78
+
79
+ ## The link scheme
80
+
81
+ A chip is written as a markdown link, sent as text, parsed back, and re-drawn.
82
+ That round trip needs a URL scheme the host can distinguish from an ordinary
83
+ link. The scheme is yours; the one shape is ours:
84
+
85
+ ```ts
86
+ import { configureMentionScheme } from '@djangocfg/widget-chat/core';
87
+
88
+ configureMentionScheme('myapp'); // once, at startup, before the first render
89
+ // a picked tool then reads myapp://tool/<id>
90
+ ```
91
+
92
+ One shape, because a tool is the only thing this package both writes and parses.
93
+ A host that lets the user name people, documents or tickets addresses them with
94
+ its own scheme and resolves them itself — guessing at that grammar here would
95
+ give every host a round trip it did not ask for.
96
+
97
+ Configure it **before the first render**. Two places read the scheme back — the
98
+ hardened protocol allowlist in `content/response.tsx` and the media `src`
99
+ allowlist in `core/media/allowed-src.ts` — and the first reads it lazily for
100
+ exactly this reason. Setting it later leaves already-written links addressing
101
+ the old scheme, which then render as plain text.
102
+
103
+ ## Documentation
104
+
105
+ This file is the contract. [`@docs/`](./@docs/README.md) is the long form —
106
+ six pages, each covering one seam:
107
+
108
+ | Page | Covers |
109
+ |---|---|
110
+ | [getting-started](./@docs/getting-started.md) | Install, the adapter, the three things that fail silently. |
111
+ | [architecture](./@docs/architecture.md) | The layers, the seven props, the executable gates. |
112
+ | [streaming](./@docs/streaming.md) | `StreamChunk`, the reducer, cancellation. |
113
+ | [capabilities](./@docs/capabilities.md) | The nine capabilities and the extension seams. |
114
+ | [composer](./@docs/composer.md) | Keys, recall, autosize, drafts. |
115
+ | [styling](./@docs/styling.md) | The structure/appearance split and theming. |
116
+
117
+ `CLAUDE.md` is the third document and answers a different question: what is
118
+ easy to break.
119
+
120
+ ## Verify
121
+
122
+ ```bash
123
+ pnpm --filter @djangocfg/widget-chat check-types test
124
+ ```
package/package.json ADDED
@@ -0,0 +1,95 @@
1
+ {
2
+ "name": "@djangocfg/widget-chat",
3
+ "version": "0.1.1",
4
+ "description": "A transport-agnostic chat surface: a pure core, a store, primitives, a composer, and the assembled components a host mounts",
5
+ "keywords": [
6
+ "chat",
7
+ "ai",
8
+ "streaming",
9
+ "markdown",
10
+ "composer",
11
+ "react",
12
+ "typescript"
13
+ ],
14
+ "author": {
15
+ "name": "DjangoCFG",
16
+ "url": "https://djangocfg.com"
17
+ },
18
+ "homepage": "https://djangocfg.com",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/markolofsen/django-cfg.git",
22
+ "directory": "widgets/chat"
23
+ },
24
+ "license": "MIT",
25
+ "type": "module",
26
+ "main": "./src/index.ts",
27
+ "types": "./src/index.ts",
28
+ "exports": {
29
+ ".": "./src/index.ts",
30
+ "./styles": "./src/styles/index.css",
31
+ "./core": "./src/core/index.ts",
32
+ "./store": "./src/store/index.ts",
33
+ "./primitives": "./src/primitives/index.ts",
34
+ "./widgets": "./src/widgets/index.ts",
35
+ "./content": "./src/content/index.ts",
36
+ "./composer": "./src/composer/index.ts",
37
+ "./ui": "./src/ui/index.ts",
38
+ "./utils": "./src/utils/index.ts",
39
+ "./i18n": "./src/i18n/index.ts",
40
+ "./testing": "./src/testing/index.ts"
41
+ },
42
+ "files": [
43
+ "src",
44
+ "!src/**/*.test.ts",
45
+ "!src/**/*.test.tsx",
46
+ "!src/**/*.stories.tsx",
47
+ "README.md",
48
+ "LICENSE"
49
+ ],
50
+ "scripts": {
51
+ "check": "tsc --noEmit",
52
+ "check-types": "tsc --noEmit",
53
+ "test": "vitest run",
54
+ "test:watch": "vitest"
55
+ },
56
+ "dependencies": {
57
+ "@djangocfg/widget-kit": "^0.1.1",
58
+ "@djangocfg/widget-media": "^0.1.1",
59
+ "@streamdown/code": "^1.1.1",
60
+ "@streamdown/math": "^1.0.2",
61
+ "mdast-util-to-string": "^4.0.0",
62
+ "rehype-harden": "^1.1.8",
63
+ "remark-parse": "^11.0.0",
64
+ "unified": "^11.0.5",
65
+ "use-stick-to-bottom": "^1.1.6"
66
+ },
67
+ "peerDependencies": {
68
+ "@djangocfg/i18n": "^2.1.542",
69
+ "@djangocfg/ui-core": "^2.1.542",
70
+ "lucide-react": "^0.545.0",
71
+ "react": "^19.0.0",
72
+ "remark-gfm": "^4.0.0",
73
+ "streamdown": "^2.6.0"
74
+ },
75
+ "devDependencies": {
76
+ "@djangocfg/i18n": "^2.1.542",
77
+ "@djangocfg/typescript-config": "^2.1.542",
78
+ "@djangocfg/ui-core": "^2.1.542",
79
+ "@storybook/react-vite": "^10.5.0",
80
+ "@types/node": "^25.9.5",
81
+ "@types/react": "19.2.15",
82
+ "@types/react-dom": "19.2.3",
83
+ "lucide-react": "0.545.0",
84
+ "react": "19.2.7",
85
+ "react-dom": "19.2.7",
86
+ "remark-gfm": "4.0.1",
87
+ "storybook": "^10.5.0",
88
+ "streamdown": "2.6.0",
89
+ "typescript": "^5.9.3",
90
+ "vitest": "^4.1.10"
91
+ },
92
+ "publishConfig": {
93
+ "access": "public"
94
+ }
95
+ }
@@ -0,0 +1,100 @@
1
+ import { useCallback, useEffect, useRef, type FormEvent, type PointerEvent, type ReactNode } from "react";
2
+
3
+ import { useChatSurfaces, useDraft } from "../store";
4
+ import { ComposerContext } from "./context";
5
+ import { focusComposerField } from "./focus-field";
6
+
7
+ export interface ComposerProps {
8
+ readonly children: ReactNode;
9
+ readonly className?: string;
10
+ /**
11
+ * A press on the composer's own padding focuses the field, caret at the end.
12
+ * Default true: the box is mostly padding, and clicking its middle did
13
+ * nothing. Opt out when the composer is not the only thing in the form.
14
+ */
15
+ readonly focusOnPress?: boolean;
16
+ }
17
+
18
+ /**
19
+ * The composer root: a form, a draft, and nothing else.
20
+ *
21
+ * Its prop count is capped on purpose. Everything that would have been
22
+ * `sendLabel` / `renderChip` lives in a part the host chooses to render —
23
+ * `@rules/budgets.md`, "a boolean prop is a component someone was too lazy to write".
24
+ *
25
+ * A real `<form>` rather than a div with a click handler: that is what makes
26
+ * Enter-to-submit, implicit submission and a screen reader's form semantics work
27
+ * without re-implementing any of them.
28
+ */
29
+ export function Composer({ children, className, focusOnPress = true }: ComposerProps) {
30
+ const draft = useDraft();
31
+ const { send } = draft;
32
+ const surfaces = useChatSurfaces();
33
+
34
+ // Appending, never replacing: a mention from the transcript is one more
35
+ // addressee, not a new draft. Spaced on both sides so tokens cannot fuse.
36
+ // Read through a ref: depending on the text would re-register this on every
37
+ // keystroke.
38
+ const { setText } = draft;
39
+ const textRef = useRef(draft.draft.text);
40
+ textRef.current = draft.draft.text;
41
+ const registerMention = surfaces?.registerMention;
42
+ useEffect(() => {
43
+ if (registerMention === undefined) return;
44
+ registerMention((token: string) => {
45
+ const current = textRef.current;
46
+ const lead = current === "" || /\s$/.test(current) ? "" : " ";
47
+ setText(`${current}${lead}${token} `);
48
+ });
49
+ return () => registerMention(null);
50
+ }, [registerMention, setText]);
51
+
52
+ const { setReplyTo } = draft;
53
+ const registerReplyTo = surfaces?.registerReplyTo;
54
+ useEffect(() => {
55
+ if (registerReplyTo === undefined) return;
56
+ registerReplyTo(setReplyTo);
57
+ return () => registerReplyTo(null);
58
+ }, [registerReplyTo, setReplyTo]);
59
+
60
+ const onSubmit = useCallback(
61
+ (event: FormEvent<HTMLFormElement>) => {
62
+ // Always: a native submit would navigate the page away.
63
+ event.preventDefault();
64
+ send();
65
+ },
66
+ [send],
67
+ );
68
+
69
+ const onPointerDown = useCallback(
70
+ (event: PointerEvent<HTMLFormElement>) => {
71
+ if (!focusOnPress) return;
72
+ // ONLY the form's own padding. A press that landed on a child is that
73
+ // child's — without this, clicking the mic or a chip also focuses the
74
+ // field and steals the caret from the control the user aimed at.
75
+ if (event.target !== event.currentTarget) return;
76
+ // Prevent the default so the press does not blur the field it is about to
77
+ // focus; a form's padding is not focusable, but the mousedown still
78
+ // clears the current selection.
79
+ event.preventDefault();
80
+ focusComposerField(event.currentTarget);
81
+ },
82
+ [focusOnPress],
83
+ );
84
+
85
+ return (
86
+ <ComposerContext.Provider value={draft}>
87
+ <form
88
+ // Registered, not exposed as a ref prop: a host needs the field to put
89
+ // the caret back after a modal closes, and threading a ref out would
90
+ // spend a prop on this capped component for one host's benefit.
91
+ ref={surfaces?.registerComposer}
92
+ className={className}
93
+ onPointerDown={onPointerDown}
94
+ onSubmit={onSubmit}
95
+ >
96
+ {children}
97
+ </form>
98
+ </ComposerContext.Provider>
99
+ );
100
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The composer's own context, deliberately separate from `ChatContext`.
3
+ *
4
+ * `useDraft()` holds per-composer state (the text being typed, the chips not yet
5
+ * sent). Putting it on the chat context would make two composers on one screen
6
+ * share a draft, and would re-render every transcript row on each keystroke —
7
+ * the chat context is read by rows that must not care about typing.
8
+ */
9
+
10
+ import { createContext, useContext } from "react";
11
+
12
+ import type { DraftApi } from "../store/use-draft";
13
+
14
+ export const ComposerContext = createContext<DraftApi | null>(null);
15
+
16
+ export function useComposer(): DraftApi {
17
+ const value = useContext(ComposerContext);
18
+ if (value === null) {
19
+ throw new Error("composer parts must be used inside <Composer>");
20
+ }
21
+ return value;
22
+ }
23
+
24
+ /**
25
+ * The draft when there is one, null otherwise.
26
+ *
27
+ * For a part that must decide whether to render at all BEFORE it needs the
28
+ * draft: hooks cannot run conditionally, so the throwing variant would fire
29
+ * before an absent capability could return null.
30
+ */
31
+ export function useOptionalComposer(): DraftApi | null {
32
+ return useContext(ComposerContext);
33
+ }
@@ -0,0 +1,104 @@
1
+ import type { ButtonHTMLAttributes, ReactNode, Ref } from "react";
2
+
3
+ /**
4
+ * One control in the composer's toolbar row.
5
+ *
6
+ * Four buttons arrived here with four different answers to "what does a control
7
+ * look like": the engine chip tinted itself with `accent` — BLUE in this theme,
8
+ * so hovering lit up a filled pill beside the blue send button — the
9
+ * dictation-language caret used `accent` too, the mic changed only its text
10
+ * colour and so appeared to have no hit box at all, and send is permanently
11
+ * filled with no hover at all. Read as a row they were four unrelated widgets.
12
+ *
13
+ * A component rather than a `.composer-box button` rule is what makes that
14
+ * stay fixed: three of these are owned by DIFFERENT packages, so a descendant
15
+ * selector would style whatever they happen to render and silently lose to any
16
+ * utility class they carry. Asking for the component is a decision each caller
17
+ * makes once; inheriting from an ancestor is a coincidence of nesting.
18
+ *
19
+ * It renders a `<button>` and nothing else — no context, no portal — so a host
20
+ * that needs a menu, a tooltip or a popover wraps this, and every one of them
21
+ * still agrees about size, radius and hover.
22
+ */
23
+
24
+ export type ComposerButtonTone = "neutral" | "primary";
25
+
26
+ export interface ComposerButtonProps
27
+ extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className"> {
28
+ /**
29
+ * `primary` is the SEND action, and there is exactly one of it. A second
30
+ * filled control in the row would make the composer offer two equal answers
31
+ * to "what does Enter do".
32
+ */
33
+ readonly tone?: ComposerButtonTone;
34
+ /**
35
+ * Wider than square, for a control that carries text (the engine chip).
36
+ *
37
+ * The default is the 32px square every icon control uses; text needs room
38
+ * either side of it, and stretching the square by padding alone would make
39
+ * the two kinds disagree about height.
40
+ */
41
+ readonly wide?: boolean;
42
+ readonly className?: string;
43
+ readonly children?: ReactNode;
44
+ readonly ref?: Ref<HTMLButtonElement>;
45
+ }
46
+
47
+ const BASE =
48
+ // The design system's control radius, matching the box around them: both are
49
+ // rounded rectangles from the same scale, so the row reads as one object.
50
+ // (It was `rounded-full` while the box was a capsule — the box drives this.)
51
+ "shrink-0 items-center justify-center rounded-[var(--radius-control,0.625rem)] transition-colors " +
52
+ // Height DERIVED from the field beside it, never a literal — a hardcoded 32px
53
+ // silently went 2px short the moment the body text moved from 15px to 16. The
54
+ // expression lives in `composer.css`, which needs the same number to keep the
55
+ // box's capsule radius concentric with these circles; two copies of one
56
+ // measurement is exactly how the corners stop matching.
57
+ "h-[var(--chat-composer-control-h,2.125rem)] disabled:opacity-40 " +
58
+ // Focus is the host theme's ring, borrowed rather than imported — this
59
+ // package declares no dependency on a component library.
60
+ "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 " +
61
+ "focus-visible:outline-[var(--ring,currentColor)] " +
62
+ "[&_svg]:size-4 [&_svg]:shrink-0";
63
+
64
+ const TONES: Record<ComposerButtonTone, string> = {
65
+ // A quiet plate on hover, never a tint. `--accent` is the theme's highlight
66
+ // (blue here), and a filled blue plate beside the blue send button reads as a
67
+ // second primary action rather than as a hover.
68
+ neutral:
69
+ "text-muted-foreground not-disabled:hover:bg-muted not-disabled:hover:text-foreground " +
70
+ "aria-expanded:bg-muted aria-expanded:text-foreground",
71
+ // Already filled, so it answers with depth instead of a plate.
72
+ primary: "bg-primary text-primary-foreground not-disabled:hover:brightness-95",
73
+ };
74
+
75
+ export function ComposerButton({
76
+ tone = "neutral",
77
+ wide = false,
78
+ className,
79
+ children,
80
+ ref,
81
+ type = "button",
82
+ ...rest
83
+ }: ComposerButtonProps) {
84
+ // `flex` for the wide variant, `grid` for the square one — and the difference
85
+ // is load-bearing, not cosmetic. A single-column grid stacks its children in
86
+ // ROWS, so a chip carrying icon + label + caret put the caret on a third row
87
+ // and pushed it out through the button's fixed 32px height, below the
88
+ // composer's own edge. Flex lays the same three out along the row, which is
89
+ // what `gap-1.5` was always describing.
90
+ // `aspect-square` rather than a second copy of the height expression: the
91
+ // square variant must stay square whatever that height resolves to, and
92
+ // repeating the calc is how the two drift apart.
93
+ const width = wide ? "flex gap-1.5 px-2.5" : "grid aspect-square";
94
+ return (
95
+ <button
96
+ {...rest}
97
+ ref={ref}
98
+ type={type}
99
+ className={`${BASE} ${width} ${TONES[tone]} ${className ?? ""}`}
100
+ >
101
+ {children}
102
+ </button>
103
+ );
104
+ }
@@ -0,0 +1,123 @@
1
+ import { useMemo, useState, type ChangeEvent, type ReactNode } from "react";
2
+
3
+ import { WEB_SPEECH_LANGUAGES, matchesLanguageQuery } from "../../core";
4
+
5
+ export interface LanguageMenuProps {
6
+ /** The tag in force, so the current row can be marked. */
7
+ readonly current: string;
8
+ readonly onSelect: (tag: string) => void;
9
+ /** Accessible name of the list. Copy is the host's — no locales here. */
10
+ readonly label: string;
11
+ readonly searchLabel: string;
12
+ readonly emptyLabel: string;
13
+ readonly className?: string;
14
+ /**
15
+ * Rendered above the search box. Absent ⇒ no header at all.
16
+ *
17
+ * The panel's OWNER supplies this, because only it knows whether the list is
18
+ * a whole surface or one view inside a larger one — and a back control that
19
+ * leads nowhere is worse than no header.
20
+ */
21
+ readonly header?: ReactNode;
22
+ }
23
+
24
+ interface Row {
25
+ readonly tag: string;
26
+ readonly name: string;
27
+ readonly region: string;
28
+ /** Everything searchable, lowercased once so the filter stays a substring test. */
29
+ readonly haystack: string;
30
+ }
31
+
32
+ /**
33
+ * Every dialect as a flat row.
34
+ *
35
+ * Flat rather than language-then-dialect: a two-level menu makes the common
36
+ * case — "I want Russian" — two interactions, and the search box already
37
+ * collapses the hierarchy better than nesting does.
38
+ *
39
+ * Built once at module scope. The catalogue is static, and rebuilding 60-odd
40
+ * languages worth of rows per render would be work done on every keystroke.
41
+ */
42
+ const ROWS: readonly Row[] = WEB_SPEECH_LANGUAGES.flatMap((language) =>
43
+ language.dialects.map((dialect) => ({
44
+ tag: dialect.code,
45
+ name: language.name,
46
+ region: dialect.region,
47
+ haystack:
48
+ `${language.name} ${language.englishName} ${language.iso} ${dialect.code} ${dialect.region}`.toLowerCase(),
49
+ })),
50
+ );
51
+
52
+ /** The display name for a tag, or the tag itself when it is not in the catalogue. */
53
+ export function languageLabel(tag: string): string {
54
+ const row = ROWS.find((candidate) => candidate.tag === tag);
55
+ if (row !== undefined) return row.name;
56
+ // A host may pin a tag the catalogue does not carry. Showing it raw is honest;
57
+ // showing nothing would hide the value that decides whether dictation works.
58
+ return tag;
59
+ }
60
+
61
+ /**
62
+ * The dictation language list.
63
+ *
64
+ * Search is LAYOUT-INDEPENDENT (`matchesLanguageQuery`): typing "hec" on an
65
+ * English layout while thinking «рус» finds Русский, because the physical keys
66
+ * are right and only the layout is wrong. That is the single most common way a
67
+ * bilingual user searches this list, and it is why the search index packs the
68
+ * native name, the English name, the ISO code, the tag and the region together.
69
+ */
70
+ export function LanguageMenu({
71
+ current,
72
+ onSelect,
73
+ label,
74
+ searchLabel,
75
+ emptyLabel,
76
+ className,
77
+ header,
78
+ }: LanguageMenuProps) {
79
+ const [query, setQuery] = useState("");
80
+
81
+ const rows = useMemo(
82
+ () => (query.trim() === "" ? ROWS : ROWS.filter((row) => matchesLanguageQuery(row.haystack, query))),
83
+ [query],
84
+ );
85
+
86
+ const onQuery = (event: ChangeEvent<HTMLInputElement>): void => setQuery(event.target.value);
87
+
88
+ return (
89
+ <div className={className}>
90
+ {header}
91
+ <input
92
+ type="search"
93
+ value={query}
94
+ onChange={onQuery}
95
+ aria-label={searchLabel}
96
+ className="border-border w-full border-b bg-transparent px-3 py-2 text-sm outline-none"
97
+ // Autofocus is correct HERE and almost nowhere else: the view is reached
98
+ // by a deliberate press and its only purpose is to be searched.
99
+ autoFocus
100
+ />
101
+ {rows.length === 0 ? (
102
+ <p className="text-muted-foreground px-3 py-2 text-sm">{emptyLabel}</p>
103
+ ) : (
104
+ <ul role="listbox" aria-label={label} className="max-h-64 overflow-y-auto py-1">
105
+ {rows.map((row) => (
106
+ <li key={row.tag}>
107
+ <button
108
+ type="button"
109
+ role="option"
110
+ aria-selected={row.tag === current}
111
+ onClick={() => onSelect(row.tag)}
112
+ className="hover:bg-accent flex w-full items-baseline gap-2 px-3 py-1.5 text-left text-sm aria-selected:font-medium"
113
+ >
114
+ <span className="min-w-0 truncate">{row.name}</span>
115
+ <span className="text-muted-foreground min-w-0 truncate text-xs">{row.region}</span>
116
+ </button>
117
+ </li>
118
+ ))}
119
+ </ul>
120
+ )}
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,83 @@
1
+ import { useCallback, type ReactNode } from "react";
2
+
3
+ import { useChatCopy } from "../../i18n";
4
+ import { useCapabilities, useTurn } from "../../store";
5
+ import { ComposerButton } from "./button";
6
+ import { ComposerTooltip } from "./tooltip";
7
+ import { useComposer } from "../context";
8
+
9
+ export interface ComposerSendProps {
10
+ readonly className?: string;
11
+ /**
12
+ * Icon or glyph. The accessible name comes from the labels, never from this.
13
+ *
14
+ * A FUNCTION of `isStreaming`, matching `ToolCallHeader` and the other
15
+ * primitives: the glyph must change with the role — an arrow that still says
16
+ * "send" while the button stops a turn is a lie about what clicking does.
17
+ * A static node cannot express that, and passing `isStreaming` back in as a
18
+ * prop would be a second reader of store state the button already holds.
19
+ */
20
+ readonly children?: (isStreaming: boolean) => ReactNode;
21
+ }
22
+
23
+ /**
24
+ * Send, or Stop while a turn streams.
25
+ *
26
+ * One component, not two and not a `mode` prop: whether a turn is in flight is
27
+ * store state, and a prop would let the host disagree with the store — a Send
28
+ * button next to a streaming reply, clicking which queues a second turn.
29
+ */
30
+ export function ComposerSend({ className, children }: ComposerSendProps) {
31
+ const { canSend, send } = useComposer();
32
+ const { isStreaming, stop } = useTurn();
33
+ const { cancelRun } = useCapabilities();
34
+ const copy = useChatCopy().composer;
35
+
36
+ const onClick = useCallback(() => {
37
+ if (isStreaming) {
38
+ // Both, in this order. `stop()` ends the local stream so the button
39
+ // answers immediately; `cancel()` ends the RUN, which the abort cannot
40
+ // reach — it is detached from its caller on purpose.
41
+ //
42
+ // Only from this button: `leaveRoom` also aborts, and cancelling there
43
+ // would truncate an answer because a tab closed, which is exactly what
44
+ // the detachment prevents.
45
+ stop();
46
+ void cancelRun?.cancel();
47
+ return;
48
+ }
49
+ send();
50
+ }, [isStreaming, stop, cancelRun, send]);
51
+
52
+ const label = isStreaming ? copy.stop : copy.send;
53
+ // Tooltipped like every other control in the row: the lone exception reads as
54
+ // an oversight. It also carries a fact the icon cannot — the key that sends,
55
+ // bound in `field/keys.ts`. Stop has no binding, so nothing is claimed for it.
56
+ const tooltip = isStreaming ? (
57
+ label
58
+ ) : (
59
+ <span className="flex flex-col gap-0.5">
60
+ <span>{label}</span>
61
+ <span className="opacity-70">{copy.sendShortcut}</span>
62
+ </span>
63
+ );
64
+
65
+ // `type="button"` in both roles — `ComposerButton`'s default: submit would
66
+ // fire the form's handler as well as this one, sending twice; and while
67
+ // streaming there is nothing to submit.
68
+ return (
69
+ <ComposerTooltip label={tooltip}>
70
+ <ComposerButton
71
+ tone="primary"
72
+ className={className}
73
+ aria-label={label}
74
+ // Stop must stay reachable on an empty draft — that is exactly when the
75
+ // user has sent and is waiting.
76
+ disabled={!isStreaming && !canSend}
77
+ onClick={onClick}
78
+ >
79
+ {children?.(isStreaming)}
80
+ </ComposerButton>
81
+ </ComposerTooltip>
82
+ );
83
+ }