basalt-ui 1.11.0 → 1.12.0
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/AGENTS.md +21 -21
- package/CHANGELOG.md +15 -0
- package/README.md +36 -27
- package/agent/rules/basalt-content.md +16 -3
- package/dist/agent/adapter.d.ts +146 -0
- package/dist/agent/adapter.d.ts.map +1 -0
- package/dist/agent/adapter.js +473 -0
- package/dist/agent/adapter.test.d.ts +2 -0
- package/dist/agent/adapter.test.d.ts.map +1 -0
- package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
- package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
- package/dist/agent/coalesce.d.ts +20 -4
- package/dist/agent/coalesce.d.ts.map +1 -1
- package/dist/agent/coalesce.js +76 -8
- package/dist/agent/coalesce.test.d.ts +2 -0
- package/dist/agent/coalesce.test.d.ts.map +1 -0
- package/dist/agent/foreign.test.d.ts +2 -0
- package/dist/agent/foreign.test.d.ts.map +1 -0
- package/dist/agent/id.d.ts +30 -0
- package/dist/agent/id.d.ts.map +1 -1
- package/dist/agent/id.js +16 -0
- package/dist/agent/id.test.d.ts +2 -0
- package/dist/agent/id.test.d.ts.map +1 -0
- package/dist/agent/index.d.ts +2 -0
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +3 -0
- package/dist/agent/merge.d.ts.map +1 -1
- package/dist/agent/merge.js +19 -7
- package/dist/agent/merge.test.d.ts +2 -0
- package/dist/agent/merge.test.d.ts.map +1 -0
- package/dist/agent/part-list.d.ts +49 -34
- package/dist/agent/part-list.d.ts.map +1 -1
- package/dist/agent/part-list.js +42 -6
- package/dist/agent/part-list.test.d.ts +2 -0
- package/dist/agent/part-list.test.d.ts.map +1 -0
- package/dist/agent/parts.d.ts +46 -10
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +16 -6
- package/dist/agent/parts.test.d.ts +2 -0
- package/dist/agent/parts.test.d.ts.map +1 -0
- package/dist/agent/thread.d.ts +14 -0
- package/dist/agent/thread.d.ts.map +1 -1
- package/dist/agent/thread.js +8 -2
- package/dist/agent/thread.test.d.ts +2 -0
- package/dist/agent/thread.test.d.ts.map +1 -0
- package/dist/agent/transport.test.d.ts +2 -0
- package/dist/agent/transport.test.d.ts.map +1 -0
- package/dist/agent/use-agent-stream.test.d.ts +2 -0
- package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
- package/dist/agent-chat/composer.d.ts +130 -8
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +189 -23
- package/dist/agent-chat/composer.test.d.ts +2 -0
- package/dist/agent-chat/composer.test.d.ts.map +1 -0
- package/dist/agent-chat/index.d.ts +1 -1
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +14 -3
- package/dist/agent-chat/thread-message.test.d.ts +2 -0
- package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-workspace.js +1 -1
- package/dist/agent-chat/tool-chip.test.d.ts +2 -0
- package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
- package/dist/charts/utils/domain.test.d.ts +2 -0
- package/dist/charts/utils/domain.test.d.ts.map +1 -0
- package/dist/cli/check-theme.test.d.ts +2 -0
- package/dist/cli/check-theme.test.d.ts.map +1 -0
- package/dist/cli/help.test.d.ts +2 -0
- package/dist/cli/help.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/init-sync.test.d.ts +2 -0
- package/dist/cli/init-sync.test.d.ts.map +1 -0
- package/dist/cli/placement-engine.test.d.ts +2 -0
- package/dist/cli/placement-engine.test.d.ts.map +1 -0
- package/dist/cli/tarball-parity.test.d.ts +2 -0
- package/dist/cli/tarball-parity.test.d.ts.map +1 -0
- package/dist/cli/template-format.test.d.ts +2 -0
- package/dist/cli/template-format.test.d.ts.map +1 -0
- package/dist/cli/template-ledger.test.d.ts +2 -0
- package/dist/cli/template-ledger.test.d.ts.map +1 -0
- package/dist/cli/tokens-css.test.d.ts +2 -0
- package/dist/cli/tokens-css.test.d.ts.map +1 -0
- package/dist/content/article-actions.test.d.ts +2 -0
- package/dist/content/article-actions.test.d.ts.map +1 -0
- package/dist/content/article-model.test.d.ts +2 -0
- package/dist/content/article-model.test.d.ts.map +1 -0
- package/dist/content/block-split.test.d.ts +2 -0
- package/dist/content/block-split.test.d.ts.map +1 -0
- package/dist/content/fence-block.d.ts +37 -10
- package/dist/content/fence-block.d.ts.map +1 -1
- package/dist/content/fence-block.js +80 -14
- package/dist/content/heading-components.d.ts.map +1 -1
- package/dist/content/heading-components.js +7 -4
- package/dist/content/heading-components.test.d.ts +2 -0
- package/dist/content/heading-components.test.d.ts.map +1 -0
- package/dist/content/index.d.ts +6 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +5 -1
- package/dist/content/markdown.d.ts +101 -14
- package/dist/content/markdown.d.ts.map +1 -1
- package/dist/content/markdown.js +141 -28
- package/dist/content/markdown.test.d.ts +2 -0
- package/dist/content/markdown.test.d.ts.map +1 -0
- package/dist/content/prose.module.css.test.d.ts +2 -0
- package/dist/content/prose.module.css.test.d.ts.map +1 -0
- package/dist/content/sanitize.d.ts +170 -0
- package/dist/content/sanitize.d.ts.map +1 -0
- package/dist/content/sanitize.js +178 -0
- package/dist/content/sanitize.test.d.ts +2 -0
- package/dist/content/sanitize.test.d.ts.map +1 -0
- package/dist/content/slug.test.d.ts +2 -0
- package/dist/content/slug.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.test.d.ts +2 -0
- package/dist/dashboard/stat-card.test.d.ts.map +1 -0
- package/dist/guard/check-source.test.d.ts +2 -0
- package/dist/guard/check-source.test.d.ts.map +1 -0
- package/dist/guard/guard-hook.test.d.ts +2 -0
- package/dist/guard/guard-hook.test.d.ts.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/provider/index.test.d.ts +2 -0
- package/dist/provider/index.test.d.ts.map +1 -0
- package/dist/provider/lab-theme.test.d.ts +2 -0
- package/dist/provider/lab-theme.test.d.ts.map +1 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
- package/dist/styles.floor.test.d.ts +2 -0
- package/dist/styles.floor.test.d.ts.map +1 -0
- package/dist/surfaces.d.ts +4 -4
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +12 -3
- package/dist/theme/border-coverage.test.d.ts +18 -0
- package/dist/theme/border-coverage.test.d.ts.map +1 -0
- package/dist/theme/contrast.test.d.ts +2 -0
- package/dist/theme/contrast.test.d.ts.map +1 -0
- package/dist/theme/density-option.test.d.ts +2 -0
- package/dist/theme/density-option.test.d.ts.map +1 -0
- package/dist/theme/density-relations.test.d.ts +2 -0
- package/dist/theme/density-relations.test.d.ts.map +1 -0
- package/dist/theme/derive-theme.test.d.ts +2 -0
- package/dist/theme/derive-theme.test.d.ts.map +1 -0
- package/dist/theme/fonts-option.test.d.ts +2 -0
- package/dist/theme/fonts-option.test.d.ts.map +1 -0
- package/dist/theme/radius-option.test.d.ts +2 -0
- package/dist/theme/radius-option.test.d.ts.map +1 -0
- package/dist/theme/radius.test.d.ts +2 -0
- package/dist/theme/radius.test.d.ts.map +1 -0
- package/dist/theme/shadow-surfaces.test.d.ts +26 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
- package/dist/theme/spacing.test.d.ts +2 -0
- package/dist/theme/spacing.test.d.ts.map +1 -0
- package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
- package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-controls.test.d.ts +2 -0
- package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-state.test.d.ts +2 -0
- package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
- package/dist/theme-lab/theme-lab.test.d.ts +2 -0
- package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
- package/dist/tokens/build-fonts-css.test.d.ts +2 -0
- package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
- package/dist/tokens/build-radius-css.test.d.ts +2 -0
- package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
- package/dist/tokens/density.test.d.ts +2 -0
- package/dist/tokens/density.test.d.ts.map +1 -0
- package/dist/tokens/derive-radius.test.d.ts +2 -0
- package/dist/tokens/derive-radius.test.d.ts.map +1 -0
- package/dist/tokens/derive.test.d.ts +2 -0
- package/dist/tokens/derive.test.d.ts.map +1 -0
- package/dist/tokens/palette.test.d.ts +2 -0
- package/dist/tokens/palette.test.d.ts.map +1 -0
- package/dist/vite-color-mix.test.d.ts +2 -0
- package/dist/vite-color-mix.test.d.ts.map +1 -0
- package/dist/vite.test.d.ts +2 -0
- package/dist/vite.test.d.ts.map +1 -0
- package/llms.txt +6 -6
- package/package.json +6 -1
- package/src/agent/adapter.test.ts +1403 -0
- package/src/agent/adapter.ts +887 -0
- package/src/agent/ai-sdk-transport.test.ts +53 -26
- package/src/agent/coalesce.test.ts +202 -9
- package/src/agent/coalesce.ts +126 -15
- package/src/agent/foreign.test.ts +5 -1
- package/src/agent/id.test.ts +6 -2
- package/src/agent/id.ts +48 -0
- package/src/agent/index.ts +8 -0
- package/src/agent/merge.test.ts +87 -0
- package/src/agent/merge.ts +57 -10
- package/src/agent/part-list.test.tsx +31 -1
- package/src/agent/part-list.tsx +77 -29
- package/src/agent/parts.test.ts +121 -4
- package/src/agent/parts.ts +78 -17
- package/src/agent/thread.test.ts +75 -2
- package/src/agent/thread.ts +21 -1
- package/src/agent/use-agent-stream.test.tsx +9 -5
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
- package/src/agent/use-agent-thread-runs.test.tsx +30 -17
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +13 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +3 -1
- package/src/agent-chat/thread-message.test.tsx +141 -1
- package/src/agent-chat/thread-message.tsx +29 -4
- package/src/agent-chat/thread-workspace.tsx +1 -1
- package/src/cli/index.ts +0 -1
- package/src/cli/template-format.test.ts +2 -2
- package/src/content/fence-block.tsx +242 -15
- package/src/content/heading-components.test.tsx +65 -0
- package/src/content/heading-components.tsx +26 -5
- package/src/content/index.ts +19 -3
- package/src/content/markdown.test.tsx +603 -0
- package/src/content/markdown.tsx +355 -35
- package/src/content/sanitize.test.ts +659 -0
- package/src/content/sanitize.ts +510 -0
- package/src/dashboard/stat-card.test.tsx +6 -1
- package/src/index.ts +13 -1
- package/src/surfaces.ts +12 -3
- package/src/theme/contrast.test.ts +1 -4
- package/src/theme/density-relations.test.ts +4 -2
- package/src/theme/fonts-option.test.ts +7 -1
- package/src/theme/shadow-surfaces.test.ts +12 -6
- package/src/theme/use-basalt-spacing.test.tsx +2 -1
|
@@ -1,17 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Composer — a
|
|
2
|
+
* Composer — a prop-driven message input: autosize Textarea + send/stop action, with slots.
|
|
3
3
|
*
|
|
4
|
-
* Enter submits (trims
|
|
5
|
-
*
|
|
4
|
+
* Enter submits (trims; Shift+Enter and an in-progress IME composition insert a newline instead).
|
|
5
|
+
* The draft (and pending attachments) clear OPTIMISTICALLY the instant a send goes out — the
|
|
6
|
+
* textarea empties immediately rather than sitting full for a round trip. `onSubmit` may return
|
|
7
|
+
* `void` or a `Promise<void>`; a SYNCHRONOUS throw or a REJECTED promise both restore exactly what
|
|
8
|
+
* was cleared, so a send the caller rejects — on the spot, or 300ms later over the network — never
|
|
9
|
+
* eats what was typed. See the `onSubmit` prop doc for the restoration guards (a newer edit or a
|
|
10
|
+
* second send always wins over a stale restore). No store or fetching coupling — the caller owns
|
|
11
|
+
* wiring `onSubmit` to whatever sends the message, and `leftSection`/`rightSection` are opaque
|
|
12
|
+
* `ReactNode` slots so basalt never has to learn what a voice recorder or a file picker is.
|
|
13
|
+
*
|
|
14
|
+
* Slot content that needs to WRITE into the composer — a voice recorder's transcript, a
|
|
15
|
+
* suggestion chip — does so through the `ref` handle (`ComposerHandle`), not a prop callback; see
|
|
16
|
+
* the example below and `ComposerHandle`'s own doc for the full contract.
|
|
6
17
|
*
|
|
7
18
|
* @example
|
|
19
|
+
* import { useRef } from 'react'
|
|
8
20
|
* import { Composer } from 'basalt-ui'
|
|
21
|
+
* import type { ComposerHandle } from 'basalt-ui'
|
|
22
|
+
*
|
|
23
|
+
* const composerRef = useRef<ComposerHandle>(null)
|
|
9
24
|
*
|
|
10
|
-
* <Composer
|
|
25
|
+
* <Composer
|
|
26
|
+
* ref={composerRef}
|
|
27
|
+
* draftKey={`thread:${thread.id}`}
|
|
28
|
+
* streaming={runStatus === 'streaming'}
|
|
29
|
+
* onStop={() => runs.stop(thread.id)}
|
|
30
|
+
* leftSection={
|
|
31
|
+
* <VoiceRecordButton
|
|
32
|
+
* onTranscript={(chunk) => composerRef.current?.insertText(chunk)}
|
|
33
|
+
* onDone={() => composerRef.current?.focus()}
|
|
34
|
+
* />
|
|
35
|
+
* }
|
|
36
|
+
* onSubmit={({ text, attachments }) => send(text, attachments)}
|
|
37
|
+
* />
|
|
11
38
|
*/
|
|
12
39
|
import { ActionIcon, Group, Kbd, Stack, Text, Textarea } from '@mantine/core'
|
|
13
|
-
import type { ComponentProps, JSX, KeyboardEvent } from 'react'
|
|
14
|
-
import {
|
|
40
|
+
import type { ClipboardEvent, ComponentProps, JSX, KeyboardEvent, ReactNode, Ref } from 'react'
|
|
41
|
+
import { useImperativeHandle, useLayoutEffect, useRef, useSyncExternalStore } from 'react'
|
|
42
|
+
import { readPersistedValue } from '../state'
|
|
15
43
|
import { VX } from '../tokens'
|
|
16
44
|
|
|
17
45
|
/** A minimal, dependency-free send-arrow glyph (icons are passed in as ReactNode elsewhere; this
|
|
@@ -30,39 +58,476 @@ function SendGlyph(): JSX.Element {
|
|
|
30
58
|
)
|
|
31
59
|
}
|
|
32
60
|
|
|
61
|
+
/** The send glyph's sibling for the Stop affordance — a filled square, the universal stop mark. */
|
|
62
|
+
function StopGlyph(): JSX.Element {
|
|
63
|
+
return (
|
|
64
|
+
<svg width={16} height={16} viewBox="0 0 24 24" aria-hidden>
|
|
65
|
+
<rect x={7} y={7} width={10} height={10} rx={2} fill="currentColor" />
|
|
66
|
+
</svg>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Draft persistence ─────────────────────────────────────────────────────────
|
|
71
|
+
//
|
|
72
|
+
// `draftKey` is a RUNTIME prop that can change while mounted (switching threads), so the module-
|
|
73
|
+
// scope `createPersistedState(...)` call every other basalt caller makes is unavailable here — a
|
|
74
|
+
// factory can't be invoked per render, and its hook can't be swapped conditionally. Instead this is
|
|
75
|
+
// one tiny external store PER KEY, memoized outside React, read through a single
|
|
76
|
+
// `useSyncExternalStore` whose store object is chosen during render. Switching `draftKey` swaps the
|
|
77
|
+
// store, React re-subscribes, and the other key's draft appears; no hook is called conditionally.
|
|
78
|
+
//
|
|
79
|
+
// The envelope written here is byte-compatible with `createPersistedState` (`{ v, value }` under
|
|
80
|
+
// `basalt:<key>`) and is read back through the exported `readPersistedValue` door, so the two
|
|
81
|
+
// mechanisms interoperate. The in-memory `cache` — not localStorage — is the snapshot authority:
|
|
82
|
+
// `getSnapshot` must be cheap and stable, and a storage write that throws (quota, private
|
|
83
|
+
// browsing) must degrade to an in-memory draft rather than freeze the textarea.
|
|
84
|
+
|
|
85
|
+
const DRAFT_VERSION = 1
|
|
86
|
+
|
|
87
|
+
type DraftStore = {
|
|
88
|
+
readonly subscribe: (cb: () => void) => () => void
|
|
89
|
+
readonly get: () => string
|
|
90
|
+
readonly set: (next: string) => void
|
|
91
|
+
/**
|
|
92
|
+
* Monotonic, per-store submission counter. `bumpSubmissionId` marks a new send as the latest one
|
|
93
|
+
* through THIS exact draft (i.e. this `draftKey`); `currentSubmissionId` peeks it without
|
|
94
|
+
* bumping. A rejected submit compares its own id against this to tell "no later submission has
|
|
95
|
+
* gone through this same draft" from "one already has" (a second send, whose own optimistic
|
|
96
|
+
* clear should win) before restoring the cleared text — see `submit()` in Composer. Scoped to
|
|
97
|
+
* the store rather than the component instance so sending a NEW message in a DIFFERENT thread
|
|
98
|
+
* never blocks restoring an older thread's failed draft.
|
|
99
|
+
*/
|
|
100
|
+
readonly bumpSubmissionId: () => number
|
|
101
|
+
readonly currentSubmissionId: () => number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One store. `key === null` means memory-only (no `draftKey` — nothing is persisted). */
|
|
105
|
+
function createDraftStore(key: string | null): DraftStore {
|
|
106
|
+
const storageKey = key === null ? null : `basalt:composer-draft:${key}`
|
|
107
|
+
const listeners = new Set<() => void>()
|
|
108
|
+
// `null` = not hydrated yet; the memory-only store starts already hydrated so it never reads.
|
|
109
|
+
let cache: string | null = key === null ? '' : null
|
|
110
|
+
let storageHandler: ((e: StorageEvent) => void) | null = null
|
|
111
|
+
let submissionId = 0
|
|
112
|
+
|
|
113
|
+
const notify = (): void => {
|
|
114
|
+
for (const cb of listeners) cb()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
subscribe: (cb) => {
|
|
119
|
+
listeners.add(cb)
|
|
120
|
+
// ONE shared 'storage' listener per key, attached on the first subscriber (state.ts idiom).
|
|
121
|
+
if (storageKey !== null && storageHandler === null) {
|
|
122
|
+
storageHandler = (e: StorageEvent): void => {
|
|
123
|
+
if (e.key !== storageKey) return
|
|
124
|
+
cache = null
|
|
125
|
+
notify()
|
|
126
|
+
}
|
|
127
|
+
window.addEventListener('storage', storageHandler)
|
|
128
|
+
}
|
|
129
|
+
return () => {
|
|
130
|
+
listeners.delete(cb)
|
|
131
|
+
if (listeners.size === 0 && storageHandler !== null) {
|
|
132
|
+
window.removeEventListener('storage', storageHandler)
|
|
133
|
+
storageHandler = null
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
get: () => {
|
|
138
|
+
if (cache === null) {
|
|
139
|
+
const persisted =
|
|
140
|
+
key === null ? null : readPersistedValue(`composer-draft:${key}`, DRAFT_VERSION)
|
|
141
|
+
cache = typeof persisted === 'string' ? persisted : ''
|
|
142
|
+
}
|
|
143
|
+
return cache
|
|
144
|
+
},
|
|
145
|
+
set: (next) => {
|
|
146
|
+
cache = next
|
|
147
|
+
if (storageKey !== null) {
|
|
148
|
+
try {
|
|
149
|
+
// An empty draft is an absent draft — don't leave a `{"v":1,"value":""}` husk behind.
|
|
150
|
+
if (next.length === 0) window.localStorage.removeItem(storageKey)
|
|
151
|
+
else
|
|
152
|
+
window.localStorage.setItem(
|
|
153
|
+
storageKey,
|
|
154
|
+
JSON.stringify({ v: DRAFT_VERSION, value: next }),
|
|
155
|
+
)
|
|
156
|
+
} catch {
|
|
157
|
+
// Quota / private browsing — the draft degrades to in-memory rather than going read-only.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
notify()
|
|
161
|
+
},
|
|
162
|
+
bumpSubmissionId: () => {
|
|
163
|
+
submissionId += 1
|
|
164
|
+
return submissionId
|
|
165
|
+
},
|
|
166
|
+
currentSubmissionId: () => submissionId,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const draftStores = new Map<string, DraftStore>()
|
|
171
|
+
|
|
172
|
+
function persistedDraftStore(key: string): DraftStore {
|
|
173
|
+
const existing = draftStores.get(key)
|
|
174
|
+
if (existing !== undefined) return existing
|
|
175
|
+
const created = createDraftStore(key)
|
|
176
|
+
draftStores.set(key, created)
|
|
177
|
+
return created
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// SSR guards, mirroring state.ts — detected once at module load.
|
|
181
|
+
const isServer = typeof window === 'undefined'
|
|
182
|
+
const noopSubscribe =
|
|
183
|
+
(_cb: () => void): (() => void) =>
|
|
184
|
+
() => {}
|
|
185
|
+
const emptyDraft = (): string => ''
|
|
186
|
+
|
|
187
|
+
export type ComposerAttachment = {
|
|
188
|
+
readonly id: string
|
|
189
|
+
readonly name: string
|
|
190
|
+
readonly mediaType: string
|
|
191
|
+
readonly size: number
|
|
192
|
+
readonly url: string
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export type ComposerSubmit = {
|
|
196
|
+
readonly text: string
|
|
197
|
+
readonly attachments: readonly ComposerAttachment[]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Stable empty list — keeps the submit payload's `attachments` referentially constant. */
|
|
201
|
+
const NO_ATTACHMENTS: readonly ComposerAttachment[] = []
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Imperative escape hatch for slot content that must write into the composer from OUTSIDE the
|
|
205
|
+
* render tree — a voice recorder streaming transcript chunks, a suggestion chip inserting at the
|
|
206
|
+
* cursor. Obtained via `ref`. Kept to exactly the three primitives that cover both real use cases
|
|
207
|
+
* (every method here is API surface fixed for a major, so this is deliberately not more):
|
|
208
|
+
*
|
|
209
|
+
* - `insertText` — the one write primitive. Inserts at the CURRENT SELECTION, replacing it if
|
|
210
|
+
* non-collapsed, so a mid-speech voice chunk lands where the caret is and a suggestion never
|
|
211
|
+
* clobbers a selection the user made on purpose. See its own doc for what "current selection"
|
|
212
|
+
* means when the textarea isn't focused.
|
|
213
|
+
* - `setValue` — a whole-value replace (loading a canned reply, discarding the draft), for when
|
|
214
|
+
* there is no meaningful selection to insert around.
|
|
215
|
+
* - `focus` — hands focus back to the textarea (e.g. once a recorder finishes).
|
|
216
|
+
*
|
|
217
|
+
* All three write through the SAME draft store `onChange` does — same `draftKey` persistence,
|
|
218
|
+
* same subscriber notification, same value the existing submit/clear/restore logic already sees.
|
|
219
|
+
* There is no second source of truth.
|
|
220
|
+
*
|
|
221
|
+
* A slot control that inserts at the caret must not steal focus first. A plain `<button>` in
|
|
222
|
+
* `leftSection`/`rightSection` blurs the textarea on mousedown, which collapses the selection before
|
|
223
|
+
* `onClick` runs — so every insert silently lands at the end and the at-caret path is unreachable by
|
|
224
|
+
* mouse. Suppress it on the control, not here:
|
|
225
|
+
*
|
|
226
|
+
* ```tsx
|
|
227
|
+
* <ActionIcon onMouseDown={(e) => e.preventDefault()} onClick={record}>
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
export type ComposerHandle = {
|
|
231
|
+
/**
|
|
232
|
+
* Inserts `text` at the textarea's current selection, replacing it if non-collapsed, then moves
|
|
233
|
+
* the caret to just after the inserted text.
|
|
234
|
+
*
|
|
235
|
+
* "Current selection" only exists while the textarea IS the focused element — a `<textarea>`'s
|
|
236
|
+
* `selectionStart`/`selectionEnd` are not a reliable caret once focus has moved elsewhere, so:
|
|
237
|
+
* - **focused, collapsed caret**: inserts at that caret position.
|
|
238
|
+
* - **focused, with a range selected**: replaces the selected range (never inserted alongside
|
|
239
|
+
* it — that would silently keep text the user was in the middle of deleting).
|
|
240
|
+
* - **not focused, including never focused**: appends to the end of the current value. This is
|
|
241
|
+
* the safe default for a voice recorder streaming chunks before the user has clicked into
|
|
242
|
+
* the box at all.
|
|
243
|
+
*/
|
|
244
|
+
readonly insertText: (text: string) => void
|
|
245
|
+
/** Replaces the whole draft value and moves the caret to the end. */
|
|
246
|
+
readonly setValue: (value: string) => void
|
|
247
|
+
/** Focuses the textarea. */
|
|
248
|
+
readonly focus: () => void
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The textarea's live `{ start, end }` selection, or `null` when there is none to trust — see
|
|
252
|
+
* `ComposerHandle.insertText`'s doc for what "none" covers. */
|
|
253
|
+
function liveSelection(el: HTMLTextAreaElement): { start: number; end: number } | null {
|
|
254
|
+
if (document.activeElement !== el) return null
|
|
255
|
+
const { selectionStart, selectionEnd } = el
|
|
256
|
+
if (selectionStart === null || selectionEnd === null) return null
|
|
257
|
+
return { start: selectionStart, end: selectionEnd }
|
|
258
|
+
}
|
|
259
|
+
|
|
33
260
|
export type ComposerProps = {
|
|
34
|
-
/**
|
|
35
|
-
readonly
|
|
36
|
-
/**
|
|
37
|
-
|
|
261
|
+
/** Imperative write/focus handle — see `ComposerHandle`. */
|
|
262
|
+
readonly ref?: Ref<ComposerHandle>
|
|
263
|
+
/**
|
|
264
|
+
* Called with the trimmed text plus the pending attachments. May return `void` (fire-and-forget,
|
|
265
|
+
* validated only synchronously) or a `Promise<void>` (its outcome is awaited, its timing is not).
|
|
266
|
+
*
|
|
267
|
+
* The draft and attachments clear OPTIMISTICALLY, immediately, before this settles — a chat
|
|
268
|
+
* composer has to feel instant, so Composer never sits on the typed text for a round trip.
|
|
269
|
+
* Rejecting restores them instead:
|
|
270
|
+
* - THROW synchronously to refuse a send outright (bad input, offline, …) — the clear never
|
|
271
|
+
* runs, exactly as before this prop went async.
|
|
272
|
+
* - Return a promise that REJECTS to report a failure discovered later (a network error) — the
|
|
273
|
+
* text and attachments are put back, UNLESS something has since made that unsafe:
|
|
274
|
+
* - the user already typed something new into the (now-empty) box — their new text wins;
|
|
275
|
+
* the failed one is not restored (surface a retry via the failed message instead);
|
|
276
|
+
* - a LATER submit has already gone through this same draft — that one's own optimistic
|
|
277
|
+
* clear (whether it went on to succeed or fail) wins over restoring this older one;
|
|
278
|
+
* - the attachments are no longer empty (the caller already put something else there), or
|
|
279
|
+
* `draftKey` has since changed (this Composer now points at a different thread/consumer
|
|
280
|
+
* destination) — either way the attachments are left as they are rather than risking a
|
|
281
|
+
* leak into whatever is current now.
|
|
282
|
+
* A resolved promise clears and stays cleared, same as a `void` return.
|
|
283
|
+
*/
|
|
284
|
+
readonly onSubmit: (payload: ComposerSubmit) => void | Promise<void>
|
|
38
285
|
readonly placeholder?: string
|
|
39
286
|
/** Autofocuses the textarea on mount. */
|
|
40
287
|
readonly autoFocus?: boolean
|
|
288
|
+
/** Autosize ceiling for the textarea. Default 6. */
|
|
289
|
+
readonly maxRows?: number
|
|
290
|
+
/** Hard-disable, independent of streaming (e.g. offline). */
|
|
291
|
+
readonly disabled?: boolean
|
|
292
|
+
/** A run is in flight on this thread. */
|
|
293
|
+
readonly streaming?: boolean
|
|
294
|
+
/** Default false — preserves the historical `disabled={streaming}` behaviour. */
|
|
295
|
+
readonly allowSubmitWhileStreaming?: boolean
|
|
296
|
+
/** When given, the send action becomes a Stop action while `streaming`. */
|
|
297
|
+
readonly onStop?: () => void
|
|
298
|
+
/**
|
|
299
|
+
* Opaque slot rendered before the textarea (a voice recorder, a mode switch, …). Slot content
|
|
300
|
+
* that needs to WRITE into the composer — append a transcript, insert a suggestion — does so
|
|
301
|
+
* through the `ref` handle (`ComposerHandle`), not a callback prop here; see the file's
|
|
302
|
+
* `@example`.
|
|
303
|
+
*/
|
|
304
|
+
readonly leftSection?: ReactNode
|
|
305
|
+
/** Opaque slot rendered between the textarea and the send action (an attach button, …). */
|
|
306
|
+
readonly rightSection?: ReactNode
|
|
307
|
+
/**
|
|
308
|
+
* Pending attachments, owned by the caller. Composer only forwards them on submit and asks for a
|
|
309
|
+
* reset afterwards — rendering previews is the caller's job, via `leftSection`/`rightSection`.
|
|
310
|
+
*/
|
|
311
|
+
readonly attachments?: readonly ComposerAttachment[]
|
|
312
|
+
/**
|
|
313
|
+
* Called with `[]` optimistically the instant a submit goes out WITH pending attachments — a
|
|
314
|
+
* submit that had none to begin with skips the call rather than reporting a no-op change — and,
|
|
315
|
+
* subject to the restoration guards documented on `onSubmit`, with the original attachments
|
|
316
|
+
* again if that submit later rejects.
|
|
317
|
+
*/
|
|
318
|
+
readonly onAttachmentsChange?: (next: readonly ComposerAttachment[]) => void
|
|
319
|
+
readonly onPaste?: (event: ClipboardEvent<HTMLTextAreaElement>) => void
|
|
320
|
+
/**
|
|
321
|
+
* Persists the unsent draft under `basalt:composer-draft:<key>`; switching keys swaps drafts, and
|
|
322
|
+
* the entry is removed (not blanked) on a successful submit.
|
|
323
|
+
*
|
|
324
|
+
* "Successful" means `onSubmit` returned (or resolved) without throwing or rejecting — a send
|
|
325
|
+
* that fails, synchronously or later over the network, restores this persisted draft instead of
|
|
326
|
+
* leaving it erased (subject to the restoration guards documented on `onSubmit`).
|
|
327
|
+
*/
|
|
328
|
+
readonly draftKey?: string
|
|
329
|
+
/** Replaces the whole Enter/Shift+Enter hint row. Pass `null` to drop it entirely. */
|
|
330
|
+
readonly hint?: ReactNode
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The default footer: the Enter / Shift+Enter keyboard contract, in the mono micro-label voice. */
|
|
334
|
+
function DefaultHint(): JSX.Element {
|
|
335
|
+
return (
|
|
336
|
+
<Group gap={4}>
|
|
337
|
+
<Kbd size="xs">Enter</Kbd>
|
|
338
|
+
<Text size="xs" c="dimmed" style={{ fontFamily: 'var(--basalt-font-mono)' }}>
|
|
339
|
+
to send
|
|
340
|
+
</Text>
|
|
341
|
+
<Kbd size="xs">Shift</Kbd>
|
|
342
|
+
<Text size="xs" c="dimmed" style={{ fontFamily: 'var(--basalt-font-mono)' }}>
|
|
343
|
+
+
|
|
344
|
+
</Text>
|
|
345
|
+
<Kbd size="xs">Enter</Kbd>
|
|
346
|
+
<Text size="xs" c="dimmed" style={{ fontFamily: 'var(--basalt-font-mono)' }}>
|
|
347
|
+
for a new line
|
|
348
|
+
</Text>
|
|
349
|
+
</Group>
|
|
350
|
+
)
|
|
41
351
|
}
|
|
42
352
|
|
|
43
353
|
/**
|
|
44
|
-
* An autosize Textarea + send ActionIcon. Enter submits, Shift+Enter
|
|
354
|
+
* An autosize Textarea + send ActionIcon, with slots either side. Enter submits, Shift+Enter
|
|
355
|
+
* inserts a newline, and `draftKey` survives an unmount.
|
|
45
356
|
*
|
|
46
357
|
* @example
|
|
47
|
-
* <Composer onSubmit={onSend}
|
|
358
|
+
* <Composer onSubmit={({ text }) => onSend(text)} streaming={streaming} onStop={stop} />
|
|
48
359
|
*/
|
|
49
360
|
export function Composer({
|
|
361
|
+
ref,
|
|
50
362
|
onSubmit,
|
|
51
|
-
disabled = false,
|
|
52
363
|
placeholder,
|
|
53
364
|
autoFocus,
|
|
365
|
+
maxRows = 6,
|
|
366
|
+
disabled = false,
|
|
367
|
+
streaming = false,
|
|
368
|
+
allowSubmitWhileStreaming = false,
|
|
369
|
+
onStop,
|
|
370
|
+
leftSection,
|
|
371
|
+
rightSection,
|
|
372
|
+
attachments,
|
|
373
|
+
onAttachmentsChange,
|
|
374
|
+
onPaste,
|
|
375
|
+
draftKey,
|
|
376
|
+
hint,
|
|
54
377
|
}: ComposerProps): JSX.Element {
|
|
55
|
-
|
|
378
|
+
// Per-instance fallback for the un-keyed case, so two keyless Composers never share a draft.
|
|
379
|
+
// A lazily-initialised ref rather than `useState` — this is a create-once value React never
|
|
380
|
+
// needs to re-render on; the store's own subscription is what drives updates.
|
|
381
|
+
const memoryStore = useRef<DraftStore | null>(null)
|
|
382
|
+
memoryStore.current ??= createDraftStore(null)
|
|
383
|
+
const store = draftKey === undefined ? memoryStore.current : persistedDraftStore(draftKey)
|
|
384
|
+
const value = useSyncExternalStore(
|
|
385
|
+
isServer ? noopSubscribe : store.subscribe,
|
|
386
|
+
isServer ? emptyDraft : store.get,
|
|
387
|
+
emptyDraft,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
const inputDisabled = disabled || (streaming && !allowSubmitWhileStreaming)
|
|
391
|
+
const showStop = streaming && onStop !== undefined
|
|
392
|
+
// Stop normally REPLACES send — mid-run there is nothing to send. But `allowSubmitWhileStreaming`
|
|
393
|
+
// is precisely the case where there is: the textarea stays live and Enter submits, so hiding the
|
|
394
|
+
// button would leave mouse and touch users with a keyboard-only send. There the two coexist, Stop
|
|
395
|
+
// first so Send keeps the rightmost primary slot.
|
|
396
|
+
const showSend = !showStop || allowSubmitWhileStreaming
|
|
397
|
+
const pending = attachments ?? NO_ATTACHMENTS
|
|
398
|
+
const trimmed = value.trim()
|
|
399
|
+
// An attachment with no caption is a legitimate send; text-only callers behave exactly as before.
|
|
400
|
+
const hasPayload = trimmed.length > 0 || pending.length > 0
|
|
401
|
+
|
|
402
|
+
// "Latest ref" mirrors of two controlled inputs, kept live every render (plain overwrites, not
|
|
403
|
+
// derived — safe to do in the render body) so a submit's rejection handler — which fires long
|
|
404
|
+
// after the render that started it — can read what is CURRENTLY true rather than what was true
|
|
405
|
+
// when it was created. Both feed the restoration guards in `submit()` below.
|
|
406
|
+
const attachmentsRef = useRef(pending)
|
|
407
|
+
attachmentsRef.current = pending
|
|
408
|
+
const draftKeyRef = useRef(draftKey)
|
|
409
|
+
draftKeyRef.current = draftKey
|
|
410
|
+
|
|
411
|
+
// ── Imperative handle (ComposerHandle) ────────────────────────────────────────
|
|
412
|
+
//
|
|
413
|
+
// The textarea is CONTROLLED — its rendered `value` comes from `useSyncExternalStore`, not from
|
|
414
|
+
// the DOM — so a caret position computed the moment `store.set` runs is computed against text
|
|
415
|
+
// the DOM node doesn't display yet (React hasn't re-rendered). `pendingCaretRef` defers the
|
|
416
|
+
// actual `setSelectionRange` call to the layout effect below, which runs once `value` has
|
|
417
|
+
// changed and the textarea's DOM content matches what the caret math assumed.
|
|
418
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
419
|
+
const pendingCaretRef = useRef<number | null>(null)
|
|
420
|
+
|
|
421
|
+
useLayoutEffect(() => {
|
|
422
|
+
const caret = pendingCaretRef.current
|
|
423
|
+
if (caret === null) return
|
|
424
|
+
pendingCaretRef.current = null
|
|
425
|
+
textareaRef.current?.setSelectionRange(caret, caret)
|
|
426
|
+
}, [value])
|
|
427
|
+
|
|
428
|
+
// Both writers below arm `pendingCaretRef` ONLY when the write actually changes the value away
|
|
429
|
+
// from `value` — the exact string this render's `useSyncExternalStore` returned, i.e. what the
|
|
430
|
+
// layout effect's `[value]` dependency will be diffed against. That is the only fact that
|
|
431
|
+
// predicts whether the effect will run at all: a write that lands back on `value` (a genuine
|
|
432
|
+
// no-op, OR a sequence of writes within the same tick that cancels back to it) will never
|
|
433
|
+
// trigger a re-render, so the effect never fires to null the ref out — arming it there leaves a
|
|
434
|
+
// caret position that a LATER, unrelated change (e.g. plain typing through `onChange`, which
|
|
435
|
+
// never touches this ref itself) would then wrongly consume. Comparing against `store.get()`
|
|
436
|
+
// (the live cache, already mutated by any earlier write in this same tick) is NOT equivalent and
|
|
437
|
+
// was tried and rejected: two writes in one tick that net back to `value` would each look like a
|
|
438
|
+
// "real change" relative to the other's intermediate cache, so the ref would still end up armed
|
|
439
|
+
// even though the final, rendered value never moved. Comparing to `value` also means every call
|
|
440
|
+
// unconditionally OVERWRITES `pendingCaretRef.current` (to a position, or explicitly to `null`),
|
|
441
|
+
// so no call can ever leave a PRIOR call's stale arm behind — there is no code path that arms
|
|
442
|
+
// without also being the one to decide the final state.
|
|
443
|
+
const insertText = (text: string): void => {
|
|
444
|
+
const current = store.get()
|
|
445
|
+
const selection = textareaRef.current === null ? null : liveSelection(textareaRef.current)
|
|
446
|
+
const start = selection?.start ?? current.length
|
|
447
|
+
const end = selection?.end ?? current.length
|
|
448
|
+
const next = current.slice(0, start) + text + current.slice(end)
|
|
449
|
+
store.set(next)
|
|
450
|
+
pendingCaretRef.current = next === value ? null : start + text.length
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const setValue = (next: string): void => {
|
|
454
|
+
store.set(next)
|
|
455
|
+
pendingCaretRef.current = next === value ? null : next.length
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const focusTextarea = (): void => {
|
|
459
|
+
textareaRef.current?.focus()
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
useImperativeHandle(ref, () => ({ insertText, setValue, focus: focusTextarea }))
|
|
56
463
|
|
|
57
464
|
const submit = (): void => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
465
|
+
if (inputDisabled || !hasPayload) return
|
|
466
|
+
const submittedText = trimmed
|
|
467
|
+
const submittedAttachments = pending
|
|
468
|
+
const submittedDraftKey = draftKey
|
|
469
|
+
// Claims THIS send as the newest through this draft store before anything else can — see
|
|
470
|
+
// `bumpSubmissionId`'s doc for why the counter lives on the store rather than the component.
|
|
471
|
+
const mySubmissionId = store.bumpSubmissionId()
|
|
472
|
+
// The clear is deliberately AFTER the call — a parent that throws to reject the send
|
|
473
|
+
// SYNCHRONOUSLY keeps both the draft and the attachments, because the throw unwinds before
|
|
474
|
+
// either line below runs.
|
|
475
|
+
const result = onSubmit({ text: submittedText, attachments: submittedAttachments })
|
|
476
|
+
store.set('')
|
|
477
|
+
if (submittedAttachments.length > 0) onAttachmentsChange?.([])
|
|
478
|
+
// `result` is typed `void | Promise<void>`, but TypeScript satisfies `Promise<void>` with ANY
|
|
479
|
+
// structurally-matching thenable — an `instanceof Promise` check is nominal and misses a
|
|
480
|
+
// thenable from another realm (an iframe, a worker bridge) or a userland promise
|
|
481
|
+
// implementation, silently dropping the restore-on-reject guarantee for exactly the value the
|
|
482
|
+
// type permits. `Promise.resolve(result)` adopts anything PromiseLike, and a plain `void`
|
|
483
|
+
// return passes through as an already-resolved promise whose `.catch` below simply never
|
|
484
|
+
// fires — so this narrows STRUCTURALLY (a callable `then`) rather than by prototype identity.
|
|
485
|
+
// Optimistic clear, pessimistic restore: the box already reads empty by the time this promise
|
|
486
|
+
// can settle (see the class doc for why this isn't await-then-clear). A REJECTION puts back
|
|
487
|
+
// what was cleared, gated so it can never clobber something newer:
|
|
488
|
+
// - TEXT restores only if no LATER submit has gone through this same store (`mySubmissionId`
|
|
489
|
+
// is still the newest one it has issued) AND the draft is still exactly the '' this submit
|
|
490
|
+
// left it in — a second submit's own clear, or fresh typing, each leave one of those false.
|
|
491
|
+
// - ATTACHMENTS restore only if they are still empty (nothing has since replaced them) AND
|
|
492
|
+
// `draftKey` has not changed since (a thread switch repoints `onAttachmentsChange` at a
|
|
493
|
+
// different destination; restoring into it would leak this attachment into that thread).
|
|
494
|
+
// An unmount does not need its own guard: the persisted store this closure captured is keyed
|
|
495
|
+
// by `draftKey` and outlives the component, so restoring into it is exactly what makes the
|
|
496
|
+
// draft reappear if the user comes back — and `onAttachmentsChange` calling into an unmounted
|
|
497
|
+
// Composer's still-mounted parent is an ordinary prop-callback call, not a React state update
|
|
498
|
+
// on a dead component.
|
|
499
|
+
void Promise.resolve(result).catch((): void => {
|
|
500
|
+
if (store.currentSubmissionId() === mySubmissionId && store.get() === '') {
|
|
501
|
+
store.set(submittedText)
|
|
502
|
+
}
|
|
503
|
+
// `draftKeyRef.current === submittedDraftKey` is `undefined === undefined` — always true —
|
|
504
|
+
// for a Composer used WITHOUT `draftKey`, so this guard is inert precisely there. That is
|
|
505
|
+
// intentional, not a gap: `draftKey` is the ONLY identity a Composer has for "which
|
|
506
|
+
// destination am I currently pointed at" (see its own prop doc — switching keys is how a
|
|
507
|
+
// caller repoints one mounted instance at a different thread). A keyless Composer therefore
|
|
508
|
+
// has no notion of a destination switch to guard against in the first place; the API's
|
|
509
|
+
// contract is that reusing one mounted instance across multiple destinations requires either
|
|
510
|
+
// a `draftKey` (this guard) or a React `key` prop (a remount, which drops this closure
|
|
511
|
+
// entirely). A caller that swaps `onAttachmentsChange` to point at a new destination on a
|
|
512
|
+
// keyless, unkeyed instance is violating that contract already — no guard here can rescue it
|
|
513
|
+
// without also breaking the legitimate same-destination restore (function identity is not a
|
|
514
|
+
// safe substitute: an inline `(next) => ...` prop is a fresh reference every render even when
|
|
515
|
+
// the destination hasn't changed).
|
|
516
|
+
if (
|
|
517
|
+
submittedAttachments.length > 0 &&
|
|
518
|
+
attachmentsRef.current.length === 0 &&
|
|
519
|
+
draftKeyRef.current === submittedDraftKey
|
|
520
|
+
) {
|
|
521
|
+
onAttachmentsChange?.(submittedAttachments)
|
|
522
|
+
}
|
|
523
|
+
})
|
|
62
524
|
}
|
|
63
525
|
|
|
64
526
|
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
|
|
65
527
|
if (event.key !== 'Enter' || event.shiftKey) return
|
|
528
|
+
// IME guard: mid-composition (CJK, and some mobile keyboards) Enter COMMITS the candidate — it
|
|
529
|
+
// is not a send. `nativeEvent.isComposing` is the reliable signal; keyCode 229 is the legacy one.
|
|
530
|
+
if (event.nativeEvent.isComposing) return
|
|
66
531
|
event.preventDefault()
|
|
67
532
|
submit()
|
|
68
533
|
}
|
|
@@ -71,10 +536,10 @@ export function Composer({
|
|
|
71
536
|
flex: 1,
|
|
72
537
|
autosize: true,
|
|
73
538
|
minRows: 1,
|
|
74
|
-
maxRows
|
|
539
|
+
maxRows,
|
|
75
540
|
value,
|
|
76
|
-
disabled,
|
|
77
|
-
onChange: (event) =>
|
|
541
|
+
disabled: inputDisabled,
|
|
542
|
+
onChange: (event) => store.set(event.currentTarget.value),
|
|
78
543
|
onKeyDown: handleKeyDown,
|
|
79
544
|
// Composer input surface (docs/DESIGN-SPEC.md §5): panel + control radius (6px, VX.radiusCtrl);
|
|
80
545
|
// the ring lives in the shadow, so the input carries no separate border. Depth is deliberately
|
|
@@ -91,42 +556,46 @@ export function Composer({
|
|
|
91
556
|
}
|
|
92
557
|
if (placeholder !== undefined) textareaProps.placeholder = placeholder
|
|
93
558
|
if (autoFocus !== undefined) textareaProps.autoFocus = autoFocus
|
|
559
|
+
if (onPaste !== undefined) textareaProps.onPaste = onPaste
|
|
94
560
|
|
|
95
561
|
return (
|
|
96
562
|
<Stack gap={6}>
|
|
97
563
|
<Group gap="xs" align="flex-end" wrap="nowrap">
|
|
98
|
-
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
564
|
+
{leftSection}
|
|
565
|
+
<Textarea {...textareaProps} ref={textareaRef} />
|
|
566
|
+
{rightSection}
|
|
567
|
+
{showStop ? (
|
|
568
|
+
<ActionIcon
|
|
569
|
+
size={42}
|
|
570
|
+
radius="md"
|
|
571
|
+
variant="filled"
|
|
572
|
+
color="red"
|
|
573
|
+
onClick={onStop}
|
|
574
|
+
aria-label="Stop generating"
|
|
575
|
+
>
|
|
576
|
+
<StopGlyph />
|
|
577
|
+
</ActionIcon>
|
|
578
|
+
) : null}
|
|
579
|
+
{showSend ? (
|
|
580
|
+
<ActionIcon
|
|
581
|
+
size={42}
|
|
582
|
+
radius="md"
|
|
583
|
+
variant="filled"
|
|
584
|
+
onClick={submit}
|
|
585
|
+
disabled={inputDisabled || !hasPayload}
|
|
586
|
+
aria-label="Send message"
|
|
587
|
+
// Send action (docs/DESIGN-SPEC.md §5): the one accent-filled control. It needs no color
|
|
588
|
+
// override — `filled` resolves through the theme to `--vx-accent-fill` / `--vx-on-accent`.
|
|
589
|
+
// (It used to hand-wire those two vars inline, which is why this was the ONLY filled
|
|
590
|
+
// control that stayed legible while the rest of the chrome went through Mantine's
|
|
591
|
+
// scheme-blind autoContrast. The theme owns it now, and hover works again — an inline
|
|
592
|
+
// style can't express a `:hover` state.)
|
|
593
|
+
>
|
|
594
|
+
<SendGlyph />
|
|
595
|
+
</ActionIcon>
|
|
596
|
+
) : null}
|
|
129
597
|
</Group>
|
|
598
|
+
{hint === undefined ? <DefaultHint /> : hint}
|
|
130
599
|
</Stack>
|
|
131
600
|
)
|
|
132
601
|
}
|
package/src/agent-chat/index.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type { ThreadDetailPanelProps } from './thread-detail-panel'
|
|
|
29
29
|
|
|
30
30
|
// ── Composer ──────────────────────────────────────────────────────────────────
|
|
31
31
|
export { Composer } from './composer'
|
|
32
|
-
export type { ComposerProps } from './composer'
|
|
32
|
+
export type { ComposerAttachment, ComposerHandle, ComposerProps, ComposerSubmit } from './composer'
|
|
33
33
|
|
|
34
34
|
// ── threadPartRenderers + ThreadTranscript ────────────────────────────────────
|
|
35
35
|
export { threadPartRenderers, ThreadTranscript } from './thread-message'
|
|
@@ -167,7 +167,9 @@ export function ThreadDetailPanel({
|
|
|
167
167
|
<Divider color="var(--vx-divider)" />
|
|
168
168
|
<Box p="sm">
|
|
169
169
|
<Stack gap="xs">
|
|
170
|
-
|
|
170
|
+
{/* `onSend` stays a plain string at this component's public seam — the payload shape is
|
|
171
|
+
unwrapped here rather than pushed onto every caller. */}
|
|
172
|
+
<Composer onSubmit={({ text }) => onSend(text)} streaming={streaming} />
|
|
171
173
|
{streaming && (
|
|
172
174
|
<Button size="compact-sm" variant="subtle" color="red" onClick={onStop}>
|
|
173
175
|
Stop
|