@flamingo-stack/openframe-frontend-core 0.0.319 → 0.0.321

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/{chunk-XAGD3XZV.js → chunk-563D7EB2.js} +4 -4
  2. package/dist/{chunk-B34YGPIB.cjs → chunk-5HQIFRRE.cjs} +7 -7
  3. package/dist/{chunk-B34YGPIB.cjs.map → chunk-5HQIFRRE.cjs.map} +1 -1
  4. package/dist/{chunk-ARQ3R7WZ.js → chunk-6DH2TCRH.js} +2 -2
  5. package/dist/{chunk-ZD5ZDEL5.cjs → chunk-7C5G45KU.cjs} +1502 -1298
  6. package/dist/chunk-7C5G45KU.cjs.map +1 -0
  7. package/dist/{chunk-CGG7EWYG.cjs → chunk-7M3QIYFW.cjs} +56 -56
  8. package/dist/{chunk-CGG7EWYG.cjs.map → chunk-7M3QIYFW.cjs.map} +1 -1
  9. package/dist/{chunk-4YFOBTFE.js → chunk-BRERE4E5.js} +4 -4
  10. package/dist/{chunk-7ZHCRY2V.cjs → chunk-CD56IAXU.cjs} +5 -5
  11. package/dist/{chunk-7ZHCRY2V.cjs.map → chunk-CD56IAXU.cjs.map} +1 -1
  12. package/dist/{chunk-2RQV7JLA.js → chunk-DEEBJBFQ.js} +2 -2
  13. package/dist/{chunk-NG7P653A.js → chunk-FNK67HTC.js} +2 -2
  14. package/dist/{chunk-4XHPUYL7.cjs → chunk-GAQAPTBU.cjs} +37 -37
  15. package/dist/{chunk-4XHPUYL7.cjs.map → chunk-GAQAPTBU.cjs.map} +1 -1
  16. package/dist/{chunk-LF5LDJOP.js → chunk-HALW7FPF.js} +2 -2
  17. package/dist/{chunk-G7CRSNFS.js → chunk-J65CQLX6.js} +2 -2
  18. package/dist/{chunk-5B37J3B5.cjs → chunk-MN2WQYXS.cjs} +15 -15
  19. package/dist/{chunk-5B37J3B5.cjs.map → chunk-MN2WQYXS.cjs.map} +1 -1
  20. package/dist/{chunk-APEJR6DV.cjs → chunk-NEY347ME.cjs} +26 -26
  21. package/dist/{chunk-APEJR6DV.cjs.map → chunk-NEY347ME.cjs.map} +1 -1
  22. package/dist/{chunk-V2OAUUF6.cjs → chunk-PDOTUMJC.cjs} +31 -31
  23. package/dist/{chunk-V2OAUUF6.cjs.map → chunk-PDOTUMJC.cjs.map} +1 -1
  24. package/dist/{chunk-JIU2I6LU.cjs → chunk-TFCGMQPK.cjs} +25 -25
  25. package/dist/{chunk-JIU2I6LU.cjs.map → chunk-TFCGMQPK.cjs.map} +1 -1
  26. package/dist/{chunk-SVPVDSHA.js → chunk-TPJJJ6KC.js} +4152 -3948
  27. package/dist/chunk-TPJJJ6KC.js.map +1 -0
  28. package/dist/{chunk-7FIWPFLE.js → chunk-TV6VD6LP.js} +4 -4
  29. package/dist/{chunk-2D3RINOJ.js → chunk-V4U7ASRM.js} +2 -2
  30. package/dist/{chunk-QJOSQVIR.js → chunk-VNWWOPIY.js} +2 -2
  31. package/dist/{chunk-J4FCWWO6.cjs → chunk-WCCZGPPP.cjs} +62 -62
  32. package/dist/{chunk-J4FCWWO6.cjs.map → chunk-WCCZGPPP.cjs.map} +1 -1
  33. package/dist/{chunk-ODMCD663.js → chunk-WCRJWZUA.js} +4 -4
  34. package/dist/{chunk-7WMHKVV7.cjs → chunk-Y2TJ64S5.cjs} +12 -12
  35. package/dist/{chunk-7WMHKVV7.cjs.map → chunk-Y2TJ64S5.cjs.map} +1 -1
  36. package/dist/{chunk-RXCP4OV4.cjs → chunk-ZFER75HU.cjs} +9 -9
  37. package/dist/{chunk-RXCP4OV4.cjs.map → chunk-ZFER75HU.cjs.map} +1 -1
  38. package/dist/components/case-studies/index.cjs +8 -8
  39. package/dist/components/case-studies/index.js +2 -2
  40. package/dist/components/chat/chat-context-picker.d.ts +11 -1
  41. package/dist/components/chat/chat-context-picker.d.ts.map +1 -1
  42. package/dist/components/chat/chat-input.d.ts.map +1 -1
  43. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  44. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  45. package/dist/components/chat/context-items-list.d.ts.map +1 -1
  46. package/dist/components/chat/embeddable-chat.d.ts +11 -1
  47. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  48. package/dist/components/chat/index.cjs +2 -2
  49. package/dist/components/chat/index.js +1 -1
  50. package/dist/components/chat/remark-mention-chips.d.ts +11 -5
  51. package/dist/components/chat/remark-mention-chips.d.ts.map +1 -1
  52. package/dist/components/chat/types/component.types.d.ts +30 -7
  53. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  54. package/dist/components/contact/index.cjs +3 -3
  55. package/dist/components/contact/index.js +2 -2
  56. package/dist/components/docs/index.cjs +5 -5
  57. package/dist/components/docs/index.js +4 -4
  58. package/dist/components/embeds/index.cjs +3 -3
  59. package/dist/components/embeds/index.js +2 -2
  60. package/dist/components/faq/index.cjs +3 -3
  61. package/dist/components/faq/index.js +2 -2
  62. package/dist/components/features/index.cjs +8 -2
  63. package/dist/components/features/index.cjs.map +1 -1
  64. package/dist/components/features/index.d.ts +1 -0
  65. package/dist/components/features/index.d.ts.map +1 -1
  66. package/dist/components/features/index.js +7 -1
  67. package/dist/components/features/paths-display.d.ts +16 -1
  68. package/dist/components/features/paths-display.d.ts.map +1 -1
  69. package/dist/components/features/warning-block.d.ts +32 -0
  70. package/dist/components/features/warning-block.d.ts.map +1 -0
  71. package/dist/components/help-center-pages/index.cjs +20 -20
  72. package/dist/components/help-center-pages/index.js +11 -11
  73. package/dist/components/index.cjs +126 -120
  74. package/dist/components/index.cjs.map +1 -1
  75. package/dist/components/index.js +15 -9
  76. package/dist/components/index.js.map +1 -1
  77. package/dist/components/navigation/index.cjs +2 -2
  78. package/dist/components/navigation/index.js +1 -1
  79. package/dist/components/onboarding-guides/index.cjs +5 -5
  80. package/dist/components/onboarding-guides/index.js +4 -4
  81. package/dist/components/related-content/index.cjs +3 -3
  82. package/dist/components/related-content/index.js +2 -2
  83. package/dist/components/tickets/index.cjs +5 -5
  84. package/dist/components/tickets/index.js +4 -4
  85. package/dist/components/ui/index.cjs +2 -2
  86. package/dist/components/ui/index.js +1 -1
  87. package/dist/index.cjs +8 -2
  88. package/dist/index.cjs.map +1 -1
  89. package/dist/index.js +7 -1
  90. package/package.json +1 -1
  91. package/src/components/chat/chat-context-picker.tsx +77 -9
  92. package/src/components/chat/chat-input.tsx +524 -489
  93. package/src/components/chat/chat-message-enhanced.tsx +4 -2
  94. package/src/components/chat/chat-message-list.tsx +2 -0
  95. package/src/components/chat/context-items-list.tsx +9 -1
  96. package/src/components/chat/embeddable-chat.tsx +55 -56
  97. package/src/components/chat/remark-mention-chips.ts +13 -7
  98. package/src/components/chat/types/component.types.ts +31 -7
  99. package/src/components/features/index.ts +1 -0
  100. package/src/components/features/paths-display.tsx +32 -1
  101. package/src/components/features/warning-block.tsx +54 -0
  102. package/src/stories/WarningBlock.stories.tsx +167 -0
  103. package/dist/chunk-SVPVDSHA.js.map +0 -1
  104. package/dist/chunk-ZD5ZDEL5.cjs.map +0 -1
  105. /package/dist/{chunk-XAGD3XZV.js.map → chunk-563D7EB2.js.map} +0 -0
  106. /package/dist/{chunk-ARQ3R7WZ.js.map → chunk-6DH2TCRH.js.map} +0 -0
  107. /package/dist/{chunk-4YFOBTFE.js.map → chunk-BRERE4E5.js.map} +0 -0
  108. /package/dist/{chunk-2RQV7JLA.js.map → chunk-DEEBJBFQ.js.map} +0 -0
  109. /package/dist/{chunk-NG7P653A.js.map → chunk-FNK67HTC.js.map} +0 -0
  110. /package/dist/{chunk-LF5LDJOP.js.map → chunk-HALW7FPF.js.map} +0 -0
  111. /package/dist/{chunk-G7CRSNFS.js.map → chunk-J65CQLX6.js.map} +0 -0
  112. /package/dist/{chunk-7FIWPFLE.js.map → chunk-TV6VD6LP.js.map} +0 -0
  113. /package/dist/{chunk-2D3RINOJ.js.map → chunk-V4U7ASRM.js.map} +0 -0
  114. /package/dist/{chunk-QJOSQVIR.js.map → chunk-VNWWOPIY.js.map} +0 -0
  115. /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 ChangeEvent } from "react"
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 { Textarea } from "../ui/textarea"
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:SLASH_COMMAND_ID_REGEX`
12
- * AND the chat-route slash dispatch parser. Keep all three in sync — server
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 COMMITTED `@`-mention token is `@<type>:<id>`. The id can be a base64 /
18
- * base64url Relay global id, so the id charset must allow `+ / =` on top of
19
- * word chars, `.` and `-` otherwise the match stops at the first base64 pad
20
- * char and the atomic-delete / prior-token strip leave orphaned token text
21
- * (which then desyncs the host's chip reconciliation). One shared fragment for
22
- * the at-end match (atomic Backspace) and the global strip (commit) keeps them
23
- * in lockstep. NOTE: the IN-PROGRESS trigger `@query` (`[\w.-]*`, below) is a
24
- * SEPARATE shape the user types a search string there, never a raw id. */
25
- const MENTION_TOKEN = '@[A-Za-z0-9_.+/=-]+:[A-Za-z0-9_.+/=-]+'
26
- const MENTION_TOKEN_AT_END = new RegExp(`${MENTION_TOKEN}$`)
27
- const MENTION_TOKEN_STRIP = new RegExp(`(^|\\s)${MENTION_TOKEN}`, 'g')
28
-
29
- const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
30
- (allProps, ref) => {
31
- // Pull `slashCommands` out FIRST, BEFORE any spread, so it can never leak
32
- // into `<Textarea {...inputProps}>` (and from there onto `<textarea>` as
33
- // an unrecognized DOM attribute). The structural separation here is the
34
- // belt to the destructure's suspenders.
35
- const { slashCommands, ...rest } = allProps
36
- const {
37
- className,
38
- onSend,
39
- onStop,
40
- sending = false,
41
- awaitingResponse = false,
42
- placeholder = "Enter your Request...",
43
- // Accepted for back-compat; consumed and discarded so the prop never
44
- // falls through to the underlying <textarea> as an unknown DOM attr.
45
- reserveAvatarOffset: _reserveAvatarOffset,
46
- disabled = false,
47
- autoFocus = false,
48
- fullWidth = false,
49
- // Composer extension pulled out so it never falls through to the
50
- // underlying <textarea> as an unknown DOM attribute. `allowEmptySend`
51
- // lets surfaces that attach files (e.g. the ticket reply composer) send
52
- // with empty text once an attachment is ready.
53
- allowEmptySend = false,
54
- // Pulled out (never spread onto <textarea>): the `@`-mention trigger
55
- // callback for the context picker.
56
- onMentionQueryChange,
57
- // Pulled out (never spread onto <textarea>): fires on EVERY value change
58
- // (typing + imperative setValue/commitMention). The composer uses it to
59
- // keep `@type:id` mention tokens in sync with the context chips — delete
60
- // the token text and its context item drops.
61
- onValueChange,
62
- // Start adornment (the composer `+`), forwarded to Textarea.startIcon.
63
- startIcon,
64
- // Suppress the textarea's own chrome when an outer card owns it.
65
- hideBorder,
66
- ...inputProps
67
- } = rest
68
-
69
- const [value, setValue] = useState('')
70
- const textareaRef = useRef<HTMLTextAreaElement>(null)
71
- const shouldRefocusRef = useRef(false)
72
- const prevSendingRef = useRef(sending)
73
- const prevAwaitingResponseRef = useRef(awaitingResponse)
74
-
75
- const focusTextarea = useCallback(() => {
76
- if (disabled) return
77
- const el = textareaRef.current
78
- if (!el || el.disabled) return
79
- // ALWAYS `preventScroll`. Focusing a chat composer must never yank the
80
- // viewport: in the global Ask-AI chat the input is a sticky, always-
81
- // visible composer (preventScroll is a no-op there), and in the ticket
82
- // reply composer the input sits BELOW a tall fixed-height feed — a
83
- // scroll-on-focus there fights the card's smooth scroll-to-top when the
84
- // drawer opens. This bit a specific flow: deep-link a ticket → close →
85
- // reopen, then the post-state-transition refocus effect below fired a
86
- // bare `focus()` AFTER the card's `window.scrollTo({behavior:'smooth'})`,
87
- // scrolling the textarea into view and cancelling the smooth animation.
88
- // `preventScroll` on every focus path removes the whole class of bug.
89
- el.focus({ preventScroll: true })
90
- }, [disabled])
91
-
92
- useEffect(() => {
93
- if (autoFocus) {
94
- focusTextarea()
95
- }
96
- }, [autoFocus, focusTextarea])
97
-
98
- // Surface every value change (typed or imperative) so the composer can
99
- // reconcile `@type:id` mention tokens with the context chips. Pure
100
- // notification never feeds back into this component's state.
101
- useEffect(() => {
102
- onValueChange?.(value)
103
- }, [value, onValueChange])
104
-
105
- // Mirror `value` into a ref so `getValue()` can read the latest value
106
- // without forcing a new imperative handle on every keystroke. Without
107
- // this, `useImperativeHandle` deps would have to include `value`,
108
- // rebuilding the handle object N times per turn.
109
- const valueRef = useRef(value)
110
- valueRef.current = value
111
-
112
- // Shared send path for the Send button, Enter key, and imperative submit().
113
- // Replaces the old eager `setValue('')`: the draft clears ONLY when the send
114
- // is not rejected (`onSend` returns / resolves !== false), so a failed ticket
115
- // reply keeps the user's text. `allowEmptySend` lets attachment surfaces send
116
- // with empty text. Memoized on its gate inputs ONLY (not `value`) so it stays
117
- // stable across keystrokes the imperative handle below depends on it without
118
- // rebuilding per keystroke (preserves the `valueRef` optimization above).
119
- const fire = useCallback(
120
- (message: string) => {
121
- const can = (message.length > 0 || allowEmptySend) && !sending && !disabled
122
- if (!can || !onSend) return
123
- const result = onSend(message)
124
- const done = () => {
125
- setValue('')
126
- shouldRefocusRef.current = true
127
- focusTextarea()
128
- }
129
- if (result instanceof Promise) {
130
- void result.then((ok) => {
131
- if (ok !== false) done()
132
- })
133
- } else if (result !== false) {
134
- done()
135
- }
136
- },
137
- [allowEmptySend, sending, disabled, onSend, focusTextarea],
138
- )
139
-
140
- // Expose the `ChatInputRef` shape so parents can imperatively pre-fill
141
- // the input (used by the empty-state quick-action chips that translate
142
- // to `/<id> ` slash invocations) and focus the textarea programmatically.
143
- // setValue() mirrors the user-typed path: updates state AND triggers the
144
- // textarea's auto-resize on the next frame so multi-line pre-fills don't
145
- // clip the rendered content.
146
- useImperativeHandle(
147
- ref,
148
- (): ChatInputRef => ({
149
- focus: () => focusTextarea(),
150
- blur: () => textareaRef.current?.blur(),
151
- clear: () => setValue(''),
152
- setValue: (next: string) => {
153
- setValue(next)
154
- // After focus, set selection to the END of the new value —
155
- // programmatic `.focus()` on a textarea defaults to caret-at-0
156
- // (browser-standard), which would land the cursor at the start of
157
- // the prefilled `/cmd ` and force users to arrow-right past every
158
- // character before typing. Auto-grow is handled by the Textarea's
159
- // native `field-sizing: content` — no manual height mgmt needed.
160
- requestAnimationFrame(() => {
161
- const el = textareaRef.current
162
- if (!el || disabled || el.disabled) return
163
- el.focus({ preventScroll: true })
164
- el.setSelectionRange(next.length, next.length)
165
- })
166
- },
167
- setValueAndCursor: (next: string, cursorOffset: number) => {
168
- setValue(next)
169
- // The caret position must be set on the underlying textarea AFTER
170
- // React commits the new value (otherwise React resets selection
171
- // to end-of-input on the next render). We clamp the offset to
172
- // [0, value.length] so a misconfigured caller can't crash.
173
- const clamped = Math.max(0, Math.min(cursorOffset, next.length))
174
- requestAnimationFrame(() => {
175
- const el = textareaRef.current
176
- if (!el) return
177
- el.focus({ preventScroll: true })
178
- el.setSelectionRange(clamped, clamped)
179
- })
180
- },
181
- submit: (next: string) => {
182
- // Mirror the user-typed Send-button path via the shared `fire` helper
183
- // (success-gated clear). Reads `next` (its argument), NOT the textarea
184
- // `value` the "Recent" quick-action injects a `/cmd` string while
185
- // `value` is empty, so reading `value` here would submit nothing.
186
- fire(next.trim())
187
- },
188
- getValue: () => valueRef.current,
189
- removeMentionTrigger: () => {
190
- // Drop the trailing `@token`, keeping any leading whitespace so the
191
- // surrounding text stays intact. No-op when nothing matches.
192
- setValue((v) => v.replace(/(^|\s)@([\w.-]*)$/, '$1'))
193
- },
194
- commitMention: (token: string) => {
195
- // Single-select: replace the trailing `@query` the user is typing
196
- // with the committed `@<type>:<id>` token (+ trailing space), AND
197
- // strip any previously committed token so only ONE mention reference
198
- // ever lives in the text. The trailing space dismisses the mention
199
- // trigger (the `@…$` detector needs no trailing whitespace), so the
200
- // picker closes on its own. The colon also keeps the committed token
201
- // out of `removeMentionTrigger`'s `@[\w.-]*$` match.
202
- setValue((v) => {
203
- const stripped = v.replace(MENTION_TOKEN_STRIP, '$1')
204
- const next = /(^|\s)@([\w.-]*)$/.test(stripped)
205
- ? stripped.replace(/(^|\s)@([\w.-]*)$/, `$1@${token} `)
206
- : `${stripped}${stripped.length > 0 && !stripped.endsWith(' ') ? ' ' : ''}@${token} `
207
- // Stripping a mid-text token leaves its surrounding spaces behind
208
- // (a double space, or a leading space when it was first). Collapse
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
- let cancelled = false
272
- const ctrl = new AbortController()
273
- const handle = setTimeout(async () => {
274
- try {
275
- // `fetchCommands` resolves the chat source server-side from
276
- // the calling deployment's `NEXT_PUBLIC_APP_TYPE` (via
277
- // `getCurrentChatSource()` in the host's route base). No
278
- // `source` argument crosses the wire preventing cross-
279
- // platform command enumeration from the autocomplete path.
280
- const next = await slashCommands.fetchCommands(slashPrefix, ctrl.signal)
281
- if (!cancelled) {
282
- setSlashSuggestions(next)
283
- setHighlightedIdx(0)
284
- }
285
- } catch (err) {
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
- }, [slashPrefix, slashCommands])
298
-
299
- const acceptSuggestion = useCallback(
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
- const handleKeyDown = useCallback(
322
- (e: KeyboardEvent<HTMLTextAreaElement>) => {
323
- // Atomic mention delete: one Backspace removes a whole committed
324
- // `@<type>:<id>` token when the caret sits RIGHT AFTER the token's last
325
- // char — so a mention reads/deletes like a single chip, not 12 chars.
326
- // The trailing space after a committed token is NOT part of the match,
327
- // so a Backspace there deletes just the space (normal editing); the
328
- // atomic wipe only fires on the mention's own characters. Only with no
329
- // active selection and the context feature on; a committed token can't
330
- // appear inside a `/slash` or `@query` prefix, so sitting before the
331
- // slash/mention branches is safe (no match fall through). The removal
332
- // flows through `setValue`, so `onValueChange` fires and the host drops
333
- // the matching context chip.
334
- if (mentionEnabled && e.key === 'Backspace') {
335
- const el = textareaRef.current
336
- if (el && el.selectionStart === el.selectionEnd && el.selectionStart > 0) {
337
- const caret = el.selectionStart
338
- const before = value.slice(0, caret)
339
- const m = before.match(MENTION_TOKEN_AT_END)
340
- if (m && m.index !== undefined) {
341
- e.preventDefault()
342
- const pos = m.index
343
- setValue(value.slice(0, pos) + value.slice(caret))
344
- requestAnimationFrame(() => {
345
- const t = textareaRef.current
346
- if (!t) return
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
- // Slash-command navigation takes precedence when the dropdown is
355
- // visible. Esc closes; ArrowUp/Down cycle; Tab/Enter accept.
356
- if (slashSuggestions.length > 0 && slashPrefix !== null) {
357
- if (e.key === 'Escape') {
358
- e.preventDefault()
359
- setSlashSuggestions([])
360
- return
361
- }
362
- if (e.key === 'ArrowDown') {
363
- e.preventDefault()
364
- setHighlightedIdx((i) => (i + 1) % slashSuggestions.length)
365
- return
366
- }
367
- if (e.key === 'ArrowUp') {
368
- e.preventDefault()
369
- setHighlightedIdx(
370
- (i) => (i - 1 + slashSuggestions.length) % slashSuggestions.length,
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
- // While the `@`-mention trigger is active the context picker owns the
383
- // intent: Escape dismisses the trigger (which closes the picker via
384
- // the value-change effect); Enter must NOT send (the user is mid-pick,
385
- // and items are chosen by click). Both are swallowed here.
386
- if (mentionQuery !== null) {
387
- if (e.key === 'Escape') {
388
- e.preventDefault()
389
- setValue((v) => v.replace(/(^|\s)@([\w.-]*)$/, '$1'))
390
- return
391
- }
392
- if (e.key === 'Enter' && !e.shiftKey) {
393
- e.preventDefault()
394
- return
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
- if (e.key === 'Enter' && !e.shiftKey) {
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
- handleSubmit()
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
- }, [sending, awaitingResponse, disabled, focusTextarea])
435
-
436
- useEffect(() => {
437
- const wasAwaiting = prevAwaitingResponseRef.current
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
- const handleStop = useCallback(async () => {
447
- if (!onStop || isStopping) return
448
- setIsStopping(true)
449
- try {
450
- await onStop()
451
- } catch {
452
- setIsStopping(false)
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
- }, [onStop, isStopping])
455
-
456
- const isStopMode = sending && !!onStop
457
- // Send gate: `allowEmptySend` lets empty text send (attachments-only).
458
- // Default (false) collapses to the original `!value.trim()` gate.
459
- const hasContent = value.trim().length > 0 || allowEmptySend
460
- const sendDisabled = sending || disabled || !hasContent
461
-
462
- return (
463
- <div
464
- className={cn(
465
- // `fullWidth=true` drops the centered-narrow content column
466
- // for chats hosted in side panels; default preserves the
467
- // legacy 600px max-width for existing consumers.
468
- fullWidth
469
- ? "w-full flex-shrink-0"
470
- : "mx-auto w-full max-w-ods-content-narrow flex-shrink-0",
471
- className,
472
- )}
473
- >
474
- {awaitingResponse ? (
475
- <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">
476
- <ChatTypingIndicator size="sm" dotClassName="bg-ods-text-primary" />
477
- <p className="text-h4 text-ods-text-secondary">Waiting for Technician Response</p>
478
- </div>
479
- ) : (
480
- <div className="relative">
481
- <SlashCommandSuggestions
482
- commands={slashPrefix !== null ? slashSuggestions : []}
483
- highlightedIdx={highlightedIdx}
484
- onHover={setHighlightedIdx}
485
- onSelect={acceptSuggestion}
486
- resolveSourceIcon={slashCommands?.resolveSourceIcon}
487
- onAction={slashCommands?.onAction}
488
- />
489
- <Textarea
490
- ref={textareaRef}
491
- value={value}
492
- onChange={handleChange}
493
- onKeyDown={handleKeyDown}
494
- placeholder={disabled ? "Connection lost. Waiting to reconnect..." : placeholder}
495
- disabled={sending || disabled}
496
- rows={1}
497
- startIcon={startIcon}
498
- hideBorder={hideBorder}
499
- endIcon={isStopMode ? <StopCircleIcon size={20} /> : <Send01Icon size={20} />}
500
- endIconAsButton
501
- endIconButtonProps={{
502
- onClick: isStopMode ? handleStop : handleSubmit,
503
- disabled: isStopMode ? isStopping : sendDisabled,
504
- 'aria-label': isStopMode ? 'Stop generation' : 'Send message',
505
- }}
506
- {...inputProps}
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
- </div>
511
- )
512
- },
513
- )
544
+ </div>
545
+ )}
546
+ </div>
547
+ )
548
+ })
514
549
 
515
550
  ChatInput.displayName = "ChatInput"
516
551