@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.
Files changed (38) hide show
  1. package/README.md +44 -3
  2. package/build/index.d.mts +762 -21
  3. package/build/index.mjs +3245 -348
  4. package/build/index.mjs.map +1 -1
  5. package/package.json +5 -4
  6. package/src/components/agent/CodeEditor.tsx +300 -0
  7. package/src/components/agent/Composer.tsx +379 -56
  8. package/src/components/agent/ContextDialog.tsx +99 -0
  9. package/src/components/agent/EditorTabs.tsx +165 -0
  10. package/src/components/agent/FileTree.tsx +287 -0
  11. package/src/components/agent/FileViewer.tsx +148 -0
  12. package/src/components/agent/HostFilesDialog.tsx +218 -0
  13. package/src/components/agent/McpDialog.tsx +363 -0
  14. package/src/components/agent/ModelSelect.tsx +34 -6
  15. package/src/components/agent/PermissionModeSelect.tsx +99 -22
  16. package/src/components/agent/PermissionPrompt.tsx +72 -6
  17. package/src/components/agent/PromptTokenText.tsx +39 -0
  18. package/src/components/agent/SessionEmptyState.tsx +65 -0
  19. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  20. package/src/components/agent/SessionPanel.tsx +379 -40
  21. package/src/components/agent/SessionWorkspace.tsx +282 -0
  22. package/src/components/agent/SkillsDialog.tsx +195 -0
  23. package/src/components/agent/StatusBar.tsx +85 -18
  24. package/src/components/agent/ToolCallCard.tsx +80 -4
  25. package/src/components/agent/Transcript.tsx +66 -17
  26. package/src/components/agent/UsageDialog.tsx +168 -0
  27. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  28. package/src/components/prompt-area/types.ts +15 -0
  29. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  30. package/src/components/ui/CopyButton.tsx +8 -1
  31. package/src/components/ui/Dialog.tsx +92 -0
  32. package/src/components/ui/Menu.tsx +55 -0
  33. package/src/components/ui/Splitter.tsx +133 -0
  34. package/src/components/ui/Tooltip.tsx +22 -5
  35. package/src/index.ts +53 -1
  36. package/src/lib/clipboard.ts +56 -0
  37. package/src/lib/format.ts +48 -0
  38. 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.9.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.9.0",
31
- "@workerdeck/protocol": "0.9.0",
32
- "@workerdeck/react": "0.9.0"
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
+ }