@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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/package.json +95 -0
- package/src/composer/composer.tsx +100 -0
- package/src/composer/context.ts +33 -0
- package/src/composer/controls/button.tsx +104 -0
- package/src/composer/controls/language-menu.tsx +123 -0
- package/src/composer/controls/send.tsx +83 -0
- package/src/composer/controls/think.tsx +50 -0
- package/src/composer/controls/tools.tsx +130 -0
- package/src/composer/controls/tooltip.tsx +10 -0
- package/src/composer/controls/voice.tsx +266 -0
- package/src/composer/field/field.tsx +140 -0
- package/src/composer/field/input.tsx +214 -0
- package/src/composer/field/keys.ts +44 -0
- package/src/composer/field/use-autosize.ts +134 -0
- package/src/composer/focus-field.ts +27 -0
- package/src/composer/index.ts +86 -0
- package/src/composer/stories/field.tsx +112 -0
- package/src/composer/stories/harness.tsx +127 -0
- package/src/composer/tools/menu.tsx +129 -0
- package/src/composer/tools/rows.tsx +180 -0
- package/src/composer/tools/use-menu-nav.ts +66 -0
- package/src/composer/tools/use-recent-tools.ts +30 -0
- package/src/composer/tray/banner.tsx +157 -0
- package/src/composer/tray/command-progress.tsx +116 -0
- package/src/composer/tray/picked-tools.tsx +104 -0
- package/src/composer/tray/queue.tsx +202 -0
- package/src/composer/tray/use-replace-text.ts +58 -0
- package/src/composer/use-draft-persistence.ts +79 -0
- package/src/content/index.ts +16 -0
- package/src/content/media-content.tsx +38 -0
- package/src/content/response.tsx +221 -0
- package/src/core/adapter.ts +29 -0
- package/src/core/capabilities.ts +286 -0
- package/src/core/chunks.ts +66 -0
- package/src/core/content/incomplete-math.ts +118 -0
- package/src/core/content/index.ts +9 -0
- package/src/core/draft-storage.ts +62 -0
- package/src/core/events.ts +88 -0
- package/src/core/focus/index.ts +10 -0
- package/src/core/focus/roving.ts +56 -0
- package/src/core/group-parts.ts +80 -0
- package/src/core/index.ts +34 -0
- package/src/core/links/index.ts +33 -0
- package/src/core/links/prose.ts +191 -0
- package/src/core/links/scheme.ts +98 -0
- package/src/core/links/tool-id.ts +26 -0
- package/src/core/media/allowed-src.ts +138 -0
- package/src/core/media/block.ts +254 -0
- package/src/core/media/index.ts +22 -0
- package/src/core/media/kinds.ts +85 -0
- package/src/core/outbound.ts +86 -0
- package/src/core/parts.ts +169 -0
- package/src/core/queue/index.ts +15 -0
- package/src/core/queue/queue.ts +100 -0
- package/src/core/recall/boundary.ts +77 -0
- package/src/core/recall/from-transcript.ts +47 -0
- package/src/core/recall/index.ts +23 -0
- package/src/core/recall/ladder.ts +171 -0
- package/src/core/reload-decision.ts +36 -0
- package/src/core/scroll/index.ts +17 -0
- package/src/core/scroll/missed.ts +106 -0
- package/src/core/scroll/sent.ts +54 -0
- package/src/core/text-replace.ts +20 -0
- package/src/core/tool-name.ts +31 -0
- package/src/core/tool-picks-set.ts +48 -0
- package/src/core/tool-picks.ts +142 -0
- package/src/core/tool-prompt.ts +47 -0
- package/src/core/tools/capability.ts +34 -0
- package/src/core/tools/catalogue.ts +109 -0
- package/src/core/tools/index.ts +17 -0
- package/src/core/tools/layout.ts +44 -0
- package/src/core/tools/nav.ts +32 -0
- package/src/core/tools/rank.ts +60 -0
- package/src/core/tools/recent.ts +39 -0
- package/src/core/transcript.ts +301 -0
- package/src/core/voice/earcons.ts +58 -0
- package/src/core/voice/index.ts +26 -0
- package/src/core/voice/language-search.ts +62 -0
- package/src/core/voice/languages.ts +267 -0
- package/src/core/voice/resolve-language.ts +93 -0
- package/src/core/voice/session.ts +196 -0
- package/src/core/voice/web-speech.ts +170 -0
- package/src/i18n/index.ts +87 -0
- package/src/i18n/locales/ar.ts +97 -0
- package/src/i18n/locales/da.ts +90 -0
- package/src/i18n/locales/de.ts +90 -0
- package/src/i18n/locales/en.ts +90 -0
- package/src/i18n/locales/es.ts +90 -0
- package/src/i18n/locales/fr.ts +90 -0
- package/src/i18n/locales/it.ts +90 -0
- package/src/i18n/locales/ja.ts +83 -0
- package/src/i18n/locales/ko.ts +83 -0
- package/src/i18n/locales/nl.ts +90 -0
- package/src/i18n/locales/no.ts +90 -0
- package/src/i18n/locales/pl.ts +96 -0
- package/src/i18n/locales/ptBR.ts +90 -0
- package/src/i18n/locales/ru.ts +96 -0
- package/src/i18n/locales/sv.ts +90 -0
- package/src/i18n/locales/tr.ts +89 -0
- package/src/i18n/locales/zh.ts +83 -0
- package/src/i18n/plural.ts +62 -0
- package/src/i18n/types.ts +257 -0
- package/src/index.ts +18 -0
- package/src/primitives/index.ts +43 -0
- package/src/primitives/message.tsx +85 -0
- package/src/primitives/part-registry.tsx +44 -0
- package/src/primitives/stories-harness.tsx +118 -0
- package/src/primitives/suggestions.tsx +112 -0
- package/src/primitives/tool-call.tsx +145 -0
- package/src/primitives/tooltip.tsx +48 -0
- package/src/primitives/transcript.tsx +285 -0
- package/src/primitives/use-dismiss.ts +100 -0
- package/src/primitives/use-missed-count.ts +30 -0
- package/src/primitives/use-overflows.ts +69 -0
- package/src/primitives/use-return-on-send.ts +29 -0
- package/src/primitives/use-roving-focus.ts +69 -0
- package/src/primitives/use-select-all-transcript.ts +111 -0
- package/src/primitives/use-stick-to-bottom.ts +83 -0
- package/src/primitives/use-turn-activity.ts +25 -0
- package/src/store/context.ts +28 -0
- package/src/store/hooks.ts +215 -0
- package/src/store/index.ts +31 -0
- package/src/store/provider.tsx +97 -0
- package/src/store/store.ts +394 -0
- package/src/store/subscribers.ts +55 -0
- package/src/store/surfaces.ts +174 -0
- package/src/store/use-draft.ts +168 -0
- package/src/store/use-queue.ts +61 -0
- package/src/store/use-recall.ts +264 -0
- package/src/store/use-reload-on-revision.ts +67 -0
- package/src/store/use-speech-language.ts +84 -0
- package/src/store/use-voice.ts +116 -0
- package/src/styles/block.css +128 -0
- package/src/styles/composer.css +449 -0
- package/src/styles/index.css +47 -0
- package/src/styles/markdown.css +368 -0
- package/src/styles/surface.css +102 -0
- package/src/styles/tool.css +128 -0
- package/src/styles/turn-mark.css +97 -0
- package/src/testing/index.ts +15 -0
- package/src/testing/mock-adapter.ts +69 -0
- package/src/testing/mock-tools.ts +101 -0
- package/src/testing/mock-voice.ts +81 -0
- package/src/ui/chat-composer.tsx +286 -0
- package/src/ui/chat-reply-chip.tsx +42 -0
- package/src/ui/chat-suggestions.tsx +87 -0
- package/src/ui/chat-transcript.tsx +157 -0
- package/src/ui/chat.tsx +197 -0
- package/src/ui/collapsible-body.tsx +180 -0
- package/src/ui/collapsible-row.tsx +81 -0
- package/src/ui/day-divider.tsx +49 -0
- package/src/ui/index.ts +54 -0
- package/src/ui/jump-to-latest.tsx +60 -0
- package/src/ui/link-chip.tsx +73 -0
- package/src/ui/message-actions.tsx +163 -0
- package/src/ui/message-quote.tsx +47 -0
- package/src/ui/message.tsx +295 -0
- package/src/ui/pending.tsx +85 -0
- package/src/ui/renderers/data.tsx +63 -0
- package/src/ui/renderers/reasoning.tsx +73 -0
- package/src/ui/renderers/tool.tsx +176 -0
- package/src/ui/turn-mark.tsx +31 -0
- package/src/utils/day-heading.ts +46 -0
- package/src/utils/error-message.ts +10 -0
- package/src/utils/index.ts +12 -0
- package/src/utils/markdown-text.ts +175 -0
- package/src/widgets/index.ts +14 -0
- 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
|
+
}
|