@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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/build/index.d.mts +927 -0
  4. package/build/index.mjs +5236 -0
  5. package/build/index.mjs.map +1 -0
  6. package/package.json +73 -0
  7. package/src/components/agent/Composer.tsx +139 -0
  8. package/src/components/agent/Conversation.tsx +59 -0
  9. package/src/components/agent/FileCard.tsx +50 -0
  10. package/src/components/agent/Loader.tsx +21 -0
  11. package/src/components/agent/Message.tsx +44 -0
  12. package/src/components/agent/ModelSelect.tsx +87 -0
  13. package/src/components/agent/PermissionModeSelect.tsx +94 -0
  14. package/src/components/agent/PermissionPrompt.tsx +52 -0
  15. package/src/components/agent/QuestionPrompt.tsx +193 -0
  16. package/src/components/agent/Reasoning.tsx +58 -0
  17. package/src/components/agent/Response.tsx +31 -0
  18. package/src/components/agent/SessionList.tsx +93 -0
  19. package/src/components/agent/SessionPanel.tsx +149 -0
  20. package/src/components/agent/StatusBar.tsx +140 -0
  21. package/src/components/agent/ToolCallCard.tsx +94 -0
  22. package/src/components/agent/Transcript.tsx +114 -0
  23. package/src/components/agent/status.ts +16 -0
  24. package/src/components/prompt-area/animated-placeholder.tsx +42 -0
  25. package/src/components/prompt-area/clipboard-helpers.ts +206 -0
  26. package/src/components/prompt-area/cursor-helpers.ts +244 -0
  27. package/src/components/prompt-area/dom-helpers.ts +721 -0
  28. package/src/components/prompt-area/file-strip.tsx +250 -0
  29. package/src/components/prompt-area/html-to-markdown.ts +278 -0
  30. package/src/components/prompt-area/image-strip.tsx +49 -0
  31. package/src/components/prompt-area/index.ts +23 -0
  32. package/src/components/prompt-area/prompt-area-engine.ts +705 -0
  33. package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
  34. package/src/components/prompt-area/prompt-area.tsx +375 -0
  35. package/src/components/prompt-area/remove-button.tsx +37 -0
  36. package/src/components/prompt-area/segment-helpers.ts +62 -0
  37. package/src/components/prompt-area/trigger-popover.tsx +139 -0
  38. package/src/components/prompt-area/trigger-presets.ts +143 -0
  39. package/src/components/prompt-area/types.ts +360 -0
  40. package/src/components/prompt-area/use-markdown-mode.ts +113 -0
  41. package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
  42. package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
  43. package/src/components/prompt-area/use-prompt-area.ts +1507 -0
  44. package/src/components/prompt-area/use-trigger-search.ts +115 -0
  45. package/src/components/ui/AlertDialog.tsx +56 -0
  46. package/src/components/ui/Badge.tsx +42 -0
  47. package/src/components/ui/Button.tsx +47 -0
  48. package/src/components/ui/Card.tsx +29 -0
  49. package/src/components/ui/CodeBlock.tsx +31 -0
  50. package/src/components/ui/CopyButton.tsx +28 -0
  51. package/src/components/ui/Input.tsx +20 -0
  52. package/src/components/ui/ProgressRing.tsx +49 -0
  53. package/src/components/ui/Select.tsx +80 -0
  54. package/src/components/ui/Sonner.tsx +22 -0
  55. package/src/components/ui/Spinner.tsx +6 -0
  56. package/src/components/ui/Textarea.tsx +21 -0
  57. package/src/components/ui/Tooltip.tsx +34 -0
  58. package/src/index.ts +99 -0
  59. package/src/lib/format.ts +67 -0
  60. package/src/lib/utils.ts +33 -0
  61. package/src/styles/theme.css +413 -0
@@ -0,0 +1,250 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import { cn } from '../../lib/utils.ts'
5
+ import { RemoveButton } from './remove-button.tsx'
6
+ import type { PromptAreaFile } from './types.ts'
7
+
8
+ type IconProps = { className?: string }
9
+
10
+ /** Shared SVG wrapper matching the lucide icon defaults (no dependency). */
11
+ function Svg({ className, children }: IconProps & { children: React.ReactNode }) {
12
+ return (
13
+ <svg
14
+ xmlns="http://www.w3.org/2000/svg"
15
+ width="24"
16
+ height="24"
17
+ viewBox="0 0 24 24"
18
+ fill="none"
19
+ stroke="currentColor"
20
+ strokeWidth="2"
21
+ strokeLinecap="round"
22
+ strokeLinejoin="round"
23
+ aria-hidden="true"
24
+ className={className}>
25
+ {children}
26
+ </svg>
27
+ )
28
+ }
29
+
30
+ const FileBody = (
31
+ <>
32
+ <path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
33
+ <path d="M14 2v5a1 1 0 0 0 1 1h5" />
34
+ </>
35
+ )
36
+
37
+ const File = ({ className }: IconProps) => <Svg className={className}>{FileBody}</Svg>
38
+ const FileText = ({ className }: IconProps) => (
39
+ <Svg className={className}>
40
+ {FileBody}
41
+ <path d="M10 9H8" />
42
+ <path d="M16 13H8" />
43
+ <path d="M16 17H8" />
44
+ </Svg>
45
+ )
46
+ const FileSpreadsheet = ({ className }: IconProps) => (
47
+ <Svg className={className}>
48
+ {FileBody}
49
+ <path d="M8 13h2" />
50
+ <path d="M14 13h2" />
51
+ <path d="M8 17h2" />
52
+ <path d="M14 17h2" />
53
+ </Svg>
54
+ )
55
+ const FileCode = ({ className }: IconProps) => (
56
+ <Svg className={className}>
57
+ {FileBody}
58
+ <path d="M10 12.5 8 15l2 2.5" />
59
+ <path d="m14 12.5 2 2.5-2 2.5" />
60
+ </Svg>
61
+ )
62
+ const ImageIcon = ({ className }: IconProps) => (
63
+ <Svg className={className}>
64
+ <rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
65
+ <circle cx="9" cy="9" r="2" />
66
+ <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
67
+ </Svg>
68
+ )
69
+
70
+ type FileStripProps = {
71
+ files: PromptAreaFile[]
72
+ onRemove?: (file: PromptAreaFile) => void
73
+ onClick?: (file: PromptAreaFile) => void
74
+ className?: string
75
+ }
76
+
77
+ /** Threshold above which collapse activates automatically. */
78
+ const COLLAPSE_THRESHOLD = 3
79
+
80
+ /** Pick a lucide icon key based on MIME type. */
81
+ function getFileIconKey(type?: string): 'pdf' | 'spreadsheet' | 'code' | 'image' | 'default' {
82
+ if (!type) return 'default'
83
+ if (type === 'application/pdf') return 'pdf'
84
+ if (type.includes('spreadsheet') || type === 'text/csv') return 'spreadsheet'
85
+ if (
86
+ type.startsWith('text/') ||
87
+ type.includes('javascript') ||
88
+ type.includes('json') ||
89
+ type.includes('xml')
90
+ )
91
+ return 'code'
92
+ if (type.startsWith('image/')) return 'image'
93
+ return 'default'
94
+ }
95
+
96
+ const FILE_ICONS = {
97
+ pdf: FileText,
98
+ spreadsheet: FileSpreadsheet,
99
+ code: FileCode,
100
+ image: ImageIcon,
101
+ default: File,
102
+ } as const
103
+
104
+ /** Format bytes into a human-readable string. */
105
+ function formatFileSize(bytes: number): string {
106
+ if (bytes < 1024) return `${bytes} B`
107
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
108
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
109
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
110
+ }
111
+
112
+ /** Extract a short extension label from a filename (e.g., "PDF", "CSV"). */
113
+ function getExtensionLabel(name: string): string | null {
114
+ const dot = name.lastIndexOf('.')
115
+ if (dot === -1 || dot === name.length - 1) return null
116
+ return name.slice(dot + 1).toUpperCase()
117
+ }
118
+
119
+ function FileCard({
120
+ file,
121
+ compact,
122
+ onRemove,
123
+ onClick,
124
+ }: {
125
+ file: PromptAreaFile
126
+ compact: boolean
127
+ onRemove?: (file: PromptAreaFile) => void
128
+ onClick?: (file: PromptAreaFile) => void
129
+ }) {
130
+ const ext = getExtensionLabel(file.name)
131
+ const sizeStr = file.size != null ? formatFileSize(file.size) : null
132
+ const meta = [ext, sizeStr].filter(Boolean).join(' · ')
133
+
134
+ return (
135
+ <div
136
+ role="listitem"
137
+ className={cn(
138
+ 'border-border relative flex flex-shrink-0 items-center gap-2 overflow-hidden rounded-lg border transition-colors',
139
+ 'hover:bg-surface-hover',
140
+ compact ? 'h-10 w-36 px-2' : 'h-14 w-48 px-3',
141
+ onClick && 'cursor-pointer',
142
+ )}
143
+ onClick={() => onClick?.(file)}>
144
+ {(() => {
145
+ const Icon = FILE_ICONS[getFileIconKey(file.type)]
146
+ return (
147
+ <Icon
148
+ className={cn('text-muted-foreground flex-shrink-0', compact ? 'h-4 w-4' : 'h-5 w-5')}
149
+ />
150
+ )
151
+ })()}
152
+ <div className="min-w-0 flex-1">
153
+ <div
154
+ className={cn('truncate font-medium', compact ? 'text-xs' : 'text-sm')}
155
+ title={file.name}>
156
+ {file.name}
157
+ </div>
158
+ {!compact && meta && <div className="text-muted-foreground truncate text-xs">{meta}</div>}
159
+ </div>
160
+
161
+ {file.loading && (
162
+ <div className="absolute inset-0 flex items-center justify-center bg-black/40">
163
+ <div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
164
+ </div>
165
+ )}
166
+
167
+ {onRemove && <RemoveButton onClick={() => onRemove(file)} label={`Remove ${file.name}`} />}
168
+ </div>
169
+ )
170
+ }
171
+
172
+ export function FileStrip({ files, onRemove, onClick, className }: FileStripProps) {
173
+ const [expanded, setExpanded] = useState(false)
174
+ const popoverRef = useRef<HTMLDivElement>(null)
175
+ const toggleRef = useRef<HTMLButtonElement>(null)
176
+
177
+ useEffect(() => {
178
+ if (!expanded) return
179
+ const handleClick = (e: MouseEvent) => {
180
+ const target = e.target
181
+ if (!(target instanceof Node)) return
182
+ if (
183
+ popoverRef.current &&
184
+ !popoverRef.current.contains(target) &&
185
+ !toggleRef.current?.contains(target)
186
+ ) {
187
+ setExpanded(false)
188
+ }
189
+ }
190
+ document.addEventListener('mousedown', handleClick)
191
+ return () => document.removeEventListener('mousedown', handleClick)
192
+ }, [expanded])
193
+
194
+ if (files.length === 0) return null
195
+
196
+ const collapsible = files.length > COLLAPSE_THRESHOLD
197
+ const compact = collapsible
198
+ const hiddenCount = files.length - COLLAPSE_THRESHOLD
199
+ const visibleFiles = files.slice(0, COLLAPSE_THRESHOLD)
200
+
201
+ return (
202
+ <div className={cn('relative', className)}>
203
+ <div className="flex flex-wrap gap-2" role="list" aria-label="Attached files">
204
+ {(collapsible ? visibleFiles : files).map((file) => (
205
+ <FileCard
206
+ key={file.id}
207
+ file={file}
208
+ compact={compact}
209
+ onRemove={onRemove}
210
+ onClick={onClick}
211
+ />
212
+ ))}
213
+ {collapsible && (
214
+ <div role="listitem">
215
+ <button
216
+ ref={toggleRef}
217
+ type="button"
218
+ onClick={() => setExpanded((v) => !v)}
219
+ className={cn(
220
+ 'border-border text-muted-foreground hover:bg-surface-hover flex flex-shrink-0 cursor-pointer items-center justify-center rounded-lg border transition-colors',
221
+ compact ? 'h-10 px-3 text-xs' : 'h-14 px-4 text-sm',
222
+ )}>
223
+ {expanded ? 'Show less' : `+${hiddenCount} more`}
224
+ </button>
225
+ </div>
226
+ )}
227
+ </div>
228
+
229
+ {expanded && (
230
+ <div
231
+ ref={popoverRef}
232
+ className={cn(
233
+ 'bg-surface border-border absolute bottom-full left-0 z-10 mb-2 max-h-48 overflow-y-auto rounded-lg border p-2 shadow-lg',
234
+ )}>
235
+ <div className="flex flex-wrap gap-2" role="list" aria-label="More attached files">
236
+ {files.slice(COLLAPSE_THRESHOLD).map((file) => (
237
+ <FileCard
238
+ key={file.id}
239
+ file={file}
240
+ compact={compact}
241
+ onRemove={onRemove}
242
+ onClick={onClick}
243
+ />
244
+ ))}
245
+ </div>
246
+ </div>
247
+ )}
248
+ </div>
249
+ )
250
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Hand-rolled, dependency-free HTML -> Markdown converter.
3
+ *
4
+ * Used by the paste handler: when the editor is in markdown mode and the
5
+ * clipboard carries rich `text/html` (web pages, Notion, Google Docs, GitHub,
6
+ * Slack, etc.), we convert it to markdown SOURCE text so the paste keeps its
7
+ * formatting. The resulting string flows through the same insertion path as a
8
+ * plain-text paste, and the editor's inline decorators render `*`/`**`/`***`
9
+ * and bare URLs automatically.
10
+ *
11
+ * Design constraints (see .size-limit.json): no runtime deps. Parsing uses the
12
+ * ambient `DOMParser`, walking is a small recursive switch. Type-safe: no
13
+ * `any`, DOM narrowed via the guards in `dom-helpers.ts`.
14
+ */
15
+ import { isHTMLElement, isTextNode } from './dom-helpers.ts'
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Inline style / emphasis detection
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** Reads a single declaration value from an inline `style` attribute string. */
22
+ function getStyleValue(style: string, prop: string): string {
23
+ const match = new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`, 'i').exec(style)
24
+ return match ? match[1].trim().toLowerCase() : ''
25
+ }
26
+
27
+ /** Whether a CSS font-weight value is bold (`bold`, `bolder`, or >= 600). */
28
+ function isBoldWeight(value: string): boolean {
29
+ if (value === 'bold' || value === 'bolder') return true
30
+ const numeric = Number.parseInt(value, 10)
31
+ return !Number.isNaN(numeric) && numeric >= 600
32
+ }
33
+
34
+ /**
35
+ * Computes the markdown emphasis markers for an element from BOTH its tag and
36
+ * its inline style. Google Docs emits `<span style="font-weight:700">` rather
37
+ * than `<b>`, and wraps everything in `<b style="font-weight:normal">`, so an
38
+ * explicit `font-weight`/`font-style` always wins over the tag name.
39
+ */
40
+ function inlineEmphasis(node: HTMLElement): { prefix: string; suffix: string } {
41
+ const tag = node.tagName
42
+ const style = node.getAttribute('style') ?? ''
43
+
44
+ const weight = getStyleValue(style, 'font-weight')
45
+ const bold = weight ? isBoldWeight(weight) : tag === 'B' || tag === 'STRONG'
46
+
47
+ const fontStyle = getStyleValue(style, 'font-style')
48
+ const italic = fontStyle
49
+ ? fontStyle === 'italic' || fontStyle === 'oblique'
50
+ : tag === 'I' || tag === 'EM'
51
+
52
+ const decoration = getStyleValue(style, 'text-decoration')
53
+ const strike =
54
+ tag === 'S' || tag === 'DEL' || tag === 'STRIKE' || decoration.includes('line-through')
55
+
56
+ const prefix = (strike ? '~~' : '') + (bold ? '**' : '') + (italic ? '*' : '')
57
+ const suffix = (italic ? '*' : '') + (bold ? '**' : '') + (strike ? '~~' : '')
58
+ return { prefix, suffix }
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Text handling
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /** Collapses HTML whitespace runs (incl. `&nbsp;` -> U+00A0) to single spaces. */
66
+ function collapseWhitespace(text: string): string {
67
+ return text.replace(/\s+/g, ' ')
68
+ }
69
+
70
+ /** Escapes literal `*` from HTML text so prose isn't re-read as emphasis. */
71
+ function escapeText(text: string): string {
72
+ return text.replace(/\*/g, '\\*')
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Block serializers
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /** Derives a fenced-block language from `class="language-ts"` or `lang="ts"`. */
80
+ function detectCodeLang(pre: HTMLElement): string {
81
+ const code = pre.querySelector('code')
82
+ const classNames = `${pre.className} ${code?.className ?? ''}`
83
+ const fromClass = /(?:language|lang)-([\w-]+)/.exec(classNames)
84
+ if (fromClass) return fromClass[1]
85
+ return pre.getAttribute('lang') ?? code?.getAttribute('lang') ?? ''
86
+ }
87
+
88
+ function serializePre(pre: HTMLElement): string {
89
+ const lang = detectCodeLang(pre)
90
+ const raw = (pre.textContent ?? '').replace(/\n$/, '')
91
+ return `\n\n\`\`\`${lang}\n${raw}\n\`\`\`\n\n`
92
+ }
93
+
94
+ function serializeInlineCode(node: HTMLElement): string {
95
+ const content = node.textContent ?? ''
96
+ if (content.includes('`')) return `\`\` ${content} \`\``
97
+ return `\`${content}\``
98
+ }
99
+
100
+ /** http(s)/mailto only; drops `#`, empty, and `javascript:` hrefs. */
101
+ function isSafeHref(href: string): boolean {
102
+ return /^(https?:|mailto:)/i.test(href)
103
+ }
104
+
105
+ function serializeAnchor(node: HTMLElement, depth: number): string {
106
+ const href = node.getAttribute('href') ?? ''
107
+ const label = serializeChildren(node, depth).trim()
108
+ if (!isSafeHref(href)) return label
109
+ if (!label || label === href) return href
110
+ return `[${label}](${href})`
111
+ }
112
+
113
+ function serializeImage(node: HTMLElement): string {
114
+ const src = node.getAttribute('src') ?? ''
115
+ // Gate the src through the same allow-list as anchors: only http(s)/mailto
116
+ // survive, so a `javascript:`/`vbscript:`/`data:` src never reaches the
117
+ // emitted markdown (defense-in-depth for consumers that render it as HTML).
118
+ if (!src || !isSafeHref(src)) return ''
119
+ return `![${node.getAttribute('alt') ?? ''}](${src})`
120
+ }
121
+
122
+ function serializeBlockquote(node: HTMLElement, depth: number): string {
123
+ const inner = serializeChildren(node, depth).trim()
124
+ const quoted = inner
125
+ .split('\n')
126
+ .map((line) => (line ? `> ${line}` : '>'))
127
+ .join('\n')
128
+ return `\n\n${quoted}\n\n`
129
+ }
130
+
131
+ /**
132
+ * Serializes a `<ul>`/`<ol>` at nesting `depth` (0 = top level). Each `<li>`'s
133
+ * own inline content becomes the marker line; a nested `<ul>`/`<ol>` child is
134
+ * serialized at `depth + 1` and appended indented below its parent item.
135
+ */
136
+ function serializeList(list: HTMLElement, depth: number): string {
137
+ const ordered = list.tagName === 'OL'
138
+ const start = Number.parseInt(list.getAttribute('start') ?? '', 10)
139
+ let index = Number.isNaN(start) ? 1 : start
140
+ const indent = ' '.repeat(depth)
141
+ const lines: string[] = []
142
+
143
+ for (const child of Array.from(list.childNodes)) {
144
+ if (!isHTMLElement(child) || child.tagName !== 'LI') continue
145
+
146
+ const marker = ordered ? `${index}. ` : '- '
147
+ index++
148
+
149
+ let label = ''
150
+ let nested = ''
151
+ for (const liChild of Array.from(child.childNodes)) {
152
+ if (isHTMLElement(liChild) && (liChild.tagName === 'UL' || liChild.tagName === 'OL')) {
153
+ nested += `\n${serializeList(liChild, depth + 1)}`
154
+ } else {
155
+ label += serializeNode(liChild, depth)
156
+ }
157
+ }
158
+ lines.push(`${indent}${marker}${label.trim()}${nested}`)
159
+ }
160
+
161
+ return lines.join('\n')
162
+ }
163
+
164
+ function serializeTable(table: HTMLElement, depth: number): string {
165
+ const rows = Array.from(table.querySelectorAll('tr'))
166
+ if (rows.length === 0) return ''
167
+
168
+ const cells = rows.map((row) =>
169
+ Array.from(row.children)
170
+ .filter((cell) => cell.tagName === 'TD' || cell.tagName === 'TH')
171
+ .map((cell) =>
172
+ serializeChildren(cell, depth).replace(/\n+/g, ' ').replace(/\|/g, '\\|').trim(),
173
+ ),
174
+ )
175
+
176
+ const header = cells[0]
177
+ const separator = header.map(() => '---')
178
+ const toRow = (row: string[]): string => `| ${row.join(' | ')} |`
179
+
180
+ return [toRow(header), toRow(separator), ...cells.slice(1).map(toRow)].join('\n')
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Recursive walker
185
+ // ---------------------------------------------------------------------------
186
+
187
+ function serializeChildren(node: Node, depth: number): string {
188
+ let out = ''
189
+ node.childNodes.forEach((child) => {
190
+ out += serializeNode(child, depth)
191
+ })
192
+ return out
193
+ }
194
+
195
+ function serializeNode(node: Node, depth: number): string {
196
+ if (isTextNode(node)) return escapeText(collapseWhitespace(node.textContent ?? ''))
197
+ if (!isHTMLElement(node)) return ''
198
+
199
+ const tag = node.tagName
200
+ switch (tag) {
201
+ case 'SCRIPT':
202
+ case 'STYLE':
203
+ case 'NOSCRIPT':
204
+ case 'HEAD':
205
+ case 'TITLE':
206
+ return ''
207
+ case 'BR':
208
+ return '\n'
209
+ case 'HR':
210
+ return '\n\n---\n\n'
211
+ case 'H1':
212
+ case 'H2':
213
+ case 'H3':
214
+ case 'H4':
215
+ case 'H5':
216
+ case 'H6':
217
+ return `\n\n${'#'.repeat(Number(tag[1]))} ${serializeChildren(node, depth).trim()}\n\n`
218
+ case 'P':
219
+ return `\n\n${serializeChildren(node, depth).trim()}\n\n`
220
+ case 'DIV':
221
+ return `\n${serializeChildren(node, depth).trim()}\n`
222
+ case 'BLOCKQUOTE':
223
+ return serializeBlockquote(node, depth)
224
+ case 'UL':
225
+ case 'OL':
226
+ return `\n\n${serializeList(node, depth)}\n\n`
227
+ case 'LI':
228
+ // A stray <li> outside a list wrapper — emit its content as a line.
229
+ return `${serializeChildren(node, depth).trim()}\n`
230
+ case 'PRE':
231
+ return serializePre(node)
232
+ case 'CODE':
233
+ // Inline code only: <pre> handles its own <code> via textContent.
234
+ return serializeInlineCode(node)
235
+ case 'A':
236
+ return serializeAnchor(node, depth)
237
+ case 'IMG':
238
+ return serializeImage(node)
239
+ case 'TABLE':
240
+ return `\n\n${serializeTable(node, depth)}\n\n`
241
+ default: {
242
+ // Inline emphasis (B/STRONG/I/EM/S/DEL + styled SPAN/FONT) and the
243
+ // "span soup" unwrap case both resolve here: emphasis markers when the
244
+ // tag or inline style is meaningful, otherwise a bare unwrap.
245
+ const { prefix, suffix } = inlineEmphasis(node)
246
+ return prefix + serializeChildren(node, depth) + suffix
247
+ }
248
+ }
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Output normalization
253
+ // ---------------------------------------------------------------------------
254
+
255
+ /** Trims trailing spaces and caps consecutive blank lines at one. */
256
+ function normalizeOutput(markdown: string): string {
257
+ return markdown
258
+ .replace(/[ \t]+\n/g, '\n')
259
+ .replace(/\n[ \t]*\n[ \t\n]*/g, '\n\n')
260
+ .trim()
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Public API
265
+ // ---------------------------------------------------------------------------
266
+
267
+ /**
268
+ * Converts an HTML string to markdown source text. Returns '' for empty or
269
+ * body-less input. Block markdown (headings, lists, quotes, fences, tables,
270
+ * links) is emitted as literal markdown text — that is the editor's intended
271
+ * display; only `*`/`**`/`***` and bare URLs get visually decorated inline.
272
+ */
273
+ export function htmlToMarkdown(html: string): string {
274
+ if (!html) return ''
275
+ const doc = new DOMParser().parseFromString(html, 'text/html')
276
+ if (!doc.body) return ''
277
+ return normalizeOutput(serializeChildren(doc.body, 0))
278
+ }
@@ -0,0 +1,49 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { RemoveButton } from './remove-button.tsx'
5
+ import type { PromptAreaImage } from './types.ts'
6
+
7
+ type ImageStripProps = {
8
+ images: PromptAreaImage[]
9
+ onRemove?: (image: PromptAreaImage) => void
10
+ onClick?: (image: PromptAreaImage) => void
11
+ className?: string
12
+ }
13
+
14
+ export function ImageStrip({ images, onRemove, onClick, className }: ImageStripProps) {
15
+ if (images.length === 0) return null
16
+
17
+ return (
18
+ <div className={cn('flex flex-wrap gap-2', className)} role="list" aria-label="Attached images">
19
+ {images.map((image) => (
20
+ <div
21
+ key={image.id}
22
+ role="listitem"
23
+ className={cn(
24
+ 'border-border relative h-16 w-16 flex-shrink-0 overflow-hidden rounded-md border',
25
+ onClick && 'cursor-pointer',
26
+ )}
27
+ onClick={() => onClick?.(image)}>
28
+ {/* eslint-disable-next-line @next/next/no-img-element -- registry component used outside Next.js */}
29
+ <img
30
+ src={image.url}
31
+ alt={image.alt ?? 'Attached image'}
32
+ className="h-full w-full object-cover"
33
+ />
34
+ {image.loading && (
35
+ <div className="absolute inset-0 flex items-center justify-center bg-black/40">
36
+ <div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
37
+ </div>
38
+ )}
39
+ {onRemove && (
40
+ <RemoveButton
41
+ onClick={() => onRemove(image)}
42
+ label={`Remove ${image.alt ?? 'image'}`}
43
+ />
44
+ )}
45
+ </div>
46
+ ))}
47
+ </div>
48
+ )
49
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Vendored from just-marketing/prompt-area (MIT) via its shadcn registry, so the
3
+ * popover/chips can be themed with this package's tokens. Upstream:
4
+ * https://github.com/just-marketing/prompt-area
5
+ */
6
+ export { PromptArea } from './prompt-area.tsx'
7
+ export { usePromptAreaState } from './use-prompt-area-state.ts'
8
+ export { commandTrigger, mentionTrigger, hashtagTrigger } from './trigger-presets.ts'
9
+ export {
10
+ segmentsToPlainText,
11
+ plainTextToSegments,
12
+ isSegmentsEmpty,
13
+ getChipsByTrigger,
14
+ } from './segment-helpers.ts'
15
+ export type {
16
+ PromptAreaHandle,
17
+ PromptAreaProps,
18
+ Segment,
19
+ ChipSegment,
20
+ TextSegment,
21
+ TriggerConfig,
22
+ TriggerSuggestion,
23
+ } from './types.ts'