@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,148 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { OpenFile } from '@workerdeck/react'
3
+ import { currentText, isDirty } from '@workerdeck/react'
4
+ import { FileWarning, TriangleAlert } from 'lucide-react'
5
+ import { cn } from '../../lib/utils.ts'
6
+ import { Button } from '../ui/Button.tsx'
7
+ import { Spinner } from '../ui/Spinner.tsx'
8
+ import { CodeEditor } from './CodeEditor.tsx'
9
+
10
+ export interface FileViewerProps {
11
+ file: OpenFile | undefined
12
+ /** From `/fs/roots`. False renders the editor read-only rather than letting
13
+ * someone type into a file this gateway will refuse to write. */
14
+ canWrite?: boolean
15
+ onChange?: (path: string, content: string) => void
16
+ onSave?: (path: string) => void
17
+ /** Discard this tab's edits — local only, no re-read. */
18
+ onRevert?: (path: string) => void
19
+ /** Take the version on disk, discarding this tab's edits. */
20
+ onReload?: (path: string) => void
21
+ /** Take this tab's version, over whatever is on disk now. */
22
+ onOverwrite?: (path: string) => void
23
+ onDismissConflict?: (path: string) => void
24
+ className?: string
25
+ }
26
+
27
+ /**
28
+ * The focused file: Monaco, plus the states a file can be in that are not
29
+ * "here is some text".
30
+ *
31
+ * No path row — the tab's tooltip carries the path and the size, and a line of
32
+ * monospace above every file is chrome that never earns its height.
33
+ */
34
+ export function FileViewer({
35
+ file,
36
+ canWrite,
37
+ onChange,
38
+ onSave,
39
+ onRevert,
40
+ onReload,
41
+ onOverwrite,
42
+ onDismissConflict,
43
+ className,
44
+ }: FileViewerProps) {
45
+ if (!file) return null
46
+
47
+ return (
48
+ <div
49
+ data-slot='file-viewer'
50
+ className={cn('flex min-h-0 min-w-0 flex-1 flex-col bg-bg', className)}>
51
+ {/* The one failure with a choice attached, so it gets a bar rather than a
52
+ toast: the agent rewrote this file while it was open, and which
53
+ version wins is not something to decide on the user's behalf. */}
54
+ {file.conflict ? (
55
+ <div className='flex shrink-0 flex-wrap items-center gap-2 border-b border-warning/40 bg-warning-bg px-3 py-2'>
56
+ <TriangleAlert className='size-3.5 shrink-0 text-warning' />
57
+ <span className='min-w-0 flex-1 text-body-sm text-warning'>
58
+ This file changed on disk since you opened it.
59
+ </span>
60
+ <Button variant='outline' size='xs' onClick={() => onReload?.(file.path)}>
61
+ Use the version on disk
62
+ </Button>
63
+ <Button variant='outline' size='xs' onClick={() => onOverwrite?.(file.path)}>
64
+ Keep mine
65
+ </Button>
66
+ <Button variant='ghost' size='xs' onClick={() => onDismissConflict?.(file.path)}>
67
+ Dismiss
68
+ </Button>
69
+ </div>
70
+ ) : file.saveError ? (
71
+ <div className='flex shrink-0 items-center gap-2 border-b border-danger/40 bg-danger-bg px-3 py-2'>
72
+ <TriangleAlert className='size-3.5 shrink-0 text-danger' />
73
+ <span className='min-w-0 flex-1 text-body-sm text-danger'>{file.saveError}</span>
74
+ <Button variant='ghost' size='xs' onClick={() => onDismissConflict?.(file.path)}>
75
+ Dismiss
76
+ </Button>
77
+ </div>
78
+ ) : null}
79
+
80
+ {file.status === 'loading' ? (
81
+ <Centred>
82
+ <Spinner className='size-4 text-fg-4' />
83
+ </Centred>
84
+ ) : file.status === 'error' ? (
85
+ <Centred>
86
+ <TriangleAlert className='size-4 text-danger' />
87
+ <p className='text-body-sm text-danger'>{file.error}</p>
88
+ </Centred>
89
+ ) : file.status === 'binary' ? (
90
+ <Centred>
91
+ <FileWarning className='size-4 text-fg-4' />
92
+ <p className='text-body-sm text-fg-4'>This file isn’t text.</p>
93
+ {/* Said out loud, because "can't show it" and "editing it here would
94
+ destroy it" are different reassurances. */}
95
+ <p className='text-label text-fg-4'>It can’t be edited here without corrupting it.</p>
96
+ </Centred>
97
+ ) : (
98
+ <CodeEditor
99
+ path={file.path}
100
+ value={currentText(file)}
101
+ readOnly={!canWrite}
102
+ onChange={onChange ? (content) => onChange(file.path, content) : undefined}
103
+ onSave={onSave ? () => onSave(file.path) : undefined}
104
+ />
105
+ )}
106
+
107
+ {/* A status strip only while there is something to say. Saving is fast
108
+ enough that a permanent row would mostly be blank. */}
109
+ {file.status === 'ready' && (file.saving || isDirty(file) || !canWrite) ? (
110
+ <div className='flex shrink-0 items-center gap-2 border-t border-border px-3 py-1'>
111
+ {file.saving ? (
112
+ <>
113
+ <Spinner className='size-3 text-fg-4' />
114
+ <span className='text-label text-fg-4'>Saving…</span>
115
+ </>
116
+ ) : !canWrite ? (
117
+ <span className='text-label text-fg-4'>
118
+ Read-only — this gateway doesn’t allow writes.
119
+ </span>
120
+ ) : (
121
+ <>
122
+ <span className='text-label text-fg-3'>Unsaved changes</span>
123
+ <span className='flex-1' />
124
+ {/* Revert, not reload: discard *my* edits and go back to what
125
+ this tab read. Re-reading is the conflict bar's job, and it is
126
+ a different question. */}
127
+ <Button variant='ghost' size='xs' onClick={() => onRevert?.(file.path)}>
128
+ Revert
129
+ </Button>
130
+ <Button variant='outline' size='xs' onClick={() => onSave?.(file.path)}>
131
+ Save
132
+ <span className='ml-1 text-fg-4'>⌘S</span>
133
+ </Button>
134
+ </>
135
+ )}
136
+ </div>
137
+ ) : null}
138
+ </div>
139
+ )
140
+ }
141
+
142
+ function Centred({ children }: { children: ReactNode }) {
143
+ return (
144
+ <div className='flex min-h-0 flex-1 flex-col items-center justify-center gap-2 p-6'>
145
+ {children}
146
+ </div>
147
+ )
148
+ }
@@ -0,0 +1,218 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import type { WorkerDeckClient } from '@workerdeck/client'
3
+ import type { HostDirEntry, HostFileMatch } from '@workerdeck/protocol'
4
+ import { ChevronLeft, File, Folder, Link2, Search } from 'lucide-react'
5
+ import { Button } from '../ui/Button.tsx'
6
+ import { CodeBlock } from '../ui/CodeBlock.tsx'
7
+ import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
8
+ import { Input } from '../ui/Input.tsx'
9
+ import { Spinner } from '../ui/Spinner.tsx'
10
+ import { formatBytes } from '../../lib/format.ts'
11
+
12
+ export interface HostFilesDialogProps {
13
+ client: WorkerDeckClient
14
+ /** The session's working directory — the browser is rooted here. */
15
+ cwd: string | undefined
16
+ open: boolean
17
+ onOpenChange: (open: boolean) => void
18
+ }
19
+
20
+ /**
21
+ * Browse the project the session is working in.
22
+ *
23
+ * Deliberately rooted at the session's cwd rather than at the server's
24
+ * `hostFiles.roots`: the roots are the *security* boundary (the server enforces
25
+ * them on every request), but what someone wants while watching an agent is this
26
+ * project's tree. Read-only — writing is a separate server opt-in and not
27
+ * something a session viewer should be doing behind the agent's back.
28
+ */
29
+ export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDialogProps) {
30
+ const [path, setPath] = useState<string | undefined>(cwd)
31
+ const [entries, setEntries] = useState<HostDirEntry[]>([])
32
+ const [truncated, setTruncated] = useState(false)
33
+ const [query, setQuery] = useState('')
34
+ const [matches, setMatches] = useState<HostFileMatch[] | undefined>()
35
+ const [file, setFile] = useState<{ path: string; content: string; bytes: number } | undefined>()
36
+ const [loading, setLoading] = useState(false)
37
+ const [error, setError] = useState<string | undefined>()
38
+
39
+ const list = useCallback(
40
+ async (target: string) => {
41
+ setLoading(true)
42
+ setError(undefined)
43
+ try {
44
+ const response = await client.listHostDir(target)
45
+ setPath(response.path)
46
+ setEntries(response.entries)
47
+ setTruncated(response.truncated ?? false)
48
+ } catch (e) {
49
+ setError(e instanceof Error ? e.message : 'Could not read that directory')
50
+ } finally {
51
+ setLoading(false)
52
+ }
53
+ },
54
+ [client],
55
+ )
56
+
57
+ // Opening resets the browser to the session's directory. Navigation from here
58
+ // is explicit (`list`) rather than an effect on `path`, so walking into a
59
+ // folder is one request and not two.
60
+ useEffect(() => {
61
+ if (!open) return
62
+ setFile(undefined)
63
+ setQuery('')
64
+ setMatches(undefined)
65
+ setError(undefined)
66
+ if (cwd) void list(cwd)
67
+ }, [open, cwd, list])
68
+
69
+ // Debounced, and only while there is something to search for; an empty box is
70
+ // "show me the directory again", not "search for everything".
71
+ useEffect(() => {
72
+ if (!open || !cwd) return
73
+ const q = query.trim()
74
+ if (!q) {
75
+ setMatches(undefined)
76
+ return
77
+ }
78
+ const timer = setTimeout(() => {
79
+ client
80
+ .findHostFiles(cwd, q, 40)
81
+ .then((response) => setMatches(response.matches))
82
+ .catch(() => setMatches([]))
83
+ }, 150)
84
+ return () => clearTimeout(timer)
85
+ }, [client, cwd, query, open])
86
+
87
+ const openFile = async (target: string) => {
88
+ setLoading(true)
89
+ setError(undefined)
90
+ try {
91
+ const response = await client.readHostFile(target)
92
+ setFile({
93
+ path: response.path,
94
+ bytes: response.bytes,
95
+ content:
96
+ response.encoding === 'utf8'
97
+ ? response.content
98
+ : '(binary file — not shown)',
99
+ })
100
+ } catch (e) {
101
+ setError(e instanceof Error ? e.message : 'Could not read that file')
102
+ } finally {
103
+ setLoading(false)
104
+ }
105
+ }
106
+
107
+ const parent = path && cwd && path !== cwd ? path.slice(0, path.lastIndexOf('/')) || '/' : undefined
108
+ const shown = matches ?? entries
109
+
110
+ return (
111
+ <Dialog open={open} onOpenChange={onOpenChange}>
112
+ <DialogContent size='lg'>
113
+ <DialogHeader
114
+ title={file ? file.path.split('/').pop()! : 'Files'}
115
+ description={file ? file.path : path}
116
+ actions={
117
+ file ? (
118
+ <Button variant='ghost' size='xs' onClick={() => setFile(undefined)}>
119
+ <ChevronLeft className='size-3.5' />
120
+ Back
121
+ </Button>
122
+ ) : parent ? (
123
+ <Button variant='ghost' size='xs' onClick={() => void list(parent)}>
124
+ <ChevronLeft className='size-3.5' />
125
+ Up
126
+ </Button>
127
+ ) : null
128
+ }
129
+ />
130
+ <DialogBody className='flex flex-col gap-3'>
131
+ {error ? (
132
+ <div className='rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger'>{error}</div>
133
+ ) : null}
134
+
135
+ {file ? (
136
+ <>
137
+ <p className='text-label text-fg-4'>{formatBytes(file.bytes)}</p>
138
+ <CodeBlock code={file.content} label={file.path.split('/').pop()} />
139
+ </>
140
+ ) : (
141
+ <>
142
+ <div className='relative'>
143
+ <Search className='absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-fg-4' />
144
+ <Input
145
+ value={query}
146
+ onChange={(e) => setQuery(e.target.value)}
147
+ placeholder='Search this project…'
148
+ className='pl-7'
149
+ spellCheck={false}
150
+ />
151
+ </div>
152
+ {loading && shown.length === 0 ? (
153
+ <div className='py-6 text-center'>
154
+ <Spinner className='size-4 text-fg-4' />
155
+ </div>
156
+ ) : shown.length === 0 ? (
157
+ <p className='py-6 text-center text-body-sm text-fg-4'>
158
+ {matches ? 'No matching files.' : 'This directory is empty.'}
159
+ </p>
160
+ ) : (
161
+ <ul className='flex flex-col'>
162
+ {matches
163
+ ? matches.map((match) => (
164
+ <li key={match.path}>
165
+ <button
166
+ type='button'
167
+ onClick={() => void openFile(match.path)}
168
+ className='flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover'>
169
+ <File className='size-3.5 shrink-0 text-fg-4' />
170
+ <span className='min-w-0 flex-1 truncate font-mono text-label text-fg-1'>
171
+ {match.relative}
172
+ </span>
173
+ </button>
174
+ </li>
175
+ ))
176
+ : entries.map((entry) => (
177
+ <li key={entry.path}>
178
+ <button
179
+ type='button'
180
+ onClick={() =>
181
+ entry.type === 'dir' ? void list(entry.path) : void openFile(entry.path)
182
+ }
183
+ className='flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover'>
184
+ <EntryIcon type={entry.type} />
185
+ <span className='min-w-0 flex-1 truncate font-mono text-label text-fg-1'>
186
+ {entry.name}
187
+ {entry.type === 'dir' ? '/' : ''}
188
+ </span>
189
+ {entry.bytes !== undefined ? (
190
+ <span className='shrink-0 text-label text-fg-4'>
191
+ {formatBytes(entry.bytes)}
192
+ </span>
193
+ ) : null}
194
+ </button>
195
+ </li>
196
+ ))}
197
+ </ul>
198
+ )}
199
+ {truncated && !matches ? (
200
+ <p className='text-label text-fg-4'>
201
+ More entries than the server will return — use the search box.
202
+ </p>
203
+ ) : null}
204
+ </>
205
+ )}
206
+ </DialogBody>
207
+ </DialogContent>
208
+ </Dialog>
209
+ )
210
+ }
211
+
212
+ /** A symlink is reported as itself and never silently resolved — following it is
213
+ * the next request's problem, and that request is refused if it escapes the roots. */
214
+ function EntryIcon({ type }: { type: HostDirEntry['type'] }) {
215
+ if (type === 'dir') return <Folder className='size-3.5 shrink-0 text-accent' />
216
+ if (type === 'symlink') return <Link2 className='size-3.5 shrink-0 text-fg-4' />
217
+ return <File className='size-3.5 shrink-0 text-fg-4' />
218
+ }
@@ -1,21 +1,89 @@
1
1
  import { cn } from '../../lib/utils.ts'
2
+ import { formatDuration, formatTokens } from '../../lib/format.ts'
3
+ import { usePulse } from './pulse.tsx'
4
+ import { LineGlyph, useLines } from './transcript-variant.tsx'
5
+
6
+ export interface LoaderProps {
7
+ /** Overrides the cycling verb — for a state that has one true name
8
+ * ("Starting session…"). */
9
+ label?: string
10
+ /** When the current run began, for the elapsed clock. Absent = no clock. */
11
+ startedAt?: number
12
+ /** Context tokens in play, shown beside the clock. */
13
+ tokens?: number
14
+ className?: string
15
+ }
16
+
17
+ /**
18
+ * What it says it is doing while it hasn't said anything yet. Cycled on a slow
19
+ * clock so a long turn doesn't sit under one frozen word — a still label reads
20
+ * as a hung process, which is exactly what this is meant to disprove.
21
+ */
22
+ const VERBS = [
23
+ 'Working',
24
+ 'Thinking',
25
+ 'Churning',
26
+ 'Pondering',
27
+ 'Whirring',
28
+ 'Computing',
29
+ 'Percolating',
30
+ 'Tinkering',
31
+ 'Deliberating',
32
+ 'Simmering',
33
+ 'Crunching',
34
+ 'Noodling',
35
+ ]
36
+ const VERB_MS = 4000
37
+
38
+ /**
39
+ * "The agent is working and hasn't produced output yet."
40
+ *
41
+ * `lines`: a terminal working line — the mark's own pulse in the gutter (see
42
+ * `pulse.tsx`), a verb, and the readings that answer "should I still be
43
+ * waiting?" in one parenthesis. `cards`: the three-dot pulse, unchanged — the
44
+ * dashboard's loader is not a gutter glyph and has no column to pulse in.
45
+ */
46
+ export function Loader({ label, startedAt, tokens, className }: LoaderProps) {
47
+ const lines = useLines()
48
+ // Only the line variant animates a glyph; cards pulse three dots in CSS.
49
+ const pulse = usePulse(lines)
50
+
51
+ if (!lines) {
52
+ return (
53
+ <div
54
+ data-slot='loader'
55
+ className={cn('flex items-center gap-2 py-1 text-body-sm text-fg-4', className)}>
56
+ <span className='flex items-center gap-1'>
57
+ {[0, 1, 2].map((i) => (
58
+ <span
59
+ key={i}
60
+ className='size-1.5 animate-pulse rounded-full bg-fg-4'
61
+ style={{ animationDelay: `${i * 160}ms` }}
62
+ />
63
+ ))}
64
+ </span>
65
+ {label ? <span>{label}</span> : null}
66
+ </div>
67
+ )
68
+ }
69
+
70
+ // Derived from the clock rather than kept in state: the frame tick is already
71
+ // re-rendering, so the seconds and the verb come along for free.
72
+ const elapsed = startedAt === undefined ? undefined : Date.now() - startedAt
73
+ const verb =
74
+ label ?? VERBS[Math.floor((elapsed ?? 0) / VERB_MS) % VERBS.length] ?? VERBS[0]
75
+ const readings = [
76
+ elapsed !== undefined ? formatDuration(elapsed) : undefined,
77
+ tokens !== undefined ? `↓ ${formatTokens(tokens)}` : undefined,
78
+ ].filter(Boolean)
2
79
 
3
- /** Three-dot pulse shown while the assistant hasn't produced output yet. */
4
- export function Loader({ label, className }: { label?: string; className?: string }) {
5
80
  return (
6
- <div
7
- data-slot='loader'
8
- className={cn('flex items-center gap-2 py-1 text-body-sm text-fg-4', className)}>
9
- <span className='flex items-center gap-1'>
10
- {[0, 1, 2].map((i) => (
11
- <span
12
- key={i}
13
- className='size-1.5 animate-pulse rounded-full bg-fg-4'
14
- style={{ animationDelay: `${i * 160}ms` }}
15
- />
16
- ))}
81
+ <div data-slot='loader' className={cn('flex items-baseline gap-2', className)}>
82
+ <LineGlyph className='text-accent'>{pulse}</LineGlyph>
83
+ <span className='min-w-0 flex-1 text-body-sm leading-5 text-fg-3'>
84
+ {verb}…{' '}
85
+ {readings.length ? <span className='text-label text-fg-4'>({readings.join(' · ')})</span> : null}
17
86
  </span>
18
- {label ? <span>{label}</span> : null}
19
87
  </div>
20
88
  )
21
89
  }