@workerdeck/ui 0.22.0 → 1.0.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 (145) hide show
  1. package/build/{SessionPanel-DgwjH4Ve.mjs → SessionPanel-DDgBkHsi.mjs} +2196 -3364
  2. package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
  3. package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
  4. package/build/format-B8ZM9LPy.d.mts +16 -0
  5. package/build/format.d.mts +8 -75
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +63 -1138
  8. package/build/index.mjs +4 -300
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +80 -24
  11. package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
  12. package/build/status-C0HSLZas.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -124
  14. package/build/workspace.mjs +13 -143
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +4 -4
  17. package/src/components/agent/CodeEditor.tsx +31 -103
  18. package/src/components/agent/Composer.tsx +114 -333
  19. package/src/components/agent/ContextDialog.tsx +20 -41
  20. package/src/components/agent/ContextRing.tsx +6 -28
  21. package/src/components/agent/Conversation.tsx +21 -33
  22. package/src/components/agent/EditorTabs.tsx +39 -67
  23. package/src/components/agent/EngineIcon.tsx +29 -72
  24. package/src/components/agent/EntryIcon.tsx +16 -0
  25. package/src/components/agent/FileCard.tsx +10 -16
  26. package/src/components/agent/FileTree.tsx +52 -125
  27. package/src/components/agent/FileViewer.tsx +30 -60
  28. package/src/components/agent/HostFilesDialog.tsx +40 -69
  29. package/src/components/agent/Loader.tsx +3 -24
  30. package/src/components/agent/McpDialog.tsx +64 -137
  31. package/src/components/agent/Message.tsx +2 -21
  32. package/src/components/agent/ModelSelect.tsx +33 -62
  33. package/src/components/agent/PermissionModeSelect.tsx +21 -79
  34. package/src/components/agent/PermissionPrompt.tsx +59 -63
  35. package/src/components/agent/ProjectIcon.tsx +228 -93
  36. package/src/components/agent/PromptTokenText.tsx +10 -16
  37. package/src/components/agent/QuestionPrompt.tsx +43 -64
  38. package/src/components/agent/Reasoning.tsx +13 -16
  39. package/src/components/agent/Response.tsx +4 -6
  40. package/src/components/agent/Scrubber.tsx +31 -80
  41. package/src/components/agent/SessionBrowser.tsx +64 -243
  42. package/src/components/agent/SessionEmptyState.tsx +10 -40
  43. package/src/components/agent/SessionInfoDialog.tsx +46 -78
  44. package/src/components/agent/SessionItem.tsx +53 -320
  45. package/src/components/agent/SessionList.tsx +23 -39
  46. package/src/components/agent/SessionPanel.tsx +345 -923
  47. package/src/components/agent/SessionStatusIcon.tsx +14 -28
  48. package/src/components/agent/SessionSteps.tsx +34 -187
  49. package/src/components/agent/SessionWorkspace.tsx +108 -215
  50. package/src/components/agent/SkillsDialog.tsx +35 -80
  51. package/src/components/agent/StatusBar.tsx +51 -136
  52. package/src/components/agent/SubagentStrip.tsx +12 -77
  53. package/src/components/agent/ToolCallCard.tsx +94 -135
  54. package/src/components/agent/Transcript.tsx +103 -1288
  55. package/src/components/agent/TranscriptItemView.tsx +155 -0
  56. package/src/components/agent/TranscriptRows.tsx +418 -0
  57. package/src/components/agent/UsageDialog.tsx +7 -29
  58. package/src/components/agent/UsageMeters.tsx +18 -73
  59. package/src/components/agent/pulse.tsx +9 -32
  60. package/src/components/agent/scrubber-marks.ts +33 -103
  61. package/src/components/agent/status.ts +1 -5
  62. package/src/components/agent/tool-result-fetch.tsx +3 -25
  63. package/src/components/agent/tool-result-image.tsx +32 -111
  64. package/src/components/agent/transcript-rows.ts +41 -118
  65. package/src/components/agent/transcript-variant.tsx +3 -80
  66. package/src/components/agent/use-height-epoch.ts +10 -24
  67. package/src/components/agent/use-path-links.ts +45 -63
  68. package/src/components/agent/use-subagent-frame.ts +106 -0
  69. package/src/components/agent/use-transcript-jumps.ts +49 -107
  70. package/src/components/prompt-area/animated-placeholder.tsx +6 -5
  71. package/src/components/prompt-area/clipboard-helpers.ts +26 -21
  72. package/src/components/prompt-area/cursor-helpers.ts +79 -37
  73. package/src/components/prompt-area/dom-helpers.ts +97 -69
  74. package/src/components/prompt-area/file-strip.tsx +90 -80
  75. package/src/components/prompt-area/html-to-markdown.ts +67 -46
  76. package/src/components/prompt-area/image-strip.tsx +8 -18
  77. package/src/components/prompt-area/index.ts +2 -15
  78. package/src/components/prompt-area/prompt-area-engine.ts +100 -115
  79. package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
  80. package/src/components/prompt-area/prompt-area.tsx +31 -38
  81. package/src/components/prompt-area/remove-button.tsx +3 -9
  82. package/src/components/prompt-area/segment-helpers.ts +4 -12
  83. package/src/components/prompt-area/trigger-popover.tsx +15 -27
  84. package/src/components/prompt-area/trigger-presets.ts +0 -10
  85. package/src/components/prompt-area/types.ts +3 -8
  86. package/src/components/prompt-area/use-chip-editing.ts +305 -0
  87. package/src/components/prompt-area/use-markdown-mode.ts +7 -17
  88. package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
  89. package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
  90. package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
  91. package/src/components/prompt-area/use-prompt-area.ts +170 -896
  92. package/src/components/prompt-area/use-trigger-search.ts +21 -19
  93. package/src/components/terminal/PermissionPrompt.tsx +56 -59
  94. package/src/components/terminal/QuestionPrompt.tsx +28 -79
  95. package/src/components/terminal/StatusLine.tsx +13 -63
  96. package/src/components/terminal/TerminalTranscript.tsx +44 -160
  97. package/src/components/terminal/affordances.tsx +68 -98
  98. package/src/components/terminal/blocks.ts +20 -156
  99. package/src/components/terminal/diff.tsx +19 -67
  100. package/src/components/terminal/height.ts +163 -313
  101. package/src/components/terminal/image-box.ts +0 -44
  102. package/src/components/terminal/items.tsx +150 -305
  103. package/src/components/terminal/markdown.tsx +36 -95
  104. package/src/components/terminal/press.tsx +26 -53
  105. package/src/components/terminal/prompt.tsx +40 -170
  106. package/src/components/terminal/result-preview.ts +8 -56
  107. package/src/components/terminal/row.tsx +11 -98
  108. package/src/components/terminal/scrubber.tsx +108 -401
  109. package/src/components/terminal/surface.tsx +6 -54
  110. package/src/components/terminal/todos.ts +66 -0
  111. package/src/components/terminal/tool-run.ts +21 -192
  112. package/src/components/ui/AlertDialog.tsx +8 -23
  113. package/src/components/ui/Badge.tsx +18 -29
  114. package/src/components/ui/Button.tsx +7 -15
  115. package/src/components/ui/Card.tsx +2 -13
  116. package/src/components/ui/CodeBlock.tsx +10 -37
  117. package/src/components/ui/CopyButton.tsx +10 -23
  118. package/src/components/ui/Dialog.tsx +23 -36
  119. package/src/components/ui/Empty.tsx +6 -29
  120. package/src/components/ui/Input.tsx +14 -16
  121. package/src/components/ui/Menu.tsx +6 -18
  122. package/src/components/ui/PortalScope.tsx +1 -21
  123. package/src/components/ui/ProgressRing.tsx +8 -19
  124. package/src/components/ui/Select.tsx +18 -31
  125. package/src/components/ui/Sonner.tsx +1 -2
  126. package/src/components/ui/Spinner.tsx +1 -1
  127. package/src/components/ui/Splitter.tsx +30 -64
  128. package/src/components/ui/Textarea.tsx +2 -5
  129. package/src/components/ui/Tooltip.tsx +3 -13
  130. package/src/format.ts +0 -9
  131. package/src/index.ts +16 -113
  132. package/src/lib/clipboard.ts +8 -28
  133. package/src/lib/css.ts +8 -0
  134. package/src/lib/format.ts +101 -86
  135. package/src/lib/plan-request.ts +16 -0
  136. package/src/lib/status.ts +45 -95
  137. package/src/lib/tool-icon.ts +39 -47
  138. package/src/lib/utils.ts +1 -3
  139. package/src/styles/terminal.css +81 -49
  140. package/src/styles/theme.css +136 -19
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-13l25ubU.d.mts +0 -609
  143. package/build/SessionPanel-DgwjH4Ve.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. package/build/status-BE-zg88x.mjs.map +0 -1
@@ -1,69 +1,34 @@
1
1
  import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
2
2
  import type { UseHostFileSearchResult, UseHostFileTreeResult } from '@workerdeck/react'
3
- import type { HostDirEntry, HostFileMatch } from '@workerdeck/protocol'
4
- import {
5
- ChevronRight,
6
- File,
7
- Folder,
8
- FolderOpen,
9
- Link2,
10
- PanelLeftClose,
11
- RefreshCw,
12
- Search,
13
- X,
14
- } from 'lucide-react'
3
+ import type { HostFileMatch } from '@workerdeck/protocol'
4
+ import { ChevronRight, File, Folder, FolderOpen, PanelLeftClose, RefreshCw, Search, X } from 'lucide-react'
15
5
  import { cn } from '../../lib/utils.ts'
16
6
  import { Button } from '../ui/Button.tsx'
17
7
  import { Input } from '../ui/Input.tsx'
18
8
  import { Spinner } from '../ui/Spinner.tsx'
9
+ import { EntryIcon } from './EntryIcon.tsx'
19
10
 
20
11
  export interface FileTreeProps {
21
- /** Tree state from `useHostFileTree` — this component holds none of its own. */
22
12
  tree: UseHostFileTreeResult
23
- /** Optional search from `useHostFileSearch`; omit and the box is not offered. */
24
13
  search?: UseHostFileSearchResult
25
- /** Path of the focused file, highlighted in the tree. */
26
14
  activePath?: string
27
15
  onOpenFile: (path: string) => void
28
- /** Offered as a button in the header when given. */
29
16
  onCollapse?: () => void
30
17
  style?: CSSProperties
31
18
  className?: string
32
19
  }
33
20
 
34
- /** How far one level of nesting indents, in pixels. Inline rather than a Tailwind
35
- * class because depth is a number at runtime and `pl-${n}` is not a class. */
36
21
  const INDENT = 12
37
22
 
38
- /**
39
- * The workspace's left rail: an expandable tree of the session's project,
40
- * with a search box over the same fuzzy route `@file` completion uses.
41
- *
42
- * Presentational by construction — every piece of state it renders comes from
43
- * the hooks in `@workerdeck/react`, and the only thing it owns is the search
44
- * query, which is the text in its own input.
45
- *
46
- * Searching replaces the tree with matches rather than filtering it: the route
47
- * answers with paths from all over the project, and threading those back into
48
- * tree positions would mean expanding a dozen directories to show six results.
49
- */
50
- export function FileTree({
51
- tree,
52
- search,
53
- activePath,
54
- onOpenFile,
55
- onCollapse,
56
- style,
57
- className,
58
- }: FileTreeProps) {
23
+ export function FileTree({ tree, search, activePath, onOpenFile, onCollapse, style, className }: FileTreeProps) {
59
24
  const [query, setQuery] = useState('')
60
25
  const [matches, setMatches] = useState<HostFileMatch[] | undefined>()
61
26
  const searching = query.trim().length > 0
62
27
 
63
- // Debounced, and only while there is something to search for — an empty box
64
- // means "show me the tree again", not "match everything".
65
28
  useEffect(() => {
66
- if (!search?.available) return
29
+ if (!search?.available) {
30
+ return
31
+ }
67
32
  const q = query.trim()
68
33
  if (!q) {
69
34
  setMatches(undefined)
@@ -72,7 +37,9 @@ export function FileTree({
72
37
  const controller = new AbortController()
73
38
  const timer = setTimeout(() => {
74
39
  void search.search(q, { limit: 60, signal: controller.signal }).then((found) => {
75
- if (!controller.signal.aborted) setMatches(found)
40
+ if (!controller.signal.aborted) {
41
+ setMatches(found)
42
+ }
76
43
  })
77
44
  }, 150)
78
45
  return () => {
@@ -82,71 +49,61 @@ export function FileTree({
82
49
  }, [search, query])
83
50
 
84
51
  return (
85
- <div
86
- data-slot='file-tree'
87
- style={style}
88
- className={cn('flex min-h-0 min-w-0 flex-col bg-surface', className)}>
89
- <div className='flex items-center gap-1 px-2 pt-2'>
52
+ <div data-slot="file-tree" style={style} className={cn('flex min-h-0 min-w-0 flex-col bg-surface', className)}>
53
+ <div className="flex items-center gap-1 px-2 pt-2">
90
54
  {search?.available ? (
91
- <div className='relative min-w-0 flex-1'>
92
- <Search className='absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-fg-4' />
55
+ <div className="relative min-w-0 flex-1">
56
+ <Search className="absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-fg-4" />
93
57
  <Input
94
58
  value={query}
95
59
  onChange={(e) => setQuery(e.target.value)}
96
- placeholder='Search files…'
97
- className='h-7 pr-7 pl-7 text-label'
60
+ placeholder="Search files…"
61
+ className="h-7 pr-7 pl-7 text-label"
98
62
  spellCheck={false}
99
63
  />
100
64
  {searching ? (
101
65
  <button
102
- type='button'
66
+ type="button"
103
67
  onClick={() => setQuery('')}
104
- aria-label='Clear search'
105
- className='absolute top-1/2 right-1.5 -translate-y-1/2 text-fg-4 transition-colors hover:text-fg-2'>
106
- <X className='size-3.5' />
68
+ aria-label="Clear search"
69
+ className="absolute top-1/2 right-1.5 -translate-y-1/2 text-fg-4 transition-colors hover:text-fg-2"
70
+ >
71
+ <X className="size-3.5" />
107
72
  </button>
108
73
  ) : null}
109
74
  </div>
110
75
  ) : (
111
- <span className='min-w-0 flex-1 truncate px-1 text-label text-fg-4'>{tree.root}</span>
76
+ <span className="min-w-0 flex-1 truncate px-1 text-label text-fg-4">{tree.root}</span>
112
77
  )}
113
- <Button
114
- variant='ghost'
115
- size='icon-sm'
116
- aria-label='Refresh the file tree'
117
- onClick={() => tree.refresh()}>
118
- <RefreshCw className='size-3.5 text-fg-3' />
78
+ <Button variant="ghost" size="icon-sm" aria-label="Refresh the file tree" onClick={() => tree.refresh()}>
79
+ <RefreshCw className="size-3.5 text-fg-3" />
119
80
  </Button>
120
81
  {onCollapse ? (
121
- <Button variant='ghost' size='icon-sm' aria-label='Hide project files' onClick={onCollapse}>
122
- <PanelLeftClose className='size-3.5 text-fg-3' />
82
+ <Button variant="ghost" size="icon-sm" aria-label="Hide project files" onClick={onCollapse}>
83
+ <PanelLeftClose className="size-3.5 text-fg-3" />
123
84
  </Button>
124
85
  ) : null}
125
86
  </div>
126
87
 
127
- <div className='min-h-0 flex-1 overflow-auto px-1 py-1'>
88
+ <div className="min-h-0 flex-1 overflow-auto px-1 py-1">
128
89
  {tree.error ? (
129
- <p className='px-2 py-3 text-body-sm text-danger'>{tree.error}</p>
90
+ <p className="px-2 py-3 text-body-sm text-danger">{tree.error}</p>
130
91
  ) : searching ? (
131
92
  matches === undefined ? (
132
- <div className='py-6 text-center'>
133
- <Spinner className='size-4 text-fg-4' />
93
+ <div className="py-6 text-center">
94
+ <Spinner className="size-4 text-fg-4" />
134
95
  </div>
135
96
  ) : matches.length === 0 ? (
136
- <p className='px-2 py-6 text-center text-body-sm text-fg-4'>No matching files.</p>
97
+ <p className="px-2 py-6 text-center text-body-sm text-fg-4">No matching files.</p>
137
98
  ) : (
138
99
  <ul>
139
100
  {matches.map((match) => (
140
101
  <li key={match.path}>
141
102
  <Row
142
103
  label={fileName(match.relative)}
143
- // The directory, dimmed and truncated separately, so a deep
144
- // path cannot push the filename out of the row — a rail full
145
- // of `apps/ios/DerivedData/B…` says nothing about which file
146
- // each hit is.
147
104
  detail={directoryOf(match.relative)}
148
105
  title={match.relative}
149
- icon={<File className='size-3.5 shrink-0 text-fg-4' />}
106
+ icon={<File className="size-3.5 shrink-0 text-fg-4" />}
150
107
  active={match.path === activePath}
151
108
  onClick={() => onOpenFile(match.path)}
152
109
  />
@@ -155,15 +112,15 @@ export function FileTree({
155
112
  </ul>
156
113
  )
157
114
  ) : tree.loading ? (
158
- <div className='py-6 text-center'>
159
- <Spinner className='size-4 text-fg-4' />
115
+ <div className="py-6 text-center">
116
+ <Spinner className="size-4 text-fg-4" />
160
117
  </div>
161
118
  ) : tree.rows.length === 0 ? (
162
- <p className='px-2 py-6 text-center text-body-sm text-fg-4'>This project is empty.</p>
119
+ <p className="px-2 py-6 text-center text-body-sm text-fg-4">This project is empty.</p>
163
120
  ) : (
164
- <ul role='tree' aria-label='Project files'>
121
+ <ul role="tree" aria-label="Project files">
165
122
  {tree.rows.map((row) => (
166
- <li key={row.entry.path} role='treeitem' aria-expanded={row.expanded} aria-level={row.depth + 1}>
123
+ <li key={row.entry.path} role="treeitem" aria-expanded={row.expanded} aria-level={row.depth + 1}>
167
124
  <Row
168
125
  label={row.entry.name}
169
126
  indent={row.depth}
@@ -171,11 +128,11 @@ export function FileTree({
171
128
  icon={
172
129
  row.entry.type === 'dir' ? (
173
130
  row.loading ? (
174
- <Spinner className='size-3.5 shrink-0 text-fg-4' />
131
+ <Spinner className="size-3.5 shrink-0 text-fg-4" />
175
132
  ) : row.expanded ? (
176
- <FolderOpen className='size-3.5 shrink-0 text-accent' />
133
+ <FolderOpen className="size-3.5 shrink-0 text-accent" />
177
134
  ) : (
178
- <Folder className='size-3.5 shrink-0 text-accent' />
135
+ <Folder className="size-3.5 shrink-0 text-accent" />
179
136
  )
180
137
  ) : (
181
138
  <EntryIcon type={row.entry.type} />
@@ -183,24 +140,13 @@ export function FileTree({
183
140
  }
184
141
  chevron={
185
142
  row.entry.type === 'dir' ? (
186
- <ChevronRight
187
- className={cn(
188
- 'size-3 shrink-0 text-fg-4 transition-transform',
189
- row.expanded && 'rotate-90',
190
- )}
191
- />
143
+ <ChevronRight className={cn('size-3 shrink-0 text-fg-4 transition-transform', row.expanded && 'rotate-90')} />
192
144
  ) : undefined
193
145
  }
194
- onClick={() =>
195
- row.entry.type === 'dir'
196
- ? tree.toggle(row.entry.path)
197
- : onOpenFile(row.entry.path)
198
- }
146
+ onClick={() => (row.entry.type === 'dir' ? tree.toggle(row.entry.path) : onOpenFile(row.entry.path))}
199
147
  />
200
148
  {row.truncated ? (
201
- <p
202
- className='truncate py-0.5 text-label text-fg-4'
203
- style={{ paddingLeft: (row.depth + 1) * INDENT + 22 }}>
149
+ <p className="truncate py-0.5 text-label text-fg-4" style={{ paddingLeft: (row.depth + 1) * INDENT + 22 }}>
204
150
  More entries than the server will return — use search.
205
151
  </p>
206
152
  ) : null}
@@ -213,8 +159,6 @@ export function FileTree({
213
159
  )
214
160
  }
215
161
 
216
- /** One clickable line. A directory and a file differ only in what the click does
217
- * and whether there is a chevron — visually they are the same row. */
218
162
  function Row({
219
163
  label,
220
164
  detail,
@@ -226,7 +170,6 @@ function Row({
226
170
  onClick,
227
171
  }: {
228
172
  label: string
229
- /** Secondary text after the label, dimmed and the first thing to be truncated. */
230
173
  detail?: string
231
174
  title?: string
232
175
  icon: ReactNode
@@ -235,54 +178,38 @@ function Row({
235
178
  active?: boolean
236
179
  onClick: () => void
237
180
  }) {
238
- // Scroll a row that became active elsewhere (a search hit, a `reveal`) into
239
- // view, but never yank the list while someone is reading it — `nearest` moves
240
- // the minimum and does nothing when the row is already visible.
241
181
  const ref = useRef<HTMLButtonElement>(null)
242
182
  useEffect(() => {
243
- if (active) ref.current?.scrollIntoView({ block: 'nearest' })
183
+ if (active) {
184
+ ref.current?.scrollIntoView({ block: 'nearest' })
185
+ }
244
186
  }, [active])
245
187
 
246
188
  return (
247
189
  <button
248
190
  ref={ref}
249
- type='button'
191
+ type="button"
250
192
  onClick={onClick}
251
193
  title={title ?? label}
252
194
  style={{ paddingLeft: indent * INDENT + 4 }}
253
195
  className={cn(
254
196
  'flex w-full items-center gap-1 rounded py-1 pr-2 text-left transition-colors',
255
197
  active ? 'bg-surface-hover text-fg-1' : 'text-fg-2 hover:bg-surface-hover',
256
- )}>
257
- <span className='flex size-3 shrink-0 items-center justify-center'>{chevron}</span>
198
+ )}
199
+ >
200
+ <span className="flex size-3 shrink-0 items-center justify-center">{chevron}</span>
258
201
  {icon}
259
- {/* `shrink-0` on the name and `min-w-0` on the detail: when the row runs
260
- out of room the directory gives way and the filename stays whole.
261
- The rail reads in the **UI font**, never mono: it is workbench chrome
262
- — a list you scan — and the editors it sits beside (VS Code, Finder)
263
- all set filenames in their UI face. Mono is for content on a grid, and
264
- nothing here is on one. It is also independent of `transcriptFont`,
265
- which scopes to the session panel alone. */}
266
- <span className='shrink-0 truncate text-label'>{label}</span>
267
- {detail ? <span className='min-w-0 flex-1 truncate text-label text-fg-4'>{detail}</span> : null}
202
+ <span className="shrink-0 truncate text-label">{label}</span>
203
+ {detail ? <span className="min-w-0 flex-1 truncate text-label text-fg-4">{detail}</span> : null}
268
204
  </button>
269
205
  )
270
206
  }
271
207
 
272
- /** Last segment of a relative match. */
273
208
  function fileName(relative: string): string {
274
209
  return relative.slice(relative.lastIndexOf('/') + 1)
275
210
  }
276
211
 
277
- /** Everything before it, or `undefined` for a file at the search root. */
278
212
  function directoryOf(relative: string): string | undefined {
279
213
  const cut = relative.lastIndexOf('/')
280
214
  return cut === -1 ? undefined : relative.slice(0, cut)
281
215
  }
282
-
283
- /** A symlink is reported as itself and never silently resolved — following it is
284
- * the next request's problem, and that request is refused if it escapes the roots. */
285
- function EntryIcon({ type }: { type: HostDirEntry['type'] }) {
286
- if (type === 'symlink') return <Link2 className='size-3.5 shrink-0 text-fg-4' />
287
- return <File className='size-3.5 shrink-0 text-fg-4' />
288
- }
@@ -9,28 +9,16 @@ import { CodeEditor } from './CodeEditor.tsx'
9
9
 
10
10
  export interface FileViewerProps {
11
11
  file: OpenFile | undefined
12
- /** From `/fs/roots`. False renders the editor read-only rather than letting
13
- * someone type into a file this gateway will refuse to write. */
14
12
  canWrite?: boolean
15
13
  onChange?: (path: string, content: string) => void
16
14
  onSave?: (path: string) => void
17
- /** Discard this tab's edits — local only, no re-read. */
18
15
  onRevert?: (path: string) => void
19
- /** Take the version on disk, discarding this tab's edits. */
20
16
  onReload?: (path: string) => void
21
- /** Take this tab's version, over whatever is on disk now. */
22
17
  onOverwrite?: (path: string) => void
23
18
  onDismissConflict?: (path: string) => void
24
19
  className?: string
25
20
  }
26
21
 
27
- /**
28
- * The focused file: Monaco, plus the states a file can be in that are not
29
- * "here is some text".
30
- *
31
- * No path row — the tab's tooltip carries the path and the size, and a line of
32
- * monospace above every file is chrome that never earns its height.
33
- */
34
22
  export function FileViewer({
35
23
  file,
36
24
  canWrite,
@@ -42,36 +30,31 @@ export function FileViewer({
42
30
  onDismissConflict,
43
31
  className,
44
32
  }: FileViewerProps) {
45
- if (!file) return null
33
+ if (!file) {
34
+ return null
35
+ }
46
36
 
47
37
  return (
48
- <div
49
- data-slot='file-viewer'
50
- className={cn('flex min-h-0 min-w-0 flex-1 flex-col bg-bg', className)}>
51
- {/* The one failure with a choice attached, so it gets a bar rather than a
52
- toast: the agent rewrote this file while it was open, and which
53
- version wins is not something to decide on the user's behalf. */}
38
+ <div data-slot="file-viewer" className={cn('flex min-h-0 min-w-0 flex-1 flex-col bg-bg', className)}>
54
39
  {file.conflict ? (
55
- <div className='flex shrink-0 flex-wrap items-center gap-2 border-b border-warning/40 bg-warning-bg px-3 py-2'>
56
- <TriangleAlert className='size-3.5 shrink-0 text-warning' />
57
- <span className='min-w-0 flex-1 text-body-sm text-warning'>
58
- This file changed on disk since you opened it.
59
- </span>
60
- <Button variant='outline' size='xs' onClick={() => onReload?.(file.path)}>
40
+ <div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-warning/40 bg-warning-bg px-3 py-2">
41
+ <TriangleAlert className="size-3.5 shrink-0 text-warning" />
42
+ <span className="min-w-0 flex-1 text-body-sm text-warning">This file changed on disk since you opened it.</span>
43
+ <Button variant="outline" size="xs" onClick={() => onReload?.(file.path)}>
61
44
  Use the version on disk
62
45
  </Button>
63
- <Button variant='outline' size='xs' onClick={() => onOverwrite?.(file.path)}>
46
+ <Button variant="outline" size="xs" onClick={() => onOverwrite?.(file.path)}>
64
47
  Keep mine
65
48
  </Button>
66
- <Button variant='ghost' size='xs' onClick={() => onDismissConflict?.(file.path)}>
49
+ <Button variant="ghost" size="xs" onClick={() => onDismissConflict?.(file.path)}>
67
50
  Dismiss
68
51
  </Button>
69
52
  </div>
70
53
  ) : file.saveError ? (
71
- <div className='flex shrink-0 items-center gap-2 border-b border-danger/40 bg-danger-bg px-3 py-2'>
72
- <TriangleAlert className='size-3.5 shrink-0 text-danger' />
73
- <span className='min-w-0 flex-1 text-body-sm text-danger'>{file.saveError}</span>
74
- <Button variant='ghost' size='xs' onClick={() => onDismissConflict?.(file.path)}>
54
+ <div className="flex shrink-0 items-center gap-2 border-b border-danger/40 bg-danger-bg px-3 py-2">
55
+ <TriangleAlert className="size-3.5 shrink-0 text-danger" />
56
+ <span className="min-w-0 flex-1 text-body-sm text-danger">{file.saveError}</span>
57
+ <Button variant="ghost" size="xs" onClick={() => onDismissConflict?.(file.path)}>
75
58
  Dismiss
76
59
  </Button>
77
60
  </div>
@@ -79,20 +62,18 @@ export function FileViewer({
79
62
 
80
63
  {file.status === 'loading' ? (
81
64
  <Centred>
82
- <Spinner className='size-4 text-fg-4' />
65
+ <Spinner className="size-4 text-fg-4" />
83
66
  </Centred>
84
67
  ) : file.status === 'error' ? (
85
68
  <Centred>
86
- <TriangleAlert className='size-4 text-danger' />
87
- <p className='text-body-sm text-danger'>{file.error}</p>
69
+ <TriangleAlert className="size-4 text-danger" />
70
+ <p className="text-body-sm text-danger">{file.error}</p>
88
71
  </Centred>
89
72
  ) : file.status === 'binary' ? (
90
73
  <Centred>
91
- <FileWarning className='size-4 text-fg-4' />
92
- <p className='text-body-sm text-fg-4'>This file isn’t text.</p>
93
- {/* Said out loud, because "can't show it" and "editing it here would
94
- destroy it" are different reassurances. */}
95
- <p className='text-label text-fg-4'>It can’t be edited here without corrupting it.</p>
74
+ <FileWarning className="size-4 text-fg-4" />
75
+ <p className="text-body-sm text-fg-4">This file isn’t text.</p>
76
+ <p className="text-label text-fg-4">It cant be edited here without corrupting it.</p>
96
77
  </Centred>
97
78
  ) : (
98
79
  <CodeEditor
@@ -104,32 +85,25 @@ export function FileViewer({
104
85
  />
105
86
  )}
106
87
 
107
- {/* A status strip only while there is something to say. Saving is fast
108
- enough that a permanent row would mostly be blank. */}
109
88
  {file.status === 'ready' && (file.saving || isDirty(file) || !canWrite) ? (
110
- <div className='flex shrink-0 items-center gap-2 border-t border-border px-3 py-1'>
89
+ <div className="flex shrink-0 items-center gap-2 border-t border-border px-3 py-1">
111
90
  {file.saving ? (
112
91
  <>
113
- <Spinner className='size-3 text-fg-4' />
114
- <span className='text-label text-fg-4'>Saving…</span>
92
+ <Spinner className="size-3 text-fg-4" />
93
+ <span className="text-label text-fg-4">Saving…</span>
115
94
  </>
116
95
  ) : !canWrite ? (
117
- <span className='text-label text-fg-4'>
118
- Read-only — this gateway doesn’t allow writes.
119
- </span>
96
+ <span className="text-label text-fg-4">Read-only — this gateway doesn’t allow writes.</span>
120
97
  ) : (
121
98
  <>
122
- <span className='text-label text-fg-3'>Unsaved changes</span>
123
- <span className='flex-1' />
124
- {/* Revert, not reload: discard *my* edits and go back to what
125
- this tab read. Re-reading is the conflict bar's job, and it is
126
- a different question. */}
127
- <Button variant='ghost' size='xs' onClick={() => onRevert?.(file.path)}>
99
+ <span className="text-label text-fg-3">Unsaved changes</span>
100
+ <span className="flex-1" />
101
+ <Button variant="ghost" size="xs" onClick={() => onRevert?.(file.path)}>
128
102
  Revert
129
103
  </Button>
130
- <Button variant='outline' size='xs' onClick={() => onSave?.(file.path)}>
104
+ <Button variant="outline" size="xs" onClick={() => onSave?.(file.path)}>
131
105
  Save
132
- <span className='ml-1 text-fg-4'>⌘S</span>
106
+ <span className="ml-1 text-fg-4">⌘S</span>
133
107
  </Button>
134
108
  </>
135
109
  )}
@@ -140,9 +114,5 @@ export function FileViewer({
140
114
  }
141
115
 
142
116
  function Centred({ children }: { children: ReactNode }) {
143
- return (
144
- <div className='flex min-h-0 flex-1 flex-col items-center justify-center gap-2 p-6'>
145
- {children}
146
- </div>
147
- )
117
+ return <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 p-6">{children}</div>
148
118
  }