@workerdeck/ui 0.7.0 → 0.11.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/README.md +81 -3
  2. package/build/SessionPanel-CyhygZx_.d.mts +277 -0
  3. package/build/SessionPanel-_U8tjX29.mjs +8409 -0
  4. package/build/SessionPanel-_U8tjX29.mjs.map +1 -0
  5. package/build/format-DqR56Y8l.mjs +162 -0
  6. package/build/format-DqR56Y8l.mjs.map +1 -0
  7. package/build/format-ljc3lKpA.d.mts +59 -0
  8. package/build/format.d.mts +2 -0
  9. package/build/format.mjs +2 -0
  10. package/build/index.d.mts +615 -87
  11. package/build/index.mjs +6 -5081
  12. package/build/index.mjs.map +1 -1
  13. package/build/workspace.d.mts +199 -0
  14. package/build/workspace.mjs +849 -0
  15. package/build/workspace.mjs.map +1 -0
  16. package/package.json +22 -4
  17. package/src/components/agent/CodeEditor.tsx +300 -0
  18. package/src/components/agent/Composer.tsx +522 -87
  19. package/src/components/agent/ContextDialog.tsx +99 -0
  20. package/src/components/agent/Conversation.tsx +11 -3
  21. package/src/components/agent/EditorTabs.tsx +165 -0
  22. package/src/components/agent/FileCard.tsx +26 -0
  23. package/src/components/agent/FileTree.tsx +287 -0
  24. package/src/components/agent/FileViewer.tsx +148 -0
  25. package/src/components/agent/HostFilesDialog.tsx +218 -0
  26. package/src/components/agent/Loader.tsx +120 -14
  27. package/src/components/agent/McpDialog.tsx +363 -0
  28. package/src/components/agent/Message.tsx +51 -17
  29. package/src/components/agent/ModelSelect.tsx +34 -6
  30. package/src/components/agent/PermissionModeSelect.tsx +133 -22
  31. package/src/components/agent/PermissionPrompt.tsx +164 -6
  32. package/src/components/agent/PromptTokenText.tsx +39 -0
  33. package/src/components/agent/QuestionPrompt.tsx +122 -0
  34. package/src/components/agent/Reasoning.tsx +20 -5
  35. package/src/components/agent/Response.tsx +128 -0
  36. package/src/components/agent/SessionEmptyState.tsx +65 -0
  37. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  38. package/src/components/agent/SessionPanel.tsx +756 -90
  39. package/src/components/agent/SessionWorkspace.tsx +282 -0
  40. package/src/components/agent/SkillsDialog.tsx +195 -0
  41. package/src/components/agent/StatusBar.tsx +85 -18
  42. package/src/components/agent/ToolCallCard.tsx +243 -30
  43. package/src/components/agent/Transcript.tsx +540 -27
  44. package/src/components/agent/UsageDialog.tsx +168 -0
  45. package/src/components/agent/line-prompt.tsx +249 -0
  46. package/src/components/agent/transcript-variant.tsx +61 -0
  47. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  48. package/src/components/prompt-area/types.ts +15 -0
  49. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  50. package/src/components/ui/CodeBlock.tsx +40 -2
  51. package/src/components/ui/CopyButton.tsx +28 -3
  52. package/src/components/ui/Dialog.tsx +92 -0
  53. package/src/components/ui/Menu.tsx +55 -0
  54. package/src/components/ui/Splitter.tsx +133 -0
  55. package/src/components/ui/Tooltip.tsx +22 -5
  56. package/src/format.ts +10 -0
  57. package/src/index.ts +63 -2
  58. package/src/lib/clipboard.ts +56 -0
  59. package/src/lib/format.ts +114 -0
  60. package/src/lib/tool-icon.ts +96 -0
  61. package/src/workspace.ts +28 -0
@@ -1,15 +1,42 @@
1
- import { useMemo, type ReactNode } from 'react'
2
- import type { SlashCommandInfo } from '@workerdeck/protocol'
3
- import { ArrowUp, Square } from 'lucide-react'
1
+ import { useImperativeHandle, useMemo, useRef, useState, type ReactNode, type Ref } from 'react'
2
+ import type { SkillInfo, SlashCommandInfo } from '@workerdeck/protocol'
3
+ import type { StagedAttachment, UseAttachmentsResult } from '@workerdeck/react'
4
+ import {
5
+ ArrowUp,
6
+ FileText,
7
+ Paperclip,
8
+ RotateCw,
9
+ Sparkles,
10
+ Square,
11
+ TriangleAlert,
12
+ X,
13
+ } from 'lucide-react'
4
14
  import { Button } from '../ui/Button.tsx'
15
+ import { Spinner } from '../ui/Spinner.tsx'
5
16
  import { PromptArea } from '../prompt-area/prompt-area.tsx'
6
17
  import { usePromptAreaState } from '../prompt-area/use-prompt-area-state.ts'
7
- import { commandTrigger } from '../prompt-area/trigger-presets.ts'
18
+ import { commandTrigger, mentionTrigger } from '../prompt-area/trigger-presets.ts'
8
19
  import type { TriggerSuggestion } from '../prompt-area/types.ts'
9
20
  import { cn } from '../../lib/utils.ts'
21
+ import { formatBytes } from '../../lib/format.ts'
22
+
23
+ /** Files matching an `@` query, for the composer's file trigger. Structural so
24
+ * the ui package doesn't have to reach for the protocol's `HostFileMatch`. */
25
+ export type ComposerFileMatch = { path: string; relative: string }
26
+
27
+ /** Imperative surface for panels that draft a message the user then finishes —
28
+ * the skills dialog's "Use this skill". Nothing here sends. */
29
+ export type ComposerHandle = {
30
+ /** Append plain text at the caret's end and focus, separating it from
31
+ * whatever is already there. */
32
+ insertText: (text: string) => void
33
+ /** Put the caret in the field, changing nothing. */
34
+ focus: () => void
35
+ }
10
36
 
11
37
  export interface ComposerProps {
12
- onSend: (text: string) => void
38
+ /** `attachmentIds` are the staged uploads, in the order they were picked. */
39
+ onSend: (text: string, attachmentIds: string[]) => void
13
40
  onInterrupt: () => void
14
41
  busy: boolean
15
42
  /** Disable input entirely (session failed/closed). */
@@ -17,123 +44,531 @@ export interface ComposerProps {
17
44
  placeholder?: string
18
45
  /** Slash commands offered as autocomplete; picked ones render as chips. */
19
46
  commands?: SlashCommandInfo[]
20
- /** Left side of the toolbar row (mode selects, attachments, …). */
47
+ /**
48
+ * Skills offered under a **`$`** popover of their own — codex's sigil, kept
49
+ * separate from `/` because the two behave differently. A skill is a typing
50
+ * aid, not a command: picking one inserts editable text (the skill's own
51
+ * `defaultPrompt` where it has one, else `$name`) and nothing is sent. No
52
+ * engine parses `$skillname` as syntax, which is exactly why these can never
53
+ * resolve to a chip the way `commands` do.
54
+ */
55
+ skills?: SkillInfo[]
56
+ /** Host-file search behind the `@` trigger. Omit to leave `@` inert — a
57
+ * gateway without host files has nothing to complete. */
58
+ onSearchFiles?: (query: string, options: { signal: AbortSignal }) => Promise<ComposerFileMatch[]>
59
+ /** Attachment staging (see `useAttachments`). Omit for a text-only composer. */
60
+ attachments?: UseAttachmentsResult
61
+ /** Left side of the toolbar row (mode selects, …). */
21
62
  toolbar?: ReactNode
63
+ /**
64
+ * `'stacked'` (default) gives the buttons a row of their own under the field —
65
+ * right where a toolbar belongs. `'inline'` puts the field and the buttons on
66
+ * ONE line, growing from a single row as the message does: for a host whose
67
+ * session controls live in its own chrome (VS Code's status bar), where the
68
+ * empty composer would otherwise spend two rows saying nothing.
69
+ */
70
+ layout?: 'stacked' | 'inline'
22
71
  className?: string
72
+ ref?: Ref<ComposerHandle>
23
73
  }
24
74
 
25
75
  /** CLI names may carry display annotations (e.g. "foo (MCP)") the parser rejects. */
26
76
  const cleanName = (name: string) => name.replace(/\s*\(MCP\)$/i, '')
27
77
 
28
- /** Framed prompt input built on prompt-area's contentEditable: typing "/" — at the
29
- * start or after whitespace — opens a suggestion dropdown fed by `commands`, and a
30
- * picked command becomes an inline chip. Submit button flips to stop while a turn
31
- * is running (messages still queue while busy). */
78
+ /**
79
+ * What a picked skill types into the composer.
80
+ *
81
+ * The engine's own `defaultPrompt` when it declared one — it knows what its
82
+ * skill wants to be asked — and otherwise `$name`, which is codex's native way
83
+ * of referring to a skill in prompt text: its `skill-creator` documents the form
84
+ * (`Use $skill-x at /path/to/skill-x to solve problem y`) and its own bundled
85
+ * prompts are written that way ("Use $pdf to …"). Spelling it the way the engine
86
+ * spells it beats paraphrasing into "Use the X skill to".
87
+ *
88
+ * Either way it ends in a space so the caret lands ready for the rest of the
89
+ * sentence, and either way it is ordinary text: nothing here is submitted, and
90
+ * nothing is parsed back out.
91
+ */
92
+ export function skillPrompt(skill: SkillInfo): string {
93
+ const base = skill.defaultPrompt?.trim() || `$${skill.name}`
94
+ return /\s$/.test(base) ? base : base + ' '
95
+ }
96
+
97
+ /** Ranks a haystack set against the typed query: 2 for a prefix hit, 1 for a
98
+ * substring, 0 for no match. Shared so commands and skills sort as one list
99
+ * rather than two concatenated ones. */
100
+ function matchScore(query: string, haystacks: string[]): number {
101
+ const needle = query.toLowerCase()
102
+ const lowered = haystacks.map((s) => s.toLowerCase())
103
+ if (lowered.some((h) => h.startsWith(needle))) return 2
104
+ return lowered.some((h) => h.includes(needle)) ? 1 : 0
105
+ }
106
+
107
+ /**
108
+ * Framed prompt input built on prompt-area's contentEditable.
109
+ *
110
+ * Three completions ride the same field and behave nothing alike. `/` is the
111
+ * CLI's command list and `$` is the engine's skill list — both local, so they
112
+ * filter completely and instantly; `@` is a search against the host filesystem,
113
+ * debounced and abortable so a fast typist makes one request rather than eight.
114
+ *
115
+ * `/` and `$` are separate keys rather than one merged menu, and that mirrors
116
+ * the engines themselves: codex completes skills on `$` and reserves `/` for
117
+ * commands. The behaviours differ too — a command resolves to a **chip**,
118
+ * because the CLI really does parse `/name` out of the message, while a skill
119
+ * resolves to plain editable **text**, because no engine parses `$name` as
120
+ * syntax; it is prose the model reads. Rendering them alike would promise
121
+ * something that does not happen.
122
+ *
123
+ * Files can arrive three ways — the paperclip, a drop, or a paste — because on a
124
+ * desktop all three are things people already do, and the upload starts the
125
+ * moment one lands rather than at send time.
126
+ */
32
127
  export function Composer({
33
128
  onSend,
34
129
  onInterrupt,
35
130
  busy,
36
131
  disabled,
37
- placeholder = 'Message Claude…',
132
+ placeholder = 'Message the agent…',
38
133
  commands,
134
+ skills,
135
+ onSearchFiles,
136
+ attachments,
39
137
  toolbar,
138
+ layout = 'stacked',
40
139
  className,
140
+ ref,
41
141
  }: ComposerProps) {
142
+ const inline = layout === 'inline'
42
143
  const { bind, plainText, isEmpty, clear, focus } = usePromptAreaState()
144
+ const fileInput = useRef<HTMLInputElement>(null)
145
+ const [dragging, setDragging] = useState(false)
146
+
147
+ useImperativeHandle(
148
+ ref,
149
+ () => ({
150
+ insertText: (text: string) => {
151
+ // A space in front only when there is something to separate from, so a
152
+ // draft into an empty composer doesn't start with one.
153
+ const prefix = plainText.length > 0 && !/\s$/.test(plainText) ? ' ' : ''
154
+ bind.ref.current?.appendText(prefix + text)
155
+ focus()
156
+ },
157
+ focus,
158
+ }),
159
+ [bind.ref, plainText, focus],
160
+ )
43
161
 
44
162
  const triggers = useMemo(() => {
45
- if (!commands || commands.length === 0) return undefined
46
- // The CLI list can contain the same skill name from several sources — first wins.
47
- const seen = new Set<string>()
48
- const unique = commands.flatMap((c) => {
49
- const name = cleanName(c.name)
50
- if (seen.has(name)) return []
51
- seen.add(name)
52
- return [{ ...c, name }]
53
- })
54
- return [
55
- commandTrigger({
56
- onSearch: (query: string): TriggerSuggestion[] => {
57
- const needle = query.toLowerCase()
58
- const scored = unique.flatMap((c) => {
59
- const haystacks = [c.name, ...(c.aliases ?? [])].map((s) => s.toLowerCase())
60
- const score = haystacks.some((h) => h.startsWith(needle))
61
- ? 2
62
- : haystacks.some((h) => h.includes(needle))
63
- ? 1
64
- : 0
65
- return score === 0 ? [] : [{ c, score }]
66
- })
67
- scored.sort((a, b) => b.score - a.score)
68
- return scored.map(({ c }) => ({
69
- value: c.name,
70
- label: `/${c.name}${c.argumentHint ? ` ${c.argumentHint}` : ''}`,
71
- description: c.description,
72
- }))
73
- },
74
- // Chip text renders as trigger + displayText — return the bare name so
75
- // the chip reads "/name" (label carries the argument hint for the menu).
76
- onSelect: (suggestion) => suggestion.value,
77
- chipClassName: 'font-mono',
78
- }),
79
- ]
80
- }, [commands])
163
+ const configured = []
164
+ const usableSkills = (skills ?? []).filter((s) => s.enabled)
165
+ if (commands && commands.length > 0) {
166
+ // The CLI list can contain the same skill name from several sources — first wins.
167
+ const seen = new Set<string>()
168
+ const unique = commands.flatMap((c) => {
169
+ const name = cleanName(c.name)
170
+ if (seen.has(name)) return []
171
+ seen.add(name)
172
+ return [{ ...c, name }]
173
+ })
174
+ configured.push(
175
+ commandTrigger({
176
+ onSearch: (query: string): TriggerSuggestion[] => {
177
+ const scored: Array<{ score: number; suggestion: TriggerSuggestion }> = []
178
+ for (const c of unique) {
179
+ // "wrapup" should find "dev:wrapup" — the bare half of a
180
+ // namespaced name is what people type.
181
+ const score = matchScore(query, [c.name, ...(c.aliases ?? []), ...c.name.split(':')])
182
+ if (score === 0) continue
183
+ scored.push({
184
+ score,
185
+ suggestion: {
186
+ value: c.name,
187
+ label: `/${c.name}${c.argumentHint ? ` ${c.argumentHint}` : ''}`,
188
+ description: c.description,
189
+ },
190
+ })
191
+ }
192
+ scored.sort((a, b) => b.score - a.score)
193
+ return scored.map(({ suggestion }) => suggestion)
194
+ },
195
+ // Chip text renders as trigger + displayText — return the bare name so
196
+ // the chip reads "/name" (label carries the argument hint for the menu).
197
+ onSelect: (suggestion) => suggestion.value,
198
+ chipClassName: 'font-mono',
199
+ }),
200
+ )
201
+ }
202
+ if (usableSkills.length > 0) {
203
+ // `$`, not `/`, because that is codex's own sigil — its TUI completes
204
+ // skills on `$` and reserves `/` for commands, and its bundled prompts
205
+ // refer to skills that way in prose ("Use $pdf to …"). Matching it means
206
+ // muscle memory transfers, and it keeps the two lists from being one
207
+ // ambiguous menu of things that behave differently.
208
+ configured.push(
209
+ commandTrigger({
210
+ char: '$',
211
+ accessibilityLabel: 'skill',
212
+ onSearch: (query: string): TriggerSuggestion[] => {
213
+ const scored: Array<{ score: number; suggestion: TriggerSuggestion }> = []
214
+ for (const skill of usableSkills) {
215
+ const score = matchScore(query, [skill.name, ...skill.name.split(/[-:_]/)])
216
+ if (score === 0) continue
217
+ const summary = skill.shortDescription ?? skill.description
218
+ scored.push({
219
+ score,
220
+ suggestion: {
221
+ value: skill.name,
222
+ label: skill.displayName ?? skill.name,
223
+ description: summary
224
+ ? `Skill · ${summary}`
225
+ : 'Skill · inserts a message you can edit',
226
+ icon: <Sparkles className='size-3.5 text-fg-3' />,
227
+ },
228
+ })
229
+ }
230
+ scored.sort((a, b) => b.score - a.score)
231
+ return scored.map(({ suggestion }) => suggestion)
232
+ },
233
+ // Always text, never a chip: a skill is not wire syntax the engine
234
+ // parses back out, so what lands has to stay ordinary editable prose.
235
+ // Returning a string unconditionally is what makes this trigger's
236
+ // whole list behave that way.
237
+ insertAsText: (suggestion) => {
238
+ const skill = usableSkills.find((s) => s.name === suggestion.value)
239
+ return skill ? skillPrompt(skill) : `$${suggestion.value} `
240
+ },
241
+ }),
242
+ )
243
+ }
244
+ if (onSearchFiles) {
245
+ configured.push(
246
+ mentionTrigger({
247
+ // A round trip per keystroke would be eight requests for one word; the
248
+ // route is cheap but not free.
249
+ searchDebounceMs: 150,
250
+ onSearch: async (query, options) => {
251
+ const matches = await onSearchFiles(query, options)
252
+ return matches.map((match) => ({
253
+ value: match.relative,
254
+ label: match.relative,
255
+ description: match.path,
256
+ }))
257
+ },
258
+ onSelect: (suggestion) => suggestion.value,
259
+ chipStyle: 'inline',
260
+ chipClassName: 'font-mono',
261
+ emptyMessage: 'No matching files',
262
+ }),
263
+ )
264
+ }
265
+ return configured.length > 0 ? configured : undefined
266
+ }, [commands, skills, onSearchFiles])
267
+
268
+ const staged = attachments?.items ?? []
269
+ // A photo on its own is a message — send doesn't wait for text. It does wait
270
+ // for the upload, since an id that hasn't landed can't be named.
271
+ const canSend =
272
+ !disabled &&
273
+ (!isEmpty || staged.length > 0) &&
274
+ !attachments?.uploading &&
275
+ !attachments?.hasFailure
81
276
 
82
277
  const submit = () => {
83
- const trimmed = plainText.trim()
84
- if (!trimmed || disabled) return
85
- onSend(trimmed)
278
+ if (!canSend) return
279
+ onSend(plainText.trim(), attachments?.readyIds ?? [])
280
+ attachments?.clear()
86
281
  clear()
87
282
  focus()
88
283
  }
89
284
 
90
- const canSend = !disabled && !isEmpty
285
+ const pick = (files: FileList | null) => {
286
+ if (files && files.length > 0) attachments?.add(files)
287
+ }
288
+
289
+ // Built once, placed twice: the stacked layout gives these a toolbar row, the
290
+ // inline one sets them either side of the field. An attach affordance the
291
+ // engine has no meaning for is not a choice — the capability record decides it
292
+ // exists.
293
+ const fileField =
294
+ attachments && !attachments.disabled ? (
295
+ <input
296
+ ref={fileInput}
297
+ type='file'
298
+ multiple
299
+ accept={attachments.accept || undefined}
300
+ className='hidden'
301
+ onChange={(e) => {
302
+ pick(e.target.files)
303
+ // Re-picking the same file must fire `change` again.
304
+ e.target.value = ''
305
+ }}
306
+ />
307
+ ) : null
308
+ const canAttach = !!attachments && !attachments.disabled
309
+
310
+ const attach = canAttach ? (
311
+ <>
312
+ {fileField}
313
+ {inline ? (
314
+ // The glyph sits in the same 14px gutter the transcript's markers use,
315
+ // so the typed line starts on the column the conversation does.
316
+ <GlyphButton label='Attach files' disabled={disabled} onClick={() => fileInput.current?.click()}>
317
+ +
318
+ </GlyphButton>
319
+ ) : (
320
+ <Button
321
+ variant='ghost'
322
+ size='icon-sm'
323
+ aria-label='Attach files'
324
+ disabled={disabled}
325
+ onClick={() => fileInput.current?.click()}>
326
+ <Paperclip className='size-4' />
327
+ </Button>
328
+ )}
329
+ </>
330
+ ) : null
331
+
332
+ const interrupting = busy && !canSend
333
+ const submitButton = inline ? (
334
+ // Terminal furniture rather than chat furniture: a glyph that lights up on
335
+ // hover/focus instead of a filled pill. `↵` is what sends, `■` is what stops
336
+ // — the same two symbols the keyboard and a shell already use.
337
+ <GlyphButton
338
+ label={interrupting ? 'Interrupt' : 'Send'}
339
+ disabled={!interrupting && !canSend}
340
+ onClick={interrupting ? onInterrupt : submit}
341
+ className={interrupting ? 'text-warning' : canSend ? 'text-accent' : undefined}>
342
+ {interrupting ? '■' : '↵'}
343
+ </GlyphButton>
344
+ ) : interrupting ? (
345
+ <Button
346
+ variant='outline'
347
+ size='icon-sm'
348
+ aria-label='Interrupt'
349
+ className='rounded-full'
350
+ onClick={onInterrupt}>
351
+ <Square className='size-3' />
352
+ </Button>
353
+ ) : (
354
+ <Button
355
+ size='icon-sm'
356
+ aria-label='Send'
357
+ className='rounded-full'
358
+ disabled={!canSend}
359
+ onClick={submit}>
360
+ <ArrowUp className='size-4' />
361
+ </Button>
362
+ )
91
363
 
92
364
  return (
93
365
  <div data-slot='composer' className={cn('px-3 pb-3', className)}>
94
366
  <div
367
+ onDragOver={(e) => {
368
+ if (attachments && !attachments.disabled) {
369
+ e.preventDefault()
370
+ setDragging(true)
371
+ }
372
+ }}
373
+ onDragLeave={() => setDragging(false)}
374
+ onDrop={(e) => {
375
+ if (!attachments || attachments.disabled) return
376
+ e.preventDefault()
377
+ setDragging(false)
378
+ pick(e.dataTransfer.files)
379
+ }}
95
380
  className={cn(
96
- 'mx-auto w-full max-w-3xl overflow-hidden rounded-lg border border-border bg-bg shadow-(--shadow-xs)',
381
+ 'mx-auto w-full max-w-[var(--wd-content-max-w,48rem)] overflow-hidden rounded-lg border border-border bg-bg shadow-(--shadow-xs)',
97
382
  'transition-colors focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30',
383
+ dragging && 'border-ring ring-2 ring-ring/30',
98
384
  disabled && 'opacity-60',
99
385
  )}>
100
- <PromptArea
101
- {...bind}
102
- triggers={triggers}
103
- onSubmit={submit}
104
- disabled={disabled}
105
- placeholder={disabled ? 'Session ended' : placeholder}
106
- minHeight={28}
107
- maxHeight={192}
108
- aria-label='Message Claude'
109
- className='px-3 pt-2.5 pb-1 text-body-sm text-text'
110
- />
111
- <div className='flex items-center justify-between gap-2 px-2 pb-2'>
112
- <div className='flex min-w-0 items-center gap-1'>{toolbar}</div>
113
- {busy ? (
114
- <Button
115
- variant='outline'
116
- size='icon-sm'
117
- aria-label='Interrupt'
118
- className='rounded-full'
119
- onClick={onInterrupt}>
120
- <Square className='size-3' />
121
- </Button>
122
- ) : (
123
- <Button
124
- size='icon-sm'
125
- aria-label='Send'
126
- className='rounded-full'
127
- disabled={!canSend}
128
- onClick={submit}>
129
- <ArrowUp className='size-4' />
130
- </Button>
131
- )}
132
- </div>
386
+ {/* Above the field, like the picture you are talking about should be. */}
387
+ {staged.length > 0 && attachments ? (
388
+ <AttachmentStrip attachments={attachments} />
389
+ ) : null}
390
+ {inline ? (
391
+ // One row until the message needs more: the field grows into the space
392
+ // rather than the frame reserving it, and the buttons stay bottom-
393
+ // aligned as it does. 4px of padding and gap all round against 24px
394
+ // buttons and a 24px line box (20px of text, 2px either side) — on a
395
+ // single line everything centres without anything being nudged.
396
+ <div className='flex items-end gap-1 p-1'>
397
+ {attach}
398
+ <PromptArea
399
+ {...bind}
400
+ triggers={triggers}
401
+ onSubmit={submit}
402
+ disabled={disabled}
403
+ placeholder={disabled ? 'Session ended' : placeholder}
404
+ minHeight={20}
405
+ maxHeight={192}
406
+ aria-label='Message the agent'
407
+ className='min-w-0 flex-1 py-0.5 text-body-sm text-text'
408
+ onImagePaste={(file) => attachments?.add([file])}
409
+ />
410
+ {submitButton}
411
+ </div>
412
+ ) : (
413
+ <>
414
+ <PromptArea
415
+ {...bind}
416
+ triggers={triggers}
417
+ onSubmit={submit}
418
+ disabled={disabled}
419
+ placeholder={disabled ? 'Session ended' : placeholder}
420
+ minHeight={28}
421
+ maxHeight={192}
422
+ aria-label='Message the agent'
423
+ className='px-3 pt-2.5 pb-1 text-body-sm text-text'
424
+ onImagePaste={(file) => attachments?.add([file])}
425
+ />
426
+ <div className='flex items-center justify-between gap-2 px-2 pb-2'>
427
+ <div className='flex min-w-0 items-center gap-1'>
428
+ {attach}
429
+ {toolbar}
430
+ </div>
431
+ {submitButton}
432
+ </div>
433
+ </>
434
+ )}
133
435
  </div>
134
- <div className='mx-auto mt-1 w-full max-w-3xl text-center text-label text-fg-4'>
135
- Enter to send · Shift+Enter for a new line
436
+ {attachments?.error ? (
437
+ <div className='mx-auto mt-1 flex w-full max-w-[var(--wd-content-max-w,48rem)] items-center gap-2 text-label text-danger'>
438
+ <TriangleAlert className='size-3 shrink-0' />
439
+ <span className='min-w-0 flex-1'>{attachments.error}</span>
440
+ <button
441
+ type='button'
442
+ onClick={attachments.dismissError}
443
+ aria-label='Dismiss'
444
+ className='shrink-0 opacity-70 hover:opacity-100'>
445
+ <X className='size-3' />
446
+ </button>
447
+ </div>
448
+ ) : (
449
+ <div
450
+ data-slot='composer-hint'
451
+ className='mx-auto mt-1 w-full max-w-[var(--wd-content-max-w,48rem)] text-center text-label text-fg-4'>
452
+ Enter to send · Shift+Enter for a new line
453
+ </div>
454
+ )}
455
+ </div>
456
+ )
457
+ }
458
+
459
+ /**
460
+ * A composer action as a **character**, for the inline (terminal) layout: no
461
+ * pill, no border, nothing drawn until you reach for it — the surface only
462
+ * appears on hover/focus/press, which is how a terminal's own affordances
463
+ * behave. Sized to the transcript's gutter glyph (14px) so the row's furniture
464
+ * lines up with the conversation above it rather than towering over it.
465
+ */
466
+ function GlyphButton({
467
+ label,
468
+ disabled,
469
+ onClick,
470
+ className,
471
+ children,
472
+ }: {
473
+ label: string
474
+ disabled?: boolean
475
+ onClick: () => void
476
+ className?: string
477
+ children: ReactNode
478
+ }) {
479
+ return (
480
+ <button
481
+ type='button'
482
+ aria-label={label}
483
+ title={label}
484
+ disabled={disabled}
485
+ onClick={onClick}
486
+ className={cn(
487
+ 'flex size-6 shrink-0 items-center justify-center rounded-sm font-mono text-body-sm leading-none',
488
+ 'text-fg-3 transition-colors outline-none select-none',
489
+ 'hover:bg-surface-hover hover:text-fg-1 focus-visible:bg-surface-hover focus-visible:text-fg-1',
490
+ 'active:bg-accent-bg disabled:pointer-events-none disabled:opacity-40',
491
+ className,
492
+ )}>
493
+ {children}
494
+ </button>
495
+ )
496
+ }
497
+
498
+ /** Staged files as a scrolling row of chips above the field. The thumbnail is
499
+ * the local blob, so nothing here waits on the network; the upload's state rides
500
+ * on top of it and the ✕ takes it back off. */
501
+ function AttachmentStrip({ attachments }: { attachments: UseAttachmentsResult }) {
502
+ return (
503
+ <div className='flex gap-2 overflow-x-auto border-b border-border px-2 py-2'>
504
+ {attachments.items.map((item) => (
505
+ <AttachmentChip
506
+ key={item.key}
507
+ item={item}
508
+ onRetry={() => attachments.retry(item.key)}
509
+ onRemove={() => attachments.remove(item.key)}
510
+ />
511
+ ))}
512
+ </div>
513
+ )
514
+ }
515
+
516
+ function AttachmentChip({
517
+ item,
518
+ onRetry,
519
+ onRemove,
520
+ }: {
521
+ item: StagedAttachment
522
+ onRetry: () => void
523
+ onRemove: () => void
524
+ }) {
525
+ const failed = item.status === 'failed'
526
+ return (
527
+ <div
528
+ className='group relative shrink-0'
529
+ title={failed ? `${item.name} — ${item.error}` : `${item.name} · ${formatBytes(item.bytes)}`}>
530
+ <div
531
+ className={cn(
532
+ 'flex size-14 items-center justify-center overflow-hidden rounded-md border border-border bg-surface',
533
+ failed && 'border-danger/50',
534
+ )}>
535
+ {item.previewUrl ? (
536
+ <img src={item.previewUrl} alt={item.name} className='size-full object-cover' />
537
+ ) : (
538
+ <div className='flex flex-col items-center gap-0.5 text-fg-3'>
539
+ <FileText className='size-4' />
540
+ <span className='max-w-12 truncate text-[9px] font-semibold uppercase'>
541
+ {extensionOf(item.name)}
542
+ </span>
543
+ </div>
544
+ )}
136
545
  </div>
546
+ {item.status === 'uploading' ? (
547
+ <div className='absolute inset-0 flex items-center justify-center rounded-md bg-black/35'>
548
+ <Spinner className='size-4 text-white' />
549
+ </div>
550
+ ) : null}
551
+ {failed ? (
552
+ <button
553
+ type='button'
554
+ onClick={onRetry}
555
+ aria-label={`Retry ${item.name}`}
556
+ className='absolute inset-0 flex items-center justify-center rounded-md bg-black/45 text-warning'>
557
+ <RotateCw className='size-4' />
558
+ </button>
559
+ ) : null}
560
+ <button
561
+ type='button'
562
+ onClick={onRemove}
563
+ aria-label={`Remove ${item.name}`}
564
+ className='absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full border border-border bg-surface text-fg-3 shadow-(--shadow-xs) hover:text-fg-1'>
565
+ <X className='size-2.5' />
566
+ </button>
137
567
  </div>
138
568
  )
139
569
  }
570
+
571
+ const extensionOf = (name: string) => {
572
+ const dot = name.lastIndexOf('.')
573
+ return dot > 0 ? name.slice(dot + 1).toUpperCase() : 'FILE'
574
+ }