@workerdeck/ui 0.9.0 → 0.10.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 +44 -3
- package/build/index.d.mts +762 -21
- package/build/index.mjs +3245 -348
- package/build/index.mjs.map +1 -1
- package/package.json +5 -4
- package/src/components/agent/CodeEditor.tsx +300 -0
- package/src/components/agent/Composer.tsx +379 -56
- package/src/components/agent/ContextDialog.tsx +99 -0
- package/src/components/agent/EditorTabs.tsx +165 -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/McpDialog.tsx +363 -0
- package/src/components/agent/ModelSelect.tsx +34 -6
- package/src/components/agent/PermissionModeSelect.tsx +99 -22
- package/src/components/agent/PermissionPrompt.tsx +72 -6
- package/src/components/agent/PromptTokenText.tsx +39 -0
- package/src/components/agent/SessionEmptyState.tsx +65 -0
- package/src/components/agent/SessionInfoDialog.tsx +163 -0
- package/src/components/agent/SessionPanel.tsx +379 -40
- package/src/components/agent/SessionWorkspace.tsx +282 -0
- package/src/components/agent/SkillsDialog.tsx +195 -0
- package/src/components/agent/StatusBar.tsx +85 -18
- package/src/components/agent/ToolCallCard.tsx +80 -4
- package/src/components/agent/Transcript.tsx +66 -17
- package/src/components/agent/UsageDialog.tsx +168 -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/CopyButton.tsx +8 -1
- 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/index.ts +53 -1
- package/src/lib/clipboard.ts +56 -0
- package/src/lib/format.ts +48 -0
- package/src/lib/tool-icon.ts +74 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workerdeck/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Styled agent-control component library for WorkerDeck: session panel, transcript with streaming, tool-call cards, permission prompts, session list, composer. Tailwind v4 + Base UI + cva. Pairs with the headless @workerdeck/react layer.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,13 +23,14 @@
|
|
|
23
23
|
"class-variance-authority": "^0.7.1",
|
|
24
24
|
"clsx": "^2.1.1",
|
|
25
25
|
"lucide-react": "^1.21.0",
|
|
26
|
+
"monaco-editor": "^0.56.0",
|
|
26
27
|
"sonner": "^2.0.7",
|
|
27
28
|
"streamdown": "^2.5.0",
|
|
28
29
|
"tailwind-merge": "^3.6.0",
|
|
29
30
|
"use-stick-to-bottom": "^1.1.6",
|
|
30
|
-
"@workerdeck/client": "0.
|
|
31
|
-
"@workerdeck/
|
|
32
|
-
"@workerdeck/
|
|
31
|
+
"@workerdeck/client": "0.10.0",
|
|
32
|
+
"@workerdeck/react": "0.10.0",
|
|
33
|
+
"@workerdeck/protocol": "0.10.0"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
|
35
36
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type * as Monaco from 'monaco-editor'
|
|
3
|
+
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
import { Spinner } from '../ui/Spinner.tsx'
|
|
5
|
+
|
|
6
|
+
export interface CodeEditorProps {
|
|
7
|
+
/** Absolute path — decides the language and identifies the model. */
|
|
8
|
+
path: string
|
|
9
|
+
/** Text to show. Applied to the model when it differs from what is on screen,
|
|
10
|
+
* so an external reload lands without fighting the user's cursor. */
|
|
11
|
+
value: string
|
|
12
|
+
onChange?: (value: string) => void
|
|
13
|
+
/** Ctrl/Cmd+S. Wired inside Monaco because the editor swallows keydown. */
|
|
14
|
+
onSave?: () => void
|
|
15
|
+
readOnly?: boolean
|
|
16
|
+
className?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Monaco — VS Code's own editor — behind a small React surface.
|
|
21
|
+
*
|
|
22
|
+
* Two deliberate choices, both about `@workerdeck/ui` being a **published
|
|
23
|
+
* library** rather than an app:
|
|
24
|
+
*
|
|
25
|
+
* 1. **Loaded on demand.** The `import()` is inside an effect, so Monaco is a
|
|
26
|
+
* separate chunk that arrives when someone first opens a file. A dashboard
|
|
27
|
+
* that never opens one never pays for it, and Monaco's ~90 language grammars
|
|
28
|
+
* are themselves lazy (each `registerLanguage` carries an `import()` loader),
|
|
29
|
+
* so opening a `.ts` file fetches the TypeScript grammar and nothing else.
|
|
30
|
+
* 2. **No `MonacoEnvironment` is configured here, and none is needed.** Workers
|
|
31
|
+
* in a library become every embedder's bootstrapping problem, and
|
|
32
|
+
* `packages/web` ships prebuilt static files at a domain root, which is
|
|
33
|
+
* exactly where hardcoded worker URLs break. The editor is configured so it
|
|
34
|
+
* never asks for one: `wordBasedSuggestions` and `quickSuggestions` off,
|
|
35
|
+
* no diff editor. A host that wants the worker-backed language services
|
|
36
|
+
* (TypeScript IntelliSense, JSON schema validation) sets `MonacoEnvironment`
|
|
37
|
+
* itself before the first file is opened — Monaco is a singleton and nothing
|
|
38
|
+
* here fights that.
|
|
39
|
+
*
|
|
40
|
+
* One model per path, kept across tab switches, so undo history and view state
|
|
41
|
+
* survive clicking away and back — which is most of what makes tabs feel like
|
|
42
|
+
* tabs rather than like re-opening a file.
|
|
43
|
+
*/
|
|
44
|
+
export function CodeEditor({
|
|
45
|
+
path,
|
|
46
|
+
value,
|
|
47
|
+
onChange,
|
|
48
|
+
onSave,
|
|
49
|
+
readOnly,
|
|
50
|
+
className,
|
|
51
|
+
}: CodeEditorProps) {
|
|
52
|
+
const host = useRef<HTMLDivElement>(null)
|
|
53
|
+
const editor = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null)
|
|
54
|
+
const monaco = useRef<typeof Monaco | null>(null)
|
|
55
|
+
const [ready, setReady] = useState(false)
|
|
56
|
+
const theme = useDocumentTheme()
|
|
57
|
+
|
|
58
|
+
// Callbacks through refs: they change identity every render, and re-creating
|
|
59
|
+
// the editor (or re-registering its listeners) on each one would drop the
|
|
60
|
+
// cursor mid-keystroke.
|
|
61
|
+
const handlers = useRef({ onChange, onSave })
|
|
62
|
+
handlers.current = { onChange, onSave }
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
let disposed = false
|
|
66
|
+
void loadMonaco().then((api) => {
|
|
67
|
+
if (disposed || !host.current) return
|
|
68
|
+
monaco.current = api
|
|
69
|
+
const instance = api.editor.create(host.current, {
|
|
70
|
+
value,
|
|
71
|
+
language: languageOf(path),
|
|
72
|
+
readOnly,
|
|
73
|
+
automaticLayout: true,
|
|
74
|
+
theme: monacoTheme(document.documentElement.getAttribute('data-theme')),
|
|
75
|
+
minimap: { enabled: false },
|
|
76
|
+
scrollBeyondLastLine: false,
|
|
77
|
+
fontSize: 12,
|
|
78
|
+
lineHeight: 20,
|
|
79
|
+
fontFamily:
|
|
80
|
+
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
|
81
|
+
renderLineHighlight: 'line',
|
|
82
|
+
smoothScrolling: true,
|
|
83
|
+
padding: { top: 8, bottom: 8 },
|
|
84
|
+
scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 },
|
|
85
|
+
// Without a worker there is no word-based suggestion provider to ask, so
|
|
86
|
+
// asking would surface an empty popup on every identifier.
|
|
87
|
+
wordBasedSuggestions: 'off',
|
|
88
|
+
quickSuggestions: false,
|
|
89
|
+
})
|
|
90
|
+
editor.current = instance
|
|
91
|
+
instance.onDidChangeModelContent(() => {
|
|
92
|
+
handlers.current.onChange?.(instance.getValue())
|
|
93
|
+
})
|
|
94
|
+
instance.addCommand(api.KeyMod.CtrlCmd | api.KeyCode.KeyS, () => {
|
|
95
|
+
handlers.current.onSave?.()
|
|
96
|
+
})
|
|
97
|
+
setReady(true)
|
|
98
|
+
})
|
|
99
|
+
return () => {
|
|
100
|
+
disposed = true
|
|
101
|
+
// Dispose the editor but NOT its model — the model is keyed by path and
|
|
102
|
+
// outlives this mount so that reopening a tab restores its undo stack.
|
|
103
|
+
editor.current?.dispose()
|
|
104
|
+
editor.current = null
|
|
105
|
+
setReady(false)
|
|
106
|
+
}
|
|
107
|
+
// Deliberately created once, with no dependencies. Path, value and readOnly
|
|
108
|
+
// are applied by the effects below instead, because re-creating the editor
|
|
109
|
+
// to change any of them would lose the cursor, the scroll position and the
|
|
110
|
+
// undo history.
|
|
111
|
+
}, [])
|
|
112
|
+
|
|
113
|
+
// Swap the model when the focused file changes. One model per path, created
|
|
114
|
+
// lazily and kept, so each tab keeps its own undo history and view state.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const api = monaco.current
|
|
117
|
+
const instance = editor.current
|
|
118
|
+
if (!api || !instance || !ready) return
|
|
119
|
+
const uri = api.Uri.parse(`workerdeck://host${path}`)
|
|
120
|
+
const model = api.editor.getModel(uri) ?? api.editor.createModel(value, languageOf(path), uri)
|
|
121
|
+
if (instance.getModel() !== model) instance.setModel(model)
|
|
122
|
+
}, [path, ready, value])
|
|
123
|
+
|
|
124
|
+
// Apply an external change — a reload from disk, a revert — without
|
|
125
|
+
// disturbing anything when the text already matches, which is the common case
|
|
126
|
+
// because most changes to `value` are echoes of the user's own typing.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const instance = editor.current
|
|
129
|
+
if (!instance || !ready) return
|
|
130
|
+
const model = instance.getModel()
|
|
131
|
+
if (!model || model.getValue() === value) return
|
|
132
|
+
// `pushEditOperations` rather than `setValue` so the replacement joins the
|
|
133
|
+
// undo stack instead of clearing it.
|
|
134
|
+
model.pushEditOperations(
|
|
135
|
+
[],
|
|
136
|
+
[{ range: model.getFullModelRange(), text: value }],
|
|
137
|
+
() => null,
|
|
138
|
+
)
|
|
139
|
+
}, [value, ready])
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (ready) editor.current?.updateOptions({ readOnly })
|
|
143
|
+
}, [readOnly, ready])
|
|
144
|
+
|
|
145
|
+
// The theme is global to Monaco, not per-editor — `setTheme` is on the
|
|
146
|
+
// namespace for that reason.
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (ready) monaco.current?.editor.setTheme(monacoTheme(theme))
|
|
149
|
+
}, [theme, ready])
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div className={cn('relative min-h-0 min-w-0 flex-1', className)}>
|
|
153
|
+
<div ref={host} className='absolute inset-0' />
|
|
154
|
+
{!ready ? (
|
|
155
|
+
<div className='absolute inset-0 flex items-center justify-center bg-bg'>
|
|
156
|
+
<Spinner className='size-4 text-fg-4' />
|
|
157
|
+
</div>
|
|
158
|
+
) : null}
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Follow the `data-theme` the design tokens already swap on, so the editor is
|
|
165
|
+
* never the one light rectangle in a dark app (or the reverse). An attribute
|
|
166
|
+
* observer rather than a media query: the app has a manual toggle, and the
|
|
167
|
+
* attribute is what that toggle writes.
|
|
168
|
+
*/
|
|
169
|
+
function useDocumentTheme(): string | null {
|
|
170
|
+
const [theme, setTheme] = useState<string | null>(() =>
|
|
171
|
+
typeof document === 'undefined' ? null : document.documentElement.getAttribute('data-theme'),
|
|
172
|
+
)
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
const root = document.documentElement
|
|
175
|
+
const sync = () => setTheme(root.getAttribute('data-theme'))
|
|
176
|
+
sync()
|
|
177
|
+
const observer = new MutationObserver(sync)
|
|
178
|
+
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] })
|
|
179
|
+
return () => observer.disconnect()
|
|
180
|
+
}, [])
|
|
181
|
+
return theme
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** An unset attribute means the host never opted into the token themes, in which
|
|
185
|
+
* case dark matches this package's default surface. */
|
|
186
|
+
function monacoTheme(theme: string | null): string {
|
|
187
|
+
return theme === 'light' ? 'wd-light' : 'wd-dark'
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Load Monaco once, register the themes, and hand back the API.
|
|
192
|
+
*
|
|
193
|
+
* Cached as a promise rather than a value so that several editors mounting in
|
|
194
|
+
* the same frame share one load instead of racing three of them.
|
|
195
|
+
*/
|
|
196
|
+
let monacoPromise: Promise<typeof Monaco> | undefined
|
|
197
|
+
function loadMonaco(): Promise<typeof Monaco> {
|
|
198
|
+
monacoPromise ??= (async () => {
|
|
199
|
+
const api = await import('monaco-editor')
|
|
200
|
+
// Themes that inherit the surrounding surface instead of Monaco's own
|
|
201
|
+
// near-black, so the editor does not sit in the layout as a differently
|
|
202
|
+
// coloured rectangle. Both are defined; the CSS variable decides nothing
|
|
203
|
+
// here, so the panel picks by `prefers-color-scheme` on the document.
|
|
204
|
+
api.editor.defineTheme('wd-dark', {
|
|
205
|
+
base: 'vs-dark',
|
|
206
|
+
inherit: true,
|
|
207
|
+
rules: [],
|
|
208
|
+
colors: { 'editor.background': '#00000000' },
|
|
209
|
+
})
|
|
210
|
+
api.editor.defineTheme('wd-light', {
|
|
211
|
+
base: 'vs',
|
|
212
|
+
inherit: true,
|
|
213
|
+
rules: [],
|
|
214
|
+
colors: { 'editor.background': '#00000000' },
|
|
215
|
+
})
|
|
216
|
+
return api
|
|
217
|
+
})()
|
|
218
|
+
return monacoPromise
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Monaco's language id for a path.
|
|
223
|
+
*
|
|
224
|
+
* By extension, with a short table for the files that have none — Monaco's own
|
|
225
|
+
* registry is keyed on extensions it knows, and an unmatched file falls through
|
|
226
|
+
* to `plaintext`, which is the honest answer rather than a guess.
|
|
227
|
+
*/
|
|
228
|
+
function languageOf(path: string): string {
|
|
229
|
+
const name = path.slice(path.lastIndexOf('/') + 1)
|
|
230
|
+
const byName = FILENAME_LANGUAGES[name.toLowerCase()]
|
|
231
|
+
if (byName) return byName
|
|
232
|
+
const extension = name.slice(name.lastIndexOf('.') + 1).toLowerCase()
|
|
233
|
+
return EXTENSION_LANGUAGES[extension] ?? 'plaintext'
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Files whose type is their whole name. */
|
|
237
|
+
const FILENAME_LANGUAGES: Record<string, string> = {
|
|
238
|
+
dockerfile: 'dockerfile',
|
|
239
|
+
makefile: 'plaintext',
|
|
240
|
+
'.gitignore': 'plaintext',
|
|
241
|
+
'.env': 'shell',
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Extension → Monaco language id. Only where the id differs from the extension
|
|
245
|
+
* or the extension is ambiguous; everything else Monaco resolves itself. */
|
|
246
|
+
const EXTENSION_LANGUAGES: Record<string, string> = {
|
|
247
|
+
ts: 'typescript',
|
|
248
|
+
tsx: 'typescript',
|
|
249
|
+
mts: 'typescript',
|
|
250
|
+
cts: 'typescript',
|
|
251
|
+
js: 'javascript',
|
|
252
|
+
jsx: 'javascript',
|
|
253
|
+
mjs: 'javascript',
|
|
254
|
+
cjs: 'javascript',
|
|
255
|
+
json: 'json',
|
|
256
|
+
jsonc: 'json',
|
|
257
|
+
md: 'markdown',
|
|
258
|
+
mdx: 'markdown',
|
|
259
|
+
css: 'css',
|
|
260
|
+
scss: 'scss',
|
|
261
|
+
less: 'less',
|
|
262
|
+
html: 'html',
|
|
263
|
+
htm: 'html',
|
|
264
|
+
xml: 'xml',
|
|
265
|
+
svg: 'xml',
|
|
266
|
+
yml: 'yaml',
|
|
267
|
+
yaml: 'yaml',
|
|
268
|
+
toml: 'plaintext',
|
|
269
|
+
sh: 'shell',
|
|
270
|
+
bash: 'shell',
|
|
271
|
+
zsh: 'shell',
|
|
272
|
+
fish: 'shell',
|
|
273
|
+
py: 'python',
|
|
274
|
+
rb: 'ruby',
|
|
275
|
+
rs: 'rust',
|
|
276
|
+
go: 'go',
|
|
277
|
+
swift: 'swift',
|
|
278
|
+
java: 'java',
|
|
279
|
+
kt: 'kotlin',
|
|
280
|
+
c: 'c',
|
|
281
|
+
h: 'c',
|
|
282
|
+
cpp: 'cpp',
|
|
283
|
+
cc: 'cpp',
|
|
284
|
+
hpp: 'cpp',
|
|
285
|
+
cs: 'csharp',
|
|
286
|
+
php: 'php',
|
|
287
|
+
sql: 'sql',
|
|
288
|
+
graphql: 'graphql',
|
|
289
|
+
gql: 'graphql',
|
|
290
|
+
lua: 'lua',
|
|
291
|
+
r: 'r',
|
|
292
|
+
pl: 'perl',
|
|
293
|
+
dart: 'dart',
|
|
294
|
+
scala: 'scala',
|
|
295
|
+
ini: 'ini',
|
|
296
|
+
cfg: 'ini',
|
|
297
|
+
conf: 'ini',
|
|
298
|
+
txt: 'plaintext',
|
|
299
|
+
log: 'plaintext',
|
|
300
|
+
}
|