@workerdeck/ui 0.7.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +615 -87
- package/build/index.mjs +6 -5081
- 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 -90
- 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 +540 -27
- 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
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import type { WorkerDeckClient } from '@workerdeck/client'
|
|
3
|
+
import type {
|
|
4
|
+
McpServerActionRequest,
|
|
5
|
+
McpServerStatusInfo,
|
|
6
|
+
McpServerToolInfo,
|
|
7
|
+
} from '@workerdeck/protocol'
|
|
8
|
+
import { ChevronLeft, ChevronRight, Power, PowerOff, RotateCw } from 'lucide-react'
|
|
9
|
+
import { Badge, type BadgeProps } from '../ui/Badge.tsx'
|
|
10
|
+
import { Button } from '../ui/Button.tsx'
|
|
11
|
+
import { Dialog, DialogBody, DialogContent, DialogHeader, DialogRow } from '../ui/Dialog.tsx'
|
|
12
|
+
import { Spinner } from '../ui/Spinner.tsx'
|
|
13
|
+
import { cn } from '../../lib/utils.ts'
|
|
14
|
+
|
|
15
|
+
export interface McpDialogProps {
|
|
16
|
+
client: WorkerDeckClient
|
|
17
|
+
sessionId: string | undefined
|
|
18
|
+
open: boolean
|
|
19
|
+
onOpenChange: (open: boolean) => void
|
|
20
|
+
/**
|
|
21
|
+
* Whether this engine can reconnect/enable/disable a server
|
|
22
|
+
* (`EngineCapabilities.mcpServerActions`). False renders the panel read-only:
|
|
23
|
+
* codex reports rich status but exposes no per-server action, and buttons
|
|
24
|
+
* that 501 are worse than buttons that aren't there.
|
|
25
|
+
*/
|
|
26
|
+
canManageServers?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The engine's status vocabulary is open — anything unrecognised renders
|
|
30
|
+
* neutrally rather than being forced into one of these. */
|
|
31
|
+
const STATUS_VARIANT: Record<string, NonNullable<BadgeProps['variant']>> = {
|
|
32
|
+
connected: 'success',
|
|
33
|
+
failed: 'danger',
|
|
34
|
+
'needs-auth': 'warning',
|
|
35
|
+
pending: 'info',
|
|
36
|
+
disabled: 'neutral',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The session's MCP servers, at the CLI's own `/mcp` depth: servers → one server
|
|
41
|
+
* → its tools → one tool, with Reconnect / Enable / Disable where they apply.
|
|
42
|
+
*
|
|
43
|
+
* Two things vary by engine rather than being fixed here. **The actions** exist
|
|
44
|
+
* only where the engine has them (`canManageServers`): codex reports rich status
|
|
45
|
+
* but has no per-server reconnect or toggle, so its panel is read-only. And
|
|
46
|
+
* **tool parameters** appear only where the engine reports a schema — codex
|
|
47
|
+
* returns each tool's full JSON Schema, the Agent SDK returns none at all, so
|
|
48
|
+
* the tool view either renders it or says why it can't, rather than leaving a
|
|
49
|
+
* silent gap or claiming the absence is universal.
|
|
50
|
+
*/
|
|
51
|
+
export function McpDialog({
|
|
52
|
+
client,
|
|
53
|
+
sessionId,
|
|
54
|
+
open,
|
|
55
|
+
onOpenChange,
|
|
56
|
+
canManageServers = true,
|
|
57
|
+
}: McpDialogProps) {
|
|
58
|
+
const [servers, setServers] = useState<McpServerStatusInfo[] | undefined>()
|
|
59
|
+
const [error, setError] = useState<string | undefined>()
|
|
60
|
+
const [loading, setLoading] = useState(false)
|
|
61
|
+
const [busyServer, setBusyServer] = useState<string | undefined>()
|
|
62
|
+
// The drill-down, by name rather than by object — an action replaces the whole
|
|
63
|
+
// list, and a held reference would go stale on the first Reconnect.
|
|
64
|
+
const [selectedServer, setSelectedServer] = useState<string | undefined>()
|
|
65
|
+
const [selectedTool, setSelectedTool] = useState<string | undefined>()
|
|
66
|
+
|
|
67
|
+
const load = useCallback(async () => {
|
|
68
|
+
if (!sessionId) return
|
|
69
|
+
setLoading(true)
|
|
70
|
+
setError(undefined)
|
|
71
|
+
try {
|
|
72
|
+
setServers(await client.listMcpServers(sessionId))
|
|
73
|
+
} catch (e) {
|
|
74
|
+
setError(e instanceof Error ? e.message : 'Could not read MCP status')
|
|
75
|
+
} finally {
|
|
76
|
+
setLoading(false)
|
|
77
|
+
}
|
|
78
|
+
}, [client, sessionId])
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!open) return
|
|
82
|
+
setSelectedServer(undefined)
|
|
83
|
+
setSelectedTool(undefined)
|
|
84
|
+
void load()
|
|
85
|
+
}, [open, load])
|
|
86
|
+
|
|
87
|
+
const act = async (name: string, action: McpServerActionRequest['action']) => {
|
|
88
|
+
if (!sessionId) return
|
|
89
|
+
setBusyServer(name)
|
|
90
|
+
setError(undefined)
|
|
91
|
+
try {
|
|
92
|
+
setServers(await client.mcpServerAction(sessionId, name, action))
|
|
93
|
+
} catch (e) {
|
|
94
|
+
setError(e instanceof Error ? e.message : `Could not ${action} ${name}`)
|
|
95
|
+
} finally {
|
|
96
|
+
setBusyServer(undefined)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const server = servers?.find((s) => s.name === selectedServer)
|
|
101
|
+
const tool = server?.tools?.find((t) => t.name === selectedTool)
|
|
102
|
+
const title = tool?.name ?? server?.name ?? 'MCP servers'
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
106
|
+
<DialogContent>
|
|
107
|
+
<DialogHeader
|
|
108
|
+
title={title}
|
|
109
|
+
description={
|
|
110
|
+
tool ? `${server?.name} tool` : server ? server.serverInfo?.name : undefined
|
|
111
|
+
}
|
|
112
|
+
actions={
|
|
113
|
+
selectedServer ? (
|
|
114
|
+
<Button
|
|
115
|
+
variant='ghost'
|
|
116
|
+
size='xs'
|
|
117
|
+
onClick={() => (selectedTool ? setSelectedTool(undefined) : setSelectedServer(undefined))}>
|
|
118
|
+
<ChevronLeft className='size-3.5' />
|
|
119
|
+
Back
|
|
120
|
+
</Button>
|
|
121
|
+
) : (
|
|
122
|
+
<Button variant='ghost' size='xs' onClick={() => void load()} disabled={loading}>
|
|
123
|
+
{loading ? <Spinner className='size-3 text-current' /> : <RotateCw className='size-3' />}
|
|
124
|
+
Refresh
|
|
125
|
+
</Button>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
/>
|
|
129
|
+
<DialogBody>
|
|
130
|
+
{error ? (
|
|
131
|
+
<div className='mb-3 rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger'>
|
|
132
|
+
{error}
|
|
133
|
+
</div>
|
|
134
|
+
) : null}
|
|
135
|
+
{tool ? (
|
|
136
|
+
<ToolView tool={tool} />
|
|
137
|
+
) : server ? (
|
|
138
|
+
<ServerView
|
|
139
|
+
server={server}
|
|
140
|
+
busy={busyServer === server.name}
|
|
141
|
+
canManage={canManageServers}
|
|
142
|
+
onAct={(action) => void act(server.name, action)}
|
|
143
|
+
onSelectTool={setSelectedTool}
|
|
144
|
+
/>
|
|
145
|
+
) : error && !servers ? (
|
|
146
|
+
// The strip above already says what went wrong. Falling through to
|
|
147
|
+
// the list here would print "No MCP servers configured" underneath
|
|
148
|
+
// it — a claim about the operator's config that a failed request
|
|
149
|
+
// gives us no standing to make.
|
|
150
|
+
null
|
|
151
|
+
) : (
|
|
152
|
+
<ServerList
|
|
153
|
+
servers={servers}
|
|
154
|
+
loading={loading}
|
|
155
|
+
onSelect={setSelectedServer}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
</DialogBody>
|
|
159
|
+
</DialogContent>
|
|
160
|
+
</Dialog>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function ServerList({
|
|
165
|
+
servers,
|
|
166
|
+
loading,
|
|
167
|
+
onSelect,
|
|
168
|
+
}: {
|
|
169
|
+
servers: McpServerStatusInfo[] | undefined
|
|
170
|
+
loading: boolean
|
|
171
|
+
onSelect: (name: string) => void
|
|
172
|
+
}) {
|
|
173
|
+
if (loading && !servers) {
|
|
174
|
+
return (
|
|
175
|
+
<div className='py-6 text-center'>
|
|
176
|
+
<Spinner className='size-4 text-fg-4' />
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
if (!servers?.length) {
|
|
181
|
+
return (
|
|
182
|
+
<p className='py-6 text-center text-body-sm text-fg-4'>
|
|
183
|
+
No MCP servers configured for this session.
|
|
184
|
+
</p>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
// Grouped by where they were configured, like the CLI's own screen.
|
|
188
|
+
const scopes = [...new Set(servers.map((s) => s.scope ?? 'other'))]
|
|
189
|
+
return (
|
|
190
|
+
<div className='flex flex-col gap-4'>
|
|
191
|
+
{scopes.map((scope) => (
|
|
192
|
+
<div key={scope}>
|
|
193
|
+
<h3 className='text-label font-medium text-fg-3 capitalize'>{scope}</h3>
|
|
194
|
+
<ul className='mt-1 flex flex-col'>
|
|
195
|
+
{servers
|
|
196
|
+
.filter((s) => (s.scope ?? 'other') === scope)
|
|
197
|
+
.map((s) => (
|
|
198
|
+
<li key={s.name}>
|
|
199
|
+
<button
|
|
200
|
+
type='button'
|
|
201
|
+
onClick={() => onSelect(s.name)}
|
|
202
|
+
className='flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-surface-hover'>
|
|
203
|
+
<span className='min-w-0 flex-1 truncate text-body-sm text-fg-1'>{s.name}</span>
|
|
204
|
+
{s.tools?.length ? (
|
|
205
|
+
<span className='shrink-0 text-label text-fg-4'>{s.tools.length} tools</span>
|
|
206
|
+
) : null}
|
|
207
|
+
<Badge variant={STATUS_VARIANT[s.status] ?? 'neutral'} dot className='shrink-0'>
|
|
208
|
+
{s.status}
|
|
209
|
+
</Badge>
|
|
210
|
+
<ChevronRight className='size-3.5 shrink-0 text-fg-4' />
|
|
211
|
+
</button>
|
|
212
|
+
</li>
|
|
213
|
+
))}
|
|
214
|
+
</ul>
|
|
215
|
+
</div>
|
|
216
|
+
))}
|
|
217
|
+
</div>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function ServerView({
|
|
222
|
+
server,
|
|
223
|
+
busy,
|
|
224
|
+
canManage,
|
|
225
|
+
onAct,
|
|
226
|
+
onSelectTool,
|
|
227
|
+
}: {
|
|
228
|
+
server: McpServerStatusInfo
|
|
229
|
+
busy: boolean
|
|
230
|
+
canManage: boolean
|
|
231
|
+
onAct: (action: McpServerActionRequest['action']) => void
|
|
232
|
+
onSelectTool: (name: string) => void
|
|
233
|
+
}) {
|
|
234
|
+
const disabled = server.status === 'disabled'
|
|
235
|
+
return (
|
|
236
|
+
<div className='flex flex-col gap-4'>
|
|
237
|
+
<div>
|
|
238
|
+
<DialogRow label='Status'>
|
|
239
|
+
<Badge variant={STATUS_VARIANT[server.status] ?? 'neutral'} dot>
|
|
240
|
+
{server.status}
|
|
241
|
+
</Badge>
|
|
242
|
+
</DialogRow>
|
|
243
|
+
{server.transport ? <DialogRow label='Transport'>{server.transport}</DialogRow> : null}
|
|
244
|
+
{server.scope ? <DialogRow label='Scope'>{server.scope}</DialogRow> : null}
|
|
245
|
+
{server.serverInfo ? (
|
|
246
|
+
<DialogRow label='Server'>
|
|
247
|
+
{server.serverInfo.name} {server.serverInfo.version}
|
|
248
|
+
</DialogRow>
|
|
249
|
+
) : null}
|
|
250
|
+
{server.command ? (
|
|
251
|
+
<DialogRow label='Command' mono>
|
|
252
|
+
{[server.command, ...(server.args ?? [])].join(' ')}
|
|
253
|
+
</DialogRow>
|
|
254
|
+
) : null}
|
|
255
|
+
{server.url ? (
|
|
256
|
+
<DialogRow label='URL' mono>
|
|
257
|
+
{server.url}
|
|
258
|
+
</DialogRow>
|
|
259
|
+
) : null}
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{server.error ? (
|
|
263
|
+
<div className='rounded-md bg-danger-bg px-3 py-2 text-body-sm break-words text-danger'>
|
|
264
|
+
{server.error}
|
|
265
|
+
</div>
|
|
266
|
+
) : null}
|
|
267
|
+
|
|
268
|
+
{/* Absent, not disabled, when the engine has no per-server action: a
|
|
269
|
+
greyed-out Reconnect invites the question "why can't I?" on every
|
|
270
|
+
visit, where nothing at all reads as "this engine works differently". */}
|
|
271
|
+
{canManage ? (
|
|
272
|
+
<div className='flex gap-2'>
|
|
273
|
+
<Button variant='outline' size='sm' disabled={busy} onClick={() => onAct('reconnect')}>
|
|
274
|
+
{busy ? <Spinner className='size-3 text-current' /> : <RotateCw className='size-3' />}
|
|
275
|
+
Reconnect
|
|
276
|
+
</Button>
|
|
277
|
+
<Button
|
|
278
|
+
variant='outline'
|
|
279
|
+
size='sm'
|
|
280
|
+
disabled={busy}
|
|
281
|
+
onClick={() => onAct(disabled ? 'enable' : 'disable')}>
|
|
282
|
+
{disabled ? <Power className='size-3' /> : <PowerOff className='size-3' />}
|
|
283
|
+
{disabled ? 'Enable' : 'Disable'}
|
|
284
|
+
</Button>
|
|
285
|
+
</div>
|
|
286
|
+
) : null}
|
|
287
|
+
|
|
288
|
+
<div>
|
|
289
|
+
<h3 className='text-label font-medium text-fg-3'>Tools</h3>
|
|
290
|
+
{!server.tools?.length ? (
|
|
291
|
+
<p className='py-2 text-body-sm text-fg-4'>
|
|
292
|
+
{server.status === 'connected'
|
|
293
|
+
? 'This server exposes no tools.'
|
|
294
|
+
: 'Tools are listed once the server connects.'}
|
|
295
|
+
</p>
|
|
296
|
+
) : (
|
|
297
|
+
<ul className='mt-1 flex flex-col'>
|
|
298
|
+
{server.tools.map((t) => (
|
|
299
|
+
<li key={t.name}>
|
|
300
|
+
<button
|
|
301
|
+
type='button'
|
|
302
|
+
onClick={() => onSelectTool(t.name)}
|
|
303
|
+
className='flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover'>
|
|
304
|
+
<span className='min-w-0 flex-1 truncate font-mono text-label text-fg-1'>
|
|
305
|
+
{t.name}
|
|
306
|
+
</span>
|
|
307
|
+
<ChevronRight className='size-3.5 shrink-0 text-fg-4' />
|
|
308
|
+
</button>
|
|
309
|
+
</li>
|
|
310
|
+
))}
|
|
311
|
+
</ul>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function ToolView({ tool }: { tool: McpServerToolInfo }) {
|
|
319
|
+
const annotations = tool.annotations
|
|
320
|
+
return (
|
|
321
|
+
<div className='flex flex-col gap-3'>
|
|
322
|
+
{tool.description ? (
|
|
323
|
+
<p className='text-body-sm whitespace-pre-wrap text-fg-2'>{tool.description}</p>
|
|
324
|
+
) : (
|
|
325
|
+
<p className='text-body-sm text-fg-4'>This tool carries no description.</p>
|
|
326
|
+
)}
|
|
327
|
+
{annotations ? (
|
|
328
|
+
<div className='flex flex-wrap gap-1.5'>
|
|
329
|
+
{annotations.readOnly ? <Badge variant='success'>read-only</Badge> : null}
|
|
330
|
+
{annotations.destructive ? <Badge variant='danger'>destructive</Badge> : null}
|
|
331
|
+
{annotations.openWorld ? <Badge variant='warning'>open world</Badge> : null}
|
|
332
|
+
</div>
|
|
333
|
+
) : null}
|
|
334
|
+
{/* Engine-dependent, and said as such: codex returns each tool's full
|
|
335
|
+
JSON Schema, the Agent SDK returns none at all. So this is a real
|
|
336
|
+
section where one exists and an explanation where it doesn't — never a
|
|
337
|
+
silent gap, and never a claim that no engine has them. */}
|
|
338
|
+
{tool.inputSchema !== undefined ? (
|
|
339
|
+
<div>
|
|
340
|
+
<h3 className='text-label font-medium text-fg-3'>Parameters</h3>
|
|
341
|
+
<pre className='mt-1 max-h-64 overflow-auto rounded-md bg-surface px-3 py-2 text-label text-fg-2'>
|
|
342
|
+
{safeSchema(tool.inputSchema)}
|
|
343
|
+
</pre>
|
|
344
|
+
</div>
|
|
345
|
+
) : (
|
|
346
|
+
<p className={cn('text-label text-fg-4')}>
|
|
347
|
+
Parameters aren’t available: this engine names and describes each tool but reports no
|
|
348
|
+
input schema.
|
|
349
|
+
</p>
|
|
350
|
+
)}
|
|
351
|
+
</div>
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The schema is an opaque JSON document from another process — pretty-print it,
|
|
356
|
+
* and never let an unserializable value take the dialog down with it. */
|
|
357
|
+
function safeSchema(schema: unknown): string {
|
|
358
|
+
try {
|
|
359
|
+
return JSON.stringify(schema, null, 2)
|
|
360
|
+
} catch {
|
|
361
|
+
return String(schema)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -1,41 +1,75 @@
|
|
|
1
1
|
import type { HTMLAttributes } from 'react'
|
|
2
2
|
import { cn } from '../../lib/utils.ts'
|
|
3
|
+
import { LINE_TEXT, LineGlyph, useLines } from './transcript-variant.tsx'
|
|
3
4
|
|
|
4
5
|
export interface MessageProps extends HTMLAttributes<HTMLDivElement> {
|
|
5
6
|
from: 'user' | 'assistant'
|
|
6
7
|
}
|
|
7
8
|
|
|
8
|
-
/**
|
|
9
|
-
*
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* One chat turn row.
|
|
11
|
+
*
|
|
12
|
+
* `cards`: user messages sit right in a bubble; assistant content is flat,
|
|
13
|
+
* full-width (the AI-chat convention — assistant output is the page, user input
|
|
14
|
+
* is quoted).
|
|
15
|
+
*
|
|
16
|
+
* `lines`: both are left-aligned full-width line items behind a gutter glyph —
|
|
17
|
+
* `❯` for what was typed, `●` for what the model said. No bubble: a prompt is
|
|
18
|
+
* already distinguishable by its marker, and the bubble's padding is vertical
|
|
19
|
+
* space the terminal treatment refuses to spend.
|
|
20
|
+
*/
|
|
21
|
+
export function Message({ from, className, children, ...props }: MessageProps) {
|
|
22
|
+
const lines = useLines()
|
|
11
23
|
return (
|
|
12
24
|
<div
|
|
13
25
|
data-slot='message'
|
|
14
26
|
data-from={from}
|
|
15
27
|
className={cn(
|
|
16
|
-
'flex w-full
|
|
17
|
-
|
|
28
|
+
'flex w-full',
|
|
29
|
+
lines
|
|
30
|
+
? cn(
|
|
31
|
+
'flex-row gap-2',
|
|
32
|
+
// What YOU said, on a band of its own — the CLI's own treatment.
|
|
33
|
+
// Full-bleed (the negative margin cancels the row's padding) so
|
|
34
|
+
// the band lines up with the hover highlight rather than sitting
|
|
35
|
+
// inside it, and square-ish so it reads as a strip, not a bubble.
|
|
36
|
+
from === 'user' && '-mx-1 rounded-sm bg-surface px-1',
|
|
37
|
+
)
|
|
38
|
+
: cn('flex-col gap-1', from === 'user' ? 'items-end' : 'items-start'),
|
|
18
39
|
className,
|
|
19
40
|
)}
|
|
20
|
-
{...props}
|
|
21
|
-
|
|
41
|
+
{...props}>
|
|
42
|
+
{lines ? (
|
|
43
|
+
<>
|
|
44
|
+
<LineGlyph className={from === 'user' ? 'text-accent' : 'text-fg-3'}>
|
|
45
|
+
{from === 'user' ? '❯' : '●'}
|
|
46
|
+
</LineGlyph>
|
|
47
|
+
<div className='flex min-w-0 flex-1 flex-col gap-1'>{children}</div>
|
|
48
|
+
</>
|
|
49
|
+
) : (
|
|
50
|
+
children
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
22
53
|
)
|
|
23
54
|
}
|
|
24
55
|
|
|
25
|
-
export function MessageContent({
|
|
26
|
-
|
|
27
|
-
...props
|
|
28
|
-
}: HTMLAttributes<HTMLDivElement>) {
|
|
56
|
+
export function MessageContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
57
|
+
const lines = useLines()
|
|
29
58
|
return (
|
|
30
59
|
<div
|
|
31
60
|
data-slot='message-content'
|
|
32
61
|
className={cn(
|
|
33
|
-
'min-w-0
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
62
|
+
'min-w-0',
|
|
63
|
+
lines
|
|
64
|
+
? cn('w-full text-fg-1', LINE_TEXT, 'in-data-[from=user]:whitespace-pre-wrap')
|
|
65
|
+
: cn(
|
|
66
|
+
'text-body-sm leading-6 text-fg-1',
|
|
67
|
+
// Bubble treatment only within a user message row.
|
|
68
|
+
'in-data-[from=user]:max-w-[85%] in-data-[from=user]:rounded-lg in-data-[from=user]:rounded-br-sm',
|
|
69
|
+
'in-data-[from=user]:bg-accent-bg in-data-[from=user]:px-3 in-data-[from=user]:py-2',
|
|
70
|
+
'in-data-[from=user]:whitespace-pre-wrap',
|
|
71
|
+
'in-data-[from=assistant]:w-full',
|
|
72
|
+
),
|
|
39
73
|
className,
|
|
40
74
|
)}
|
|
41
75
|
{...props}
|
|
@@ -29,15 +29,43 @@ export interface ModelSelectProps {
|
|
|
29
29
|
* is a sentinel, not a model id — selecting it means "clear the override". */
|
|
30
30
|
const isDefaultOption = (value: string) => value === 'default'
|
|
31
31
|
|
|
32
|
+
/** Everything before a '[1m]'-style context-window suffix. */
|
|
33
|
+
const dropVariant = (id: string) => id.replace(/\[.*\]$/, '')
|
|
34
|
+
|
|
35
|
+
/** 'claude-opus-4-8' → "opus", 'sonnet' → "sonnet". The vendor prefix and the
|
|
36
|
+
* version tail are dropped; what is left is the name a person would say. */
|
|
37
|
+
function family(id: string): string {
|
|
38
|
+
const parts = id.toLowerCase().split('-')
|
|
39
|
+
if (parts[0] === 'claude') parts.shift()
|
|
40
|
+
return parts[0] ?? ''
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether a row is the one naming `model`.
|
|
45
|
+
*
|
|
46
|
+
* Three passes, narrowest first, because the rows and the id a session *reports*
|
|
47
|
+
* are written differently: the rows are aliases ('opus[1m]', 'sonnet',
|
|
48
|
+
* 'claude-fable-5[1m]') and a session reports a resolved wire id
|
|
49
|
+
* ('claude-opus-5[1m]'). `resolvedModel` is the server's own answer to this and
|
|
50
|
+
* wins when present; the family fallback covers a server that doesn't send it,
|
|
51
|
+
* which is the difference between the chip reading "Opus 5" and reading
|
|
52
|
+
* `claude-opus-5[1m]`. Kept identical to the iOS client's `ModelOption.matches`.
|
|
53
|
+
*/
|
|
54
|
+
function optionMatches(option: ModelOption, model: string): boolean {
|
|
55
|
+
if (model === option.value || model === option.resolvedModel) return true
|
|
56
|
+
const stripped = dropVariant(model)
|
|
57
|
+
// A row that declares what it resolves to is *authoritative*, including when
|
|
58
|
+
// it disagrees: two rows of the same family ("Opus 5" and "Opus 4.8") differ
|
|
59
|
+
// only here, so falling through to the family would match both.
|
|
60
|
+
if (option.resolvedModel) return stripped === dropVariant(option.resolvedModel)
|
|
61
|
+
const token = family(stripped)
|
|
62
|
+
return token !== '' && token === family(dropVariant(option.value))
|
|
63
|
+
}
|
|
64
|
+
|
|
32
65
|
/** Find the option matching a (possibly decorated/aliased) session model id. */
|
|
33
66
|
function matchModel(models: ModelOption[], model?: string): ModelOption | undefined {
|
|
34
67
|
if (!model) return undefined
|
|
35
|
-
|
|
36
|
-
const concrete = models.filter((m) => !isDefaultOption(m.value))
|
|
37
|
-
return (
|
|
38
|
-
concrete.find((m) => m.value === normalized) ??
|
|
39
|
-
concrete.find((m) => normalized.includes(m.value) || m.value.includes(normalized))
|
|
40
|
-
)
|
|
68
|
+
return models.filter((m) => !isDefaultOption(m.value)).find((m) => optionMatches(m, model))
|
|
41
69
|
}
|
|
42
70
|
|
|
43
71
|
/** Compact model switcher for the composer toolbar; fed by the `capabilities` event.
|