@workerdeck/ui 0.7.0 → 0.11.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 (61) hide show
  1. package/README.md +81 -3
  2. package/build/SessionPanel-CyhygZx_.d.mts +277 -0
  3. package/build/SessionPanel-_U8tjX29.mjs +8409 -0
  4. package/build/SessionPanel-_U8tjX29.mjs.map +1 -0
  5. package/build/format-DqR56Y8l.mjs +162 -0
  6. package/build/format-DqR56Y8l.mjs.map +1 -0
  7. package/build/format-ljc3lKpA.d.mts +59 -0
  8. package/build/format.d.mts +2 -0
  9. package/build/format.mjs +2 -0
  10. package/build/index.d.mts +615 -87
  11. package/build/index.mjs +6 -5081
  12. package/build/index.mjs.map +1 -1
  13. package/build/workspace.d.mts +199 -0
  14. package/build/workspace.mjs +849 -0
  15. package/build/workspace.mjs.map +1 -0
  16. package/package.json +22 -4
  17. package/src/components/agent/CodeEditor.tsx +300 -0
  18. package/src/components/agent/Composer.tsx +522 -87
  19. package/src/components/agent/ContextDialog.tsx +99 -0
  20. package/src/components/agent/Conversation.tsx +11 -3
  21. package/src/components/agent/EditorTabs.tsx +165 -0
  22. package/src/components/agent/FileCard.tsx +26 -0
  23. package/src/components/agent/FileTree.tsx +287 -0
  24. package/src/components/agent/FileViewer.tsx +148 -0
  25. package/src/components/agent/HostFilesDialog.tsx +218 -0
  26. package/src/components/agent/Loader.tsx +120 -14
  27. package/src/components/agent/McpDialog.tsx +363 -0
  28. package/src/components/agent/Message.tsx +51 -17
  29. package/src/components/agent/ModelSelect.tsx +34 -6
  30. package/src/components/agent/PermissionModeSelect.tsx +133 -22
  31. package/src/components/agent/PermissionPrompt.tsx +164 -6
  32. package/src/components/agent/PromptTokenText.tsx +39 -0
  33. package/src/components/agent/QuestionPrompt.tsx +122 -0
  34. package/src/components/agent/Reasoning.tsx +20 -5
  35. package/src/components/agent/Response.tsx +128 -0
  36. package/src/components/agent/SessionEmptyState.tsx +65 -0
  37. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  38. package/src/components/agent/SessionPanel.tsx +756 -90
  39. package/src/components/agent/SessionWorkspace.tsx +282 -0
  40. package/src/components/agent/SkillsDialog.tsx +195 -0
  41. package/src/components/agent/StatusBar.tsx +85 -18
  42. package/src/components/agent/ToolCallCard.tsx +243 -30
  43. package/src/components/agent/Transcript.tsx +540 -27
  44. package/src/components/agent/UsageDialog.tsx +168 -0
  45. package/src/components/agent/line-prompt.tsx +249 -0
  46. package/src/components/agent/transcript-variant.tsx +61 -0
  47. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  48. package/src/components/prompt-area/types.ts +15 -0
  49. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  50. package/src/components/ui/CodeBlock.tsx +40 -2
  51. package/src/components/ui/CopyButton.tsx +28 -3
  52. package/src/components/ui/Dialog.tsx +92 -0
  53. package/src/components/ui/Menu.tsx +55 -0
  54. package/src/components/ui/Splitter.tsx +133 -0
  55. package/src/components/ui/Tooltip.tsx +22 -5
  56. package/src/format.ts +10 -0
  57. package/src/index.ts +63 -2
  58. package/src/lib/clipboard.ts +56 -0
  59. package/src/lib/format.ts +114 -0
  60. package/src/lib/tool-icon.ts +96 -0
  61. package/src/workspace.ts +28 -0
@@ -1,6 +1,131 @@
1
1
  import { memo } from 'react'
2
2
  import { Streamdown } from 'streamdown'
3
3
  import { cn } from '../../lib/utils.ts'
4
+ import { useLines } from './transcript-variant.tsx'
5
+
6
+ /**
7
+ * Markdown on a terminal's grid.
8
+ *
9
+ * Prose defaults give every block its own rhythm — a heading is bigger, a list
10
+ * item is looser, an inline `code` chip is padded taller than the line it sits
11
+ * in — and the result is text that visibly breathes at different rates as you
12
+ * scroll. A terminal has exactly one line height and one type size, and that
13
+ * uniformity is most of what makes it readable at a glance.
14
+ *
15
+ * So: every line 1.25rem, every block flush, and one blank line (1.25rem, i.e.
16
+ * exactly one row) between blocks. Headings keep their weight but lose their
17
+ * size. Inline code keeps a background — it still has to be distinguishable —
18
+ * but no vertical padding, because that is what pushes a line off the grid.
19
+ */
20
+ const TERMINAL_PROSE = [
21
+ 'text-body-sm leading-5',
22
+ '[&_*]:leading-5',
23
+ // Block rhythm: nothing has its own margins, and siblings are one row apart.
24
+ // `!` throughout because these fight the renderer's own utilities, and some of
25
+ // those (`space-y-*`, which is a `> * + *` rule) outrank a plain descendant
26
+ // selector — a list would otherwise keep its half-row gaps while every other
27
+ // block snapped to the grid.
28
+ '[&_p]:my-0! [&_pre]:my-0! [&_blockquote]:my-0! [&_table]:my-0!',
29
+ '[&_h1]:my-0! [&_h2]:my-0! [&_h3]:my-0! [&_h4]:my-0! [&_h5]:my-0! [&_h6]:my-0!',
30
+ '[&>*+*]:mt-5',
31
+ // Lists are rows, not paragraphs: no margin anywhere in them, and no
32
+ // inter-item spacing from a `space-y` on the list itself.
33
+ '[&_ul]:my-0! [&_ol]:my-0! [&_li]:my-0! [&_li>p]:my-0!',
34
+ '[&_li+li]:mt-0! [&_ul_ul]:mt-0! [&_ul_ol]:mt-0! [&_ol_ul]:mt-0! [&_ol_ol]:mt-0!',
35
+ '[&_li]:py-0! [&_ul]:py-0! [&_ol]:py-0!',
36
+ // Markers on their own column, at the width the marker actually is — the
37
+ // indent a terminal (and the markdown source) uses, not a typographic one.
38
+ // `ch` is the unit because this variant runs in a monospace face: `• ` is two
39
+ // cells, `1. ` is three, so the text starts exactly where it would in the raw
40
+ // document and a wrapped line hangs under it instead of under the bullet.
41
+ // Nesting then costs one marker width per level, which is the 2-space step.
42
+ // `list-outside` is the load-bearing half: the renderer marks lists `inside`,
43
+ // where the marker joins the text flow — so the indent moves the bullet too
44
+ // (the list sits a marker-width right of the paragraph above it) and a wrapped
45
+ // line runs back under the bullet instead of hanging under its own text.
46
+ '[&_ul]:list-outside! [&_ol]:list-outside!',
47
+ '[&_ul]:pl-[2ch]! [&_ol]:pl-[3ch]! [&_li]:pl-0!',
48
+ // Headings are weight, not size — a terminal has one type size.
49
+ '[&_h1]:text-body-sm [&_h2]:text-body-sm [&_h3]:text-body-sm',
50
+ '[&_h4]:text-body-sm [&_h5]:text-body-sm [&_h6]:text-body-sm',
51
+ '[&_h1]:font-semibold [&_h2]:font-semibold [&_h3]:font-semibold',
52
+ '[&_h4]:font-semibold [&_h5]:font-semibold [&_h6]:font-semibold',
53
+ '[&_blockquote]:border-l [&_blockquote]:border-border [&_blockquote]:pl-2',
54
+ // Inline code only (`:not(pre) > code`) — fenced blocks keep their own box.
55
+ // Horizontal padding is free; vertical padding is what pushes a line off grid.
56
+ '[&_:not(pre)>code]:rounded-none [&_:not(pre)>code]:px-1! [&_:not(pre)>code]:py-0!',
57
+ '[&_:not(pre)>code]:text-body-sm [&_:not(pre)>code]:leading-5! [&_:not(pre)>code]:text-fg-1',
58
+ '[&_th]:py-0! [&_td]:py-0!',
59
+ // The renderer's fenced-code card: rounded frame, its own header strip, a
60
+ // second rounded frame inside it, and a floating pill for the buttons. Four
61
+ // boxes around what is, in a terminal, a band of dim text. Flattened to
62
+ // exactly that — the band keeps a background so a block still reads as code,
63
+ // and the actions keep their top-right corner but lose the pill.
64
+ '[&_[data-streamdown=code-block]]:my-0! [&_[data-streamdown=code-block]]:gap-0!',
65
+ '[&_[data-streamdown=code-block]]:rounded-none! [&_[data-streamdown=code-block]]:border-0!',
66
+ '[&_[data-streamdown=code-block]]:bg-transparent! [&_[data-streamdown=code-block]]:p-0!',
67
+ // The language strip costs a whole row to say what the code already shows, and
68
+ // the actions cost another. Both go: the block is the band, and the buttons
69
+ // float over its top-right corner, appearing on hover or keyboard focus.
70
+ '[&_[data-streamdown=code-block]]:relative!',
71
+ '[&_[data-streamdown=code-block-header]]:hidden!',
72
+ '[&_div:has(>[data-streamdown=code-block-actions])]:absolute! [&_div:has(>[data-streamdown=code-block-actions])]:top-0!',
73
+ '[&_div:has(>[data-streamdown=code-block-actions])]:right-0! [&_div:has(>[data-streamdown=code-block-actions])]:mt-0!',
74
+ '[&_div:has(>[data-streamdown=code-block-actions])]:h-5! [&_div:has(>[data-streamdown=code-block-actions])]:opacity-0!',
75
+ '[&_div:has(>[data-streamdown=code-block-actions])]:transition-opacity',
76
+ '[&_[data-streamdown=code-block]:hover_div:has(>[data-streamdown=code-block-actions])]:opacity-100!',
77
+ '[&_[data-streamdown=code-block]:focus-within_div:has(>[data-streamdown=code-block-actions])]:opacity-100!',
78
+ '[&_[data-streamdown=code-block-actions]]:rounded-none! [&_[data-streamdown=code-block-actions]]:border-0!',
79
+ '[&_[data-streamdown=code-block-actions]]:bg-code-bg! [&_[data-streamdown=code-block-actions]]:px-1!',
80
+ '[&_[data-streamdown=code-block-actions]]:py-0! [&_[data-streamdown=code-block-actions]]:gap-1!',
81
+ '[&_[data-streamdown=code-block-actions]]:backdrop-blur-none!',
82
+ '[&_[data-streamdown=code-block-body]]:rounded-none! [&_[data-streamdown=code-block-body]]:border-0!',
83
+ '[&_[data-streamdown=code-block-body]]:bg-code-bg! [&_[data-streamdown=code-block-body]]:px-2!',
84
+ '[&_[data-streamdown=code-block-body]]:py-0! [&_[data-streamdown=code-block-body]]:text-body-sm!',
85
+ // The action buttons' hit areas, on the grid like everything else.
86
+ '[&_[data-streamdown=code-block-copy-button]]:p-0! [&_[data-streamdown=code-block-download-button]]:p-0!',
87
+ // Tables come in a frame of their own, plus a controls row above them and a
88
+ // second frame around the scroller — three boxes and a blank line to hold
89
+ // three buttons. Same treatment: the table is the content, the buttons float
90
+ // over its top-right on hover. The controls row is the child that has no
91
+ // `<table>` in it, which is also how it knows to disappear when the renderer
92
+ // draws no controls at all.
93
+ '[&_[data-streamdown=table-wrapper]]:rounded-none! [&_[data-streamdown=table-wrapper]]:border-0!',
94
+ '[&_[data-streamdown=table-wrapper]]:my-0! [&_[data-streamdown=table-wrapper]]:bg-transparent!',
95
+ '[&_[data-streamdown=table-wrapper]]:relative! [&_[data-streamdown=table-wrapper]]:gap-0!',
96
+ '[&_[data-streamdown=table-wrapper]]:p-0!',
97
+ '[&_[data-streamdown=table-wrapper]>div:not(:has(table))]:absolute! [&_[data-streamdown=table-wrapper]>div:not(:has(table))]:top-0!',
98
+ '[&_[data-streamdown=table-wrapper]>div:not(:has(table))]:right-0! [&_[data-streamdown=table-wrapper]>div:not(:has(table))]:z-10!',
99
+ '[&_[data-streamdown=table-wrapper]>div:not(:has(table))]:opacity-0! [&_[data-streamdown=table-wrapper]>div:not(:has(table))]:bg-code-bg!',
100
+ '[&_[data-streamdown=table-wrapper]>div:not(:has(table))]:px-1! [&_[data-streamdown=table-wrapper]>div:not(:has(table))]:transition-opacity',
101
+ '[&_[data-streamdown=table-wrapper]:hover>div:not(:has(table))]:opacity-100!',
102
+ '[&_[data-streamdown=table-wrapper]:focus-within>div:not(:has(table))]:opacity-100!',
103
+ '[&_[data-streamdown=table-wrapper]>div:has(table)]:rounded-none! [&_[data-streamdown=table-wrapper]>div:has(table)]:border-0!',
104
+ '[&_[data-streamdown=table-wrapper]>div:has(table)]:bg-transparent!',
105
+ ].join(' ')
106
+
107
+ /**
108
+ * The controls' icons as characters.
109
+ *
110
+ * The renderer draws its copy/download affordances as line-art SVGs, which next
111
+ * to monospace text read as buttons from another application. These are the
112
+ * glyphs a terminal would use — and they inherit the surrounding font, so they
113
+ * sit on the same grid as the code they belong to.
114
+ */
115
+ const glyphIcon = (glyph: string) =>
116
+ function GlyphIcon({ className }: { className?: string }) {
117
+ return (
118
+ <span aria-hidden className={cn('font-mono text-body-sm leading-5', className)}>
119
+ {glyph}
120
+ </span>
121
+ )
122
+ }
123
+
124
+ const TERMINAL_ICONS = {
125
+ DownloadIcon: glyphIcon('⤓'),
126
+ CopyIcon: glyphIcon('⧉'),
127
+ CheckIcon: glyphIcon('✓'),
128
+ }
4
129
 
5
130
  export interface ResponseProps {
6
131
  children: string
@@ -13,13 +138,16 @@ export interface ResponseProps {
13
138
  * highlighted with shiki (dual theme follows [data-theme] through the dark: variant). */
14
139
  export const Response = memo(
15
140
  function Response({ children, streaming, className }: ResponseProps) {
141
+ const lines = useLines()
16
142
  return (
17
143
  <Streamdown
18
144
  mode={streaming ? 'streaming' : 'static'}
19
145
  parseIncompleteMarkdown={streaming}
20
146
  shikiTheme={['github-light', 'github-dark']}
147
+ icons={lines ? TERMINAL_ICONS : undefined}
21
148
  className={cn(
22
149
  'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
150
+ lines && TERMINAL_PROSE,
23
151
  className,
24
152
  )}>
25
153
  {children}
@@ -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
+ }