@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,849 @@
1
+ import { $ as TooltipProvider, Ct as cn, J as Spinner, K as Splitter, Z as Tip, t as SessionPanel, vt as Input, xt as Button } from "./SessionPanel-_U8tjX29.mjs";
2
+ import { n as formatBytes } from "./format-DqR56Y8l.mjs";
3
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
4
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
+ import { ChevronRight, File, FileWarning, Folder, FolderOpen, Link2, PanelLeftClose, PanelLeftOpen, RefreshCw, Search, TriangleAlert, X } from "lucide-react";
6
+ import { currentText, isDirty, useHostFileRoots, useHostFileSearch, useHostFileTree, useOpenFiles, useSessionInfo } from "@workerdeck/react";
7
+ import { createPortal } from "react-dom";
8
+ //#region src/components/agent/EditorTabs.tsx
9
+ /**
10
+ * The open-file tab strip.
11
+ *
12
+ * Hand-rolled rather than built on `@base-ui/react`'s `Tabs`: a VS Code tab
13
+ * carries a close button, and a `<button>` inside a `<button>` is invalid HTML —
14
+ * getting the primitive to render something else costs more than the roving
15
+ * tabindex it would have provided. So that part is here, explicitly, along with
16
+ * the two affordances that actually make a tab strip feel right: middle-click to
17
+ * close, and the active tab scrolling itself into view.
18
+ *
19
+ * Deliberately state-free. Which files are open, which is focused and what
20
+ * closing does are all decided by `useOpenFiles`.
21
+ */
22
+ function EditorTabs({ files, activePath, onActivate, onClose, className }) {
23
+ return /* @__PURE__ */ jsx(TooltipProvider, {
24
+ delay: 500,
25
+ closeDelay: 0,
26
+ children: /* @__PURE__ */ jsx("div", {
27
+ "data-slot": "editor-tabs",
28
+ role: "tablist",
29
+ "aria-label": "Open files",
30
+ className: cn("flex shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface", className),
31
+ children: files.map((file) => /* @__PURE__ */ jsx(Tab, {
32
+ file,
33
+ active: file.path === activePath,
34
+ onActivate: () => onActivate(file.path),
35
+ onClose: () => onClose(file.path),
36
+ onArrow: (direction) => {
37
+ const next = files[files.findIndex((f) => f.path === file.path) + direction];
38
+ if (next) onActivate(next.path);
39
+ }
40
+ }, file.path))
41
+ })
42
+ });
43
+ }
44
+ function Tab({ file, active, onActivate, onClose, onArrow }) {
45
+ const dirty = isDirty(file);
46
+ const ref = useRef(null);
47
+ useEffect(() => {
48
+ if (active) ref.current?.scrollIntoView({
49
+ block: "nearest",
50
+ inline: "nearest"
51
+ });
52
+ }, [active]);
53
+ const onAuxClick = (event) => {
54
+ if (event.button !== 1) return;
55
+ event.preventDefault();
56
+ onClose();
57
+ };
58
+ return /* @__PURE__ */ jsxs(Tip, {
59
+ side: "bottom",
60
+ render: /* @__PURE__ */ jsx("div", {
61
+ ref,
62
+ role: "tab",
63
+ tabIndex: active ? 0 : -1,
64
+ "aria-selected": active,
65
+ onClick: onActivate,
66
+ onAuxClick,
67
+ onKeyDown: (event) => {
68
+ if (event.key === "ArrowRight") onArrow(1);
69
+ else if (event.key === "ArrowLeft") onArrow(-1);
70
+ else if (event.key === "Enter" || event.key === " ") onActivate();
71
+ else return;
72
+ event.preventDefault();
73
+ },
74
+ className: cn("group flex min-w-0 shrink-0 cursor-pointer items-center gap-1.5 border-r border-border px-3 py-1.5 transition-colors", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent focus-visible:ring-inset", active ? "bg-bg text-fg-1" : "text-fg-3 hover:bg-surface-hover hover:text-fg-2")
75
+ }),
76
+ content: /* @__PURE__ */ jsxs("span", {
77
+ className: "flex flex-col gap-0.5",
78
+ children: [/* @__PURE__ */ jsx("span", {
79
+ className: "font-mono break-all",
80
+ children: file.path
81
+ }), file.bytes !== void 0 ? /* @__PURE__ */ jsx("span", {
82
+ className: "text-fg-4",
83
+ children: formatBytes(file.bytes)
84
+ }) : null]
85
+ }),
86
+ children: [
87
+ /* @__PURE__ */ jsx("span", {
88
+ className: cn("max-w-40 truncate font-mono text-label", dirty && "italic"),
89
+ children: file.name
90
+ }),
91
+ file.status === "error" ? /* @__PURE__ */ jsx("span", {
92
+ className: "shrink-0 text-danger",
93
+ children: "!"
94
+ }) : null,
95
+ /* @__PURE__ */ jsxs("button", {
96
+ type: "button",
97
+ "aria-label": dirty ? `Close ${file.name} (unsaved changes)` : `Close ${file.name}`,
98
+ onClick: (event) => {
99
+ event.stopPropagation();
100
+ onClose();
101
+ },
102
+ className: cn("shrink-0 rounded p-0.5 text-fg-4 transition-opacity hover:bg-surface-hover hover:text-fg-1", active || dirty ? "opacity-70" : "opacity-0 group-hover:opacity-70 focus-visible:opacity-100"),
103
+ children: [/* @__PURE__ */ jsx("span", {
104
+ className: cn("block", dirty && "group-hover:hidden"),
105
+ children: dirty ? /* @__PURE__ */ jsx("span", { className: "block size-3 rounded-full bg-fg-2" }) : /* @__PURE__ */ jsx(X, { className: "size-3" })
106
+ }), dirty ? /* @__PURE__ */ jsx("span", {
107
+ className: "hidden group-hover:block",
108
+ children: /* @__PURE__ */ jsx(X, { className: "size-3" })
109
+ }) : null]
110
+ })
111
+ ]
112
+ });
113
+ }
114
+ //#endregion
115
+ //#region src/components/agent/FileTree.tsx
116
+ /** How far one level of nesting indents, in pixels. Inline rather than a Tailwind
117
+ * class because depth is a number at runtime and `pl-${n}` is not a class. */
118
+ const INDENT = 12;
119
+ /**
120
+ * The workspace's left rail: an expandable tree of the session's project,
121
+ * with a search box over the same fuzzy route `@file` completion uses.
122
+ *
123
+ * Presentational by construction — every piece of state it renders comes from
124
+ * the hooks in `@workerdeck/react`, and the only thing it owns is the search
125
+ * query, which is the text in its own input.
126
+ *
127
+ * Searching replaces the tree with matches rather than filtering it: the route
128
+ * answers with paths from all over the project, and threading those back into
129
+ * tree positions would mean expanding a dozen directories to show six results.
130
+ */
131
+ function FileTree({ tree, search, activePath, onOpenFile, onCollapse, style, className }) {
132
+ const [query, setQuery] = useState("");
133
+ const [matches, setMatches] = useState();
134
+ const searching = query.trim().length > 0;
135
+ useEffect(() => {
136
+ if (!search?.available) return;
137
+ const q = query.trim();
138
+ if (!q) {
139
+ setMatches(void 0);
140
+ return;
141
+ }
142
+ const controller = new AbortController();
143
+ const timer = setTimeout(() => {
144
+ search.search(q, {
145
+ limit: 60,
146
+ signal: controller.signal
147
+ }).then((found) => {
148
+ if (!controller.signal.aborted) setMatches(found);
149
+ });
150
+ }, 150);
151
+ return () => {
152
+ controller.abort();
153
+ clearTimeout(timer);
154
+ };
155
+ }, [search, query]);
156
+ return /* @__PURE__ */ jsxs("div", {
157
+ "data-slot": "file-tree",
158
+ style,
159
+ className: cn("flex min-h-0 min-w-0 flex-col bg-surface", className),
160
+ children: [/* @__PURE__ */ jsxs("div", {
161
+ className: "flex items-center gap-1 px-2 pt-2",
162
+ children: [
163
+ search?.available ? /* @__PURE__ */ jsxs("div", {
164
+ className: "relative min-w-0 flex-1",
165
+ children: [
166
+ /* @__PURE__ */ jsx(Search, { className: "absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-fg-4" }),
167
+ /* @__PURE__ */ jsx(Input, {
168
+ value: query,
169
+ onChange: (e) => setQuery(e.target.value),
170
+ placeholder: "Search files…",
171
+ className: "h-7 pr-7 pl-7 text-label",
172
+ spellCheck: false
173
+ }),
174
+ searching ? /* @__PURE__ */ jsx("button", {
175
+ type: "button",
176
+ onClick: () => setQuery(""),
177
+ "aria-label": "Clear search",
178
+ className: "absolute top-1/2 right-1.5 -translate-y-1/2 text-fg-4 transition-colors hover:text-fg-2",
179
+ children: /* @__PURE__ */ jsx(X, { className: "size-3.5" })
180
+ }) : null
181
+ ]
182
+ }) : /* @__PURE__ */ jsx("span", {
183
+ className: "min-w-0 flex-1 truncate px-1 font-mono text-label text-fg-4",
184
+ children: tree.root
185
+ }),
186
+ /* @__PURE__ */ jsx(Button, {
187
+ variant: "ghost",
188
+ size: "icon-sm",
189
+ "aria-label": "Refresh the file tree",
190
+ onClick: () => tree.refresh(),
191
+ children: /* @__PURE__ */ jsx(RefreshCw, { className: "size-3.5 text-fg-3" })
192
+ }),
193
+ onCollapse ? /* @__PURE__ */ jsx(Button, {
194
+ variant: "ghost",
195
+ size: "icon-sm",
196
+ "aria-label": "Hide project files",
197
+ onClick: onCollapse,
198
+ children: /* @__PURE__ */ jsx(PanelLeftClose, { className: "size-3.5 text-fg-3" })
199
+ }) : null
200
+ ]
201
+ }), /* @__PURE__ */ jsx("div", {
202
+ className: "min-h-0 flex-1 overflow-auto px-1 py-1",
203
+ children: tree.error ? /* @__PURE__ */ jsx("p", {
204
+ className: "px-2 py-3 text-body-sm text-danger",
205
+ children: tree.error
206
+ }) : searching ? matches === void 0 ? /* @__PURE__ */ jsx("div", {
207
+ className: "py-6 text-center",
208
+ children: /* @__PURE__ */ jsx(Spinner, { className: "size-4 text-fg-4" })
209
+ }) : matches.length === 0 ? /* @__PURE__ */ jsx("p", {
210
+ className: "px-2 py-6 text-center text-body-sm text-fg-4",
211
+ children: "No matching files."
212
+ }) : /* @__PURE__ */ jsx("ul", { children: matches.map((match) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(Row, {
213
+ label: fileName(match.relative),
214
+ detail: directoryOf(match.relative),
215
+ title: match.relative,
216
+ icon: /* @__PURE__ */ jsx(File, { className: "size-3.5 shrink-0 text-fg-4" }),
217
+ active: match.path === activePath,
218
+ onClick: () => onOpenFile(match.path)
219
+ }) }, match.path)) }) : tree.loading ? /* @__PURE__ */ jsx("div", {
220
+ className: "py-6 text-center",
221
+ children: /* @__PURE__ */ jsx(Spinner, { className: "size-4 text-fg-4" })
222
+ }) : tree.rows.length === 0 ? /* @__PURE__ */ jsx("p", {
223
+ className: "px-2 py-6 text-center text-body-sm text-fg-4",
224
+ children: "This project is empty."
225
+ }) : /* @__PURE__ */ jsx("ul", {
226
+ role: "tree",
227
+ "aria-label": "Project files",
228
+ children: tree.rows.map((row) => /* @__PURE__ */ jsxs("li", {
229
+ role: "treeitem",
230
+ "aria-expanded": row.expanded,
231
+ "aria-level": row.depth + 1,
232
+ children: [/* @__PURE__ */ jsx(Row, {
233
+ label: row.entry.name,
234
+ indent: row.depth,
235
+ active: row.entry.path === activePath,
236
+ icon: row.entry.type === "dir" ? row.loading ? /* @__PURE__ */ jsx(Spinner, { className: "size-3.5 shrink-0 text-fg-4" }) : row.expanded ? /* @__PURE__ */ jsx(FolderOpen, { className: "size-3.5 shrink-0 text-accent" }) : /* @__PURE__ */ jsx(Folder, { className: "size-3.5 shrink-0 text-accent" }) : /* @__PURE__ */ jsx(EntryIcon, { type: row.entry.type }),
237
+ chevron: row.entry.type === "dir" ? /* @__PURE__ */ jsx(ChevronRight, { className: cn("size-3 shrink-0 text-fg-4 transition-transform", row.expanded && "rotate-90") }) : void 0,
238
+ onClick: () => row.entry.type === "dir" ? tree.toggle(row.entry.path) : onOpenFile(row.entry.path)
239
+ }), row.truncated ? /* @__PURE__ */ jsx("p", {
240
+ className: "truncate py-0.5 text-label text-fg-4",
241
+ style: { paddingLeft: (row.depth + 1) * INDENT + 22 },
242
+ children: "More entries than the server will return — use search."
243
+ }) : null]
244
+ }, row.entry.path))
245
+ })
246
+ })]
247
+ });
248
+ }
249
+ /** One clickable line. A directory and a file differ only in what the click does
250
+ * and whether there is a chevron — visually they are the same row. */
251
+ function Row({ label, detail, title, icon, chevron, indent = 0, active, onClick }) {
252
+ const ref = useRef(null);
253
+ useEffect(() => {
254
+ if (active) ref.current?.scrollIntoView({ block: "nearest" });
255
+ }, [active]);
256
+ return /* @__PURE__ */ jsxs("button", {
257
+ ref,
258
+ type: "button",
259
+ onClick,
260
+ title: title ?? label,
261
+ style: { paddingLeft: indent * INDENT + 4 },
262
+ className: cn("flex w-full items-center gap-1 rounded py-1 pr-2 text-left transition-colors", active ? "bg-surface-hover text-fg-1" : "text-fg-2 hover:bg-surface-hover"),
263
+ children: [
264
+ /* @__PURE__ */ jsx("span", {
265
+ className: "flex size-3 shrink-0 items-center justify-center",
266
+ children: chevron
267
+ }),
268
+ icon,
269
+ /* @__PURE__ */ jsx("span", {
270
+ className: "shrink-0 truncate font-mono text-label",
271
+ children: label
272
+ }),
273
+ detail ? /* @__PURE__ */ jsx("span", {
274
+ className: "min-w-0 flex-1 truncate font-mono text-label text-fg-4",
275
+ children: detail
276
+ }) : null
277
+ ]
278
+ });
279
+ }
280
+ /** Last segment of a relative match. */
281
+ function fileName(relative) {
282
+ return relative.slice(relative.lastIndexOf("/") + 1);
283
+ }
284
+ /** Everything before it, or `undefined` for a file at the search root. */
285
+ function directoryOf(relative) {
286
+ const cut = relative.lastIndexOf("/");
287
+ return cut === -1 ? void 0 : relative.slice(0, cut);
288
+ }
289
+ /** A symlink is reported as itself and never silently resolved — following it is
290
+ * the next request's problem, and that request is refused if it escapes the roots. */
291
+ function EntryIcon({ type }) {
292
+ if (type === "symlink") return /* @__PURE__ */ jsx(Link2, { className: "size-3.5 shrink-0 text-fg-4" });
293
+ return /* @__PURE__ */ jsx(File, { className: "size-3.5 shrink-0 text-fg-4" });
294
+ }
295
+ //#endregion
296
+ //#region src/components/agent/CodeEditor.tsx
297
+ /**
298
+ * Monaco — VS Code's own editor — behind a small React surface.
299
+ *
300
+ * Two deliberate choices, both about `@workerdeck/ui` being a **published
301
+ * library** rather than an app:
302
+ *
303
+ * 1. **Loaded on demand.** The `import()` is inside an effect, so Monaco is a
304
+ * separate chunk that arrives when someone first opens a file. A dashboard
305
+ * that never opens one never pays for it, and Monaco's ~90 language grammars
306
+ * are themselves lazy (each `registerLanguage` carries an `import()` loader),
307
+ * so opening a `.ts` file fetches the TypeScript grammar and nothing else.
308
+ * 2. **No `MonacoEnvironment` is configured here, and none is needed.** Workers
309
+ * in a library become every embedder's bootstrapping problem, and
310
+ * `packages/web` ships prebuilt static files at a domain root, which is
311
+ * exactly where hardcoded worker URLs break. The editor is configured so it
312
+ * never asks for one: `wordBasedSuggestions` and `quickSuggestions` off,
313
+ * no diff editor. A host that wants the worker-backed language services
314
+ * (TypeScript IntelliSense, JSON schema validation) sets `MonacoEnvironment`
315
+ * itself before the first file is opened — Monaco is a singleton and nothing
316
+ * here fights that.
317
+ *
318
+ * One model per path, kept across tab switches, so undo history and view state
319
+ * survive clicking away and back — which is most of what makes tabs feel like
320
+ * tabs rather than like re-opening a file.
321
+ */
322
+ function CodeEditor({ path, value, onChange, onSave, readOnly, className }) {
323
+ const host = useRef(null);
324
+ const editor = useRef(null);
325
+ const monaco = useRef(null);
326
+ const [ready, setReady] = useState(false);
327
+ const theme = useDocumentTheme();
328
+ const handlers = useRef({
329
+ onChange,
330
+ onSave
331
+ });
332
+ handlers.current = {
333
+ onChange,
334
+ onSave
335
+ };
336
+ useEffect(() => {
337
+ let disposed = false;
338
+ loadMonaco().then((api) => {
339
+ if (disposed || !host.current) return;
340
+ monaco.current = api;
341
+ const instance = api.editor.create(host.current, {
342
+ value,
343
+ language: languageOf(path),
344
+ readOnly,
345
+ automaticLayout: true,
346
+ theme: monacoTheme(document.documentElement.getAttribute("data-theme")),
347
+ minimap: { enabled: false },
348
+ scrollBeyondLastLine: false,
349
+ fontSize: 12,
350
+ lineHeight: 20,
351
+ fontFamily: "ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace",
352
+ renderLineHighlight: "line",
353
+ smoothScrolling: true,
354
+ padding: {
355
+ top: 8,
356
+ bottom: 8
357
+ },
358
+ scrollbar: {
359
+ verticalScrollbarSize: 10,
360
+ horizontalScrollbarSize: 10
361
+ },
362
+ wordBasedSuggestions: "off",
363
+ quickSuggestions: false
364
+ });
365
+ editor.current = instance;
366
+ instance.onDidChangeModelContent(() => {
367
+ handlers.current.onChange?.(instance.getValue());
368
+ });
369
+ instance.addCommand(api.KeyMod.CtrlCmd | api.KeyCode.KeyS, () => {
370
+ handlers.current.onSave?.();
371
+ });
372
+ setReady(true);
373
+ });
374
+ return () => {
375
+ disposed = true;
376
+ editor.current?.dispose();
377
+ editor.current = null;
378
+ setReady(false);
379
+ };
380
+ }, []);
381
+ useEffect(() => {
382
+ const api = monaco.current;
383
+ const instance = editor.current;
384
+ if (!api || !instance || !ready) return;
385
+ const uri = api.Uri.parse(`workerdeck://host${path}`);
386
+ const model = api.editor.getModel(uri) ?? api.editor.createModel(value, languageOf(path), uri);
387
+ if (instance.getModel() !== model) instance.setModel(model);
388
+ }, [
389
+ path,
390
+ ready,
391
+ value
392
+ ]);
393
+ useEffect(() => {
394
+ const instance = editor.current;
395
+ if (!instance || !ready) return;
396
+ const model = instance.getModel();
397
+ if (!model || model.getValue() === value) return;
398
+ model.pushEditOperations([], [{
399
+ range: model.getFullModelRange(),
400
+ text: value
401
+ }], () => null);
402
+ }, [value, ready]);
403
+ useEffect(() => {
404
+ if (ready) editor.current?.updateOptions({ readOnly });
405
+ }, [readOnly, ready]);
406
+ useEffect(() => {
407
+ if (ready) monaco.current?.editor.setTheme(monacoTheme(theme));
408
+ }, [theme, ready]);
409
+ return /* @__PURE__ */ jsxs("div", {
410
+ className: cn("relative min-h-0 min-w-0 flex-1", className),
411
+ children: [/* @__PURE__ */ jsx("div", {
412
+ ref: host,
413
+ className: "absolute inset-0"
414
+ }), !ready ? /* @__PURE__ */ jsx("div", {
415
+ className: "absolute inset-0 flex items-center justify-center bg-bg",
416
+ children: /* @__PURE__ */ jsx(Spinner, { className: "size-4 text-fg-4" })
417
+ }) : null]
418
+ });
419
+ }
420
+ /**
421
+ * Follow the `data-theme` the design tokens already swap on, so the editor is
422
+ * never the one light rectangle in a dark app (or the reverse). An attribute
423
+ * observer rather than a media query: the app has a manual toggle, and the
424
+ * attribute is what that toggle writes.
425
+ */
426
+ function useDocumentTheme() {
427
+ const [theme, setTheme] = useState(() => typeof document === "undefined" ? null : document.documentElement.getAttribute("data-theme"));
428
+ useEffect(() => {
429
+ const root = document.documentElement;
430
+ const sync = () => setTheme(root.getAttribute("data-theme"));
431
+ sync();
432
+ const observer = new MutationObserver(sync);
433
+ observer.observe(root, {
434
+ attributes: true,
435
+ attributeFilter: ["data-theme"]
436
+ });
437
+ return () => observer.disconnect();
438
+ }, []);
439
+ return theme;
440
+ }
441
+ /** An unset attribute means the host never opted into the token themes, in which
442
+ * case dark matches this package's default surface. */
443
+ function monacoTheme(theme) {
444
+ return theme === "light" ? "wd-light" : "wd-dark";
445
+ }
446
+ /**
447
+ * Load Monaco once, register the themes, and hand back the API.
448
+ *
449
+ * Cached as a promise rather than a value so that several editors mounting in
450
+ * the same frame share one load instead of racing three of them.
451
+ */
452
+ let monacoPromise;
453
+ function loadMonaco() {
454
+ monacoPromise ??= (async () => {
455
+ const api = await import("monaco-editor");
456
+ api.editor.defineTheme("wd-dark", {
457
+ base: "vs-dark",
458
+ inherit: true,
459
+ rules: [],
460
+ colors: { "editor.background": "#00000000" }
461
+ });
462
+ api.editor.defineTheme("wd-light", {
463
+ base: "vs",
464
+ inherit: true,
465
+ rules: [],
466
+ colors: { "editor.background": "#00000000" }
467
+ });
468
+ return api;
469
+ })();
470
+ return monacoPromise;
471
+ }
472
+ /**
473
+ * Monaco's language id for a path.
474
+ *
475
+ * By extension, with a short table for the files that have none — Monaco's own
476
+ * registry is keyed on extensions it knows, and an unmatched file falls through
477
+ * to `plaintext`, which is the honest answer rather than a guess.
478
+ */
479
+ function languageOf(path) {
480
+ const name = path.slice(path.lastIndexOf("/") + 1);
481
+ const byName = FILENAME_LANGUAGES[name.toLowerCase()];
482
+ if (byName) return byName;
483
+ return EXTENSION_LANGUAGES[name.slice(name.lastIndexOf(".") + 1).toLowerCase()] ?? "plaintext";
484
+ }
485
+ /** Files whose type is their whole name. */
486
+ const FILENAME_LANGUAGES = {
487
+ dockerfile: "dockerfile",
488
+ makefile: "plaintext",
489
+ ".gitignore": "plaintext",
490
+ ".env": "shell"
491
+ };
492
+ /** Extension → Monaco language id. Only where the id differs from the extension
493
+ * or the extension is ambiguous; everything else Monaco resolves itself. */
494
+ const EXTENSION_LANGUAGES = {
495
+ ts: "typescript",
496
+ tsx: "typescript",
497
+ mts: "typescript",
498
+ cts: "typescript",
499
+ js: "javascript",
500
+ jsx: "javascript",
501
+ mjs: "javascript",
502
+ cjs: "javascript",
503
+ json: "json",
504
+ jsonc: "json",
505
+ md: "markdown",
506
+ mdx: "markdown",
507
+ css: "css",
508
+ scss: "scss",
509
+ less: "less",
510
+ html: "html",
511
+ htm: "html",
512
+ xml: "xml",
513
+ svg: "xml",
514
+ yml: "yaml",
515
+ yaml: "yaml",
516
+ toml: "plaintext",
517
+ sh: "shell",
518
+ bash: "shell",
519
+ zsh: "shell",
520
+ fish: "shell",
521
+ py: "python",
522
+ rb: "ruby",
523
+ rs: "rust",
524
+ go: "go",
525
+ swift: "swift",
526
+ java: "java",
527
+ kt: "kotlin",
528
+ c: "c",
529
+ h: "c",
530
+ cpp: "cpp",
531
+ cc: "cpp",
532
+ hpp: "cpp",
533
+ cs: "csharp",
534
+ php: "php",
535
+ sql: "sql",
536
+ graphql: "graphql",
537
+ gql: "graphql",
538
+ lua: "lua",
539
+ r: "r",
540
+ pl: "perl",
541
+ dart: "dart",
542
+ scala: "scala",
543
+ ini: "ini",
544
+ cfg: "ini",
545
+ conf: "ini",
546
+ txt: "plaintext",
547
+ log: "plaintext"
548
+ };
549
+ //#endregion
550
+ //#region src/components/agent/FileViewer.tsx
551
+ /**
552
+ * The focused file: Monaco, plus the states a file can be in that are not
553
+ * "here is some text".
554
+ *
555
+ * No path row — the tab's tooltip carries the path and the size, and a line of
556
+ * monospace above every file is chrome that never earns its height.
557
+ */
558
+ function FileViewer({ file, canWrite, onChange, onSave, onRevert, onReload, onOverwrite, onDismissConflict, className }) {
559
+ if (!file) return null;
560
+ return /* @__PURE__ */ jsxs("div", {
561
+ "data-slot": "file-viewer",
562
+ className: cn("flex min-h-0 min-w-0 flex-1 flex-col bg-bg", className),
563
+ children: [
564
+ file.conflict ? /* @__PURE__ */ jsxs("div", {
565
+ className: "flex shrink-0 flex-wrap items-center gap-2 border-b border-warning/40 bg-warning-bg px-3 py-2",
566
+ children: [
567
+ /* @__PURE__ */ jsx(TriangleAlert, { className: "size-3.5 shrink-0 text-warning" }),
568
+ /* @__PURE__ */ jsx("span", {
569
+ className: "min-w-0 flex-1 text-body-sm text-warning",
570
+ children: "This file changed on disk since you opened it."
571
+ }),
572
+ /* @__PURE__ */ jsx(Button, {
573
+ variant: "outline",
574
+ size: "xs",
575
+ onClick: () => onReload?.(file.path),
576
+ children: "Use the version on disk"
577
+ }),
578
+ /* @__PURE__ */ jsx(Button, {
579
+ variant: "outline",
580
+ size: "xs",
581
+ onClick: () => onOverwrite?.(file.path),
582
+ children: "Keep mine"
583
+ }),
584
+ /* @__PURE__ */ jsx(Button, {
585
+ variant: "ghost",
586
+ size: "xs",
587
+ onClick: () => onDismissConflict?.(file.path),
588
+ children: "Dismiss"
589
+ })
590
+ ]
591
+ }) : file.saveError ? /* @__PURE__ */ jsxs("div", {
592
+ className: "flex shrink-0 items-center gap-2 border-b border-danger/40 bg-danger-bg px-3 py-2",
593
+ children: [
594
+ /* @__PURE__ */ jsx(TriangleAlert, { className: "size-3.5 shrink-0 text-danger" }),
595
+ /* @__PURE__ */ jsx("span", {
596
+ className: "min-w-0 flex-1 text-body-sm text-danger",
597
+ children: file.saveError
598
+ }),
599
+ /* @__PURE__ */ jsx(Button, {
600
+ variant: "ghost",
601
+ size: "xs",
602
+ onClick: () => onDismissConflict?.(file.path),
603
+ children: "Dismiss"
604
+ })
605
+ ]
606
+ }) : null,
607
+ file.status === "loading" ? /* @__PURE__ */ jsx(Centred, { children: /* @__PURE__ */ jsx(Spinner, { className: "size-4 text-fg-4" }) }) : file.status === "error" ? /* @__PURE__ */ jsxs(Centred, { children: [/* @__PURE__ */ jsx(TriangleAlert, { className: "size-4 text-danger" }), /* @__PURE__ */ jsx("p", {
608
+ className: "text-body-sm text-danger",
609
+ children: file.error
610
+ })] }) : file.status === "binary" ? /* @__PURE__ */ jsxs(Centred, { children: [
611
+ /* @__PURE__ */ jsx(FileWarning, { className: "size-4 text-fg-4" }),
612
+ /* @__PURE__ */ jsx("p", {
613
+ className: "text-body-sm text-fg-4",
614
+ children: "This file isn’t text."
615
+ }),
616
+ /* @__PURE__ */ jsx("p", {
617
+ className: "text-label text-fg-4",
618
+ children: "It can’t be edited here without corrupting it."
619
+ })
620
+ ] }) : /* @__PURE__ */ jsx(CodeEditor, {
621
+ path: file.path,
622
+ value: currentText(file),
623
+ readOnly: !canWrite,
624
+ onChange: onChange ? (content) => onChange(file.path, content) : void 0,
625
+ onSave: onSave ? () => onSave(file.path) : void 0
626
+ }),
627
+ file.status === "ready" && (file.saving || isDirty(file) || !canWrite) ? /* @__PURE__ */ jsx("div", {
628
+ className: "flex shrink-0 items-center gap-2 border-t border-border px-3 py-1",
629
+ children: file.saving ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Spinner, { className: "size-3 text-fg-4" }), /* @__PURE__ */ jsx("span", {
630
+ className: "text-label text-fg-4",
631
+ children: "Saving…"
632
+ })] }) : !canWrite ? /* @__PURE__ */ jsx("span", {
633
+ className: "text-label text-fg-4",
634
+ children: "Read-only — this gateway doesn’t allow writes."
635
+ }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
636
+ /* @__PURE__ */ jsx("span", {
637
+ className: "text-label text-fg-3",
638
+ children: "Unsaved changes"
639
+ }),
640
+ /* @__PURE__ */ jsx("span", { className: "flex-1" }),
641
+ /* @__PURE__ */ jsx(Button, {
642
+ variant: "ghost",
643
+ size: "xs",
644
+ onClick: () => onRevert?.(file.path),
645
+ children: "Revert"
646
+ }),
647
+ /* @__PURE__ */ jsxs(Button, {
648
+ variant: "outline",
649
+ size: "xs",
650
+ onClick: () => onSave?.(file.path),
651
+ children: ["Save", /* @__PURE__ */ jsx("span", {
652
+ className: "ml-1 text-fg-4",
653
+ children: "⌘S"
654
+ })]
655
+ })
656
+ ] })
657
+ }) : null
658
+ ]
659
+ });
660
+ }
661
+ function Centred({ children }) {
662
+ return /* @__PURE__ */ jsx("div", {
663
+ className: "flex min-h-0 flex-1 flex-col items-center justify-center gap-2 p-6",
664
+ children
665
+ });
666
+ }
667
+ //#endregion
668
+ //#region src/components/agent/SessionWorkspace.tsx
669
+ const RAIL_MIN = 180;
670
+ const RAIL_MAX = 520;
671
+ /** How little of the agent column the editor may leave. Below this the composer
672
+ * and a line of transcript stop fitting together, which is the point at which the
673
+ * agent has stopped being usable rather than merely small. */
674
+ const AGENT_MIN = 220;
675
+ const EDITOR_MIN = 120;
676
+ /**
677
+ * A VS Code-shaped workspace around a live session: file tree on the left, open
678
+ * files above, the agent below.
679
+ *
680
+ * **Strictly additive.** {@link SessionPanel} is untouched and still the whole
681
+ * session surface on its own — an embedder picks one or the other, and one that
682
+ * has its own file tree keeps using the panel.
683
+ *
684
+ * Two things here are load-bearing and easy to break:
685
+ *
686
+ * 1. **The editor region is absent from the layout when nothing is open**, not
687
+ * collapsed to zero height. A zero-height pane leaves a draggable splitter and
688
+ * parks the composer at an odd offset; absence is what makes the agent's
689
+ * "claims the full column" state actually look like the panel alone.
690
+ * 2. **`SessionPanel` keeps its position in the tree across that transition.** It
691
+ * holds the WebSocket attach and the entire transcript, so moving it between
692
+ * parents — or wrapping it conditionally — would remount it and drop the
693
+ * session's rendered history on the floor the first time someone opens a file.
694
+ * The conditional children before it are `? :` expressions that leave a null in
695
+ * their slot, which is exactly what keeps its index stable.
696
+ */
697
+ function SessionWorkspace({ client, sessionId, header, defaultRailWidth = 260, defaultRailCollapsed, className }) {
698
+ const { info } = useSessionInfo(client, sessionId);
699
+ const cwd = info?.cwd;
700
+ const tree = useHostFileTree(client, cwd);
701
+ const search = useHostFileSearch(client, cwd);
702
+ const files = useOpenFiles(client);
703
+ const { canWrite } = useHostFileRoots(client);
704
+ useEffect(() => {
705
+ if (!files.hasUnsaved) return;
706
+ const warn = (event) => event.preventDefault();
707
+ window.addEventListener("beforeunload", warn);
708
+ return () => window.removeEventListener("beforeunload", warn);
709
+ }, [files.hasUnsaved]);
710
+ const wide = useIsWide();
711
+ const [railCollapsed, setRailCollapsed] = useState(defaultRailCollapsed ?? false);
712
+ const [railWidth, setRailWidth] = useState(defaultRailWidth);
713
+ const [editorHeight, setEditorHeight] = useState(360);
714
+ const hasFiles = files.files.length > 0;
715
+ const overlayRail = !wide;
716
+ const railOpen = tree.available && !railCollapsed;
717
+ useEffect(() => {
718
+ if (overlayRail) setRailCollapsed(true);
719
+ }, [overlayRail]);
720
+ const closeTab = useCallback((path) => {
721
+ const file = files.files.find((f) => f.path === path);
722
+ if (file && isDirty(file)) {
723
+ const name = file.name;
724
+ if (!window.confirm(`${name} has unsaved changes. Close it and lose them?`)) return;
725
+ }
726
+ files.close(path);
727
+ }, [files]);
728
+ const column = useRef(null);
729
+ const columnHeight = useElementHeight(column);
730
+ const editorMax = Math.max(EDITOR_MIN, columnHeight - AGENT_MIN);
731
+ const [topBar, setTopBar] = useState(null);
732
+ const hoisted = header === void 0 || topBar === null ? void 0 : typeof header === "function" ? (slots) => createPortal(header(slots), topBar) : createPortal(header, topBar);
733
+ return /* @__PURE__ */ jsxs("div", {
734
+ "data-slot": "session-workspace",
735
+ className: cn("flex h-full min-h-0 w-full flex-col overflow-hidden bg-bg", className),
736
+ children: [header !== void 0 ? /* @__PURE__ */ jsx("div", {
737
+ ref: setTopBar,
738
+ className: "shrink-0"
739
+ }) : null, /* @__PURE__ */ jsxs("div", {
740
+ className: "relative flex min-h-0 flex-1",
741
+ children: [
742
+ railOpen ? /* @__PURE__ */ jsx(FileTree, {
743
+ tree,
744
+ search,
745
+ activePath: files.activePath,
746
+ onOpenFile: files.open,
747
+ onCollapse: () => setRailCollapsed(true),
748
+ style: { width: overlayRail ? Math.min(railWidth, 320) : railWidth },
749
+ className: cn("shrink-0 border-r border-border", overlayRail && "absolute inset-y-0 left-0 z-20 shadow-lg")
750
+ }) : tree.available ? /* @__PURE__ */ jsx("div", {
751
+ className: "flex w-8 shrink-0 flex-col items-center border-r border-border bg-surface pt-1.5",
752
+ children: /* @__PURE__ */ jsx(Button, {
753
+ variant: "ghost",
754
+ size: "icon-sm",
755
+ "aria-label": "Show project files",
756
+ onClick: () => setRailCollapsed(false),
757
+ children: /* @__PURE__ */ jsx(PanelLeftOpen, { className: "size-4 text-fg-3" })
758
+ })
759
+ }) : null,
760
+ railOpen && !overlayRail ? /* @__PURE__ */ jsx(Splitter, {
761
+ orientation: "vertical",
762
+ value: railWidth,
763
+ onValueChange: setRailWidth,
764
+ min: RAIL_MIN,
765
+ max: RAIL_MAX,
766
+ "aria-label": "Resize the file tree"
767
+ }) : null,
768
+ /* @__PURE__ */ jsxs("div", {
769
+ ref: column,
770
+ className: "flex min-h-0 min-w-0 flex-1 flex-col",
771
+ children: [
772
+ hasFiles ? /* @__PURE__ */ jsxs("div", {
773
+ className: "flex min-h-0 shrink-0 flex-col overflow-hidden",
774
+ style: { height: Math.min(editorHeight, editorMax || editorHeight) },
775
+ children: [/* @__PURE__ */ jsx(EditorTabs, {
776
+ files: files.files,
777
+ activePath: files.activePath,
778
+ onActivate: files.activate,
779
+ onClose: closeTab
780
+ }), /* @__PURE__ */ jsx(FileViewer, {
781
+ file: files.active,
782
+ canWrite,
783
+ onChange: files.edit,
784
+ onSave: (path) => void files.save(path),
785
+ onRevert: files.revert,
786
+ onReload: files.reload,
787
+ onOverwrite: (path) => void files.overwrite(path),
788
+ onDismissConflict: files.dismissConflict
789
+ })]
790
+ }) : null,
791
+ hasFiles ? /* @__PURE__ */ jsx(Splitter, {
792
+ orientation: "horizontal",
793
+ value: Math.min(editorHeight, editorMax || editorHeight),
794
+ onValueChange: setEditorHeight,
795
+ min: EDITOR_MIN,
796
+ max: editorMax,
797
+ "aria-label": "Resize the open file"
798
+ }) : null,
799
+ /* @__PURE__ */ jsx(SessionPanel, {
800
+ client,
801
+ sessionId,
802
+ header: hoisted,
803
+ className: "min-h-0 flex-1"
804
+ })
805
+ ]
806
+ }),
807
+ overlayRail && railOpen ? /* @__PURE__ */ jsx("button", {
808
+ type: "button",
809
+ "aria-label": "Close the file tree",
810
+ onClick: () => setRailCollapsed(true),
811
+ className: "absolute inset-0 z-10 bg-black/30"
812
+ }) : null
813
+ ]
814
+ })]
815
+ });
816
+ }
817
+ /** Live height of an element, for a splitter that needs to know how much room it
818
+ * is dividing. Zero until the first observation, which callers treat as
819
+ * "unmeasured" rather than as a real bound. */
820
+ function useElementHeight(ref) {
821
+ const [height, setHeight] = useState(0);
822
+ useLayoutEffect(() => {
823
+ const element = ref.current;
824
+ if (!element) return;
825
+ const observer = new ResizeObserver(([entry]) => {
826
+ if (entry) setHeight(entry.contentRect.height);
827
+ });
828
+ observer.observe(element);
829
+ return () => observer.disconnect();
830
+ }, [ref]);
831
+ return height;
832
+ }
833
+ /** Whether there is room for a rail beside the content. Presentation only — the
834
+ * workspace's actual state lives in the hooks from `@workerdeck/react`. */
835
+ function useIsWide() {
836
+ const [wide, setWide] = useState(true);
837
+ useEffect(() => {
838
+ const query = window.matchMedia("(min-width: 768px)");
839
+ const sync = () => setWide(query.matches);
840
+ sync();
841
+ query.addEventListener("change", sync);
842
+ return () => query.removeEventListener("change", sync);
843
+ }, []);
844
+ return wide;
845
+ }
846
+ //#endregion
847
+ export { CodeEditor, EditorTabs, FileTree, FileViewer, SessionWorkspace };
848
+
849
+ //# sourceMappingURL=workspace.mjs.map