@workerdeck/ui 0.9.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +81 -3
  2. package/build/SessionPanel-Dy9lQrOV.d.mts +319 -0
  3. package/build/SessionPanel-NQ8ksCfj.mjs +8474 -0
  4. package/build/SessionPanel-NQ8ksCfj.mjs.map +1 -0
  5. package/build/format-DqR56Y8l.mjs +162 -0
  6. package/build/format-DqR56Y8l.mjs.map +1 -0
  7. package/build/format-ljc3lKpA.d.mts +59 -0
  8. package/build/format.d.mts +66 -0
  9. package/build/format.mjs +119 -0
  10. package/build/format.mjs.map +1 -0
  11. package/build/index.d.mts +671 -88
  12. package/build/index.mjs +387 -5160
  13. package/build/index.mjs.map +1 -1
  14. package/build/workspace.d.mts +226 -0
  15. package/build/workspace.mjs +861 -0
  16. package/build/workspace.mjs.map +1 -0
  17. package/package.json +22 -4
  18. package/src/components/agent/CodeEditor.tsx +300 -0
  19. package/src/components/agent/Composer.tsx +522 -87
  20. package/src/components/agent/ContextDialog.tsx +99 -0
  21. package/src/components/agent/Conversation.tsx +11 -3
  22. package/src/components/agent/EditorTabs.tsx +165 -0
  23. package/src/components/agent/FileCard.tsx +26 -0
  24. package/src/components/agent/FileTree.tsx +287 -0
  25. package/src/components/agent/FileViewer.tsx +148 -0
  26. package/src/components/agent/HostFilesDialog.tsx +218 -0
  27. package/src/components/agent/Loader.tsx +82 -14
  28. package/src/components/agent/McpDialog.tsx +363 -0
  29. package/src/components/agent/Message.tsx +51 -17
  30. package/src/components/agent/ModelSelect.tsx +34 -6
  31. package/src/components/agent/PermissionModeSelect.tsx +133 -22
  32. package/src/components/agent/PermissionPrompt.tsx +164 -6
  33. package/src/components/agent/PromptTokenText.tsx +39 -0
  34. package/src/components/agent/QuestionPrompt.tsx +122 -0
  35. package/src/components/agent/Reasoning.tsx +20 -5
  36. package/src/components/agent/Response.tsx +128 -0
  37. package/src/components/agent/SessionBrowser.tsx +428 -0
  38. package/src/components/agent/SessionEmptyState.tsx +65 -0
  39. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  40. package/src/components/agent/SessionPanel.tsx +783 -91
  41. package/src/components/agent/SessionWorkspace.tsx +317 -0
  42. package/src/components/agent/SkillsDialog.tsx +195 -0
  43. package/src/components/agent/StatusBar.tsx +85 -18
  44. package/src/components/agent/ToolCallCard.tsx +252 -30
  45. package/src/components/agent/Transcript.tsx +513 -30
  46. package/src/components/agent/UsageDialog.tsx +168 -0
  47. package/src/components/agent/line-prompt.tsx +249 -0
  48. package/src/components/agent/pulse.tsx +60 -0
  49. package/src/components/agent/transcript-variant.tsx +123 -0
  50. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  51. package/src/components/prompt-area/types.ts +15 -0
  52. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  53. package/src/components/ui/CodeBlock.tsx +40 -2
  54. package/src/components/ui/CopyButton.tsx +28 -3
  55. package/src/components/ui/Dialog.tsx +92 -0
  56. package/src/components/ui/Menu.tsx +55 -0
  57. package/src/components/ui/Splitter.tsx +133 -0
  58. package/src/components/ui/Tooltip.tsx +22 -5
  59. package/src/format.ts +11 -0
  60. package/src/index.ts +67 -2
  61. package/src/lib/clipboard.ts +56 -0
  62. package/src/lib/format.ts +114 -0
  63. package/src/lib/status.ts +124 -0
  64. package/src/lib/tool-icon.ts +96 -0
  65. 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
- /** One chat turn row. User messages sit right in a bubble; assistant content is flat,
9
- * full-width (the AI-chat convention — assistant output is the page, user input is quoted). */
10
- export function Message({ from, className, ...props }: MessageProps) {
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 flex-col gap-1',
17
- from === 'user' ? 'items-end' : 'items-start',
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
- className,
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 text-body-sm leading-6 text-fg-1',
34
- // Bubble treatment only within a user message row.
35
- 'in-data-[from=user]:max-w-[85%] in-data-[from=user]:rounded-lg in-data-[from=user]:rounded-br-sm',
36
- 'in-data-[from=user]:bg-accent-bg in-data-[from=user]:px-3 in-data-[from=user]:py-2',
37
- 'in-data-[from=user]:whitespace-pre-wrap',
38
- 'in-data-[from=assistant]:w-full',
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
- const normalized = model.replace(/\[.*\]$/, '')
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.