@workerdeck/ui 0.9.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 +762 -21
- package/build/index.mjs +3245 -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 +379 -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 +66 -17
- 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
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { LucideIcon } from 'lucide-react'
|
|
1
2
|
import type { PermissionMode } from '@workerdeck/protocol'
|
|
3
|
+
import { ClipboardList, Code, Hand, ShieldCheck, TriangleAlert, Zap } from 'lucide-react'
|
|
2
4
|
import {
|
|
3
5
|
Select,
|
|
4
6
|
SelectContent,
|
|
@@ -11,34 +13,94 @@ import { cn } from '../../lib/utils.ts'
|
|
|
11
13
|
|
|
12
14
|
export type PermissionModeMeta = {
|
|
13
15
|
value: PermissionMode
|
|
16
|
+
/** The name Claude Code itself uses. */
|
|
14
17
|
label: string
|
|
18
|
+
/** The chip form, for bars where the label shares a line with three other
|
|
19
|
+
* things and "Bypass permissions" would eat half of it. */
|
|
20
|
+
shortLabel: string
|
|
21
|
+
/** What the mode actually does — the CLI's own one-liners. */
|
|
15
22
|
description: string
|
|
23
|
+
icon: LucideIcon
|
|
16
24
|
dangerous?: boolean
|
|
17
25
|
}
|
|
18
26
|
|
|
19
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* The modes surfaced across UI surfaces (session creation, in-session switcher),
|
|
29
|
+
* ordered by how much of the approval gate they give away.
|
|
30
|
+
*
|
|
31
|
+
* Notably `default` is **"Manual"**: the wire value is `default`, but calling it
|
|
32
|
+
* that in the UI conflates a real mode (ask me every time) with "whatever the
|
|
33
|
+
* server picked", which is the one confusion a mode chip exists to avoid. The
|
|
34
|
+
* naming, the icons and the summaries are shared with the iOS app on purpose —
|
|
35
|
+
* the two surfaces should read as the same list.
|
|
36
|
+
*/
|
|
20
37
|
export const PERMISSION_MODES: PermissionModeMeta[] = [
|
|
21
|
-
{
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
38
|
+
{
|
|
39
|
+
value: 'default',
|
|
40
|
+
label: 'Manual',
|
|
41
|
+
shortLabel: 'Manual',
|
|
42
|
+
description: 'Always ask before making changes',
|
|
43
|
+
icon: Hand,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
value: 'acceptEdits',
|
|
47
|
+
label: 'Accept edits',
|
|
48
|
+
shortLabel: 'Edits',
|
|
49
|
+
description: 'Automatically accept all file edits',
|
|
50
|
+
icon: Code,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
value: 'plan',
|
|
54
|
+
label: 'Plan',
|
|
55
|
+
shortLabel: 'Plan',
|
|
56
|
+
description: 'Create a plan before making changes',
|
|
57
|
+
icon: ClipboardList,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
value: 'auto',
|
|
61
|
+
label: 'Auto',
|
|
62
|
+
shortLabel: 'Auto',
|
|
63
|
+
description: 'Claude handles permission decisions',
|
|
64
|
+
icon: Zap,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
value: 'dontAsk',
|
|
68
|
+
label: "Don't ask",
|
|
69
|
+
shortLabel: "Don't ask",
|
|
70
|
+
// The CLI's own definition, and the opposite of bypass: it never prompts,
|
|
71
|
+
// and anything not already permitted is denied rather than allowed.
|
|
72
|
+
description: 'Never ask — deny anything not pre-approved',
|
|
73
|
+
icon: ShieldCheck,
|
|
74
|
+
},
|
|
26
75
|
{
|
|
27
76
|
value: 'bypassPermissions',
|
|
28
|
-
label: '
|
|
29
|
-
|
|
77
|
+
label: 'Bypass permissions',
|
|
78
|
+
shortLabel: 'Bypass',
|
|
79
|
+
description: 'Skip every approval — the agent is unsupervised',
|
|
80
|
+
icon: TriangleAlert,
|
|
30
81
|
dangerous: true,
|
|
31
82
|
},
|
|
32
83
|
]
|
|
33
84
|
|
|
85
|
+
export const permissionModeMeta = (mode: PermissionMode): PermissionModeMeta | undefined =>
|
|
86
|
+
PERMISSION_MODES.find((m) => m.value === mode)
|
|
87
|
+
|
|
34
88
|
export interface PermissionModeSelectProps {
|
|
35
89
|
/** The session's current mode (TranscriptState.permissionMode). */
|
|
36
90
|
mode?: PermissionMode
|
|
37
91
|
onModeChange: (mode: PermissionMode) => void
|
|
38
92
|
/** Restrict what is offered — most of {@link PERMISSION_MODES} is Claude Code
|
|
39
|
-
* vocabulary the
|
|
40
|
-
* pass
|
|
93
|
+
* vocabulary the other engines have no meaning for. Defaults to all of them;
|
|
94
|
+
* pass the session's `capabilities.permissionModes`. */
|
|
41
95
|
modes?: readonly PermissionMode[]
|
|
96
|
+
/**
|
|
97
|
+
* Whether this session may be switched into `bypassPermissions` at all. The
|
|
98
|
+
* CLI refuses unless the process was spawned for it, so a session that didn't
|
|
99
|
+
* ask up front can never gain it. The row is shown disabled rather than hidden
|
|
100
|
+
* — "you can't have this here" is a more useful answer than a row that
|
|
101
|
+
* silently isn't there. `undefined` (an older server) offers it.
|
|
102
|
+
*/
|
|
103
|
+
canBypass?: boolean
|
|
42
104
|
/** 'toolbar' (default) is the composer's compact borderless trigger;
|
|
43
105
|
* 'form' is a standard field-sized Select for create/settings forms. */
|
|
44
106
|
variant?: 'toolbar' | 'form'
|
|
@@ -51,12 +113,15 @@ export function PermissionModeSelect({
|
|
|
51
113
|
mode,
|
|
52
114
|
onModeChange,
|
|
53
115
|
modes,
|
|
116
|
+
canBypass,
|
|
54
117
|
variant = 'toolbar',
|
|
55
118
|
disabled,
|
|
56
119
|
className,
|
|
57
120
|
}: PermissionModeSelectProps) {
|
|
58
121
|
const dangerous = mode === 'bypassPermissions'
|
|
59
122
|
const offered = modes ? PERMISSION_MODES.filter((m) => modes.includes(m.value)) : PERMISSION_MODES
|
|
123
|
+
const unavailable = (meta: PermissionModeMeta) =>
|
|
124
|
+
meta.value === 'bypassPermissions' && canBypass === false
|
|
60
125
|
return (
|
|
61
126
|
<Select
|
|
62
127
|
items={offered.map((m) => ({ value: m.value, label: m.label }))}
|
|
@@ -73,21 +138,33 @@ export function PermissionModeSelect({
|
|
|
73
138
|
dangerous ? 'text-danger' : variant === 'toolbar' ? 'text-fg-3' : undefined,
|
|
74
139
|
className,
|
|
75
140
|
)}>
|
|
76
|
-
<span className={cn('truncate', variant === 'toolbar' && '
|
|
141
|
+
<span className={cn('truncate', variant === 'toolbar' && 'text-label')}>
|
|
77
142
|
<SelectValue placeholder='permissions' />
|
|
78
143
|
</span>
|
|
79
144
|
</SelectTrigger>
|
|
80
|
-
<SelectContent className='min-w-
|
|
81
|
-
{offered.map((m) =>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
145
|
+
<SelectContent className='min-w-64'>
|
|
146
|
+
{offered.map((m) => {
|
|
147
|
+
const blocked = unavailable(m)
|
|
148
|
+
return (
|
|
149
|
+
<SelectItem key={m.value} value={m.value} disabled={blocked}>
|
|
150
|
+
<SelectItemText>
|
|
151
|
+
<span className='flex items-center gap-2'>
|
|
152
|
+
<m.icon
|
|
153
|
+
className={cn('size-3.5 shrink-0', m.dangerous ? 'text-danger' : 'text-fg-3')}
|
|
154
|
+
/>
|
|
155
|
+
<span className={cn('font-medium', m.dangerous && 'text-danger')}>{m.label}</span>
|
|
156
|
+
</span>
|
|
157
|
+
</SelectItemText>
|
|
158
|
+
<span
|
|
159
|
+
className={cn(
|
|
160
|
+
'pl-5.5 text-label',
|
|
161
|
+
blocked ? 'text-fg-4' : m.dangerous ? 'text-danger/80' : 'text-fg-4',
|
|
162
|
+
)}>
|
|
163
|
+
{blocked ? 'Only available to a session started in this mode' : m.description}
|
|
164
|
+
</span>
|
|
165
|
+
</SelectItem>
|
|
166
|
+
)
|
|
167
|
+
})}
|
|
91
168
|
</SelectContent>
|
|
92
169
|
</Select>
|
|
93
170
|
)
|
|
@@ -1,31 +1,73 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import type { PermissionRequest } from '@workerdeck/protocol'
|
|
3
|
-
import {
|
|
3
|
+
import { Hand } from 'lucide-react'
|
|
4
4
|
import { Button } from '../ui/Button.tsx'
|
|
5
|
+
import { Input } from '../ui/Input.tsx'
|
|
5
6
|
import { cn } from '../../lib/utils.ts'
|
|
7
|
+
import { toolInputPreview } from '../../lib/format.ts'
|
|
8
|
+
import { toolIcon } from '../../lib/tool-icon.ts'
|
|
6
9
|
|
|
7
10
|
export interface PermissionPromptProps {
|
|
8
11
|
request: PermissionRequest
|
|
9
12
|
onApprove: (requestId: string) => void
|
|
10
|
-
|
|
13
|
+
/** `message` is fed back to the agent, which can then try something else;
|
|
14
|
+
* `interrupt` also stops the turn. */
|
|
15
|
+
onDeny: (requestId: string, message?: string, interrupt?: boolean) => void
|
|
11
16
|
className?: string
|
|
12
17
|
}
|
|
13
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Generic allow/deny prompt for a pending permission request.
|
|
21
|
+
*
|
|
22
|
+
* Three outcomes, not two: denying usually means "not that, try something else",
|
|
23
|
+
* so plain Deny lets the turn continue (with an optional reason the agent reads)
|
|
24
|
+
* while "Deny & stop" also interrupts.
|
|
25
|
+
*
|
|
26
|
+
* The heading is whatever the engine authored — `title`, else `displayName`.
|
|
27
|
+
* Composing "wants to use {tool}" instead would be wrong for codex, where an
|
|
28
|
+
* approval is usually an *escalation after a sandbox refusal* and the runner has
|
|
29
|
+
* already written the sentence that says so.
|
|
30
|
+
*/
|
|
14
31
|
export function PermissionPrompt({ request, onApprove, onDeny, className }: PermissionPromptProps) {
|
|
15
32
|
const [showInput, setShowInput] = useState(false)
|
|
33
|
+
const [denying, setDenying] = useState(false)
|
|
34
|
+
const [reason, setReason] = useState('')
|
|
35
|
+
|
|
36
|
+
const deny = (interrupt: boolean) => {
|
|
37
|
+
const message = reason.trim()
|
|
38
|
+
onDeny(request.id, message || undefined, interrupt || undefined)
|
|
39
|
+
setReason('')
|
|
40
|
+
setDenying(false)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const summary = toolInputPreview(request.input)
|
|
44
|
+
const ToolIcon = toolIcon(request.toolName)
|
|
45
|
+
|
|
16
46
|
return (
|
|
17
47
|
<div
|
|
18
48
|
data-slot='permission-prompt'
|
|
19
49
|
className={cn('rounded-lg border border-warning/40 bg-warning-bg p-3', className)}>
|
|
20
50
|
<div className='flex items-start gap-2.5'>
|
|
21
|
-
<
|
|
51
|
+
<Hand className='mt-0.5 size-4 shrink-0 text-warning' />
|
|
22
52
|
<div className='min-w-0 flex-1'>
|
|
23
53
|
<div className='text-body-sm font-medium text-fg-1'>
|
|
24
|
-
{request.title ??
|
|
54
|
+
{request.title ?? request.displayName ?? 'Permission needed'}
|
|
25
55
|
</div>
|
|
26
56
|
{request.description ? (
|
|
27
57
|
<div className='mt-0.5 text-label text-fg-3'>{request.description}</div>
|
|
28
58
|
) : null}
|
|
59
|
+
{request.decisionReason ? (
|
|
60
|
+
<div className='mt-0.5 text-label text-fg-4'>{request.decisionReason}</div>
|
|
61
|
+
) : null}
|
|
62
|
+
<div className='mt-1.5 flex min-w-0 items-center gap-2'>
|
|
63
|
+
<ToolIcon className='size-3 shrink-0 text-fg-3' />
|
|
64
|
+
<span className='shrink-0 font-mono text-label font-medium text-fg-2'>
|
|
65
|
+
{request.toolName}
|
|
66
|
+
</span>
|
|
67
|
+
{summary ? (
|
|
68
|
+
<span className='min-w-0 truncate font-mono text-label text-fg-4'>{summary}</span>
|
|
69
|
+
) : null}
|
|
70
|
+
</div>
|
|
29
71
|
<button
|
|
30
72
|
type='button'
|
|
31
73
|
className='mt-1 font-mono text-label text-fg-3 underline-offset-2 hover:underline'
|
|
@@ -38,15 +80,39 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
|
|
|
38
80
|
</pre>
|
|
39
81
|
) : null}
|
|
40
82
|
</div>
|
|
41
|
-
<div className='flex shrink-0 gap-1.5'>
|
|
83
|
+
<div className='flex shrink-0 flex-wrap justify-end gap-1.5'>
|
|
42
84
|
<Button size='sm' onClick={() => onApprove(request.id)}>
|
|
43
85
|
Allow
|
|
44
86
|
</Button>
|
|
45
|
-
<Button size='sm' variant='outline' onClick={() =>
|
|
87
|
+
<Button size='sm' variant='outline' onClick={() => setDenying((v) => !v)}>
|
|
46
88
|
Deny
|
|
47
89
|
</Button>
|
|
90
|
+
<Button size='sm' variant='outline' onClick={() => deny(true)}>
|
|
91
|
+
Deny & stop
|
|
92
|
+
</Button>
|
|
48
93
|
</div>
|
|
49
94
|
</div>
|
|
95
|
+
{denying ? (
|
|
96
|
+
<div className='mt-2.5 flex items-center gap-2 border-t border-warning/30 pt-2.5'>
|
|
97
|
+
<Input
|
|
98
|
+
autoFocus
|
|
99
|
+
value={reason}
|
|
100
|
+
onChange={(e) => setReason(e.target.value)}
|
|
101
|
+
onKeyDown={(e) => {
|
|
102
|
+
if (e.key === 'Enter') deny(false)
|
|
103
|
+
if (e.key === 'Escape') setDenying(false)
|
|
104
|
+
}}
|
|
105
|
+
placeholder='Reason (optional) — the agent reads this and can try something else'
|
|
106
|
+
className='h-7 flex-1'
|
|
107
|
+
/>
|
|
108
|
+
<Button size='sm' variant='outline' onClick={() => setDenying(false)}>
|
|
109
|
+
Cancel
|
|
110
|
+
</Button>
|
|
111
|
+
<Button size='sm' variant='destructive' onClick={() => deny(false)}>
|
|
112
|
+
Deny
|
|
113
|
+
</Button>
|
|
114
|
+
</div>
|
|
115
|
+
) : null}
|
|
50
116
|
</div>
|
|
51
117
|
)
|
|
52
118
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Fragment } from 'react'
|
|
2
|
+
import { scanPromptTokens } from '@workerdeck/react'
|
|
3
|
+
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A sent message, with its `@file` and `/command` tokens styled the way the CLI
|
|
7
|
+
* writes them: monospace and tinted, no background — the bubble already has one,
|
|
8
|
+
* and a second fill inside it reads as a button.
|
|
9
|
+
*
|
|
10
|
+
* Literal text, never markdown: what was typed is what was sent. The one pass
|
|
11
|
+
* over it is this, so a message reads the same after sending as it did in the
|
|
12
|
+
* composer. Two tokens, two meanings — a file is a reference, a command is an
|
|
13
|
+
* action — so they are told apart by hue rather than by shape alone.
|
|
14
|
+
*/
|
|
15
|
+
export function PromptTokenText({ text, className }: { text: string; className?: string }) {
|
|
16
|
+
const tokens = scanPromptTokens(text)
|
|
17
|
+
if (tokens.length === 0) return <span className={className}>{text}</span>
|
|
18
|
+
const parts: React.ReactNode[] = []
|
|
19
|
+
let cursor = 0
|
|
20
|
+
for (const [index, token] of tokens.entries()) {
|
|
21
|
+
if (token.start > cursor) parts.push(text.slice(cursor, token.start))
|
|
22
|
+
parts.push(
|
|
23
|
+
<span
|
|
24
|
+
key={`${token.start}-${index}`}
|
|
25
|
+
className={cn('font-mono', token.kind === 'file' ? 'text-accent' : 'text-info')}>
|
|
26
|
+
{token.text}
|
|
27
|
+
</span>,
|
|
28
|
+
)
|
|
29
|
+
cursor = token.end
|
|
30
|
+
}
|
|
31
|
+
if (cursor < text.length) parts.push(text.slice(cursor))
|
|
32
|
+
return (
|
|
33
|
+
<span className={className}>
|
|
34
|
+
{parts.map((part, index) => (
|
|
35
|
+
<Fragment key={index}>{part}</Fragment>
|
|
36
|
+
))}
|
|
37
|
+
</span>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { AtSign, MessageSquareText, SlashSquare, Sparkles, Terminal } from 'lucide-react'
|
|
2
|
+
import { cn } from '../../lib/utils.ts'
|
|
3
|
+
|
|
4
|
+
export interface SessionEmptyStateProps {
|
|
5
|
+
cwd?: string
|
|
6
|
+
/** Whether `/command` completion is live yet — the CLI reports its commands a
|
|
7
|
+
* beat after the session starts, and promising a feature that isn't wired up
|
|
8
|
+
* yet is worse than not mentioning it. */
|
|
9
|
+
hasCommands?: boolean
|
|
10
|
+
/** Whether the engine has reported skills the `/` popover can offer. Its own
|
|
11
|
+
* flag, not a variant of `hasCommands`: what `/` does differs — a command is
|
|
12
|
+
* submitted, a skill is typed for you to edit — and an engine can have one
|
|
13
|
+
* without the other. */
|
|
14
|
+
hasSkills?: boolean
|
|
15
|
+
/** Whether this gateway serves `@file` search for the session's directory. */
|
|
16
|
+
canBrowseFiles?: boolean
|
|
17
|
+
className?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What a session shows before it has said anything: where the agent is sitting,
|
|
22
|
+
* and what the composer accepts beyond prose.
|
|
23
|
+
*
|
|
24
|
+
* Every hint is conditional on the affordance actually existing — an engine
|
|
25
|
+
* without slash commands, or a gateway without host files, is not told about
|
|
26
|
+
* them. Deliberately no project name (the header already carries it) and no
|
|
27
|
+
* brand mark (its geometry is inlined in several places already and they are
|
|
28
|
+
* meant to stay identical).
|
|
29
|
+
*/
|
|
30
|
+
export function SessionEmptyState({
|
|
31
|
+
cwd,
|
|
32
|
+
hasCommands,
|
|
33
|
+
hasSkills,
|
|
34
|
+
canBrowseFiles,
|
|
35
|
+
className,
|
|
36
|
+
}: SessionEmptyStateProps) {
|
|
37
|
+
const hints = [
|
|
38
|
+
{ icon: MessageSquareText, text: 'Tell the agent what to do.' },
|
|
39
|
+
// Two keys, two hints — they are different features, not two spellings of
|
|
40
|
+
// one. `$` is codex's own sigil for skills; `/` stays the CLI's commands.
|
|
41
|
+
...(hasCommands ? [{ icon: SlashSquare, text: 'Type / for the CLI’s slash commands.' }] : []),
|
|
42
|
+
...(hasSkills
|
|
43
|
+
? [{ icon: Sparkles, text: 'Type $ to draft a message for one of the agent’s skills.' }]
|
|
44
|
+
: []),
|
|
45
|
+
...(canBrowseFiles
|
|
46
|
+
? [{ icon: AtSign, text: 'Type @ to search this project’s files.' }]
|
|
47
|
+
: []),
|
|
48
|
+
]
|
|
49
|
+
return (
|
|
50
|
+
<div className={cn('flex flex-col items-center gap-4 py-10', className)}>
|
|
51
|
+
<div className='flex size-12 items-center justify-center rounded-xl bg-surface text-fg-3'>
|
|
52
|
+
<Terminal className='size-5' />
|
|
53
|
+
</div>
|
|
54
|
+
{cwd ? <p className='max-w-full truncate font-mono text-label text-fg-4'>{cwd}</p> : null}
|
|
55
|
+
<ul className='w-full max-w-sm divide-y divide-border overflow-hidden rounded-lg bg-surface'>
|
|
56
|
+
{hints.map((hint) => (
|
|
57
|
+
<li key={hint.text} className='flex items-center gap-3 px-3.5 py-2.5'>
|
|
58
|
+
<hint.icon className='size-4 shrink-0 text-fg-4' />
|
|
59
|
+
<span className='text-body-sm text-fg-3'>{hint.text}</span>
|
|
60
|
+
</li>
|
|
61
|
+
))}
|
|
62
|
+
</ul>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { WorkerDeckClient } from '@workerdeck/client'
|
|
3
|
+
import type { SessionFileInfo } from '@workerdeck/protocol'
|
|
4
|
+
import type { TranscriptState } from '@workerdeck/react'
|
|
5
|
+
import { Download } from 'lucide-react'
|
|
6
|
+
import { CopyButton } from '../ui/CopyButton.tsx'
|
|
7
|
+
import { Dialog, DialogBody, DialogContent, DialogHeader, DialogRow } from '../ui/Dialog.tsx'
|
|
8
|
+
import { Spinner } from '../ui/Spinner.tsx'
|
|
9
|
+
import { formatBytes, formatCost, formatRelativeTime } from '../../lib/format.ts'
|
|
10
|
+
import { permissionModeMeta } from './PermissionModeSelect.tsx'
|
|
11
|
+
|
|
12
|
+
export interface SessionInfoDialogProps {
|
|
13
|
+
state: TranscriptState
|
|
14
|
+
client: WorkerDeckClient
|
|
15
|
+
sessionId: string | undefined
|
|
16
|
+
open: boolean
|
|
17
|
+
onOpenChange: (open: boolean) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What this session *is*: engine, profile, model, mode, where it runs, which
|
|
22
|
+
* credentials it found, and the files it has handed over.
|
|
23
|
+
*
|
|
24
|
+
* The identity half of the session's facts. Context and usage have their own
|
|
25
|
+
* panels — they change every turn and are consulted mid-run, while everything
|
|
26
|
+
* here is fixed at creation and looked up once.
|
|
27
|
+
*/
|
|
28
|
+
export function SessionInfoDialog({
|
|
29
|
+
state,
|
|
30
|
+
client,
|
|
31
|
+
sessionId,
|
|
32
|
+
open,
|
|
33
|
+
onOpenChange,
|
|
34
|
+
}: SessionInfoDialogProps) {
|
|
35
|
+
const session = state.session
|
|
36
|
+
const mode = state.permissionMode ? permissionModeMeta(state.permissionMode) : undefined
|
|
37
|
+
return (
|
|
38
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
39
|
+
<DialogContent>
|
|
40
|
+
<DialogHeader title='Session info' description={session?.title} />
|
|
41
|
+
<DialogBody>
|
|
42
|
+
<div className='flex flex-col divide-y divide-border'>
|
|
43
|
+
<div className='pb-2'>
|
|
44
|
+
<DialogRow label='Engine'>{state.engine ?? 'claude'}</DialogRow>
|
|
45
|
+
{session?.profile ? (
|
|
46
|
+
<DialogRow label='Profile'>{session.profile}</DialogRow>
|
|
47
|
+
) : null}
|
|
48
|
+
{state.model ? (
|
|
49
|
+
<DialogRow label='Model' mono>
|
|
50
|
+
{state.model}
|
|
51
|
+
</DialogRow>
|
|
52
|
+
) : null}
|
|
53
|
+
{mode ? <DialogRow label='Permission mode'>{mode.label}</DialogRow> : null}
|
|
54
|
+
{session?.apiKeySource ? (
|
|
55
|
+
<DialogRow label='Credentials'>{session.apiKeySource}</DialogRow>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
<div className='py-2'>
|
|
59
|
+
{state.cwd ? <CopyRow label='Working directory' value={state.cwd} /> : null}
|
|
60
|
+
{state.sdkSessionId ? (
|
|
61
|
+
<CopyRow label='Engine session id' value={state.sdkSessionId} />
|
|
62
|
+
) : null}
|
|
63
|
+
{session ? <CopyRow label='Gateway session id' value={session.id} /> : null}
|
|
64
|
+
</div>
|
|
65
|
+
<div className='py-2'>
|
|
66
|
+
{session?.createdAt ? (
|
|
67
|
+
<DialogRow label='Started'>{formatRelativeTime(session.createdAt)}</DialogRow>
|
|
68
|
+
) : null}
|
|
69
|
+
{session?.numTurns !== undefined ? (
|
|
70
|
+
<DialogRow label='Turns'>{session.numTurns}</DialogRow>
|
|
71
|
+
) : null}
|
|
72
|
+
<DialogRow label='Cost' mono>
|
|
73
|
+
{formatCost(state.totalCostUsd)}
|
|
74
|
+
</DialogRow>
|
|
75
|
+
</div>
|
|
76
|
+
{/* Only for an engine that has a scratch VFS — for the rest the route
|
|
77
|
+
404s, and a "Files" heading over nothing reads as a failed load. */}
|
|
78
|
+
{state.capabilities.vfs ? (
|
|
79
|
+
<SessionFiles client={client} sessionId={sessionId} open={open} />
|
|
80
|
+
) : null}
|
|
81
|
+
</div>
|
|
82
|
+
</DialogBody>
|
|
83
|
+
</DialogContent>
|
|
84
|
+
</Dialog>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Long ids are for pasting elsewhere, so give them a copy target. */
|
|
89
|
+
function CopyRow({ label, value }: { label: string; value: string }) {
|
|
90
|
+
return (
|
|
91
|
+
<div className='flex items-start justify-between gap-2 py-1.5'>
|
|
92
|
+
<div className='min-w-0 flex-1'>
|
|
93
|
+
<div className='text-label text-fg-3'>{label}</div>
|
|
94
|
+
<div className='mt-0.5 font-mono text-label break-all text-fg-1'>{value}</div>
|
|
95
|
+
</div>
|
|
96
|
+
<CopyButton value={value} aria-label={`Copy ${label.toLowerCase()}`} />
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Files the agent produced inside the session's VFS. Fetched when the panel
|
|
102
|
+
* opens — this is not a live list and nothing pushes changes to it. */
|
|
103
|
+
function SessionFiles({
|
|
104
|
+
client,
|
|
105
|
+
sessionId,
|
|
106
|
+
open,
|
|
107
|
+
}: {
|
|
108
|
+
client: WorkerDeckClient
|
|
109
|
+
sessionId: string | undefined
|
|
110
|
+
open: boolean
|
|
111
|
+
}) {
|
|
112
|
+
const [files, setFiles] = useState<SessionFileInfo[] | undefined>()
|
|
113
|
+
const [loading, setLoading] = useState(false)
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!open || !sessionId) return
|
|
117
|
+
let cancelled = false
|
|
118
|
+
setLoading(true)
|
|
119
|
+
client
|
|
120
|
+
.listSessionFiles(sessionId)
|
|
121
|
+
.then((list) => {
|
|
122
|
+
if (!cancelled) setFiles(list)
|
|
123
|
+
})
|
|
124
|
+
.catch(() => {
|
|
125
|
+
if (!cancelled) setFiles([])
|
|
126
|
+
})
|
|
127
|
+
.finally(() => {
|
|
128
|
+
if (!cancelled) setLoading(false)
|
|
129
|
+
})
|
|
130
|
+
return () => {
|
|
131
|
+
cancelled = true
|
|
132
|
+
}
|
|
133
|
+
}, [client, sessionId, open])
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div className='pt-3'>
|
|
137
|
+
<h3 className='text-label font-medium text-fg-3'>Files</h3>
|
|
138
|
+
{loading ? (
|
|
139
|
+
<div className='py-3 text-center'>
|
|
140
|
+
<Spinner className='size-4 text-fg-4' />
|
|
141
|
+
</div>
|
|
142
|
+
) : !files?.length ? (
|
|
143
|
+
<p className='py-2 text-body-sm text-fg-4'>Nothing delivered yet.</p>
|
|
144
|
+
) : (
|
|
145
|
+
<ul className='mt-1 flex flex-col'>
|
|
146
|
+
{files.map((file) => (
|
|
147
|
+
<li key={file.path}>
|
|
148
|
+
<a
|
|
149
|
+
href={sessionId ? client.sessionFileUrl(sessionId, file.path) : undefined}
|
|
150
|
+
className='flex items-center gap-2 rounded-md px-1 py-1.5 hover:bg-surface-hover'>
|
|
151
|
+
<Download className='size-3.5 shrink-0 text-fg-4' />
|
|
152
|
+
<span className='min-w-0 flex-1 truncate font-mono text-label text-fg-1'>
|
|
153
|
+
{file.path}
|
|
154
|
+
</span>
|
|
155
|
+
<span className='shrink-0 text-label text-fg-4'>{formatBytes(file.bytes)}</span>
|
|
156
|
+
</a>
|
|
157
|
+
</li>
|
|
158
|
+
))}
|
|
159
|
+
</ul>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
)
|
|
163
|
+
}
|