@workerdeck/ui 0.9.0 → 0.10.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 +44 -3
- package/build/index.d.mts +762 -21
- package/build/index.mjs +3245 -348
- package/build/index.mjs.map +1 -1
- package/package.json +5 -4
- package/src/components/agent/CodeEditor.tsx +300 -0
- package/src/components/agent/Composer.tsx +379 -56
- package/src/components/agent/ContextDialog.tsx +99 -0
- package/src/components/agent/EditorTabs.tsx +165 -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/McpDialog.tsx +363 -0
- package/src/components/agent/ModelSelect.tsx +34 -6
- package/src/components/agent/PermissionModeSelect.tsx +99 -22
- package/src/components/agent/PermissionPrompt.tsx +72 -6
- package/src/components/agent/PromptTokenText.tsx +39 -0
- package/src/components/agent/SessionEmptyState.tsx +65 -0
- package/src/components/agent/SessionInfoDialog.tsx +163 -0
- package/src/components/agent/SessionPanel.tsx +379 -40
- 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 +80 -4
- package/src/components/agent/Transcript.tsx +66 -17
- package/src/components/agent/UsageDialog.tsx +168 -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/CopyButton.tsx +8 -1
- 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/index.ts +53 -1
- package/src/lib/clipboard.ts +56 -0
- package/src/lib/format.ts +48 -0
- package/src/lib/tool-icon.ts +74 -0
|
@@ -1,15 +1,40 @@
|
|
|
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
|
+
}
|
|
10
34
|
|
|
11
35
|
export interface ComposerProps {
|
|
12
|
-
|
|
36
|
+
/** `attachmentIds` are the staged uploads, in the order they were picked. */
|
|
37
|
+
onSend: (text: string, attachmentIds: string[]) => void
|
|
13
38
|
onInterrupt: () => void
|
|
14
39
|
busy: boolean
|
|
15
40
|
/** Disable input entirely (session failed/closed). */
|
|
@@ -17,86 +42,263 @@ export interface ComposerProps {
|
|
|
17
42
|
placeholder?: string
|
|
18
43
|
/** Slash commands offered as autocomplete; picked ones render as chips. */
|
|
19
44
|
commands?: SlashCommandInfo[]
|
|
20
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Skills offered under a **`$`** popover of their own — codex's sigil, kept
|
|
47
|
+
* separate from `/` because the two behave differently. A skill is a typing
|
|
48
|
+
* aid, not a command: picking one inserts editable text (the skill's own
|
|
49
|
+
* `defaultPrompt` where it has one, else `$name`) and nothing is sent. No
|
|
50
|
+
* engine parses `$skillname` as syntax, which is exactly why these can never
|
|
51
|
+
* resolve to a chip the way `commands` do.
|
|
52
|
+
*/
|
|
53
|
+
skills?: SkillInfo[]
|
|
54
|
+
/** Host-file search behind the `@` trigger. Omit to leave `@` inert — a
|
|
55
|
+
* gateway without host files has nothing to complete. */
|
|
56
|
+
onSearchFiles?: (query: string, options: { signal: AbortSignal }) => Promise<ComposerFileMatch[]>
|
|
57
|
+
/** Attachment staging (see `useAttachments`). Omit for a text-only composer. */
|
|
58
|
+
attachments?: UseAttachmentsResult
|
|
59
|
+
/** Left side of the toolbar row (mode selects, …). */
|
|
21
60
|
toolbar?: ReactNode
|
|
22
61
|
className?: string
|
|
62
|
+
ref?: Ref<ComposerHandle>
|
|
23
63
|
}
|
|
24
64
|
|
|
25
65
|
/** CLI names may carry display annotations (e.g. "foo (MCP)") the parser rejects. */
|
|
26
66
|
const cleanName = (name: string) => name.replace(/\s*\(MCP\)$/i, '')
|
|
27
67
|
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
68
|
+
/**
|
|
69
|
+
* What a picked skill types into the composer.
|
|
70
|
+
*
|
|
71
|
+
* The engine's own `defaultPrompt` when it declared one — it knows what its
|
|
72
|
+
* skill wants to be asked — and otherwise `$name`, which is codex's native way
|
|
73
|
+
* of referring to a skill in prompt text: its `skill-creator` documents the form
|
|
74
|
+
* (`Use $skill-x at /path/to/skill-x to solve problem y`) and its own bundled
|
|
75
|
+
* prompts are written that way ("Use $pdf to …"). Spelling it the way the engine
|
|
76
|
+
* spells it beats paraphrasing into "Use the X skill to".
|
|
77
|
+
*
|
|
78
|
+
* Either way it ends in a space so the caret lands ready for the rest of the
|
|
79
|
+
* sentence, and either way it is ordinary text: nothing here is submitted, and
|
|
80
|
+
* nothing is parsed back out.
|
|
81
|
+
*/
|
|
82
|
+
export function skillPrompt(skill: SkillInfo): string {
|
|
83
|
+
const base = skill.defaultPrompt?.trim() || `$${skill.name}`
|
|
84
|
+
return /\s$/.test(base) ? base : base + ' '
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Ranks a haystack set against the typed query: 2 for a prefix hit, 1 for a
|
|
88
|
+
* substring, 0 for no match. Shared so commands and skills sort as one list
|
|
89
|
+
* rather than two concatenated ones. */
|
|
90
|
+
function matchScore(query: string, haystacks: string[]): number {
|
|
91
|
+
const needle = query.toLowerCase()
|
|
92
|
+
const lowered = haystacks.map((s) => s.toLowerCase())
|
|
93
|
+
if (lowered.some((h) => h.startsWith(needle))) return 2
|
|
94
|
+
return lowered.some((h) => h.includes(needle)) ? 1 : 0
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Framed prompt input built on prompt-area's contentEditable.
|
|
99
|
+
*
|
|
100
|
+
* Three completions ride the same field and behave nothing alike. `/` is the
|
|
101
|
+
* CLI's command list and `$` is the engine's skill list — both local, so they
|
|
102
|
+
* filter completely and instantly; `@` is a search against the host filesystem,
|
|
103
|
+
* debounced and abortable so a fast typist makes one request rather than eight.
|
|
104
|
+
*
|
|
105
|
+
* `/` and `$` are separate keys rather than one merged menu, and that mirrors
|
|
106
|
+
* the engines themselves: codex completes skills on `$` and reserves `/` for
|
|
107
|
+
* commands. The behaviours differ too — a command resolves to a **chip**,
|
|
108
|
+
* because the CLI really does parse `/name` out of the message, while a skill
|
|
109
|
+
* resolves to plain editable **text**, because no engine parses `$name` as
|
|
110
|
+
* syntax; it is prose the model reads. Rendering them alike would promise
|
|
111
|
+
* something that does not happen.
|
|
112
|
+
*
|
|
113
|
+
* Files can arrive three ways — the paperclip, a drop, or a paste — because on a
|
|
114
|
+
* desktop all three are things people already do, and the upload starts the
|
|
115
|
+
* moment one lands rather than at send time.
|
|
116
|
+
*/
|
|
32
117
|
export function Composer({
|
|
33
118
|
onSend,
|
|
34
119
|
onInterrupt,
|
|
35
120
|
busy,
|
|
36
121
|
disabled,
|
|
37
|
-
placeholder = 'Message
|
|
122
|
+
placeholder = 'Message the agent…',
|
|
38
123
|
commands,
|
|
124
|
+
skills,
|
|
125
|
+
onSearchFiles,
|
|
126
|
+
attachments,
|
|
39
127
|
toolbar,
|
|
40
128
|
className,
|
|
129
|
+
ref,
|
|
41
130
|
}: ComposerProps) {
|
|
42
131
|
const { bind, plainText, isEmpty, clear, focus } = usePromptAreaState()
|
|
132
|
+
const fileInput = useRef<HTMLInputElement>(null)
|
|
133
|
+
const [dragging, setDragging] = useState(false)
|
|
134
|
+
|
|
135
|
+
useImperativeHandle(
|
|
136
|
+
ref,
|
|
137
|
+
() => ({
|
|
138
|
+
insertText: (text: string) => {
|
|
139
|
+
// A space in front only when there is something to separate from, so a
|
|
140
|
+
// draft into an empty composer doesn't start with one.
|
|
141
|
+
const prefix = plainText.length > 0 && !/\s$/.test(plainText) ? ' ' : ''
|
|
142
|
+
bind.ref.current?.appendText(prefix + text)
|
|
143
|
+
focus()
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
[bind.ref, plainText, focus],
|
|
147
|
+
)
|
|
43
148
|
|
|
44
149
|
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
|
-
|
|
150
|
+
const configured = []
|
|
151
|
+
const usableSkills = (skills ?? []).filter((s) => s.enabled)
|
|
152
|
+
if (commands && commands.length > 0) {
|
|
153
|
+
// The CLI list can contain the same skill name from several sources — first wins.
|
|
154
|
+
const seen = new Set<string>()
|
|
155
|
+
const unique = commands.flatMap((c) => {
|
|
156
|
+
const name = cleanName(c.name)
|
|
157
|
+
if (seen.has(name)) return []
|
|
158
|
+
seen.add(name)
|
|
159
|
+
return [{ ...c, name }]
|
|
160
|
+
})
|
|
161
|
+
configured.push(
|
|
162
|
+
commandTrigger({
|
|
163
|
+
onSearch: (query: string): TriggerSuggestion[] => {
|
|
164
|
+
const scored: Array<{ score: number; suggestion: TriggerSuggestion }> = []
|
|
165
|
+
for (const c of unique) {
|
|
166
|
+
// "wrapup" should find "dev:wrapup" — the bare half of a
|
|
167
|
+
// namespaced name is what people type.
|
|
168
|
+
const score = matchScore(query, [c.name, ...(c.aliases ?? []), ...c.name.split(':')])
|
|
169
|
+
if (score === 0) continue
|
|
170
|
+
scored.push({
|
|
171
|
+
score,
|
|
172
|
+
suggestion: {
|
|
173
|
+
value: c.name,
|
|
174
|
+
label: `/${c.name}${c.argumentHint ? ` ${c.argumentHint}` : ''}`,
|
|
175
|
+
description: c.description,
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
scored.sort((a, b) => b.score - a.score)
|
|
180
|
+
return scored.map(({ suggestion }) => suggestion)
|
|
181
|
+
},
|
|
182
|
+
// Chip text renders as trigger + displayText — return the bare name so
|
|
183
|
+
// the chip reads "/name" (label carries the argument hint for the menu).
|
|
184
|
+
onSelect: (suggestion) => suggestion.value,
|
|
185
|
+
chipClassName: 'font-mono',
|
|
186
|
+
}),
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
if (usableSkills.length > 0) {
|
|
190
|
+
// `$`, not `/`, because that is codex's own sigil — its TUI completes
|
|
191
|
+
// skills on `$` and reserves `/` for commands, and its bundled prompts
|
|
192
|
+
// refer to skills that way in prose ("Use $pdf to …"). Matching it means
|
|
193
|
+
// muscle memory transfers, and it keeps the two lists from being one
|
|
194
|
+
// ambiguous menu of things that behave differently.
|
|
195
|
+
configured.push(
|
|
196
|
+
commandTrigger({
|
|
197
|
+
char: '$',
|
|
198
|
+
accessibilityLabel: 'skill',
|
|
199
|
+
onSearch: (query: string): TriggerSuggestion[] => {
|
|
200
|
+
const scored: Array<{ score: number; suggestion: TriggerSuggestion }> = []
|
|
201
|
+
for (const skill of usableSkills) {
|
|
202
|
+
const score = matchScore(query, [skill.name, ...skill.name.split(/[-:_]/)])
|
|
203
|
+
if (score === 0) continue
|
|
204
|
+
const summary = skill.shortDescription ?? skill.description
|
|
205
|
+
scored.push({
|
|
206
|
+
score,
|
|
207
|
+
suggestion: {
|
|
208
|
+
value: skill.name,
|
|
209
|
+
label: skill.displayName ?? skill.name,
|
|
210
|
+
description: summary
|
|
211
|
+
? `Skill · ${summary}`
|
|
212
|
+
: 'Skill · inserts a message you can edit',
|
|
213
|
+
icon: <Sparkles className='size-3.5 text-fg-3' />,
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
scored.sort((a, b) => b.score - a.score)
|
|
218
|
+
return scored.map(({ suggestion }) => suggestion)
|
|
219
|
+
},
|
|
220
|
+
// Always text, never a chip: a skill is not wire syntax the engine
|
|
221
|
+
// parses back out, so what lands has to stay ordinary editable prose.
|
|
222
|
+
// Returning a string unconditionally is what makes this trigger's
|
|
223
|
+
// whole list behave that way.
|
|
224
|
+
insertAsText: (suggestion) => {
|
|
225
|
+
const skill = usableSkills.find((s) => s.name === suggestion.value)
|
|
226
|
+
return skill ? skillPrompt(skill) : `$${suggestion.value} `
|
|
227
|
+
},
|
|
228
|
+
}),
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
if (onSearchFiles) {
|
|
232
|
+
configured.push(
|
|
233
|
+
mentionTrigger({
|
|
234
|
+
// A round trip per keystroke would be eight requests for one word; the
|
|
235
|
+
// route is cheap but not free.
|
|
236
|
+
searchDebounceMs: 150,
|
|
237
|
+
onSearch: async (query, options) => {
|
|
238
|
+
const matches = await onSearchFiles(query, options)
|
|
239
|
+
return matches.map((match) => ({
|
|
240
|
+
value: match.relative,
|
|
241
|
+
label: match.relative,
|
|
242
|
+
description: match.path,
|
|
243
|
+
}))
|
|
244
|
+
},
|
|
245
|
+
onSelect: (suggestion) => suggestion.value,
|
|
246
|
+
chipStyle: 'inline',
|
|
247
|
+
chipClassName: 'font-mono',
|
|
248
|
+
emptyMessage: 'No matching files',
|
|
249
|
+
}),
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
return configured.length > 0 ? configured : undefined
|
|
253
|
+
}, [commands, skills, onSearchFiles])
|
|
254
|
+
|
|
255
|
+
const staged = attachments?.items ?? []
|
|
256
|
+
// A photo on its own is a message — send doesn't wait for text. It does wait
|
|
257
|
+
// for the upload, since an id that hasn't landed can't be named.
|
|
258
|
+
const canSend =
|
|
259
|
+
!disabled &&
|
|
260
|
+
(!isEmpty || staged.length > 0) &&
|
|
261
|
+
!attachments?.uploading &&
|
|
262
|
+
!attachments?.hasFailure
|
|
81
263
|
|
|
82
264
|
const submit = () => {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
265
|
+
if (!canSend) return
|
|
266
|
+
onSend(plainText.trim(), attachments?.readyIds ?? [])
|
|
267
|
+
attachments?.clear()
|
|
86
268
|
clear()
|
|
87
269
|
focus()
|
|
88
270
|
}
|
|
89
271
|
|
|
90
|
-
const
|
|
272
|
+
const pick = (files: FileList | null) => {
|
|
273
|
+
if (files && files.length > 0) attachments?.add(files)
|
|
274
|
+
}
|
|
91
275
|
|
|
92
276
|
return (
|
|
93
277
|
<div data-slot='composer' className={cn('px-3 pb-3', className)}>
|
|
94
278
|
<div
|
|
279
|
+
onDragOver={(e) => {
|
|
280
|
+
if (attachments && !attachments.disabled) {
|
|
281
|
+
e.preventDefault()
|
|
282
|
+
setDragging(true)
|
|
283
|
+
}
|
|
284
|
+
}}
|
|
285
|
+
onDragLeave={() => setDragging(false)}
|
|
286
|
+
onDrop={(e) => {
|
|
287
|
+
if (!attachments || attachments.disabled) return
|
|
288
|
+
e.preventDefault()
|
|
289
|
+
setDragging(false)
|
|
290
|
+
pick(e.dataTransfer.files)
|
|
291
|
+
}}
|
|
95
292
|
className={cn(
|
|
96
293
|
'mx-auto w-full max-w-3xl overflow-hidden rounded-lg border border-border bg-bg shadow-(--shadow-xs)',
|
|
97
294
|
'transition-colors focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30',
|
|
295
|
+
dragging && 'border-ring ring-2 ring-ring/30',
|
|
98
296
|
disabled && 'opacity-60',
|
|
99
297
|
)}>
|
|
298
|
+
{/* Above the field, like the picture you are talking about should be. */}
|
|
299
|
+
{staged.length > 0 && attachments ? (
|
|
300
|
+
<AttachmentStrip attachments={attachments} />
|
|
301
|
+
) : null}
|
|
100
302
|
<PromptArea
|
|
101
303
|
{...bind}
|
|
102
304
|
triggers={triggers}
|
|
@@ -105,12 +307,41 @@ export function Composer({
|
|
|
105
307
|
placeholder={disabled ? 'Session ended' : placeholder}
|
|
106
308
|
minHeight={28}
|
|
107
309
|
maxHeight={192}
|
|
108
|
-
aria-label='Message
|
|
310
|
+
aria-label='Message the agent'
|
|
109
311
|
className='px-3 pt-2.5 pb-1 text-body-sm text-text'
|
|
312
|
+
onImagePaste={(file) => attachments?.add([file])}
|
|
110
313
|
/>
|
|
111
314
|
<div className='flex items-center justify-between gap-2 px-2 pb-2'>
|
|
112
|
-
<div className='flex min-w-0 items-center gap-1'>
|
|
113
|
-
|
|
315
|
+
<div className='flex min-w-0 items-center gap-1'>
|
|
316
|
+
{/* An attach affordance the engine has no meaning for is not a
|
|
317
|
+
choice — the capability record decides whether it exists. */}
|
|
318
|
+
{attachments && !attachments.disabled ? (
|
|
319
|
+
<>
|
|
320
|
+
<input
|
|
321
|
+
ref={fileInput}
|
|
322
|
+
type='file'
|
|
323
|
+
multiple
|
|
324
|
+
accept={attachments.accept || undefined}
|
|
325
|
+
className='hidden'
|
|
326
|
+
onChange={(e) => {
|
|
327
|
+
pick(e.target.files)
|
|
328
|
+
// Re-picking the same file must fire `change` again.
|
|
329
|
+
e.target.value = ''
|
|
330
|
+
}}
|
|
331
|
+
/>
|
|
332
|
+
<Button
|
|
333
|
+
variant='ghost'
|
|
334
|
+
size='icon-sm'
|
|
335
|
+
aria-label='Attach files'
|
|
336
|
+
disabled={disabled}
|
|
337
|
+
onClick={() => fileInput.current?.click()}>
|
|
338
|
+
<Paperclip className='size-4' />
|
|
339
|
+
</Button>
|
|
340
|
+
</>
|
|
341
|
+
) : null}
|
|
342
|
+
{toolbar}
|
|
343
|
+
</div>
|
|
344
|
+
{busy && !canSend ? (
|
|
114
345
|
<Button
|
|
115
346
|
variant='outline'
|
|
116
347
|
size='icon-sm'
|
|
@@ -131,9 +362,101 @@ export function Composer({
|
|
|
131
362
|
)}
|
|
132
363
|
</div>
|
|
133
364
|
</div>
|
|
134
|
-
|
|
135
|
-
|
|
365
|
+
{attachments?.error ? (
|
|
366
|
+
<div className='mx-auto mt-1 flex w-full max-w-3xl items-center gap-2 text-label text-danger'>
|
|
367
|
+
<TriangleAlert className='size-3 shrink-0' />
|
|
368
|
+
<span className='min-w-0 flex-1'>{attachments.error}</span>
|
|
369
|
+
<button
|
|
370
|
+
type='button'
|
|
371
|
+
onClick={attachments.dismissError}
|
|
372
|
+
aria-label='Dismiss'
|
|
373
|
+
className='shrink-0 opacity-70 hover:opacity-100'>
|
|
374
|
+
<X className='size-3' />
|
|
375
|
+
</button>
|
|
376
|
+
</div>
|
|
377
|
+
) : (
|
|
378
|
+
<div className='mx-auto mt-1 w-full max-w-3xl text-center text-label text-fg-4'>
|
|
379
|
+
Enter to send · Shift+Enter for a new line
|
|
380
|
+
</div>
|
|
381
|
+
)}
|
|
382
|
+
</div>
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Staged files as a scrolling row of chips above the field. The thumbnail is
|
|
387
|
+
* the local blob, so nothing here waits on the network; the upload's state rides
|
|
388
|
+
* on top of it and the ✕ takes it back off. */
|
|
389
|
+
function AttachmentStrip({ attachments }: { attachments: UseAttachmentsResult }) {
|
|
390
|
+
return (
|
|
391
|
+
<div className='flex gap-2 overflow-x-auto border-b border-border px-2 py-2'>
|
|
392
|
+
{attachments.items.map((item) => (
|
|
393
|
+
<AttachmentChip
|
|
394
|
+
key={item.key}
|
|
395
|
+
item={item}
|
|
396
|
+
onRetry={() => attachments.retry(item.key)}
|
|
397
|
+
onRemove={() => attachments.remove(item.key)}
|
|
398
|
+
/>
|
|
399
|
+
))}
|
|
400
|
+
</div>
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function AttachmentChip({
|
|
405
|
+
item,
|
|
406
|
+
onRetry,
|
|
407
|
+
onRemove,
|
|
408
|
+
}: {
|
|
409
|
+
item: StagedAttachment
|
|
410
|
+
onRetry: () => void
|
|
411
|
+
onRemove: () => void
|
|
412
|
+
}) {
|
|
413
|
+
const failed = item.status === 'failed'
|
|
414
|
+
return (
|
|
415
|
+
<div
|
|
416
|
+
className='group relative shrink-0'
|
|
417
|
+
title={failed ? `${item.name} — ${item.error}` : `${item.name} · ${formatBytes(item.bytes)}`}>
|
|
418
|
+
<div
|
|
419
|
+
className={cn(
|
|
420
|
+
'flex size-14 items-center justify-center overflow-hidden rounded-md border border-border bg-surface',
|
|
421
|
+
failed && 'border-danger/50',
|
|
422
|
+
)}>
|
|
423
|
+
{item.previewUrl ? (
|
|
424
|
+
<img src={item.previewUrl} alt={item.name} className='size-full object-cover' />
|
|
425
|
+
) : (
|
|
426
|
+
<div className='flex flex-col items-center gap-0.5 text-fg-3'>
|
|
427
|
+
<FileText className='size-4' />
|
|
428
|
+
<span className='max-w-12 truncate text-[9px] font-semibold uppercase'>
|
|
429
|
+
{extensionOf(item.name)}
|
|
430
|
+
</span>
|
|
431
|
+
</div>
|
|
432
|
+
)}
|
|
136
433
|
</div>
|
|
434
|
+
{item.status === 'uploading' ? (
|
|
435
|
+
<div className='absolute inset-0 flex items-center justify-center rounded-md bg-black/35'>
|
|
436
|
+
<Spinner className='size-4 text-white' />
|
|
437
|
+
</div>
|
|
438
|
+
) : null}
|
|
439
|
+
{failed ? (
|
|
440
|
+
<button
|
|
441
|
+
type='button'
|
|
442
|
+
onClick={onRetry}
|
|
443
|
+
aria-label={`Retry ${item.name}`}
|
|
444
|
+
className='absolute inset-0 flex items-center justify-center rounded-md bg-black/45 text-warning'>
|
|
445
|
+
<RotateCw className='size-4' />
|
|
446
|
+
</button>
|
|
447
|
+
) : null}
|
|
448
|
+
<button
|
|
449
|
+
type='button'
|
|
450
|
+
onClick={onRemove}
|
|
451
|
+
aria-label={`Remove ${item.name}`}
|
|
452
|
+
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'>
|
|
453
|
+
<X className='size-2.5' />
|
|
454
|
+
</button>
|
|
137
455
|
</div>
|
|
138
456
|
)
|
|
139
457
|
}
|
|
458
|
+
|
|
459
|
+
const extensionOf = (name: string) => {
|
|
460
|
+
const dot = name.lastIndexOf('.')
|
|
461
|
+
return dot > 0 ? name.slice(dot + 1).toUpperCase() : 'FILE'
|
|
462
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ContextUsage } from '@workerdeck/protocol'
|
|
2
|
+
import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
|
|
3
|
+
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
import { formatTokens } from '../../lib/format.ts'
|
|
5
|
+
|
|
6
|
+
export interface ContextDialogProps {
|
|
7
|
+
usage?: ContextUsage
|
|
8
|
+
open: boolean
|
|
9
|
+
onOpenChange: (open: boolean) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The CLI reports category colors as its own theme token names ('inactive',
|
|
13
|
+
* 'promptBorder', ...), not CSS colors — only pass through what CSS can render. */
|
|
14
|
+
const cssColor = (color: string): string | undefined =>
|
|
15
|
+
typeof CSS !== 'undefined' && CSS.supports('color', color) ? color : undefined
|
|
16
|
+
|
|
17
|
+
const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent')
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What is in the model's context window right now, category by category.
|
|
21
|
+
*
|
|
22
|
+
* One of the three panels the status bar opens. Context, usage and session info
|
|
23
|
+
* are different questions asked at different moments, so they are different
|
|
24
|
+
* screens rather than one "details" list you scroll past two answers to reach.
|
|
25
|
+
*/
|
|
26
|
+
export function ContextDialog({ usage, open, onOpenChange }: ContextDialogProps) {
|
|
27
|
+
return (
|
|
28
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
29
|
+
<DialogContent>
|
|
30
|
+
<DialogHeader title='Context' description={usage?.model} />
|
|
31
|
+
<DialogBody>
|
|
32
|
+
{!usage ? (
|
|
33
|
+
<p className='py-6 text-center text-body-sm text-fg-4'>
|
|
34
|
+
No reading yet — the context window is measured after a turn completes.
|
|
35
|
+
</p>
|
|
36
|
+
) : (
|
|
37
|
+
<>
|
|
38
|
+
<div className='flex items-baseline justify-between gap-4'>
|
|
39
|
+
<span className='text-label text-fg-3'>Used</span>
|
|
40
|
+
<span className='font-mono text-body-sm text-fg-1'>
|
|
41
|
+
{formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} ·{' '}
|
|
42
|
+
{usage.percentage.toFixed(0)}%
|
|
43
|
+
</span>
|
|
44
|
+
</div>
|
|
45
|
+
<div className='mt-2 h-2 overflow-hidden rounded-full bg-border'>
|
|
46
|
+
<div
|
|
47
|
+
className={cn('h-full rounded-full', usageTint(usage.percentage))}
|
|
48
|
+
style={{ width: `${Math.min(100, Math.max(2, usage.percentage))}%` }}
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
{/* Codex reports occupancy with no breakdown, so an engine can send
|
|
52
|
+
a real reading and an empty `categories`. A "Breakdown" heading
|
|
53
|
+
over nothing reads as a failed load; the row above already says
|
|
54
|
+
everything that is known. */}
|
|
55
|
+
{usage.categories.length > 0 ? (
|
|
56
|
+
<div className='mt-5'>
|
|
57
|
+
<h3 className='text-label font-medium text-fg-3'>Breakdown</h3>
|
|
58
|
+
<div className='mt-2 flex flex-col gap-3'>
|
|
59
|
+
{usage.categories.map((category) => {
|
|
60
|
+
const share = (category.tokens / Math.max(usage.maxTokens, 1)) * 100
|
|
61
|
+
const color = cssColor(category.color)
|
|
62
|
+
return (
|
|
63
|
+
<div key={category.name}>
|
|
64
|
+
<div className='flex items-baseline justify-between gap-3'>
|
|
65
|
+
<span className='flex min-w-0 items-center gap-2'>
|
|
66
|
+
<span
|
|
67
|
+
className='size-2 shrink-0 rounded-full bg-fg-4'
|
|
68
|
+
style={color ? { backgroundColor: color } : undefined}
|
|
69
|
+
/>
|
|
70
|
+
<span className='truncate text-body-sm text-fg-1'>
|
|
71
|
+
{category.name}
|
|
72
|
+
</span>
|
|
73
|
+
</span>
|
|
74
|
+
<span className='shrink-0 font-mono text-label text-fg-3'>
|
|
75
|
+
{formatTokens(category.tokens)}
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
<div className='mt-1 h-1 overflow-hidden rounded-full bg-border'>
|
|
79
|
+
<div
|
|
80
|
+
className='h-full rounded-full bg-fg-4'
|
|
81
|
+
style={{
|
|
82
|
+
width: `${Math.min(100, share)}%`,
|
|
83
|
+
backgroundColor: color,
|
|
84
|
+
}}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
})}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
) : null}
|
|
93
|
+
</>
|
|
94
|
+
)}
|
|
95
|
+
</DialogBody>
|
|
96
|
+
</DialogContent>
|
|
97
|
+
</Dialog>
|
|
98
|
+
)
|
|
99
|
+
}
|