@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.
- package/README.md +81 -3
- package/build/SessionPanel-Dy9lQrOV.d.mts +319 -0
- package/build/SessionPanel-NQ8ksCfj.mjs +8474 -0
- package/build/SessionPanel-NQ8ksCfj.mjs.map +1 -0
- package/build/format-DqR56Y8l.mjs +162 -0
- package/build/format-DqR56Y8l.mjs.map +1 -0
- package/build/format-ljc3lKpA.d.mts +59 -0
- package/build/format.d.mts +66 -0
- package/build/format.mjs +119 -0
- package/build/format.mjs.map +1 -0
- package/build/index.d.mts +671 -88
- package/build/index.mjs +387 -5160
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +226 -0
- package/build/workspace.mjs +861 -0
- package/build/workspace.mjs.map +1 -0
- package/package.json +22 -4
- package/src/components/agent/CodeEditor.tsx +300 -0
- package/src/components/agent/Composer.tsx +522 -87
- package/src/components/agent/ContextDialog.tsx +99 -0
- package/src/components/agent/Conversation.tsx +11 -3
- package/src/components/agent/EditorTabs.tsx +165 -0
- package/src/components/agent/FileCard.tsx +26 -0
- package/src/components/agent/FileTree.tsx +287 -0
- package/src/components/agent/FileViewer.tsx +148 -0
- package/src/components/agent/HostFilesDialog.tsx +218 -0
- package/src/components/agent/Loader.tsx +82 -14
- package/src/components/agent/McpDialog.tsx +363 -0
- package/src/components/agent/Message.tsx +51 -17
- package/src/components/agent/ModelSelect.tsx +34 -6
- package/src/components/agent/PermissionModeSelect.tsx +133 -22
- package/src/components/agent/PermissionPrompt.tsx +164 -6
- package/src/components/agent/PromptTokenText.tsx +39 -0
- package/src/components/agent/QuestionPrompt.tsx +122 -0
- package/src/components/agent/Reasoning.tsx +20 -5
- package/src/components/agent/Response.tsx +128 -0
- package/src/components/agent/SessionBrowser.tsx +428 -0
- package/src/components/agent/SessionEmptyState.tsx +65 -0
- package/src/components/agent/SessionInfoDialog.tsx +163 -0
- package/src/components/agent/SessionPanel.tsx +783 -91
- package/src/components/agent/SessionWorkspace.tsx +317 -0
- package/src/components/agent/SkillsDialog.tsx +195 -0
- package/src/components/agent/StatusBar.tsx +85 -18
- package/src/components/agent/ToolCallCard.tsx +252 -30
- package/src/components/agent/Transcript.tsx +513 -30
- package/src/components/agent/UsageDialog.tsx +168 -0
- package/src/components/agent/line-prompt.tsx +249 -0
- package/src/components/agent/pulse.tsx +60 -0
- package/src/components/agent/transcript-variant.tsx +123 -0
- package/src/components/prompt-area/prompt-area-engine.ts +53 -0
- package/src/components/prompt-area/types.ts +15 -0
- package/src/components/prompt-area/use-prompt-area.ts +20 -0
- package/src/components/ui/CodeBlock.tsx +40 -2
- package/src/components/ui/CopyButton.tsx +28 -3
- package/src/components/ui/Dialog.tsx +92 -0
- package/src/components/ui/Menu.tsx +55 -0
- package/src/components/ui/Splitter.tsx +133 -0
- package/src/components/ui/Tooltip.tsx +22 -5
- package/src/format.ts +11 -0
- package/src/index.ts +67 -2
- package/src/lib/clipboard.ts +56 -0
- package/src/lib/format.ts +114 -0
- package/src/lib/status.ts +124 -0
- package/src/lib/tool-icon.ts +96 -0
- 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
|
-
|
|
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
|
-
|
|
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-
|
|
163
|
+
'flex items-center gap-2 border-b border-border bg-surface px-3 py-1.5',
|
|
122
164
|
className,
|
|
123
165
|
)}>
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
}
|