@workerdeck/ui 0.7.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 +767 -21
- package/build/index.mjs +3268 -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 +380 -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 +109 -12
- 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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import type { WorkerDeckClient } from '@workerdeck/client'
|
|
3
|
+
import type { HostDirEntry, HostFileMatch } from '@workerdeck/protocol'
|
|
4
|
+
import { ChevronLeft, File, Folder, Link2, Search } from 'lucide-react'
|
|
5
|
+
import { Button } from '../ui/Button.tsx'
|
|
6
|
+
import { CodeBlock } from '../ui/CodeBlock.tsx'
|
|
7
|
+
import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
|
|
8
|
+
import { Input } from '../ui/Input.tsx'
|
|
9
|
+
import { Spinner } from '../ui/Spinner.tsx'
|
|
10
|
+
import { formatBytes } from '../../lib/format.ts'
|
|
11
|
+
|
|
12
|
+
export interface HostFilesDialogProps {
|
|
13
|
+
client: WorkerDeckClient
|
|
14
|
+
/** The session's working directory — the browser is rooted here. */
|
|
15
|
+
cwd: string | undefined
|
|
16
|
+
open: boolean
|
|
17
|
+
onOpenChange: (open: boolean) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Browse the project the session is working in.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately rooted at the session's cwd rather than at the server's
|
|
24
|
+
* `hostFiles.roots`: the roots are the *security* boundary (the server enforces
|
|
25
|
+
* them on every request), but what someone wants while watching an agent is this
|
|
26
|
+
* project's tree. Read-only — writing is a separate server opt-in and not
|
|
27
|
+
* something a session viewer should be doing behind the agent's back.
|
|
28
|
+
*/
|
|
29
|
+
export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDialogProps) {
|
|
30
|
+
const [path, setPath] = useState<string | undefined>(cwd)
|
|
31
|
+
const [entries, setEntries] = useState<HostDirEntry[]>([])
|
|
32
|
+
const [truncated, setTruncated] = useState(false)
|
|
33
|
+
const [query, setQuery] = useState('')
|
|
34
|
+
const [matches, setMatches] = useState<HostFileMatch[] | undefined>()
|
|
35
|
+
const [file, setFile] = useState<{ path: string; content: string; bytes: number } | undefined>()
|
|
36
|
+
const [loading, setLoading] = useState(false)
|
|
37
|
+
const [error, setError] = useState<string | undefined>()
|
|
38
|
+
|
|
39
|
+
const list = useCallback(
|
|
40
|
+
async (target: string) => {
|
|
41
|
+
setLoading(true)
|
|
42
|
+
setError(undefined)
|
|
43
|
+
try {
|
|
44
|
+
const response = await client.listHostDir(target)
|
|
45
|
+
setPath(response.path)
|
|
46
|
+
setEntries(response.entries)
|
|
47
|
+
setTruncated(response.truncated ?? false)
|
|
48
|
+
} catch (e) {
|
|
49
|
+
setError(e instanceof Error ? e.message : 'Could not read that directory')
|
|
50
|
+
} finally {
|
|
51
|
+
setLoading(false)
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
[client],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// Opening resets the browser to the session's directory. Navigation from here
|
|
58
|
+
// is explicit (`list`) rather than an effect on `path`, so walking into a
|
|
59
|
+
// folder is one request and not two.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!open) return
|
|
62
|
+
setFile(undefined)
|
|
63
|
+
setQuery('')
|
|
64
|
+
setMatches(undefined)
|
|
65
|
+
setError(undefined)
|
|
66
|
+
if (cwd) void list(cwd)
|
|
67
|
+
}, [open, cwd, list])
|
|
68
|
+
|
|
69
|
+
// Debounced, and only while there is something to search for; an empty box is
|
|
70
|
+
// "show me the directory again", not "search for everything".
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!open || !cwd) return
|
|
73
|
+
const q = query.trim()
|
|
74
|
+
if (!q) {
|
|
75
|
+
setMatches(undefined)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
client
|
|
80
|
+
.findHostFiles(cwd, q, 40)
|
|
81
|
+
.then((response) => setMatches(response.matches))
|
|
82
|
+
.catch(() => setMatches([]))
|
|
83
|
+
}, 150)
|
|
84
|
+
return () => clearTimeout(timer)
|
|
85
|
+
}, [client, cwd, query, open])
|
|
86
|
+
|
|
87
|
+
const openFile = async (target: string) => {
|
|
88
|
+
setLoading(true)
|
|
89
|
+
setError(undefined)
|
|
90
|
+
try {
|
|
91
|
+
const response = await client.readHostFile(target)
|
|
92
|
+
setFile({
|
|
93
|
+
path: response.path,
|
|
94
|
+
bytes: response.bytes,
|
|
95
|
+
content:
|
|
96
|
+
response.encoding === 'utf8'
|
|
97
|
+
? response.content
|
|
98
|
+
: '(binary file — not shown)',
|
|
99
|
+
})
|
|
100
|
+
} catch (e) {
|
|
101
|
+
setError(e instanceof Error ? e.message : 'Could not read that file')
|
|
102
|
+
} finally {
|
|
103
|
+
setLoading(false)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const parent = path && cwd && path !== cwd ? path.slice(0, path.lastIndexOf('/')) || '/' : undefined
|
|
108
|
+
const shown = matches ?? entries
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
112
|
+
<DialogContent size='lg'>
|
|
113
|
+
<DialogHeader
|
|
114
|
+
title={file ? file.path.split('/').pop()! : 'Files'}
|
|
115
|
+
description={file ? file.path : path}
|
|
116
|
+
actions={
|
|
117
|
+
file ? (
|
|
118
|
+
<Button variant='ghost' size='xs' onClick={() => setFile(undefined)}>
|
|
119
|
+
<ChevronLeft className='size-3.5' />
|
|
120
|
+
Back
|
|
121
|
+
</Button>
|
|
122
|
+
) : parent ? (
|
|
123
|
+
<Button variant='ghost' size='xs' onClick={() => void list(parent)}>
|
|
124
|
+
<ChevronLeft className='size-3.5' />
|
|
125
|
+
Up
|
|
126
|
+
</Button>
|
|
127
|
+
) : null
|
|
128
|
+
}
|
|
129
|
+
/>
|
|
130
|
+
<DialogBody className='flex flex-col gap-3'>
|
|
131
|
+
{error ? (
|
|
132
|
+
<div className='rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger'>{error}</div>
|
|
133
|
+
) : null}
|
|
134
|
+
|
|
135
|
+
{file ? (
|
|
136
|
+
<>
|
|
137
|
+
<p className='text-label text-fg-4'>{formatBytes(file.bytes)}</p>
|
|
138
|
+
<CodeBlock code={file.content} label={file.path.split('/').pop()} />
|
|
139
|
+
</>
|
|
140
|
+
) : (
|
|
141
|
+
<>
|
|
142
|
+
<div className='relative'>
|
|
143
|
+
<Search className='absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-fg-4' />
|
|
144
|
+
<Input
|
|
145
|
+
value={query}
|
|
146
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
147
|
+
placeholder='Search this project…'
|
|
148
|
+
className='pl-7'
|
|
149
|
+
spellCheck={false}
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
{loading && shown.length === 0 ? (
|
|
153
|
+
<div className='py-6 text-center'>
|
|
154
|
+
<Spinner className='size-4 text-fg-4' />
|
|
155
|
+
</div>
|
|
156
|
+
) : shown.length === 0 ? (
|
|
157
|
+
<p className='py-6 text-center text-body-sm text-fg-4'>
|
|
158
|
+
{matches ? 'No matching files.' : 'This directory is empty.'}
|
|
159
|
+
</p>
|
|
160
|
+
) : (
|
|
161
|
+
<ul className='flex flex-col'>
|
|
162
|
+
{matches
|
|
163
|
+
? matches.map((match) => (
|
|
164
|
+
<li key={match.path}>
|
|
165
|
+
<button
|
|
166
|
+
type='button'
|
|
167
|
+
onClick={() => void openFile(match.path)}
|
|
168
|
+
className='flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover'>
|
|
169
|
+
<File className='size-3.5 shrink-0 text-fg-4' />
|
|
170
|
+
<span className='min-w-0 flex-1 truncate font-mono text-label text-fg-1'>
|
|
171
|
+
{match.relative}
|
|
172
|
+
</span>
|
|
173
|
+
</button>
|
|
174
|
+
</li>
|
|
175
|
+
))
|
|
176
|
+
: entries.map((entry) => (
|
|
177
|
+
<li key={entry.path}>
|
|
178
|
+
<button
|
|
179
|
+
type='button'
|
|
180
|
+
onClick={() =>
|
|
181
|
+
entry.type === 'dir' ? void list(entry.path) : void openFile(entry.path)
|
|
182
|
+
}
|
|
183
|
+
className='flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover'>
|
|
184
|
+
<EntryIcon type={entry.type} />
|
|
185
|
+
<span className='min-w-0 flex-1 truncate font-mono text-label text-fg-1'>
|
|
186
|
+
{entry.name}
|
|
187
|
+
{entry.type === 'dir' ? '/' : ''}
|
|
188
|
+
</span>
|
|
189
|
+
{entry.bytes !== undefined ? (
|
|
190
|
+
<span className='shrink-0 text-label text-fg-4'>
|
|
191
|
+
{formatBytes(entry.bytes)}
|
|
192
|
+
</span>
|
|
193
|
+
) : null}
|
|
194
|
+
</button>
|
|
195
|
+
</li>
|
|
196
|
+
))}
|
|
197
|
+
</ul>
|
|
198
|
+
)}
|
|
199
|
+
{truncated && !matches ? (
|
|
200
|
+
<p className='text-label text-fg-4'>
|
|
201
|
+
More entries than the server will return — use the search box.
|
|
202
|
+
</p>
|
|
203
|
+
) : null}
|
|
204
|
+
</>
|
|
205
|
+
)}
|
|
206
|
+
</DialogBody>
|
|
207
|
+
</DialogContent>
|
|
208
|
+
</Dialog>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A symlink is reported as itself and never silently resolved — following it is
|
|
213
|
+
* the next request's problem, and that request is refused if it escapes the roots. */
|
|
214
|
+
function EntryIcon({ type }: { type: HostDirEntry['type'] }) {
|
|
215
|
+
if (type === 'dir') return <Folder className='size-3.5 shrink-0 text-accent' />
|
|
216
|
+
if (type === 'symlink') return <Link2 className='size-3.5 shrink-0 text-fg-4' />
|
|
217
|
+
return <File className='size-3.5 shrink-0 text-fg-4' />
|
|
218
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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.
|