@workerdeck/ui 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/build/index.d.mts +927 -0
- package/build/index.mjs +5236 -0
- package/build/index.mjs.map +1 -0
- package/package.json +73 -0
- package/src/components/agent/Composer.tsx +139 -0
- package/src/components/agent/Conversation.tsx +59 -0
- package/src/components/agent/FileCard.tsx +50 -0
- package/src/components/agent/Loader.tsx +21 -0
- package/src/components/agent/Message.tsx +44 -0
- package/src/components/agent/ModelSelect.tsx +87 -0
- package/src/components/agent/PermissionModeSelect.tsx +94 -0
- package/src/components/agent/PermissionPrompt.tsx +52 -0
- package/src/components/agent/QuestionPrompt.tsx +193 -0
- package/src/components/agent/Reasoning.tsx +58 -0
- package/src/components/agent/Response.tsx +31 -0
- package/src/components/agent/SessionList.tsx +93 -0
- package/src/components/agent/SessionPanel.tsx +149 -0
- package/src/components/agent/StatusBar.tsx +140 -0
- package/src/components/agent/ToolCallCard.tsx +94 -0
- package/src/components/agent/Transcript.tsx +114 -0
- package/src/components/agent/status.ts +16 -0
- package/src/components/prompt-area/animated-placeholder.tsx +42 -0
- package/src/components/prompt-area/clipboard-helpers.ts +206 -0
- package/src/components/prompt-area/cursor-helpers.ts +244 -0
- package/src/components/prompt-area/dom-helpers.ts +721 -0
- package/src/components/prompt-area/file-strip.tsx +250 -0
- package/src/components/prompt-area/html-to-markdown.ts +278 -0
- package/src/components/prompt-area/image-strip.tsx +49 -0
- package/src/components/prompt-area/index.ts +23 -0
- package/src/components/prompt-area/prompt-area-engine.ts +705 -0
- package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
- package/src/components/prompt-area/prompt-area.tsx +375 -0
- package/src/components/prompt-area/remove-button.tsx +37 -0
- package/src/components/prompt-area/segment-helpers.ts +62 -0
- package/src/components/prompt-area/trigger-popover.tsx +139 -0
- package/src/components/prompt-area/trigger-presets.ts +143 -0
- package/src/components/prompt-area/types.ts +360 -0
- package/src/components/prompt-area/use-markdown-mode.ts +113 -0
- package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
- package/src/components/prompt-area/use-prompt-area.ts +1507 -0
- package/src/components/prompt-area/use-trigger-search.ts +115 -0
- package/src/components/ui/AlertDialog.tsx +56 -0
- package/src/components/ui/Badge.tsx +42 -0
- package/src/components/ui/Button.tsx +47 -0
- package/src/components/ui/Card.tsx +29 -0
- package/src/components/ui/CodeBlock.tsx +31 -0
- package/src/components/ui/CopyButton.tsx +28 -0
- package/src/components/ui/Input.tsx +20 -0
- package/src/components/ui/ProgressRing.tsx +49 -0
- package/src/components/ui/Select.tsx +80 -0
- package/src/components/ui/Sonner.tsx +22 -0
- package/src/components/ui/Spinner.tsx +6 -0
- package/src/components/ui/Textarea.tsx +21 -0
- package/src/components/ui/Tooltip.tsx +34 -0
- package/src/index.ts +99 -0
- package/src/lib/format.ts +67 -0
- package/src/lib/utils.ts +33 -0
- package/src/styles/theme.css +413 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
4
|
+
import { cn } from '../../lib/utils.ts'
|
|
5
|
+
import type { PromptAreaProps, PromptAreaHandle } from './types.ts'
|
|
6
|
+
import { usePromptArea } from './use-prompt-area.ts'
|
|
7
|
+
import { BLUR_DELAY_MS } from './use-prompt-area-events.ts'
|
|
8
|
+
import { TriggerPopover } from './trigger-popover.tsx'
|
|
9
|
+
import { AnimatedPlaceholder } from './animated-placeholder.tsx'
|
|
10
|
+
import { ImageStrip } from './image-strip.tsx'
|
|
11
|
+
import { FileStrip } from './file-strip.tsx'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* PromptArea - A lightweight rich text input with trigger support.
|
|
15
|
+
*
|
|
16
|
+
* Uses contentEditable to support inline chips (immutable pills) for
|
|
17
|
+
* mentions, commands, and other triggered tokens. Each trigger character
|
|
18
|
+
* can be configured to show a dropdown or fire a callback.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* const [segments, setSegments] = useState<Segment[]>([])
|
|
23
|
+
*
|
|
24
|
+
* <PromptArea
|
|
25
|
+
* value={segments}
|
|
26
|
+
* onChange={setSegments}
|
|
27
|
+
* triggers={[
|
|
28
|
+
* { char: '@', position: 'any', mode: 'dropdown', onSearch: searchUsers },
|
|
29
|
+
* { char: '/', position: 'start', mode: 'dropdown', onSearch: searchCommands },
|
|
30
|
+
* { char: '#', position: 'any', mode: 'dropdown', onSearch: searchTags },
|
|
31
|
+
* ]}
|
|
32
|
+
* placeholder="Type a message..."
|
|
33
|
+
* onSubmit={handleSubmit}
|
|
34
|
+
* autoGrow
|
|
35
|
+
* />
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function PromptArea({
|
|
39
|
+
value,
|
|
40
|
+
onChange,
|
|
41
|
+
triggers,
|
|
42
|
+
placeholder,
|
|
43
|
+
className,
|
|
44
|
+
disabled = false,
|
|
45
|
+
markdown,
|
|
46
|
+
normalizeBullets,
|
|
47
|
+
onSubmit,
|
|
48
|
+
onEscape,
|
|
49
|
+
onChipClick,
|
|
50
|
+
onChipAdd,
|
|
51
|
+
onChipDelete,
|
|
52
|
+
onLinkClick,
|
|
53
|
+
onPaste,
|
|
54
|
+
onUndo,
|
|
55
|
+
onRedo,
|
|
56
|
+
minHeight = 80,
|
|
57
|
+
maxHeight,
|
|
58
|
+
autoFocus = false,
|
|
59
|
+
autoGrow = false,
|
|
60
|
+
'aria-label': ariaLabel,
|
|
61
|
+
'data-test-id': dataTestId,
|
|
62
|
+
images = [],
|
|
63
|
+
imagePosition = 'above',
|
|
64
|
+
onImagePaste,
|
|
65
|
+
onImageRemove,
|
|
66
|
+
onImageClick,
|
|
67
|
+
files = [],
|
|
68
|
+
filePosition = 'above',
|
|
69
|
+
onFileRemove,
|
|
70
|
+
onFileClick,
|
|
71
|
+
onKeyDown,
|
|
72
|
+
onBlur,
|
|
73
|
+
onRawPaste,
|
|
74
|
+
submitOnEnter,
|
|
75
|
+
spellCheck,
|
|
76
|
+
maxLength,
|
|
77
|
+
'aria-describedby': ariaDescribedBy,
|
|
78
|
+
ref,
|
|
79
|
+
}: PromptAreaProps & { ref?: React.Ref<PromptAreaHandle> }) {
|
|
80
|
+
const {
|
|
81
|
+
editorRef,
|
|
82
|
+
activeTrigger,
|
|
83
|
+
suggestions,
|
|
84
|
+
suggestionsLoading,
|
|
85
|
+
suggestionsError,
|
|
86
|
+
selectedSuggestionIndex,
|
|
87
|
+
handleInput,
|
|
88
|
+
handleKeyDown,
|
|
89
|
+
handleClick,
|
|
90
|
+
handleMouseDown,
|
|
91
|
+
selectSuggestion,
|
|
92
|
+
dismissTrigger,
|
|
93
|
+
handle,
|
|
94
|
+
triggerRect,
|
|
95
|
+
eventHandlers,
|
|
96
|
+
} = usePromptArea({
|
|
97
|
+
value,
|
|
98
|
+
onChange,
|
|
99
|
+
triggers,
|
|
100
|
+
disabled,
|
|
101
|
+
onSubmit,
|
|
102
|
+
onEscape,
|
|
103
|
+
onChipClick,
|
|
104
|
+
onChipAdd,
|
|
105
|
+
onChipDelete,
|
|
106
|
+
onLinkClick,
|
|
107
|
+
onPaste,
|
|
108
|
+
onRawPaste,
|
|
109
|
+
onUndo,
|
|
110
|
+
onRedo,
|
|
111
|
+
onImagePaste,
|
|
112
|
+
markdown,
|
|
113
|
+
normalizeBullets,
|
|
114
|
+
submitOnEnter,
|
|
115
|
+
maxLength,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// Expose imperative handle via ref
|
|
119
|
+
useImperativeHandle(ref, () => handle, [handle])
|
|
120
|
+
|
|
121
|
+
// Auto-focus on mount
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (autoFocus) {
|
|
124
|
+
editorRef.current?.focus()
|
|
125
|
+
}
|
|
126
|
+
}, [autoFocus, editorRef])
|
|
127
|
+
|
|
128
|
+
// -----------------------------------------------------------------------
|
|
129
|
+
// Auto-grow: expand on focus/input, shrink on blur
|
|
130
|
+
// -----------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
const [isFocused, setIsFocused] = useState(false)
|
|
133
|
+
const [editorHeight, setEditorHeight] = useState<number | undefined>(undefined)
|
|
134
|
+
|
|
135
|
+
const syncHeight = useCallback(() => {
|
|
136
|
+
const el = editorRef.current
|
|
137
|
+
if (!el) return
|
|
138
|
+
// Temporarily set height to auto so scrollHeight reflects true content height
|
|
139
|
+
el.style.height = 'auto'
|
|
140
|
+
const contentHeight = el.scrollHeight
|
|
141
|
+
el.style.height = `${contentHeight}px`
|
|
142
|
+
setEditorHeight(contentHeight)
|
|
143
|
+
}, [editorRef])
|
|
144
|
+
|
|
145
|
+
const handleFocus = useCallback(() => {
|
|
146
|
+
if (!autoGrow) return
|
|
147
|
+
setIsFocused(true)
|
|
148
|
+
syncHeight()
|
|
149
|
+
}, [autoGrow, syncHeight])
|
|
150
|
+
|
|
151
|
+
const handleBlurWithShrink = useCallback(() => {
|
|
152
|
+
eventHandlers.onBlur()
|
|
153
|
+
if (!autoGrow) return
|
|
154
|
+
setTimeout(() => {
|
|
155
|
+
const editor = editorRef.current
|
|
156
|
+
if (!editor) return
|
|
157
|
+
// Only shrink if focus truly left the component
|
|
158
|
+
const activeEl = document.activeElement
|
|
159
|
+
if (activeEl && editor.parentElement?.contains(activeEl)) return
|
|
160
|
+
setIsFocused(false)
|
|
161
|
+
setEditorHeight(undefined)
|
|
162
|
+
}, BLUR_DELAY_MS)
|
|
163
|
+
}, [eventHandlers, autoGrow, editorRef])
|
|
164
|
+
|
|
165
|
+
const handleInputWithGrow = useCallback(() => {
|
|
166
|
+
handleInput()
|
|
167
|
+
if (autoGrow && isFocused) {
|
|
168
|
+
syncHeight()
|
|
169
|
+
}
|
|
170
|
+
}, [handleInput, autoGrow, isFocused, syncHeight])
|
|
171
|
+
|
|
172
|
+
// Re-measure on value changes (chip insertion, undo/redo, programmatic updates)
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (autoGrow && isFocused) {
|
|
175
|
+
requestAnimationFrame(() => syncHeight())
|
|
176
|
+
}
|
|
177
|
+
}, [value, autoGrow, isFocused, syncHeight])
|
|
178
|
+
|
|
179
|
+
// -----------------------------------------------------------------------
|
|
180
|
+
// Overflow indicator: detect when collapsed content is clipped
|
|
181
|
+
// -----------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
const [hasOverflow, setHasOverflow] = useState(false)
|
|
184
|
+
const overflowTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!autoGrow) return
|
|
188
|
+
|
|
189
|
+
const checkOverflow = () => {
|
|
190
|
+
if (isFocused) {
|
|
191
|
+
setHasOverflow(false)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
const el = editorRef.current
|
|
195
|
+
if (!el) return
|
|
196
|
+
setHasOverflow(el.scrollHeight > el.clientHeight)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Delay the check so the CSS height transition (150ms) finishes first;
|
|
200
|
+
// on initial mount there is no transition so the check still runs quickly.
|
|
201
|
+
const delay = isFocused ? 0 : 160
|
|
202
|
+
overflowTimerRef.current = setTimeout(checkOverflow, delay)
|
|
203
|
+
return () => {
|
|
204
|
+
if (overflowTimerRef.current !== null) {
|
|
205
|
+
clearTimeout(overflowTimerRef.current)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}, [autoGrow, isFocused, value, editorRef])
|
|
209
|
+
|
|
210
|
+
// -----------------------------------------------------------------------
|
|
211
|
+
// Compute editor style
|
|
212
|
+
// -----------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
const editorStyle = useMemo((): React.CSSProperties => {
|
|
215
|
+
if (!autoGrow) {
|
|
216
|
+
const style: React.CSSProperties = { minHeight: `${minHeight}px` }
|
|
217
|
+
if (maxHeight) {
|
|
218
|
+
style.maxHeight = `${maxHeight}px`
|
|
219
|
+
style.overflowY = 'auto'
|
|
220
|
+
}
|
|
221
|
+
return style
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
height: isFocused && editorHeight ? `${editorHeight}px` : `${minHeight}px`,
|
|
225
|
+
minHeight: `${minHeight}px`,
|
|
226
|
+
// Respect an explicit maxHeight; otherwise fall back to a viewport-relative
|
|
227
|
+
// cap so the editor never grows past the screen.
|
|
228
|
+
maxHeight: maxHeight ? `${maxHeight}px` : '70dvh',
|
|
229
|
+
overflowY: isFocused ? 'auto' : 'hidden',
|
|
230
|
+
// `min-height` is eased so consumers that animate it (e.g. the compact
|
|
231
|
+
// prompt area's collapse/expand) morph smoothly instead of snapping.
|
|
232
|
+
transition: 'height 150ms ease-out, min-height 240ms cubic-bezier(0.33, 1, 0.68, 1)',
|
|
233
|
+
}
|
|
234
|
+
}, [autoGrow, minHeight, maxHeight, isFocused, editorHeight])
|
|
235
|
+
|
|
236
|
+
// Run a consumer's onKeyDown first; if it calls preventDefault, skip all of
|
|
237
|
+
// PromptArea's built-in key handling (submit, trigger nav, etc.).
|
|
238
|
+
const handleKeyDownCombined = useCallback(
|
|
239
|
+
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
240
|
+
onKeyDown?.(e)
|
|
241
|
+
if (e.defaultPrevented) return
|
|
242
|
+
handleKeyDown(e)
|
|
243
|
+
},
|
|
244
|
+
[onKeyDown, handleKeyDown],
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
// Forward blur to the consumer (with relatedTarget) alongside the internal
|
|
248
|
+
// trigger-dismiss / auto-grow-shrink handling.
|
|
249
|
+
const handleBlurCombined = useCallback(
|
|
250
|
+
(e: React.FocusEvent<HTMLDivElement>) => {
|
|
251
|
+
onBlur?.(e)
|
|
252
|
+
// handleBlurWithShrink already calls eventHandlers.onBlur() and only
|
|
253
|
+
// shrinks when autoGrow is on, so it covers both modes.
|
|
254
|
+
handleBlurWithShrink()
|
|
255
|
+
},
|
|
256
|
+
[onBlur, handleBlurWithShrink],
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
const isEmpty =
|
|
260
|
+
value.length === 0 || (value.length === 1 && value[0].type === 'text' && value[0].text === '')
|
|
261
|
+
|
|
262
|
+
const imageStrip =
|
|
263
|
+
images.length > 0 ? (
|
|
264
|
+
<ImageStrip
|
|
265
|
+
images={images}
|
|
266
|
+
onRemove={onImageRemove}
|
|
267
|
+
onClick={onImageClick}
|
|
268
|
+
className={imagePosition === 'above' ? 'pb-2' : 'pt-2'}
|
|
269
|
+
/>
|
|
270
|
+
) : null
|
|
271
|
+
|
|
272
|
+
const fileStrip =
|
|
273
|
+
files.length > 0 ? (
|
|
274
|
+
<FileStrip
|
|
275
|
+
files={files}
|
|
276
|
+
onRemove={onFileRemove}
|
|
277
|
+
onClick={onFileClick}
|
|
278
|
+
className={filePosition === 'above' ? 'pb-2' : 'pt-2'}
|
|
279
|
+
/>
|
|
280
|
+
) : null
|
|
281
|
+
|
|
282
|
+
// Typography (font-size/line-height) lives on the container, not the editor, so
|
|
283
|
+
// it cascades to the editor AND the placeholder overlays — and a consumer can
|
|
284
|
+
// override all three at once via `className` (e.g. `text-base leading-6`).
|
|
285
|
+
return (
|
|
286
|
+
<div className={cn('prompt-area-container relative text-sm leading-relaxed', className)}>
|
|
287
|
+
{imagePosition === 'above' && imageStrip}
|
|
288
|
+
{filePosition === 'above' && fileStrip}
|
|
289
|
+
|
|
290
|
+
{/* Editor + placeholder wrapper */}
|
|
291
|
+
<div className="relative">
|
|
292
|
+
<div
|
|
293
|
+
ref={editorRef}
|
|
294
|
+
contentEditable={!disabled}
|
|
295
|
+
suppressContentEditableWarning
|
|
296
|
+
role="textbox"
|
|
297
|
+
aria-label={ariaLabel ?? 'Text input'}
|
|
298
|
+
aria-multiline="true"
|
|
299
|
+
aria-disabled={disabled || undefined}
|
|
300
|
+
aria-describedby={ariaDescribedBy}
|
|
301
|
+
data-test-id={dataTestId}
|
|
302
|
+
spellCheck={spellCheck}
|
|
303
|
+
className={cn(
|
|
304
|
+
'prompt-area-editor',
|
|
305
|
+
'w-full min-w-0 break-words whitespace-pre-wrap outline-none',
|
|
306
|
+
disabled && 'cursor-not-allowed opacity-50',
|
|
307
|
+
)}
|
|
308
|
+
style={editorStyle}
|
|
309
|
+
onFocus={handleFocus}
|
|
310
|
+
onInput={autoGrow ? handleInputWithGrow : handleInput}
|
|
311
|
+
onKeyDown={handleKeyDownCombined}
|
|
312
|
+
onMouseDown={handleMouseDown}
|
|
313
|
+
onClick={handleClick}
|
|
314
|
+
onPaste={eventHandlers.onPaste}
|
|
315
|
+
onCopy={eventHandlers.onCopy}
|
|
316
|
+
onCut={eventHandlers.onCut}
|
|
317
|
+
onDrop={eventHandlers.onDrop}
|
|
318
|
+
onDragOver={eventHandlers.onDragOver}
|
|
319
|
+
onCompositionStart={eventHandlers.onCompositionStart}
|
|
320
|
+
onCompositionEnd={eventHandlers.onCompositionEnd}
|
|
321
|
+
onBlur={handleBlurCombined}
|
|
322
|
+
/>
|
|
323
|
+
|
|
324
|
+
{/* Overflow gradient indicator – visible when auto-grow is collapsed and content is clipped */}
|
|
325
|
+
{autoGrow && hasOverflow && !isFocused && (
|
|
326
|
+
<div
|
|
327
|
+
aria-hidden="true"
|
|
328
|
+
className="pointer-events-auto absolute right-0 bottom-0 left-0 cursor-pointer"
|
|
329
|
+
style={{ height: '32px' }}
|
|
330
|
+
onClick={() => editorRef.current?.focus()}>
|
|
331
|
+
<div
|
|
332
|
+
className="h-full w-full"
|
|
333
|
+
style={{
|
|
334
|
+
background:
|
|
335
|
+
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--prompt-area-surface, var(--background)) 80%, transparent), var(--prompt-area-surface, var(--background)))',
|
|
336
|
+
}}
|
|
337
|
+
/>
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
340
|
+
|
|
341
|
+
{/* Placeholder overlay */}
|
|
342
|
+
{isEmpty &&
|
|
343
|
+
placeholder &&
|
|
344
|
+
(Array.isArray(placeholder) ? (
|
|
345
|
+
<AnimatedPlaceholder texts={placeholder} />
|
|
346
|
+
) : (
|
|
347
|
+
<div
|
|
348
|
+
className="pointer-events-none absolute top-0 left-0 select-none"
|
|
349
|
+
style={{ color: 'var(--prompt-area-placeholder, var(--muted-foreground))' }}
|
|
350
|
+
aria-hidden="true">
|
|
351
|
+
{placeholder}
|
|
352
|
+
</div>
|
|
353
|
+
))}
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
{filePosition === 'below' && fileStrip}
|
|
357
|
+
{imagePosition === 'below' && imageStrip}
|
|
358
|
+
|
|
359
|
+
{/* Trigger suggestion popover */}
|
|
360
|
+
{activeTrigger && activeTrigger.config.mode === 'dropdown' && (
|
|
361
|
+
<TriggerPopover
|
|
362
|
+
suggestions={suggestions}
|
|
363
|
+
loading={suggestionsLoading}
|
|
364
|
+
error={suggestionsError}
|
|
365
|
+
emptyMessage={activeTrigger.config.emptyMessage}
|
|
366
|
+
selectedIndex={selectedSuggestionIndex}
|
|
367
|
+
onSelect={selectSuggestion}
|
|
368
|
+
onDismiss={dismissTrigger}
|
|
369
|
+
triggerRect={triggerRect}
|
|
370
|
+
triggerChar={activeTrigger.config.char}
|
|
371
|
+
/>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
)
|
|
375
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { cn } from '../../lib/utils.ts'
|
|
2
|
+
|
|
3
|
+
type RemoveButtonProps = {
|
|
4
|
+
onClick: () => void
|
|
5
|
+
label: string
|
|
6
|
+
className?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function RemoveButton({ onClick, label, className }: RemoveButtonProps) {
|
|
10
|
+
return (
|
|
11
|
+
<button
|
|
12
|
+
type="button"
|
|
13
|
+
onClick={(e) => {
|
|
14
|
+
e.stopPropagation()
|
|
15
|
+
onClick()
|
|
16
|
+
}}
|
|
17
|
+
className={cn(
|
|
18
|
+
'absolute top-0.5 right-0.5 grid h-3.5 w-3.5 cursor-pointer place-items-center',
|
|
19
|
+
'rounded-full bg-black/60 text-white hover:bg-black/80 dark:bg-white/60 dark:text-black dark:hover:bg-white/80',
|
|
20
|
+
'transition-colors',
|
|
21
|
+
className,
|
|
22
|
+
)}
|
|
23
|
+
aria-label={label}>
|
|
24
|
+
<svg
|
|
25
|
+
width="8"
|
|
26
|
+
height="8"
|
|
27
|
+
viewBox="0 0 10 10"
|
|
28
|
+
fill="none"
|
|
29
|
+
stroke="currentColor"
|
|
30
|
+
strokeWidth="1.5"
|
|
31
|
+
strokeLinecap="round">
|
|
32
|
+
<line x1="2.75" y1="2.75" x2="7.25" y2="7.25" />
|
|
33
|
+
<line x1="7.25" y1="2.75" x2="2.75" y2="7.25" />
|
|
34
|
+
</svg>
|
|
35
|
+
</button>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convenience helpers for creating and inspecting Segments.
|
|
3
|
+
*
|
|
4
|
+
* These reduce boilerplate when building AI chat UIs that work with the
|
|
5
|
+
* PromptArea document model.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { text, chip, isSegmentsEmpty, segmentsToPlainText } from './segment-helpers.ts'
|
|
10
|
+
*
|
|
11
|
+
* const greeting = [text('Hello '), chip({ trigger: '@', value: 'u1', displayText: 'Alice' })]
|
|
12
|
+
* isSegmentsEmpty(greeting) // false
|
|
13
|
+
* segmentsToPlainText(greeting) // "Hello @Alice"
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Segment, TextSegment, ChipSegment } from './types.ts'
|
|
18
|
+
import { segmentsToPlainText, plainTextToSegments } from './prompt-area-engine.ts'
|
|
19
|
+
|
|
20
|
+
// Re-export serialization utilities so consumers have a single import.
|
|
21
|
+
export { segmentsToPlainText, plainTextToSegments }
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Factories
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** Create a text segment. */
|
|
28
|
+
export function text(value: string): TextSegment {
|
|
29
|
+
return { type: 'text', text: value }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Create a chip segment. */
|
|
33
|
+
export function chip(opts: Omit<ChipSegment, 'type'>): ChipSegment {
|
|
34
|
+
return { type: 'chip', ...opts }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Predicates
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/** Returns `true` when the segment array is empty or contains only whitespace text. */
|
|
42
|
+
export function isSegmentsEmpty(segments: Segment[]): boolean {
|
|
43
|
+
if (segments.length === 0) return true
|
|
44
|
+
return segments.every((seg) => seg.type === 'text' && seg.text.trim() === '')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Returns `true` when the segment array contains at least one chip. */
|
|
48
|
+
export function hasChips(segments: Segment[]): boolean {
|
|
49
|
+
return segments.some((seg) => seg.type === 'chip')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Extracts all chip segments from a segment array. */
|
|
53
|
+
export function getChips(segments: Segment[]): ChipSegment[] {
|
|
54
|
+
return segments.filter((seg): seg is ChipSegment => seg.type === 'chip')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Extracts chips matching a specific trigger character. */
|
|
58
|
+
export function getChipsByTrigger(segments: Segment[], trigger: string): ChipSegment[] {
|
|
59
|
+
return segments.filter(
|
|
60
|
+
(seg): seg is ChipSegment => seg.type === 'chip' && seg.trigger === trigger,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
|
+
import { cn } from '../../lib/utils.ts'
|
|
5
|
+
import type { TriggerSuggestion } from './types.ts'
|
|
6
|
+
|
|
7
|
+
type TriggerPopoverProps = {
|
|
8
|
+
suggestions: TriggerSuggestion[]
|
|
9
|
+
loading: boolean
|
|
10
|
+
error?: string | null
|
|
11
|
+
emptyMessage?: string
|
|
12
|
+
selectedIndex: number
|
|
13
|
+
onSelect: (suggestion: TriggerSuggestion) => void
|
|
14
|
+
onDismiss: () => void
|
|
15
|
+
triggerRect: DOMRect | null
|
|
16
|
+
triggerChar: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Single source of truth for the popover height: drives both the flip-above
|
|
20
|
+
// calculation and the maxHeight style.
|
|
21
|
+
const POPOVER_MAX_HEIGHT = 240
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Floating popover that displays trigger suggestions.
|
|
25
|
+
* Positioned relative to the trigger character location in the editor.
|
|
26
|
+
*/
|
|
27
|
+
export function TriggerPopover({
|
|
28
|
+
suggestions,
|
|
29
|
+
loading,
|
|
30
|
+
error,
|
|
31
|
+
emptyMessage,
|
|
32
|
+
selectedIndex,
|
|
33
|
+
onSelect,
|
|
34
|
+
onDismiss,
|
|
35
|
+
triggerRect,
|
|
36
|
+
triggerChar,
|
|
37
|
+
}: TriggerPopoverProps) {
|
|
38
|
+
const popoverRef = useRef<HTMLDivElement>(null)
|
|
39
|
+
const selectedRef = useRef<HTMLButtonElement>(null)
|
|
40
|
+
|
|
41
|
+
// Scroll selected item into view
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
selectedRef.current?.scrollIntoView({ block: 'nearest' })
|
|
44
|
+
}, [selectedIndex])
|
|
45
|
+
|
|
46
|
+
// Click outside to dismiss
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
49
|
+
const target = e.target
|
|
50
|
+
if (popoverRef.current && target instanceof Node && !popoverRef.current.contains(target)) {
|
|
51
|
+
onDismiss()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
55
|
+
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
56
|
+
}, [onDismiss])
|
|
57
|
+
|
|
58
|
+
if (!triggerRect) return null
|
|
59
|
+
if (suggestions.length === 0 && !loading && !error && !emptyMessage) return null
|
|
60
|
+
|
|
61
|
+
// Position the popover relative to the trigger character, clamped to the
|
|
62
|
+
// viewport. Flip above the trigger when there isn't enough room below, so the
|
|
63
|
+
// suggestion list stays on-screen near the bottom edge.
|
|
64
|
+
const popoverMaxWidth = Math.min(320, window.innerWidth - 16)
|
|
65
|
+
const left = Math.min(triggerRect.left, window.innerWidth - popoverMaxWidth - 8)
|
|
66
|
+
const spaceBelow = window.innerHeight - triggerRect.bottom
|
|
67
|
+
const positionAbove = spaceBelow < POPOVER_MAX_HEIGHT && triggerRect.top > spaceBelow
|
|
68
|
+
const style: React.CSSProperties = {
|
|
69
|
+
position: 'fixed',
|
|
70
|
+
left: `${Math.max(8, left)}px`,
|
|
71
|
+
zIndex: 50,
|
|
72
|
+
maxWidth: `${popoverMaxWidth}px`,
|
|
73
|
+
maxHeight: `${POPOVER_MAX_HEIGHT}px`,
|
|
74
|
+
...(positionAbove
|
|
75
|
+
? { bottom: `${window.innerHeight - triggerRect.top + 4}px` }
|
|
76
|
+
: { top: `${triggerRect.bottom + 4}px` }),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
ref={popoverRef}
|
|
82
|
+
className={cn(
|
|
83
|
+
'min-w-[200px] overflow-y-auto',
|
|
84
|
+
'bg-surface rounded-xl border p-2 shadow-md',
|
|
85
|
+
'animate-in fade-in-0 zoom-in-95',
|
|
86
|
+
)}
|
|
87
|
+
style={style}
|
|
88
|
+
role="listbox"
|
|
89
|
+
aria-label={`${triggerChar} suggestions`}>
|
|
90
|
+
{loading ? (
|
|
91
|
+
<div
|
|
92
|
+
role="option"
|
|
93
|
+
aria-selected={false}
|
|
94
|
+
className="text-muted-foreground px-3 py-2 text-sm">
|
|
95
|
+
Loading suggestions...
|
|
96
|
+
</div>
|
|
97
|
+
) : error ? (
|
|
98
|
+
<div role="option" aria-selected={false} className="text-destructive px-3 py-2 text-sm">
|
|
99
|
+
{error}
|
|
100
|
+
</div>
|
|
101
|
+
) : suggestions.length === 0 && emptyMessage ? (
|
|
102
|
+
<div
|
|
103
|
+
role="option"
|
|
104
|
+
aria-selected={false}
|
|
105
|
+
className="text-muted-foreground px-3 py-2 text-sm">
|
|
106
|
+
{emptyMessage}
|
|
107
|
+
</div>
|
|
108
|
+
) : (
|
|
109
|
+
suggestions.map((suggestion, index) => (
|
|
110
|
+
<button
|
|
111
|
+
key={suggestion.value}
|
|
112
|
+
ref={index === selectedIndex ? selectedRef : undefined}
|
|
113
|
+
type="button"
|
|
114
|
+
role="option"
|
|
115
|
+
aria-selected={index === selectedIndex}
|
|
116
|
+
className={cn(
|
|
117
|
+
'text-foreground flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm',
|
|
118
|
+
'hover:bg-surface-hover cursor-pointer transition-colors',
|
|
119
|
+
index === selectedIndex && 'bg-surface-hover',
|
|
120
|
+
)}
|
|
121
|
+
onMouseDown={(e) => {
|
|
122
|
+
e.preventDefault() // Prevent blur on the editor
|
|
123
|
+
onSelect(suggestion)
|
|
124
|
+
}}>
|
|
125
|
+
{suggestion.icon && <span className="mt-0.5 shrink-0">{suggestion.icon}</span>}
|
|
126
|
+
<div className="min-w-0 flex-1">
|
|
127
|
+
<div className="truncate font-medium">{suggestion.label}</div>
|
|
128
|
+
{suggestion.description && (
|
|
129
|
+
<div className="text-muted-foreground truncate text-xs">
|
|
130
|
+
{suggestion.description}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
</button>
|
|
135
|
+
))
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|