@workerdeck/ui 0.9.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.
- package/README.md +81 -3
- package/build/SessionPanel-CyhygZx_.d.mts +277 -0
- package/build/SessionPanel-_U8tjX29.mjs +8409 -0
- package/build/SessionPanel-_U8tjX29.mjs.map +1 -0
- package/build/format-DqR56Y8l.mjs +162 -0
- package/build/format-DqR56Y8l.mjs.map +1 -0
- package/build/format-ljc3lKpA.d.mts +59 -0
- package/build/format.d.mts +2 -0
- package/build/format.mjs +2 -0
- package/build/index.d.mts +610 -87
- package/build/index.mjs +6 -5104
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +199 -0
- package/build/workspace.mjs +849 -0
- package/build/workspace.mjs.map +1 -0
- package/package.json +22 -4
- package/src/components/agent/CodeEditor.tsx +300 -0
- package/src/components/agent/Composer.tsx +522 -87
- package/src/components/agent/ContextDialog.tsx +99 -0
- package/src/components/agent/Conversation.tsx +11 -3
- package/src/components/agent/EditorTabs.tsx +165 -0
- package/src/components/agent/FileCard.tsx +26 -0
- package/src/components/agent/FileTree.tsx +287 -0
- package/src/components/agent/FileViewer.tsx +148 -0
- package/src/components/agent/HostFilesDialog.tsx +218 -0
- package/src/components/agent/Loader.tsx +120 -14
- package/src/components/agent/McpDialog.tsx +363 -0
- package/src/components/agent/Message.tsx +51 -17
- package/src/components/agent/ModelSelect.tsx +34 -6
- package/src/components/agent/PermissionModeSelect.tsx +133 -22
- package/src/components/agent/PermissionPrompt.tsx +164 -6
- package/src/components/agent/PromptTokenText.tsx +39 -0
- package/src/components/agent/QuestionPrompt.tsx +122 -0
- package/src/components/agent/Reasoning.tsx +20 -5
- package/src/components/agent/Response.tsx +128 -0
- package/src/components/agent/SessionEmptyState.tsx +65 -0
- package/src/components/agent/SessionInfoDialog.tsx +163 -0
- package/src/components/agent/SessionPanel.tsx +756 -91
- package/src/components/agent/SessionWorkspace.tsx +282 -0
- package/src/components/agent/SkillsDialog.tsx +195 -0
- package/src/components/agent/StatusBar.tsx +85 -18
- package/src/components/agent/ToolCallCard.tsx +243 -30
- package/src/components/agent/Transcript.tsx +495 -30
- package/src/components/agent/UsageDialog.tsx +168 -0
- package/src/components/agent/line-prompt.tsx +249 -0
- package/src/components/agent/transcript-variant.tsx +61 -0
- package/src/components/prompt-area/prompt-area-engine.ts +53 -0
- package/src/components/prompt-area/types.ts +15 -0
- package/src/components/prompt-area/use-prompt-area.ts +20 -0
- package/src/components/ui/CodeBlock.tsx +40 -2
- package/src/components/ui/CopyButton.tsx +28 -3
- package/src/components/ui/Dialog.tsx +92 -0
- package/src/components/ui/Menu.tsx +55 -0
- package/src/components/ui/Splitter.tsx +133 -0
- package/src/components/ui/Tooltip.tsx +22 -5
- package/src/format.ts +10 -0
- package/src/index.ts +63 -2
- package/src/lib/clipboard.ts +56 -0
- package/src/lib/format.ts +114 -0
- package/src/lib/tool-icon.ts +96 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
+
}
|