@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.
Files changed (38) hide show
  1. package/README.md +44 -3
  2. package/build/index.d.mts +762 -21
  3. package/build/index.mjs +3245 -348
  4. package/build/index.mjs.map +1 -1
  5. package/package.json +5 -4
  6. package/src/components/agent/CodeEditor.tsx +300 -0
  7. package/src/components/agent/Composer.tsx +379 -56
  8. package/src/components/agent/ContextDialog.tsx +99 -0
  9. package/src/components/agent/EditorTabs.tsx +165 -0
  10. package/src/components/agent/FileTree.tsx +287 -0
  11. package/src/components/agent/FileViewer.tsx +148 -0
  12. package/src/components/agent/HostFilesDialog.tsx +218 -0
  13. package/src/components/agent/McpDialog.tsx +363 -0
  14. package/src/components/agent/ModelSelect.tsx +34 -6
  15. package/src/components/agent/PermissionModeSelect.tsx +99 -22
  16. package/src/components/agent/PermissionPrompt.tsx +72 -6
  17. package/src/components/agent/PromptTokenText.tsx +39 -0
  18. package/src/components/agent/SessionEmptyState.tsx +65 -0
  19. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  20. package/src/components/agent/SessionPanel.tsx +379 -40
  21. package/src/components/agent/SessionWorkspace.tsx +282 -0
  22. package/src/components/agent/SkillsDialog.tsx +195 -0
  23. package/src/components/agent/StatusBar.tsx +85 -18
  24. package/src/components/agent/ToolCallCard.tsx +80 -4
  25. package/src/components/agent/Transcript.tsx +66 -17
  26. package/src/components/agent/UsageDialog.tsx +168 -0
  27. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  28. package/src/components/prompt-area/types.ts +15 -0
  29. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  30. package/src/components/ui/CopyButton.tsx +8 -1
  31. package/src/components/ui/Dialog.tsx +92 -0
  32. package/src/components/ui/Menu.tsx +55 -0
  33. package/src/components/ui/Splitter.tsx +133 -0
  34. package/src/components/ui/Tooltip.tsx +22 -5
  35. package/src/index.ts +53 -1
  36. package/src/lib/clipboard.ts +56 -0
  37. package/src/lib/format.ts +48 -0
  38. 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
- /** The modes surfaced across UI surfaces (session creation, in-session switcher). */
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
- { value: 'default', label: 'default', description: 'ask for approval' },
22
- { value: 'acceptEdits', label: 'acceptEdits', description: 'auto-approve file edits' },
23
- { value: 'plan', label: 'plan', description: 'read-only planning' },
24
- { value: 'auto', label: 'auto', description: 'model decides when to ask' },
25
- { value: 'dontAsk', label: 'dontAsk', description: 'never ask deny unapproved' },
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: 'bypassPermissions',
29
- description: 'no prompts (danger)',
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 provider engine has no meaning for. Defaults to all of them;
40
- * pass `PROVIDER_PERMISSION_MODES` for a provider session. */
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' && 'font-mono text-label')}>
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-56'>
81
- {offered.map((m) => (
82
- <SelectItem key={m.value} value={m.value}>
83
- <SelectItemText>
84
- <span className={cn('font-medium', m.dangerous && 'text-danger')}>{m.label}</span>
85
- </SelectItemText>
86
- <span className={cn('text-label', m.dangerous ? 'text-danger/80' : 'text-fg-4')}>
87
- {m.description}
88
- </span>
89
- </SelectItem>
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 { ShieldAlert } from 'lucide-react'
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
- onDeny: (requestId: string, message?: string) => void
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
- <ShieldAlert className='mt-0.5 size-4 shrink-0 text-warning' />
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 ?? `Claude wants to use ${request.toolName}`}
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={() => onDeny(request.id, 'Denied by user')}>
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 &amp; 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
+ }