@workerdeck/ui 0.23.0 → 1.1.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/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-I5QSLLb0.mjs} +2266 -3459
- package/build/SessionPanel-I5QSLLb0.mjs.map +1 -0
- package/build/SessionPanel-yNO60Tzw.d.mts +151 -0
- package/build/format-C_lpFq0l.d.mts +16 -0
- package/build/format.d.mts +8 -76
- package/build/format.mjs +2 -2
- package/build/index.d.mts +291 -1695
- package/build/index.mjs +6 -301
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +32 -23
- package/build/{status-BE-zg88x.mjs → status-Dm9INVTt.mjs} +17 -88
- package/build/status-Dm9INVTt.mjs.map +1 -0
- package/build/workspace.d.mts +8 -187
- package/build/workspace.mjs +13 -144
- package/build/workspace.mjs.map +1 -1
- package/package.json +24 -24
- package/src/components/agent/CodeEditor.tsx +31 -103
- package/src/components/agent/Composer.tsx +114 -333
- package/src/components/agent/ContextDialog.tsx +20 -41
- package/src/components/agent/ContextRing.tsx +6 -28
- package/src/components/agent/Conversation.tsx +21 -33
- package/src/components/agent/EditorTabs.tsx +39 -67
- package/src/components/agent/EngineIcon.tsx +29 -72
- package/src/components/agent/EntryIcon.tsx +16 -0
- package/src/components/agent/FileCard.tsx +10 -16
- package/src/components/agent/FileTree.tsx +52 -125
- package/src/components/agent/FileViewer.tsx +30 -60
- package/src/components/agent/HostFilesDialog.tsx +40 -69
- package/src/components/agent/Loader.tsx +3 -24
- package/src/components/agent/McpDialog.tsx +64 -137
- package/src/components/agent/Message.tsx +2 -21
- package/src/components/agent/ModelSelect.tsx +33 -62
- package/src/components/agent/PermissionModeSelect.tsx +21 -79
- package/src/components/agent/PermissionPrompt.tsx +59 -63
- package/src/components/agent/ProjectIcon.tsx +228 -93
- package/src/components/agent/PromptTokenText.tsx +10 -16
- package/src/components/agent/QuestionPrompt.tsx +43 -64
- package/src/components/agent/Reasoning.tsx +13 -16
- package/src/components/agent/Response.tsx +3 -9
- package/src/components/agent/Scrubber.tsx +31 -80
- package/src/components/agent/SessionBrowser.tsx +64 -243
- package/src/components/agent/SessionEmptyState.tsx +10 -40
- package/src/components/agent/SessionInfoDialog.tsx +46 -78
- package/src/components/agent/SessionItem.tsx +53 -320
- package/src/components/agent/SessionList.tsx +23 -39
- package/src/components/agent/SessionPanel.tsx +315 -996
- package/src/components/agent/SessionStatusIcon.tsx +14 -28
- package/src/components/agent/SessionSteps.tsx +34 -187
- package/src/components/agent/SessionWorkspace.tsx +104 -219
- package/src/components/agent/SkillsDialog.tsx +35 -80
- package/src/components/agent/StatusBar.tsx +51 -136
- package/src/components/agent/SubagentStrip.tsx +12 -77
- package/src/components/agent/ToolCallCard.tsx +94 -135
- package/src/components/agent/Transcript.tsx +103 -1288
- package/src/components/agent/TranscriptItemView.tsx +155 -0
- package/src/components/agent/TranscriptRows.tsx +418 -0
- package/src/components/agent/UsageDialog.tsx +7 -29
- package/src/components/agent/UsageMeters.tsx +18 -73
- package/src/components/agent/pulse.tsx +9 -32
- package/src/components/agent/scrubber-marks.ts +33 -103
- package/src/components/agent/status.ts +1 -5
- package/src/components/agent/tool-result-fetch.tsx +3 -25
- package/src/components/agent/tool-result-image.tsx +32 -111
- package/src/components/agent/transcript-rows.ts +41 -118
- package/src/components/agent/transcript-variant.tsx +3 -80
- package/src/components/agent/use-height-epoch.ts +10 -24
- package/src/components/agent/use-path-links.ts +45 -63
- package/src/components/agent/use-subagent-frame.ts +106 -0
- package/src/components/agent/use-transcript-jumps.ts +49 -107
- package/src/components/prompt-area/animated-placeholder.tsx +6 -5
- package/src/components/prompt-area/clipboard-helpers.ts +26 -21
- package/src/components/prompt-area/cursor-helpers.ts +79 -37
- package/src/components/prompt-area/dom-helpers.ts +97 -69
- package/src/components/prompt-area/file-strip.tsx +90 -80
- package/src/components/prompt-area/html-to-markdown.ts +67 -46
- package/src/components/prompt-area/image-strip.tsx +8 -18
- package/src/components/prompt-area/index.ts +2 -15
- package/src/components/prompt-area/prompt-area-engine.ts +100 -115
- package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
- package/src/components/prompt-area/prompt-area.tsx +31 -38
- package/src/components/prompt-area/remove-button.tsx +3 -9
- package/src/components/prompt-area/segment-helpers.ts +4 -12
- package/src/components/prompt-area/trigger-popover.tsx +15 -27
- package/src/components/prompt-area/trigger-presets.ts +0 -10
- package/src/components/prompt-area/types.ts +3 -8
- package/src/components/prompt-area/use-chip-editing.ts +305 -0
- package/src/components/prompt-area/use-markdown-mode.ts +7 -17
- package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
- package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
- package/src/components/prompt-area/use-prompt-area.ts +170 -896
- package/src/components/prompt-area/use-trigger-search.ts +21 -19
- package/src/components/terminal/PermissionPrompt.tsx +56 -59
- package/src/components/terminal/QuestionPrompt.tsx +28 -79
- package/src/components/terminal/StatusLine.tsx +13 -63
- package/src/components/terminal/TerminalTranscript.tsx +44 -160
- package/src/components/terminal/affordances.tsx +68 -98
- package/src/components/terminal/blocks.ts +20 -156
- package/src/components/terminal/diff.tsx +19 -67
- package/src/components/terminal/height.ts +163 -313
- package/src/components/terminal/image-box.ts +0 -44
- package/src/components/terminal/items.tsx +150 -305
- package/src/components/terminal/markdown.tsx +36 -95
- package/src/components/terminal/press.tsx +26 -53
- package/src/components/terminal/prompt.tsx +40 -170
- package/src/components/terminal/result-preview.ts +8 -56
- package/src/components/terminal/row.tsx +11 -98
- package/src/components/terminal/scrubber.tsx +108 -401
- package/src/components/terminal/surface.tsx +6 -54
- package/src/components/terminal/todos.ts +66 -0
- package/src/components/terminal/tool-run.ts +21 -192
- package/src/components/ui/AlertDialog.tsx +8 -23
- package/src/components/ui/Badge.tsx +18 -29
- package/src/components/ui/Button.tsx +7 -15
- package/src/components/ui/Card.tsx +2 -13
- package/src/components/ui/CodeBlock.tsx +10 -37
- package/src/components/ui/CopyButton.tsx +10 -23
- package/src/components/ui/Dialog.tsx +23 -36
- package/src/components/ui/Empty.tsx +6 -29
- package/src/components/ui/Input.tsx +14 -16
- package/src/components/ui/Menu.tsx +6 -18
- package/src/components/ui/PortalScope.tsx +1 -21
- package/src/components/ui/ProgressRing.tsx +8 -19
- package/src/components/ui/Select.tsx +18 -31
- package/src/components/ui/Sonner.tsx +1 -2
- package/src/components/ui/Spinner.tsx +1 -1
- package/src/components/ui/Splitter.tsx +30 -64
- package/src/components/ui/Textarea.tsx +2 -5
- package/src/components/ui/Tooltip.tsx +3 -13
- package/src/format.ts +0 -9
- package/src/index.ts +16 -113
- package/src/lib/clipboard.ts +8 -28
- package/src/lib/css.ts +8 -0
- package/src/lib/format.ts +101 -86
- package/src/lib/plan-request.ts +16 -0
- package/src/lib/status.ts +45 -95
- package/src/lib/tool-icon.ts +39 -47
- package/src/lib/utils.ts +1 -3
- package/src/styles/terminal.css +81 -49
- package/src/styles/theme.css +96 -32
- package/src/workspace.ts +1 -24
- package/build/SessionPanel-BobynUo4.d.mts +0 -669
- package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
- package/build/format-ljc3lKpA.d.mts +0 -59
- package/build/status-BE-zg88x.mjs.map +0 -1
|
@@ -1,28 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
useImperativeHandle,
|
|
3
|
-
useMemo,
|
|
4
|
-
useRef,
|
|
5
|
-
useState,
|
|
6
|
-
type DragEvent,
|
|
7
|
-
type ReactNode,
|
|
8
|
-
type Ref,
|
|
9
|
-
} from 'react'
|
|
1
|
+
import { useEffect, useImperativeHandle, useMemo, useRef, useState, type DragEvent, type ReactNode, type Ref } from 'react'
|
|
10
2
|
import type { SkillInfo, SlashCommandInfo } from '@workerdeck/protocol'
|
|
11
3
|
import type { StagedAttachment, UseAttachmentsResult } from '@workerdeck/react'
|
|
12
|
-
import {
|
|
13
|
-
ArrowUp,
|
|
14
|
-
FileText,
|
|
15
|
-
Paperclip,
|
|
16
|
-
RotateCw,
|
|
17
|
-
Sparkles,
|
|
18
|
-
Square,
|
|
19
|
-
TriangleAlert,
|
|
20
|
-
X,
|
|
21
|
-
} from 'lucide-react'
|
|
4
|
+
import { ArrowUp, FileText, Paperclip, RotateCw, Sparkles, Square, TriangleAlert, X } from 'lucide-react'
|
|
22
5
|
import { Button } from '../ui/Button.tsx'
|
|
23
6
|
import { Spinner } from '../ui/Spinner.tsx'
|
|
24
7
|
import { PromptArea } from '../prompt-area/prompt-area.tsx'
|
|
25
8
|
import { usePromptAreaState } from '../prompt-area/use-prompt-area-state.ts'
|
|
9
|
+
import { plainTextToSegments } from '../prompt-area/prompt-area-engine.ts'
|
|
26
10
|
import { commandTrigger, mentionTrigger } from '../prompt-area/trigger-presets.ts'
|
|
27
11
|
import { useTranscriptVariant } from './transcript-variant.tsx'
|
|
28
12
|
import type { TerminalAffordances } from '../terminal/affordances.tsx'
|
|
@@ -32,123 +16,55 @@ import type { TriggerSuggestion } from '../prompt-area/types.ts'
|
|
|
32
16
|
import { cn } from '../../lib/utils.ts'
|
|
33
17
|
import { formatBytes } from '../../lib/format.ts'
|
|
34
18
|
|
|
35
|
-
/** Files matching an `@` query, for the composer's file trigger. Structural so
|
|
36
|
-
* the ui package doesn't have to reach for the protocol's `HostFileMatch`. */
|
|
37
19
|
export type ComposerFileMatch = { path: string; relative: string }
|
|
38
20
|
|
|
39
|
-
/** Imperative surface for panels that draft a message the user then finishes —
|
|
40
|
-
* the skills dialog's "Use this skill". Nothing here sends. */
|
|
41
21
|
export type ComposerHandle = {
|
|
42
|
-
/** Append plain text at the caret's end and focus, separating it from
|
|
43
|
-
* whatever is already there. */
|
|
44
22
|
insertText: (text: string) => void
|
|
45
|
-
/** Put the caret in the field, changing nothing. */
|
|
46
23
|
focus: () => void
|
|
47
24
|
}
|
|
48
25
|
|
|
49
26
|
export interface ComposerProps {
|
|
50
|
-
/** `attachmentIds` are the staged uploads, in the order they were picked. */
|
|
51
27
|
onSend: (text: string, attachmentIds: string[]) => void
|
|
52
28
|
onInterrupt: () => void
|
|
53
29
|
busy: boolean
|
|
54
|
-
/** Disable input entirely (session failed/closed). */
|
|
55
30
|
disabled?: boolean
|
|
56
31
|
placeholder?: string
|
|
57
|
-
/** Slash commands offered as autocomplete; picked ones render as chips. */
|
|
58
32
|
commands?: SlashCommandInfo[]
|
|
59
|
-
/**
|
|
60
|
-
* Skills offered under a **`$`** popover of their own — codex's sigil, kept
|
|
61
|
-
* separate from `/` because the two behave differently. A skill is a typing
|
|
62
|
-
* aid, not a command: picking one inserts editable text (the skill's own
|
|
63
|
-
* `defaultPrompt` where it has one, else `$name`) and nothing is sent. No
|
|
64
|
-
* engine parses `$skillname` as syntax, which is exactly why these can never
|
|
65
|
-
* resolve to a chip the way `commands` do.
|
|
66
|
-
*/
|
|
67
33
|
skills?: SkillInfo[]
|
|
68
|
-
/** Host-file search behind the `@` trigger. Omit to leave `@` inert — a
|
|
69
|
-
* gateway without host files has nothing to complete. */
|
|
70
34
|
onSearchFiles?: (query: string, options: { signal: AbortSignal }) => Promise<ComposerFileMatch[]>
|
|
71
|
-
/** Attachment staging (see `useAttachments`). Omit for a text-only composer. */
|
|
72
35
|
attachments?: UseAttachmentsResult
|
|
73
|
-
/** Left side of the toolbar row (mode selects, …). */
|
|
74
36
|
toolbar?: ReactNode
|
|
75
|
-
/**
|
|
76
|
-
* `'stacked'` (default) gives the buttons a row of their own under the field —
|
|
77
|
-
* right where a toolbar belongs. `'inline'` puts the field and the buttons on
|
|
78
|
-
* ONE line, growing from a single row as the message does: for a host whose
|
|
79
|
-
* session controls live in its own chrome (VS Code's status bar), where the
|
|
80
|
-
* empty composer would otherwise spend two rows saying nothing.
|
|
81
|
-
*/
|
|
82
37
|
layout?: 'stacked' | 'inline'
|
|
83
|
-
/**
|
|
84
|
-
* Terminal theme only: the cell the composer draws on. Passed straight
|
|
85
|
-
* through to its own {@link TerminalSurface}, and it has to be passed — the
|
|
86
|
-
* composer sits outside the transcript's scroller, so it establishes a second
|
|
87
|
-
* surface, and a surface handed no metrics falls back to the 13/18 default. A
|
|
88
|
-
* host running the transcript at its editor's size and the composer at the
|
|
89
|
-
* default would have the caret land on a different column from the text above
|
|
90
|
-
* it, which is the one thing this theme exists to prevent.
|
|
91
|
-
*/
|
|
92
38
|
fontSize?: number
|
|
93
39
|
lineHeight?: number
|
|
94
|
-
/** Terminal theme only; see {@link TerminalAffordances}. */
|
|
95
40
|
affordances?: TerminalAffordances | boolean
|
|
41
|
+
/**
|
|
42
|
+
* Where unsent text is remembered between mounts. The composer is remounted on every session switch, so without
|
|
43
|
+
* this a half-written prompt dies with the switch. Omit to keep the composer stateless across mounts.
|
|
44
|
+
*/
|
|
45
|
+
draft?: { initialText: string; save: (text: string) => void; clear: () => void }
|
|
96
46
|
className?: string
|
|
97
47
|
ref?: Ref<ComposerHandle>
|
|
98
48
|
}
|
|
99
49
|
|
|
100
|
-
|
|
101
|
-
|
|
50
|
+
function cleanName(name: string) {
|
|
51
|
+
return name.replace(/\s*\(MCP\)$/i, '')
|
|
52
|
+
}
|
|
102
53
|
|
|
103
|
-
/**
|
|
104
|
-
* What a picked skill types into the composer.
|
|
105
|
-
*
|
|
106
|
-
* The engine's own `defaultPrompt` when it declared one — it knows what its
|
|
107
|
-
* skill wants to be asked — and otherwise `$name`, which is codex's native way
|
|
108
|
-
* of referring to a skill in prompt text: its `skill-creator` documents the form
|
|
109
|
-
* (`Use $skill-x at /path/to/skill-x to solve problem y`) and its own bundled
|
|
110
|
-
* prompts are written that way ("Use $pdf to …"). Spelling it the way the engine
|
|
111
|
-
* spells it beats paraphrasing into "Use the X skill to".
|
|
112
|
-
*
|
|
113
|
-
* Either way it ends in a space so the caret lands ready for the rest of the
|
|
114
|
-
* sentence, and either way it is ordinary text: nothing here is submitted, and
|
|
115
|
-
* nothing is parsed back out.
|
|
116
|
-
*/
|
|
117
54
|
export function skillPrompt(skill: SkillInfo): string {
|
|
118
55
|
const base = skill.defaultPrompt?.trim() || `$${skill.name}`
|
|
119
56
|
return /\s$/.test(base) ? base : base + ' '
|
|
120
57
|
}
|
|
121
58
|
|
|
122
|
-
/** Ranks a haystack set against the typed query: 2 for a prefix hit, 1 for a
|
|
123
|
-
* substring, 0 for no match. Shared so commands and skills sort as one list
|
|
124
|
-
* rather than two concatenated ones. */
|
|
125
59
|
function matchScore(query: string, haystacks: string[]): number {
|
|
126
60
|
const needle = query.toLowerCase()
|
|
127
61
|
const lowered = haystacks.map((s) => s.toLowerCase())
|
|
128
|
-
if (lowered.some((h) => h.startsWith(needle)))
|
|
62
|
+
if (lowered.some((h) => h.startsWith(needle))) {
|
|
63
|
+
return 2
|
|
64
|
+
}
|
|
129
65
|
return lowered.some((h) => h.includes(needle)) ? 1 : 0
|
|
130
66
|
}
|
|
131
67
|
|
|
132
|
-
/**
|
|
133
|
-
* Framed prompt input built on prompt-area's contentEditable.
|
|
134
|
-
*
|
|
135
|
-
* Three completions ride the same field and behave nothing alike. `/` is the
|
|
136
|
-
* CLI's command list and `$` is the engine's skill list — both local, so they
|
|
137
|
-
* filter completely and instantly; `@` is a search against the host filesystem,
|
|
138
|
-
* debounced and abortable so a fast typist makes one request rather than eight.
|
|
139
|
-
*
|
|
140
|
-
* `/` and `$` are separate keys rather than one merged menu, and that mirrors
|
|
141
|
-
* the engines themselves: codex completes skills on `$` and reserves `/` for
|
|
142
|
-
* commands. The behaviours differ too — a command resolves to a **chip**,
|
|
143
|
-
* because the CLI really does parse `/name` out of the message, while a skill
|
|
144
|
-
* resolves to plain editable **text**, because no engine parses `$name` as
|
|
145
|
-
* syntax; it is prose the model reads. Rendering them alike would promise
|
|
146
|
-
* something that does not happen.
|
|
147
|
-
*
|
|
148
|
-
* Files can arrive three ways — the paperclip, a drop, or a paste — because on a
|
|
149
|
-
* desktop all three are things people already do, and the upload starts the
|
|
150
|
-
* moment one lands rather than at send time.
|
|
151
|
-
*/
|
|
152
68
|
export function Composer({
|
|
153
69
|
onSend,
|
|
154
70
|
onInterrupt,
|
|
@@ -164,14 +80,17 @@ export function Composer({
|
|
|
164
80
|
fontSize,
|
|
165
81
|
lineHeight,
|
|
166
82
|
affordances,
|
|
83
|
+
draft,
|
|
167
84
|
className,
|
|
168
85
|
ref,
|
|
169
86
|
}: ComposerProps) {
|
|
170
87
|
const inline = layout === 'inline'
|
|
171
|
-
// The transcript's variant reaches here through the panel-wide context, so
|
|
172
|
-
// the composer matches the rows above it without a prop chain.
|
|
173
88
|
const terminal = useTranscriptVariant() === 'terminal'
|
|
174
|
-
|
|
89
|
+
// Read once, on mount: re-seeding mid-session would fight whatever is being typed.
|
|
90
|
+
const [initialDraft] = useState(() => draft?.initialText ?? '')
|
|
91
|
+
const { bind, plainText, isEmpty, clear, focus } = usePromptAreaState(
|
|
92
|
+
initialDraft === '' ? undefined : { initialValue: plainTextToSegments(initialDraft) },
|
|
93
|
+
)
|
|
175
94
|
const fileInput = useRef<HTMLInputElement>(null)
|
|
176
95
|
const [dragging, setDragging] = useState(false)
|
|
177
96
|
|
|
@@ -179,8 +98,6 @@ export function Composer({
|
|
|
179
98
|
ref,
|
|
180
99
|
() => ({
|
|
181
100
|
insertText: (text: string) => {
|
|
182
|
-
// A space in front only when there is something to separate from, so a
|
|
183
|
-
// draft into an empty composer doesn't start with one.
|
|
184
101
|
const prefix = plainText.length > 0 && !/\s$/.test(plainText) ? ' ' : ''
|
|
185
102
|
bind.ref.current?.appendText(prefix + text)
|
|
186
103
|
focus()
|
|
@@ -194,11 +111,12 @@ export function Composer({
|
|
|
194
111
|
const configured = []
|
|
195
112
|
const usableSkills = (skills ?? []).filter((s) => s.enabled)
|
|
196
113
|
if (commands && commands.length > 0) {
|
|
197
|
-
// The CLI list can contain the same skill name from several sources — first wins.
|
|
198
114
|
const seen = new Set<string>()
|
|
199
115
|
const unique = commands.flatMap((c) => {
|
|
200
116
|
const name = cleanName(c.name)
|
|
201
|
-
if (seen.has(name))
|
|
117
|
+
if (seen.has(name)) {
|
|
118
|
+
return []
|
|
119
|
+
}
|
|
202
120
|
seen.add(name)
|
|
203
121
|
return [{ ...c, name }]
|
|
204
122
|
})
|
|
@@ -207,10 +125,10 @@ export function Composer({
|
|
|
207
125
|
onSearch: (query: string): TriggerSuggestion[] => {
|
|
208
126
|
const scored: Array<{ score: number; suggestion: TriggerSuggestion }> = []
|
|
209
127
|
for (const c of unique) {
|
|
210
|
-
// "wrapup" should find "dev:wrapup" — the bare half of a
|
|
211
|
-
// namespaced name is what people type.
|
|
212
128
|
const score = matchScore(query, [c.name, ...(c.aliases ?? []), ...c.name.split(':')])
|
|
213
|
-
if (score === 0)
|
|
129
|
+
if (score === 0) {
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
214
132
|
scored.push({
|
|
215
133
|
score,
|
|
216
134
|
suggestion: {
|
|
@@ -223,19 +141,12 @@ export function Composer({
|
|
|
223
141
|
scored.sort((a, b) => b.score - a.score)
|
|
224
142
|
return scored.map(({ suggestion }) => suggestion)
|
|
225
143
|
},
|
|
226
|
-
// Chip text renders as trigger + displayText — return the bare name so
|
|
227
|
-
// the chip reads "/name" (label carries the argument hint for the menu).
|
|
228
144
|
onSelect: (suggestion) => suggestion.value,
|
|
229
145
|
chipClassName: 'font-mono',
|
|
230
146
|
}),
|
|
231
147
|
)
|
|
232
148
|
}
|
|
233
149
|
if (usableSkills.length > 0) {
|
|
234
|
-
// `$`, not `/`, because that is codex's own sigil — its TUI completes
|
|
235
|
-
// skills on `$` and reserves `/` for commands, and its bundled prompts
|
|
236
|
-
// refer to skills that way in prose ("Use $pdf to …"). Matching it means
|
|
237
|
-
// muscle memory transfers, and it keeps the two lists from being one
|
|
238
|
-
// ambiguous menu of things that behave differently.
|
|
239
150
|
configured.push(
|
|
240
151
|
commandTrigger({
|
|
241
152
|
char: '$',
|
|
@@ -244,27 +155,23 @@ export function Composer({
|
|
|
244
155
|
const scored: Array<{ score: number; suggestion: TriggerSuggestion }> = []
|
|
245
156
|
for (const skill of usableSkills) {
|
|
246
157
|
const score = matchScore(query, [skill.name, ...skill.name.split(/[-:_]/)])
|
|
247
|
-
if (score === 0)
|
|
158
|
+
if (score === 0) {
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
248
161
|
const summary = skill.shortDescription ?? skill.description
|
|
249
162
|
scored.push({
|
|
250
163
|
score,
|
|
251
164
|
suggestion: {
|
|
252
165
|
value: skill.name,
|
|
253
166
|
label: skill.displayName ?? skill.name,
|
|
254
|
-
description: summary
|
|
255
|
-
|
|
256
|
-
: 'Skill · inserts a message you can edit',
|
|
257
|
-
icon: <Sparkles className='size-3.5 text-fg-3' />,
|
|
167
|
+
description: summary ? `Skill · ${summary}` : 'Skill · inserts a message you can edit',
|
|
168
|
+
icon: <Sparkles className="size-3.5 text-fg-3" />,
|
|
258
169
|
},
|
|
259
170
|
})
|
|
260
171
|
}
|
|
261
172
|
scored.sort((a, b) => b.score - a.score)
|
|
262
173
|
return scored.map(({ suggestion }) => suggestion)
|
|
263
174
|
},
|
|
264
|
-
// Always text, never a chip: a skill is not wire syntax the engine
|
|
265
|
-
// parses back out, so what lands has to stay ordinary editable prose.
|
|
266
|
-
// Returning a string unconditionally is what makes this trigger's
|
|
267
|
-
// whole list behave that way.
|
|
268
175
|
insertAsText: (suggestion) => {
|
|
269
176
|
const skill = usableSkills.find((s) => s.name === suggestion.value)
|
|
270
177
|
return skill ? skillPrompt(skill) : `$${suggestion.value} `
|
|
@@ -275,8 +182,6 @@ export function Composer({
|
|
|
275
182
|
if (onSearchFiles) {
|
|
276
183
|
configured.push(
|
|
277
184
|
mentionTrigger({
|
|
278
|
-
// A round trip per keystroke would be eight requests for one word; the
|
|
279
|
-
// route is cheap but not free.
|
|
280
185
|
searchDebounceMs: 150,
|
|
281
186
|
onSearch: async (query, options) => {
|
|
282
187
|
const matches = await onSearchFiles(query, options)
|
|
@@ -296,39 +201,39 @@ export function Composer({
|
|
|
296
201
|
return configured.length > 0 ? configured : undefined
|
|
297
202
|
}, [commands, skills, onSearchFiles])
|
|
298
203
|
|
|
204
|
+
const saveDraft = draft?.save
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
saveDraft?.(plainText)
|
|
207
|
+
}, [plainText, saveDraft])
|
|
208
|
+
|
|
299
209
|
const staged = attachments?.items ?? []
|
|
300
|
-
|
|
301
|
-
// for the upload, since an id that hasn't landed can't be named.
|
|
302
|
-
const canSend =
|
|
303
|
-
!disabled &&
|
|
304
|
-
(!isEmpty || staged.length > 0) &&
|
|
305
|
-
!attachments?.uploading &&
|
|
306
|
-
!attachments?.hasFailure
|
|
210
|
+
const canSend = !disabled && (!isEmpty || staged.length > 0) && !attachments?.uploading && !attachments?.hasFailure
|
|
307
211
|
|
|
308
212
|
const submit = () => {
|
|
309
|
-
if (!canSend)
|
|
213
|
+
if (!canSend) {
|
|
214
|
+
return
|
|
215
|
+
}
|
|
310
216
|
onSend(plainText.trim(), attachments?.readyIds ?? [])
|
|
311
217
|
attachments?.clear()
|
|
218
|
+
draft?.clear()
|
|
312
219
|
clear()
|
|
313
220
|
focus()
|
|
314
221
|
}
|
|
315
222
|
|
|
316
223
|
const pick = (files: FileList | null) => {
|
|
317
|
-
if (files && files.length > 0)
|
|
224
|
+
if (files && files.length > 0) {
|
|
225
|
+
attachments?.add(files)
|
|
226
|
+
}
|
|
318
227
|
}
|
|
319
228
|
|
|
320
|
-
// Built once, placed twice: the stacked layout gives these a toolbar row, the
|
|
321
|
-
// inline one sets them either side of the field. An attach affordance the
|
|
322
|
-
// engine has no meaning for is not a choice — the capability record decides it
|
|
323
|
-
// exists.
|
|
324
229
|
const fileField =
|
|
325
230
|
attachments && !attachments.disabled ? (
|
|
326
231
|
<input
|
|
327
232
|
ref={fileInput}
|
|
328
|
-
type=
|
|
233
|
+
type="file"
|
|
329
234
|
multiple
|
|
330
235
|
accept={attachments.accept || undefined}
|
|
331
|
-
className=
|
|
236
|
+
className="hidden"
|
|
332
237
|
onChange={(e) => {
|
|
333
238
|
pick(e.target.files)
|
|
334
239
|
// Re-picking the same file must fire `change` again.
|
|
@@ -341,87 +246,38 @@ export function Composer({
|
|
|
341
246
|
const attach = canAttach ? (
|
|
342
247
|
<>
|
|
343
248
|
{fileField}
|
|
344
|
-
<Button
|
|
345
|
-
|
|
346
|
-
size='icon-sm'
|
|
347
|
-
aria-label='Attach files'
|
|
348
|
-
disabled={disabled}
|
|
349
|
-
onClick={() => fileInput.current?.click()}>
|
|
350
|
-
<Paperclip className='size-4' />
|
|
249
|
+
<Button variant="ghost" size="icon-sm" aria-label="Attach files" disabled={disabled} onClick={() => fileInput.current?.click()}>
|
|
250
|
+
<Paperclip className="size-4" />
|
|
351
251
|
</Button>
|
|
352
252
|
</>
|
|
353
253
|
) : null
|
|
354
254
|
|
|
355
|
-
/**
|
|
356
|
-
* The terminal composer's **gutter cell** — the one column every transcript
|
|
357
|
-
* row's marker sits in, so whatever stands here cannot move the text beside
|
|
358
|
-
* it. It holds one of three things, in this order:
|
|
359
|
-
*
|
|
360
|
-
* `✕` **while the session is working**, because the gutter is where the eye
|
|
361
|
-
* already is (it is the column the pulse and every marker share) and stop is
|
|
362
|
-
* the only action that matters mid-run. Note the condition is `busy` alone,
|
|
363
|
-
* not the old `busy && !canSend`: with send living on the other side of the
|
|
364
|
-
* field there is no longer a slot to compete for, so typing a follow-up while
|
|
365
|
-
* a turn runs no longer hides the way to stop it.
|
|
366
|
-
*
|
|
367
|
-
* A cross rather than the `■` this started as: the square reads as a *state*
|
|
368
|
-
* ("stopped") in a column where `●` and `◆` really are states, so it looked
|
|
369
|
-
* like a status marker rather than something to press. A cross reads as an
|
|
370
|
-
* action and collides with nothing else on the column. It is also one of the
|
|
371
|
-
* few candidates that measures exactly 1ch in JetBrains Mono — `⏹`, `⏸` and
|
|
372
|
-
* `⏻`, the obvious picks, are 1.05–1.31 cells and would break the grid.
|
|
373
|
-
*
|
|
374
|
-
* `+` **otherwise**, when there is anything to attach — the composer's own
|
|
375
|
-
* affordance, in the composer's own gutter.
|
|
376
|
-
*
|
|
377
|
-
* `❯` when neither applies, so the column is never empty and the typed line
|
|
378
|
-
* never shifts. Blue, not the brand's coral: coral is the *working* mark, and
|
|
379
|
-
* a prompt waiting for you is not the session working.
|
|
380
|
-
*/
|
|
381
255
|
const gutter = busy ? (
|
|
382
|
-
<GlyphButton gutter label=
|
|
256
|
+
<GlyphButton gutter label="Interrupt" tone="yellow" onClick={onInterrupt}>
|
|
383
257
|
✕
|
|
384
258
|
</GlyphButton>
|
|
385
259
|
) : canAttach ? (
|
|
386
|
-
<GlyphButton
|
|
387
|
-
gutter
|
|
388
|
-
label='Attach files'
|
|
389
|
-
disabled={disabled}
|
|
390
|
-
onClick={() => fileInput.current?.click()}>
|
|
260
|
+
<GlyphButton gutter label="Attach files" disabled={disabled} onClick={() => fileInput.current?.click()}>
|
|
391
261
|
+
|
|
392
262
|
</GlyphButton>
|
|
393
263
|
) : (
|
|
394
|
-
<span aria-hidden className=
|
|
264
|
+
<span aria-hidden className="term-gutter" data-tone="blue">
|
|
395
265
|
{PROMPT_GLYPH}
|
|
396
266
|
</span>
|
|
397
267
|
)
|
|
398
268
|
|
|
399
269
|
const interrupting = busy && !canSend
|
|
400
270
|
const submitButton = terminal ? (
|
|
401
|
-
|
|
402
|
-
// hover/focus instead of a filled pill. `↵` is the only thing on this side
|
|
403
|
-
// now — stop moved to the gutter — so it means one thing at all times, and
|
|
404
|
-
// a reader never has to check which symbol is currently under their cursor.
|
|
405
|
-
<GlyphButton label='Send' disabled={!canSend} onClick={submit} tone={canSend ? 'blue' : undefined}>
|
|
271
|
+
<GlyphButton label="Send" disabled={!canSend} onClick={submit} tone={canSend ? 'blue' : undefined}>
|
|
406
272
|
↵
|
|
407
273
|
</GlyphButton>
|
|
408
274
|
) : interrupting ? (
|
|
409
|
-
<Button
|
|
410
|
-
|
|
411
|
-
size='icon-sm'
|
|
412
|
-
aria-label='Interrupt'
|
|
413
|
-
className='rounded-full'
|
|
414
|
-
onClick={onInterrupt}>
|
|
415
|
-
<Square className='size-3' />
|
|
275
|
+
<Button variant="outline" size="icon-sm" aria-label="Interrupt" className="rounded-full" onClick={onInterrupt}>
|
|
276
|
+
<Square className="size-3" />
|
|
416
277
|
</Button>
|
|
417
278
|
) : (
|
|
418
|
-
<Button
|
|
419
|
-
size
|
|
420
|
-
aria-label='Send'
|
|
421
|
-
className='rounded-full'
|
|
422
|
-
disabled={!canSend}
|
|
423
|
-
onClick={submit}>
|
|
424
|
-
<ArrowUp className='size-4' />
|
|
279
|
+
<Button size="icon-sm" aria-label="Send" className="rounded-full" disabled={!canSend} onClick={submit}>
|
|
280
|
+
<ArrowUp className="size-4" />
|
|
425
281
|
</Button>
|
|
426
282
|
)
|
|
427
283
|
|
|
@@ -434,7 +290,9 @@ export function Composer({
|
|
|
434
290
|
},
|
|
435
291
|
onDragLeave: () => setDragging(false),
|
|
436
292
|
onDrop: (e: DragEvent) => {
|
|
437
|
-
if (!attachments || attachments.disabled)
|
|
293
|
+
if (!attachments || attachments.disabled) {
|
|
294
|
+
return
|
|
295
|
+
}
|
|
438
296
|
e.preventDefault()
|
|
439
297
|
setDragging(false)
|
|
440
298
|
pick(e.dataTransfer.files)
|
|
@@ -442,84 +300,55 @@ export function Composer({
|
|
|
442
300
|
}
|
|
443
301
|
|
|
444
302
|
const errorRow = attachments?.error ? (
|
|
445
|
-
<div
|
|
446
|
-
className=
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
<span className='min-w-0 flex-1'>{attachments.error}</span>
|
|
451
|
-
<button
|
|
452
|
-
type='button'
|
|
453
|
-
onClick={attachments.dismissError}
|
|
454
|
-
aria-label='Dismiss'
|
|
455
|
-
className='shrink-0 opacity-70 hover:opacity-100'>
|
|
456
|
-
<X className='size-3' />
|
|
303
|
+
<div className={cn('mx-auto mt-1 flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-danger')}>
|
|
304
|
+
<TriangleAlert className="size-3 shrink-0" />
|
|
305
|
+
<span className="min-w-0 flex-1">{attachments.error}</span>
|
|
306
|
+
<button type="button" onClick={attachments.dismissError} aria-label="Dismiss" className="shrink-0 opacity-70 hover:opacity-100">
|
|
307
|
+
<X className="size-3" />
|
|
457
308
|
</button>
|
|
458
309
|
</div>
|
|
459
310
|
) : null
|
|
460
311
|
|
|
461
312
|
if (terminal) {
|
|
462
|
-
// The CLI's own prompt, on the CLI's own grid: `>` sits in the gutter cell
|
|
463
|
-
// every transcript row's marker sits in, so what you type starts on the
|
|
464
|
-
// column what you are reading starts on. That alignment is the entire
|
|
465
|
-
// reason this is its own branch rather than the docked chrome restyled —
|
|
466
|
-
// the composer is outside the transcript's scroller, so it needs a surface
|
|
467
|
-
// of its own, at the same metrics.
|
|
468
313
|
const line = lineHeight ?? 18
|
|
469
314
|
return (
|
|
470
|
-
<div data-slot=
|
|
315
|
+
<div data-slot="composer" className={cn('shrink-0', className)}>
|
|
471
316
|
<TerminalSurface
|
|
472
317
|
{...dropHandlers}
|
|
473
318
|
fontSize={fontSize}
|
|
474
319
|
lineHeight={lineHeight}
|
|
475
320
|
affordances={affordances}
|
|
476
|
-
bleed=
|
|
321
|
+
bleed="1ch"
|
|
477
322
|
data-dragging={dragging || undefined}
|
|
478
|
-
className={cn('term-composer', disabled && 'opacity-60')}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
<div className='term-row'>
|
|
484
|
-
{/* The hidden input only — its trigger lives in the gutter. */}
|
|
323
|
+
className={cn('term-composer', disabled && 'opacity-60')}
|
|
324
|
+
>
|
|
325
|
+
<div className="term-composer-body">
|
|
326
|
+
{staged.length > 0 && attachments ? <AttachmentStrip attachments={attachments} /> : null}
|
|
327
|
+
<div className="term-row">
|
|
485
328
|
{canAttach ? fileField : null}
|
|
486
329
|
{gutter}
|
|
487
|
-
<div className=
|
|
330
|
+
<div className="flex min-w-0 items-start">
|
|
488
331
|
<PromptArea
|
|
489
332
|
{...bind}
|
|
490
333
|
triggers={triggers}
|
|
491
|
-
|
|
492
|
-
// typed is the marker that has to survive. Left on (the
|
|
493
|
-
// default), the editor rewrites `- ` to `• ` in the *model*,
|
|
494
|
-
// not just on screen — so a bulleted message reached the agent
|
|
495
|
-
// as `• item`, which is not a list in any markdown parser, and
|
|
496
|
-
// drew a glyph the character grid has no cell for. Off, the
|
|
497
|
-
// convenience stays and only the rewrite goes:
|
|
498
|
-
// `insertListContinuation` keys on `[•\-*] ` and reuses the
|
|
499
|
-
// line's own marker, so Enter after `- item` still inserts
|
|
500
|
-
// `\n- `, and Enter on an empty item still leaves the list.
|
|
334
|
+
markdown={false}
|
|
501
335
|
normalizeBullets={false}
|
|
502
336
|
onSubmit={submit}
|
|
503
337
|
disabled={disabled}
|
|
504
338
|
placeholder={disabled ? 'Session ended' : placeholder}
|
|
505
|
-
// The field's own metrics are the cell's: one row is one line,
|
|
506
|
-
// and it grows in whole lines from there.
|
|
507
339
|
minHeight={line}
|
|
508
340
|
maxHeight={line * 10}
|
|
509
|
-
aria-label=
|
|
510
|
-
className=
|
|
341
|
+
aria-label="Message the agent"
|
|
342
|
+
className="term-composer-field min-w-0 flex-1"
|
|
511
343
|
onImagePaste={(file) => attachments?.add([file])}
|
|
512
344
|
/>
|
|
513
345
|
{submitButton}
|
|
514
346
|
</div>
|
|
515
347
|
</div>
|
|
516
|
-
{/* Model and mode below the prompt, on the body column so they read
|
|
517
|
-
as a continuation of the line rather than new chrome. Absent
|
|
518
|
-
under `controlsSurface: 'external'`. */}
|
|
519
348
|
{toolbar ? (
|
|
520
|
-
<div className=
|
|
521
|
-
<span aria-hidden className=
|
|
522
|
-
<div className=
|
|
349
|
+
<div className="term-row">
|
|
350
|
+
<span aria-hidden className="term-gutter" />
|
|
351
|
+
<div className="flex min-w-0 items-center gap-[1ch]">{toolbar}</div>
|
|
523
352
|
</div>
|
|
524
353
|
) : null}
|
|
525
354
|
</div>
|
|
@@ -530,7 +359,7 @@ export function Composer({
|
|
|
530
359
|
}
|
|
531
360
|
|
|
532
361
|
return (
|
|
533
|
-
<div data-slot=
|
|
362
|
+
<div data-slot="composer" className={cn('px-[var(--wd-composer-padding)] pb-[var(--wd-composer-padding)]', className)}>
|
|
534
363
|
<div
|
|
535
364
|
{...dropHandlers}
|
|
536
365
|
className={cn(
|
|
@@ -539,32 +368,24 @@ export function Composer({
|
|
|
539
368
|
'focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30',
|
|
540
369
|
dragging && 'border-ring ring-2 ring-ring/30',
|
|
541
370
|
disabled && 'opacity-60',
|
|
542
|
-
)}
|
|
543
|
-
|
|
544
|
-
{staged.length > 0 && attachments ?
|
|
545
|
-
<AttachmentStrip attachments={attachments} />
|
|
546
|
-
) : null}
|
|
371
|
+
)}
|
|
372
|
+
>
|
|
373
|
+
{staged.length > 0 && attachments ? <AttachmentStrip attachments={attachments} /> : null}
|
|
547
374
|
{inline ? (
|
|
548
|
-
|
|
549
|
-
// buttons stay bottom-aligned as it does (`items-end`). On a single
|
|
550
|
-
// line, the 28px buttons and the 20px line-height text need matching
|
|
551
|
-
// vertical padding so their visual centres land on the same line:
|
|
552
|
-
// 4px above + 4px below the 20px text = 28px, the button height.
|
|
553
|
-
<div className='flex items-end gap-1 p-1'>
|
|
375
|
+
<div className="flex items-end gap-1 p-1">
|
|
554
376
|
{attach}
|
|
555
377
|
<PromptArea
|
|
556
378
|
{...bind}
|
|
557
379
|
triggers={triggers}
|
|
558
|
-
|
|
559
|
-
// marker, and this rewrite reaches the sent message.
|
|
380
|
+
markdown={false}
|
|
560
381
|
normalizeBullets={false}
|
|
561
382
|
onSubmit={submit}
|
|
562
383
|
disabled={disabled}
|
|
563
384
|
placeholder={disabled ? 'Session ended' : placeholder}
|
|
564
385
|
minHeight={20}
|
|
565
386
|
maxHeight={192}
|
|
566
|
-
aria-label=
|
|
567
|
-
className=
|
|
387
|
+
aria-label="Message the agent"
|
|
388
|
+
className="min-w-0 flex-1 py-1 text-body-sm text-text"
|
|
568
389
|
onImagePaste={(file) => attachments?.add([file])}
|
|
569
390
|
/>
|
|
570
391
|
{submitButton}
|
|
@@ -574,20 +395,19 @@ export function Composer({
|
|
|
574
395
|
<PromptArea
|
|
575
396
|
{...bind}
|
|
576
397
|
triggers={triggers}
|
|
577
|
-
|
|
578
|
-
// marker, and this rewrite reaches the sent message.
|
|
398
|
+
markdown={false}
|
|
579
399
|
normalizeBullets={false}
|
|
580
400
|
onSubmit={submit}
|
|
581
401
|
disabled={disabled}
|
|
582
402
|
placeholder={disabled ? 'Session ended' : placeholder}
|
|
583
403
|
minHeight={28}
|
|
584
404
|
maxHeight={192}
|
|
585
|
-
aria-label=
|
|
586
|
-
className=
|
|
405
|
+
aria-label="Message the agent"
|
|
406
|
+
className="px-3 pt-2.5 pb-0 text-body-sm text-text"
|
|
587
407
|
onImagePaste={(file) => attachments?.add([file])}
|
|
588
408
|
/>
|
|
589
|
-
<div className=
|
|
590
|
-
<div className=
|
|
409
|
+
<div className="flex items-center justify-between gap-2 px-2 pb-2">
|
|
410
|
+
<div className="flex min-w-0 items-center gap-1">
|
|
591
411
|
{attach}
|
|
592
412
|
{toolbar}
|
|
593
413
|
</div>
|
|
@@ -601,14 +421,6 @@ export function Composer({
|
|
|
601
421
|
)
|
|
602
422
|
}
|
|
603
423
|
|
|
604
|
-
/**
|
|
605
|
-
* A composer action as a **character**, for the terminal composer: no pill, no
|
|
606
|
-
* border, nothing drawn until you reach for it — the surface only appears on
|
|
607
|
-
* hover/focus, which is how a terminal's own affordances behave.
|
|
608
|
-
*
|
|
609
|
-
* One cell wide and one line tall (`term-glyph` in `terminal.css`), so it sits
|
|
610
|
-
* on the same grid as the row it shares and removing it would move nothing.
|
|
611
|
-
*/
|
|
612
424
|
function GlyphButton({
|
|
613
425
|
tone,
|
|
614
426
|
label,
|
|
@@ -622,42 +434,30 @@ function GlyphButton({
|
|
|
622
434
|
label: string
|
|
623
435
|
disabled?: boolean
|
|
624
436
|
onClick: () => void
|
|
625
|
-
/** Standing in the gutter cell rather than at the row's trailing edge, so the
|
|
626
|
-
* glyph aligns to the column's start like every transcript marker instead of
|
|
627
|
-
* centring in the 2ch cell — see `.term-glyph[data-gutter]`. */
|
|
628
437
|
gutter?: boolean
|
|
629
438
|
className?: string
|
|
630
439
|
children: ReactNode
|
|
631
440
|
}) {
|
|
632
441
|
return (
|
|
633
442
|
<button
|
|
634
|
-
type=
|
|
443
|
+
type="button"
|
|
635
444
|
aria-label={label}
|
|
636
445
|
title={label}
|
|
637
446
|
disabled={disabled}
|
|
638
447
|
onClick={onClick}
|
|
639
448
|
data-tone={tone}
|
|
640
449
|
data-gutter={gutter || undefined}
|
|
641
|
-
className={cn('term-glyph', className)}
|
|
450
|
+
className={cn('term-glyph', className)}
|
|
451
|
+
>
|
|
642
452
|
{children}
|
|
643
453
|
</button>
|
|
644
454
|
)
|
|
645
455
|
}
|
|
646
456
|
|
|
647
|
-
/** Staged files as a scrolling row of chips above the field. The thumbnail is
|
|
648
|
-
* the local blob, so nothing here waits on the network; the upload's state rides
|
|
649
|
-
* on top of it and the ✕ takes it back off. */
|
|
650
457
|
function AttachmentStrip({ attachments }: { attachments: UseAttachmentsResult }) {
|
|
651
458
|
const terminal = useTranscriptVariant() === 'terminal'
|
|
652
459
|
return (
|
|
653
|
-
<div
|
|
654
|
-
className={cn(
|
|
655
|
-
'flex gap-2 overflow-x-auto',
|
|
656
|
-
// The terminal form draws no rule of its own: the composer's frame is
|
|
657
|
-
// already directly below this strip, and two rules a few pixels apart
|
|
658
|
-
// is the box the theme has none of. Geometry in `terminal.css`.
|
|
659
|
-
terminal ? 'term-attachments' : 'border-b border-border px-2 py-2',
|
|
660
|
-
)}>
|
|
460
|
+
<div className={cn('flex gap-2 overflow-x-auto', terminal ? 'term-attachments' : 'border-b border-border px-2 py-2')}>
|
|
661
461
|
{attachments.items.map((item) => (
|
|
662
462
|
<AttachmentChip
|
|
663
463
|
key={item.key}
|
|
@@ -670,25 +470,12 @@ function AttachmentStrip({ attachments }: { attachments: UseAttachmentsResult })
|
|
|
670
470
|
)
|
|
671
471
|
}
|
|
672
472
|
|
|
673
|
-
function AttachmentChip({
|
|
674
|
-
item,
|
|
675
|
-
onRetry,
|
|
676
|
-
onRemove,
|
|
677
|
-
}: {
|
|
678
|
-
item: StagedAttachment
|
|
679
|
-
onRetry: () => void
|
|
680
|
-
onRemove: () => void
|
|
681
|
-
}) {
|
|
473
|
+
function AttachmentChip({ item, onRetry, onRemove }: { item: StagedAttachment; onRetry: () => void; onRemove: () => void }) {
|
|
682
474
|
const failed = item.status === 'failed'
|
|
683
475
|
const terminal = useTranscriptVariant() === 'terminal'
|
|
684
|
-
// Corners are the whole difference: rounded and floating in `cards`, square
|
|
685
|
-
// and inside the frame here. Kept as one flag rather than four so a thumbnail
|
|
686
|
-
// and the overlays stacked on it cannot disagree about their own shape.
|
|
687
476
|
const round = terminal ? '' : 'rounded-md'
|
|
688
477
|
return (
|
|
689
|
-
<div
|
|
690
|
-
className='group relative shrink-0'
|
|
691
|
-
title={failed ? `${item.name} — ${item.error}` : `${item.name} · ${formatBytes(item.bytes)}`}>
|
|
478
|
+
<div className="group relative shrink-0" title={failed ? `${item.name} — ${item.error}` : `${item.name} · ${formatBytes(item.bytes)}`}>
|
|
692
479
|
<div
|
|
693
480
|
className={cn(
|
|
694
481
|
'flex size-14 items-center justify-center overflow-hidden bg-surface',
|
|
@@ -696,54 +483,48 @@ function AttachmentChip({
|
|
|
696
483
|
terminal ? 'term-attachment' : 'border border-border',
|
|
697
484
|
failed && 'border-danger/50',
|
|
698
485
|
)}
|
|
699
|
-
data-failed={terminal && failed ? '' : undefined}
|
|
486
|
+
data-failed={terminal && failed ? '' : undefined}
|
|
487
|
+
>
|
|
700
488
|
{item.previewUrl ? (
|
|
701
|
-
<img src={item.previewUrl} alt={item.name} className=
|
|
489
|
+
<img src={item.previewUrl} alt={item.name} className="size-full object-cover" />
|
|
702
490
|
) : (
|
|
703
|
-
<div className=
|
|
704
|
-
<FileText className=
|
|
705
|
-
<span className=
|
|
706
|
-
{extensionOf(item.name)}
|
|
707
|
-
</span>
|
|
491
|
+
<div className="flex flex-col items-center gap-0.5 text-fg-3">
|
|
492
|
+
<FileText className="size-4" />
|
|
493
|
+
<span className="max-w-12 truncate text-[9px] font-semibold uppercase">{extensionOf(item.name)}</span>
|
|
708
494
|
</div>
|
|
709
495
|
)}
|
|
710
496
|
</div>
|
|
711
497
|
{item.status === 'uploading' ? (
|
|
712
498
|
<div className={cn('absolute inset-0 flex items-center justify-center bg-black/35', round)}>
|
|
713
|
-
<Spinner className=
|
|
499
|
+
<Spinner className="size-4 text-white" />
|
|
714
500
|
</div>
|
|
715
501
|
) : null}
|
|
716
502
|
{failed ? (
|
|
717
503
|
<button
|
|
718
|
-
type=
|
|
504
|
+
type="button"
|
|
719
505
|
onClick={onRetry}
|
|
720
506
|
aria-label={`Retry ${item.name}`}
|
|
721
|
-
className={cn(
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
)}>
|
|
725
|
-
<RotateCw className='size-4' />
|
|
507
|
+
className={cn('absolute inset-0 flex items-center justify-center bg-black/45 text-warning', round)}
|
|
508
|
+
>
|
|
509
|
+
<RotateCw className="size-4" />
|
|
726
510
|
</button>
|
|
727
511
|
) : null}
|
|
728
512
|
<button
|
|
729
|
-
type=
|
|
513
|
+
type="button"
|
|
730
514
|
onClick={onRemove}
|
|
731
515
|
aria-label={`Remove ${item.name}`}
|
|
732
516
|
className={cn(
|
|
733
517
|
'absolute flex size-4 items-center justify-center bg-surface text-fg-3 hover:text-fg-1',
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
: '-top-1 -right-1 rounded-full border border-border shadow-(--shadow-xs)',
|
|
739
|
-
)}>
|
|
740
|
-
<X className='size-2.5' />
|
|
518
|
+
terminal ? 'term-attachment-remove' : '-top-1 -right-1 rounded-full border border-border shadow-(--shadow-xs)',
|
|
519
|
+
)}
|
|
520
|
+
>
|
|
521
|
+
<X className="size-2.5" />
|
|
741
522
|
</button>
|
|
742
523
|
</div>
|
|
743
524
|
)
|
|
744
525
|
}
|
|
745
526
|
|
|
746
|
-
|
|
527
|
+
function extensionOf(name: string) {
|
|
747
528
|
const dot = name.lastIndexOf('.')
|
|
748
529
|
return dot > 0 ? name.slice(dot + 1).toUpperCase() : 'FILE'
|
|
749
530
|
}
|