@workerdeck/ui 0.9.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.
- package/README.md +81 -3
- package/build/SessionPanel-CyhygZx_.d.mts +277 -0
- package/build/SessionPanel-_U8tjX29.mjs +8409 -0
- package/build/SessionPanel-_U8tjX29.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 +2 -0
- package/build/format.mjs +2 -0
- package/build/index.d.mts +610 -87
- package/build/index.mjs +6 -5104
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +199 -0
- package/build/workspace.mjs +849 -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 +120 -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/SessionEmptyState.tsx +65 -0
- package/src/components/agent/SessionInfoDialog.tsx +163 -0
- package/src/components/agent/SessionPanel.tsx +756 -91
- 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 +243 -30
- package/src/components/agent/Transcript.tsx +495 -30
- package/src/components/agent/UsageDialog.tsx +168 -0
- package/src/components/agent/line-prompt.tsx +249 -0
- package/src/components/agent/transcript-variant.tsx +61 -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 +10 -0
- package/src/index.ts +63 -2
- package/src/lib/clipboard.ts +56 -0
- package/src/lib/format.ts +114 -0
- package/src/lib/tool-icon.ts +96 -0
- 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
|