@workerdeck/ui 0.23.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-DUt2VzXG.mjs → SessionPanel-DDgBkHsi.mjs} +2151 -3357
  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 +15 -23
  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 +5 -126
  14. package/build/workspace.mjs +11 -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 +3 -9
  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 +315 -996
  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 +104 -219
  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 +96 -32
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-BobynUo4.d.mts +0 -669
  143. package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. package/build/status-BE-zg88x.mjs.map +0 -1
@@ -1,14 +1,7 @@
1
1
  import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import type { WorkerDeckClient } from '@workerdeck/client'
4
- import {
5
- useHostFileRoots,
6
- useHostFileSearch,
7
- useHostFileTree,
8
- useOpenFiles,
9
- useSessionInfo,
10
- isDirty,
11
- } from '@workerdeck/react'
4
+ import { useHostFileRoots, useHostFileSearch, useHostFileTree, useOpenFiles, useSessionInfo, isDirty } from '@workerdeck/react'
12
5
  import { PanelLeftOpen } from 'lucide-react'
13
6
  import { cn } from '../../lib/utils.ts'
14
7
  import { Button } from '../ui/Button.tsx'
@@ -22,90 +15,35 @@ import { resolveAgainstCwd, usePathLinks } from './use-path-links.ts'
22
15
  export interface SessionWorkspaceProps {
23
16
  client: WorkerDeckClient
24
17
  sessionId: string | undefined
25
- /** Passed straight through to {@link SessionPanel} — including the render-prop
26
- * form that claims the session-actions menu. */
27
18
  header?: SessionPanelProps['header']
28
- /**
29
- * Panel seams the workspace does not interpret, forwarded verbatim.
30
- *
31
- * They are listed rather than spread so the workspace stays explicit about
32
- * what it passes on: the panel owns the session's one attach, and a seam that
33
- * silently arrived here would be a second place to look for why a session
34
- * renders the way it does.
35
- */
36
19
  transcriptVariant?: SessionPanelProps['transcriptVariant']
37
20
  transcriptDensity?: SessionPanelProps['transcriptDensity']
38
21
  transcriptFont?: SessionPanelProps['transcriptFont']
39
- /** The overview ruler in place of the scrollbar — see `SessionPanel`. */
40
22
  scrubber?: SessionPanelProps['scrubber']
41
- scrubberMarks?: SessionPanelProps['scrubberMarks']
42
- /** The last prompt pinned above the transcript — see `SessionPanel`. */
23
+ bookmarks?: SessionPanelProps['bookmarks']
24
+ onToggleBookmark?: SessionPanelProps['onToggleBookmark']
43
25
  stickyPrompt?: SessionPanelProps['stickyPrompt']
44
- /** Take the panel body over with one sub-agent's work — see `SessionPanel`. */
45
26
  openSubagent?: SessionPanelProps['openSubagent']
46
- /** Travel to a row in the conversation without framing anything — where a
47
- * **task** press lands, as opposed to `openSubagent`'s takeover. See
48
- * `SessionPanel`. */
49
27
  reveal?: SessionPanelProps['reveal']
50
- /** The outward half of `openSubagent`: which sub-agent the panel now has
51
- * framed, or `undefined` for the conversation — see `SessionPanel`. */
52
28
  onSubagentChange?: SessionPanelProps['onSubagentChange']
53
- /** Which end of the panel the status bar sits at — see `SessionPanel`. */
54
29
  statusPlacement?: SessionPanelProps['statusPlacement']
55
30
  controlsSurface?: SessionPanelProps['controlsSurface']
56
- /** Base font size in whole pixels — see `SessionPanel.fontSize`. */
57
31
  fontSize?: SessionPanelProps['fontSize']
58
- /** Link click handler — see `SessionPanel.onLinkClick`. */
59
32
  onLinkClick?: SessionPanelProps['onLinkClick']
60
33
  unseen?: SessionPanelProps['unseen']
61
- /** Viewer mode — no composer, no approval prompts. Forwarded verbatim to
62
- * {@link SessionPanel}; the file tree and editor are unaffected, since reading
63
- * a run's files is the point of a read-only view. */
64
34
  readOnly?: SessionPanelProps['readOnly']
65
35
  onVitals?: SessionPanelProps['onVitals']
66
- /** Rail width in pixels on first render. */
67
36
  defaultRailWidth?: number
68
- /** Start with the file rail collapsed even on a wide viewport. */
69
37
  defaultRailCollapsed?: boolean
70
- /**
71
- * The rail moved. Paired with the two defaults so an embedder can persist the
72
- * layout — the workspace deliberately does not, because *where* to keep it (a
73
- * Memento, localStorage, a workspace file) is the embedder's call, and a
74
- * component that picked one would be wrong in the other hosts.
75
- */
76
38
  onRailChange?: (rail: { width: number; collapsed: boolean }) => void
77
39
  className?: string
78
40
  }
79
41
 
80
42
  const RAIL_MIN = 180
81
43
  const RAIL_MAX = 520
82
- /** How little of the agent column the editor may leave. Below this the composer
83
- * and a line of transcript stop fitting together, which is the point at which the
84
- * agent has stopped being usable rather than merely small. */
85
44
  const AGENT_MIN = 220
86
45
  const EDITOR_MIN = 120
87
46
 
88
- /**
89
- * A VS Code-shaped workspace around a live session: file tree on the left, open
90
- * files above, the agent below.
91
- *
92
- * **Strictly additive.** {@link SessionPanel} is untouched and still the whole
93
- * session surface on its own — an embedder picks one or the other, and one that
94
- * has its own file tree keeps using the panel.
95
- *
96
- * Two things here are load-bearing and easy to break:
97
- *
98
- * 1. **The editor region is absent from the layout when nothing is open**, not
99
- * collapsed to zero height. A zero-height pane leaves a draggable splitter and
100
- * parks the composer at an odd offset; absence is what makes the agent's
101
- * "claims the full column" state actually look like the panel alone.
102
- * 2. **`SessionPanel` keeps its position in the tree across that transition.** It
103
- * holds the WebSocket attach and the entire transcript, so moving it between
104
- * parents — or wrapping it conditionally — would remount it and drop the
105
- * session's rendered history on the floor the first time someone opens a file.
106
- * The conditional children before it are `? :` expressions that leave a null in
107
- * their slot, which is exactly what keeps its index stable.
108
- */
109
47
  export function SessionWorkspace({
110
48
  client,
111
49
  sessionId,
@@ -114,7 +52,8 @@ export function SessionWorkspace({
114
52
  transcriptDensity,
115
53
  transcriptFont,
116
54
  scrubber,
117
- scrubberMarks,
55
+ bookmarks,
56
+ onToggleBookmark,
118
57
  stickyPrompt,
119
58
  openSubagent,
120
59
  reveal,
@@ -131,26 +70,18 @@ export function SessionWorkspace({
131
70
  onRailChange,
132
71
  className,
133
72
  }: SessionWorkspaceProps) {
134
- // The cwd is the tree's root, and it comes from the registry rather than from
135
- // the panel: reading it off the panel's own session hook would mean attaching
136
- // a second WebSocket client, and the tool bridge asks the *first* attached
137
- // client — a second one changes who answers.
138
73
  const { info } = useSessionInfo(client, sessionId)
139
74
  const cwd = info?.cwd
140
75
 
141
76
  const tree = useHostFileTree(client, cwd)
142
77
  const search = useHostFileSearch(client, cwd)
143
78
  const files = useOpenFiles(client)
144
- // Writing is a separate server opt-in from reading and defaults off, so the
145
- // editor asks before it offers to save anything.
146
79
  const { canWrite } = useHostFileRoots(client)
147
80
 
148
-
149
- // Nothing here can save on the user's behalf when the tab is going away, so
150
- // the browser's own guard is the last line. Registered only while there is
151
- // something to lose — an unconditional handler makes every navigation prompt.
152
81
  useEffect(() => {
153
- if (!files.hasUnsaved) return
82
+ if (!files.hasUnsaved) {
83
+ return
84
+ }
154
85
  const warn = (event: BeforeUnloadEvent) => event.preventDefault()
155
86
  window.addEventListener('beforeunload', warn)
156
87
  return () => window.removeEventListener('beforeunload', warn)
@@ -159,8 +90,6 @@ export function SessionWorkspace({
159
90
  const wide = useIsWide()
160
91
  const [railCollapsed, setRailCollapsed] = useState(defaultRailCollapsed ?? false)
161
92
  const [railWidth, setRailWidth] = useState(defaultRailWidth)
162
- // Reported rather than stored. Kept in a ref so the effect below fires on a
163
- // real change instead of on every render an inline callback would cause.
164
93
  const onRailChangeRef = useRef(onRailChange)
165
94
  onRailChangeRef.current = onRailChange
166
95
  useEffect(() => {
@@ -168,30 +97,24 @@ export function SessionWorkspace({
168
97
  }, [railWidth, railCollapsed])
169
98
  const [editorHeight, setEditorHeight] = useState(360)
170
99
 
171
- // Closing every tab returns the agent to the full column; opening one again
172
- // should restore the height the user had chosen, so this is not reset here.
173
100
  const hasFiles = files.files.length > 0
174
101
 
175
- // A narrow viewport cannot spare 260px of rail beside a transcript, so there
176
- // the rail overlays the workspace instead of sitting next to it — and starts
177
- // out of the way.
178
102
  const overlayRail = !wide
179
103
  const railOpen = tree.available && !railCollapsed
180
104
  useEffect(() => {
181
- if (overlayRail) setRailCollapsed(true)
105
+ if (overlayRail) {
106
+ setRailCollapsed(true)
107
+ }
182
108
  }, [overlayRail])
183
109
 
184
- // Closing a dirty tab is the one destructive thing the strip can do, and the
185
- // edits are not recoverable once the tab is gone. `confirm` rather than a
186
- // styled dialog on purpose: it is modal, it cannot be missed, and a
187
- // custom one here would be a second modal system inside a component an
188
- // embedder already renders inside their own.
189
110
  const closeTab = useCallback(
190
111
  (path: string) => {
191
112
  const file = files.files.find((f) => f.path === path)
192
113
  if (file && isDirty(file)) {
193
114
  const name = file.name
194
- if (!window.confirm(`${name} has unsaved changes. Close it and lose them?`)) return
115
+ if (!window.confirm(`${name} has unsaved changes. Close it and lose them?`)) {
116
+ return
117
+ }
195
118
  }
196
119
  files.close(path)
197
120
  },
@@ -200,16 +123,7 @@ export function SessionWorkspace({
200
123
 
201
124
  const column = useRef<HTMLDivElement>(null)
202
125
  const columnHeight = useElementHeight(column)
203
- // Cmd/Ctrl+click a file the agent named and it opens in the editor above
204
- // the workspace is the only surface that *has* an editor, which is why this
205
- // lives here and not in the panel. Gated on the tree being available: without
206
- // a host filesystem there is nothing to open, and a link that cannot resolve
207
- // is worse than plain text. Monaco is excluded because Cmd+click already
208
- // means go-to-definition in there, and every identifier would match.
209
- const openPath = useCallback(
210
- ({ path }: { path: string }) => files.open(resolveAgainstCwd(path, cwd)),
211
- [files.open, cwd],
212
- )
126
+ const openPath = useCallback(({ path }: { path: string }) => files.open(resolveAgainstCwd(path, cwd)), [files.open, cwd])
213
127
  usePathLinks({
214
128
  container: column,
215
129
  onOpen: openPath,
@@ -218,11 +132,6 @@ export function SessionWorkspace({
218
132
  })
219
133
  const editorMax = Math.max(EDITOR_MIN, columnHeight - AGENT_MIN)
220
134
 
221
- // The embedder's header is app chrome and belongs above everything — but only
222
- // `SessionPanel` can *build* the `⋯` menu it is handed, and only if it is the
223
- // one calling the render-prop. So the panel still calls it, in its own tree,
224
- // and the result is portalled up here. That keeps `SessionPanel` untouched and
225
- // keeps the menu's own context (it is a Base UI popup) intact.
226
135
  const [topBar, setTopBar] = useState<HTMLDivElement | null>(null)
227
136
  const hoisted: SessionPanelProps['header'] =
228
137
  header === undefined || topBar === null
@@ -232,121 +141,98 @@ export function SessionWorkspace({
232
141
  : createPortal(header, topBar)
233
142
 
234
143
  return (
235
- <div
236
- data-slot='session-workspace'
237
- className={cn('flex h-full min-h-0 w-full flex-col overflow-hidden bg-bg', className)}>
238
- {header !== undefined ? <div ref={setTopBar} className='shrink-0' /> : null}
239
- <div className='relative flex min-h-0 flex-1'>
240
- {railOpen ? (
241
- <FileTree
242
- tree={tree}
243
- search={search}
244
- activePath={files.activePath}
245
- onOpenFile={files.open}
246
- onCollapse={() => setRailCollapsed(true)}
247
- style={{ width: overlayRail ? Math.min(railWidth, 320) : railWidth }}
248
- className={cn(
249
- 'shrink-0 border-r border-border',
250
- overlayRail && 'absolute inset-y-0 left-0 z-20 shadow-lg',
251
- )}
252
- />
253
- ) : tree.available ? (
254
- // Collapsed: a slim strip that keeps the rail one click away. In flow
255
- // rather than floating over the panel, so it can never land on top of an
256
- // embedder's own header controls.
257
- <div className='flex w-8 shrink-0 flex-col items-center border-r border-border bg-surface pt-1.5'>
258
- <Button
259
- variant='ghost'
260
- size='icon-sm'
261
- aria-label='Show project files'
262
- onClick={() => setRailCollapsed(false)}>
263
- <PanelLeftOpen className='size-4 text-fg-3' />
264
- </Button>
265
- </div>
266
- ) : null}
267
- {/* No splitter over an overlay rail — dragging a drawer's edge on a phone
268
- fights the scroll it is sitting on top of. */}
269
- {railOpen && !overlayRail ? (
270
- <Splitter
271
- orientation='vertical'
272
- value={railWidth}
273
- onValueChange={setRailWidth}
274
- min={RAIL_MIN}
275
- max={RAIL_MAX}
276
- defaultValue={defaultRailWidth}
277
- aria-label='Resize the file tree'
278
- />
279
- ) : null}
280
-
281
- <div ref={column} className='flex min-h-0 min-w-0 flex-1 flex-col'>
282
- {/* Slot 1 of 3. The `? :` leaves a null here when nothing is open, which
283
- is what holds the agent's slot below and keeps it from remounting. */}
284
- {hasFiles ? (
285
- <div
286
- className='flex min-h-0 shrink-0 flex-col overflow-hidden'
287
- style={{ height: Math.min(editorHeight, editorMax || editorHeight) }}>
288
- <EditorTabs
289
- files={files.files}
290
- activePath={files.activePath}
291
- onActivate={files.activate}
292
- onClose={closeTab}
293
- />
294
- <FileViewer
295
- file={files.active}
296
- canWrite={canWrite}
297
- onChange={files.edit}
298
- onSave={(path) => void files.save(path)}
299
- onRevert={files.revert}
300
- onReload={files.reload}
301
- onOverwrite={(path) => void files.overwrite(path)}
302
- onDismissConflict={files.dismissConflict}
303
- />
144
+ <div data-slot="session-workspace" className={cn('flex h-full min-h-0 w-full flex-col overflow-hidden bg-bg', className)}>
145
+ {header !== undefined ? <div ref={setTopBar} className="shrink-0" /> : null}
146
+ <div className="relative flex min-h-0 flex-1">
147
+ {railOpen ? (
148
+ <FileTree
149
+ tree={tree}
150
+ search={search}
151
+ activePath={files.activePath}
152
+ onOpenFile={files.open}
153
+ onCollapse={() => setRailCollapsed(true)}
154
+ style={{ width: overlayRail ? Math.min(railWidth, 320) : railWidth }}
155
+ className={cn('shrink-0 border-r border-border', overlayRail && 'absolute inset-y-0 left-0 z-20 shadow-lg')}
156
+ />
157
+ ) : tree.available ? (
158
+ <div className="flex w-8 shrink-0 flex-col items-center border-r border-border bg-surface pt-1.5">
159
+ <Button variant="ghost" size="icon-sm" aria-label="Show project files" onClick={() => setRailCollapsed(false)}>
160
+ <PanelLeftOpen className="size-4 text-fg-3" />
161
+ </Button>
304
162
  </div>
305
163
  ) : null}
306
- {/* Slot 2 of 3. */}
307
- {hasFiles ? (
164
+ {railOpen && !overlayRail ? (
308
165
  <Splitter
309
- orientation='horizontal'
310
- value={Math.min(editorHeight, editorMax || editorHeight)}
311
- onValueChange={setEditorHeight}
312
- min={EDITOR_MIN}
313
- max={editorMax}
314
- aria-label='Resize the open file'
166
+ orientation="vertical"
167
+ value={railWidth}
168
+ onValueChange={setRailWidth}
169
+ min={RAIL_MIN}
170
+ max={RAIL_MAX}
171
+ defaultValue={defaultRailWidth}
172
+ aria-label="Resize the file tree"
315
173
  />
316
174
  ) : null}
317
- {/* Slot 3 of 3 — always here, always at this index. */}
318
- <SessionPanel
319
- client={client}
320
- sessionId={sessionId}
321
- header={hoisted}
322
- transcriptVariant={transcriptVariant}
323
- transcriptDensity={transcriptDensity}
324
- transcriptFont={transcriptFont}
325
- scrubber={scrubber}
326
- scrubberMarks={scrubberMarks}
327
- stickyPrompt={stickyPrompt}
328
- openSubagent={openSubagent}
329
- reveal={reveal}
330
- onSubagentChange={onSubagentChange}
331
- controlsSurface={controlsSurface}
332
- fontSize={fontSize}
333
- onLinkClick={onLinkClick}
334
- statusPlacement={statusPlacement}
335
- unseen={unseen}
336
- readOnly={readOnly}
337
- onVitals={onVitals}
338
- className='min-h-0 flex-1'
339
- />
340
- </div>
341
175
 
342
- {/* Tapping away closes the drawer, which is the only way back to the
343
- transcript on a narrow screen. */}
176
+ <div ref={column} className="flex min-h-0 min-w-0 flex-1 flex-col">
177
+ {hasFiles ? (
178
+ <div
179
+ className="flex min-h-0 shrink-0 flex-col overflow-hidden"
180
+ style={{ height: Math.min(editorHeight, editorMax || editorHeight) }}
181
+ >
182
+ <EditorTabs files={files.files} activePath={files.activePath} onActivate={files.activate} onClose={closeTab} />
183
+ <FileViewer
184
+ file={files.active}
185
+ canWrite={canWrite}
186
+ onChange={files.edit}
187
+ onSave={(path) => void files.save(path)}
188
+ onRevert={files.revert}
189
+ onReload={files.reload}
190
+ onOverwrite={(path) => void files.overwrite(path)}
191
+ onDismissConflict={files.dismissConflict}
192
+ />
193
+ </div>
194
+ ) : null}
195
+ {hasFiles ? (
196
+ <Splitter
197
+ orientation="horizontal"
198
+ value={Math.min(editorHeight, editorMax || editorHeight)}
199
+ onValueChange={setEditorHeight}
200
+ min={EDITOR_MIN}
201
+ max={editorMax}
202
+ aria-label="Resize the open file"
203
+ />
204
+ ) : null}
205
+ <SessionPanel
206
+ client={client}
207
+ sessionId={sessionId}
208
+ header={hoisted}
209
+ transcriptVariant={transcriptVariant}
210
+ transcriptDensity={transcriptDensity}
211
+ transcriptFont={transcriptFont}
212
+ scrubber={scrubber}
213
+ bookmarks={bookmarks}
214
+ onToggleBookmark={onToggleBookmark}
215
+ stickyPrompt={stickyPrompt}
216
+ openSubagent={openSubagent}
217
+ reveal={reveal}
218
+ onSubagentChange={onSubagentChange}
219
+ controlsSurface={controlsSurface}
220
+ fontSize={fontSize}
221
+ onLinkClick={onLinkClick}
222
+ statusPlacement={statusPlacement}
223
+ unseen={unseen}
224
+ readOnly={readOnly}
225
+ onVitals={onVitals}
226
+ className="min-h-0 flex-1"
227
+ />
228
+ </div>
229
+
344
230
  {overlayRail && railOpen ? (
345
231
  <button
346
- type='button'
347
- aria-label='Close the file tree'
232
+ type="button"
233
+ aria-label="Close the file tree"
348
234
  onClick={() => setRailCollapsed(true)}
349
- className='absolute inset-0 z-10 bg-black/30'
235
+ className="absolute inset-0 z-10 bg-black/30"
350
236
  />
351
237
  ) : null}
352
238
  </div>
@@ -354,16 +240,17 @@ export function SessionWorkspace({
354
240
  )
355
241
  }
356
242
 
357
- /** Live height of an element, for a splitter that needs to know how much room it
358
- * is dividing. Zero until the first observation, which callers treat as
359
- * "unmeasured" rather than as a real bound. */
360
243
  function useElementHeight(ref: RefObject<HTMLElement | null>): number {
361
244
  const [height, setHeight] = useState(0)
362
245
  useLayoutEffect(() => {
363
246
  const element = ref.current
364
- if (!element) return
247
+ if (!element) {
248
+ return
249
+ }
365
250
  const observer = new ResizeObserver(([entry]) => {
366
- if (entry) setHeight(entry.contentRect.height)
251
+ if (entry) {
252
+ setHeight(entry.contentRect.height)
253
+ }
367
254
  })
368
255
  observer.observe(element)
369
256
  return () => observer.disconnect()
@@ -371,8 +258,6 @@ function useElementHeight(ref: RefObject<HTMLElement | null>): number {
371
258
  return height
372
259
  }
373
260
 
374
- /** Whether there is room for a rail beside the content. Presentation only — the
375
- * workspace's actual state lives in the hooks from `@workerdeck/react`. */
376
261
  function useIsWide(): boolean {
377
262
  const [wide, setWide] = useState(true)
378
263
  useEffect(() => {
@@ -9,14 +9,10 @@ export interface SkillsDialogProps {
9
9
  skills: SkillInfo[] | undefined
10
10
  open: boolean
11
11
  onOpenChange: (open: boolean) => void
12
- /** Insert a skill's opening message into the composer, if the host offers
13
- * that. Omit and the dialog is read-only. */
14
12
  onUse?: (skill: SkillInfo) => void
15
13
  className?: string
16
14
  }
17
15
 
18
- /** Where the skill came from. The engine's set is open, so an unrecognised
19
- * scope renders as itself rather than being forced into a bucket. */
20
16
  const SCOPE_LABEL: Record<string, string> = {
21
17
  user: 'Personal',
22
18
  repo: 'This project',
@@ -24,19 +20,6 @@ const SCOPE_LABEL: Record<string, string> = {
24
20
  admin: 'Managed',
25
21
  }
26
22
 
27
- /**
28
- * What this session's engine can do beyond its own tools: the skills it found,
29
- * grouped by where they came from, with one drilled-down view each.
30
- *
31
- * The framing matters more here than in most panels. A skill is **not** a
32
- * command — the model decides to use one by reading its description, and there
33
- * is no wire syntax that invokes it. So this screen is a *discovery* surface,
34
- * and the one action it offers ("Use this skill") is honest about being a
35
- * drafting aid: it types a message for the operator to edit and send.
36
- *
37
- * Fed from the session's `skills` event rather than a REST route, because that
38
- * is the channel the engine refreshes on its own when a skill changes on disk.
39
- */
40
23
  export function SkillsDialog({ skills, open, onOpenChange, onUse, className }: SkillsDialogProps) {
41
24
  const [selected, setSelected] = useState<string | undefined>()
42
25
  const skill = skills?.find((s) => s.name === selected)
@@ -45,9 +28,12 @@ export function SkillsDialog({ skills, open, onOpenChange, onUse, className }: S
45
28
  <Dialog
46
29
  open={open}
47
30
  onOpenChange={(next) => {
48
- if (!next) setSelected(undefined)
31
+ if (!next) {
32
+ setSelected(undefined)
33
+ }
49
34
  onOpenChange(next)
50
- }}>
35
+ }}
36
+ >
51
37
  <DialogContent className={className}>
52
38
  <DialogHeader
53
39
  title={skill ? (skill.displayName ?? skill.name) : 'Skills'}
@@ -60,8 +46,8 @@ export function SkillsDialog({ skills, open, onOpenChange, onUse, className }: S
60
46
  }
61
47
  actions={
62
48
  skill ? (
63
- <Button variant='ghost' size='xs' onClick={() => setSelected(undefined)}>
64
- <ChevronLeft className='size-3.5' />
49
+ <Button variant="ghost" size="xs" onClick={() => setSelected(undefined)}>
50
+ <ChevronLeft className="size-3.5" />
65
51
  Back
66
52
  </Button>
67
53
  ) : undefined
@@ -79,59 +65,41 @@ export function SkillsDialog({ skills, open, onOpenChange, onUse, className }: S
79
65
  )
80
66
  }
81
67
 
82
- function SkillList({
83
- skills,
84
- onSelect,
85
- }: {
86
- skills: SkillInfo[] | undefined
87
- onSelect: (name: string) => void
88
- }) {
68
+ function SkillList({ skills, onSelect }: { skills: SkillInfo[] | undefined; onSelect: (name: string) => void }) {
89
69
  if (!skills) {
90
- // Not "none" codex can only list skills over a live child, which it does
91
- // not spawn until the session has something to do. Saying so beats an empty
92
- // list that reads as "you have no skills".
93
- return (
94
- <p className='py-6 text-center text-body-sm text-fg-4'>
95
- Skills are listed once the session connects — send a message first.
96
- </p>
97
- )
70
+ return <p className="py-6 text-center text-body-sm text-fg-4">Skills are listed once the session connects send a message first.</p>
98
71
  }
99
72
  if (skills.length === 0) {
100
- return <p className='py-6 text-center text-body-sm text-fg-4'>This session found no skills.</p>
73
+ return <p className="py-6 text-center text-body-sm text-fg-4">This session found no skills.</p>
101
74
  }
102
75
  const scopes = [...new Set(skills.map((s) => s.scope ?? 'other'))]
103
76
  return (
104
- <div className='flex flex-col gap-4'>
77
+ <div className="flex flex-col gap-4">
105
78
  {scopes.map((scope) => (
106
79
  <div key={scope}>
107
- <h3 className='text-label font-medium text-fg-3'>{SCOPE_LABEL[scope] ?? scope}</h3>
108
- <ul className='mt-1 flex flex-col'>
80
+ <h3 className="text-label font-medium text-fg-3">{SCOPE_LABEL[scope] ?? scope}</h3>
81
+ <ul className="mt-1 flex flex-col">
109
82
  {skills
110
83
  .filter((s) => (s.scope ?? 'other') === scope)
111
84
  .map((s) => (
112
85
  <li key={s.name}>
113
86
  <button
114
- type='button'
87
+ type="button"
115
88
  onClick={() => onSelect(s.name)}
116
- className='flex w-full items-start gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-surface-hover'>
117
- <span className='min-w-0 flex-1'>
118
- <span className='block truncate text-body-sm text-fg-1'>
119
- {s.displayName ?? s.name}
120
- </span>
121
- {s.shortDescription ?? s.description ? (
122
- <span className='block truncate text-label text-fg-4'>
123
- {s.shortDescription ?? s.description}
124
- </span>
89
+ className="flex w-full items-start gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-surface-hover"
90
+ >
91
+ <span className="min-w-0 flex-1">
92
+ <span className="block truncate text-body-sm text-fg-1">{s.displayName ?? s.name}</span>
93
+ {(s.shortDescription ?? s.description) ? (
94
+ <span className="block truncate text-label text-fg-4">{s.shortDescription ?? s.description}</span>
125
95
  ) : null}
126
96
  </span>
127
- {/* Listed but switched off: a different answer from absent,
128
- and the one an operator hunting for a missing skill needs. */}
129
97
  {!s.enabled ? (
130
- <Badge variant='neutral' className='mt-0.5 shrink-0'>
98
+ <Badge variant="neutral" className="mt-0.5 shrink-0">
131
99
  off
132
100
  </Badge>
133
101
  ) : null}
134
- <ChevronRight className='mt-0.5 size-3.5 shrink-0 text-fg-4' />
102
+ <ChevronRight className="mt-0.5 size-3.5 shrink-0 text-fg-4" />
135
103
  </button>
136
104
  </li>
137
105
  ))}
@@ -142,22 +110,12 @@ function SkillList({
142
110
  )
143
111
  }
144
112
 
145
- function SkillView({
146
- skill,
147
- onUse,
148
- onUsed,
149
- }: {
150
- skill: SkillInfo
151
- onUse?: (skill: SkillInfo) => void
152
- onUsed: () => void
153
- }) {
113
+ function SkillView({ skill, onUse, onUsed }: { skill: SkillInfo; onUse?: (skill: SkillInfo) => void; onUsed: () => void }) {
154
114
  return (
155
- <div className='flex flex-col gap-4'>
115
+ <div className="flex flex-col gap-4">
156
116
  <div>
157
- {skill.scope ? (
158
- <DialogRow label='Scope'>{SCOPE_LABEL[skill.scope] ?? skill.scope}</DialogRow>
159
- ) : null}
160
- <DialogRow label='Status'>
117
+ {skill.scope ? <DialogRow label="Scope">{SCOPE_LABEL[skill.scope] ?? skill.scope}</DialogRow> : null}
118
+ <DialogRow label="Status">
161
119
  <Badge variant={skill.enabled ? 'success' : 'neutral'} dot>
162
120
  {skill.enabled ? 'enabled' : 'disabled'}
163
121
  </Badge>
@@ -165,29 +123,26 @@ function SkillView({
165
123
  </div>
166
124
  {skill.description ? (
167
125
  <div>
168
- <h3 className='text-label font-medium text-fg-3'>Description</h3>
169
- {/* Shown verbatim because this is the text the MODEL selects on —
170
- paraphrasing it here would describe a different skill than the one
171
- the agent is reading about. */}
172
- <p className='mt-1 text-body-sm whitespace-pre-wrap text-fg-2'>{skill.description}</p>
126
+ <h3 className="text-label font-medium text-fg-3">Description</h3>
127
+ <p className="mt-1 text-body-sm whitespace-pre-wrap text-fg-2">{skill.description}</p>
173
128
  </div>
174
129
  ) : (
175
- <p className='text-body-sm text-fg-4'>This skill carries no description.</p>
130
+ <p className="text-body-sm text-fg-4">This skill carries no description.</p>
176
131
  )}
177
132
  {onUse && skill.enabled ? (
178
133
  <div>
179
134
  <Button
180
- variant='outline'
181
- size='sm'
135
+ variant="outline"
136
+ size="sm"
182
137
  onClick={() => {
183
138
  onUse(skill)
184
139
  onUsed()
185
- }}>
140
+ }}
141
+ >
186
142
  Use this skill
187
143
  </Button>
188
- <p className='mt-1.5 text-label text-fg-4'>
189
- Writes an opening message into the composer for you to edit — it isn’t sent, and there
190
- is no command that runs a skill directly.
144
+ <p className="mt-1.5 text-label text-fg-4">
145
+ Writes an opening message into the composer for you to edit — it isn’t sent, and there is no command that runs a skill directly.
191
146
  </p>
192
147
  </div>
193
148
  ) : null}