@workerdeck/ui 2.15.0 → 3.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/README.md +6 -5
- package/build/{SessionPanel-BVKqPs_3.d.mts → SessionPanel-BNNc8grK.d.mts} +18 -16
- package/build/index.d.mts +194 -49
- package/build/index.mjs +562 -260
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +170 -21
- package/build/{use-path-links-BgWXmfAS.mjs → use-path-links-C6Fh5zdx.mjs} +3312 -2758
- package/build/use-path-links-C6Fh5zdx.mjs.map +1 -0
- package/build/workspace.d.mts +3 -3
- package/build/workspace.mjs +3 -3
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/ApprovalPrompts.tsx +87 -0
- package/src/components/agent/Composer.tsx +25 -47
- package/src/components/agent/ContextDialog.tsx +75 -67
- package/src/components/agent/FrameStrip.tsx +74 -0
- package/src/components/agent/HostFilesDialog.tsx +3 -2
- package/src/components/agent/McpDialog.tsx +112 -78
- package/src/components/agent/PermissionPrompt.tsx +22 -17
- package/src/components/agent/QuestionPrompt.tsx +6 -41
- package/src/components/agent/Response.tsx +8 -1
- package/src/components/agent/Scrubber.tsx +6 -75
- package/src/components/agent/SessionBrowser.tsx +131 -214
- package/src/components/agent/SessionFilters.tsx +266 -0
- package/src/components/agent/SessionInfoDialog.tsx +47 -63
- package/src/components/agent/SessionItem.tsx +29 -6
- package/src/components/agent/SessionPanel.tsx +249 -498
- package/src/components/agent/SessionSteps.tsx +98 -14
- package/src/components/agent/SessionWorkspace.tsx +3 -3
- package/src/components/agent/ShellStrip.tsx +43 -36
- package/src/components/agent/ShellTerminal.tsx +1 -1
- package/src/components/agent/SubagentStrip.tsx +13 -30
- package/src/components/agent/ToolCallCard.tsx +18 -41
- package/src/components/agent/Transcript.tsx +111 -93
- package/src/components/agent/TranscriptItemView.tsx +80 -67
- package/src/components/agent/TranscriptRows.tsx +4 -2
- package/src/components/agent/composer-commands.ts +80 -1
- package/src/components/agent/image-viewer.tsx +326 -0
- package/src/components/agent/markdown-image.tsx +139 -0
- package/src/components/agent/scrubber-marks.ts +33 -11
- package/src/components/agent/scrubber-peek.tsx +122 -0
- package/src/components/agent/session-panel-providers.tsx +41 -0
- package/src/components/agent/shell-actions.tsx +38 -1
- package/src/components/agent/tool-result-image.tsx +40 -0
- package/src/components/agent/transcript-variant.tsx +3 -21
- package/src/components/agent/use-catch-up.ts +20 -0
- package/src/components/agent/use-frame.ts +88 -0
- package/src/components/agent/use-host-image.ts +81 -0
- package/src/components/agent/use-shell-frame.ts +12 -70
- package/src/components/agent/use-subagent-frame.ts +19 -75
- package/src/components/prompt-area/prompt-area-engine.ts +21 -20
- package/src/components/prompt-area/prompt-area-list-ops.ts +12 -15
- package/src/components/prompt-area/use-prompt-area-keydown.ts +4 -2
- package/src/components/terminal/PermissionPrompt.tsx +31 -28
- package/src/components/terminal/QuestionPrompt.tsx +10 -46
- package/src/components/terminal/TerminalTranscript.tsx +23 -89
- package/src/components/terminal/affordances.tsx +100 -74
- package/src/components/terminal/height.ts +48 -32
- package/src/components/terminal/image-box.ts +31 -0
- package/src/components/terminal/items.tsx +70 -30
- package/src/components/terminal/markdown.tsx +4 -1
- package/src/components/terminal/scrubber.tsx +25 -272
- package/src/components/terminal/shell-row.ts +17 -3
- package/src/components/ui/Popover.tsx +29 -0
- package/src/components/ui/Select.tsx +25 -0
- package/src/index.ts +65 -12
- package/src/lib/file-link.ts +3 -16
- package/src/lib/permission-prompt.ts +57 -0
- package/src/lib/question-answers.ts +65 -0
- package/src/lib/shell-request.ts +115 -0
- package/src/lib/tool-icon.ts +13 -1
- package/src/styles/terminal.css +129 -22
- package/src/styles/theme.css +4 -0
- package/build/use-path-links-BgWXmfAS.mjs.map +0 -1
- package/src/components/prompt-area/use-markdown-mode.ts +0 -103
|
@@ -14,81 +14,89 @@ export interface ContextDialogProps {
|
|
|
14
14
|
className?: string
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export interface ContextPanelProps {
|
|
18
|
+
usage?: ContextUsage
|
|
19
|
+
engine?: ProfileEngine
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
export function ContextDialog({ usage, engine, open, onOpenChange, className }: ContextDialogProps) {
|
|
18
|
-
const note = contextNote(engine)
|
|
19
23
|
return (
|
|
20
24
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
21
25
|
<DialogContent className={className}>
|
|
22
26
|
<DialogHeader title="Context" description={usage?.model} />
|
|
23
27
|
<DialogBody>
|
|
24
|
-
{
|
|
25
|
-
<p className="py-6 text-center text-body-sm text-fg-4">
|
|
26
|
-
No reading yet - the context window is measured after a turn completes.
|
|
27
|
-
</p>
|
|
28
|
-
) : (
|
|
29
|
-
<>
|
|
30
|
-
<div className="flex items-baseline justify-between gap-4">
|
|
31
|
-
<span className="text-label text-fg-3">Used</span>
|
|
32
|
-
<span className="font-mono text-body-sm text-fg-1">
|
|
33
|
-
{formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} · {usage.percentage.toFixed(0)}%
|
|
34
|
-
</span>
|
|
35
|
-
</div>
|
|
36
|
-
<div className="mt-2 h-2 overflow-hidden rounded-full bg-border">
|
|
37
|
-
<div
|
|
38
|
-
className={cn('h-full rounded-full', meterTintClass(usage.percentage))}
|
|
39
|
-
style={{ width: `${Math.min(100, Math.max(2, usage.percentage))}%` }}
|
|
40
|
-
/>
|
|
41
|
-
</div>
|
|
42
|
-
{usage.categories.length > 0 ? (
|
|
43
|
-
<div className="mt-5">
|
|
44
|
-
<h3 className="text-label font-medium text-fg-3">Breakdown</h3>
|
|
45
|
-
<div className="mt-2 flex flex-col gap-3">
|
|
46
|
-
{usage.categories.map((category) => {
|
|
47
|
-
const share = (category.tokens / Math.max(usage.maxTokens, 1)) * 100
|
|
48
|
-
const color = cssColor(category.color)
|
|
49
|
-
return (
|
|
50
|
-
<div key={category.name}>
|
|
51
|
-
<div className="flex items-baseline justify-between gap-3">
|
|
52
|
-
<span className="flex min-w-0 items-center gap-2">
|
|
53
|
-
<span
|
|
54
|
-
className="size-2 shrink-0 rounded-full bg-fg-4"
|
|
55
|
-
style={color ? { backgroundColor: color } : undefined}
|
|
56
|
-
/>
|
|
57
|
-
<span className="truncate text-body-sm text-fg-1">{category.name}</span>
|
|
58
|
-
</span>
|
|
59
|
-
<span className="shrink-0 font-mono text-label text-fg-3">{formatTokens(category.tokens)}</span>
|
|
60
|
-
</div>
|
|
61
|
-
<div className="mt-1 h-1 overflow-hidden rounded-full bg-border">
|
|
62
|
-
<div
|
|
63
|
-
className="h-full rounded-full bg-fg-4"
|
|
64
|
-
style={{
|
|
65
|
-
width: `${Math.min(100, share)}%`,
|
|
66
|
-
backgroundColor: color,
|
|
67
|
-
}}
|
|
68
|
-
/>
|
|
69
|
-
</div>
|
|
70
|
-
</div>
|
|
71
|
-
)
|
|
72
|
-
})}
|
|
73
|
-
</div>
|
|
74
|
-
</div>
|
|
75
|
-
) : null}
|
|
76
|
-
</>
|
|
77
|
-
)}
|
|
78
|
-
{note ? (
|
|
79
|
-
<div className="mt-5 border-t border-border pt-4">
|
|
80
|
-
<h3 className="text-label font-medium text-fg-3">Why this window is 272K</h3>
|
|
81
|
-
<p className="mt-2 text-body-sm text-fg-2">{note.summary}</p>
|
|
82
|
-
{note.detail.map((line) => (
|
|
83
|
-
<p key={line} className="mt-2 text-body-sm text-fg-3">
|
|
84
|
-
{line}
|
|
85
|
-
</p>
|
|
86
|
-
))}
|
|
87
|
-
<p className="mt-2 text-body-sm text-warning">{note.caveat}</p>
|
|
88
|
-
</div>
|
|
89
|
-
) : null}
|
|
28
|
+
<ContextPanel usage={usage} engine={engine} />
|
|
90
29
|
</DialogBody>
|
|
91
30
|
</DialogContent>
|
|
92
31
|
</Dialog>
|
|
93
32
|
)
|
|
94
33
|
}
|
|
34
|
+
|
|
35
|
+
export function ContextPanel({ usage, engine }: ContextPanelProps) {
|
|
36
|
+
const note = contextNote(engine)
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
{!usage ? (
|
|
40
|
+
<p className="py-6 text-center text-body-sm text-fg-4">No reading yet - the context window is measured after a turn completes.</p>
|
|
41
|
+
) : (
|
|
42
|
+
<>
|
|
43
|
+
<div className="flex items-baseline justify-between gap-4">
|
|
44
|
+
<span className="text-label text-fg-3">Used</span>
|
|
45
|
+
<span className="font-mono text-body-sm text-fg-1">
|
|
46
|
+
{formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} · {usage.percentage.toFixed(0)}%
|
|
47
|
+
</span>
|
|
48
|
+
</div>
|
|
49
|
+
<div className="mt-2 h-2 overflow-hidden rounded-full bg-border">
|
|
50
|
+
<div
|
|
51
|
+
className={cn('h-full rounded-full', meterTintClass(usage.percentage))}
|
|
52
|
+
style={{ width: `${Math.min(100, Math.max(2, usage.percentage))}%` }}
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
{usage.categories.length > 0 ? (
|
|
56
|
+
<div className="mt-5">
|
|
57
|
+
<h3 className="text-label font-medium text-fg-3">Breakdown</h3>
|
|
58
|
+
<div className="mt-2 flex flex-col gap-3">
|
|
59
|
+
{usage.categories.map((category) => {
|
|
60
|
+
const share = (category.tokens / Math.max(usage.maxTokens, 1)) * 100
|
|
61
|
+
const color = cssColor(category.color)
|
|
62
|
+
return (
|
|
63
|
+
<div key={category.name}>
|
|
64
|
+
<div className="flex items-baseline justify-between gap-3">
|
|
65
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
66
|
+
<span className="size-2 shrink-0 rounded-full bg-fg-4" style={color ? { backgroundColor: color } : undefined} />
|
|
67
|
+
<span className="truncate text-body-sm text-fg-1">{category.name}</span>
|
|
68
|
+
</span>
|
|
69
|
+
<span className="shrink-0 font-mono text-label text-fg-3">{formatTokens(category.tokens)}</span>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="mt-1 h-1 overflow-hidden rounded-full bg-border">
|
|
72
|
+
<div
|
|
73
|
+
className="h-full rounded-full bg-fg-4"
|
|
74
|
+
style={{
|
|
75
|
+
width: `${Math.min(100, share)}%`,
|
|
76
|
+
backgroundColor: color,
|
|
77
|
+
}}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
})}
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
) : null}
|
|
86
|
+
</>
|
|
87
|
+
)}
|
|
88
|
+
{note ? (
|
|
89
|
+
<div className="mt-5 border-t border-border pt-4">
|
|
90
|
+
<h3 className="text-label font-medium text-fg-3">Why this window is 272K</h3>
|
|
91
|
+
<p className="mt-2 text-body-sm text-fg-2">{note.summary}</p>
|
|
92
|
+
{note.detail.map((line) => (
|
|
93
|
+
<p key={line} className="mt-2 text-body-sm text-fg-3">
|
|
94
|
+
{line}
|
|
95
|
+
</p>
|
|
96
|
+
))}
|
|
97
|
+
<p className="mt-2 text-body-sm text-warning">{note.caveat}</p>
|
|
98
|
+
</div>
|
|
99
|
+
) : null}
|
|
100
|
+
</>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { ArrowLeft } from 'lucide-react'
|
|
3
|
+
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
import { Button } from '../ui/Button.tsx'
|
|
5
|
+
import { Ink, Row } from '../terminal/row.tsx'
|
|
6
|
+
import { TerminalSurface } from '../terminal/surface.tsx'
|
|
7
|
+
import { WithActions } from '../terminal/affordances.tsx'
|
|
8
|
+
|
|
9
|
+
export type FrameKind = 'shell' | 'subagent'
|
|
10
|
+
|
|
11
|
+
export interface FrameStripProps {
|
|
12
|
+
kind: FrameKind
|
|
13
|
+
name: string
|
|
14
|
+
status?: string
|
|
15
|
+
detail?: string
|
|
16
|
+
failed: boolean
|
|
17
|
+
live: boolean
|
|
18
|
+
onBack: () => void
|
|
19
|
+
// Drawn at the strip's trailing edge; a terminal strip then hangs them inline, which is why only the name stays the Back target.
|
|
20
|
+
actions?: ReactNode
|
|
21
|
+
terminal: boolean
|
|
22
|
+
fontSize?: number
|
|
23
|
+
lineHeight?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TERMINAL_TONE = { shell: { name: 'magenta', live: 'magenta' }, subagent: { name: 'green', live: 'mark' } } as const
|
|
27
|
+
const CARDS_LIVE_CLASS: Record<FrameKind, string> = { shell: 'text-[var(--wd-shell-accent)]', subagent: 'text-accent' }
|
|
28
|
+
|
|
29
|
+
export function FrameStrip({ kind, name, status, detail, failed, live, onBack, actions, terminal, fontSize, lineHeight }: FrameStripProps) {
|
|
30
|
+
if (terminal) {
|
|
31
|
+
const tones = TERMINAL_TONE[kind]
|
|
32
|
+
const line = (label: ReactNode) => (
|
|
33
|
+
<Row glyph="←" glyphTone="dim" indent={1} tone={failed ? 'red' : tones.name}>
|
|
34
|
+
{label}
|
|
35
|
+
{status ? <Ink tone={failed ? 'red' : live ? tones.live : 'dim'}> · {status}</Ink> : null}
|
|
36
|
+
{detail ? <Ink tone="faint"> · {detail}</Ink> : null}
|
|
37
|
+
</Row>
|
|
38
|
+
)
|
|
39
|
+
return (
|
|
40
|
+
<TerminalSurface fontSize={fontSize} lineHeight={lineHeight} className="shrink-0">
|
|
41
|
+
{actions === undefined ? (
|
|
42
|
+
<button type="button" onClick={onBack} aria-label="Back to the session" className="block w-full cursor-pointer text-left">
|
|
43
|
+
{line(name)}
|
|
44
|
+
</button>
|
|
45
|
+
) : (
|
|
46
|
+
<WithActions placement="inline" actions={actions}>
|
|
47
|
+
{line(
|
|
48
|
+
<button type="button" onClick={onBack} aria-label="Back to the session" className="cursor-pointer text-left">
|
|
49
|
+
{name}
|
|
50
|
+
</button>,
|
|
51
|
+
)}
|
|
52
|
+
</WithActions>
|
|
53
|
+
)}
|
|
54
|
+
</TerminalSurface>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
|
|
60
|
+
<Button variant="ghost" size="sm" onClick={onBack} className="h-6 gap-1 px-1.5">
|
|
61
|
+
<ArrowLeft className="size-3.5" />
|
|
62
|
+
Back
|
|
63
|
+
</Button>
|
|
64
|
+
<span className={cn('min-w-0 flex-1 truncate text-body-sm', kind === 'shell' && 'font-mono', failed ? 'text-danger' : 'text-fg-2')}>
|
|
65
|
+
{name}
|
|
66
|
+
</span>
|
|
67
|
+
{status ? (
|
|
68
|
+
<span className={cn('shrink-0 text-label', failed ? 'text-danger' : live ? CARDS_LIVE_CLASS[kind] : 'text-fg-3')}>{status}</span>
|
|
69
|
+
) : null}
|
|
70
|
+
{detail ? <span className="shrink-0 text-label text-fg-4">{detail}</span> : null}
|
|
71
|
+
{actions}
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errorMessage } from '@workerdeck/protocol'
|
|
1
2
|
import { useCallback, useEffect, useState } from 'react'
|
|
2
3
|
import type { WorkerDeckClient } from '@workerdeck/client'
|
|
3
4
|
import type { HostDirEntry, HostFileMatch } from '@workerdeck/protocol'
|
|
@@ -37,7 +38,7 @@ export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDi
|
|
|
37
38
|
setEntries(response.entries)
|
|
38
39
|
setTruncated(response.truncated ?? false)
|
|
39
40
|
} catch (e) {
|
|
40
|
-
setError(e
|
|
41
|
+
setError(errorMessage(e, 'Could not read that directory'))
|
|
41
42
|
} finally {
|
|
42
43
|
setLoading(false)
|
|
43
44
|
}
|
|
@@ -87,7 +88,7 @@ export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDi
|
|
|
87
88
|
content: response.encoding === 'utf8' ? response.content : '(binary file - not shown)',
|
|
88
89
|
})
|
|
89
90
|
} catch (e) {
|
|
90
|
-
setError(e
|
|
91
|
+
setError(errorMessage(e, 'Could not read that file'))
|
|
91
92
|
} finally {
|
|
92
93
|
setLoading(false)
|
|
93
94
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
2
|
import type { WorkerDeckClient } from '@workerdeck/client'
|
|
3
|
-
import
|
|
3
|
+
import { useAsync } from '@workerdeck/react'
|
|
4
|
+
import { errorMessage, type McpServerActionRequest, type McpServerStatusInfo, type McpServerToolInfo } from '@workerdeck/protocol'
|
|
4
5
|
import { ChevronLeft, ChevronRight, Power, PowerOff, RotateCw } from 'lucide-react'
|
|
5
6
|
import { Badge, type BadgeProps } from '../ui/Badge.tsx'
|
|
6
7
|
import { Button } from '../ui/Button.tsx'
|
|
@@ -17,6 +18,27 @@ export interface McpDialogProps {
|
|
|
17
18
|
className?: string
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
export interface McpPanelModel {
|
|
22
|
+
servers: McpServerStatusInfo[] | undefined
|
|
23
|
+
server: McpServerStatusInfo | undefined
|
|
24
|
+
tool: McpServerToolInfo | undefined
|
|
25
|
+
loading: boolean
|
|
26
|
+
busyServer: string | undefined
|
|
27
|
+
error: string | undefined
|
|
28
|
+
title: string
|
|
29
|
+
description: string | undefined
|
|
30
|
+
selectServer: (name: string | undefined) => void
|
|
31
|
+
selectTool: (name: string | undefined) => void
|
|
32
|
+
back: (() => void) | undefined
|
|
33
|
+
reload: () => void
|
|
34
|
+
act: (name: string, action: McpServerActionRequest['action']) => Promise<void>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface McpPanelProps {
|
|
38
|
+
model: McpPanelModel
|
|
39
|
+
canManageServers?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
20
42
|
const STATUS_VARIANT: Record<string, NonNullable<BadgeProps['variant']>> = {
|
|
21
43
|
connected: 'success',
|
|
22
44
|
failed: 'danger',
|
|
@@ -26,94 +48,106 @@ const STATUS_VARIANT: Record<string, NonNullable<BadgeProps['variant']>> = {
|
|
|
26
48
|
}
|
|
27
49
|
|
|
28
50
|
export function McpDialog({ client, sessionId, open, onOpenChange, canManageServers = true, className }: McpDialogProps) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
51
|
+
const mcp = useMcpPanel(client, sessionId, open)
|
|
52
|
+
return (
|
|
53
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
54
|
+
<DialogContent className={className}>
|
|
55
|
+
<DialogHeader title={mcp.title} description={mcp.description} actions={<McpPanelActions model={mcp} />} />
|
|
56
|
+
<DialogBody>
|
|
57
|
+
<McpPanel model={mcp} canManageServers={canManageServers} />
|
|
58
|
+
</DialogBody>
|
|
59
|
+
</DialogContent>
|
|
60
|
+
</Dialog>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useMcpPanel(client: WorkerDeckClient | undefined, sessionId: string | undefined, active = true): McpPanelModel {
|
|
65
|
+
const listed = useAsync(() => client!.listMcpServers(sessionId!), [client, sessionId], { enabled: active && !!client && !!sessionId })
|
|
66
|
+
const [actError, setActError] = useState<string | undefined>()
|
|
32
67
|
const [busyServer, setBusyServer] = useState<string | undefined>()
|
|
33
68
|
const [selectedServer, setSelectedServer] = useState<string | undefined>()
|
|
34
69
|
const [selectedTool, setSelectedTool] = useState<string | undefined>()
|
|
35
70
|
|
|
36
|
-
const load = useCallback(async () => {
|
|
37
|
-
if (!sessionId) {
|
|
38
|
-
return
|
|
39
|
-
}
|
|
40
|
-
setLoading(true)
|
|
41
|
-
setError(undefined)
|
|
42
|
-
try {
|
|
43
|
-
setServers(await client.listMcpServers(sessionId))
|
|
44
|
-
} catch (e) {
|
|
45
|
-
setError(e instanceof Error ? e.message : 'Could not read MCP status')
|
|
46
|
-
} finally {
|
|
47
|
-
setLoading(false)
|
|
48
|
-
}
|
|
49
|
-
}, [client, sessionId])
|
|
50
|
-
|
|
51
71
|
useEffect(() => {
|
|
52
|
-
if (
|
|
53
|
-
|
|
72
|
+
if (active) {
|
|
73
|
+
setSelectedServer(undefined)
|
|
74
|
+
setSelectedTool(undefined)
|
|
75
|
+
setActError(undefined)
|
|
54
76
|
}
|
|
55
|
-
|
|
56
|
-
setSelectedTool(undefined)
|
|
57
|
-
void load()
|
|
58
|
-
}, [open, load])
|
|
59
|
-
|
|
60
|
-
const act = async (name: string, action: McpServerActionRequest['action']) => {
|
|
61
|
-
if (!sessionId) {
|
|
62
|
-
return
|
|
63
|
-
}
|
|
64
|
-
setBusyServer(name)
|
|
65
|
-
setError(undefined)
|
|
66
|
-
try {
|
|
67
|
-
setServers(await client.mcpServerAction(sessionId, name, action))
|
|
68
|
-
} catch (e) {
|
|
69
|
-
setError(e instanceof Error ? e.message : `Could not ${action} ${name}`)
|
|
70
|
-
} finally {
|
|
71
|
-
setBusyServer(undefined)
|
|
72
|
-
}
|
|
73
|
-
}
|
|
77
|
+
}, [active, client, sessionId])
|
|
74
78
|
|
|
79
|
+
const servers = listed.data
|
|
75
80
|
const server = servers?.find((s) => s.name === selectedServer)
|
|
76
81
|
const tool = server?.tools?.find((t) => t.name === selectedTool)
|
|
77
|
-
|
|
82
|
+
return {
|
|
83
|
+
servers,
|
|
84
|
+
server,
|
|
85
|
+
tool,
|
|
86
|
+
loading: listed.loading,
|
|
87
|
+
busyServer,
|
|
88
|
+
error: actError ?? (listed.error === undefined ? undefined : errorMessage(listed.error, 'Could not read MCP status')),
|
|
89
|
+
title: tool?.name ?? server?.name ?? 'MCP servers',
|
|
90
|
+
description: tool ? `${server?.name} tool` : server ? server.serverInfo?.name : undefined,
|
|
91
|
+
selectServer: setSelectedServer,
|
|
92
|
+
selectTool: setSelectedTool,
|
|
93
|
+
back: selectedServer ? () => (selectedTool ? setSelectedTool(undefined) : setSelectedServer(undefined)) : undefined,
|
|
94
|
+
reload: () => {
|
|
95
|
+
setActError(undefined)
|
|
96
|
+
void listed.reload()
|
|
97
|
+
},
|
|
98
|
+
act: async (name, action) => {
|
|
99
|
+
if (!client || !sessionId) {
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
setBusyServer(name)
|
|
103
|
+
setActError(undefined)
|
|
104
|
+
try {
|
|
105
|
+
listed.setData(await client.mcpServerAction(sessionId, name, action))
|
|
106
|
+
} catch (e) {
|
|
107
|
+
setActError(errorMessage(e, `Could not ${action} ${name}`))
|
|
108
|
+
} finally {
|
|
109
|
+
setBusyServer(undefined)
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
78
114
|
|
|
115
|
+
export function McpPanelActions({ model }: { model: McpPanelModel }) {
|
|
116
|
+
if (model.back) {
|
|
117
|
+
return (
|
|
118
|
+
<Button variant="ghost" size="xs" onClick={model.back}>
|
|
119
|
+
<ChevronLeft className="size-3.5" />
|
|
120
|
+
Back
|
|
121
|
+
</Button>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
79
124
|
return (
|
|
80
|
-
<
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
125
|
+
<Button variant="ghost" size="xs" onClick={model.reload} disabled={model.loading}>
|
|
126
|
+
{model.loading ? <Spinner className="size-3 text-current" /> : <RotateCw className="size-3" />}
|
|
127
|
+
Refresh
|
|
128
|
+
</Button>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function McpPanel({ model, canManageServers = true }: McpPanelProps) {
|
|
133
|
+
const { error, servers, server, tool } = model
|
|
134
|
+
return (
|
|
135
|
+
<>
|
|
136
|
+
{error ? <div className="mb-3 rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger">{error}</div> : null}
|
|
137
|
+
{tool ? (
|
|
138
|
+
<ToolView tool={tool} />
|
|
139
|
+
) : server ? (
|
|
140
|
+
<ServerView
|
|
141
|
+
server={server}
|
|
142
|
+
busy={model.busyServer === server.name}
|
|
143
|
+
canManage={canManageServers}
|
|
144
|
+
onAct={(action) => void model.act(server.name, action)}
|
|
145
|
+
onSelectTool={model.selectTool}
|
|
98
146
|
/>
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
) : server ? (
|
|
104
|
-
<ServerView
|
|
105
|
-
server={server}
|
|
106
|
-
busy={busyServer === server.name}
|
|
107
|
-
canManage={canManageServers}
|
|
108
|
-
onAct={(action) => void act(server.name, action)}
|
|
109
|
-
onSelectTool={setSelectedTool}
|
|
110
|
-
/>
|
|
111
|
-
) : error && !servers ? null : (
|
|
112
|
-
<ServerList servers={servers} loading={loading} onSelect={setSelectedServer} />
|
|
113
|
-
)}
|
|
114
|
-
</DialogBody>
|
|
115
|
-
</DialogContent>
|
|
116
|
-
</Dialog>
|
|
147
|
+
) : error && !servers ? null : (
|
|
148
|
+
<ServerList servers={servers} loading={model.loading} onSelect={model.selectServer} />
|
|
149
|
+
)}
|
|
150
|
+
</>
|
|
117
151
|
)
|
|
118
152
|
}
|
|
119
153
|
|
|
@@ -5,7 +5,8 @@ import { Button } from '../ui/Button.tsx'
|
|
|
5
5
|
import { Input } from '../ui/Input.tsx'
|
|
6
6
|
import { cn } from '../../lib/utils.ts'
|
|
7
7
|
import { toolInputPreview } from '../../lib/format.ts'
|
|
8
|
-
import {
|
|
8
|
+
import { permissionPromptModel } from '../../lib/permission-prompt.ts'
|
|
9
|
+
import { shellRequestLines } from '../../lib/shell-request.ts'
|
|
9
10
|
import { toolIcon } from '../../lib/tool-icon.ts'
|
|
10
11
|
import { Response } from './Response.tsx'
|
|
11
12
|
|
|
@@ -28,7 +29,9 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
|
|
|
28
29
|
setDenying(false)
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
const
|
|
32
|
+
const model = permissionPromptModel(request)
|
|
33
|
+
const { plan, shell } = model
|
|
34
|
+
const [allow, keep, stop] = model.choices
|
|
32
35
|
const summary = toolInputPreview(request.input)
|
|
33
36
|
const ToolIcon = toolIcon(request.toolName)
|
|
34
37
|
|
|
@@ -37,21 +40,27 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
|
|
|
37
40
|
<div className="flex items-start gap-2.5">
|
|
38
41
|
<Hand className="mt-0.5 size-4 shrink-0 text-warning" />
|
|
39
42
|
<div className="min-w-0 flex-1">
|
|
40
|
-
<div className="text-body-sm font-medium text-fg-1">
|
|
41
|
-
|
|
42
|
-
</div>
|
|
43
|
-
{request.description ? <div className="mt-0.5 text-label text-fg-3">{request.description}</div> : null}
|
|
44
|
-
{request.decisionReason ? <div className="mt-0.5 text-label text-fg-4">{request.decisionReason}</div> : null}
|
|
43
|
+
<div className="text-body-sm font-medium text-fg-1">{model.heading}</div>
|
|
44
|
+
{model.description ? <div className="mt-0.5 text-label text-fg-3">{model.description}</div> : null}
|
|
45
|
+
{model.decisionReason ? <div className="mt-0.5 text-label text-fg-4">{model.decisionReason}</div> : null}
|
|
45
46
|
{plan ? (
|
|
46
47
|
<div className="mt-1.5 max-h-72 overflow-y-auto rounded-md bg-code-bg px-2.5 py-2 text-body-sm text-fg-2">
|
|
47
48
|
<Response>{plan}</Response>
|
|
48
49
|
</div>
|
|
49
50
|
) : (
|
|
50
51
|
<>
|
|
52
|
+
{shell ? (
|
|
53
|
+
<pre
|
|
54
|
+
data-slot="shell-request"
|
|
55
|
+
className="mt-1.5 max-h-48 overflow-auto rounded-md bg-code-bg px-2.5 py-1.5 font-mono text-body-sm whitespace-pre-wrap text-fg-1"
|
|
56
|
+
>
|
|
57
|
+
{shellRequestLines(shell).join('\n')}
|
|
58
|
+
</pre>
|
|
59
|
+
) : null}
|
|
51
60
|
<div className="mt-1.5 flex min-w-0 items-center gap-2">
|
|
52
61
|
<ToolIcon className="size-3 shrink-0 text-fg-3" />
|
|
53
62
|
<span className="shrink-0 font-mono text-label font-medium text-fg-2">{request.toolName}</span>
|
|
54
|
-
{summary ? <span className="min-w-0 truncate font-mono text-label text-fg-4">{summary}</span> : null}
|
|
63
|
+
{summary && !shell ? <span className="min-w-0 truncate font-mono text-label text-fg-4">{summary}</span> : null}
|
|
55
64
|
</div>
|
|
56
65
|
<button
|
|
57
66
|
type="button"
|
|
@@ -70,13 +79,13 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
|
|
|
70
79
|
</div>
|
|
71
80
|
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
|
|
72
81
|
<Button size="sm" onClick={() => onApprove(request.id)}>
|
|
73
|
-
{
|
|
82
|
+
{allow.button}
|
|
74
83
|
</Button>
|
|
75
84
|
<Button size="sm" variant="outline" onClick={() => setDenying((v) => !v)}>
|
|
76
|
-
{
|
|
85
|
+
{keep.button}
|
|
77
86
|
</Button>
|
|
78
87
|
<Button size="sm" variant="outline" onClick={() => deny(true)}>
|
|
79
|
-
{
|
|
88
|
+
{stop.button}
|
|
80
89
|
</Button>
|
|
81
90
|
</div>
|
|
82
91
|
</div>
|
|
@@ -94,18 +103,14 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
|
|
|
94
103
|
setDenying(false)
|
|
95
104
|
}
|
|
96
105
|
}}
|
|
97
|
-
placeholder={
|
|
98
|
-
plan
|
|
99
|
-
? 'What should change? (optional) - the agent keeps planning and reads this'
|
|
100
|
-
: 'Reason (optional) - the agent reads this and can try something else'
|
|
101
|
-
}
|
|
106
|
+
placeholder={model.denyPlaceholder}
|
|
102
107
|
className="h-7 flex-1"
|
|
103
108
|
/>
|
|
104
109
|
<Button size="sm" variant="outline" onClick={() => setDenying(false)}>
|
|
105
110
|
Cancel
|
|
106
111
|
</Button>
|
|
107
112
|
<Button size="sm" variant="destructive" onClick={() => deny(false)}>
|
|
108
|
-
{
|
|
113
|
+
{keep.button}
|
|
109
114
|
</Button>
|
|
110
115
|
</div>
|
|
111
116
|
) : null}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
2
1
|
import { parseUserQuestions, type PermissionRequest, type QuestionBehavior } from '@workerdeck/protocol'
|
|
3
2
|
|
|
4
3
|
// Re-exported, not redefined: the parse moved to protocol so the CLI's Live Activity builder could
|
|
@@ -7,6 +6,7 @@ import { MessageCircleQuestion, X } from 'lucide-react'
|
|
|
7
6
|
import { Badge } from '../ui/Badge.tsx'
|
|
8
7
|
import { Button } from '../ui/Button.tsx'
|
|
9
8
|
import { Textarea } from '../ui/Textarea.tsx'
|
|
9
|
+
import { useQuestionAnswers } from '../../lib/question-answers.ts'
|
|
10
10
|
import { cn } from '../../lib/utils.ts'
|
|
11
11
|
|
|
12
12
|
export { parseUserQuestions }
|
|
@@ -23,18 +23,6 @@ export const QUESTION_BEHAVIORS: QuestionBehaviorMeta[] = [
|
|
|
23
23
|
{ value: 'deny', label: 'Disabled', description: 'the agent is told to decide on its own' },
|
|
24
24
|
]
|
|
25
25
|
|
|
26
|
-
type Selection = { labels: string[]; other: string; otherActive: boolean }
|
|
27
|
-
|
|
28
|
-
const EMPTY_SELECTION: Selection = { labels: [], other: '', otherActive: false }
|
|
29
|
-
|
|
30
|
-
function answerFor(selection: Selection): string {
|
|
31
|
-
const parts = [...selection.labels]
|
|
32
|
-
if (selection.otherActive && selection.other.trim()) {
|
|
33
|
-
parts.push(selection.other.trim())
|
|
34
|
-
}
|
|
35
|
-
return parts.join(', ')
|
|
36
|
-
}
|
|
37
|
-
|
|
38
26
|
export interface QuestionPromptProps {
|
|
39
27
|
request: PermissionRequest
|
|
40
28
|
onAnswer: (requestId: string, updatedInput: Record<string, unknown>) => void
|
|
@@ -44,32 +32,9 @@ export interface QuestionPromptProps {
|
|
|
44
32
|
|
|
45
33
|
export function QuestionPrompt({ request, onAnswer, onDismiss, className }: QuestionPromptProps) {
|
|
46
34
|
const questions = parseUserQuestions(request.input)
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
const update = (index: number, patch: Partial<Selection>) => {
|
|
50
|
-
setSelections((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)))
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const toggle = (index: number, label: string, multiSelect: boolean) => {
|
|
54
|
-
const current = selections[index] ?? EMPTY_SELECTION
|
|
55
|
-
if (multiSelect) {
|
|
56
|
-
update(index, {
|
|
57
|
-
labels: current.labels.includes(label) ? current.labels.filter((l) => l !== label) : [...current.labels, label],
|
|
58
|
-
})
|
|
59
|
-
} else {
|
|
60
|
-
update(index, { labels: current.labels[0] === label ? [] : [label], otherActive: false })
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const complete = questions.every((_, i) => answerFor(selections[i] ?? EMPTY_SELECTION) !== '')
|
|
35
|
+
const { selectionFor, update, toggle, toggleOther, complete, answers } = useQuestionAnswers(questions)
|
|
65
36
|
|
|
66
|
-
const submit = () => {
|
|
67
|
-
const answers: Record<string, string> = {}
|
|
68
|
-
questions.forEach((q, i) => {
|
|
69
|
-
answers[q.question] = answerFor(selections[i] ?? EMPTY_SELECTION)
|
|
70
|
-
})
|
|
71
|
-
onAnswer(request.id, { ...request.input, answers })
|
|
72
|
-
}
|
|
37
|
+
const submit = () => onAnswer(request.id, { ...request.input, answers: answers() })
|
|
73
38
|
|
|
74
39
|
return (
|
|
75
40
|
<div data-slot="question-prompt" className={cn('rounded-lg border border-info/40 bg-info-bg p-3', className)}>
|
|
@@ -77,7 +42,7 @@ export function QuestionPrompt({ request, onAnswer, onDismiss, className }: Ques
|
|
|
77
42
|
<MessageCircleQuestion className="mt-0.5 size-4 shrink-0 text-info" />
|
|
78
43
|
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
|
79
44
|
{questions.map((q, index) => {
|
|
80
|
-
const selection =
|
|
45
|
+
const selection = selectionFor(index)
|
|
81
46
|
return (
|
|
82
47
|
<div key={index} className="flex flex-col gap-1.5">
|
|
83
48
|
<div className="flex items-center gap-2">
|
|
@@ -91,7 +56,7 @@ export function QuestionPrompt({ request, onAnswer, onDismiss, className }: Ques
|
|
|
91
56
|
<button
|
|
92
57
|
key={option.label}
|
|
93
58
|
type="button"
|
|
94
|
-
onClick={() => toggle(index, option.label
|
|
59
|
+
onClick={() => toggle(index, option.label)}
|
|
95
60
|
className={cn(
|
|
96
61
|
'rounded-md border px-2.5 py-1.5 text-left transition-colors',
|
|
97
62
|
selected ? 'border-info bg-bg' : 'border-border bg-bg/50 hover:border-border-strong hover:bg-bg',
|
|
@@ -110,7 +75,7 @@ export function QuestionPrompt({ request, onAnswer, onDismiss, className }: Ques
|
|
|
110
75
|
<div className="flex items-center gap-2">
|
|
111
76
|
<button
|
|
112
77
|
type="button"
|
|
113
|
-
onClick={() =>
|
|
78
|
+
onClick={() => toggleOther(index)}
|
|
114
79
|
className={cn(
|
|
115
80
|
'shrink-0 rounded-md border px-2.5 py-1.5 text-body-sm font-medium transition-colors',
|
|
116
81
|
selection.otherActive
|