@workerdeck/ui 0.7.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +81 -3
  2. package/build/SessionPanel-CyhygZx_.d.mts +277 -0
  3. package/build/SessionPanel-_U8tjX29.mjs +8409 -0
  4. package/build/SessionPanel-_U8tjX29.mjs.map +1 -0
  5. package/build/format-DqR56Y8l.mjs +162 -0
  6. package/build/format-DqR56Y8l.mjs.map +1 -0
  7. package/build/format-ljc3lKpA.d.mts +59 -0
  8. package/build/format.d.mts +2 -0
  9. package/build/format.mjs +2 -0
  10. package/build/index.d.mts +615 -87
  11. package/build/index.mjs +6 -5081
  12. package/build/index.mjs.map +1 -1
  13. package/build/workspace.d.mts +199 -0
  14. package/build/workspace.mjs +849 -0
  15. package/build/workspace.mjs.map +1 -0
  16. package/package.json +22 -4
  17. package/src/components/agent/CodeEditor.tsx +300 -0
  18. package/src/components/agent/Composer.tsx +522 -87
  19. package/src/components/agent/ContextDialog.tsx +99 -0
  20. package/src/components/agent/Conversation.tsx +11 -3
  21. package/src/components/agent/EditorTabs.tsx +165 -0
  22. package/src/components/agent/FileCard.tsx +26 -0
  23. package/src/components/agent/FileTree.tsx +287 -0
  24. package/src/components/agent/FileViewer.tsx +148 -0
  25. package/src/components/agent/HostFilesDialog.tsx +218 -0
  26. package/src/components/agent/Loader.tsx +120 -14
  27. package/src/components/agent/McpDialog.tsx +363 -0
  28. package/src/components/agent/Message.tsx +51 -17
  29. package/src/components/agent/ModelSelect.tsx +34 -6
  30. package/src/components/agent/PermissionModeSelect.tsx +133 -22
  31. package/src/components/agent/PermissionPrompt.tsx +164 -6
  32. package/src/components/agent/PromptTokenText.tsx +39 -0
  33. package/src/components/agent/QuestionPrompt.tsx +122 -0
  34. package/src/components/agent/Reasoning.tsx +20 -5
  35. package/src/components/agent/Response.tsx +128 -0
  36. package/src/components/agent/SessionEmptyState.tsx +65 -0
  37. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  38. package/src/components/agent/SessionPanel.tsx +756 -90
  39. package/src/components/agent/SessionWorkspace.tsx +282 -0
  40. package/src/components/agent/SkillsDialog.tsx +195 -0
  41. package/src/components/agent/StatusBar.tsx +85 -18
  42. package/src/components/agent/ToolCallCard.tsx +243 -30
  43. package/src/components/agent/Transcript.tsx +540 -27
  44. package/src/components/agent/UsageDialog.tsx +168 -0
  45. package/src/components/agent/line-prompt.tsx +249 -0
  46. package/src/components/agent/transcript-variant.tsx +61 -0
  47. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  48. package/src/components/prompt-area/types.ts +15 -0
  49. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  50. package/src/components/ui/CodeBlock.tsx +40 -2
  51. package/src/components/ui/CopyButton.tsx +28 -3
  52. package/src/components/ui/Dialog.tsx +92 -0
  53. package/src/components/ui/Menu.tsx +55 -0
  54. package/src/components/ui/Splitter.tsx +133 -0
  55. package/src/components/ui/Tooltip.tsx +22 -5
  56. package/src/format.ts +10 -0
  57. package/src/index.ts +63 -2
  58. package/src/lib/clipboard.ts +56 -0
  59. package/src/lib/format.ts +114 -0
  60. package/src/lib/tool-icon.ts +96 -0
  61. package/src/workspace.ts +28 -0
@@ -0,0 +1,282 @@
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
+ /** Rail width in pixels on first render. */
28
+ defaultRailWidth?: number
29
+ /** Start with the file rail collapsed even on a wide viewport. */
30
+ defaultRailCollapsed?: boolean
31
+ className?: string
32
+ }
33
+
34
+ const RAIL_MIN = 180
35
+ const RAIL_MAX = 520
36
+ /** How little of the agent column the editor may leave. Below this the composer
37
+ * and a line of transcript stop fitting together, which is the point at which the
38
+ * agent has stopped being usable rather than merely small. */
39
+ const AGENT_MIN = 220
40
+ const EDITOR_MIN = 120
41
+
42
+ /**
43
+ * A VS Code-shaped workspace around a live session: file tree on the left, open
44
+ * files above, the agent below.
45
+ *
46
+ * **Strictly additive.** {@link SessionPanel} is untouched and still the whole
47
+ * session surface on its own — an embedder picks one or the other, and one that
48
+ * has its own file tree keeps using the panel.
49
+ *
50
+ * Two things here are load-bearing and easy to break:
51
+ *
52
+ * 1. **The editor region is absent from the layout when nothing is open**, not
53
+ * collapsed to zero height. A zero-height pane leaves a draggable splitter and
54
+ * parks the composer at an odd offset; absence is what makes the agent's
55
+ * "claims the full column" state actually look like the panel alone.
56
+ * 2. **`SessionPanel` keeps its position in the tree across that transition.** It
57
+ * holds the WebSocket attach and the entire transcript, so moving it between
58
+ * parents — or wrapping it conditionally — would remount it and drop the
59
+ * session's rendered history on the floor the first time someone opens a file.
60
+ * The conditional children before it are `? :` expressions that leave a null in
61
+ * their slot, which is exactly what keeps its index stable.
62
+ */
63
+ export function SessionWorkspace({
64
+ client,
65
+ sessionId,
66
+ header,
67
+ defaultRailWidth = 260,
68
+ defaultRailCollapsed,
69
+ className,
70
+ }: SessionWorkspaceProps) {
71
+ // The cwd is the tree's root, and it comes from the registry rather than from
72
+ // the panel: reading it off the panel's own session hook would mean attaching
73
+ // a second WebSocket client, and the tool bridge asks the *first* attached
74
+ // client — a second one changes who answers.
75
+ const { info } = useSessionInfo(client, sessionId)
76
+ const cwd = info?.cwd
77
+
78
+ const tree = useHostFileTree(client, cwd)
79
+ const search = useHostFileSearch(client, cwd)
80
+ const files = useOpenFiles(client)
81
+ // Writing is a separate server opt-in from reading and defaults off, so the
82
+ // editor asks before it offers to save anything.
83
+ const { canWrite } = useHostFileRoots(client)
84
+
85
+ // Nothing here can save on the user's behalf when the tab is going away, so
86
+ // the browser's own guard is the last line. Registered only while there is
87
+ // something to lose — an unconditional handler makes every navigation prompt.
88
+ useEffect(() => {
89
+ if (!files.hasUnsaved) return
90
+ const warn = (event: BeforeUnloadEvent) => event.preventDefault()
91
+ window.addEventListener('beforeunload', warn)
92
+ return () => window.removeEventListener('beforeunload', warn)
93
+ }, [files.hasUnsaved])
94
+
95
+ const wide = useIsWide()
96
+ const [railCollapsed, setRailCollapsed] = useState(defaultRailCollapsed ?? false)
97
+ const [railWidth, setRailWidth] = useState(defaultRailWidth)
98
+ const [editorHeight, setEditorHeight] = useState(360)
99
+
100
+ // Closing every tab returns the agent to the full column; opening one again
101
+ // should restore the height the user had chosen, so this is not reset here.
102
+ const hasFiles = files.files.length > 0
103
+
104
+ // A narrow viewport cannot spare 260px of rail beside a transcript, so there
105
+ // the rail overlays the workspace instead of sitting next to it — and starts
106
+ // out of the way.
107
+ const overlayRail = !wide
108
+ const railOpen = tree.available && !railCollapsed
109
+ useEffect(() => {
110
+ if (overlayRail) setRailCollapsed(true)
111
+ }, [overlayRail])
112
+
113
+ // Closing a dirty tab is the one destructive thing the strip can do, and the
114
+ // edits are not recoverable once the tab is gone. `confirm` rather than a
115
+ // styled dialog on purpose: it is modal, it cannot be missed, and a
116
+ // custom one here would be a second modal system inside a component an
117
+ // embedder already renders inside their own.
118
+ const closeTab = useCallback(
119
+ (path: string) => {
120
+ const file = files.files.find((f) => f.path === path)
121
+ if (file && isDirty(file)) {
122
+ const name = file.name
123
+ if (!window.confirm(`${name} has unsaved changes. Close it and lose them?`)) return
124
+ }
125
+ files.close(path)
126
+ },
127
+ [files],
128
+ )
129
+
130
+ const column = useRef<HTMLDivElement>(null)
131
+ const columnHeight = useElementHeight(column)
132
+ const editorMax = Math.max(EDITOR_MIN, columnHeight - AGENT_MIN)
133
+
134
+ // The embedder's header is app chrome and belongs above everything — but only
135
+ // `SessionPanel` can *build* the `⋯` menu it is handed, and only if it is the
136
+ // one calling the render-prop. So the panel still calls it, in its own tree,
137
+ // and the result is portalled up here. That keeps `SessionPanel` untouched and
138
+ // keeps the menu's own context (it is a Base UI popup) intact.
139
+ const [topBar, setTopBar] = useState<HTMLDivElement | null>(null)
140
+ const hoisted: SessionPanelProps['header'] =
141
+ header === undefined || topBar === null
142
+ ? undefined
143
+ : typeof header === 'function'
144
+ ? (slots) => createPortal(header(slots), topBar)
145
+ : createPortal(header, topBar)
146
+
147
+ return (
148
+ <div
149
+ data-slot='session-workspace'
150
+ className={cn('flex h-full min-h-0 w-full flex-col overflow-hidden bg-bg', className)}>
151
+ {header !== undefined ? <div ref={setTopBar} className='shrink-0' /> : null}
152
+ <div className='relative flex min-h-0 flex-1'>
153
+ {railOpen ? (
154
+ <FileTree
155
+ tree={tree}
156
+ search={search}
157
+ activePath={files.activePath}
158
+ onOpenFile={files.open}
159
+ onCollapse={() => setRailCollapsed(true)}
160
+ style={{ width: overlayRail ? Math.min(railWidth, 320) : railWidth }}
161
+ className={cn(
162
+ 'shrink-0 border-r border-border',
163
+ overlayRail && 'absolute inset-y-0 left-0 z-20 shadow-lg',
164
+ )}
165
+ />
166
+ ) : tree.available ? (
167
+ // Collapsed: a slim strip that keeps the rail one click away. In flow
168
+ // rather than floating over the panel, so it can never land on top of an
169
+ // embedder's own header controls.
170
+ <div className='flex w-8 shrink-0 flex-col items-center border-r border-border bg-surface pt-1.5'>
171
+ <Button
172
+ variant='ghost'
173
+ size='icon-sm'
174
+ aria-label='Show project files'
175
+ onClick={() => setRailCollapsed(false)}>
176
+ <PanelLeftOpen className='size-4 text-fg-3' />
177
+ </Button>
178
+ </div>
179
+ ) : null}
180
+ {/* No splitter over an overlay rail — dragging a drawer's edge on a phone
181
+ fights the scroll it is sitting on top of. */}
182
+ {railOpen && !overlayRail ? (
183
+ <Splitter
184
+ orientation='vertical'
185
+ value={railWidth}
186
+ onValueChange={setRailWidth}
187
+ min={RAIL_MIN}
188
+ max={RAIL_MAX}
189
+ aria-label='Resize the file tree'
190
+ />
191
+ ) : null}
192
+
193
+ <div ref={column} className='flex min-h-0 min-w-0 flex-1 flex-col'>
194
+ {/* Slot 1 of 3. The `? :` leaves a null here when nothing is open, which
195
+ is what holds the agent's slot below and keeps it from remounting. */}
196
+ {hasFiles ? (
197
+ <div
198
+ className='flex min-h-0 shrink-0 flex-col overflow-hidden'
199
+ style={{ height: Math.min(editorHeight, editorMax || editorHeight) }}>
200
+ <EditorTabs
201
+ files={files.files}
202
+ activePath={files.activePath}
203
+ onActivate={files.activate}
204
+ onClose={closeTab}
205
+ />
206
+ <FileViewer
207
+ file={files.active}
208
+ canWrite={canWrite}
209
+ onChange={files.edit}
210
+ onSave={(path) => void files.save(path)}
211
+ onRevert={files.revert}
212
+ onReload={files.reload}
213
+ onOverwrite={(path) => void files.overwrite(path)}
214
+ onDismissConflict={files.dismissConflict}
215
+ />
216
+ </div>
217
+ ) : null}
218
+ {/* Slot 2 of 3. */}
219
+ {hasFiles ? (
220
+ <Splitter
221
+ orientation='horizontal'
222
+ value={Math.min(editorHeight, editorMax || editorHeight)}
223
+ onValueChange={setEditorHeight}
224
+ min={EDITOR_MIN}
225
+ max={editorMax}
226
+ aria-label='Resize the open file'
227
+ />
228
+ ) : null}
229
+ {/* Slot 3 of 3 — always here, always at this index. */}
230
+ <SessionPanel
231
+ client={client}
232
+ sessionId={sessionId}
233
+ header={hoisted}
234
+ className='min-h-0 flex-1'
235
+ />
236
+ </div>
237
+
238
+ {/* Tapping away closes the drawer, which is the only way back to the
239
+ transcript on a narrow screen. */}
240
+ {overlayRail && railOpen ? (
241
+ <button
242
+ type='button'
243
+ aria-label='Close the file tree'
244
+ onClick={() => setRailCollapsed(true)}
245
+ className='absolute inset-0 z-10 bg-black/30'
246
+ />
247
+ ) : null}
248
+ </div>
249
+ </div>
250
+ )
251
+ }
252
+
253
+ /** Live height of an element, for a splitter that needs to know how much room it
254
+ * is dividing. Zero until the first observation, which callers treat as
255
+ * "unmeasured" rather than as a real bound. */
256
+ function useElementHeight(ref: RefObject<HTMLElement | null>): number {
257
+ const [height, setHeight] = useState(0)
258
+ useLayoutEffect(() => {
259
+ const element = ref.current
260
+ if (!element) return
261
+ const observer = new ResizeObserver(([entry]) => {
262
+ if (entry) setHeight(entry.contentRect.height)
263
+ })
264
+ observer.observe(element)
265
+ return () => observer.disconnect()
266
+ }, [ref])
267
+ return height
268
+ }
269
+
270
+ /** Whether there is room for a rail beside the content. Presentation only — the
271
+ * workspace's actual state lives in the hooks from `@workerdeck/react`. */
272
+ function useIsWide(): boolean {
273
+ const [wide, setWide] = useState(true)
274
+ useEffect(() => {
275
+ const query = window.matchMedia('(min-width: 768px)')
276
+ const sync = () => setWide(query.matches)
277
+ sync()
278
+ query.addEventListener('change', sync)
279
+ return () => query.removeEventListener('change', sync)
280
+ }, [])
281
+ return wide
282
+ }
@@ -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
  }