@flamingo-stack/openframe-frontend-core 0.0.319 → 0.0.320
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/dist/{chunk-XAGD3XZV.js → chunk-563D7EB2.js} +4 -4
- package/dist/{chunk-B34YGPIB.cjs → chunk-5HQIFRRE.cjs} +7 -7
- package/dist/{chunk-B34YGPIB.cjs.map → chunk-5HQIFRRE.cjs.map} +1 -1
- package/dist/{chunk-ARQ3R7WZ.js → chunk-6DH2TCRH.js} +2 -2
- package/dist/{chunk-ZD5ZDEL5.cjs → chunk-7C5G45KU.cjs} +1502 -1298
- package/dist/chunk-7C5G45KU.cjs.map +1 -0
- package/dist/{chunk-CGG7EWYG.cjs → chunk-7M3QIYFW.cjs} +56 -56
- package/dist/{chunk-CGG7EWYG.cjs.map → chunk-7M3QIYFW.cjs.map} +1 -1
- package/dist/{chunk-4YFOBTFE.js → chunk-BRERE4E5.js} +4 -4
- package/dist/{chunk-7ZHCRY2V.cjs → chunk-CD56IAXU.cjs} +5 -5
- package/dist/{chunk-7ZHCRY2V.cjs.map → chunk-CD56IAXU.cjs.map} +1 -1
- package/dist/{chunk-2RQV7JLA.js → chunk-DEEBJBFQ.js} +2 -2
- package/dist/{chunk-NG7P653A.js → chunk-FNK67HTC.js} +2 -2
- package/dist/{chunk-4XHPUYL7.cjs → chunk-GAQAPTBU.cjs} +37 -37
- package/dist/{chunk-4XHPUYL7.cjs.map → chunk-GAQAPTBU.cjs.map} +1 -1
- package/dist/{chunk-LF5LDJOP.js → chunk-HALW7FPF.js} +2 -2
- package/dist/{chunk-G7CRSNFS.js → chunk-J65CQLX6.js} +2 -2
- package/dist/{chunk-5B37J3B5.cjs → chunk-MN2WQYXS.cjs} +15 -15
- package/dist/{chunk-5B37J3B5.cjs.map → chunk-MN2WQYXS.cjs.map} +1 -1
- package/dist/{chunk-APEJR6DV.cjs → chunk-NEY347ME.cjs} +26 -26
- package/dist/{chunk-APEJR6DV.cjs.map → chunk-NEY347ME.cjs.map} +1 -1
- package/dist/{chunk-V2OAUUF6.cjs → chunk-PDOTUMJC.cjs} +31 -31
- package/dist/{chunk-V2OAUUF6.cjs.map → chunk-PDOTUMJC.cjs.map} +1 -1
- package/dist/{chunk-JIU2I6LU.cjs → chunk-TFCGMQPK.cjs} +25 -25
- package/dist/{chunk-JIU2I6LU.cjs.map → chunk-TFCGMQPK.cjs.map} +1 -1
- package/dist/{chunk-SVPVDSHA.js → chunk-TPJJJ6KC.js} +4152 -3948
- package/dist/chunk-TPJJJ6KC.js.map +1 -0
- package/dist/{chunk-7FIWPFLE.js → chunk-TV6VD6LP.js} +4 -4
- package/dist/{chunk-2D3RINOJ.js → chunk-V4U7ASRM.js} +2 -2
- package/dist/{chunk-QJOSQVIR.js → chunk-VNWWOPIY.js} +2 -2
- package/dist/{chunk-J4FCWWO6.cjs → chunk-WCCZGPPP.cjs} +62 -62
- package/dist/{chunk-J4FCWWO6.cjs.map → chunk-WCCZGPPP.cjs.map} +1 -1
- package/dist/{chunk-ODMCD663.js → chunk-WCRJWZUA.js} +4 -4
- package/dist/{chunk-7WMHKVV7.cjs → chunk-Y2TJ64S5.cjs} +12 -12
- package/dist/{chunk-7WMHKVV7.cjs.map → chunk-Y2TJ64S5.cjs.map} +1 -1
- package/dist/{chunk-RXCP4OV4.cjs → chunk-ZFER75HU.cjs} +9 -9
- package/dist/{chunk-RXCP4OV4.cjs.map → chunk-ZFER75HU.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/chat-context-picker.d.ts +11 -1
- package/dist/components/chat/chat-context-picker.d.ts.map +1 -1
- package/dist/components/chat/chat-input.d.ts.map +1 -1
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/context-items-list.d.ts.map +1 -1
- package/dist/components/chat/embeddable-chat.d.ts +11 -1
- package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/chat/remark-mention-chips.d.ts +11 -5
- package/dist/components/chat/remark-mention-chips.d.ts.map +1 -1
- package/dist/components/chat/types/component.types.d.ts +30 -7
- package/dist/components/chat/types/component.types.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +8 -2
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.d.ts +1 -0
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +7 -1
- package/dist/components/features/paths-display.d.ts +16 -1
- package/dist/components/features/paths-display.d.ts.map +1 -1
- package/dist/components/features/warning-block.d.ts +32 -0
- package/dist/components/features/warning-block.d.ts.map +1 -0
- package/dist/components/help-center-pages/index.cjs +20 -20
- package/dist/components/help-center-pages/index.js +11 -11
- package/dist/components/index.cjs +126 -120
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +15 -9
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +5 -5
- package/dist/components/tickets/index.js +4 -4
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/index.cjs +8 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +7 -1
- package/package.json +1 -1
- package/src/components/chat/chat-context-picker.tsx +77 -9
- package/src/components/chat/chat-input.tsx +524 -489
- package/src/components/chat/chat-message-enhanced.tsx +4 -2
- package/src/components/chat/chat-message-list.tsx +2 -0
- package/src/components/chat/context-items-list.tsx +9 -1
- package/src/components/chat/embeddable-chat.tsx +55 -56
- package/src/components/chat/remark-mention-chips.ts +13 -7
- package/src/components/chat/types/component.types.ts +31 -7
- package/src/components/features/index.ts +1 -0
- package/src/components/features/paths-display.tsx +32 -1
- package/src/components/features/warning-block.tsx +54 -0
- package/src/stories/WarningBlock.stories.tsx +167 -0
- package/dist/chunk-SVPVDSHA.js.map +0 -1
- package/dist/chunk-ZD5ZDEL5.cjs.map +0 -1
- /package/dist/{chunk-XAGD3XZV.js.map → chunk-563D7EB2.js.map} +0 -0
- /package/dist/{chunk-ARQ3R7WZ.js.map → chunk-6DH2TCRH.js.map} +0 -0
- /package/dist/{chunk-4YFOBTFE.js.map → chunk-BRERE4E5.js.map} +0 -0
- /package/dist/{chunk-2RQV7JLA.js.map → chunk-DEEBJBFQ.js.map} +0 -0
- /package/dist/{chunk-NG7P653A.js.map → chunk-FNK67HTC.js.map} +0 -0
- /package/dist/{chunk-LF5LDJOP.js.map → chunk-HALW7FPF.js.map} +0 -0
- /package/dist/{chunk-G7CRSNFS.js.map → chunk-J65CQLX6.js.map} +0 -0
- /package/dist/{chunk-7FIWPFLE.js.map → chunk-TV6VD6LP.js.map} +0 -0
- /package/dist/{chunk-2D3RINOJ.js.map → chunk-V4U7ASRM.js.map} +0 -0
- /package/dist/{chunk-QJOSQVIR.js.map → chunk-VNWWOPIY.js.map} +0 -0
- /package/dist/{chunk-ODMCD663.js.map → chunk-WCRJWZUA.js.map} +0 -0
|
@@ -1,516 +1,551 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { useState, useRef, useImperativeHandle, forwardRef, useCallback, useEffect, useMemo, type KeyboardEvent, type
|
|
3
|
+
import { useState, useRef, useImperativeHandle, forwardRef, useCallback, useEffect, useMemo, type ReactNode, type KeyboardEvent, type ClipboardEvent } from "react"
|
|
4
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
4
5
|
import { cn } from "../../utils/cn"
|
|
5
6
|
import { Send01Icon, StopCircleIcon } from "../icons-v2-generated"
|
|
6
|
-
import {
|
|
7
|
+
import { Tag } from "../ui/tag"
|
|
7
8
|
import { ChatTypingIndicator } from "./chat-typing-indicator"
|
|
8
9
|
import { SlashCommandSuggestions } from "./slash-command-suggestions"
|
|
9
|
-
import type { ChatInputProps, ChatInputRef, SlashCommandSummary } from "./types"
|
|
10
|
+
import type { ChatInputProps, ChatInputRef, MentionMeta, SlashCommandSummary } from "./types"
|
|
10
11
|
|
|
11
|
-
/** SHARED with `lib/config/slash-commands-config.ts
|
|
12
|
-
*
|
|
13
|
-
* registration validates against the same shape, so a UI that suggests
|
|
14
|
-
* uppercase / out-of-charset names would offer commands the API rejects. */
|
|
12
|
+
/** SHARED with `lib/config/slash-commands-config.ts` AND the chat-route slash
|
|
13
|
+
* dispatch parser. Keep all three in sync. */
|
|
15
14
|
const SLASH_INPUT_TRIGGER = /^\/([a-z][a-z0-9-]*)?$/
|
|
16
15
|
|
|
17
|
-
/** A
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
*
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// runs of horizontal whitespace and drop any leading space — but
|
|
210
|
-
// preserve newlines and the single trailing space (which dismisses
|
|
211
|
-
// the `@`-mention trigger so the picker closes on its own).
|
|
212
|
-
return next.replace(/[^\S\n]{2,}/g, ' ').replace(/^[^\S\n]+/, '')
|
|
213
|
-
})
|
|
214
|
-
requestAnimationFrame(() => {
|
|
215
|
-
const el = textareaRef.current
|
|
216
|
-
if (!el || disabled || el.disabled) return
|
|
217
|
-
el.focus({ preventScroll: true })
|
|
218
|
-
el.setSelectionRange(el.value.length, el.value.length)
|
|
219
|
-
})
|
|
220
|
-
},
|
|
221
|
-
}),
|
|
222
|
-
// `valueRef` and `shouldRefocusRef` are stable refs. `disabled` (read by
|
|
223
|
-
// `setValue`'s rAF guard) and `focusTextarea` change session-rarely; `fire`
|
|
224
|
-
// is memoized on its gate inputs (`sending`/`onSend`/`allowEmptySend` all
|
|
225
|
-
// live inside it), so the handle rebuilds only when those change — still
|
|
226
|
-
// far fewer than per-keystroke.
|
|
227
|
-
[focusTextarea, disabled, fire],
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
// The Enter key calls handleSubmit directly (bypassing the Send button's
|
|
231
|
-
// per-render `sendDisabled`), so it must read a fresh gate — `fire` is
|
|
232
|
-
// memoized on every gate input, so depending on `fire` + `value` is both
|
|
233
|
-
// fresh and exhaustive-deps-clean.
|
|
234
|
-
const handleSubmit = useCallback(() => {
|
|
235
|
-
fire(value.trim())
|
|
236
|
-
}, [fire, value])
|
|
237
|
-
|
|
238
|
-
// `@`-mention trigger detection. Matches a trailing `@token` preceded by
|
|
239
|
-
// start-of-text or whitespace (so emails like `foo@bar` never trigger).
|
|
240
|
-
// `@` alone yields an empty query (open the picker unfiltered). A trailing
|
|
241
|
-
// space dismisses the trigger. Enabled only when the host wires the
|
|
242
|
-
// callback.
|
|
243
|
-
const mentionEnabled = !!onMentionQueryChange
|
|
244
|
-
const mentionQuery = useMemo(() => {
|
|
245
|
-
if (!mentionEnabled) return null
|
|
246
|
-
const m = value.match(/(?:^|\s)@([\w.-]*)$/)
|
|
247
|
-
return m ? (m[1] ?? '') : null
|
|
248
|
-
}, [value, mentionEnabled])
|
|
249
|
-
|
|
250
|
-
// Surface the active mention query (or null) to the composer, which maps
|
|
251
|
-
// a non-null value to "open the context picker".
|
|
252
|
-
useEffect(() => {
|
|
253
|
-
if (mentionEnabled) onMentionQueryChange?.(mentionQuery)
|
|
254
|
-
}, [mentionEnabled, mentionQuery, onMentionQueryChange])
|
|
255
|
-
|
|
256
|
-
// Slash-command autocomplete state. Detection runs in render so the
|
|
257
|
-
// keyboard handler can branch on it without re-parsing.
|
|
258
|
-
const slashMatch = useMemo(
|
|
259
|
-
() => (slashCommands ? value.match(SLASH_INPUT_TRIGGER) : null),
|
|
260
|
-
[value, slashCommands],
|
|
261
|
-
)
|
|
262
|
-
const slashPrefix = slashMatch ? slashMatch[1] ?? '' : null
|
|
263
|
-
const [slashSuggestions, setSlashSuggestions] = useState<SlashCommandSummary[]>([])
|
|
264
|
-
const [highlightedIdx, setHighlightedIdx] = useState(0)
|
|
265
|
-
|
|
266
|
-
useEffect(() => {
|
|
267
|
-
if (slashPrefix == null || !slashCommands) {
|
|
268
|
-
setSlashSuggestions([])
|
|
269
|
-
return
|
|
16
|
+
/** A committed `@`-mention serializes into the draft text as `@<marker>:<id>`
|
|
17
|
+
* (id charset allows `+ / = . -` for base64 Relay global ids). The editor
|
|
18
|
+
* renders each such token as an inline chip; the serialized string keeps the
|
|
19
|
+
* literal token so the host's `@type:id` reconciliation + send payload are
|
|
20
|
+
* unchanged. */
|
|
21
|
+
const MENTION_GLOBAL = /@[A-Za-z0-9_.+/=-]+:[A-Za-z0-9_.+/=-]+/g
|
|
22
|
+
/** The IN-PROGRESS trigger being typed — `@` + search string at the END of the
|
|
23
|
+
* draft, preceded by start-of-text or whitespace (so emails never fire). */
|
|
24
|
+
const MENTION_TRIGGER_AT_END = /(^|\s)@([\w.-]*)$/
|
|
25
|
+
|
|
26
|
+
type Segment = { kind: 'text'; text: string } | { kind: 'mention'; token: string; label: string; icon?: ReactNode }
|
|
27
|
+
|
|
28
|
+
function parseSegments(value: string, meta: Map<string, MentionMeta>): Segment[] {
|
|
29
|
+
const segs: Segment[] = []
|
|
30
|
+
let last = 0
|
|
31
|
+
MENTION_GLOBAL.lastIndex = 0
|
|
32
|
+
let m: RegExpExecArray | null
|
|
33
|
+
while ((m = MENTION_GLOBAL.exec(value)) !== null) {
|
|
34
|
+
if (m.index > last) segs.push({ kind: 'text', text: value.slice(last, m.index) })
|
|
35
|
+
const token = m[0].slice(1)
|
|
36
|
+
const info = meta.get(token)
|
|
37
|
+
segs.push({ kind: 'mention', token, label: info?.label ?? token.slice(token.indexOf(':') + 1), icon: info?.icon })
|
|
38
|
+
last = m.index + m[0].length
|
|
39
|
+
}
|
|
40
|
+
if (last < value.length) segs.push({ kind: 'text', text: value.slice(last) })
|
|
41
|
+
return segs
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isChip = (n: Node): n is HTMLElement => n.nodeType === 1 && (n as HTMLElement).dataset?.token !== undefined
|
|
45
|
+
|
|
46
|
+
/** Serialize the editor DOM back to the draft string: text nodes verbatim,
|
|
47
|
+
* mention chips → `@<token>`, browser-inserted `<br>` ignored (newlines live as
|
|
48
|
+
* literal `\n` text via `white-space: pre-wrap`). Chips are atomic
|
|
49
|
+
* (`contentEditable=false`) so we never descend into their inner markup. */
|
|
50
|
+
function serialize(el: HTMLElement): string {
|
|
51
|
+
let out = ''
|
|
52
|
+
for (const node of Array.from(el.childNodes)) {
|
|
53
|
+
if (node.nodeType === Node.TEXT_NODE) out += node.textContent ?? ''
|
|
54
|
+
else if (isChip(node)) out += `@${(node as HTMLElement).dataset.token}`
|
|
55
|
+
else if ((node as HTMLElement).tagName === 'BR') continue
|
|
56
|
+
else out += node.textContent ?? ''
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build a mention chip as a plain (React-free) DOM node — the editor is an
|
|
62
|
+
* UNCONTROLLED contenteditable, so chips must be real DOM the browser owns
|
|
63
|
+
* (atomically deletable, never reconciled by React). We REUSE the lib `Tag`
|
|
64
|
+
* (`variant="badge"` + its built-in `onClose` ⊗) by rendering it to static
|
|
65
|
+
* markup; the outer wrapper carries `data-token`/`contenteditable=false`, and
|
|
66
|
+
* the close button is wired via a delegated click on the editor (events don't
|
|
67
|
+
* survive static markup). */
|
|
68
|
+
function buildChipEl(token: string, label: string, icon?: ReactNode): HTMLElement {
|
|
69
|
+
const span = document.createElement('span')
|
|
70
|
+
span.dataset.token = token
|
|
71
|
+
span.setAttribute('contenteditable', 'false')
|
|
72
|
+
// Center the chip in its line box DETERMINISTICALLY (independent of font
|
|
73
|
+
// metrics): `vertical-align: middle` references the text baseline + x-height,
|
|
74
|
+
// not the geometric line-box center, so it leaves the chip ~2px low. Instead
|
|
75
|
+
// make the wrapper exactly one line box tall (`h-9` == editor `leading-9`,
|
|
76
|
+
// 36px) and `align-top` (pure geometric: wrapper top == line-box top, no
|
|
77
|
+
// baseline offset), then center the shorter chip inside via `items-center`.
|
|
78
|
+
span.className = 'mx-0.5 inline-flex h-9 items-center select-none align-top'
|
|
79
|
+
span.innerHTML = renderToStaticMarkup(
|
|
80
|
+
<Tag
|
|
81
|
+
as="span"
|
|
82
|
+
variant="badge"
|
|
83
|
+
icon={icon}
|
|
84
|
+
label={label}
|
|
85
|
+
onClose={() => {}}
|
|
86
|
+
// Match the context-chip pill, not the badge defaults: muted (grey) lead
|
|
87
|
+
// icon + ⊗ instead of white (`text-ods-text-primary` stays on the label
|
|
88
|
+
// only), and NO accent-yellow border on hover.
|
|
89
|
+
className="max-w-[16rem] hover:border-ods-border [&_svg]:text-ods-text-secondary"
|
|
90
|
+
/>,
|
|
91
|
+
)
|
|
92
|
+
return span
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Replace the editor's content with text nodes + chips parsed from `value`. */
|
|
96
|
+
function rebuildDom(el: HTMLElement, value: string, meta: Map<string, MentionMeta>): void {
|
|
97
|
+
el.replaceChildren()
|
|
98
|
+
for (const seg of parseSegments(value, meta)) {
|
|
99
|
+
if (seg.kind === 'text') el.appendChild(document.createTextNode(seg.text))
|
|
100
|
+
else el.appendChild(buildChipEl(seg.token, seg.label, seg.icon))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function placeCaretAtEnd(el: HTMLElement): void {
|
|
105
|
+
const sel = typeof window !== 'undefined' ? window.getSelection() : null
|
|
106
|
+
if (!sel) return
|
|
107
|
+
const range = document.createRange()
|
|
108
|
+
range.selectNodeContents(el)
|
|
109
|
+
range.collapse(false)
|
|
110
|
+
sel.removeAllRanges()
|
|
111
|
+
sel.addRange(range)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Place the caret at a character offset in a PLAIN-text editor (no chips) —
|
|
115
|
+
* used only by `setValueAndCursor` (slash prefill). */
|
|
116
|
+
function placeCaretAtOffset(el: HTMLElement, offset: number): void {
|
|
117
|
+
const sel = typeof window !== 'undefined' ? window.getSelection() : null
|
|
118
|
+
if (!sel) return
|
|
119
|
+
const node = el.firstChild
|
|
120
|
+
const range = document.createRange()
|
|
121
|
+
if (node && node.nodeType === Node.TEXT_NODE) {
|
|
122
|
+
range.setStart(node, Math.max(0, Math.min(offset, node.textContent?.length ?? 0)))
|
|
123
|
+
range.collapse(true)
|
|
124
|
+
} else {
|
|
125
|
+
range.selectNodeContents(el)
|
|
126
|
+
range.collapse(false)
|
|
127
|
+
}
|
|
128
|
+
sel.removeAllRanges()
|
|
129
|
+
sel.addRange(range)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Insert plain text at the caret as a real text node (paste / Shift+Enter), so
|
|
133
|
+
* no `<br>`/`<div>` ever enters the DOM and serialization stays deterministic. */
|
|
134
|
+
function insertTextAtCaret(text: string): void {
|
|
135
|
+
const sel = typeof window !== 'undefined' ? window.getSelection() : null
|
|
136
|
+
if (!sel || sel.rangeCount === 0) return
|
|
137
|
+
const range = sel.getRangeAt(0)
|
|
138
|
+
range.deleteContents()
|
|
139
|
+
const tn = document.createTextNode(text)
|
|
140
|
+
range.insertNode(tn)
|
|
141
|
+
range.setStartAfter(tn)
|
|
142
|
+
range.collapse(true)
|
|
143
|
+
sel.removeAllRanges()
|
|
144
|
+
sel.addRange(range)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
|
|
148
|
+
const { slashCommands, ...rest } = allProps
|
|
149
|
+
const {
|
|
150
|
+
className,
|
|
151
|
+
onSend,
|
|
152
|
+
onStop,
|
|
153
|
+
sending = false,
|
|
154
|
+
awaitingResponse = false,
|
|
155
|
+
placeholder = "Enter your Request...",
|
|
156
|
+
reserveAvatarOffset: _reserveAvatarOffset,
|
|
157
|
+
disabled = false,
|
|
158
|
+
autoFocus = false,
|
|
159
|
+
fullWidth = false,
|
|
160
|
+
allowEmptySend = false,
|
|
161
|
+
onMentionQueryChange,
|
|
162
|
+
onValueChange,
|
|
163
|
+
startIcon,
|
|
164
|
+
hideBorder,
|
|
165
|
+
// Remaining textarea-only attrs are intentionally dropped — the editor is a
|
|
166
|
+
// contenteditable div, not a <textarea>, so spreading them would warn.
|
|
167
|
+
} = rest as typeof rest & { rows?: number }
|
|
168
|
+
|
|
169
|
+
const [value, setValue] = useState('')
|
|
170
|
+
const [focused, setFocused] = useState(false)
|
|
171
|
+
const [isStopping, setIsStopping] = useState(false)
|
|
172
|
+
const editorRef = useRef<HTMLDivElement>(null)
|
|
173
|
+
const shouldRefocusRef = useRef(false)
|
|
174
|
+
const prevSendingRef = useRef(sending)
|
|
175
|
+
const prevAwaitingResponseRef = useRef(awaitingResponse)
|
|
176
|
+
const isComposingRef = useRef(false)
|
|
177
|
+
// Live mirror of the value + committed-chip meta for sync reads inside the
|
|
178
|
+
// stable imperative handle (no rebuild of the handle per keystroke).
|
|
179
|
+
const valueRef = useRef(value)
|
|
180
|
+
valueRef.current = value
|
|
181
|
+
const metaRef = useRef<Map<string, MentionMeta>>(new Map())
|
|
182
|
+
|
|
183
|
+
const focusEditor = useCallback(() => {
|
|
184
|
+
if (disabled) return
|
|
185
|
+
editorRef.current?.focus({ preventScroll: true })
|
|
186
|
+
}, [disabled])
|
|
187
|
+
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (autoFocus) focusEditor()
|
|
190
|
+
}, [autoFocus, focusEditor])
|
|
191
|
+
|
|
192
|
+
// Surface every value change (typed or imperative) so the composer reconciles
|
|
193
|
+
// `@type:id` mention chips with the context items.
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
onValueChange?.(value)
|
|
196
|
+
}, [value, onValueChange])
|
|
197
|
+
|
|
198
|
+
// Apply a NEW draft value to BOTH the DOM (imperative rebuild) and state. Used
|
|
199
|
+
// by every imperative mutation (commit, setValue, clear, remove). Typing does
|
|
200
|
+
// NOT go through here — the browser owns the DOM and we only read it back.
|
|
201
|
+
const applyValue = useCallback((next: string, caret: 'end' | number = 'end') => {
|
|
202
|
+
const el = editorRef.current
|
|
203
|
+
if (el) {
|
|
204
|
+
rebuildDom(el, next, metaRef.current)
|
|
205
|
+
if (document.activeElement === el || caret === 'end') {
|
|
206
|
+
if (typeof caret === 'number') placeCaretAtOffset(el, caret)
|
|
207
|
+
else placeCaretAtEnd(el)
|
|
270
208
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
// AbortError on dep-change / unmount is expected.
|
|
287
|
-
if (!cancelled && (err as Error)?.name !== 'AbortError') {
|
|
288
|
-
console.warn('[chat-input] slash-command fetch failed:', err)
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
}, 150)
|
|
292
|
-
return () => {
|
|
293
|
-
cancelled = true
|
|
294
|
-
ctrl.abort()
|
|
295
|
-
clearTimeout(handle)
|
|
209
|
+
}
|
|
210
|
+
setValue(next)
|
|
211
|
+
}, [])
|
|
212
|
+
|
|
213
|
+
// ── send ──────────────────────────────────────────────────────────────────
|
|
214
|
+
const fire = useCallback(
|
|
215
|
+
(message: string) => {
|
|
216
|
+
const can = (message.length > 0 || allowEmptySend) && !sending && !disabled
|
|
217
|
+
if (!can || !onSend) return
|
|
218
|
+
const result = onSend(message)
|
|
219
|
+
const done = () => {
|
|
220
|
+
metaRef.current = new Map()
|
|
221
|
+
applyValue('')
|
|
222
|
+
shouldRefocusRef.current = true
|
|
223
|
+
focusEditor()
|
|
296
224
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
(cmd: SlashCommandSummary) => {
|
|
301
|
-
// Keep trailing space so the user types args without an extra space.
|
|
302
|
-
// Matches macOS / VSCode autocomplete UX.
|
|
303
|
-
const next = `/${cmd.id} `
|
|
304
|
-
setValue(next)
|
|
305
|
-
setSlashSuggestions([])
|
|
306
|
-
// After focus, position the caret at the end of the prefilled
|
|
307
|
-
// string. Programmatic `.focus()` defaults to caret-at-0 in most
|
|
308
|
-
// browsers, which would force the user to arrow-right past every
|
|
309
|
-
// prefilled character before typing args. Defer one frame so the
|
|
310
|
-
// textarea has the new value committed by React first.
|
|
311
|
-
requestAnimationFrame(() => {
|
|
312
|
-
const el = textareaRef.current
|
|
313
|
-
if (!el || el.disabled) return
|
|
314
|
-
el.focus({ preventScroll: true })
|
|
315
|
-
el.setSelectionRange(next.length, next.length)
|
|
225
|
+
if (result instanceof Promise) {
|
|
226
|
+
void result.then((ok) => {
|
|
227
|
+
if (ok !== false) done()
|
|
316
228
|
})
|
|
229
|
+
} else if (result !== false) {
|
|
230
|
+
done()
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
[allowEmptySend, sending, disabled, onSend, focusEditor, applyValue],
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
const handleSubmit = useCallback(() => {
|
|
237
|
+
fire(valueRef.current.trim())
|
|
238
|
+
}, [fire])
|
|
239
|
+
|
|
240
|
+
useImperativeHandle(
|
|
241
|
+
ref,
|
|
242
|
+
(): ChatInputRef => ({
|
|
243
|
+
focus: () => focusEditor(),
|
|
244
|
+
blur: () => editorRef.current?.blur(),
|
|
245
|
+
clear: () => {
|
|
246
|
+
metaRef.current = new Map()
|
|
247
|
+
applyValue('')
|
|
317
248
|
},
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
(
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
//
|
|
333
|
-
// the
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
t.setSelectionRange(pos, pos)
|
|
348
|
-
})
|
|
349
|
-
return
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
249
|
+
setValue: (next: string) => {
|
|
250
|
+
applyValue(next)
|
|
251
|
+
requestAnimationFrame(() => focusEditor())
|
|
252
|
+
},
|
|
253
|
+
setValueAndCursor: (next: string, cursorOffset: number) => {
|
|
254
|
+
applyValue(next, Math.max(0, Math.min(cursorOffset, next.length)))
|
|
255
|
+
requestAnimationFrame(() => focusEditor())
|
|
256
|
+
},
|
|
257
|
+
submit: (next: string) => fire(next.trim()),
|
|
258
|
+
getValue: () => valueRef.current,
|
|
259
|
+
removeMentionTrigger: () => {
|
|
260
|
+
applyValue(valueRef.current.replace(MENTION_TRIGGER_AT_END, '$1'))
|
|
261
|
+
},
|
|
262
|
+
commitMention: (token: string, meta?: MentionMeta) => {
|
|
263
|
+
// Multi-select: replace the trailing `@query` being typed with the
|
|
264
|
+
// committed `@<token> ` (+ trailing space → dismisses the trigger so the
|
|
265
|
+
// picker closes; also gives the caret a text node to land in after the
|
|
266
|
+
// chip). Prior committed mentions stay — several chips coexist.
|
|
267
|
+
if (meta) metaRef.current = new Map(metaRef.current).set(token, meta)
|
|
268
|
+
const v = valueRef.current
|
|
269
|
+
const next = MENTION_TRIGGER_AT_END.test(v)
|
|
270
|
+
? v.replace(MENTION_TRIGGER_AT_END, `$1@${token} `)
|
|
271
|
+
: `${v}${v.length > 0 && !v.endsWith(' ') ? ' ' : ''}@${token} `
|
|
272
|
+
applyValue(next)
|
|
273
|
+
requestAnimationFrame(() => focusEditor())
|
|
274
|
+
},
|
|
275
|
+
}),
|
|
276
|
+
[focusEditor, fire, applyValue],
|
|
277
|
+
)
|
|
353
278
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
)
|
|
372
|
-
return
|
|
373
|
-
}
|
|
374
|
-
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
|
|
375
|
-
e.preventDefault()
|
|
376
|
-
const sel = slashSuggestions[highlightedIdx]
|
|
377
|
-
if (sel) acceptSuggestion(sel)
|
|
378
|
-
return
|
|
379
|
-
}
|
|
380
|
-
}
|
|
279
|
+
// ── `@`-mention trigger detection ───────────────────────────────────────────
|
|
280
|
+
const mentionEnabled = !!onMentionQueryChange
|
|
281
|
+
const mentionQuery = useMemo(() => {
|
|
282
|
+
if (!mentionEnabled) return null
|
|
283
|
+
const m = value.match(MENTION_TRIGGER_AT_END)
|
|
284
|
+
return m ? (m[2] ?? '') : null
|
|
285
|
+
}, [value, mentionEnabled])
|
|
286
|
+
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (mentionEnabled) onMentionQueryChange?.(mentionQuery)
|
|
289
|
+
}, [mentionEnabled, mentionQuery, onMentionQueryChange])
|
|
290
|
+
|
|
291
|
+
// ── slash-command autocomplete ──────────────────────────────────────────────
|
|
292
|
+
const slashMatch = useMemo(() => (slashCommands ? value.match(SLASH_INPUT_TRIGGER) : null), [value, slashCommands])
|
|
293
|
+
const slashPrefix = slashMatch ? (slashMatch[1] ?? '') : null
|
|
294
|
+
const [slashSuggestions, setSlashSuggestions] = useState<SlashCommandSummary[]>([])
|
|
295
|
+
const [highlightedIdx, setHighlightedIdx] = useState(0)
|
|
381
296
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
if (slashPrefix == null || !slashCommands) {
|
|
299
|
+
setSlashSuggestions([])
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
let cancelled = false
|
|
303
|
+
const ctrl = new AbortController()
|
|
304
|
+
const handle = setTimeout(async () => {
|
|
305
|
+
try {
|
|
306
|
+
const next = await slashCommands.fetchCommands(slashPrefix, ctrl.signal)
|
|
307
|
+
if (!cancelled) {
|
|
308
|
+
setSlashSuggestions(next)
|
|
309
|
+
setHighlightedIdx(0)
|
|
310
|
+
}
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (!cancelled && (err as Error)?.name !== 'AbortError') {
|
|
313
|
+
console.warn('[chat-input] slash-command fetch failed:', err)
|
|
396
314
|
}
|
|
315
|
+
}
|
|
316
|
+
}, 150)
|
|
317
|
+
return () => {
|
|
318
|
+
cancelled = true
|
|
319
|
+
ctrl.abort()
|
|
320
|
+
clearTimeout(handle)
|
|
321
|
+
}
|
|
322
|
+
}, [slashPrefix, slashCommands])
|
|
397
323
|
|
|
398
|
-
|
|
324
|
+
const acceptSuggestion = useCallback((cmd: SlashCommandSummary) => {
|
|
325
|
+
metaRef.current = new Map()
|
|
326
|
+
const next = `/${cmd.id} `
|
|
327
|
+
applyValue(next, next.length)
|
|
328
|
+
setSlashSuggestions([])
|
|
329
|
+
requestAnimationFrame(() => focusEditor())
|
|
330
|
+
}, [applyValue, focusEditor])
|
|
331
|
+
|
|
332
|
+
// ── editor events ───────────────────────────────────────────────────────────
|
|
333
|
+
const syncFromDom = useCallback(() => {
|
|
334
|
+
const el = editorRef.current
|
|
335
|
+
if (!el) return
|
|
336
|
+
setValue(serialize(el))
|
|
337
|
+
}, [])
|
|
338
|
+
|
|
339
|
+
const handleInput = useCallback(() => {
|
|
340
|
+
if (isComposingRef.current) return // mid-IME: the browser owns the DOM
|
|
341
|
+
syncFromDom()
|
|
342
|
+
}, [syncFromDom])
|
|
343
|
+
|
|
344
|
+
const handlePaste = useCallback((e: ClipboardEvent<HTMLDivElement>) => {
|
|
345
|
+
e.preventDefault()
|
|
346
|
+
const text = e.clipboardData.getData('text/plain')
|
|
347
|
+
if (!text) return
|
|
348
|
+
insertTextAtCaret(text)
|
|
349
|
+
syncFromDom()
|
|
350
|
+
}, [syncFromDom])
|
|
351
|
+
|
|
352
|
+
const handleKeyDown = useCallback(
|
|
353
|
+
(e: KeyboardEvent<HTMLDivElement>) => {
|
|
354
|
+
if (slashSuggestions.length > 0 && slashPrefix !== null) {
|
|
355
|
+
if (e.key === 'Escape') { e.preventDefault(); setSlashSuggestions([]); return }
|
|
356
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightedIdx((i) => (i + 1) % slashSuggestions.length); return }
|
|
357
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightedIdx((i) => (i - 1 + slashSuggestions.length) % slashSuggestions.length); return }
|
|
358
|
+
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
|
|
399
359
|
e.preventDefault()
|
|
400
|
-
|
|
360
|
+
const sel = slashSuggestions[highlightedIdx]
|
|
361
|
+
if (sel) acceptSuggestion(sel)
|
|
362
|
+
return
|
|
401
363
|
}
|
|
402
|
-
},
|
|
403
|
-
[
|
|
404
|
-
slashSuggestions,
|
|
405
|
-
slashPrefix,
|
|
406
|
-
highlightedIdx,
|
|
407
|
-
acceptSuggestion,
|
|
408
|
-
handleSubmit,
|
|
409
|
-
mentionQuery,
|
|
410
|
-
mentionEnabled,
|
|
411
|
-
value,
|
|
412
|
-
],
|
|
413
|
-
)
|
|
414
|
-
|
|
415
|
-
const handleChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
416
|
-
setValue(e.target.value)
|
|
417
|
-
}, [])
|
|
418
|
-
|
|
419
|
-
const [isStopping, setIsStopping] = useState(false)
|
|
420
|
-
|
|
421
|
-
useEffect(() => {
|
|
422
|
-
if (!sending) {
|
|
423
|
-
setIsStopping(false)
|
|
424
|
-
}
|
|
425
|
-
}, [sending])
|
|
426
|
-
|
|
427
|
-
useEffect(() => {
|
|
428
|
-
const wasSending = prevSendingRef.current
|
|
429
|
-
prevSendingRef.current = sending
|
|
430
|
-
if (wasSending && !sending && shouldRefocusRef.current && !awaitingResponse && !disabled) {
|
|
431
|
-
shouldRefocusRef.current = false
|
|
432
|
-
focusTextarea()
|
|
433
364
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
prevAwaitingResponseRef.current = awaitingResponse
|
|
439
|
-
if (wasAwaiting && !awaitingResponse && shouldRefocusRef.current && !disabled) {
|
|
440
|
-
shouldRefocusRef.current = false
|
|
441
|
-
const id = requestAnimationFrame(() => focusTextarea())
|
|
442
|
-
return () => cancelAnimationFrame(id)
|
|
365
|
+
|
|
366
|
+
if (mentionQuery !== null) {
|
|
367
|
+
if (e.key === 'Escape') { e.preventDefault(); applyValue(valueRef.current.replace(MENTION_TRIGGER_AT_END, '$1')); return }
|
|
368
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); return }
|
|
443
369
|
}
|
|
444
|
-
}, [awaitingResponse, disabled, focusTextarea])
|
|
445
370
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
371
|
+
if (e.key === 'Enter') {
|
|
372
|
+
if (e.shiftKey) {
|
|
373
|
+
e.preventDefault()
|
|
374
|
+
insertTextAtCaret('\n')
|
|
375
|
+
syncFromDom()
|
|
376
|
+
} else {
|
|
377
|
+
e.preventDefault()
|
|
378
|
+
handleSubmit()
|
|
379
|
+
}
|
|
453
380
|
}
|
|
454
|
-
},
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
381
|
+
},
|
|
382
|
+
[slashSuggestions, slashPrefix, highlightedIdx, acceptSuggestion, handleSubmit, mentionQuery, applyValue, syncFromDom],
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
// Delegated remove: a click on a chip's ⊗ (Tag's `onClose` button, inside a
|
|
386
|
+
// `[data-token]` wrapper). Strip that token from the draft → value change →
|
|
387
|
+
// host drops the matching context item.
|
|
388
|
+
const handleEditorMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
389
|
+
const target = e.target as HTMLElement
|
|
390
|
+
const chip = target.closest<HTMLElement>('[data-token]')
|
|
391
|
+
if (!chip || !target.closest('button')) return
|
|
392
|
+
e.preventDefault() // keep editor focus; don't move the caret into the chip
|
|
393
|
+
const token = chip.dataset.token ?? ''
|
|
394
|
+
const next = valueRef.current
|
|
395
|
+
.replace(`@${token} `, '')
|
|
396
|
+
.replace(`@${token}`, '')
|
|
397
|
+
.replace(/[^\S\n]{2,}/g, ' ')
|
|
398
|
+
.replace(/^[^\S\n]+/, '')
|
|
399
|
+
applyValue(next)
|
|
400
|
+
requestAnimationFrame(() => focusEditor())
|
|
401
|
+
}, [applyValue, focusEditor])
|
|
402
|
+
|
|
403
|
+
// ── refocus after send/await transitions ────────────────────────────────────
|
|
404
|
+
useEffect(() => { if (!sending) setIsStopping(false) }, [sending])
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
const wasSending = prevSendingRef.current
|
|
407
|
+
prevSendingRef.current = sending
|
|
408
|
+
if (wasSending && !sending && shouldRefocusRef.current && !awaitingResponse && !disabled) {
|
|
409
|
+
shouldRefocusRef.current = false
|
|
410
|
+
focusEditor()
|
|
411
|
+
}
|
|
412
|
+
}, [sending, awaitingResponse, disabled, focusEditor])
|
|
413
|
+
useEffect(() => {
|
|
414
|
+
const wasAwaiting = prevAwaitingResponseRef.current
|
|
415
|
+
prevAwaitingResponseRef.current = awaitingResponse
|
|
416
|
+
if (wasAwaiting && !awaitingResponse && shouldRefocusRef.current && !disabled) {
|
|
417
|
+
shouldRefocusRef.current = false
|
|
418
|
+
const id = requestAnimationFrame(() => focusEditor())
|
|
419
|
+
return () => cancelAnimationFrame(id)
|
|
420
|
+
}
|
|
421
|
+
}, [awaitingResponse, disabled, focusEditor])
|
|
422
|
+
|
|
423
|
+
const handleStop = useCallback(async () => {
|
|
424
|
+
if (!onStop || isStopping) return
|
|
425
|
+
setIsStopping(true)
|
|
426
|
+
try {
|
|
427
|
+
await onStop()
|
|
428
|
+
} catch {
|
|
429
|
+
setIsStopping(false)
|
|
430
|
+
}
|
|
431
|
+
}, [onStop, isStopping])
|
|
432
|
+
|
|
433
|
+
const isStopMode = sending && !!onStop
|
|
434
|
+
const hasContent = value.trim().length > 0 || allowEmptySend
|
|
435
|
+
const sendDisabled = sending || disabled || !hasContent
|
|
436
|
+
const isEmpty = value.length === 0
|
|
437
|
+
const isDisabled = sending || disabled
|
|
438
|
+
|
|
439
|
+
return (
|
|
440
|
+
<div
|
|
441
|
+
className={cn(
|
|
442
|
+
fullWidth ? "w-full flex-shrink-0" : "mx-auto w-full max-w-ods-content-narrow flex-shrink-0",
|
|
443
|
+
className,
|
|
444
|
+
)}
|
|
445
|
+
>
|
|
446
|
+
{awaitingResponse ? (
|
|
447
|
+
<div className="relative flex items-center justify-center gap-[var(--spacing-system-xs)] rounded-md bg-ods-card border border-ods-border px-[var(--spacing-system-s)] py-[var(--spacing-system-s)] transition-colors">
|
|
448
|
+
<ChatTypingIndicator size="sm" dotClassName="bg-ods-text-primary" />
|
|
449
|
+
<p className="text-h4 text-ods-text-secondary">Waiting for Technician Response</p>
|
|
450
|
+
</div>
|
|
451
|
+
) : (
|
|
452
|
+
<div className="relative">
|
|
453
|
+
<SlashCommandSuggestions
|
|
454
|
+
commands={slashPrefix !== null ? slashSuggestions : []}
|
|
455
|
+
highlightedIdx={highlightedIdx}
|
|
456
|
+
onHover={setHighlightedIdx}
|
|
457
|
+
onSelect={acceptSuggestion}
|
|
458
|
+
resolveSourceIcon={slashCommands?.resolveSourceIcon}
|
|
459
|
+
onAction={slashCommands?.onAction}
|
|
460
|
+
/>
|
|
461
|
+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: focus proxy for the contenteditable; keyboard users focus the editor directly. */}
|
|
462
|
+
<div
|
|
463
|
+
onMouseDown={(e) => {
|
|
464
|
+
if (e.target === e.currentTarget) {
|
|
465
|
+
e.preventDefault()
|
|
466
|
+
focusEditor()
|
|
467
|
+
}
|
|
468
|
+
}}
|
|
469
|
+
className={cn(
|
|
470
|
+
// `items-center` keeps the empty single-line text AND a mention chip
|
|
471
|
+
// vertically centered the same way. `py-1.5` (6px) pairs with the
|
|
472
|
+
// editor's `leading-9` (36px) → a single line is exactly 48px
|
|
473
|
+
// (36 + 2*6). The 36px line box is just barely above the 32px chip
|
|
474
|
+
// (incl. its ~3.5px vertical-align overhead), so there's almost no
|
|
475
|
+
// slack for the chip to drift in → it reads as centered, and the row
|
|
476
|
+
// never overflows past 48px.
|
|
477
|
+
"flex w-full items-center gap-2 px-3 py-1.5 min-h-11 md:min-h-12 cursor-text group transition-colors duration-200",
|
|
478
|
+
!hideBorder && "rounded-[6px] border bg-ods-card border-ods-border",
|
|
479
|
+
!hideBorder && (focused ? "border-ods-accent" : !isDisabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover"),
|
|
480
|
+
!hideBorder && isDisabled && "!cursor-not-allowed bg-ods-bg",
|
|
481
|
+
hideBorder && "bg-transparent",
|
|
482
|
+
)}
|
|
483
|
+
>
|
|
484
|
+
{startIcon && <span className="flex h-6 shrink-0 items-center">{startIcon}</span>}
|
|
485
|
+
|
|
486
|
+
<div className="relative flex-1 min-w-0">
|
|
487
|
+
{isEmpty && (
|
|
488
|
+
<span className="pointer-events-none absolute left-0 top-0 select-none text-h4 !leading-9 text-ods-text-secondary">
|
|
489
|
+
{disabled ? "Connection lost. Waiting to reconnect..." : placeholder}
|
|
490
|
+
</span>
|
|
491
|
+
)}
|
|
492
|
+
{/* UNCONTROLLED contenteditable: React renders it WITHOUT children
|
|
493
|
+
and never reconciles its content — all text/chips are managed
|
|
494
|
+
imperatively (browser owns typing/delete; we own chip insert). */}
|
|
495
|
+
{/* biome-ignore lint/a11y/useFocusableInteractive: role=textbox on a contenteditable is focusable. */}
|
|
496
|
+
{/* biome-ignore lint/a11y/useSemanticElements: contenteditable rich input has no native element. */}
|
|
497
|
+
<div
|
|
498
|
+
ref={editorRef}
|
|
499
|
+
data-editor
|
|
500
|
+
role="textbox"
|
|
501
|
+
aria-multiline="true"
|
|
502
|
+
aria-label={typeof placeholder === 'string' ? placeholder : 'Message'}
|
|
503
|
+
contentEditable={!isDisabled}
|
|
504
|
+
suppressContentEditableWarning
|
|
505
|
+
spellCheck
|
|
506
|
+
onInput={handleInput}
|
|
507
|
+
onKeyDown={handleKeyDown}
|
|
508
|
+
onPaste={handlePaste}
|
|
509
|
+
onMouseDown={handleEditorMouseDown}
|
|
510
|
+
onCompositionStart={() => { isComposingRef.current = true }}
|
|
511
|
+
onCompositionEnd={() => { isComposingRef.current = false; syncFromDom() }}
|
|
512
|
+
onFocus={() => setFocused(true)}
|
|
513
|
+
onBlur={() => setFocused(false)}
|
|
514
|
+
className={cn(
|
|
515
|
+
"max-h-[160px] overflow-y-auto whitespace-pre-wrap break-words outline-none",
|
|
516
|
+
// `leading-9` (36px) sits just above the 32px chip's line-box need
|
|
517
|
+
// (~35.5px incl. vertical-align overhead): the LINE drives row
|
|
518
|
+
// height (deterministic 48px, no overflow) AND the near-zero slack
|
|
519
|
+
// keeps the chip centered. ~4px gap between wrapped chip rows.
|
|
520
|
+
"text-h4 text-ods-text-primary !leading-9",
|
|
521
|
+
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30",
|
|
522
|
+
isDisabled && "cursor-not-allowed",
|
|
523
|
+
)}
|
|
524
|
+
/>
|
|
525
|
+
</div>
|
|
526
|
+
|
|
527
|
+
<button
|
|
528
|
+
type="button"
|
|
529
|
+
aria-label={isStopMode ? 'Stop generation' : 'Send message'}
|
|
530
|
+
onClick={isStopMode ? handleStop : handleSubmit}
|
|
531
|
+
disabled={isStopMode ? isStopping : sendDisabled}
|
|
532
|
+
className={cn(
|
|
533
|
+
"flex h-6 shrink-0 items-center text-ods-text-secondary transition-colors duration-200",
|
|
534
|
+
focused && "text-ods-accent",
|
|
535
|
+
"[&_svg]:size-4 md:[&_svg]:size-6",
|
|
536
|
+
"cursor-pointer hover:text-ods-text-primary",
|
|
537
|
+
"focus-visible:outline-none focus-visible:text-ods-accent",
|
|
538
|
+
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-ods-text-secondary",
|
|
539
|
+
)}
|
|
540
|
+
>
|
|
541
|
+
{isStopMode ? <StopCircleIcon size={20} /> : <Send01Icon size={20} />}
|
|
542
|
+
</button>
|
|
508
543
|
</div>
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
)
|
|
544
|
+
</div>
|
|
545
|
+
)}
|
|
546
|
+
</div>
|
|
547
|
+
)
|
|
548
|
+
})
|
|
514
549
|
|
|
515
550
|
ChatInput.displayName = "ChatInput"
|
|
516
551
|
|