@workerdeck/ui 0.9.0 → 0.12.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 (65) hide show
  1. package/README.md +81 -3
  2. package/build/SessionPanel-Dy9lQrOV.d.mts +319 -0
  3. package/build/SessionPanel-NQ8ksCfj.mjs +8474 -0
  4. package/build/SessionPanel-NQ8ksCfj.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 +66 -0
  9. package/build/format.mjs +119 -0
  10. package/build/format.mjs.map +1 -0
  11. package/build/index.d.mts +671 -88
  12. package/build/index.mjs +387 -5160
  13. package/build/index.mjs.map +1 -1
  14. package/build/workspace.d.mts +226 -0
  15. package/build/workspace.mjs +861 -0
  16. package/build/workspace.mjs.map +1 -0
  17. package/package.json +22 -4
  18. package/src/components/agent/CodeEditor.tsx +300 -0
  19. package/src/components/agent/Composer.tsx +522 -87
  20. package/src/components/agent/ContextDialog.tsx +99 -0
  21. package/src/components/agent/Conversation.tsx +11 -3
  22. package/src/components/agent/EditorTabs.tsx +165 -0
  23. package/src/components/agent/FileCard.tsx +26 -0
  24. package/src/components/agent/FileTree.tsx +287 -0
  25. package/src/components/agent/FileViewer.tsx +148 -0
  26. package/src/components/agent/HostFilesDialog.tsx +218 -0
  27. package/src/components/agent/Loader.tsx +82 -14
  28. package/src/components/agent/McpDialog.tsx +363 -0
  29. package/src/components/agent/Message.tsx +51 -17
  30. package/src/components/agent/ModelSelect.tsx +34 -6
  31. package/src/components/agent/PermissionModeSelect.tsx +133 -22
  32. package/src/components/agent/PermissionPrompt.tsx +164 -6
  33. package/src/components/agent/PromptTokenText.tsx +39 -0
  34. package/src/components/agent/QuestionPrompt.tsx +122 -0
  35. package/src/components/agent/Reasoning.tsx +20 -5
  36. package/src/components/agent/Response.tsx +128 -0
  37. package/src/components/agent/SessionBrowser.tsx +428 -0
  38. package/src/components/agent/SessionEmptyState.tsx +65 -0
  39. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  40. package/src/components/agent/SessionPanel.tsx +783 -91
  41. package/src/components/agent/SessionWorkspace.tsx +317 -0
  42. package/src/components/agent/SkillsDialog.tsx +195 -0
  43. package/src/components/agent/StatusBar.tsx +85 -18
  44. package/src/components/agent/ToolCallCard.tsx +252 -30
  45. package/src/components/agent/Transcript.tsx +513 -30
  46. package/src/components/agent/UsageDialog.tsx +168 -0
  47. package/src/components/agent/line-prompt.tsx +249 -0
  48. package/src/components/agent/pulse.tsx +60 -0
  49. package/src/components/agent/transcript-variant.tsx +123 -0
  50. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  51. package/src/components/prompt-area/types.ts +15 -0
  52. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  53. package/src/components/ui/CodeBlock.tsx +40 -2
  54. package/src/components/ui/CopyButton.tsx +28 -3
  55. package/src/components/ui/Dialog.tsx +92 -0
  56. package/src/components/ui/Menu.tsx +55 -0
  57. package/src/components/ui/Splitter.tsx +133 -0
  58. package/src/components/ui/Tooltip.tsx +22 -5
  59. package/src/format.ts +11 -0
  60. package/src/index.ts +67 -2
  61. package/src/lib/clipboard.ts +56 -0
  62. package/src/lib/format.ts +114 -0
  63. package/src/lib/status.ts +124 -0
  64. package/src/lib/tool-icon.ts +96 -0
  65. package/src/workspace.ts +28 -0
@@ -0,0 +1,317 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'
2
+ import { createPortal } from 'react-dom'
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'
12
+ import { PanelLeftOpen } from 'lucide-react'
13
+ import { cn } from '../../lib/utils.ts'
14
+ import { Button } from '../ui/Button.tsx'
15
+ import { Splitter } from '../ui/Splitter.tsx'
16
+ import { EditorTabs } from './EditorTabs.tsx'
17
+ import { FileTree } from './FileTree.tsx'
18
+ import { FileViewer } from './FileViewer.tsx'
19
+ import { SessionPanel, type SessionPanelProps } from './SessionPanel.tsx'
20
+
21
+ export interface SessionWorkspaceProps {
22
+ client: WorkerDeckClient
23
+ sessionId: string | undefined
24
+ /** Passed straight through to {@link SessionPanel} — including the render-prop
25
+ * form that claims the session-actions menu. */
26
+ header?: SessionPanelProps['header']
27
+ /**
28
+ * Panel seams the workspace does not interpret, forwarded verbatim.
29
+ *
30
+ * They are listed rather than spread so the workspace stays explicit about
31
+ * what it passes on: the panel owns the session's one attach, and a seam that
32
+ * silently arrived here would be a second place to look for why a session
33
+ * renders the way it does.
34
+ */
35
+ transcriptVariant?: SessionPanelProps['transcriptVariant']
36
+ transcriptDensity?: SessionPanelProps['transcriptDensity']
37
+ unseen?: SessionPanelProps['unseen']
38
+ onVitals?: SessionPanelProps['onVitals']
39
+ /** Rail width in pixels on first render. */
40
+ defaultRailWidth?: number
41
+ /** Start with the file rail collapsed even on a wide viewport. */
42
+ defaultRailCollapsed?: boolean
43
+ /**
44
+ * The rail moved. Paired with the two defaults so an embedder can persist the
45
+ * layout — the workspace deliberately does not, because *where* to keep it (a
46
+ * Memento, localStorage, a workspace file) is the embedder's call, and a
47
+ * component that picked one would be wrong in the other hosts.
48
+ */
49
+ onRailChange?: (rail: { width: number; collapsed: boolean }) => void
50
+ className?: string
51
+ }
52
+
53
+ const RAIL_MIN = 180
54
+ const RAIL_MAX = 520
55
+ /** How little of the agent column the editor may leave. Below this the composer
56
+ * and a line of transcript stop fitting together, which is the point at which the
57
+ * agent has stopped being usable rather than merely small. */
58
+ const AGENT_MIN = 220
59
+ const EDITOR_MIN = 120
60
+
61
+ /**
62
+ * A VS Code-shaped workspace around a live session: file tree on the left, open
63
+ * files above, the agent below.
64
+ *
65
+ * **Strictly additive.** {@link SessionPanel} is untouched and still the whole
66
+ * session surface on its own — an embedder picks one or the other, and one that
67
+ * has its own file tree keeps using the panel.
68
+ *
69
+ * Two things here are load-bearing and easy to break:
70
+ *
71
+ * 1. **The editor region is absent from the layout when nothing is open**, not
72
+ * collapsed to zero height. A zero-height pane leaves a draggable splitter and
73
+ * parks the composer at an odd offset; absence is what makes the agent's
74
+ * "claims the full column" state actually look like the panel alone.
75
+ * 2. **`SessionPanel` keeps its position in the tree across that transition.** It
76
+ * holds the WebSocket attach and the entire transcript, so moving it between
77
+ * parents — or wrapping it conditionally — would remount it and drop the
78
+ * session's rendered history on the floor the first time someone opens a file.
79
+ * The conditional children before it are `? :` expressions that leave a null in
80
+ * their slot, which is exactly what keeps its index stable.
81
+ */
82
+ export function SessionWorkspace({
83
+ client,
84
+ sessionId,
85
+ header,
86
+ transcriptVariant,
87
+ transcriptDensity,
88
+ unseen,
89
+ onVitals,
90
+ defaultRailWidth = 260,
91
+ defaultRailCollapsed,
92
+ onRailChange,
93
+ className,
94
+ }: SessionWorkspaceProps) {
95
+ // The cwd is the tree's root, and it comes from the registry rather than from
96
+ // the panel: reading it off the panel's own session hook would mean attaching
97
+ // a second WebSocket client, and the tool bridge asks the *first* attached
98
+ // client — a second one changes who answers.
99
+ const { info } = useSessionInfo(client, sessionId)
100
+ const cwd = info?.cwd
101
+
102
+ const tree = useHostFileTree(client, cwd)
103
+ const search = useHostFileSearch(client, cwd)
104
+ const files = useOpenFiles(client)
105
+ // Writing is a separate server opt-in from reading and defaults off, so the
106
+ // editor asks before it offers to save anything.
107
+ const { canWrite } = useHostFileRoots(client)
108
+
109
+ // Nothing here can save on the user's behalf when the tab is going away, so
110
+ // the browser's own guard is the last line. Registered only while there is
111
+ // something to lose — an unconditional handler makes every navigation prompt.
112
+ useEffect(() => {
113
+ if (!files.hasUnsaved) return
114
+ const warn = (event: BeforeUnloadEvent) => event.preventDefault()
115
+ window.addEventListener('beforeunload', warn)
116
+ return () => window.removeEventListener('beforeunload', warn)
117
+ }, [files.hasUnsaved])
118
+
119
+ const wide = useIsWide()
120
+ const [railCollapsed, setRailCollapsed] = useState(defaultRailCollapsed ?? false)
121
+ const [railWidth, setRailWidth] = useState(defaultRailWidth)
122
+ // Reported rather than stored. Kept in a ref so the effect below fires on a
123
+ // real change instead of on every render an inline callback would cause.
124
+ const onRailChangeRef = useRef(onRailChange)
125
+ onRailChangeRef.current = onRailChange
126
+ useEffect(() => {
127
+ onRailChangeRef.current?.({ width: railWidth, collapsed: railCollapsed })
128
+ }, [railWidth, railCollapsed])
129
+ const [editorHeight, setEditorHeight] = useState(360)
130
+
131
+ // Closing every tab returns the agent to the full column; opening one again
132
+ // should restore the height the user had chosen, so this is not reset here.
133
+ const hasFiles = files.files.length > 0
134
+
135
+ // A narrow viewport cannot spare 260px of rail beside a transcript, so there
136
+ // the rail overlays the workspace instead of sitting next to it — and starts
137
+ // out of the way.
138
+ const overlayRail = !wide
139
+ const railOpen = tree.available && !railCollapsed
140
+ useEffect(() => {
141
+ if (overlayRail) setRailCollapsed(true)
142
+ }, [overlayRail])
143
+
144
+ // Closing a dirty tab is the one destructive thing the strip can do, and the
145
+ // edits are not recoverable once the tab is gone. `confirm` rather than a
146
+ // styled dialog on purpose: it is modal, it cannot be missed, and a
147
+ // custom one here would be a second modal system inside a component an
148
+ // embedder already renders inside their own.
149
+ const closeTab = useCallback(
150
+ (path: string) => {
151
+ const file = files.files.find((f) => f.path === path)
152
+ if (file && isDirty(file)) {
153
+ const name = file.name
154
+ if (!window.confirm(`${name} has unsaved changes. Close it and lose them?`)) return
155
+ }
156
+ files.close(path)
157
+ },
158
+ [files],
159
+ )
160
+
161
+ const column = useRef<HTMLDivElement>(null)
162
+ const columnHeight = useElementHeight(column)
163
+ const editorMax = Math.max(EDITOR_MIN, columnHeight - AGENT_MIN)
164
+
165
+ // The embedder's header is app chrome and belongs above everything — but only
166
+ // `SessionPanel` can *build* the `⋯` menu it is handed, and only if it is the
167
+ // one calling the render-prop. So the panel still calls it, in its own tree,
168
+ // and the result is portalled up here. That keeps `SessionPanel` untouched and
169
+ // keeps the menu's own context (it is a Base UI popup) intact.
170
+ const [topBar, setTopBar] = useState<HTMLDivElement | null>(null)
171
+ const hoisted: SessionPanelProps['header'] =
172
+ header === undefined || topBar === null
173
+ ? undefined
174
+ : typeof header === 'function'
175
+ ? (slots) => createPortal(header(slots), topBar)
176
+ : createPortal(header, topBar)
177
+
178
+ return (
179
+ <div
180
+ data-slot='session-workspace'
181
+ className={cn('flex h-full min-h-0 w-full flex-col overflow-hidden bg-bg', className)}>
182
+ {header !== undefined ? <div ref={setTopBar} className='shrink-0' /> : null}
183
+ <div className='relative flex min-h-0 flex-1'>
184
+ {railOpen ? (
185
+ <FileTree
186
+ tree={tree}
187
+ search={search}
188
+ activePath={files.activePath}
189
+ onOpenFile={files.open}
190
+ onCollapse={() => setRailCollapsed(true)}
191
+ style={{ width: overlayRail ? Math.min(railWidth, 320) : railWidth }}
192
+ className={cn(
193
+ 'shrink-0 border-r border-border',
194
+ overlayRail && 'absolute inset-y-0 left-0 z-20 shadow-lg',
195
+ )}
196
+ />
197
+ ) : tree.available ? (
198
+ // Collapsed: a slim strip that keeps the rail one click away. In flow
199
+ // rather than floating over the panel, so it can never land on top of an
200
+ // embedder's own header controls.
201
+ <div className='flex w-8 shrink-0 flex-col items-center border-r border-border bg-surface pt-1.5'>
202
+ <Button
203
+ variant='ghost'
204
+ size='icon-sm'
205
+ aria-label='Show project files'
206
+ onClick={() => setRailCollapsed(false)}>
207
+ <PanelLeftOpen className='size-4 text-fg-3' />
208
+ </Button>
209
+ </div>
210
+ ) : null}
211
+ {/* No splitter over an overlay rail — dragging a drawer's edge on a phone
212
+ fights the scroll it is sitting on top of. */}
213
+ {railOpen && !overlayRail ? (
214
+ <Splitter
215
+ orientation='vertical'
216
+ value={railWidth}
217
+ onValueChange={setRailWidth}
218
+ min={RAIL_MIN}
219
+ max={RAIL_MAX}
220
+ aria-label='Resize the file tree'
221
+ />
222
+ ) : null}
223
+
224
+ <div ref={column} className='flex min-h-0 min-w-0 flex-1 flex-col'>
225
+ {/* Slot 1 of 3. The `? :` leaves a null here when nothing is open, which
226
+ is what holds the agent's slot below and keeps it from remounting. */}
227
+ {hasFiles ? (
228
+ <div
229
+ className='flex min-h-0 shrink-0 flex-col overflow-hidden'
230
+ style={{ height: Math.min(editorHeight, editorMax || editorHeight) }}>
231
+ <EditorTabs
232
+ files={files.files}
233
+ activePath={files.activePath}
234
+ onActivate={files.activate}
235
+ onClose={closeTab}
236
+ />
237
+ <FileViewer
238
+ file={files.active}
239
+ canWrite={canWrite}
240
+ onChange={files.edit}
241
+ onSave={(path) => void files.save(path)}
242
+ onRevert={files.revert}
243
+ onReload={files.reload}
244
+ onOverwrite={(path) => void files.overwrite(path)}
245
+ onDismissConflict={files.dismissConflict}
246
+ />
247
+ </div>
248
+ ) : null}
249
+ {/* Slot 2 of 3. */}
250
+ {hasFiles ? (
251
+ <Splitter
252
+ orientation='horizontal'
253
+ value={Math.min(editorHeight, editorMax || editorHeight)}
254
+ onValueChange={setEditorHeight}
255
+ min={EDITOR_MIN}
256
+ max={editorMax}
257
+ aria-label='Resize the open file'
258
+ />
259
+ ) : null}
260
+ {/* Slot 3 of 3 — always here, always at this index. */}
261
+ <SessionPanel
262
+ client={client}
263
+ sessionId={sessionId}
264
+ header={hoisted}
265
+ transcriptVariant={transcriptVariant}
266
+ transcriptDensity={transcriptDensity}
267
+ unseen={unseen}
268
+ onVitals={onVitals}
269
+ className='min-h-0 flex-1'
270
+ />
271
+ </div>
272
+
273
+ {/* Tapping away closes the drawer, which is the only way back to the
274
+ transcript on a narrow screen. */}
275
+ {overlayRail && railOpen ? (
276
+ <button
277
+ type='button'
278
+ aria-label='Close the file tree'
279
+ onClick={() => setRailCollapsed(true)}
280
+ className='absolute inset-0 z-10 bg-black/30'
281
+ />
282
+ ) : null}
283
+ </div>
284
+ </div>
285
+ )
286
+ }
287
+
288
+ /** Live height of an element, for a splitter that needs to know how much room it
289
+ * is dividing. Zero until the first observation, which callers treat as
290
+ * "unmeasured" rather than as a real bound. */
291
+ function useElementHeight(ref: RefObject<HTMLElement | null>): number {
292
+ const [height, setHeight] = useState(0)
293
+ useLayoutEffect(() => {
294
+ const element = ref.current
295
+ if (!element) return
296
+ const observer = new ResizeObserver(([entry]) => {
297
+ if (entry) setHeight(entry.contentRect.height)
298
+ })
299
+ observer.observe(element)
300
+ return () => observer.disconnect()
301
+ }, [ref])
302
+ return height
303
+ }
304
+
305
+ /** Whether there is room for a rail beside the content. Presentation only — the
306
+ * workspace's actual state lives in the hooks from `@workerdeck/react`. */
307
+ function useIsWide(): boolean {
308
+ const [wide, setWide] = useState(true)
309
+ useEffect(() => {
310
+ const query = window.matchMedia('(min-width: 768px)')
311
+ const sync = () => setWide(query.matches)
312
+ sync()
313
+ query.addEventListener('change', sync)
314
+ return () => query.removeEventListener('change', sync)
315
+ }, [])
316
+ return wide
317
+ }
@@ -0,0 +1,195 @@
1
+ import { useState } from 'react'
2
+ import type { SkillInfo } from '@workerdeck/protocol'
3
+ import { ChevronLeft, ChevronRight } from 'lucide-react'
4
+ import { Badge } from '../ui/Badge.tsx'
5
+ import { Button } from '../ui/Button.tsx'
6
+ import { Dialog, DialogBody, DialogContent, DialogHeader, DialogRow } from '../ui/Dialog.tsx'
7
+
8
+ export interface SkillsDialogProps {
9
+ skills: SkillInfo[] | undefined
10
+ open: boolean
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
+ onUse?: (skill: SkillInfo) => void
15
+ }
16
+
17
+ /** Where the skill came from. The engine's set is open, so an unrecognised
18
+ * scope renders as itself rather than being forced into a bucket. */
19
+ const SCOPE_LABEL: Record<string, string> = {
20
+ user: 'Personal',
21
+ repo: 'This project',
22
+ system: 'System',
23
+ admin: 'Managed',
24
+ }
25
+
26
+ /**
27
+ * What this session's engine can do beyond its own tools: the skills it found,
28
+ * grouped by where they came from, with one drilled-down view each.
29
+ *
30
+ * The framing matters more here than in most panels. A skill is **not** a
31
+ * command — the model decides to use one by reading its description, and there
32
+ * is no wire syntax that invokes it. So this screen is a *discovery* surface,
33
+ * and the one action it offers ("Use this skill") is honest about being a
34
+ * drafting aid: it types a message for the operator to edit and send.
35
+ *
36
+ * Fed from the session's `skills` event rather than a REST route, because that
37
+ * is the channel the engine refreshes on its own when a skill changes on disk.
38
+ */
39
+ export function SkillsDialog({ skills, open, onOpenChange, onUse }: SkillsDialogProps) {
40
+ const [selected, setSelected] = useState<string | undefined>()
41
+ const skill = skills?.find((s) => s.name === selected)
42
+
43
+ return (
44
+ <Dialog
45
+ open={open}
46
+ onOpenChange={(next) => {
47
+ if (!next) setSelected(undefined)
48
+ onOpenChange(next)
49
+ }}>
50
+ <DialogContent>
51
+ <DialogHeader
52
+ title={skill ? (skill.displayName ?? skill.name) : 'Skills'}
53
+ description={
54
+ skill
55
+ ? skill.name !== (skill.displayName ?? skill.name)
56
+ ? skill.name
57
+ : undefined
58
+ : 'Capabilities the agent can choose to use. Not commands — the model picks them from their descriptions.'
59
+ }
60
+ actions={
61
+ skill ? (
62
+ <Button variant='ghost' size='xs' onClick={() => setSelected(undefined)}>
63
+ <ChevronLeft className='size-3.5' />
64
+ Back
65
+ </Button>
66
+ ) : undefined
67
+ }
68
+ />
69
+ <DialogBody>
70
+ {skill ? (
71
+ <SkillView skill={skill} onUse={onUse} onUsed={() => onOpenChange(false)} />
72
+ ) : (
73
+ <SkillList skills={skills} onSelect={setSelected} />
74
+ )}
75
+ </DialogBody>
76
+ </DialogContent>
77
+ </Dialog>
78
+ )
79
+ }
80
+
81
+ function SkillList({
82
+ skills,
83
+ onSelect,
84
+ }: {
85
+ skills: SkillInfo[] | undefined
86
+ onSelect: (name: string) => void
87
+ }) {
88
+ if (!skills) {
89
+ // Not "none" — codex can only list skills over a live child, which it does
90
+ // not spawn until the session has something to do. Saying so beats an empty
91
+ // list that reads as "you have no skills".
92
+ return (
93
+ <p className='py-6 text-center text-body-sm text-fg-4'>
94
+ Skills are listed once the session connects — send a message first.
95
+ </p>
96
+ )
97
+ }
98
+ if (skills.length === 0) {
99
+ return <p className='py-6 text-center text-body-sm text-fg-4'>This session found no skills.</p>
100
+ }
101
+ const scopes = [...new Set(skills.map((s) => s.scope ?? 'other'))]
102
+ return (
103
+ <div className='flex flex-col gap-4'>
104
+ {scopes.map((scope) => (
105
+ <div key={scope}>
106
+ <h3 className='text-label font-medium text-fg-3'>{SCOPE_LABEL[scope] ?? scope}</h3>
107
+ <ul className='mt-1 flex flex-col'>
108
+ {skills
109
+ .filter((s) => (s.scope ?? 'other') === scope)
110
+ .map((s) => (
111
+ <li key={s.name}>
112
+ <button
113
+ type='button'
114
+ onClick={() => onSelect(s.name)}
115
+ className='flex w-full items-start gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-surface-hover'>
116
+ <span className='min-w-0 flex-1'>
117
+ <span className='block truncate text-body-sm text-fg-1'>
118
+ {s.displayName ?? s.name}
119
+ </span>
120
+ {s.shortDescription ?? s.description ? (
121
+ <span className='block truncate text-label text-fg-4'>
122
+ {s.shortDescription ?? s.description}
123
+ </span>
124
+ ) : null}
125
+ </span>
126
+ {/* Listed but switched off: a different answer from absent,
127
+ and the one an operator hunting for a missing skill needs. */}
128
+ {!s.enabled ? (
129
+ <Badge variant='neutral' className='mt-0.5 shrink-0'>
130
+ off
131
+ </Badge>
132
+ ) : null}
133
+ <ChevronRight className='mt-0.5 size-3.5 shrink-0 text-fg-4' />
134
+ </button>
135
+ </li>
136
+ ))}
137
+ </ul>
138
+ </div>
139
+ ))}
140
+ </div>
141
+ )
142
+ }
143
+
144
+ function SkillView({
145
+ skill,
146
+ onUse,
147
+ onUsed,
148
+ }: {
149
+ skill: SkillInfo
150
+ onUse?: (skill: SkillInfo) => void
151
+ onUsed: () => void
152
+ }) {
153
+ return (
154
+ <div className='flex flex-col gap-4'>
155
+ <div>
156
+ {skill.scope ? (
157
+ <DialogRow label='Scope'>{SCOPE_LABEL[skill.scope] ?? skill.scope}</DialogRow>
158
+ ) : null}
159
+ <DialogRow label='Status'>
160
+ <Badge variant={skill.enabled ? 'success' : 'neutral'} dot>
161
+ {skill.enabled ? 'enabled' : 'disabled'}
162
+ </Badge>
163
+ </DialogRow>
164
+ </div>
165
+ {skill.description ? (
166
+ <div>
167
+ <h3 className='text-label font-medium text-fg-3'>Description</h3>
168
+ {/* Shown verbatim because this is the text the MODEL selects on —
169
+ paraphrasing it here would describe a different skill than the one
170
+ the agent is reading about. */}
171
+ <p className='mt-1 text-body-sm whitespace-pre-wrap text-fg-2'>{skill.description}</p>
172
+ </div>
173
+ ) : (
174
+ <p className='text-body-sm text-fg-4'>This skill carries no description.</p>
175
+ )}
176
+ {onUse && skill.enabled ? (
177
+ <div>
178
+ <Button
179
+ variant='outline'
180
+ size='sm'
181
+ onClick={() => {
182
+ onUse(skill)
183
+ onUsed()
184
+ }}>
185
+ Use this skill
186
+ </Button>
187
+ <p className='mt-1.5 text-label text-fg-4'>
188
+ Writes an opening message into the composer for you to edit — it isn’t sent, and there
189
+ is no command that runs a skill directly.
190
+ </p>
191
+ </div>
192
+ ) : null}
193
+ </div>
194
+ )
195
+ }
@@ -1,7 +1,7 @@
1
- import { useEffect, useState } from 'react'
2
- import type { TranscriptState } from '@workerdeck/react'
1
+ import { useEffect, useState, type ReactNode } from 'react'
2
+ import type { ConnectionState, TranscriptState } from '@workerdeck/react'
3
3
  import type { ContextUsage, RateLimitInfo } from '@workerdeck/protocol'
4
- import { WifiOff } from 'lucide-react'
4
+ import { RefreshCw, WifiOff } from 'lucide-react'
5
5
  import { Badge } from '../ui/Badge.tsx'
6
6
  import { ProgressRing } from '../ui/ProgressRing.tsx'
7
7
  import { Spinner } from '../ui/Spinner.tsx'
@@ -12,7 +12,24 @@ import { STATUS_META } from './status.ts'
12
12
 
13
13
  export interface StatusBarProps {
14
14
  state: TranscriptState
15
- connected: boolean
15
+ /** @deprecated Pass {@link StatusBarProps.connection}; kept so an embedder
16
+ * still handing over a boolean keeps working. */
17
+ connected?: boolean
18
+ /** How the client is doing at reaching the gateway. A dropped socket wins the
19
+ * status slot: the session status held over a dead socket is a stale reading,
20
+ * and presenting it as a live one is the thing worth avoiding. */
21
+ connection?: ConnectionState
22
+ /**
23
+ * Where the gauges lead. Each one opens the panel that answers *its* question
24
+ * — the two meters measure different things, so sending both to one "details"
25
+ * list would be a detour every time. Omit a handler and that gauge stays a
26
+ * read-only tooltip.
27
+ */
28
+ onOpenStatus?: () => void
29
+ onOpenContext?: () => void
30
+ onOpenUsage?: () => void
31
+ /** Trailing slot — the session-actions menu, in the panel's top-right. */
32
+ actions?: ReactNode
16
33
  className?: string
17
34
  }
18
35
 
@@ -109,32 +126,82 @@ function RateLimitMeter({ label, info, now }: { label: string; info: RateLimitIn
109
126
  )
110
127
  }
111
128
 
112
- export function StatusBar({ state, connected, className }: StatusBarProps) {
129
+ /** A gauge that leads somewhere. `undefined` handler leaves it inert, so an
130
+ * embedder that mounts no panels doesn't get buttons that do nothing. */
131
+ function Slot({ onClick, hint, children }: { onClick?: () => void; hint: string; children: ReactNode }) {
132
+ if (!onClick) return <>{children}</>
133
+ return (
134
+ <button
135
+ type='button'
136
+ onClick={onClick}
137
+ aria-label={hint}
138
+ className='rounded-md px-1 py-0.5 transition-colors outline-none hover:bg-surface-hover focus-visible:bg-surface-hover'>
139
+ {children}
140
+ </button>
141
+ )
142
+ }
143
+
144
+ export function StatusBar({
145
+ state,
146
+ connected,
147
+ connection,
148
+ onOpenStatus,
149
+ onOpenContext,
150
+ onOpenUsage,
151
+ actions,
152
+ className,
153
+ }: StatusBarProps) {
113
154
  const meta = STATUS_META[state.status]
114
155
  const now = useNow()
115
156
  const session = state.rateLimits?.five_hour
116
157
  const weekly = state.rateLimits?.seven_day
158
+ const link: ConnectionState = connection ?? (connected === false ? 'reconnecting' : 'live')
117
159
  return (
118
160
  <div
119
161
  data-slot='status-bar'
120
162
  className={cn(
121
- 'flex items-center gap-3 border-b border-border bg-surface px-3 py-2',
163
+ 'flex items-center gap-2 border-b border-border bg-surface px-3 py-1.5',
122
164
  className,
123
165
  )}>
124
- <Badge variant={meta.variant} dot={!meta.busy}>
125
- {meta.busy ? <Spinner className='size-3 text-current' /> : null}
126
- {meta.label}
127
- </Badge>
128
- {state.contextUsage ? <ContextMeter usage={state.contextUsage} /> : null}
129
- {session ? <RateLimitMeter label='Session' info={session} now={now} /> : null}
130
- {weekly ? <RateLimitMeter label='Weekly' info={weekly} now={now} /> : null}
131
- <span className='flex-1' />
132
- {!connected ? (
133
- <span className='inline-flex items-center gap-1 text-label text-warning'>
134
- <WifiOff className='size-3' /> reconnecting…
135
- </span>
166
+ {/* One slot, two meanings: connection trouble wins it, because a session
167
+ status shown over a dead socket is a stale reading presented as a live
168
+ one. Tapping it opens the session's own facts — where it runs, on what,
169
+ with which credentials — which is the question a status prompts. */}
170
+ <Slot onClick={onOpenStatus} hint='Session info'>
171
+ {link === 'live' ? (
172
+ <Badge variant={meta.variant} dot={!meta.busy}>
173
+ {meta.busy ? <Spinner className='size-3 text-current' /> : null}
174
+ {meta.label}
175
+ </Badge>
176
+ ) : (
177
+ <Badge variant={link === 'offline' ? 'danger' : 'warning'} dot={false}>
178
+ {link === 'offline' ? (
179
+ <WifiOff className='size-3 text-current' />
180
+ ) : (
181
+ <RefreshCw className='size-3 animate-spin text-current' />
182
+ )}
183
+ {link === 'offline' ? 'Offline' : 'Reconnecting…'}
184
+ </Badge>
185
+ )}
186
+ </Slot>
187
+ {/* Never a 0% meter for an engine that doesn't measure: an absent reading
188
+ and a full window are not the same claim. */}
189
+ {state.capabilities.contextUsage && state.contextUsage ? (
190
+ <Slot onClick={onOpenContext} hint='Context breakdown'>
191
+ <ContextMeter usage={state.contextUsage} />
192
+ </Slot>
193
+ ) : null}
194
+ {session || weekly ? (
195
+ <Slot onClick={onOpenUsage} hint='Plan usage'>
196
+ <span className='inline-flex items-center gap-2'>
197
+ {session ? <RateLimitMeter label='Session' info={session} now={now} /> : null}
198
+ {weekly ? <RateLimitMeter label='Weekly' info={weekly} now={now} /> : null}
199
+ </span>
200
+ </Slot>
136
201
  ) : null}
202
+ <span className='flex-1' />
137
203
  <span className='font-mono text-label text-fg-3'>{formatCost(state.totalCostUsd)}</span>
204
+ {actions}
138
205
  </div>
139
206
  )
140
207
  }