@agimon-ai/po-workflow 0.0.0-alpha.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/LICENSE +29 -0
- package/LICENSE.web +29 -0
- package/README.md +146 -0
- package/dist/apiContracts.cjs +361 -0
- package/dist/apiContracts.cjs.map +1 -0
- package/dist/apiContracts.d.mts +53 -0
- package/dist/apiContracts.d.mts.map +1 -0
- package/dist/apiContracts.mjs +356 -0
- package/dist/apiContracts.mjs.map +1 -0
- package/dist/extensions/server.cjs +4289 -0
- package/dist/extensions/server.cjs.map +1 -0
- package/dist/extensions/server.d.mts +5 -0
- package/dist/extensions/server.d.mts.map +1 -0
- package/dist/extensions/server.mjs +4259 -0
- package/dist/extensions/server.mjs.map +1 -0
- package/dist/extensions/web.mjs +3788 -0
- package/dist/extensions/web.mjs.map +1 -0
- package/dist/index.cjs +4 -0
- package/dist/index.d.mts +59 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/workflowLaunchCommand-C69Nn3k5.cjs +139 -0
- package/dist/workflowLaunchCommand-C69Nn3k5.cjs.map +1 -0
- package/dist/workflowLaunchCommand-GGqimmTC.mjs +122 -0
- package/dist/workflowLaunchCommand-GGqimmTC.mjs.map +1 -0
- package/llms.txt +24 -0
- package/package.json +133 -0
- package/skills/workflow-recovery/SKILL.md +85 -0
- package/src/prompts/po-author-workflow/SKILL.md +64 -0
- package/src/prompts/po-use-workflow/SKILL.md +21 -0
- package/src/types/webWorkflowTerminal.ts +114 -0
- package/src/types/webWorkflows.ts +208 -0
|
@@ -0,0 +1,3788 @@
|
|
|
1
|
+
import { apiResponse, createApiClient, defineActivityGroup, defineApiRoutes, defineChannelFile, defineContextAction, defineDockFace, defineFill, defineLeaderBinding, defineMinorModeFile, defineSessionStore, defineToolRenderer, defineWebPlugin, sessionApiAddress } from "@agimon-ai/po-core/web";
|
|
2
|
+
import { AnsiLine, ArrowRightIcon, Badge, Button, ChevronDownIcon, ChevronRightIcon, CloseIcon, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, Dot, EmptyState, Input, Kbd, Markdown, MessageItem, MessageItemBody, MessageItemHeader, MessageLines, OptionLabel, OptionRow, Popover, PopoverContent, PopoverFooter, PopoverHeader, PopoverTrigger, SearchIcon, SectionLabel, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetTitle, StatusBadge, StreamCursor, Textarea, WorkflowIcon, cn, toolTone } from "@agimon-ai/po-web-components";
|
|
3
|
+
import { useStore } from "@tanstack/react-store";
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import { sealedTransport } from "@agimon-ai/po-web-security/browser";
|
|
7
|
+
const WORKFLOW_RUNS_TYPE = "workflow_runs";
|
|
8
|
+
const WORKFLOW_CATALOG_TYPE = "workflow_catalog";
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_lib/catalogStore.ts
|
|
11
|
+
const catalog = defineSessionStore({
|
|
12
|
+
cwd: "",
|
|
13
|
+
workflows: [],
|
|
14
|
+
warning: void 0,
|
|
15
|
+
open: false,
|
|
16
|
+
filter: "",
|
|
17
|
+
prompt: "",
|
|
18
|
+
selected: void 0,
|
|
19
|
+
inspected: void 0,
|
|
20
|
+
launch: void 0
|
|
21
|
+
});
|
|
22
|
+
function isRecord$2(value) {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
function openCatalog(sessionId, prompt = "") {
|
|
26
|
+
catalog.update(sessionId, (current) => ({
|
|
27
|
+
...current,
|
|
28
|
+
open: true,
|
|
29
|
+
prompt
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
/** Opens the Workflow picker with context supplied by another installed plugin. */
|
|
33
|
+
function openWorkflowCatalogForContext(context) {
|
|
34
|
+
if (context.sessionId === null) return;
|
|
35
|
+
openCatalog(context.sessionId, context.item.content);
|
|
36
|
+
}
|
|
37
|
+
function closeCatalog(sessionId) {
|
|
38
|
+
catalog.update(sessionId, (current) => ({
|
|
39
|
+
...current,
|
|
40
|
+
open: false,
|
|
41
|
+
prompt: "",
|
|
42
|
+
launch: void 0
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
function setCatalogFilter(sessionId, filter) {
|
|
46
|
+
catalog.update(sessionId, (current) => ({
|
|
47
|
+
...current,
|
|
48
|
+
filter
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
function selectWorkflow(sessionId, path) {
|
|
52
|
+
catalog.update(sessionId, (current) => ({
|
|
53
|
+
...current,
|
|
54
|
+
selected: path
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
function toggleInspect(sessionId, path) {
|
|
58
|
+
catalog.update(sessionId, (current) => ({
|
|
59
|
+
...current,
|
|
60
|
+
inspected: current.inspected === path ? void 0 : path
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
function openLaunch(sessionId, path) {
|
|
64
|
+
catalog.update(sessionId, (current) => ({
|
|
65
|
+
...current,
|
|
66
|
+
selected: path,
|
|
67
|
+
launch: path
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
function closeLaunch(sessionId) {
|
|
71
|
+
catalog.update(sessionId, (current) => ({
|
|
72
|
+
...current,
|
|
73
|
+
launch: void 0
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
/** Rows whose name, path, tag or job matches the filter, case-insensitively. */
|
|
77
|
+
function filterCatalog(workflows, filter) {
|
|
78
|
+
const needle = filter.trim().toLowerCase();
|
|
79
|
+
if (needle === "") return [...workflows];
|
|
80
|
+
return workflows.filter((workflow) => [
|
|
81
|
+
workflow.name,
|
|
82
|
+
workflow.relativePath,
|
|
83
|
+
workflow.description,
|
|
84
|
+
...workflow.tags,
|
|
85
|
+
...workflow.jobs.map((job) => job.name)
|
|
86
|
+
].join("\n").toLowerCase().includes(needle));
|
|
87
|
+
}
|
|
88
|
+
/** The plugin's catalog channel: 'workflow_catalog' payloads into the store. */
|
|
89
|
+
const workflowCatalogChannel = catalog.channel({
|
|
90
|
+
channel: WORKFLOW_CATALOG_TYPE,
|
|
91
|
+
parse(input) {
|
|
92
|
+
if (!isRecord$2(input) || typeof input.cwd !== "string" || !Array.isArray(input.workflows)) return null;
|
|
93
|
+
return {
|
|
94
|
+
cwd: input.cwd,
|
|
95
|
+
workflows: input.workflows.filter(isRecord$2),
|
|
96
|
+
...typeof input.warning === "string" ? { warning: input.warning } : {}
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
reduce(current, payload) {
|
|
100
|
+
const paths = new Set(payload.workflows.map((workflow) => workflow.path));
|
|
101
|
+
return {
|
|
102
|
+
...current,
|
|
103
|
+
cwd: payload.cwd,
|
|
104
|
+
workflows: payload.workflows,
|
|
105
|
+
warning: payload.warning,
|
|
106
|
+
selected: current.selected !== void 0 && paths.has(current.selected) ? current.selected : void 0,
|
|
107
|
+
inspected: current.inspected !== void 0 && paths.has(current.inspected) ? current.inspected : void 0,
|
|
108
|
+
launch: current.launch !== void 0 && paths.has(current.launch) ? current.launch : void 0
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_lib/runDuration.ts
|
|
114
|
+
/**
|
|
115
|
+
* Local copy of the host's formatRunDuration (src/web/lib/sessionSummary.ts):
|
|
116
|
+
* this plugin keeps zero host-lib imports so its later move into the owning
|
|
117
|
+
* package is a file relocation.
|
|
118
|
+
*/
|
|
119
|
+
const MINUTE_MS = 6e4;
|
|
120
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
121
|
+
function formatRunDuration(elapsedMs) {
|
|
122
|
+
if (elapsedMs < MINUTE_MS) return "<1m";
|
|
123
|
+
if (elapsedMs < HOUR_MS) return `${Math.floor(elapsedMs / MINUTE_MS)}m`;
|
|
124
|
+
const hours = Math.floor(elapsedMs / HOUR_MS);
|
|
125
|
+
const minutes = Math.floor(elapsedMs % HOUR_MS / MINUTE_MS);
|
|
126
|
+
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_lib/workflowActivity.ts
|
|
130
|
+
const PAUSED_STATES = /* @__PURE__ */ new Set(["paused", "pause_requested"]);
|
|
131
|
+
function workflowRunIdentity(run) {
|
|
132
|
+
return `${run.workspace}/${run.runKey}`;
|
|
133
|
+
}
|
|
134
|
+
/** Whether the session runs this workflow itself; a record from an older hub names no owner and means the viewer. */
|
|
135
|
+
function ownsRun(run, sessionId) {
|
|
136
|
+
return sessionId !== null && (run.ownerSessionId ?? sessionId) === sessionId;
|
|
137
|
+
}
|
|
138
|
+
function toneOf(run) {
|
|
139
|
+
if (run.stage === "running") return run.executionState !== void 0 && PAUSED_STATES.has(run.executionState) ? "paused" : "running";
|
|
140
|
+
if (run.stage === "error") return "failed";
|
|
141
|
+
if (run.outcome !== void 0 && run.outcome !== "success") return run.outcome === "failed" ? "failed" : "skipped";
|
|
142
|
+
return "done";
|
|
143
|
+
}
|
|
144
|
+
function detailOf(run, tone) {
|
|
145
|
+
if (tone === "paused") return "paused; resume it from the owning session";
|
|
146
|
+
if (tone === "failed") return run.errorMessage ?? (run.failedJob === void 0 ? "failed" : `job '${run.failedJob}' failed`);
|
|
147
|
+
if (tone === "running") {
|
|
148
|
+
if (run.position === void 0) return "starting";
|
|
149
|
+
return run.position.step === void 0 ? run.position.job : `${run.position.job} · ${run.position.step}`;
|
|
150
|
+
}
|
|
151
|
+
return run.outcome ?? "done";
|
|
152
|
+
}
|
|
153
|
+
function elapsedOf(run, now) {
|
|
154
|
+
const start = Date.parse(run.startedAt);
|
|
155
|
+
if (!Number.isFinite(start)) return "";
|
|
156
|
+
const end = run.finishedAt === void 0 ? now : Date.parse(run.finishedAt);
|
|
157
|
+
return formatRunDuration(Math.max(0, (Number.isFinite(end) ? end : now) - start));
|
|
158
|
+
}
|
|
159
|
+
/** The hub's presented order is kept: it already puts live runs first. */
|
|
160
|
+
function workflowActivityRows(runs, now, sessionId = null) {
|
|
161
|
+
return runs.map((run) => {
|
|
162
|
+
const tone = toneOf(run);
|
|
163
|
+
return {
|
|
164
|
+
identity: workflowRunIdentity(run),
|
|
165
|
+
runKey: run.runKey,
|
|
166
|
+
name: run.displayName,
|
|
167
|
+
tone,
|
|
168
|
+
elapsed: elapsedOf(run, now),
|
|
169
|
+
detail: detailOf(run, tone),
|
|
170
|
+
...run.ownerSessionId === void 0 || run.ownerSessionId === sessionId ? {} : { ownerSessionId: run.ownerSessionId }
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const GROUP_OF = {
|
|
175
|
+
running: "running",
|
|
176
|
+
paused: "running",
|
|
177
|
+
failed: "failed",
|
|
178
|
+
done: "successful",
|
|
179
|
+
skipped: "successful"
|
|
180
|
+
};
|
|
181
|
+
const GROUP_ORDER = [
|
|
182
|
+
"running",
|
|
183
|
+
"failed",
|
|
184
|
+
"successful"
|
|
185
|
+
];
|
|
186
|
+
/** Rows split by outcome, in the order the dock shows them; empty groups are dropped. */
|
|
187
|
+
function workflowActivityGroups(rows) {
|
|
188
|
+
return GROUP_ORDER.map((name) => ({
|
|
189
|
+
name,
|
|
190
|
+
rows: rows.filter((row) => GROUP_OF[row.tone] === name),
|
|
191
|
+
openByDefault: name !== "successful"
|
|
192
|
+
})).filter((group) => group.rows.length > 0);
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_lib/workflowsStore.ts
|
|
196
|
+
const workflows = defineSessionStore({
|
|
197
|
+
runs: [],
|
|
198
|
+
focusedRun: void 0,
|
|
199
|
+
pendingLaunch: void 0
|
|
200
|
+
});
|
|
201
|
+
function isRecord$1(value) {
|
|
202
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
203
|
+
}
|
|
204
|
+
/** Points the workflows tab at one run; the activity dock uses this before opening the tab. */
|
|
205
|
+
function focusRun(sessionId, identity) {
|
|
206
|
+
workflows.update(sessionId, (current) => ({
|
|
207
|
+
...current,
|
|
208
|
+
focusedRun: identity
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
/** Removes a deleted run immediately while the registry channel catches up. */
|
|
212
|
+
function removeRun(sessionId, identity) {
|
|
213
|
+
workflows.update(sessionId, (current) => {
|
|
214
|
+
const runs = current.runs.filter((run) => workflowRunIdentity(run) !== identity);
|
|
215
|
+
return {
|
|
216
|
+
...current,
|
|
217
|
+
runs,
|
|
218
|
+
focusedRun: current.focusedRun === identity ? runs[0] === void 0 ? void 0 : workflowRunIdentity(runs[0]) : current.focusedRun
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Sends a launch line to the session and remembers what was already running.
|
|
224
|
+
*
|
|
225
|
+
* The cockpit's only channel to a session is a prompt frame, so a launch is
|
|
226
|
+
* the verb the extension registers for exactly this; see web/launchLine.ts.
|
|
227
|
+
*/
|
|
228
|
+
function requestLaunch(send, sessionId, line, workflowPath) {
|
|
229
|
+
send(sessionId, {
|
|
230
|
+
type: "prompt",
|
|
231
|
+
message: line
|
|
232
|
+
});
|
|
233
|
+
workflows.update(sessionId, (current) => ({
|
|
234
|
+
...current,
|
|
235
|
+
pendingLaunch: {
|
|
236
|
+
workflowPath,
|
|
237
|
+
knownRuns: current.runs.map(workflowRunIdentity)
|
|
238
|
+
}
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
/** The plugin's session data channel: 'workflow_runs' payloads into the store. */
|
|
242
|
+
const workflowRunsChannel = workflows.channel({
|
|
243
|
+
channel: WORKFLOW_RUNS_TYPE,
|
|
244
|
+
parse(input) {
|
|
245
|
+
if (!isRecord$1(input) || !Array.isArray(input.runs)) return null;
|
|
246
|
+
return { runs: input.runs.filter(isRecord$1) };
|
|
247
|
+
},
|
|
248
|
+
reduce: (current, { runs }) => {
|
|
249
|
+
const pending = current.pendingLaunch;
|
|
250
|
+
if (pending === void 0) return {
|
|
251
|
+
...current,
|
|
252
|
+
runs
|
|
253
|
+
};
|
|
254
|
+
const known = new Set(pending.knownRuns);
|
|
255
|
+
const arrived = runs.find((run) => !known.has(workflowRunIdentity(run)));
|
|
256
|
+
if (arrived === void 0) return {
|
|
257
|
+
...current,
|
|
258
|
+
runs
|
|
259
|
+
};
|
|
260
|
+
return {
|
|
261
|
+
...current,
|
|
262
|
+
runs,
|
|
263
|
+
focusedRun: workflowRunIdentity(arrived),
|
|
264
|
+
pendingLaunch: void 0
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region src/extensions/workspaces/sessions/(frontend)/fill/_components/WorkflowsActivitySection.tsx
|
|
270
|
+
const TICK_MS$1 = 1e4;
|
|
271
|
+
const TONE_DOT = {
|
|
272
|
+
running: "yellow",
|
|
273
|
+
paused: "yellow",
|
|
274
|
+
failed: "red",
|
|
275
|
+
done: "green",
|
|
276
|
+
skipped: "muted"
|
|
277
|
+
};
|
|
278
|
+
const TONE_DETAIL = {
|
|
279
|
+
running: "text-doom-dim",
|
|
280
|
+
paused: "text-doom-yellow",
|
|
281
|
+
failed: "text-doom-red",
|
|
282
|
+
done: "text-doom-dim",
|
|
283
|
+
skipped: "text-doom-dim"
|
|
284
|
+
};
|
|
285
|
+
const GROUP_LABEL = {
|
|
286
|
+
running: "text-doom-yellow",
|
|
287
|
+
failed: "text-doom-red",
|
|
288
|
+
successful: "text-doom-green"
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* The workflows group's body in the activity dock: every run this session
|
|
292
|
+
* started, split into running, failed and successful.
|
|
293
|
+
*
|
|
294
|
+
* A session keeps its whole history, so the groups fold: failures open because
|
|
295
|
+
* they are the only ones that need an answer, and the rest stay out of the way
|
|
296
|
+
* until somebody asks. Idle offers the catalog instead of saying nothing.
|
|
297
|
+
*/
|
|
298
|
+
function WorkflowsActivitySection({ sessionId, openSession }) {
|
|
299
|
+
const runs = useStore(workflows.store, (state) => workflows.select(state, sessionId).runs);
|
|
300
|
+
const [now, setNow] = useState(() => Date.now());
|
|
301
|
+
const [folded, setFolded] = useState({});
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
const timer = setInterval(() => setNow(Date.now()), TICK_MS$1);
|
|
304
|
+
return () => clearInterval(timer);
|
|
305
|
+
}, []);
|
|
306
|
+
const groups = workflowActivityGroups(workflowActivityRows(runs, now, sessionId));
|
|
307
|
+
if (groups.length === 0) return /* @__PURE__ */ jsxs("div", {
|
|
308
|
+
className: "flex flex-wrap items-center gap-x-4 gap-y-1",
|
|
309
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
310
|
+
"data-testid": "activity-summary-workflows",
|
|
311
|
+
className: "text-sm text-doom-dim",
|
|
312
|
+
children: "Idle"
|
|
313
|
+
}), /* @__PURE__ */ jsxs(Button, {
|
|
314
|
+
variant: "link",
|
|
315
|
+
size: "sm",
|
|
316
|
+
"data-testid": "activity-workflow-launch",
|
|
317
|
+
className: "h-auto gap-1 px-0 text-doom-green",
|
|
318
|
+
onClick: () => {
|
|
319
|
+
if (sessionId === null) return;
|
|
320
|
+
openCatalog(sessionId);
|
|
321
|
+
},
|
|
322
|
+
children: ["Launch a workflow", /* @__PURE__ */ jsx(ArrowRightIcon, {
|
|
323
|
+
"aria-hidden": true,
|
|
324
|
+
className: "size-3.5"
|
|
325
|
+
})]
|
|
326
|
+
})]
|
|
327
|
+
});
|
|
328
|
+
const RunRow = ({ row }) => {
|
|
329
|
+
const delegated = row.ownerSessionId !== void 0 && openSession !== void 0;
|
|
330
|
+
return /* @__PURE__ */ jsxs(Button, {
|
|
331
|
+
variant: "ghost",
|
|
332
|
+
size: "card",
|
|
333
|
+
"data-testid": `activity-workflow-${row.runKey}`,
|
|
334
|
+
"data-run-tone": row.tone,
|
|
335
|
+
"data-delegated": delegated,
|
|
336
|
+
title: delegated ? "open its workflow session" : "show this run in the workflow dock",
|
|
337
|
+
onClick: () => {
|
|
338
|
+
if (row.ownerSessionId !== void 0 && openSession !== void 0) {
|
|
339
|
+
openSession(row.ownerSessionId);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (sessionId !== null) focusRun(sessionId, row.identity);
|
|
343
|
+
},
|
|
344
|
+
className: "min-w-0 gap-0.5 rounded-md px-1 py-1 hover:bg-doom-panel",
|
|
345
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
346
|
+
className: "flex min-w-0 items-center gap-1.5",
|
|
347
|
+
children: [
|
|
348
|
+
/* @__PURE__ */ jsx(Dot, {
|
|
349
|
+
tone: TONE_DOT[row.tone],
|
|
350
|
+
pulse: row.tone === "running"
|
|
351
|
+
}),
|
|
352
|
+
/* @__PURE__ */ jsx("span", {
|
|
353
|
+
className: `min-w-0 flex-1 truncate text-base font-semibold ${row.tone === "running" || row.tone === "paused" ? "text-doom-hi" : "text-doom-dim"}`,
|
|
354
|
+
children: row.name
|
|
355
|
+
}),
|
|
356
|
+
/* @__PURE__ */ jsx("span", {
|
|
357
|
+
className: "shrink-0 text-sm text-doom-dim",
|
|
358
|
+
children: row.elapsed
|
|
359
|
+
})
|
|
360
|
+
]
|
|
361
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
362
|
+
className: `truncate pl-3 text-sm ${TONE_DETAIL[row.tone]}`,
|
|
363
|
+
children: row.detail
|
|
364
|
+
})]
|
|
365
|
+
});
|
|
366
|
+
};
|
|
367
|
+
return /* @__PURE__ */ jsx("div", {
|
|
368
|
+
"data-testid": "activity-workflow-runs",
|
|
369
|
+
className: "flex flex-col gap-0.5",
|
|
370
|
+
children: groups.map((group) => {
|
|
371
|
+
const open = folded[group.name] ?? group.openByDefault;
|
|
372
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
373
|
+
className: "flex flex-col gap-0.5",
|
|
374
|
+
children: [/* @__PURE__ */ jsxs(Button, {
|
|
375
|
+
variant: "ghost",
|
|
376
|
+
size: "xs",
|
|
377
|
+
"data-testid": `activity-workflow-group-${group.name}`,
|
|
378
|
+
"data-open": open,
|
|
379
|
+
"aria-expanded": open,
|
|
380
|
+
onClick: () => setFolded((current) => ({
|
|
381
|
+
...current,
|
|
382
|
+
[group.name]: !open
|
|
383
|
+
})),
|
|
384
|
+
className: "justify-start gap-1.5 rounded-md px-1 py-0.5 hover:bg-doom-panel",
|
|
385
|
+
children: [
|
|
386
|
+
open ? /* @__PURE__ */ jsx(ChevronDownIcon, { className: "h-2.5 w-2.5 text-doom-faint" }) : /* @__PURE__ */ jsx(ChevronRightIcon, { className: "h-2.5 w-2.5 text-doom-faint" }),
|
|
387
|
+
/* @__PURE__ */ jsx("span", {
|
|
388
|
+
className: `inline-block text-sm font-semibold first-letter:uppercase ${GROUP_LABEL[group.name]}`,
|
|
389
|
+
children: group.name
|
|
390
|
+
}),
|
|
391
|
+
/* @__PURE__ */ jsx("span", {
|
|
392
|
+
className: "text-2xs text-doom-faint",
|
|
393
|
+
children: group.rows.length
|
|
394
|
+
})
|
|
395
|
+
]
|
|
396
|
+
}), open ? /* @__PURE__ */ jsx("div", {
|
|
397
|
+
className: "flex flex-col gap-0.5 pl-2",
|
|
398
|
+
children: group.rows.map((row) => /* @__PURE__ */ jsx(RunRow, { row }, row.identity))
|
|
399
|
+
}) : /* @__PURE__ */ jsxs("span", {
|
|
400
|
+
className: "truncate pl-5 text-sm text-doom-dim",
|
|
401
|
+
children: [group.rows.slice(0, 2).map((row) => row.name).join(", "), group.rows.length > 2 ? `, and ${group.rows.length - 2} more` : ""]
|
|
402
|
+
})]
|
|
403
|
+
}, group.name);
|
|
404
|
+
})
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
//#endregion
|
|
408
|
+
//#region src/extensions/workspaces/sessions/(frontend)/tool/_lib/workflowToolRender.ts
|
|
409
|
+
const COLLAPSED_CATALOG_ROWS = 6;
|
|
410
|
+
const COLLAPSED_DETAIL_LINES = 8;
|
|
411
|
+
const WORKFLOW_TOOL_NAMES = [
|
|
412
|
+
"list_workflows",
|
|
413
|
+
"launch_workflow",
|
|
414
|
+
"workflow_run",
|
|
415
|
+
"workflow_tools"
|
|
416
|
+
];
|
|
417
|
+
function asRecord(value) {
|
|
418
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
419
|
+
return value;
|
|
420
|
+
}
|
|
421
|
+
function inlineText(value) {
|
|
422
|
+
let normalized = "";
|
|
423
|
+
for (const character of value) {
|
|
424
|
+
const code = character.codePointAt(0) ?? 0;
|
|
425
|
+
normalized += code <= 31 || code === 127 ? " " : character;
|
|
426
|
+
}
|
|
427
|
+
return normalized.replace(/\s+/gu, " ").trim();
|
|
428
|
+
}
|
|
429
|
+
function readString(record, key) {
|
|
430
|
+
const value = record?.[key];
|
|
431
|
+
if (typeof value !== "string") return void 0;
|
|
432
|
+
return inlineText(value) || void 0;
|
|
433
|
+
}
|
|
434
|
+
function readNumber(record, key) {
|
|
435
|
+
const value = record?.[key];
|
|
436
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
437
|
+
}
|
|
438
|
+
function readBoolean(record, key) {
|
|
439
|
+
const value = record?.[key];
|
|
440
|
+
return typeof value === "boolean" ? value : void 0;
|
|
441
|
+
}
|
|
442
|
+
function readStringArray(record, key) {
|
|
443
|
+
const value = record?.[key];
|
|
444
|
+
if (!Array.isArray(value)) return [];
|
|
445
|
+
return value.filter((item) => typeof item === "string");
|
|
446
|
+
}
|
|
447
|
+
function parseJsonRecord(text) {
|
|
448
|
+
if (!text) return void 0;
|
|
449
|
+
try {
|
|
450
|
+
return asRecord(JSON.parse(text));
|
|
451
|
+
} catch {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function textBlocks(result) {
|
|
456
|
+
if (!result) return [];
|
|
457
|
+
return result.content.flatMap((item) => {
|
|
458
|
+
const record = asRecord(item);
|
|
459
|
+
return record?.type === "text" && typeof record.text === "string" ? [record.text] : [];
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
function detailLines(result) {
|
|
463
|
+
return textBlocks(result).flatMap((block) => block.split("\n")).map((entry) => entry.trimEnd()).filter((entry) => entry.length > 0);
|
|
464
|
+
}
|
|
465
|
+
function firstPayload(result) {
|
|
466
|
+
return parseJsonRecord(textBlocks(result)[0]);
|
|
467
|
+
}
|
|
468
|
+
function plural(count, singular, pluralForm = `${singular}s`) {
|
|
469
|
+
return count === 1 ? singular : pluralForm;
|
|
470
|
+
}
|
|
471
|
+
function line(text, tone, extra = {}) {
|
|
472
|
+
return {
|
|
473
|
+
text,
|
|
474
|
+
tone,
|
|
475
|
+
...extra
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function labelValue(label, value) {
|
|
479
|
+
return line(`${label} ${value}`, "text");
|
|
480
|
+
}
|
|
481
|
+
function hiddenLine(hidden) {
|
|
482
|
+
return line(`… ${hidden} more`, "dim");
|
|
483
|
+
}
|
|
484
|
+
function targetScope(args) {
|
|
485
|
+
const runKey = readString(args, "runKey");
|
|
486
|
+
if (!runKey) return void 0;
|
|
487
|
+
const workspace = readString(args, "workspace");
|
|
488
|
+
return workspace ? `${workspace}/${runKey}` : runKey;
|
|
489
|
+
}
|
|
490
|
+
function toolAction(toolName, args) {
|
|
491
|
+
if (toolName === "list_workflows") return "list";
|
|
492
|
+
if (toolName === "launch_workflow") return "launch";
|
|
493
|
+
return readString(args, "action") ?? "run";
|
|
494
|
+
}
|
|
495
|
+
function toolTarget(toolName, args) {
|
|
496
|
+
if (toolName === "list_workflows") return readString(args, "directory");
|
|
497
|
+
if (toolName === "launch_workflow") return readString(args, "workflowPath");
|
|
498
|
+
return targetScope(args);
|
|
499
|
+
}
|
|
500
|
+
function statusTimestamp(value) {
|
|
501
|
+
return value.replace("T", " ").replace(/\.\d{3}Z$/, "Z");
|
|
502
|
+
}
|
|
503
|
+
function workflowNameFromPath(path) {
|
|
504
|
+
if (!path) return "workflow";
|
|
505
|
+
const filename = path.split("/").at(-1) ?? path;
|
|
506
|
+
return filename.replace(/\.workflow\.ya?ml$/u, "") || filename;
|
|
507
|
+
}
|
|
508
|
+
function callMetadata(toolName, args) {
|
|
509
|
+
const values = [];
|
|
510
|
+
if (toolName === "list_workflows") {
|
|
511
|
+
const page = readNumber(args, "page");
|
|
512
|
+
const pageSize = readNumber(args, "pageSize");
|
|
513
|
+
if (page !== void 0) values.push(`page ${page}`);
|
|
514
|
+
if (pageSize !== void 0) values.push(`${pageSize}/page`);
|
|
515
|
+
if (readString(args, "filter")) values.push("filtered");
|
|
516
|
+
const tags = readStringArray(args, "tags");
|
|
517
|
+
if (tags.length > 0) values.push(`${tags.length} ${plural(tags.length, "tag")}`);
|
|
518
|
+
} else if (toolName === "launch_workflow") {
|
|
519
|
+
const job = readString(args, "job");
|
|
520
|
+
const runner = readString(args, "runner");
|
|
521
|
+
const workspace = readString(args, "workspace");
|
|
522
|
+
const inputCount = Object.keys(asRecord(args.inputs) ?? {}).length;
|
|
523
|
+
const environmentCount = Object.keys(asRecord(args.env) ?? {}).length;
|
|
524
|
+
if (workspace) values.push(`workspace ${workspace}`);
|
|
525
|
+
if (job) values.push(`job ${job}`);
|
|
526
|
+
if (runner) values.push(`runner ${runner}`);
|
|
527
|
+
if (readBoolean(args, "dryRun")) values.push("dry run");
|
|
528
|
+
if (readString(args, "prompt")) values.push("with prompt");
|
|
529
|
+
if (inputCount > 0) values.push(`${inputCount} ${plural(inputCount, "input")}`);
|
|
530
|
+
if (environmentCount > 0) values.push(`${environmentCount} env`);
|
|
531
|
+
} else {
|
|
532
|
+
if (readString(args, "expectedRunId")) values.push("generation checked");
|
|
533
|
+
if (readString(args, "reason")) values.push("with reason");
|
|
534
|
+
if (readBoolean(args, "dryRun")) values.push("dry run");
|
|
535
|
+
if (readString(args, "job")) values.push("job override");
|
|
536
|
+
if (readString(args, "runner")) values.push("runner override");
|
|
537
|
+
}
|
|
538
|
+
return values;
|
|
539
|
+
}
|
|
540
|
+
/** The header beside the tool name: action, target, and the call's flags. */
|
|
541
|
+
function workflowCallSummary(toolName, args) {
|
|
542
|
+
const target = toolTarget(toolName, args);
|
|
543
|
+
return {
|
|
544
|
+
action: toolAction(toolName, args),
|
|
545
|
+
...target === void 0 ? {} : { target },
|
|
546
|
+
metadata: callMetadata(toolName, args)
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
function parseCatalog(payload) {
|
|
550
|
+
if (!Array.isArray(payload?.workflows)) return void 0;
|
|
551
|
+
const workflows = payload.workflows.flatMap((value) => {
|
|
552
|
+
const record = asRecord(value);
|
|
553
|
+
const path = readString(record, "path");
|
|
554
|
+
if (!record || !path) return [];
|
|
555
|
+
return [{
|
|
556
|
+
description: readString(record, "description") ?? "",
|
|
557
|
+
name: readString(record, "name") ?? "",
|
|
558
|
+
path,
|
|
559
|
+
tags: readStringArray(record, "tags")
|
|
560
|
+
}];
|
|
561
|
+
});
|
|
562
|
+
const tags = Array.isArray(payload.tags) ? payload.tags.flatMap((value) => {
|
|
563
|
+
const record = asRecord(value);
|
|
564
|
+
const tag = readString(record, "tag");
|
|
565
|
+
const count = readNumber(record, "count");
|
|
566
|
+
return tag && count !== void 0 ? [{
|
|
567
|
+
count,
|
|
568
|
+
tag
|
|
569
|
+
}] : [];
|
|
570
|
+
}) : [];
|
|
571
|
+
const directory = readString(payload, "directory");
|
|
572
|
+
return {
|
|
573
|
+
...directory === void 0 ? {} : { directory },
|
|
574
|
+
page: readNumber(payload, "page") ?? 1,
|
|
575
|
+
pageSize: readNumber(payload, "pageSize") ?? workflows.length,
|
|
576
|
+
tags,
|
|
577
|
+
total: readNumber(payload, "total") ?? workflows.length,
|
|
578
|
+
totalPages: readNumber(payload, "totalPages") ?? (workflows.length > 0 ? 1 : 0),
|
|
579
|
+
workflows
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
function catalogItemLines(item, expanded) {
|
|
583
|
+
const lines = [line(`› ${item.name || workflowNameFromPath(item.path)} · ${item.path}`, "text")];
|
|
584
|
+
if (!expanded) return lines;
|
|
585
|
+
if (item.description) lines.push(line(item.description, "muted", { indent: true }));
|
|
586
|
+
if (item.tags.length > 0) lines.push(line(`tags ${item.tags.join(", ")}`, "dim", { indent: true }));
|
|
587
|
+
return lines;
|
|
588
|
+
}
|
|
589
|
+
function catalogLines(result, options) {
|
|
590
|
+
const catalog = parseCatalog(firstPayload(result));
|
|
591
|
+
if (!catalog) return void 0;
|
|
592
|
+
const pageLabel = `page ${catalog.page}/${catalog.totalPages}`;
|
|
593
|
+
const summary = line(`${catalog.total > 0 ? "✓" : "○"} ${catalog.total} ${plural(catalog.total, "workflow")} · ${pageLabel}`, "hi", { bold: true });
|
|
594
|
+
const shown = options.expanded ? catalog.workflows : catalog.workflows.slice(0, COLLAPSED_CATALOG_ROWS);
|
|
595
|
+
const lines = [summary, ...shown.flatMap((item) => catalogItemLines(item, options.expanded))];
|
|
596
|
+
const hidden = catalog.workflows.length - shown.length;
|
|
597
|
+
if (hidden > 0) lines.push(hiddenLine(hidden));
|
|
598
|
+
if (options.expanded) {
|
|
599
|
+
if (catalog.tags.length > 0) {
|
|
600
|
+
const tags = catalog.tags.map(({ count, tag }) => `${tag} (${count})`).join(", ");
|
|
601
|
+
lines.push(labelValue("available tags", tags));
|
|
602
|
+
}
|
|
603
|
+
if (catalog.directory) lines.push(labelValue("directory", catalog.directory));
|
|
604
|
+
lines.push(labelValue("page size", String(catalog.pageSize)));
|
|
605
|
+
}
|
|
606
|
+
return lines;
|
|
607
|
+
}
|
|
608
|
+
function readWorkflowState(record) {
|
|
609
|
+
const stage = readString(record, "effectiveStage") ?? readString(record, "stage") ?? "unknown";
|
|
610
|
+
const outcome = readString(record, "effectiveOutcome") ?? readString(record, "outcome");
|
|
611
|
+
const executionState = readString(record, "executionState");
|
|
612
|
+
if (outcome === "interrupted") return {
|
|
613
|
+
tone: "warning",
|
|
614
|
+
glyph: "!",
|
|
615
|
+
label: "interrupted"
|
|
616
|
+
};
|
|
617
|
+
if (stage === "error" || outcome === "failed") return {
|
|
618
|
+
tone: "error",
|
|
619
|
+
glyph: "✗",
|
|
620
|
+
label: "failed"
|
|
621
|
+
};
|
|
622
|
+
if (stage === "completed" && outcome === "skipped") return {
|
|
623
|
+
tone: "muted",
|
|
624
|
+
glyph: "○",
|
|
625
|
+
label: "skipped"
|
|
626
|
+
};
|
|
627
|
+
if (stage === "completed") return {
|
|
628
|
+
tone: "success",
|
|
629
|
+
glyph: "✓",
|
|
630
|
+
label: "completed"
|
|
631
|
+
};
|
|
632
|
+
if (executionState === "paused") return {
|
|
633
|
+
tone: "warning",
|
|
634
|
+
glyph: "Ⅱ",
|
|
635
|
+
label: "paused"
|
|
636
|
+
};
|
|
637
|
+
if (executionState === "pause_requested") return {
|
|
638
|
+
tone: "warning",
|
|
639
|
+
glyph: "◐",
|
|
640
|
+
label: "pause requested"
|
|
641
|
+
};
|
|
642
|
+
if (executionState === "resume_requested") return {
|
|
643
|
+
tone: "warning",
|
|
644
|
+
glyph: "◐",
|
|
645
|
+
label: "resume requested"
|
|
646
|
+
};
|
|
647
|
+
if (stage === "running") return {
|
|
648
|
+
tone: "accent",
|
|
649
|
+
glyph: "◐",
|
|
650
|
+
label: "running"
|
|
651
|
+
};
|
|
652
|
+
return {
|
|
653
|
+
tone: "muted",
|
|
654
|
+
glyph: "○",
|
|
655
|
+
label: stage
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
function currentActivity(record) {
|
|
659
|
+
const position = asRecord(record.position);
|
|
660
|
+
const cursor = asRecord(record.executionCursor);
|
|
661
|
+
const job = readString(position, "job") ?? readString(cursor, "job") ?? readString(record, "job");
|
|
662
|
+
const step = readString(position, "step") ?? readString(cursor, "stepName");
|
|
663
|
+
const phase = readString(cursor, "phase");
|
|
664
|
+
if (!job && !step && !phase) return void 0;
|
|
665
|
+
return line(`[${job ?? phase ?? "workflow"}] ${step ?? phase ?? "current job"}`, "text", { indent: true });
|
|
666
|
+
}
|
|
667
|
+
function launcherLabel(record) {
|
|
668
|
+
const launcher = asRecord(record.launcher);
|
|
669
|
+
const type = readString(launcher, "type");
|
|
670
|
+
if (!type) return void 0;
|
|
671
|
+
const identity = readString(launcher, "sessionName") ?? readString(launcher, "workspaceId");
|
|
672
|
+
return identity ? `${type} ${identity}` : type;
|
|
673
|
+
}
|
|
674
|
+
function workflowRecordLines(record, expanded) {
|
|
675
|
+
const runKey = readString(record, "runKey");
|
|
676
|
+
const stage = readString(record, "stage");
|
|
677
|
+
if (!runKey || !stage) return void 0;
|
|
678
|
+
const state = readWorkflowState(record);
|
|
679
|
+
const lines = [
|
|
680
|
+
line(`Status retrieved · ${readString(record, "displayName") ?? runKey} · workflow: ${state.label}`, state.tone, { bold: true }),
|
|
681
|
+
labelValue("workspace", readString(record, "workspace") ?? "unknown"),
|
|
682
|
+
labelValue("runKey", runKey)
|
|
683
|
+
];
|
|
684
|
+
const activity = currentActivity(record);
|
|
685
|
+
if (activity) lines.push(activity);
|
|
686
|
+
const failure = readString(record, "errorMessage") ?? readString(record, "failureReason");
|
|
687
|
+
if (failure) lines.push(line(`error ${failure}`, "error"));
|
|
688
|
+
const staleReason = readString(record, "staleReason");
|
|
689
|
+
if (staleReason) lines.push(line(`stale ${staleReason}`, "warning"));
|
|
690
|
+
const startedAt = readString(record, "startedAt");
|
|
691
|
+
const exitCode = readNumber(record, "exitCode");
|
|
692
|
+
const conciseMetadata = [
|
|
693
|
+
readString(record, "runner") ? `runner ${readString(record, "runner")}` : void 0,
|
|
694
|
+
readBoolean(record, "dryRun") ? "dry run" : void 0,
|
|
695
|
+
exitCode !== void 0 ? `exit ${exitCode}` : void 0,
|
|
696
|
+
startedAt ? `started ${statusTimestamp(startedAt)}` : void 0
|
|
697
|
+
].filter((value) => !!value);
|
|
698
|
+
if (conciseMetadata.length > 0) lines.push(line(conciseMetadata.join(" · "), "dim"));
|
|
699
|
+
if (!expanded) return lines;
|
|
700
|
+
const finishedAt = readString(record, "finishedAt");
|
|
701
|
+
const repairId = readString(record, "activeRepairId");
|
|
702
|
+
const detailedValues = [
|
|
703
|
+
["workflow", readString(record, "workflowPath")],
|
|
704
|
+
["workflow id", readString(record, "workflowId")],
|
|
705
|
+
["run id", readString(record, "runId")],
|
|
706
|
+
["launcher", launcherLabel(record)],
|
|
707
|
+
["job", readString(record, "job")],
|
|
708
|
+
["outcome", readString(record, "effectiveOutcome") ?? readString(record, "outcome")],
|
|
709
|
+
["finished", finishedAt ? statusTimestamp(finishedAt) : void 0],
|
|
710
|
+
["pid", readNumber(record, "pid")?.toString()],
|
|
711
|
+
["repair", repairId ? `#${repairId}` : void 0],
|
|
712
|
+
["worktree", readString(record, "worktreeBranch") ?? readString(record, "worktreePath")]
|
|
713
|
+
];
|
|
714
|
+
for (const [label, value] of detailedValues) if (value) lines.push(labelValue(label, value));
|
|
715
|
+
const jobs = record.jobs;
|
|
716
|
+
if (Array.isArray(jobs)) for (const value of jobs) {
|
|
717
|
+
const job = asRecord(value);
|
|
718
|
+
const name = readString(job, "name");
|
|
719
|
+
const status = readString(job, "status");
|
|
720
|
+
if (!name || !status) continue;
|
|
721
|
+
lines.push(labelValue("job", `${name} · ${status}`));
|
|
722
|
+
if (!Array.isArray(job?.steps)) continue;
|
|
723
|
+
for (const value of job.steps) {
|
|
724
|
+
const step = asRecord(value);
|
|
725
|
+
const stepName = readString(step, "name");
|
|
726
|
+
const stepStatus = readString(step, "status");
|
|
727
|
+
if (stepName && stepStatus) lines.push(line(`step ${stepName} · ${stepStatus}`, "text", { indent: true }));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return lines;
|
|
731
|
+
}
|
|
732
|
+
function statusLines(result, options) {
|
|
733
|
+
const payload = firstPayload(result);
|
|
734
|
+
if (!payload) return void 0;
|
|
735
|
+
return workflowRecordLines(payload, options.expanded);
|
|
736
|
+
}
|
|
737
|
+
function nestedRequestedAt(payload) {
|
|
738
|
+
return readString(asRecord(payload?.request), "requestedAt") ?? readString(payload, "requestedAt");
|
|
739
|
+
}
|
|
740
|
+
function controlLines(action, args, result, options) {
|
|
741
|
+
const payload = firstPayload(result);
|
|
742
|
+
const scope = targetScope(args) ?? readString(payload, "runKey") ?? "workflow";
|
|
743
|
+
const payloadWorkspace = readString(payload, "workspace");
|
|
744
|
+
const lines = [line(`◐ ${action} requested · ${payloadWorkspace && !scope.includes("/") ? `${payloadWorkspace}/${scope}` : scope}`, "warning", { bold: true })];
|
|
745
|
+
const requestedAt = nestedRequestedAt(payload);
|
|
746
|
+
if (requestedAt) lines.push(labelValue("requested", statusTimestamp(requestedAt)));
|
|
747
|
+
if (options.expanded) lines.push(line("verify the transition with workflow_run status", "dim"));
|
|
748
|
+
return lines;
|
|
749
|
+
}
|
|
750
|
+
function matchLine(text, pattern) {
|
|
751
|
+
return text.match(pattern)?.[1]?.trim();
|
|
752
|
+
}
|
|
753
|
+
function launchSummary(text, args) {
|
|
754
|
+
const startedName = matchLine(text, /^Started (.+) in workspace .+\.$/mu);
|
|
755
|
+
const workspace = matchLine(text, /^Started .+ in workspace (.+)\.$/mu) ?? readString(args, "workspace");
|
|
756
|
+
const runKey = matchLine(text, /^Run key:\s*(.+)$/mu);
|
|
757
|
+
const path = readString(args, "workflowPath");
|
|
758
|
+
const name = startedName ?? workflowNameFromPath(path);
|
|
759
|
+
const scope = runKey ? workspace ? `${workspace}/${runKey}` : runKey : workspace ?? path ?? "workflow";
|
|
760
|
+
if (/Workflow completed successfully/iu.test(text)) return {
|
|
761
|
+
name,
|
|
762
|
+
scope,
|
|
763
|
+
state: {
|
|
764
|
+
tone: "success",
|
|
765
|
+
glyph: "✓",
|
|
766
|
+
label: "completed"
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
if (/Workflow skipped/iu.test(text)) return {
|
|
770
|
+
name,
|
|
771
|
+
scope,
|
|
772
|
+
state: {
|
|
773
|
+
tone: "muted",
|
|
774
|
+
glyph: "○",
|
|
775
|
+
label: "skipped"
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
if (/no run was registered/iu.test(text)) return {
|
|
779
|
+
name,
|
|
780
|
+
scope,
|
|
781
|
+
state: {
|
|
782
|
+
tone: "warning",
|
|
783
|
+
glyph: "◐",
|
|
784
|
+
label: "starting · run key pending"
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
return {
|
|
788
|
+
name,
|
|
789
|
+
scope,
|
|
790
|
+
state: {
|
|
791
|
+
tone: "accent",
|
|
792
|
+
glyph: "◐",
|
|
793
|
+
label: runKey ? "started" : "launch accepted"
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function launchLines(args, result, options) {
|
|
798
|
+
const summary = launchSummary(textBlocks(result).join("\n"), args);
|
|
799
|
+
const lines = [line(`${summary.state.glyph} ${summary.name} · ${summary.scope} · ${summary.state.label}`, summary.state.tone, { bold: true })];
|
|
800
|
+
const metadata = [
|
|
801
|
+
readString(args, "job") ? `job ${readString(args, "job")}` : void 0,
|
|
802
|
+
readString(args, "runner") ? `runner ${readString(args, "runner")}` : void 0,
|
|
803
|
+
readBoolean(args, "dryRun") ? "dry run" : void 0
|
|
804
|
+
].filter((value) => !!value);
|
|
805
|
+
if (metadata.length > 0) lines.push(line(metadata.join(" · "), "dim"));
|
|
806
|
+
if (options.expanded) {
|
|
807
|
+
const details = detailLines(result).filter((entry) => !entry.startsWith("Next steps for the agent:"));
|
|
808
|
+
lines.push(...details.map((entry) => line(entry, "text")));
|
|
809
|
+
}
|
|
810
|
+
return lines;
|
|
811
|
+
}
|
|
812
|
+
function splitEvidence(text) {
|
|
813
|
+
const sections = [];
|
|
814
|
+
let current;
|
|
815
|
+
for (const entry of text.split("\n")) {
|
|
816
|
+
const match = entry.match(/^--- (.+) ---$/u);
|
|
817
|
+
if (match?.[1]) {
|
|
818
|
+
current = {
|
|
819
|
+
body: [],
|
|
820
|
+
name: match[1]
|
|
821
|
+
};
|
|
822
|
+
sections.push(current);
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
current?.body.push(entry);
|
|
826
|
+
}
|
|
827
|
+
return sections;
|
|
828
|
+
}
|
|
829
|
+
function recoveryEvidenceLines(args, result, options) {
|
|
830
|
+
const sections = splitEvidence(textBlocks(result).join("\n"));
|
|
831
|
+
const record = parseJsonRecord(sections.find((section) => section.name === "run.json")?.body.join("\n"));
|
|
832
|
+
const recordLines = record ? workflowRecordLines(record, options.expanded) : void 0;
|
|
833
|
+
const evidenceSections = sections.filter((section) => section.name !== "run.json");
|
|
834
|
+
const scope = targetScope(args) ?? "workflow";
|
|
835
|
+
const lines = recordLines ?? [line(`✓ recovery evidence · ${scope}`, "success", { bold: true })];
|
|
836
|
+
const names = evidenceSections.map((section) => section.name);
|
|
837
|
+
lines.push(names.length > 0 ? labelValue("evidence", names.join(", ")) : line("no durable evidence files recorded", "muted"));
|
|
838
|
+
if (options.expanded) for (const section of evidenceSections) {
|
|
839
|
+
lines.push(line(section.name, "hi", { bold: true }));
|
|
840
|
+
lines.push(...section.body.filter((entry) => entry.length > 0).map((entry) => line(entry, "text")));
|
|
841
|
+
}
|
|
842
|
+
return lines;
|
|
843
|
+
}
|
|
844
|
+
function simpleActionLines(action, args, result, options) {
|
|
845
|
+
const scope = targetScope(args) ?? "workflow";
|
|
846
|
+
const actionLabel = {
|
|
847
|
+
follow: "following output",
|
|
848
|
+
open: "launcher opened",
|
|
849
|
+
recover: readBoolean(args, "dryRun") ? "recovery dry run finished" : "recovery accepted",
|
|
850
|
+
tail: "launcher output checked"
|
|
851
|
+
};
|
|
852
|
+
const state = action === "recover" ? {
|
|
853
|
+
tone: "accent",
|
|
854
|
+
glyph: "◐",
|
|
855
|
+
label: actionLabel[action] ?? action
|
|
856
|
+
} : {
|
|
857
|
+
tone: "success",
|
|
858
|
+
glyph: "✓",
|
|
859
|
+
label: actionLabel[action] ?? action
|
|
860
|
+
};
|
|
861
|
+
const lines = [line(`${state.glyph} ${state.label} · ${scope}`, state.tone, { bold: true })];
|
|
862
|
+
const details = detailLines(result);
|
|
863
|
+
if (action === "tail") {
|
|
864
|
+
const workflowLine = details.find((entry) => entry.startsWith("Workflow:"));
|
|
865
|
+
const stageLine = details.find((entry) => entry.startsWith("Stage:"));
|
|
866
|
+
if (workflowLine) lines.push(line(workflowLine, "text"));
|
|
867
|
+
if (stageLine) lines.push(line(stageLine, "text"));
|
|
868
|
+
}
|
|
869
|
+
if (options.expanded) lines.push(...details.map((entry) => line(entry, "text")));
|
|
870
|
+
return lines;
|
|
871
|
+
}
|
|
872
|
+
function failureLines(toolName, args, result, options) {
|
|
873
|
+
const action = toolAction(toolName, args);
|
|
874
|
+
const target = toolTarget(toolName, args);
|
|
875
|
+
const details = detailLines(result);
|
|
876
|
+
const shown = options.expanded ? details : details.slice(0, COLLAPSED_DETAIL_LINES);
|
|
877
|
+
const lines = [line(toolName === "workflow_run" && action === "status" ? `✗ Status lookup failed${target ? ` · ${target}` : ""}` : `✗ ${action} failed${target ? ` · ${target}` : ""}`, "error", { bold: true }), ...shown.length > 0 ? shown.map((entry) => line(entry, "text")) : [line("Unknown workflow error", "error")]];
|
|
878
|
+
const hidden = details.length - shown.length;
|
|
879
|
+
if (hidden > 0) lines.push(hiddenLine(hidden));
|
|
880
|
+
return lines;
|
|
881
|
+
}
|
|
882
|
+
function partialLines(toolName, args, result) {
|
|
883
|
+
const details = detailLines(result);
|
|
884
|
+
const shown = details.slice(-4);
|
|
885
|
+
const action = toolAction(toolName, args);
|
|
886
|
+
const target = toolTarget(toolName, args);
|
|
887
|
+
const hidden = details.length - shown.length;
|
|
888
|
+
const lines = [line(`◐ ${action}${target ? ` · ${target}` : ""}`, "accent", { bold: true })];
|
|
889
|
+
if (hidden > 0) lines.push(line(`… ${hidden} earlier`, "dim"));
|
|
890
|
+
lines.push(...shown.map((entry) => line(entry, "text")));
|
|
891
|
+
return lines;
|
|
892
|
+
}
|
|
893
|
+
function fallbackLines(result, options) {
|
|
894
|
+
const details = detailLines(result);
|
|
895
|
+
if (details.length === 0) return [line("No workflow output.", "muted")];
|
|
896
|
+
const shown = options.expanded ? details : details.slice(0, COLLAPSED_DETAIL_LINES);
|
|
897
|
+
const lines = shown.map((entry) => line(entry, "text"));
|
|
898
|
+
const hidden = details.length - shown.length;
|
|
899
|
+
if (hidden > 0) lines.push(hiddenLine(hidden));
|
|
900
|
+
return lines;
|
|
901
|
+
}
|
|
902
|
+
/** The body of a workflow tool card, chosen by tool, action, and result shape. */
|
|
903
|
+
function workflowResultLines(toolName, args, result, options) {
|
|
904
|
+
if (options.isPartial) return partialLines(toolName, args, result);
|
|
905
|
+
if (options.isError) return failureLines(toolName, args, result, options);
|
|
906
|
+
if (toolName === "list_workflows") return catalogLines(result, options) ?? fallbackLines(result, options);
|
|
907
|
+
if (toolName === "launch_workflow") return launchLines(args, result, options);
|
|
908
|
+
const action = readString(args, "action");
|
|
909
|
+
if (action === "status") return statusLines(result, options) ?? fallbackLines(result, options);
|
|
910
|
+
if (action === "pause" || action === "resume" || action === "stop") return controlLines(action, args, result, options);
|
|
911
|
+
if (action === "recovery-evidence") return recoveryEvidenceLines(args, result, options);
|
|
912
|
+
if (action === "follow" || action === "open" || action === "recover" || action === "tail") return simpleActionLines(action, args, result, options);
|
|
913
|
+
return fallbackLines(result, options);
|
|
914
|
+
}
|
|
915
|
+
//#endregion
|
|
916
|
+
//#region src/extensions/workspaces/sessions/(frontend)/tool/_components/WorkflowToolMessage.tsx
|
|
917
|
+
function workflowToolName(name) {
|
|
918
|
+
return WORKFLOW_TOOL_NAMES.includes(name) ? name : "workflow_run";
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* The workflow tools' timeline item, the web analog of renderWorkflowToolCall
|
|
922
|
+
* and renderWorkflowToolResult: `action · target · flags` in the header and
|
|
923
|
+
* the shape-specific summary lines in the body.
|
|
924
|
+
*/
|
|
925
|
+
function WorkflowToolMessage({ toolName, args, result, running, isError }) {
|
|
926
|
+
const name = workflowToolName(toolName);
|
|
927
|
+
const summary = workflowCallSummary(name, args);
|
|
928
|
+
const collapsed = workflowResultLines(name, args, result, {
|
|
929
|
+
expanded: false,
|
|
930
|
+
isError,
|
|
931
|
+
isPartial: running
|
|
932
|
+
});
|
|
933
|
+
const full = workflowResultLines(name, args, result, {
|
|
934
|
+
expanded: true,
|
|
935
|
+
isError,
|
|
936
|
+
isPartial: running
|
|
937
|
+
});
|
|
938
|
+
return /* @__PURE__ */ jsx(MessageItem, {
|
|
939
|
+
tone: toolTone({
|
|
940
|
+
running,
|
|
941
|
+
isError
|
|
942
|
+
}),
|
|
943
|
+
expandable: full.length > collapsed.length,
|
|
944
|
+
children: ({ expanded }) => /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(MessageItemHeader, {
|
|
945
|
+
title: toolName,
|
|
946
|
+
children: /* @__PURE__ */ jsxs("span", {
|
|
947
|
+
"data-testid": `tool-call-${toolName}`,
|
|
948
|
+
className: "flex min-w-0 flex-1 items-center gap-2",
|
|
949
|
+
children: [
|
|
950
|
+
/* @__PURE__ */ jsx("span", {
|
|
951
|
+
className: "font-semibold text-doom-hi",
|
|
952
|
+
children: summary.action
|
|
953
|
+
}),
|
|
954
|
+
summary.target ? /* @__PURE__ */ jsx("span", {
|
|
955
|
+
className: "min-w-0 truncate text-doom-dim",
|
|
956
|
+
children: summary.target
|
|
957
|
+
}) : null,
|
|
958
|
+
summary.metadata.map((value) => /* @__PURE__ */ jsxs("span", {
|
|
959
|
+
className: "shrink-0 text-doom-dim",
|
|
960
|
+
children: ["· ", value]
|
|
961
|
+
}, value))
|
|
962
|
+
]
|
|
963
|
+
})
|
|
964
|
+
}), (expanded ? full : collapsed).length > 0 ? /* @__PURE__ */ jsx(MessageItemBody, {
|
|
965
|
+
"data-testid": `tool-result-${toolName}`,
|
|
966
|
+
children: /* @__PURE__ */ jsx(MessageLines, { lines: expanded ? full : collapsed })
|
|
967
|
+
}) : null] })
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
//#endregion
|
|
971
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_lib/index.ts
|
|
972
|
+
const WORKFLOWS_GROUP = {
|
|
973
|
+
key: "w",
|
|
974
|
+
label: "workflows",
|
|
975
|
+
detail: "multi-step agent runs"
|
|
976
|
+
};
|
|
977
|
+
var _lib_default = {
|
|
978
|
+
channels: [workflowRunsChannel, workflowCatalogChannel],
|
|
979
|
+
contextActions: [{
|
|
980
|
+
id: "launch-workflow",
|
|
981
|
+
label: "Launch Workflow",
|
|
982
|
+
detail: "Choose a workflow, review its inputs, then launch it.",
|
|
983
|
+
kinds: ["work-item"],
|
|
984
|
+
order: 20,
|
|
985
|
+
run: (context) => openWorkflowCatalogForContext(context)
|
|
986
|
+
}],
|
|
987
|
+
minorModes: [{
|
|
988
|
+
name: "workflow",
|
|
989
|
+
keys: "w e",
|
|
990
|
+
widgetKey: "workflow-mcp-progress",
|
|
991
|
+
order: 50
|
|
992
|
+
}],
|
|
993
|
+
activityGroups: [{
|
|
994
|
+
name: "workflows",
|
|
995
|
+
keys: "w r",
|
|
996
|
+
icon: WorkflowIcon,
|
|
997
|
+
widgetKeys: ["workflow-mcp-progress", "workflow-mcp-follow"],
|
|
998
|
+
activeSource: {
|
|
999
|
+
subscribe(listener) {
|
|
1000
|
+
const subscription = workflows.store.subscribe(listener);
|
|
1001
|
+
return () => subscription.unsubscribe();
|
|
1002
|
+
},
|
|
1003
|
+
isActive(sessionId) {
|
|
1004
|
+
return workflows.select(workflows.store.state, sessionId).runs.some((run) => run.stage === "running");
|
|
1005
|
+
}
|
|
1006
|
+
},
|
|
1007
|
+
order: 30
|
|
1008
|
+
}],
|
|
1009
|
+
activitySections: [{
|
|
1010
|
+
id: "workflows",
|
|
1011
|
+
component: WorkflowsActivitySection
|
|
1012
|
+
}],
|
|
1013
|
+
toolRenderers: [{
|
|
1014
|
+
tools: [
|
|
1015
|
+
"list_workflows",
|
|
1016
|
+
"launch_workflow",
|
|
1017
|
+
"workflow_run",
|
|
1018
|
+
"workflow_tools"
|
|
1019
|
+
],
|
|
1020
|
+
message: WorkflowToolMessage
|
|
1021
|
+
}],
|
|
1022
|
+
leaderBindings: [{
|
|
1023
|
+
id: "doom-workflow.catalog",
|
|
1024
|
+
path: [WORKFLOWS_GROUP, {
|
|
1025
|
+
key: "l",
|
|
1026
|
+
label: "launch",
|
|
1027
|
+
detail: "pick a workflow and launch it"
|
|
1028
|
+
}],
|
|
1029
|
+
run: (context) => {
|
|
1030
|
+
if (context.sessionId !== null) openCatalog(context.sessionId);
|
|
1031
|
+
}
|
|
1032
|
+
}, {
|
|
1033
|
+
id: "doom-workflow.toggle",
|
|
1034
|
+
path: [WORKFLOWS_GROUP, {
|
|
1035
|
+
key: "e",
|
|
1036
|
+
label: "toggle",
|
|
1037
|
+
detail: "give the agent workflow tools or take them back"
|
|
1038
|
+
}],
|
|
1039
|
+
command: "minor workflow"
|
|
1040
|
+
}]
|
|
1041
|
+
};
|
|
1042
|
+
//#endregion
|
|
1043
|
+
//#region src/extensions/workspaces/sessions/(frontend)/action/launch-workflow.web.ts
|
|
1044
|
+
var launch_workflow_web_default = defineContextAction(_lib_default.contextActions[0]);
|
|
1045
|
+
//#endregion
|
|
1046
|
+
//#region src/extensions/workspaces/sessions/(frontend)/activity-group/workflows.web.ts
|
|
1047
|
+
var workflows_web_default = defineActivityGroup(_lib_default.activityGroups[0]);
|
|
1048
|
+
//#endregion
|
|
1049
|
+
//#region src/extensions/workspaces/sessions/(frontend)/channel/workflow-catalog.web.ts
|
|
1050
|
+
var workflow_catalog_web_default = defineChannelFile(_lib_default.channels[1]);
|
|
1051
|
+
//#endregion
|
|
1052
|
+
//#region src/extensions/workspaces/sessions/(frontend)/channel/workflow-runs.web.ts
|
|
1053
|
+
var workflow_runs_web_default = defineChannelFile(_lib_default.channels[0]);
|
|
1054
|
+
//#endregion
|
|
1055
|
+
//#region src/types/apiRoutes.ts
|
|
1056
|
+
/**
|
|
1057
|
+
* This package's routes, as data.
|
|
1058
|
+
*
|
|
1059
|
+
* No scope and no base path. The build reads both off
|
|
1060
|
+
* `src/extensions/(backend)/api/workflow/`, so the mount is stated once, by the
|
|
1061
|
+
* folder that creates it. The same API answers at all three scopes, because a
|
|
1062
|
+
* global contribution cascades into workspace and session; the folder under
|
|
1063
|
+
* `workspaces/sessions/` only re-mounts it for a session's own server.
|
|
1064
|
+
*
|
|
1065
|
+
* This is the one file the routes, the Hono app and the browser all read, so a
|
|
1066
|
+
* path cannot move on one side alone. The page used to build the hub's own
|
|
1067
|
+
* absolute URL and then rewrite its plugin prefix with String.replace to reach
|
|
1068
|
+
* a session, which is exactly the kind of copy that can move by one character
|
|
1069
|
+
* without anything noticing.
|
|
1070
|
+
*
|
|
1071
|
+
* `:workspace` and `:runKey` are Hono's own parameter syntax, because the app
|
|
1072
|
+
* registers `path` verbatim.
|
|
1073
|
+
*/
|
|
1074
|
+
/** One run, which every route below addresses. */
|
|
1075
|
+
const RUN = "/runs/:workspace/:runKey";
|
|
1076
|
+
/**
|
|
1077
|
+
* The artifact route's parameter, as Hono spells a multi-segment match.
|
|
1078
|
+
*
|
|
1079
|
+
* An artifact is named by its path inside the run directory, so `reports/a.md`
|
|
1080
|
+
* is one parameter across two segments. The client's `params` substitution
|
|
1081
|
+
* fills a single segment and percent-encodes the slash with it, so the page
|
|
1082
|
+
* substitutes this one itself; see the page's `_lib/terminalApi.ts`.
|
|
1083
|
+
*/
|
|
1084
|
+
const ARTIFACT_NAME_PARAM = ":name{.+}";
|
|
1085
|
+
/**
|
|
1086
|
+
* The step whose pane a terminal route reads: a step ref's id. Absent, the
|
|
1087
|
+
* route follows the run's current step, which is one of a parallel group's.
|
|
1088
|
+
*/
|
|
1089
|
+
const STEP_TARGET_PARAM = "step";
|
|
1090
|
+
const ARTIFACT_DOWNLOAD_PARAM = "download";
|
|
1091
|
+
var apiRoutes_default = defineApiRoutes({
|
|
1092
|
+
/**
|
|
1093
|
+
* The terminal, as server-sent events.
|
|
1094
|
+
*
|
|
1095
|
+
* `stream: true` gets a URL and no call method: the consumer is the browser's
|
|
1096
|
+
* own `EventSource`, and a relayed call would buffer a stream that never ends.
|
|
1097
|
+
*/
|
|
1098
|
+
screen: {
|
|
1099
|
+
method: "GET",
|
|
1100
|
+
path: `${RUN}/screen/stream`,
|
|
1101
|
+
query: [STEP_TARGET_PARAM],
|
|
1102
|
+
stream: true
|
|
1103
|
+
},
|
|
1104
|
+
control: {
|
|
1105
|
+
method: "POST",
|
|
1106
|
+
path: `${RUN}/control`,
|
|
1107
|
+
response: apiResponse()
|
|
1108
|
+
},
|
|
1109
|
+
keys: {
|
|
1110
|
+
method: "POST",
|
|
1111
|
+
path: `${RUN}/keys`
|
|
1112
|
+
},
|
|
1113
|
+
resize: {
|
|
1114
|
+
method: "POST",
|
|
1115
|
+
path: `${RUN}/resize`,
|
|
1116
|
+
response: apiResponse()
|
|
1117
|
+
},
|
|
1118
|
+
remove: {
|
|
1119
|
+
method: "DELETE",
|
|
1120
|
+
path: RUN,
|
|
1121
|
+
response: apiResponse()
|
|
1122
|
+
},
|
|
1123
|
+
stop: {
|
|
1124
|
+
method: "POST",
|
|
1125
|
+
path: `${RUN}/stop`,
|
|
1126
|
+
response: apiResponse()
|
|
1127
|
+
},
|
|
1128
|
+
/** Guidance for the agent session the running step is in, like steering a Team sub-agent. */
|
|
1129
|
+
steer: {
|
|
1130
|
+
method: "POST",
|
|
1131
|
+
path: `${RUN}/steer`,
|
|
1132
|
+
response: apiResponse()
|
|
1133
|
+
},
|
|
1134
|
+
/**
|
|
1135
|
+
* Launch a workflow into the mounting session's own runtime, which then owns
|
|
1136
|
+
* the run. A dispatcher session reaches its root's session mount through this.
|
|
1137
|
+
*/
|
|
1138
|
+
launch: {
|
|
1139
|
+
method: "POST",
|
|
1140
|
+
path: "/launch",
|
|
1141
|
+
response: apiResponse()
|
|
1142
|
+
},
|
|
1143
|
+
openRunDirectory: {
|
|
1144
|
+
method: "POST",
|
|
1145
|
+
path: `${RUN}/open-directory`,
|
|
1146
|
+
response: apiResponse()
|
|
1147
|
+
},
|
|
1148
|
+
artifacts: {
|
|
1149
|
+
method: "GET",
|
|
1150
|
+
path: `${RUN}/artifacts`,
|
|
1151
|
+
query: ["directory"],
|
|
1152
|
+
response: apiResponse()
|
|
1153
|
+
},
|
|
1154
|
+
/** One artifact: its metadata and text, or its raw bytes under `raw=1`. */
|
|
1155
|
+
artifact: {
|
|
1156
|
+
method: "GET",
|
|
1157
|
+
path: `${RUN}/artifacts/${ARTIFACT_NAME_PARAM}`,
|
|
1158
|
+
query: ["raw", ARTIFACT_DOWNLOAD_PARAM],
|
|
1159
|
+
response: apiResponse()
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
const api = createApiClient(apiRoutes_default, {
|
|
1163
|
+
scopes: [
|
|
1164
|
+
"global",
|
|
1165
|
+
"workspace",
|
|
1166
|
+
"session"
|
|
1167
|
+
],
|
|
1168
|
+
basePath: "workflow",
|
|
1169
|
+
transport: (input, init) => sealedTransport.fetch(input, init),
|
|
1170
|
+
sessionAddress: sessionApiAddress
|
|
1171
|
+
});
|
|
1172
|
+
//#endregion
|
|
1173
|
+
//#region src/types/webWorkflowTerminal.ts
|
|
1174
|
+
/** The SSE event name the screen stream writes. */
|
|
1175
|
+
const WORKFLOW_SCREEN_EVENT = "screen";
|
|
1176
|
+
//#endregion
|
|
1177
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_lib/terminalApi.ts
|
|
1178
|
+
/**
|
|
1179
|
+
* The page's half of this package's hub API: one run's terminal and the files
|
|
1180
|
+
* its run directory holds.
|
|
1181
|
+
*
|
|
1182
|
+
* These are adapters now, not a transport. The generated client owns the URL
|
|
1183
|
+
* and the sealed transport with it, so nothing here spells a mount. What stays
|
|
1184
|
+
* is this package's own vocabulary: which refusals the reader is shown, and the
|
|
1185
|
+
* shapes the terminal expects back.
|
|
1186
|
+
*
|
|
1187
|
+
* A run belongs to its multiplexer rather than to a session, so the same API
|
|
1188
|
+
* answers at the hub and inside a session's own server. Passing a session id
|
|
1189
|
+
* addresses that session's copy and passing none addresses the hub. The deleted
|
|
1190
|
+
* builder made that branch the other way round: it always built the hub's
|
|
1191
|
+
* absolute URL, then rewrote the plugin prefix into the session's with
|
|
1192
|
+
* String.replace, so a session's URL was a hub URL with its head swapped.
|
|
1193
|
+
*/
|
|
1194
|
+
const UNREACHABLE = "The cockpit hub is unreachable.";
|
|
1195
|
+
/** The hub, or one session's own copy of it; a null session id is the hub. */
|
|
1196
|
+
function scoped(sessionId) {
|
|
1197
|
+
return sessionId == null ? api.global : api.session(sessionId);
|
|
1198
|
+
}
|
|
1199
|
+
/** The run every route addresses, as the route table's two path parameters. */
|
|
1200
|
+
function runParams(workspace, runKey) {
|
|
1201
|
+
return {
|
|
1202
|
+
workspace,
|
|
1203
|
+
runKey
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
function isRecord(value) {
|
|
1207
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* The message a reader is shown: the route's own words, or why there were none.
|
|
1211
|
+
*
|
|
1212
|
+
* `error` is optional because an answered call that simply carried the wrong
|
|
1213
|
+
* shape reaches here too, and that result has no error field at all.
|
|
1214
|
+
*/
|
|
1215
|
+
function messageOf(result, fallback) {
|
|
1216
|
+
if (result.status === 0) return UNREACHABLE;
|
|
1217
|
+
return result.error === void 0 || result.error === "" ? fallback : result.error;
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* One artifact's URL, with its path filled in.
|
|
1221
|
+
*
|
|
1222
|
+
* An artifact is named by its path inside the run directory, which Hono matches
|
|
1223
|
+
* as the one multi-segment parameter `:name{.+}`. The client's own `params`
|
|
1224
|
+
* substitution fills a single segment and percent-encodes the separating slash
|
|
1225
|
+
* with it, which would move every nested artifact's URL, so that one segment is
|
|
1226
|
+
* substituted here instead: each segment encoded, the slashes between them
|
|
1227
|
+
* kept. Everything left of it still comes from the client.
|
|
1228
|
+
*/
|
|
1229
|
+
function artifactUrl(scope, workspace, runKey, artifactPath, query) {
|
|
1230
|
+
return scope.artifact.url({
|
|
1231
|
+
params: runParams(workspace, runKey),
|
|
1232
|
+
...query === void 0 ? {} : { query }
|
|
1233
|
+
}).replace(ARTIFACT_NAME_PARAM, artifactPath.split("/").map(encodeURIComponent).join("/"));
|
|
1234
|
+
}
|
|
1235
|
+
/** The pane a terminal call names, when it reads one step's rather than the run's current one. */
|
|
1236
|
+
function stepBody(step) {
|
|
1237
|
+
return step === void 0 ? {} : { step };
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Follows one run's screen until the run settles or the caller stops.
|
|
1241
|
+
*
|
|
1242
|
+
* Returns the stop function. The stream is server-sent events, so a hub that
|
|
1243
|
+
* restarts reconnects on its own and the page keeps painting. The route is
|
|
1244
|
+
* declared `stream: true`, which is why the client hands out its address and no
|
|
1245
|
+
* call: an `EventSource` opens it directly, and a relayed call would buffer a
|
|
1246
|
+
* response that never ends.
|
|
1247
|
+
*/
|
|
1248
|
+
function followScreen(workspace, runKey, onEvent, sessionId, step) {
|
|
1249
|
+
const source = new EventSource(scoped(sessionId).screen.url({
|
|
1250
|
+
params: runParams(workspace, runKey),
|
|
1251
|
+
...step === void 0 ? {} : { query: { [STEP_TARGET_PARAM]: step } }
|
|
1252
|
+
}));
|
|
1253
|
+
const stop = () => {
|
|
1254
|
+
source.removeEventListener(WORKFLOW_SCREEN_EVENT, handler);
|
|
1255
|
+
source.close();
|
|
1256
|
+
};
|
|
1257
|
+
const handler = (message) => {
|
|
1258
|
+
let parsed;
|
|
1259
|
+
try {
|
|
1260
|
+
parsed = JSON.parse(message.data);
|
|
1261
|
+
} catch {
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.lines)) return;
|
|
1265
|
+
const event = parsed;
|
|
1266
|
+
onEvent(event);
|
|
1267
|
+
if (event.ended === true) stop();
|
|
1268
|
+
};
|
|
1269
|
+
source.addEventListener(WORKFLOW_SCREEN_EVENT, handler);
|
|
1270
|
+
return stop;
|
|
1271
|
+
}
|
|
1272
|
+
/** Takes the keyboard for one run, renewing when this page already holds it. */
|
|
1273
|
+
async function takeControl(workspace, runKey, token, sessionId, step) {
|
|
1274
|
+
const result = await scoped(sessionId).control({
|
|
1275
|
+
params: runParams(workspace, runKey),
|
|
1276
|
+
body: {
|
|
1277
|
+
token,
|
|
1278
|
+
...stepBody(step)
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
if (result.status === 0) return {
|
|
1282
|
+
held: false,
|
|
1283
|
+
reason: UNREACHABLE
|
|
1284
|
+
};
|
|
1285
|
+
if (isRecord(result.data) && typeof result.data.held === "boolean") return result.data;
|
|
1286
|
+
return {
|
|
1287
|
+
held: false,
|
|
1288
|
+
reason: messageOf(result, "The run refused the keyboard.")
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
async function releaseControl(workspace, runKey, token, sessionId, step) {
|
|
1292
|
+
await scoped(sessionId).control({
|
|
1293
|
+
params: runParams(workspace, runKey),
|
|
1294
|
+
body: {
|
|
1295
|
+
token,
|
|
1296
|
+
release: true,
|
|
1297
|
+
...stepBody(step)
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
/** Sends literal keystrokes; the reason comes back when the lease has moved on. */
|
|
1302
|
+
async function sendKeys(workspace, runKey, token, data, sessionId, step) {
|
|
1303
|
+
const result = await scoped(sessionId).keys({
|
|
1304
|
+
params: runParams(workspace, runKey),
|
|
1305
|
+
body: {
|
|
1306
|
+
token,
|
|
1307
|
+
data,
|
|
1308
|
+
...stepBody(step)
|
|
1309
|
+
}
|
|
1310
|
+
});
|
|
1311
|
+
if (result.ok) return {};
|
|
1312
|
+
return { error: messageOf(result, "The run would not take those keys.") };
|
|
1313
|
+
}
|
|
1314
|
+
/** Permanently removes one settled run and its run directory. */
|
|
1315
|
+
async function deleteWorkflowRun(workspace, runKey, sessionId) {
|
|
1316
|
+
const result = await scoped(sessionId).remove({ params: runParams(workspace, runKey) });
|
|
1317
|
+
if (!result.ok) return { error: messageOf(result, "The workflow could not be deleted.") };
|
|
1318
|
+
if (result.data.deleted === true) return { result: { deleted: true } };
|
|
1319
|
+
return { error: "The workflow hub returned an invalid deletion response." };
|
|
1320
|
+
}
|
|
1321
|
+
/** Asks the engine running a workflow to stop it; the run settles on its own shortly after. */
|
|
1322
|
+
async function stopWorkflowRun(workspace, runKey, sessionId) {
|
|
1323
|
+
const result = await scoped(sessionId).stop({ params: runParams(workspace, runKey) });
|
|
1324
|
+
if (!result.ok) return { error: messageOf(result, "The workflow could not be stopped.") };
|
|
1325
|
+
if (result.data.requested === true) return { result: { requested: true } };
|
|
1326
|
+
return { error: "The workflow hub returned an invalid stop response." };
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Sends guidance to the agent session a running step is in: the run's current
|
|
1330
|
+
* step, or `step`, the session of one step of a parallel group.
|
|
1331
|
+
*/
|
|
1332
|
+
async function steerStep(workspace, runKey, message, sessionId, step) {
|
|
1333
|
+
const result = await scoped(sessionId).steer({
|
|
1334
|
+
params: runParams(workspace, runKey),
|
|
1335
|
+
body: step === void 0 ? { message } : {
|
|
1336
|
+
message,
|
|
1337
|
+
step
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
if (!result.ok) return { error: messageOf(result, "The step would not take that guidance.") };
|
|
1341
|
+
if (result.data.delivered === true) return { result: { delivered: true } };
|
|
1342
|
+
return { error: "The workflow hub returned an invalid steer response." };
|
|
1343
|
+
}
|
|
1344
|
+
/** Opens the current registry folder, never a browser-supplied path. */
|
|
1345
|
+
async function openRunDirectory(workspace, runKey, sessionId) {
|
|
1346
|
+
const result = await scoped(sessionId).openRunDirectory({ params: runParams(workspace, runKey) });
|
|
1347
|
+
if (!result.ok) return { error: messageOf(result, "The run folder could not be opened in Finder.") };
|
|
1348
|
+
return result.data.opened === true ? {} : { error: "The workflow hub returned an invalid folder response." };
|
|
1349
|
+
}
|
|
1350
|
+
async function fetchArtifacts(workspace, runKey, sessionId, directory) {
|
|
1351
|
+
const result = await scoped(sessionId).artifacts({
|
|
1352
|
+
params: runParams(workspace, runKey),
|
|
1353
|
+
query: { directory }
|
|
1354
|
+
});
|
|
1355
|
+
if (!result.ok) return { error: messageOf(result, "This run has no directory to read.") };
|
|
1356
|
+
return { artifacts: result.data };
|
|
1357
|
+
}
|
|
1358
|
+
/** Trusted same-origin URL for browser-native media previews and downloads. */
|
|
1359
|
+
function artifactContentUrl(workspace, runKey, path, download = false, sessionId) {
|
|
1360
|
+
return artifactUrl(scoped(sessionId), workspace, runKey, path, {
|
|
1361
|
+
["raw"]: 1,
|
|
1362
|
+
...download ? { [ARTIFACT_DOWNLOAD_PARAM]: 1 } : {}
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* One artifact's metadata and text.
|
|
1367
|
+
*
|
|
1368
|
+
* The only route here that addresses itself rather than being called: its path
|
|
1369
|
+
* parameter spans segments, so the URL comes from `artifactUrl` and the request
|
|
1370
|
+
* goes over the same sealed transport the client is built on.
|
|
1371
|
+
*/
|
|
1372
|
+
async function fetchArtifact(workspace, runKey, path, sessionId) {
|
|
1373
|
+
try {
|
|
1374
|
+
const response = await sealedTransport.fetch(artifactUrl(scoped(sessionId), workspace, runKey, path));
|
|
1375
|
+
const body = await response.json();
|
|
1376
|
+
if (!response.ok) {
|
|
1377
|
+
const error = isRecord(body) && typeof body.error === "string" && body.error !== "" ? body.error : "";
|
|
1378
|
+
return { error: error === "" ? "That file has not been written." : error };
|
|
1379
|
+
}
|
|
1380
|
+
return { artifact: body };
|
|
1381
|
+
} catch {
|
|
1382
|
+
return { error: UNREACHABLE };
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
//#endregion
|
|
1386
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_components/ArtifactsPane.tsx
|
|
1387
|
+
const TAB_ID_PREFIX$1 = "workflows-artifact-";
|
|
1388
|
+
const BYTES_PER_UNIT = 1024;
|
|
1389
|
+
const UNITS = [
|
|
1390
|
+
"B",
|
|
1391
|
+
"KB",
|
|
1392
|
+
"MB"
|
|
1393
|
+
];
|
|
1394
|
+
function formatSize(bytes) {
|
|
1395
|
+
if (bytes === void 0) return "";
|
|
1396
|
+
let value = bytes;
|
|
1397
|
+
let unit = 0;
|
|
1398
|
+
while (value >= BYTES_PER_UNIT && unit < UNITS.length - 1) {
|
|
1399
|
+
value /= BYTES_PER_UNIT;
|
|
1400
|
+
unit += 1;
|
|
1401
|
+
}
|
|
1402
|
+
return `${unit === 0 ? value : value.toFixed(1)} ${UNITS[unit]}`;
|
|
1403
|
+
}
|
|
1404
|
+
function formatWhen(iso) {
|
|
1405
|
+
if (iso === void 0) return "";
|
|
1406
|
+
const parsed = Date.parse(iso);
|
|
1407
|
+
if (!Number.isFinite(parsed)) return "";
|
|
1408
|
+
return new Date(parsed).toLocaleTimeString([], {
|
|
1409
|
+
hour: "2-digit",
|
|
1410
|
+
minute: "2-digit"
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
const STATE_GLYPH = {
|
|
1414
|
+
written: {
|
|
1415
|
+
glyph: "✓",
|
|
1416
|
+
className: "text-doom-green"
|
|
1417
|
+
},
|
|
1418
|
+
empty: {
|
|
1419
|
+
glyph: "○",
|
|
1420
|
+
className: "text-doom-yellow"
|
|
1421
|
+
},
|
|
1422
|
+
pending: {
|
|
1423
|
+
glyph: "○",
|
|
1424
|
+
className: "text-doom-faint"
|
|
1425
|
+
},
|
|
1426
|
+
unreadable: {
|
|
1427
|
+
glyph: "!",
|
|
1428
|
+
className: "text-doom-red"
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
function parseDelimited(text, delimiter) {
|
|
1432
|
+
const rows = [];
|
|
1433
|
+
let row = [];
|
|
1434
|
+
let cell = "";
|
|
1435
|
+
let quoted = false;
|
|
1436
|
+
for (let index = 0; index <= text.length && rows.length < 500; index += 1) {
|
|
1437
|
+
const character = text[index] ?? "\n";
|
|
1438
|
+
if (quoted) {
|
|
1439
|
+
if (character === "\"" && text[index + 1] === "\"") {
|
|
1440
|
+
cell += "\"";
|
|
1441
|
+
index += 1;
|
|
1442
|
+
} else if (character === "\"") quoted = false;
|
|
1443
|
+
else cell += character;
|
|
1444
|
+
} else if (character === "\"" && cell === "") quoted = true;
|
|
1445
|
+
else if (character === delimiter) {
|
|
1446
|
+
row.push(cell);
|
|
1447
|
+
cell = "";
|
|
1448
|
+
} else if (character === "\n") {
|
|
1449
|
+
row.push(cell.replace(/\r$/, ""));
|
|
1450
|
+
rows.push(row);
|
|
1451
|
+
row = [];
|
|
1452
|
+
cell = "";
|
|
1453
|
+
} else cell += character;
|
|
1454
|
+
}
|
|
1455
|
+
return rows;
|
|
1456
|
+
}
|
|
1457
|
+
function DelimitedPreview({ text, delimiter }) {
|
|
1458
|
+
const [head = [], ...body] = parseDelimited(text, delimiter);
|
|
1459
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1460
|
+
className: "overflow-auto",
|
|
1461
|
+
children: /* @__PURE__ */ jsxs("table", {
|
|
1462
|
+
"data-testid": "artifact-table",
|
|
1463
|
+
className: "min-w-full border-collapse font-mono text-sm",
|
|
1464
|
+
children: [/* @__PURE__ */ jsx("thead", {
|
|
1465
|
+
className: "sticky top-0 bg-doom-panel text-doom-hi",
|
|
1466
|
+
children: /* @__PURE__ */ jsx("tr", { children: head.map((cell, index) => /* @__PURE__ */ jsx("th", {
|
|
1467
|
+
className: "border border-doom-border px-2 py-1.5 text-left font-semibold",
|
|
1468
|
+
children: cell
|
|
1469
|
+
}, index)) })
|
|
1470
|
+
}), /* @__PURE__ */ jsx("tbody", { children: body.map((cells, rowIndex) => /* @__PURE__ */ jsx("tr", { children: cells.map((cell, cellIndex) => /* @__PURE__ */ jsx("td", {
|
|
1471
|
+
className: "border border-doom-border px-2 py-1 align-top text-doom-text",
|
|
1472
|
+
children: cell
|
|
1473
|
+
}, cellIndex)) }, rowIndex)) })]
|
|
1474
|
+
})
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
/** Keeps a newly built viewer compatible while an older hub process is restarting. */
|
|
1478
|
+
function previewMimeType(content) {
|
|
1479
|
+
if (content.mimeType !== void 0) return content.mimeType;
|
|
1480
|
+
const extension = content.path.split(".").pop()?.toLowerCase();
|
|
1481
|
+
if (extension === "md" || extension === "markdown") return "text/markdown";
|
|
1482
|
+
if (extension === "json") return "application/json";
|
|
1483
|
+
if (extension === "csv") return "text/csv";
|
|
1484
|
+
if (extension === "tsv") return "text/tab-separated-values";
|
|
1485
|
+
if (extension === "html" || extension === "htm") return "text/html";
|
|
1486
|
+
if (extension === "pdf") return "application/pdf";
|
|
1487
|
+
if ([
|
|
1488
|
+
"bmp",
|
|
1489
|
+
"gif",
|
|
1490
|
+
"ico",
|
|
1491
|
+
"jpeg",
|
|
1492
|
+
"jpg",
|
|
1493
|
+
"png",
|
|
1494
|
+
"svg",
|
|
1495
|
+
"webp"
|
|
1496
|
+
].includes(extension ?? "")) return "image/unknown";
|
|
1497
|
+
if ([
|
|
1498
|
+
"aac",
|
|
1499
|
+
"m4a",
|
|
1500
|
+
"mp3",
|
|
1501
|
+
"oga",
|
|
1502
|
+
"ogg",
|
|
1503
|
+
"wav"
|
|
1504
|
+
].includes(extension ?? "")) return "audio/unknown";
|
|
1505
|
+
if ([
|
|
1506
|
+
"mov",
|
|
1507
|
+
"mp4",
|
|
1508
|
+
"ogv",
|
|
1509
|
+
"webm"
|
|
1510
|
+
].includes(extension ?? "")) return "video/unknown";
|
|
1511
|
+
return content.text === void 0 ? "application/octet-stream" : "text/plain";
|
|
1512
|
+
}
|
|
1513
|
+
function ArtifactPreview({ content, rawUrl, raw }) {
|
|
1514
|
+
const text = content.text ?? "";
|
|
1515
|
+
const mimeType = previewMimeType(content);
|
|
1516
|
+
if (content.size === 0) return /* @__PURE__ */ jsx(EmptyState, {
|
|
1517
|
+
"data-testid": "artifact-empty",
|
|
1518
|
+
title: "this artifact is empty",
|
|
1519
|
+
description: "The workflow created the file but did not write any content."
|
|
1520
|
+
});
|
|
1521
|
+
if (mimeType === "text/markdown") return raw ? /* @__PURE__ */ jsx("pre", {
|
|
1522
|
+
"data-testid": "artifact-raw",
|
|
1523
|
+
className: "whitespace-pre-wrap break-words font-mono text-sm leading-snug text-doom-text",
|
|
1524
|
+
children: text
|
|
1525
|
+
}) : /* @__PURE__ */ jsx("div", {
|
|
1526
|
+
"data-testid": "artifact-markdown",
|
|
1527
|
+
className: "max-w-5xl text-base text-doom-text",
|
|
1528
|
+
children: /* @__PURE__ */ jsx(Markdown, { text })
|
|
1529
|
+
});
|
|
1530
|
+
if (mimeType === "application/json") {
|
|
1531
|
+
let formatted = text;
|
|
1532
|
+
try {
|
|
1533
|
+
formatted = JSON.stringify(JSON.parse(text), null, 2);
|
|
1534
|
+
} catch {}
|
|
1535
|
+
return /* @__PURE__ */ jsx("pre", {
|
|
1536
|
+
className: "whitespace-pre-wrap break-words font-mono text-sm leading-snug text-doom-text",
|
|
1537
|
+
children: formatted
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
if (mimeType === "text/csv" || mimeType === "text/tab-separated-values") return /* @__PURE__ */ jsx(DelimitedPreview, {
|
|
1541
|
+
text,
|
|
1542
|
+
delimiter: mimeType === "text/csv" ? "," : " "
|
|
1543
|
+
});
|
|
1544
|
+
if (mimeType === "text/html") return /* @__PURE__ */ jsx("iframe", {
|
|
1545
|
+
"data-testid": "artifact-html",
|
|
1546
|
+
title: content.path,
|
|
1547
|
+
sandbox: "",
|
|
1548
|
+
referrerPolicy: "no-referrer",
|
|
1549
|
+
srcDoc: text,
|
|
1550
|
+
className: "min-h-[520px] w-full rounded bg-white"
|
|
1551
|
+
});
|
|
1552
|
+
if (mimeType.startsWith("image/")) return /* @__PURE__ */ jsx("img", {
|
|
1553
|
+
"data-testid": "artifact-image",
|
|
1554
|
+
src: rawUrl,
|
|
1555
|
+
alt: content.path,
|
|
1556
|
+
className: "max-h-full max-w-full object-contain"
|
|
1557
|
+
});
|
|
1558
|
+
if (mimeType === "application/pdf") return /* @__PURE__ */ jsx("object", {
|
|
1559
|
+
"data-testid": "artifact-pdf",
|
|
1560
|
+
data: rawUrl,
|
|
1561
|
+
type: "application/pdf",
|
|
1562
|
+
"aria-label": "PDF artifact",
|
|
1563
|
+
className: "min-h-[600px] w-full"
|
|
1564
|
+
});
|
|
1565
|
+
if (mimeType.startsWith("audio/")) return /* @__PURE__ */ jsx("audio", {
|
|
1566
|
+
"data-testid": "artifact-audio",
|
|
1567
|
+
src: rawUrl,
|
|
1568
|
+
controls: true,
|
|
1569
|
+
preload: "metadata",
|
|
1570
|
+
className: "w-full",
|
|
1571
|
+
children: /* @__PURE__ */ jsx("track", { kind: "captions" })
|
|
1572
|
+
});
|
|
1573
|
+
if (mimeType.startsWith("video/")) return /* @__PURE__ */ jsx("video", {
|
|
1574
|
+
"data-testid": "artifact-video",
|
|
1575
|
+
src: rawUrl,
|
|
1576
|
+
controls: true,
|
|
1577
|
+
preload: "metadata",
|
|
1578
|
+
className: "max-h-full max-w-full",
|
|
1579
|
+
children: /* @__PURE__ */ jsx("track", { kind: "captions" })
|
|
1580
|
+
});
|
|
1581
|
+
if (content.text !== void 0) return /* @__PURE__ */ jsx("pre", {
|
|
1582
|
+
className: "whitespace-pre-wrap break-words font-mono text-sm leading-snug text-doom-text",
|
|
1583
|
+
children: text
|
|
1584
|
+
});
|
|
1585
|
+
return /* @__PURE__ */ jsx(EmptyState, {
|
|
1586
|
+
title: "preview unavailable",
|
|
1587
|
+
description: "Download this artifact to open it with a local application."
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
/** One artifact, read-only, in its own tab. */
|
|
1591
|
+
function artifactTab(run, path) {
|
|
1592
|
+
return {
|
|
1593
|
+
id: `${TAB_ID_PREFIX$1}${run.workspace}-${run.runKey}-${path}`.replace(/[^\w-]+/g, "-"),
|
|
1594
|
+
label: path.split("/").pop() ?? path,
|
|
1595
|
+
panel: (props) => /* @__PURE__ */ jsx(ArtifactViewerPanel, {
|
|
1596
|
+
...props,
|
|
1597
|
+
workspace: run.workspace,
|
|
1598
|
+
runKey: run.runKey,
|
|
1599
|
+
path
|
|
1600
|
+
})
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* One artifact's text.
|
|
1605
|
+
*
|
|
1606
|
+
* Read-only and reloadable: a run keeps writing while somebody is reading, and
|
|
1607
|
+
* the file on the next read is the one that matters.
|
|
1608
|
+
*/
|
|
1609
|
+
function ArtifactViewerPanel({ workspace, runKey, path, sessionId }) {
|
|
1610
|
+
const [content, setContent] = useState();
|
|
1611
|
+
const [error, setError] = useState();
|
|
1612
|
+
const [reloads, setReloads] = useState(0);
|
|
1613
|
+
const [raw, setRaw] = useState(false);
|
|
1614
|
+
useEffect(() => {
|
|
1615
|
+
let live = true;
|
|
1616
|
+
fetchArtifact(workspace, runKey, path, sessionId).then((result) => {
|
|
1617
|
+
if (!live) return;
|
|
1618
|
+
if ("error" in result) {
|
|
1619
|
+
setError(result.error);
|
|
1620
|
+
setContent(void 0);
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
setError(void 0);
|
|
1624
|
+
setContent(result.artifact);
|
|
1625
|
+
});
|
|
1626
|
+
return () => {
|
|
1627
|
+
live = false;
|
|
1628
|
+
};
|
|
1629
|
+
}, [
|
|
1630
|
+
workspace,
|
|
1631
|
+
runKey,
|
|
1632
|
+
path,
|
|
1633
|
+
reloads,
|
|
1634
|
+
sessionId
|
|
1635
|
+
]);
|
|
1636
|
+
const rawUrl = artifactContentUrl(workspace, runKey, path, false, sessionId);
|
|
1637
|
+
const downloadUrl = artifactContentUrl(workspace, runKey, path, true, sessionId);
|
|
1638
|
+
const mimeType = content === void 0 ? void 0 : previewMimeType(content);
|
|
1639
|
+
const isMarkdown = mimeType === "text/markdown";
|
|
1640
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1641
|
+
"data-testid": "artifact-viewer",
|
|
1642
|
+
className: "flex min-h-0 flex-1 flex-col px-[26px] py-[18px]",
|
|
1643
|
+
children: [
|
|
1644
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1645
|
+
className: "flex items-center gap-2.5 pb-3",
|
|
1646
|
+
children: [
|
|
1647
|
+
/* @__PURE__ */ jsx("span", {
|
|
1648
|
+
className: "truncate text-base font-semibold text-doom-hi",
|
|
1649
|
+
children: path
|
|
1650
|
+
}),
|
|
1651
|
+
/* @__PURE__ */ jsx(Badge, {
|
|
1652
|
+
size: "xs",
|
|
1653
|
+
children: mimeType ?? "artifact"
|
|
1654
|
+
}),
|
|
1655
|
+
/* @__PURE__ */ jsx("span", {
|
|
1656
|
+
className: "text-sm text-doom-dim",
|
|
1657
|
+
children: content === void 0 ? "" : `${formatSize(content.size)} · written ${formatWhen(content.modifiedAt)}`
|
|
1658
|
+
}),
|
|
1659
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
|
|
1660
|
+
isMarkdown ? /* @__PURE__ */ jsxs("div", {
|
|
1661
|
+
className: "flex items-center",
|
|
1662
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
1663
|
+
variant: raw ? "outline" : "primary",
|
|
1664
|
+
size: "xs",
|
|
1665
|
+
"data-testid": "artifact-rendered-toggle",
|
|
1666
|
+
onClick: () => setRaw(false),
|
|
1667
|
+
children: "rendered"
|
|
1668
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
1669
|
+
variant: raw ? "primary" : "outline",
|
|
1670
|
+
size: "xs",
|
|
1671
|
+
"data-testid": "artifact-raw-toggle",
|
|
1672
|
+
onClick: () => setRaw(true),
|
|
1673
|
+
children: "raw"
|
|
1674
|
+
})]
|
|
1675
|
+
}) : null,
|
|
1676
|
+
/* @__PURE__ */ jsx(Button, {
|
|
1677
|
+
asChild: true,
|
|
1678
|
+
variant: "ghost",
|
|
1679
|
+
size: "xs",
|
|
1680
|
+
children: /* @__PURE__ */ jsx("a", {
|
|
1681
|
+
"data-testid": "artifact-download",
|
|
1682
|
+
href: downloadUrl,
|
|
1683
|
+
download: true,
|
|
1684
|
+
children: "download"
|
|
1685
|
+
})
|
|
1686
|
+
}),
|
|
1687
|
+
/* @__PURE__ */ jsx(Button, {
|
|
1688
|
+
variant: "ghost",
|
|
1689
|
+
size: "xs",
|
|
1690
|
+
"data-testid": "artifact-reload",
|
|
1691
|
+
onClick: () => setReloads((count) => count + 1),
|
|
1692
|
+
children: "reload"
|
|
1693
|
+
})
|
|
1694
|
+
]
|
|
1695
|
+
}),
|
|
1696
|
+
/* @__PURE__ */ jsx("div", {
|
|
1697
|
+
className: "min-h-0 flex-1 overflow-auto rounded-md border border-doom-border bg-doom-deep p-4",
|
|
1698
|
+
children: error !== void 0 ? /* @__PURE__ */ jsx("span", {
|
|
1699
|
+
"data-testid": "artifact-error",
|
|
1700
|
+
className: "text-base text-doom-yellow",
|
|
1701
|
+
children: error
|
|
1702
|
+
}) : content === void 0 ? /* @__PURE__ */ jsx("span", {
|
|
1703
|
+
className: "text-sm text-doom-dim",
|
|
1704
|
+
children: "loading preview"
|
|
1705
|
+
}) : /* @__PURE__ */ jsx(ArtifactPreview, {
|
|
1706
|
+
content,
|
|
1707
|
+
rawUrl,
|
|
1708
|
+
raw
|
|
1709
|
+
})
|
|
1710
|
+
}),
|
|
1711
|
+
content?.truncated === true ? /* @__PURE__ */ jsxs("span", {
|
|
1712
|
+
className: "pt-2 text-sm text-doom-dim",
|
|
1713
|
+
children: [
|
|
1714
|
+
"this preview shows the first ",
|
|
1715
|
+
formatSize(content.text?.length),
|
|
1716
|
+
"; download the artifact for the complete file"
|
|
1717
|
+
]
|
|
1718
|
+
}) : null
|
|
1719
|
+
]
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
/** The run's file tree, including declared outputs that have not been written yet. */
|
|
1723
|
+
function ArtifactsPane({ run, sessionId, onOpen }) {
|
|
1724
|
+
const [listing, setListing] = useState();
|
|
1725
|
+
const [error, setError] = useState();
|
|
1726
|
+
useEffect(() => {
|
|
1727
|
+
let live = true;
|
|
1728
|
+
let reading = false;
|
|
1729
|
+
const read = () => {
|
|
1730
|
+
if (reading) return;
|
|
1731
|
+
reading = true;
|
|
1732
|
+
fetchArtifacts(run.workspace, run.runKey, sessionId).then((result) => {
|
|
1733
|
+
if (!live) return;
|
|
1734
|
+
if ("error" in result) {
|
|
1735
|
+
setError(result.error);
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
setError(void 0);
|
|
1739
|
+
setListing(result.artifacts);
|
|
1740
|
+
}).finally(() => {
|
|
1741
|
+
reading = false;
|
|
1742
|
+
});
|
|
1743
|
+
};
|
|
1744
|
+
read();
|
|
1745
|
+
const timer = run.stage === "running" ? setInterval(read, 5e3) : void 0;
|
|
1746
|
+
return () => {
|
|
1747
|
+
live = false;
|
|
1748
|
+
if (timer !== void 0) clearInterval(timer);
|
|
1749
|
+
};
|
|
1750
|
+
}, [
|
|
1751
|
+
run.workspace,
|
|
1752
|
+
run.runKey,
|
|
1753
|
+
run.stage,
|
|
1754
|
+
sessionId
|
|
1755
|
+
]);
|
|
1756
|
+
return /* @__PURE__ */ jsx(ArtifactList, {
|
|
1757
|
+
listing,
|
|
1758
|
+
error,
|
|
1759
|
+
onLoadDirectory: (directory) => fetchArtifacts(run.workspace, run.runKey, sessionId, directory),
|
|
1760
|
+
onOpen,
|
|
1761
|
+
onOpenDirectory: () => openRunDirectory(run.workspace, run.runKey, sessionId)
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
/** Directory-first file tree with declared output metadata and lazy folder reads. */
|
|
1765
|
+
function ArtifactList({ listing, error, onOpen, onOpenDirectory, onLoadDirectory }) {
|
|
1766
|
+
const [opening, setOpening] = useState(false);
|
|
1767
|
+
const [openError, setOpenError] = useState();
|
|
1768
|
+
const openDirectory = async () => {
|
|
1769
|
+
if (onOpenDirectory === void 0 || opening) return;
|
|
1770
|
+
setOpening(true);
|
|
1771
|
+
setOpenError(void 0);
|
|
1772
|
+
try {
|
|
1773
|
+
const result = await onOpenDirectory();
|
|
1774
|
+
setOpenError(result.error);
|
|
1775
|
+
} catch {
|
|
1776
|
+
setOpenError("The run folder could not be opened in Finder.");
|
|
1777
|
+
} finally {
|
|
1778
|
+
setOpening(false);
|
|
1779
|
+
}
|
|
1780
|
+
};
|
|
1781
|
+
const [expanded, setExpanded] = useState(/* @__PURE__ */ new Set());
|
|
1782
|
+
const [loaded, setLoaded] = useState({});
|
|
1783
|
+
const [folderErrors, setFolderErrors] = useState({});
|
|
1784
|
+
const [loading, setLoading] = useState(/* @__PURE__ */ new Set());
|
|
1785
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1786
|
+
for (const entry of [...Object.values(loaded).flat(), ...listing?.artifacts ?? []]) entries.set(entry.path, entry);
|
|
1787
|
+
for (const entry of entries.values()) {
|
|
1788
|
+
const parts = entry.path.split("/");
|
|
1789
|
+
parts.pop();
|
|
1790
|
+
while (parts.length > 0) {
|
|
1791
|
+
const parent = parts.join("/");
|
|
1792
|
+
if (!entries.has(parent)) entries.set(parent, {
|
|
1793
|
+
path: parent,
|
|
1794
|
+
kind: "directory",
|
|
1795
|
+
description: "",
|
|
1796
|
+
producedBy: [],
|
|
1797
|
+
declared: false,
|
|
1798
|
+
state: entry.state === "pending" ? "pending" : "written"
|
|
1799
|
+
});
|
|
1800
|
+
parts.pop();
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
const toggle = async (entry) => {
|
|
1804
|
+
const closing = expanded.has(entry.path);
|
|
1805
|
+
setExpanded((current) => {
|
|
1806
|
+
const next = new Set(current);
|
|
1807
|
+
if (closing) next.delete(entry.path);
|
|
1808
|
+
else next.add(entry.path);
|
|
1809
|
+
return next;
|
|
1810
|
+
});
|
|
1811
|
+
if (closing || loading.has(entry.path) || onLoadDirectory === void 0 || entry.state === "pending") return;
|
|
1812
|
+
setLoading((current) => new Set(current).add(entry.path));
|
|
1813
|
+
try {
|
|
1814
|
+
const result = await onLoadDirectory(entry.path);
|
|
1815
|
+
if ("error" in result) setFolderErrors((current) => ({
|
|
1816
|
+
...current,
|
|
1817
|
+
[entry.path]: result.error
|
|
1818
|
+
}));
|
|
1819
|
+
else {
|
|
1820
|
+
setLoaded((current) => ({
|
|
1821
|
+
...current,
|
|
1822
|
+
[entry.path]: result.artifacts.artifacts
|
|
1823
|
+
}));
|
|
1824
|
+
setFolderErrors((current) => ({
|
|
1825
|
+
...current,
|
|
1826
|
+
[entry.path]: ""
|
|
1827
|
+
}));
|
|
1828
|
+
}
|
|
1829
|
+
} catch {
|
|
1830
|
+
setFolderErrors((current) => ({
|
|
1831
|
+
...current,
|
|
1832
|
+
[entry.path]: "The folder cannot be read."
|
|
1833
|
+
}));
|
|
1834
|
+
} finally {
|
|
1835
|
+
setLoading((current) => {
|
|
1836
|
+
const next = new Set(current);
|
|
1837
|
+
next.delete(entry.path);
|
|
1838
|
+
return next;
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
const renderRow = (entry) => {
|
|
1843
|
+
const glyph = STATE_GLYPH[entry.state];
|
|
1844
|
+
const openable = entry.kind === "directory" || entry.state !== "pending";
|
|
1845
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
1846
|
+
type: "button",
|
|
1847
|
+
"data-testid": `artifact-row-${entry.path}`,
|
|
1848
|
+
"data-artifact-state": entry.state,
|
|
1849
|
+
disabled: !openable,
|
|
1850
|
+
"aria-expanded": entry.kind === "directory" ? expanded.has(entry.path) : void 0,
|
|
1851
|
+
onClick: () => entry.kind === "directory" ? void toggle(entry) : onOpen(entry.path),
|
|
1852
|
+
className: `flex flex-col gap-0.5 px-3 py-1.5 text-left ${openable ? "cursor-pointer hover:bg-doom-panel" : "cursor-default"}`,
|
|
1853
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
1854
|
+
className: "flex min-w-0 items-center gap-2",
|
|
1855
|
+
children: [
|
|
1856
|
+
/* @__PURE__ */ jsx("span", {
|
|
1857
|
+
className: `w-3 shrink-0 text-xs ${glyph.className}`,
|
|
1858
|
+
children: entry.kind === "directory" ? expanded.has(entry.path) ? "▾" : "▸" : glyph.glyph
|
|
1859
|
+
}),
|
|
1860
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1861
|
+
title: entry.path,
|
|
1862
|
+
className: `min-w-0 flex-1 truncate text-base font-semibold ${entry.state === "pending" ? "text-doom-dim" : "text-doom-hi"}`,
|
|
1863
|
+
children: [entry.path.split("/").pop(), entry.kind === "directory" ? "/" : ""]
|
|
1864
|
+
}),
|
|
1865
|
+
/* @__PURE__ */ jsx("span", {
|
|
1866
|
+
className: "shrink-0 text-sm text-doom-dim",
|
|
1867
|
+
children: entry.state === "pending" ? "pending" : entry.kind === "directory" ? "" : [formatSize(entry.size), formatWhen(entry.modifiedAt)].filter(Boolean).join(" · ")
|
|
1868
|
+
})
|
|
1869
|
+
]
|
|
1870
|
+
}), entry.declared || entry.producedBy.length > 0 || entry.description !== "" ? /* @__PURE__ */ jsxs("span", {
|
|
1871
|
+
className: "flex min-w-0 items-center gap-1 pl-5 text-sm text-doom-dim",
|
|
1872
|
+
children: [
|
|
1873
|
+
entry.declared ? /* @__PURE__ */ jsx(Badge, {
|
|
1874
|
+
size: "xs",
|
|
1875
|
+
tone: "violet",
|
|
1876
|
+
className: "shrink-0",
|
|
1877
|
+
children: "declared"
|
|
1878
|
+
}) : null,
|
|
1879
|
+
entry.producedBy.length === 0 ? null : /* @__PURE__ */ jsx("span", {
|
|
1880
|
+
title: entry.producedBy.join(", "),
|
|
1881
|
+
className: "truncate",
|
|
1882
|
+
children: entry.producedBy.join(", ")
|
|
1883
|
+
}),
|
|
1884
|
+
entry.description === "" ? null : /* @__PURE__ */ jsx("span", {
|
|
1885
|
+
title: entry.description,
|
|
1886
|
+
className: "truncate",
|
|
1887
|
+
children: entry.description
|
|
1888
|
+
})
|
|
1889
|
+
]
|
|
1890
|
+
}) : null]
|
|
1891
|
+
});
|
|
1892
|
+
};
|
|
1893
|
+
const renderChildren = (parent) => {
|
|
1894
|
+
return [...entries.values()].filter((entry) => {
|
|
1895
|
+
const cut = entry.path.lastIndexOf("/");
|
|
1896
|
+
return (cut === -1 ? "" : entry.path.slice(0, cut)) === parent;
|
|
1897
|
+
}).sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory") || left.path.localeCompare(right.path)).map((entry) => /* @__PURE__ */ jsxs("div", {
|
|
1898
|
+
className: "flex flex-col",
|
|
1899
|
+
children: [renderRow(entry), entry.kind === "directory" && expanded.has(entry.path) ? /* @__PURE__ */ jsxs("div", {
|
|
1900
|
+
className: "ml-3 border-l border-doom-border pl-1",
|
|
1901
|
+
children: [
|
|
1902
|
+
loading.has(entry.path) ? /* @__PURE__ */ jsx("span", {
|
|
1903
|
+
className: "px-3 text-sm text-doom-dim",
|
|
1904
|
+
children: "loading…"
|
|
1905
|
+
}) : null,
|
|
1906
|
+
folderErrors[entry.path] ? /* @__PURE__ */ jsx("span", {
|
|
1907
|
+
role: "alert",
|
|
1908
|
+
className: "px-3 text-base text-doom-yellow",
|
|
1909
|
+
children: folderErrors[entry.path]
|
|
1910
|
+
}) : null,
|
|
1911
|
+
renderChildren(entry.path)
|
|
1912
|
+
]
|
|
1913
|
+
}) : null]
|
|
1914
|
+
}, entry.path));
|
|
1915
|
+
};
|
|
1916
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1917
|
+
"data-testid": "artifacts-pane",
|
|
1918
|
+
className: "flex min-h-0 flex-1 flex-col",
|
|
1919
|
+
children: [
|
|
1920
|
+
error !== void 0 ? /* @__PURE__ */ jsx(EmptyState, {
|
|
1921
|
+
className: "py-4",
|
|
1922
|
+
title: "the run directory cannot be read",
|
|
1923
|
+
description: error
|
|
1924
|
+
}) : null,
|
|
1925
|
+
entries.size === 0 && error === void 0 ? /* @__PURE__ */ jsx(EmptyState, {
|
|
1926
|
+
className: "py-4",
|
|
1927
|
+
title: "nothing in the run directory yet"
|
|
1928
|
+
}) : null,
|
|
1929
|
+
listing?.description ? /* @__PURE__ */ jsx("span", {
|
|
1930
|
+
className: "px-3 py-2 text-sm text-doom-dim",
|
|
1931
|
+
children: listing.description
|
|
1932
|
+
}) : null,
|
|
1933
|
+
renderChildren(""),
|
|
1934
|
+
openError === void 0 ? null : /* @__PURE__ */ jsx("span", {
|
|
1935
|
+
role: "alert",
|
|
1936
|
+
className: "px-3 text-base text-doom-yellow",
|
|
1937
|
+
children: openError
|
|
1938
|
+
}),
|
|
1939
|
+
listing === void 0 ? null : /* @__PURE__ */ jsx("button", {
|
|
1940
|
+
type: "button",
|
|
1941
|
+
"data-testid": "workflow-open-directory",
|
|
1942
|
+
"aria-label": "Open workflow folder in Finder",
|
|
1943
|
+
title: `${listing.runDir} (open in Finder)`,
|
|
1944
|
+
disabled: opening || onOpenDirectory === void 0,
|
|
1945
|
+
onClick: () => void openDirectory(),
|
|
1946
|
+
className: "truncate px-3 py-2 text-left text-base text-doom-dim hover:text-doom-hi disabled:cursor-default",
|
|
1947
|
+
children: listing.runDir
|
|
1948
|
+
})
|
|
1949
|
+
]
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
//#endregion
|
|
1953
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_lib/keySender.ts
|
|
1954
|
+
/**
|
|
1955
|
+
* Sends keystrokes to a run one request at a time, in the order they were typed.
|
|
1956
|
+
*
|
|
1957
|
+
* One request per keystroke, all in flight together, can land out of order: a
|
|
1958
|
+
* quickly typed word reaches the pane as an anagram. Keys typed while a request
|
|
1959
|
+
* is in flight are batched into the next one. `send` answers false when the run
|
|
1960
|
+
* refused the keys; whatever is still queued is then dropped rather than sent
|
|
1961
|
+
* after a refusal.
|
|
1962
|
+
*/
|
|
1963
|
+
function createKeySender(send) {
|
|
1964
|
+
let pending = "";
|
|
1965
|
+
let sending = false;
|
|
1966
|
+
const drain = async () => {
|
|
1967
|
+
sending = true;
|
|
1968
|
+
while (pending !== "") {
|
|
1969
|
+
const batch = pending;
|
|
1970
|
+
pending = "";
|
|
1971
|
+
if (!await send(batch)) pending = "";
|
|
1972
|
+
}
|
|
1973
|
+
sending = false;
|
|
1974
|
+
};
|
|
1975
|
+
return (data) => {
|
|
1976
|
+
pending += data;
|
|
1977
|
+
if (!sending) drain();
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
//#endregion
|
|
1981
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_components/StepSteerComposer.tsx
|
|
1982
|
+
/**
|
|
1983
|
+
* Guidance for the agent a running customRun step is in, the way a Team
|
|
1984
|
+
* sub-agent tab steers its run: the text lands in the turn the agent is
|
|
1985
|
+
* working on. The caller shows it only while the step runs, since guidance
|
|
1986
|
+
* after the step settles would no longer shape the workflow.
|
|
1987
|
+
*/
|
|
1988
|
+
function StepSteerComposer({ run, sessionId, step }) {
|
|
1989
|
+
const [guidance, setGuidance] = useState("");
|
|
1990
|
+
const [sending, setSending] = useState(false);
|
|
1991
|
+
const [error, setError] = useState();
|
|
1992
|
+
const submit = async (event) => {
|
|
1993
|
+
event.preventDefault();
|
|
1994
|
+
const message = guidance.trim();
|
|
1995
|
+
if (message === "" || sending) return;
|
|
1996
|
+
setSending(true);
|
|
1997
|
+
setError(void 0);
|
|
1998
|
+
try {
|
|
1999
|
+
const result = await steerStep(run.workspace, run.runKey, message, sessionId, step);
|
|
2000
|
+
if ("error" in result) {
|
|
2001
|
+
setError(result.error);
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
setGuidance("");
|
|
2005
|
+
} catch (cause) {
|
|
2006
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
2007
|
+
} finally {
|
|
2008
|
+
setSending(false);
|
|
2009
|
+
}
|
|
2010
|
+
};
|
|
2011
|
+
return /* @__PURE__ */ jsx("form", {
|
|
2012
|
+
"data-testid": "step-steer-composer",
|
|
2013
|
+
className: "shrink-0 border-t border-doom-border-soft bg-doom-rail px-3 pt-3 pb-2.5",
|
|
2014
|
+
onSubmit: (event) => void submit(event),
|
|
2015
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
2016
|
+
className: "rounded-lg border border-doom-border bg-doom-deep transition-colors focus-within:border-doom-blue/60",
|
|
2017
|
+
children: [
|
|
2018
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2019
|
+
className: "flex min-w-0 items-start gap-2.5 px-3.5 pt-3",
|
|
2020
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
2021
|
+
className: "mt-[3px] shrink-0 select-none text-base leading-none text-doom-green",
|
|
2022
|
+
children: ">"
|
|
2023
|
+
}), /* @__PURE__ */ jsx(Textarea, {
|
|
2024
|
+
variant: "bare",
|
|
2025
|
+
"data-testid": "step-steer-input",
|
|
2026
|
+
"aria-label": "Steering guidance",
|
|
2027
|
+
rows: 2,
|
|
2028
|
+
value: guidance,
|
|
2029
|
+
readOnly: sending,
|
|
2030
|
+
placeholder: "Guide this agent…",
|
|
2031
|
+
className: "min-w-0 flex-1 text-base leading-relaxed",
|
|
2032
|
+
onChange: (event) => setGuidance(event.target.value)
|
|
2033
|
+
})]
|
|
2034
|
+
}),
|
|
2035
|
+
error === void 0 ? null : /* @__PURE__ */ jsx("p", {
|
|
2036
|
+
role: "alert",
|
|
2037
|
+
"data-testid": "step-steer-error",
|
|
2038
|
+
className: "px-3.5 pt-2 text-base text-doom-red",
|
|
2039
|
+
children: error
|
|
2040
|
+
}),
|
|
2041
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2042
|
+
className: "flex items-center gap-2 px-3.5 pt-2 pb-2.5",
|
|
2043
|
+
children: [
|
|
2044
|
+
/* @__PURE__ */ jsx("span", {
|
|
2045
|
+
role: "status",
|
|
2046
|
+
"data-testid": "step-steer-hint",
|
|
2047
|
+
className: "min-w-0 truncate text-sm text-doom-dim",
|
|
2048
|
+
children: "guidance reaches this step while it is still working"
|
|
2049
|
+
}),
|
|
2050
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
|
|
2051
|
+
/* @__PURE__ */ jsx(Button, {
|
|
2052
|
+
"data-testid": "step-steer-submit",
|
|
2053
|
+
type: "submit",
|
|
2054
|
+
variant: "primary",
|
|
2055
|
+
size: "md",
|
|
2056
|
+
className: "px-3.5",
|
|
2057
|
+
disabled: sending || guidance.trim() === "",
|
|
2058
|
+
children: sending ? "sending…" : "send"
|
|
2059
|
+
})
|
|
2060
|
+
]
|
|
2061
|
+
})
|
|
2062
|
+
]
|
|
2063
|
+
})
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
//#endregion
|
|
2067
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_components/StepTerminalPanel.tsx
|
|
2068
|
+
/** The tab id doubles as the URL segment, so it stays plain and unique across plugins. */
|
|
2069
|
+
const TAB_ID_PREFIX = "workflows-step-";
|
|
2070
|
+
/** Keys that mean something to a terminal but nothing to a text field. */
|
|
2071
|
+
const CONTROL_KEYS = {
|
|
2072
|
+
Enter: "\r",
|
|
2073
|
+
Backspace: "",
|
|
2074
|
+
Tab: " ",
|
|
2075
|
+
Escape: "\x1B",
|
|
2076
|
+
ArrowUp: "\x1B[A",
|
|
2077
|
+
ArrowDown: "\x1B[B",
|
|
2078
|
+
ArrowRight: "\x1B[C",
|
|
2079
|
+
ArrowLeft: "\x1B[D"
|
|
2080
|
+
};
|
|
2081
|
+
const CTRL_C = "";
|
|
2082
|
+
const CTRL_D = "";
|
|
2083
|
+
const CTRL_A_CODE = 64;
|
|
2084
|
+
/** The temporary tab for one step; the host keeps this panel while the tab is open. */
|
|
2085
|
+
function stepTerminalTab(run, job, step) {
|
|
2086
|
+
const target = {
|
|
2087
|
+
workspace: run.workspace,
|
|
2088
|
+
runKey: run.runKey,
|
|
2089
|
+
job,
|
|
2090
|
+
...step === void 0 ? {} : { step }
|
|
2091
|
+
};
|
|
2092
|
+
const label = step ?? job;
|
|
2093
|
+
return {
|
|
2094
|
+
id: `${TAB_ID_PREFIX}${run.workspace}-${run.runKey}-${job}${step === void 0 ? "" : `-${step}`}`.replace(/[^\w-]+/g, "-"),
|
|
2095
|
+
label: `${run.displayName} › ${label}`,
|
|
2096
|
+
panel: (props) => /* @__PURE__ */ jsx(StepTerminalPanel, {
|
|
2097
|
+
...props,
|
|
2098
|
+
target
|
|
2099
|
+
})
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
/**
|
|
2103
|
+
* The child session a customRun step ran as, when the tab shows one.
|
|
2104
|
+
*
|
|
2105
|
+
* A step tab follows that step. A job tab follows the job's latest step only,
|
|
2106
|
+
* so a job that moved on to a command step shows its terminal again.
|
|
2107
|
+
*/
|
|
2108
|
+
/** The step a tab points at: the named one, or the job's last step when it names none. */
|
|
2109
|
+
function targetStep(run, target) {
|
|
2110
|
+
const steps = run?.jobs.find((job) => job.name === target.job)?.steps ?? [];
|
|
2111
|
+
return target.step === void 0 ? steps.at(-1) : steps.find((candidate) => candidate.name === target.step);
|
|
2112
|
+
}
|
|
2113
|
+
/** The pane of the step a tab points at, so a parallel group's steps each read their own. */
|
|
2114
|
+
function stepPaneId(run, target) {
|
|
2115
|
+
const ref = targetStep(run, target)?.ref;
|
|
2116
|
+
return ref?.kind === "pane" ? ref.id : void 0;
|
|
2117
|
+
}
|
|
2118
|
+
function stepSessionRef(run, target) {
|
|
2119
|
+
const step = targetStep(run, target);
|
|
2120
|
+
return step?.ref?.kind === "session" ? step.ref : void 0;
|
|
2121
|
+
}
|
|
2122
|
+
function ScreenLine({ line }) {
|
|
2123
|
+
if (line.length === 0) return /* @__PURE__ */ jsx("span", { className: "block h-[15px]" });
|
|
2124
|
+
return /* @__PURE__ */ jsx(AnsiLine, {
|
|
2125
|
+
line,
|
|
2126
|
+
className: "block whitespace-pre"
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
/** The run, job and step the tab was opened from, with the run's stage and any controls. */
|
|
2130
|
+
function StepHeader({ run, target, children }) {
|
|
2131
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2132
|
+
"data-testid": "step-terminal-head",
|
|
2133
|
+
className: "flex items-center gap-2.5 pb-3",
|
|
2134
|
+
children: [
|
|
2135
|
+
/* @__PURE__ */ jsx("span", {
|
|
2136
|
+
className: "shrink-0 truncate text-base font-semibold text-doom-hi",
|
|
2137
|
+
children: run?.displayName ?? target.runKey
|
|
2138
|
+
}),
|
|
2139
|
+
/* @__PURE__ */ jsx("span", {
|
|
2140
|
+
className: "text-xs text-doom-faint",
|
|
2141
|
+
children: "›"
|
|
2142
|
+
}),
|
|
2143
|
+
/* @__PURE__ */ jsx("span", {
|
|
2144
|
+
className: "shrink-0 truncate text-base font-semibold text-doom-hi",
|
|
2145
|
+
children: target.job
|
|
2146
|
+
}),
|
|
2147
|
+
target.step === void 0 ? null : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
|
|
2148
|
+
className: "text-xs text-doom-faint",
|
|
2149
|
+
children: "›"
|
|
2150
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
2151
|
+
className: "min-w-0 truncate text-base text-doom-text",
|
|
2152
|
+
children: target.step
|
|
2153
|
+
})] }),
|
|
2154
|
+
run === void 0 ? null : /* @__PURE__ */ jsx(StatusBadge, {
|
|
2155
|
+
tone: run.stage === "running" ? "running" : run.stage === "error" ? "error" : "ok",
|
|
2156
|
+
"data-testid": "step-terminal-stage",
|
|
2157
|
+
children: run.stage
|
|
2158
|
+
}),
|
|
2159
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
|
|
2160
|
+
children
|
|
2161
|
+
]
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* One step of a run: the agent conversation for a customRun step, else the run's terminal.
|
|
2166
|
+
*
|
|
2167
|
+
* A customRun step runs as a child session, so its real messages replace the
|
|
2168
|
+
* screen a shell step would paint. The session outlives the step, so a
|
|
2169
|
+
* finished step still reads back.
|
|
2170
|
+
*/
|
|
2171
|
+
function StepTerminalPanel(props) {
|
|
2172
|
+
const { sessionId, target, renderSessionTranscript } = props;
|
|
2173
|
+
const run = useStore(workflows.store, (state) => workflows.select(state, sessionId).runs).find((candidate) => candidate.workspace === target.workspace && candidate.runKey === target.runKey);
|
|
2174
|
+
const sessionRef = stepSessionRef(run, target);
|
|
2175
|
+
if (sessionRef !== void 0 && renderSessionTranscript !== void 0) return /* @__PURE__ */ jsxs("div", {
|
|
2176
|
+
"data-testid": "step-conversation-panel",
|
|
2177
|
+
className: "flex min-h-0 flex-1 flex-col px-[26px] py-[18px]",
|
|
2178
|
+
children: [
|
|
2179
|
+
/* @__PURE__ */ jsx(StepHeader, {
|
|
2180
|
+
run,
|
|
2181
|
+
target,
|
|
2182
|
+
children: /* @__PURE__ */ jsx(Badge, {
|
|
2183
|
+
size: "xs",
|
|
2184
|
+
tone: "blue",
|
|
2185
|
+
"data-testid": "step-conversation-session",
|
|
2186
|
+
children: "agent session"
|
|
2187
|
+
})
|
|
2188
|
+
}),
|
|
2189
|
+
/* @__PURE__ */ jsx("div", {
|
|
2190
|
+
"data-testid": "step-conversation",
|
|
2191
|
+
className: "flex min-h-0 flex-1 flex-col",
|
|
2192
|
+
children: renderSessionTranscript(sessionRef.id)
|
|
2193
|
+
}),
|
|
2194
|
+
run?.stage === "running" && targetStep(run, target)?.status === "running" ? /* @__PURE__ */ jsx(StepSteerComposer, {
|
|
2195
|
+
run,
|
|
2196
|
+
sessionId,
|
|
2197
|
+
step: sessionRef.id
|
|
2198
|
+
}, sessionRef.id) : null
|
|
2199
|
+
]
|
|
2200
|
+
});
|
|
2201
|
+
return /* @__PURE__ */ jsx(StepTerminal, {
|
|
2202
|
+
sessionId,
|
|
2203
|
+
target,
|
|
2204
|
+
run
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
/**
|
|
2208
|
+
* One run's terminal, under a header naming the step the reader opened it from.
|
|
2209
|
+
*
|
|
2210
|
+
* A run has one terminal, shared by its steps in sequence, so this is the run's
|
|
2211
|
+
* screen and the header says which step is on it. Watching is free; typing
|
|
2212
|
+
* takes the keyboard first, because the pane belongs to whoever is answering
|
|
2213
|
+
* whatever the nested agent asked.
|
|
2214
|
+
*/
|
|
2215
|
+
function StepTerminal({ sessionId, target, run }) {
|
|
2216
|
+
const [lines, setLines] = useState([]);
|
|
2217
|
+
const [capabilities, setCapabilities] = useState();
|
|
2218
|
+
const [ended, setEnded] = useState(false);
|
|
2219
|
+
const [token, setToken] = useState();
|
|
2220
|
+
const [notice, setNotice] = useState();
|
|
2221
|
+
const screenRef = useRef(null);
|
|
2222
|
+
const pane = stepPaneId(run, target);
|
|
2223
|
+
const targetKey = `${sessionId ?? ""}\0${target.workspace}\0${target.runKey}\0${pane ?? ""}`;
|
|
2224
|
+
const currentTarget = useRef(targetKey);
|
|
2225
|
+
useEffect(() => {
|
|
2226
|
+
currentTarget.current = targetKey;
|
|
2227
|
+
return () => {
|
|
2228
|
+
currentTarget.current = void 0;
|
|
2229
|
+
};
|
|
2230
|
+
}, [targetKey]);
|
|
2231
|
+
useEffect(() => {
|
|
2232
|
+
setEnded(false);
|
|
2233
|
+
return followScreen(target.workspace, target.runKey, (event) => {
|
|
2234
|
+
setLines(event.lines);
|
|
2235
|
+
setCapabilities(event.capabilities);
|
|
2236
|
+
if (event.ended === true) setEnded(true);
|
|
2237
|
+
}, sessionId, pane);
|
|
2238
|
+
}, [
|
|
2239
|
+
sessionId,
|
|
2240
|
+
target.workspace,
|
|
2241
|
+
target.runKey,
|
|
2242
|
+
pane
|
|
2243
|
+
]);
|
|
2244
|
+
useEffect(() => {
|
|
2245
|
+
if (token === void 0) return;
|
|
2246
|
+
return () => {
|
|
2247
|
+
releaseControl(target.workspace, target.runKey, token, sessionId, pane);
|
|
2248
|
+
};
|
|
2249
|
+
}, [
|
|
2250
|
+
sessionId,
|
|
2251
|
+
token,
|
|
2252
|
+
target.workspace,
|
|
2253
|
+
target.runKey,
|
|
2254
|
+
pane
|
|
2255
|
+
]);
|
|
2256
|
+
useEffect(() => {
|
|
2257
|
+
screenRef.current?.scrollTo({ top: screenRef.current.scrollHeight });
|
|
2258
|
+
}, [lines]);
|
|
2259
|
+
const arm = useCallback(async () => {
|
|
2260
|
+
const requested = targetKey;
|
|
2261
|
+
const result = await takeControl(target.workspace, target.runKey, void 0, sessionId, pane);
|
|
2262
|
+
if (currentTarget.current !== requested) {
|
|
2263
|
+
if (result.held && result.token !== void 0) releaseControl(target.workspace, target.runKey, result.token, sessionId, pane);
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
if (result.held && result.token !== void 0) {
|
|
2267
|
+
setToken(result.token);
|
|
2268
|
+
setNotice(void 0);
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
setNotice(result.reason ?? "The keyboard is not available for this run.");
|
|
2272
|
+
}, [
|
|
2273
|
+
sessionId,
|
|
2274
|
+
target.workspace,
|
|
2275
|
+
target.runKey,
|
|
2276
|
+
targetKey,
|
|
2277
|
+
pane
|
|
2278
|
+
]);
|
|
2279
|
+
const disarm = useCallback(() => {
|
|
2280
|
+
setToken(void 0);
|
|
2281
|
+
}, []);
|
|
2282
|
+
const typeKeys = useMemo(() => token === void 0 ? void 0 : createKeySender(async (data) => {
|
|
2283
|
+
const { error } = await sendKeys(target.workspace, target.runKey, token, data, sessionId, pane);
|
|
2284
|
+
if (error === void 0) return true;
|
|
2285
|
+
setNotice(error);
|
|
2286
|
+
setToken(void 0);
|
|
2287
|
+
return false;
|
|
2288
|
+
}), [
|
|
2289
|
+
token,
|
|
2290
|
+
target.workspace,
|
|
2291
|
+
target.runKey,
|
|
2292
|
+
sessionId,
|
|
2293
|
+
pane
|
|
2294
|
+
]);
|
|
2295
|
+
const type = (data) => typeKeys?.(data);
|
|
2296
|
+
const onKeyDown = (event) => {
|
|
2297
|
+
if (token === void 0) return;
|
|
2298
|
+
if (event.key === "Escape" && !event.ctrlKey) {
|
|
2299
|
+
event.preventDefault();
|
|
2300
|
+
disarm();
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
const control = CONTROL_KEYS[event.key];
|
|
2304
|
+
if (control !== void 0) {
|
|
2305
|
+
event.preventDefault();
|
|
2306
|
+
type(control);
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
if (event.ctrlKey && event.key.length === 1) {
|
|
2310
|
+
event.preventDefault();
|
|
2311
|
+
const letter = event.key.toUpperCase().charCodeAt(0);
|
|
2312
|
+
type(event.key === "c" ? CTRL_C : event.key === "d" ? CTRL_D : String.fromCharCode(letter - CTRL_A_CODE));
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
if (event.key.length === 1 && !event.metaKey && !event.altKey) {
|
|
2316
|
+
event.preventDefault();
|
|
2317
|
+
type(event.key);
|
|
2318
|
+
}
|
|
2319
|
+
};
|
|
2320
|
+
const held = token !== void 0;
|
|
2321
|
+
const writable = capabilities?.writable === true;
|
|
2322
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2323
|
+
"data-testid": "step-terminal-panel",
|
|
2324
|
+
className: "flex min-h-0 flex-1 flex-col px-[26px] py-[18px]",
|
|
2325
|
+
children: [
|
|
2326
|
+
/* @__PURE__ */ jsxs(StepHeader, {
|
|
2327
|
+
run,
|
|
2328
|
+
target,
|
|
2329
|
+
children: [/* @__PURE__ */ jsx(Badge, {
|
|
2330
|
+
size: "xs",
|
|
2331
|
+
tone: held ? "blue" : "neutral",
|
|
2332
|
+
"data-testid": "step-terminal-control",
|
|
2333
|
+
className: held ? "border-doom-blue/60 bg-doom-tint-blue text-doom-hi" : "",
|
|
2334
|
+
children: held ? "keyboard yours" : "watching"
|
|
2335
|
+
}), writable ? /* @__PURE__ */ jsx(Button, {
|
|
2336
|
+
variant: held ? "outline" : "primary",
|
|
2337
|
+
size: "xs",
|
|
2338
|
+
"data-testid": "step-terminal-arm",
|
|
2339
|
+
onClick: () => held ? disarm() : void arm(),
|
|
2340
|
+
children: held ? "release keyboard" : "take control"
|
|
2341
|
+
}) : null]
|
|
2342
|
+
}),
|
|
2343
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2344
|
+
ref: screenRef,
|
|
2345
|
+
"data-testid": "step-terminal-screen",
|
|
2346
|
+
tabIndex: 0,
|
|
2347
|
+
onKeyDown,
|
|
2348
|
+
className: `min-h-0 flex-1 overflow-y-auto rounded-md border bg-doom-deep px-4 py-3 font-mono text-sm leading-tight text-doom-text outline-none ${held ? "border-doom-blue/60" : "border-doom-border"}`,
|
|
2349
|
+
children: [lines.length === 0 ? /* @__PURE__ */ jsx("span", {
|
|
2350
|
+
className: "text-sm text-doom-dim",
|
|
2351
|
+
children: capabilities?.readable === false ? capabilities.reason ?? "This run has no terminal to read." : "waiting for the run to paint…"
|
|
2352
|
+
}) : lines.map((line, index) => /* @__PURE__ */ jsx(ScreenLine, { line }, index)), held && !ended ? /* @__PURE__ */ jsx(StreamCursor, { className: "mt-0.5 h-[12px] w-1.5" }) : null]
|
|
2353
|
+
}),
|
|
2354
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2355
|
+
className: "flex items-center gap-3 pt-2.5",
|
|
2356
|
+
children: [
|
|
2357
|
+
/* @__PURE__ */ jsx("span", {
|
|
2358
|
+
"data-testid": "step-terminal-hint",
|
|
2359
|
+
className: "text-sm text-doom-dim",
|
|
2360
|
+
children: held ? "keys go to the run · ctrl-c and ctrl-d pass through · esc releases the keyboard" : capabilities?.writable === false ? capabilities.reason ?? "this run cannot be typed into" : "read-only until you take control · the run keeps going either way"
|
|
2361
|
+
}),
|
|
2362
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
|
|
2363
|
+
ended ? /* @__PURE__ */ jsx("span", {
|
|
2364
|
+
"data-testid": "step-terminal-ended",
|
|
2365
|
+
className: "text-sm text-doom-dim",
|
|
2366
|
+
children: "the run has settled; this is its last screen"
|
|
2367
|
+
}) : null,
|
|
2368
|
+
notice === void 0 ? null : /* @__PURE__ */ jsx("span", {
|
|
2369
|
+
"data-testid": "step-terminal-notice",
|
|
2370
|
+
className: "text-sm text-doom-yellow",
|
|
2371
|
+
children: notice
|
|
2372
|
+
})
|
|
2373
|
+
]
|
|
2374
|
+
})
|
|
2375
|
+
]
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
//#endregion
|
|
2379
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_components/WorkflowRunParts.tsx
|
|
2380
|
+
const STATE_ICON = {
|
|
2381
|
+
pending: {
|
|
2382
|
+
glyph: "○",
|
|
2383
|
+
className: "text-doom-faint"
|
|
2384
|
+
},
|
|
2385
|
+
running: {
|
|
2386
|
+
glyph: "●",
|
|
2387
|
+
className: "text-doom-blue"
|
|
2388
|
+
},
|
|
2389
|
+
resumed: {
|
|
2390
|
+
glyph: "●",
|
|
2391
|
+
className: "text-doom-blue"
|
|
2392
|
+
},
|
|
2393
|
+
completed: {
|
|
2394
|
+
glyph: "✓",
|
|
2395
|
+
className: "text-doom-green"
|
|
2396
|
+
},
|
|
2397
|
+
failed: {
|
|
2398
|
+
glyph: "✕",
|
|
2399
|
+
className: "text-doom-red"
|
|
2400
|
+
},
|
|
2401
|
+
skipped: {
|
|
2402
|
+
glyph: "↷",
|
|
2403
|
+
className: "text-doom-faint"
|
|
2404
|
+
},
|
|
2405
|
+
pause_requested: {
|
|
2406
|
+
glyph: "!",
|
|
2407
|
+
className: "text-doom-yellow"
|
|
2408
|
+
},
|
|
2409
|
+
paused: {
|
|
2410
|
+
glyph: "!",
|
|
2411
|
+
className: "text-doom-yellow"
|
|
2412
|
+
}
|
|
2413
|
+
};
|
|
2414
|
+
const ACTIVE_STATES = /* @__PURE__ */ new Set([
|
|
2415
|
+
"running",
|
|
2416
|
+
"resumed",
|
|
2417
|
+
"pause_requested",
|
|
2418
|
+
"paused"
|
|
2419
|
+
]);
|
|
2420
|
+
function spanDuration(startedAt, endedAt, now) {
|
|
2421
|
+
if (startedAt === void 0) return void 0;
|
|
2422
|
+
const start = Date.parse(startedAt);
|
|
2423
|
+
if (!Number.isFinite(start)) return void 0;
|
|
2424
|
+
const end = endedAt === void 0 ? now : Date.parse(endedAt);
|
|
2425
|
+
if (!Number.isFinite(end)) return void 0;
|
|
2426
|
+
return formatRunDuration(Math.max(0, end - start));
|
|
2427
|
+
}
|
|
2428
|
+
function attentionFor(run) {
|
|
2429
|
+
if (run.stage === "error") {
|
|
2430
|
+
const cause = run.errorMessage ?? "the run ended in the error stage";
|
|
2431
|
+
return {
|
|
2432
|
+
kind: "error",
|
|
2433
|
+
text: run.failedJob === void 0 ? cause : `job '${run.failedJob}' failed: ${cause}`
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
if (run.stale === true) return {
|
|
2437
|
+
kind: "stale",
|
|
2438
|
+
text: run.staleReason ?? "the process that ran this workflow is gone"
|
|
2439
|
+
};
|
|
2440
|
+
if (run.executionState === "paused" || run.executionState === "pause_requested") return {
|
|
2441
|
+
kind: "paused",
|
|
2442
|
+
text: "the run is paused and will not move until it is resumed"
|
|
2443
|
+
};
|
|
2444
|
+
}
|
|
2445
|
+
function runDot(run) {
|
|
2446
|
+
if (run.stage === "error") return "red";
|
|
2447
|
+
if (run.executionState === "paused" || run.executionState === "pause_requested") return "yellow";
|
|
2448
|
+
if (run.stage === "running") return "blue";
|
|
2449
|
+
if (run.outcome === "success") return "green";
|
|
2450
|
+
return "neutral";
|
|
2451
|
+
}
|
|
2452
|
+
function runState(run) {
|
|
2453
|
+
if (run.stage === "error") return "failed";
|
|
2454
|
+
if (run.executionState === "paused" || run.executionState === "pause_requested") return "paused";
|
|
2455
|
+
if (run.stage === "running") return "running";
|
|
2456
|
+
return run.outcome ?? run.stage;
|
|
2457
|
+
}
|
|
2458
|
+
/** The statuses the picker filters by, in the order its status row lists them. */
|
|
2459
|
+
const RUN_FILTERS = [
|
|
2460
|
+
"all",
|
|
2461
|
+
"running",
|
|
2462
|
+
"paused",
|
|
2463
|
+
"failed",
|
|
2464
|
+
"done"
|
|
2465
|
+
];
|
|
2466
|
+
function runFilterOf(run) {
|
|
2467
|
+
if (run.stage === "error") return "failed";
|
|
2468
|
+
if (run.executionState === "paused" || run.executionState === "pause_requested") return "paused";
|
|
2469
|
+
if (run.stage === "running") return "running";
|
|
2470
|
+
return "done";
|
|
2471
|
+
}
|
|
2472
|
+
function runPriority(run) {
|
|
2473
|
+
if (attentionFor(run) !== void 0) return 0;
|
|
2474
|
+
if (run.stage === "running") return 1;
|
|
2475
|
+
return 2;
|
|
2476
|
+
}
|
|
2477
|
+
function runSearchText(run) {
|
|
2478
|
+
return [
|
|
2479
|
+
run.displayName,
|
|
2480
|
+
run.workflowName,
|
|
2481
|
+
run.stage,
|
|
2482
|
+
run.outcome,
|
|
2483
|
+
run.executionState,
|
|
2484
|
+
run.position?.job,
|
|
2485
|
+
run.position?.step
|
|
2486
|
+
].filter((part) => typeof part === "string").join(" ").toLowerCase();
|
|
2487
|
+
}
|
|
2488
|
+
function WorkflowPicker({ runs, selected, onSelect, className }) {
|
|
2489
|
+
const [open, setOpen] = useState(false);
|
|
2490
|
+
const [query, setQuery] = useState("");
|
|
2491
|
+
const [filter, setFilter] = useState("all");
|
|
2492
|
+
const ordered = useMemo(() => [...runs].sort((left, right) => runPriority(left) - runPriority(right)), [runs]);
|
|
2493
|
+
const needle = query.trim().toLowerCase();
|
|
2494
|
+
const shown = ordered.filter((run) => (filter === "all" || runFilterOf(run) === filter) && runSearchText(run).includes(needle));
|
|
2495
|
+
return /* @__PURE__ */ jsxs(Popover, {
|
|
2496
|
+
open,
|
|
2497
|
+
onOpenChange: (next) => {
|
|
2498
|
+
setOpen(next);
|
|
2499
|
+
if (!next) setQuery("");
|
|
2500
|
+
},
|
|
2501
|
+
children: [/* @__PURE__ */ jsx(PopoverTrigger, {
|
|
2502
|
+
asChild: true,
|
|
2503
|
+
children: /* @__PURE__ */ jsxs(Button, {
|
|
2504
|
+
variant: "outline",
|
|
2505
|
+
size: "sm",
|
|
2506
|
+
"data-testid": "workflow-picker",
|
|
2507
|
+
className: cn("h-8 w-full max-w-[360px] justify-start border-doom-border bg-doom-deep px-2.5", className),
|
|
2508
|
+
children: [
|
|
2509
|
+
/* @__PURE__ */ jsx(SearchIcon, { className: "h-3 w-3 shrink-0 text-doom-faint" }),
|
|
2510
|
+
/* @__PURE__ */ jsx("span", {
|
|
2511
|
+
className: "min-w-0 flex-1 truncate text-left text-base font-semibold text-doom-hi",
|
|
2512
|
+
children: selected.displayName
|
|
2513
|
+
}),
|
|
2514
|
+
/* @__PURE__ */ jsxs("span", {
|
|
2515
|
+
className: "shrink-0 text-sm font-normal text-doom-dim",
|
|
2516
|
+
children: [runs.length, " workflows"]
|
|
2517
|
+
}),
|
|
2518
|
+
/* @__PURE__ */ jsx(ChevronDownIcon, { className: "h-3 w-3 shrink-0 text-doom-dim" })
|
|
2519
|
+
]
|
|
2520
|
+
})
|
|
2521
|
+
}), /* @__PURE__ */ jsxs(PopoverContent, {
|
|
2522
|
+
side: "bottom",
|
|
2523
|
+
align: "start",
|
|
2524
|
+
"data-testid": "workflow-picker-popover",
|
|
2525
|
+
className: "w-[440px] border-doom-edge-blue",
|
|
2526
|
+
children: [
|
|
2527
|
+
/* @__PURE__ */ jsxs(PopoverHeader, { children: [/* @__PURE__ */ jsx(SectionLabel, {
|
|
2528
|
+
className: "text-doom-blue",
|
|
2529
|
+
children: "workflow"
|
|
2530
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
2531
|
+
className: "truncate text-sm text-doom-dim",
|
|
2532
|
+
children: selected.displayName
|
|
2533
|
+
})] }),
|
|
2534
|
+
/* @__PURE__ */ jsx("div", {
|
|
2535
|
+
className: "border-b border-doom-border-soft p-1.5",
|
|
2536
|
+
children: /* @__PURE__ */ jsx(Input, {
|
|
2537
|
+
autoFocus: true,
|
|
2538
|
+
"data-testid": "workflow-picker-search",
|
|
2539
|
+
value: query,
|
|
2540
|
+
onChange: (event) => setQuery(event.target.value),
|
|
2541
|
+
placeholder: "filter workflows…",
|
|
2542
|
+
className: "w-full px-2 py-1 text-sm"
|
|
2543
|
+
})
|
|
2544
|
+
}),
|
|
2545
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2546
|
+
role: "listbox",
|
|
2547
|
+
"aria-label": "workflows",
|
|
2548
|
+
className: "flex max-h-[300px] flex-col gap-0.5 overflow-y-auto p-1.5",
|
|
2549
|
+
children: [shown.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
|
|
2550
|
+
className: "py-4",
|
|
2551
|
+
title: "no workflow matches"
|
|
2552
|
+
}) : null, shown.map((run) => {
|
|
2553
|
+
const active = workflowRunIdentity(run) === workflowRunIdentity(selected);
|
|
2554
|
+
return /* @__PURE__ */ jsxs(OptionRow, {
|
|
2555
|
+
density: "compact",
|
|
2556
|
+
active,
|
|
2557
|
+
"data-testid": `workflow-option-${run.runKey}`,
|
|
2558
|
+
"data-run-stage": run.stage,
|
|
2559
|
+
"data-active": active,
|
|
2560
|
+
onClick: () => {
|
|
2561
|
+
onSelect(run);
|
|
2562
|
+
setOpen(false);
|
|
2563
|
+
},
|
|
2564
|
+
className: cn("w-full gap-2.5 py-1.5 transition-colors hover:bg-doom-deep focus-visible:bg-doom-deep", active && "bg-doom-tint-blue hover:brightness-125"),
|
|
2565
|
+
children: [
|
|
2566
|
+
/* @__PURE__ */ jsx("span", {
|
|
2567
|
+
className: "w-14 shrink-0 truncate text-sm text-doom-dim",
|
|
2568
|
+
children: runState(run)
|
|
2569
|
+
}),
|
|
2570
|
+
/* @__PURE__ */ jsx(OptionLabel, {
|
|
2571
|
+
density: "compact",
|
|
2572
|
+
className: active ? "text-doom-blue" : "text-doom-hi",
|
|
2573
|
+
children: run.displayName
|
|
2574
|
+
}),
|
|
2575
|
+
/* @__PURE__ */ jsx("span", {
|
|
2576
|
+
className: "max-w-[40%] truncate text-sm text-doom-dim",
|
|
2577
|
+
children: [run.position?.job, run.position?.step].filter(Boolean).join(" · ")
|
|
2578
|
+
}),
|
|
2579
|
+
/* @__PURE__ */ jsx(Dot, {
|
|
2580
|
+
tone: runDot(run),
|
|
2581
|
+
pulse: run.stage === "running"
|
|
2582
|
+
})
|
|
2583
|
+
]
|
|
2584
|
+
}, workflowRunIdentity(run));
|
|
2585
|
+
})]
|
|
2586
|
+
}),
|
|
2587
|
+
/* @__PURE__ */ jsxs(PopoverFooter, {
|
|
2588
|
+
className: "flex-wrap justify-start gap-1 py-1.5",
|
|
2589
|
+
children: [/* @__PURE__ */ jsx(SectionLabel, {
|
|
2590
|
+
className: "mr-1",
|
|
2591
|
+
children: "status"
|
|
2592
|
+
}), RUN_FILTERS.map((candidate) => {
|
|
2593
|
+
const current = filter === candidate;
|
|
2594
|
+
return /* @__PURE__ */ jsx(Button, {
|
|
2595
|
+
variant: "ghost",
|
|
2596
|
+
size: "xs",
|
|
2597
|
+
"data-testid": `workflow-filter-${candidate}`,
|
|
2598
|
+
"data-current": current,
|
|
2599
|
+
onClick: () => setFilter(candidate),
|
|
2600
|
+
className: cn(current && "bg-doom-blue/25 font-semibold text-doom-blue hover:bg-doom-blue/25"),
|
|
2601
|
+
children: candidate
|
|
2602
|
+
}, candidate);
|
|
2603
|
+
})]
|
|
2604
|
+
})
|
|
2605
|
+
]
|
|
2606
|
+
})]
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
function StepRow({ step, now, selected, onSelect }) {
|
|
2610
|
+
const icon = STATE_ICON[step.status];
|
|
2611
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
2612
|
+
type: "button",
|
|
2613
|
+
disabled: step.status === "pending",
|
|
2614
|
+
title: `${step.name}: ${step.status}`,
|
|
2615
|
+
"data-testid": `step-row-${step.name}`,
|
|
2616
|
+
"data-step-status": step.status,
|
|
2617
|
+
"data-active": selected,
|
|
2618
|
+
onClick: onSelect,
|
|
2619
|
+
className: cn("flex w-full cursor-pointer flex-col gap-0.5 rounded px-2 py-1.5 text-left enabled:hover:bg-doom-deep disabled:cursor-default", selected && "bg-doom-tint-blue ring-1 ring-inset ring-doom-blue/40"),
|
|
2620
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
2621
|
+
className: "flex items-center gap-2",
|
|
2622
|
+
children: [
|
|
2623
|
+
/* @__PURE__ */ jsx("span", {
|
|
2624
|
+
className: `w-3 shrink-0 text-xs ${icon.className}`,
|
|
2625
|
+
children: icon.glyph
|
|
2626
|
+
}),
|
|
2627
|
+
/* @__PURE__ */ jsx("span", {
|
|
2628
|
+
className: cn("min-w-0 flex-1 truncate text-base font-medium", step.status === "failed" ? "text-doom-red" : "text-doom-text"),
|
|
2629
|
+
children: step.name
|
|
2630
|
+
}),
|
|
2631
|
+
step.group === void 0 ? null : /* @__PURE__ */ jsxs("span", {
|
|
2632
|
+
"data-testid": `step-group-${step.name}`,
|
|
2633
|
+
className: "max-w-24 shrink-0 truncate text-sm text-doom-dim",
|
|
2634
|
+
children: ["∥ ", step.group]
|
|
2635
|
+
}),
|
|
2636
|
+
/* @__PURE__ */ jsx("span", {
|
|
2637
|
+
className: "shrink-0 text-sm text-doom-dim",
|
|
2638
|
+
children: step.status === "pending" ? "pending" : spanDuration(step.startedAt, step.endedAt, now) ?? ""
|
|
2639
|
+
})
|
|
2640
|
+
]
|
|
2641
|
+
}), step.reason === void 0 ? null : /* @__PURE__ */ jsx("span", {
|
|
2642
|
+
"data-testid": `step-reason-${step.name}`,
|
|
2643
|
+
title: step.reason,
|
|
2644
|
+
className: cn("truncate pl-5 text-sm", step.status === "failed" ? "text-doom-red" : "text-doom-dim"),
|
|
2645
|
+
children: step.reason
|
|
2646
|
+
})]
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2649
|
+
function DeleteWorkflowDialog({ run, sessionId, onClose, onDeleted }) {
|
|
2650
|
+
const [deleting, setDeleting] = useState(false);
|
|
2651
|
+
const [error, setError] = useState();
|
|
2652
|
+
const confirm = () => {
|
|
2653
|
+
setDeleting(true);
|
|
2654
|
+
setError(void 0);
|
|
2655
|
+
deleteWorkflowRun(run.workspace, run.runKey, sessionId).then((result) => {
|
|
2656
|
+
if ("error" in result) {
|
|
2657
|
+
setDeleting(false);
|
|
2658
|
+
setError(result.error);
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
onDeleted();
|
|
2662
|
+
});
|
|
2663
|
+
};
|
|
2664
|
+
return /* @__PURE__ */ jsx(Dialog, {
|
|
2665
|
+
open: true,
|
|
2666
|
+
onOpenChange: (open) => {
|
|
2667
|
+
if (!open && !deleting) onClose();
|
|
2668
|
+
},
|
|
2669
|
+
children: /* @__PURE__ */ jsxs(DialogContent, {
|
|
2670
|
+
width: "md",
|
|
2671
|
+
"data-testid": "delete-workflow-dialog",
|
|
2672
|
+
"aria-describedby": void 0,
|
|
2673
|
+
children: [/* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsxs(DialogTitle, { children: [
|
|
2674
|
+
"Delete ",
|
|
2675
|
+
run.displayName,
|
|
2676
|
+
"?"
|
|
2677
|
+
] }) }), /* @__PURE__ */ jsxs(DialogBody, { children: [
|
|
2678
|
+
/* @__PURE__ */ jsx("p", {
|
|
2679
|
+
className: "text-sm leading-relaxed text-doom-dim",
|
|
2680
|
+
children: "This permanently removes the workflow run, including its logs and artifacts. This action cannot be undone."
|
|
2681
|
+
}),
|
|
2682
|
+
error === void 0 ? null : /* @__PURE__ */ jsx("p", {
|
|
2683
|
+
"data-testid": "delete-workflow-error",
|
|
2684
|
+
className: "text-base text-doom-red",
|
|
2685
|
+
children: error
|
|
2686
|
+
}),
|
|
2687
|
+
/* @__PURE__ */ jsxs(DialogFooter, { children: [
|
|
2688
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
|
|
2689
|
+
/* @__PURE__ */ jsx(Button, {
|
|
2690
|
+
variant: "outline",
|
|
2691
|
+
size: "xs",
|
|
2692
|
+
"data-testid": "delete-workflow-cancel",
|
|
2693
|
+
disabled: deleting,
|
|
2694
|
+
onClick: onClose,
|
|
2695
|
+
children: "cancel"
|
|
2696
|
+
}),
|
|
2697
|
+
/* @__PURE__ */ jsx(Button, {
|
|
2698
|
+
variant: "danger",
|
|
2699
|
+
size: "xs",
|
|
2700
|
+
"data-testid": "delete-workflow-confirm",
|
|
2701
|
+
loading: deleting,
|
|
2702
|
+
loadingLabel: "deleting workflow",
|
|
2703
|
+
onClick: confirm,
|
|
2704
|
+
children: "delete permanently"
|
|
2705
|
+
})
|
|
2706
|
+
] })
|
|
2707
|
+
] })]
|
|
2708
|
+
})
|
|
2709
|
+
});
|
|
2710
|
+
}
|
|
2711
|
+
/** Ends a running workflow; the button holds its busy state until the run settles and it unmounts. */
|
|
2712
|
+
function StopWorkflowButton({ run, sessionId }) {
|
|
2713
|
+
const [stopping, setStopping] = useState(false);
|
|
2714
|
+
const [error, setError] = useState();
|
|
2715
|
+
const stop = () => {
|
|
2716
|
+
setStopping(true);
|
|
2717
|
+
setError(void 0);
|
|
2718
|
+
stopWorkflowRun(run.workspace, run.runKey, sessionId).then((result) => {
|
|
2719
|
+
if (!("error" in result)) return;
|
|
2720
|
+
setStopping(false);
|
|
2721
|
+
setError(result.error);
|
|
2722
|
+
});
|
|
2723
|
+
};
|
|
2724
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2725
|
+
className: "flex shrink-0 flex-col gap-1 border-t border-doom-border-soft p-1.5",
|
|
2726
|
+
children: [error === void 0 ? null : /* @__PURE__ */ jsx("span", {
|
|
2727
|
+
"data-testid": "stop-workflow-error",
|
|
2728
|
+
className: "px-1 text-base text-doom-red",
|
|
2729
|
+
children: error
|
|
2730
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
2731
|
+
variant: "danger-outline",
|
|
2732
|
+
size: "xs",
|
|
2733
|
+
"data-testid": "stop-workflow",
|
|
2734
|
+
loading: stopping,
|
|
2735
|
+
loadingLabel: "stopping workflow",
|
|
2736
|
+
onClick: stop,
|
|
2737
|
+
className: "w-full",
|
|
2738
|
+
children: "stop workflow"
|
|
2739
|
+
})]
|
|
2740
|
+
});
|
|
2741
|
+
}
|
|
2742
|
+
//#endregion
|
|
2743
|
+
//#region src/extensions/workspaces/sessions/(frontend)/dock/_components/WorkflowDockPanel.tsx
|
|
2744
|
+
const TICK_MS = 1e4;
|
|
2745
|
+
const STATE_TONE = {
|
|
2746
|
+
running: "info",
|
|
2747
|
+
paused: "running",
|
|
2748
|
+
failed: "error",
|
|
2749
|
+
success: "ok"
|
|
2750
|
+
};
|
|
2751
|
+
function stateTone(state) {
|
|
2752
|
+
return state in STATE_TONE ? STATE_TONE[state] : "neutral";
|
|
2753
|
+
}
|
|
2754
|
+
/** The steps moving right now, across every job, so a parallel group reads as one list. */
|
|
2755
|
+
function activeSteps(run) {
|
|
2756
|
+
return run.jobs.flatMap((job) => job.steps.filter((step) => ACTIVE_STATES.has(step.status)).map((step) => ({
|
|
2757
|
+
job: job.name,
|
|
2758
|
+
step
|
|
2759
|
+
})));
|
|
2760
|
+
}
|
|
2761
|
+
function SectionHeading({ label, meta }) {
|
|
2762
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2763
|
+
className: "flex items-center px-4 pb-1 pt-3",
|
|
2764
|
+
children: [
|
|
2765
|
+
/* @__PURE__ */ jsx("span", {
|
|
2766
|
+
className: "text-lg font-semibold text-doom-hi",
|
|
2767
|
+
children: label
|
|
2768
|
+
}),
|
|
2769
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
|
|
2770
|
+
meta === void 0 ? null : /* @__PURE__ */ jsx("span", {
|
|
2771
|
+
className: "text-sm text-doom-dim",
|
|
2772
|
+
children: meta
|
|
2773
|
+
})
|
|
2774
|
+
]
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
function RunHeader({ run, runs, now, onSelectRun }) {
|
|
2778
|
+
const state = runState(run);
|
|
2779
|
+
const where = [run.position?.job, run.position?.step].filter(Boolean).join(" › ");
|
|
2780
|
+
const elapsed = spanDuration(run.startedAt, run.finishedAt, now);
|
|
2781
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2782
|
+
"data-testid": "workflow-dock-run",
|
|
2783
|
+
className: "flex shrink-0 flex-col gap-1.5 border-b border-doom-border px-4 py-3",
|
|
2784
|
+
children: [
|
|
2785
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2786
|
+
className: "flex min-w-0 items-center gap-2",
|
|
2787
|
+
children: [
|
|
2788
|
+
/* @__PURE__ */ jsx(Dot, {
|
|
2789
|
+
tone: runDot(run),
|
|
2790
|
+
pulse: run.stage === "running" && state === "running"
|
|
2791
|
+
}),
|
|
2792
|
+
/* @__PURE__ */ jsx("span", {
|
|
2793
|
+
title: run.displayName,
|
|
2794
|
+
className: "min-w-0 flex-1 truncate text-base font-semibold text-doom-hi",
|
|
2795
|
+
children: run.displayName
|
|
2796
|
+
}),
|
|
2797
|
+
/* @__PURE__ */ jsx(StatusBadge, {
|
|
2798
|
+
size: "xs",
|
|
2799
|
+
tone: stateTone(state),
|
|
2800
|
+
"data-testid": "workflow-dock-state",
|
|
2801
|
+
"data-run-state": state,
|
|
2802
|
+
children: state
|
|
2803
|
+
})
|
|
2804
|
+
]
|
|
2805
|
+
}),
|
|
2806
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2807
|
+
className: "flex min-w-0 items-center gap-1.5 text-sm text-doom-dim",
|
|
2808
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
2809
|
+
title: where,
|
|
2810
|
+
className: "min-w-0 flex-1 truncate",
|
|
2811
|
+
children: where || (run.stage === "running" ? "starting" : run.workflowPath)
|
|
2812
|
+
}), elapsed === void 0 ? null : /* @__PURE__ */ jsx("span", {
|
|
2813
|
+
className: "shrink-0",
|
|
2814
|
+
children: elapsed
|
|
2815
|
+
})]
|
|
2816
|
+
}),
|
|
2817
|
+
runs.length > 1 ? /* @__PURE__ */ jsx(WorkflowPicker, {
|
|
2818
|
+
runs,
|
|
2819
|
+
selected: run,
|
|
2820
|
+
onSelect: onSelectRun,
|
|
2821
|
+
className: "max-w-none"
|
|
2822
|
+
}) : null
|
|
2823
|
+
]
|
|
2824
|
+
});
|
|
2825
|
+
}
|
|
2826
|
+
function AttentionBanner({ run }) {
|
|
2827
|
+
const attention = attentionFor(run);
|
|
2828
|
+
if (attention === void 0) return null;
|
|
2829
|
+
const paused = attention.kind === "paused";
|
|
2830
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2831
|
+
"data-testid": "workflow-dock-attention",
|
|
2832
|
+
"data-attention": attention.kind,
|
|
2833
|
+
className: cn("mx-3 mt-3 flex flex-col gap-1 rounded-md border px-3 py-2", paused ? "border-doom-edge-yellow bg-doom-tint-yellow/40" : "border-doom-edge-red bg-doom-tint-red/40"),
|
|
2834
|
+
children: [
|
|
2835
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2836
|
+
className: "flex items-center gap-2",
|
|
2837
|
+
children: [/* @__PURE__ */ jsx(StatusBadge, {
|
|
2838
|
+
size: "xs",
|
|
2839
|
+
tone: paused ? "running" : "error",
|
|
2840
|
+
children: attention.kind
|
|
2841
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
2842
|
+
className: "min-w-0 flex-1 text-sm text-doom-dim",
|
|
2843
|
+
children: paused ? "waiting to be resumed" : "needs a look"
|
|
2844
|
+
})]
|
|
2845
|
+
}),
|
|
2846
|
+
/* @__PURE__ */ jsx("span", {
|
|
2847
|
+
className: "break-words text-base text-doom-text",
|
|
2848
|
+
children: attention.text
|
|
2849
|
+
}),
|
|
2850
|
+
/* @__PURE__ */ jsx("span", {
|
|
2851
|
+
className: "text-sm text-doom-dim",
|
|
2852
|
+
children: paused ? "ask the agent in this conversation to resume it" : "ask the agent in this conversation to diagnose or recover it"
|
|
2853
|
+
})
|
|
2854
|
+
]
|
|
2855
|
+
});
|
|
2856
|
+
}
|
|
2857
|
+
function ActiveNow({ run, now, onOpenStep }) {
|
|
2858
|
+
const active = activeSteps(run);
|
|
2859
|
+
if (active.length === 0) return null;
|
|
2860
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
2861
|
+
"data-testid": "workflow-dock-active",
|
|
2862
|
+
"aria-label": "active steps",
|
|
2863
|
+
children: [/* @__PURE__ */ jsx(SectionHeading, {
|
|
2864
|
+
label: "Active now",
|
|
2865
|
+
meta: String(active.length)
|
|
2866
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
2867
|
+
className: "flex flex-col gap-0.5 px-2",
|
|
2868
|
+
children: active.map(({ job, step }) => {
|
|
2869
|
+
const paused = step.status === "paused" || step.status === "pause_requested";
|
|
2870
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
2871
|
+
type: "button",
|
|
2872
|
+
"data-testid": `workflow-dock-active-${step.name}`,
|
|
2873
|
+
title: `open ${step.name}`,
|
|
2874
|
+
onClick: () => onOpenStep(job, step.name),
|
|
2875
|
+
className: "flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-doom-panel",
|
|
2876
|
+
children: [
|
|
2877
|
+
/* @__PURE__ */ jsx(Dot, {
|
|
2878
|
+
tone: paused ? "yellow" : "blue",
|
|
2879
|
+
pulse: !paused
|
|
2880
|
+
}),
|
|
2881
|
+
/* @__PURE__ */ jsxs("span", {
|
|
2882
|
+
className: "flex min-w-0 flex-1 flex-col",
|
|
2883
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
2884
|
+
className: "truncate text-base font-semibold text-doom-hi",
|
|
2885
|
+
children: step.name
|
|
2886
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
2887
|
+
className: "truncate text-sm text-doom-dim",
|
|
2888
|
+
children: [job, step.group === void 0 ? "" : ` · ∥ ${step.group}`]
|
|
2889
|
+
})]
|
|
2890
|
+
}),
|
|
2891
|
+
/* @__PURE__ */ jsx("span", {
|
|
2892
|
+
className: "shrink-0 text-sm text-doom-dim",
|
|
2893
|
+
children: spanDuration(step.startedAt, step.endedAt, now) ?? ""
|
|
2894
|
+
})
|
|
2895
|
+
]
|
|
2896
|
+
}, `${job}/${step.name}`);
|
|
2897
|
+
})
|
|
2898
|
+
})]
|
|
2899
|
+
});
|
|
2900
|
+
}
|
|
2901
|
+
function JobTree({ run, now, onOpenStep }) {
|
|
2902
|
+
const [toggled, setToggled] = useState({});
|
|
2903
|
+
const done = run.jobs.filter((job) => job.status === "completed" || job.status === "skipped").length;
|
|
2904
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
2905
|
+
"data-testid": "workflow-dock-jobs",
|
|
2906
|
+
"aria-label": "jobs",
|
|
2907
|
+
children: [/* @__PURE__ */ jsx(SectionHeading, {
|
|
2908
|
+
label: "Jobs",
|
|
2909
|
+
meta: `${done}/${run.jobs.length}`
|
|
2910
|
+
}), run.jobs.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
|
|
2911
|
+
className: "py-3",
|
|
2912
|
+
title: "no progress recorded yet"
|
|
2913
|
+
}) : /* @__PURE__ */ jsx("div", {
|
|
2914
|
+
className: "flex flex-col gap-0.5 px-2",
|
|
2915
|
+
children: run.jobs.map((job) => {
|
|
2916
|
+
const open = toggled[job.name] ?? true;
|
|
2917
|
+
const icon = STATE_ICON[job.status];
|
|
2918
|
+
const finished = job.steps.filter((step) => step.status === "completed" || step.status === "skipped").length;
|
|
2919
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2920
|
+
className: "flex flex-col",
|
|
2921
|
+
children: [/* @__PURE__ */ jsxs("button", {
|
|
2922
|
+
type: "button",
|
|
2923
|
+
"data-testid": `job-row-${job.name}`,
|
|
2924
|
+
"data-job-status": job.status,
|
|
2925
|
+
"aria-expanded": open,
|
|
2926
|
+
title: `${job.name}: ${job.status}`,
|
|
2927
|
+
onClick: () => setToggled((current) => ({
|
|
2928
|
+
...current,
|
|
2929
|
+
[job.name]: !open
|
|
2930
|
+
})),
|
|
2931
|
+
className: "flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-doom-panel",
|
|
2932
|
+
children: [
|
|
2933
|
+
open ? /* @__PURE__ */ jsx(ChevronDownIcon, { className: "h-2.5 w-2.5 shrink-0 text-doom-faint" }) : /* @__PURE__ */ jsx(ChevronRightIcon, { className: "h-2.5 w-2.5 shrink-0 text-doom-faint" }),
|
|
2934
|
+
/* @__PURE__ */ jsx("span", {
|
|
2935
|
+
className: `w-3 shrink-0 text-xs ${icon.className}`,
|
|
2936
|
+
children: icon.glyph
|
|
2937
|
+
}),
|
|
2938
|
+
/* @__PURE__ */ jsx("span", {
|
|
2939
|
+
className: cn("min-w-0 flex-1 truncate text-base font-semibold", job.status === "failed" ? "text-doom-red" : "text-doom-hi"),
|
|
2940
|
+
children: job.name
|
|
2941
|
+
}),
|
|
2942
|
+
/* @__PURE__ */ jsx("span", {
|
|
2943
|
+
className: "shrink-0 text-sm text-doom-dim",
|
|
2944
|
+
children: job.status === "pending" ? "pending" : open || job.steps.length === 0 ? spanDuration(job.startedAt, job.endedAt, now) ?? "" : `${finished}/${job.steps.length} steps`
|
|
2945
|
+
})
|
|
2946
|
+
]
|
|
2947
|
+
}), open && job.steps.length > 0 ? /* @__PURE__ */ jsx("div", {
|
|
2948
|
+
className: "ml-4 flex flex-col gap-0.5 border-l border-doom-border-soft pl-1",
|
|
2949
|
+
children: job.steps.map((step) => /* @__PURE__ */ jsx(StepRow, {
|
|
2950
|
+
step,
|
|
2951
|
+
now,
|
|
2952
|
+
selected: ACTIVE_STATES.has(step.status),
|
|
2953
|
+
onSelect: () => onOpenStep(job.name, step.name)
|
|
2954
|
+
}, step.name))
|
|
2955
|
+
}) : null]
|
|
2956
|
+
}, job.name);
|
|
2957
|
+
})
|
|
2958
|
+
})]
|
|
2959
|
+
});
|
|
2960
|
+
}
|
|
2961
|
+
/** One workflow run as the dock shows it: where it is, what is moving, what it produced. */
|
|
2962
|
+
function WorkflowDockView({ runs, run, now, sessionId, onSelectRun, onOpenStep, onDeleted, artifacts }) {
|
|
2963
|
+
const [deleting, setDeleting] = useState(false);
|
|
2964
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2965
|
+
"data-testid": "workflow-dock",
|
|
2966
|
+
className: "flex h-full min-h-0 flex-1 flex-col",
|
|
2967
|
+
children: [
|
|
2968
|
+
/* @__PURE__ */ jsx(RunHeader, {
|
|
2969
|
+
run,
|
|
2970
|
+
runs,
|
|
2971
|
+
now,
|
|
2972
|
+
onSelectRun
|
|
2973
|
+
}),
|
|
2974
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2975
|
+
"data-testid": "workflow-dock-scroll",
|
|
2976
|
+
className: "min-h-0 flex-1 overflow-y-auto pb-3",
|
|
2977
|
+
children: [
|
|
2978
|
+
/* @__PURE__ */ jsx(AttentionBanner, { run }),
|
|
2979
|
+
/* @__PURE__ */ jsx(ActiveNow, {
|
|
2980
|
+
run,
|
|
2981
|
+
now,
|
|
2982
|
+
onOpenStep
|
|
2983
|
+
}),
|
|
2984
|
+
/* @__PURE__ */ jsx(JobTree, {
|
|
2985
|
+
run,
|
|
2986
|
+
now,
|
|
2987
|
+
onOpenStep
|
|
2988
|
+
}, workflowRunIdentity(run)),
|
|
2989
|
+
/* @__PURE__ */ jsxs("section", {
|
|
2990
|
+
"data-testid": "workflow-dock-artifacts",
|
|
2991
|
+
"aria-label": "artifacts",
|
|
2992
|
+
children: [/* @__PURE__ */ jsx(SectionHeading, { label: "Artifacts" }), /* @__PURE__ */ jsx("div", {
|
|
2993
|
+
className: "px-1",
|
|
2994
|
+
children: artifacts
|
|
2995
|
+
})]
|
|
2996
|
+
})
|
|
2997
|
+
]
|
|
2998
|
+
}),
|
|
2999
|
+
run.stage === "running" ? /* @__PURE__ */ jsxs("div", {
|
|
3000
|
+
className: "shrink-0",
|
|
3001
|
+
children: [/* @__PURE__ */ jsx(StopWorkflowButton, {
|
|
3002
|
+
run,
|
|
3003
|
+
sessionId
|
|
3004
|
+
}, workflowRunIdentity(run)), /* @__PURE__ */ jsx("p", {
|
|
3005
|
+
className: "px-3 pb-2 text-sm text-doom-dim",
|
|
3006
|
+
children: "this session runs the workflow; removing or restarting it stops the run"
|
|
3007
|
+
})]
|
|
3008
|
+
}) : /* @__PURE__ */ jsx("div", {
|
|
3009
|
+
className: "flex shrink-0 border-t border-doom-border-soft p-1.5",
|
|
3010
|
+
children: /* @__PURE__ */ jsx(Button, {
|
|
3011
|
+
variant: "danger-outline",
|
|
3012
|
+
size: "xs",
|
|
3013
|
+
"data-testid": "delete-workflow",
|
|
3014
|
+
disabled: sessionId === null,
|
|
3015
|
+
onClick: () => setDeleting(true),
|
|
3016
|
+
className: "w-full",
|
|
3017
|
+
children: "delete run"
|
|
3018
|
+
})
|
|
3019
|
+
}),
|
|
3020
|
+
sessionId !== null && deleting ? /* @__PURE__ */ jsx(DeleteWorkflowDialog, {
|
|
3021
|
+
run,
|
|
3022
|
+
sessionId,
|
|
3023
|
+
onClose: () => setDeleting(false),
|
|
3024
|
+
onDeleted: () => {
|
|
3025
|
+
setDeleting(false);
|
|
3026
|
+
onDeleted();
|
|
3027
|
+
}
|
|
3028
|
+
}) : null
|
|
3029
|
+
]
|
|
3030
|
+
});
|
|
3031
|
+
}
|
|
3032
|
+
/** The `workflow` dock face: the run this workflow session owns, live from the runs channel. */
|
|
3033
|
+
function WorkflowDockPanel({ sessionId, openTransientTab }) {
|
|
3034
|
+
const reported = useStore(workflows.store, (state) => workflows.select(state, sessionId).runs);
|
|
3035
|
+
const focused = useStore(workflows.store, (state) => workflows.select(state, sessionId).focusedRun);
|
|
3036
|
+
const runs = useMemo(() => reported.filter((candidate) => ownsRun(candidate, sessionId)), [reported, sessionId]);
|
|
3037
|
+
const [now, setNow] = useState(() => Date.now());
|
|
3038
|
+
useEffect(() => {
|
|
3039
|
+
const timer = setInterval(() => setNow(Date.now()), TICK_MS);
|
|
3040
|
+
return () => clearInterval(timer);
|
|
3041
|
+
}, []);
|
|
3042
|
+
const run = runs.find((candidate) => workflowRunIdentity(candidate) === focused) ?? runs[0];
|
|
3043
|
+
if (run === void 0) return /* @__PURE__ */ jsx(EmptyState, {
|
|
3044
|
+
"data-testid": "workflow-dock-empty",
|
|
3045
|
+
className: "px-4 py-5",
|
|
3046
|
+
title: "this session has no workflow run",
|
|
3047
|
+
description: "a run launched from here shows its jobs, steps and artifacts in this face.",
|
|
3048
|
+
children: /* @__PURE__ */ jsx(Button, {
|
|
3049
|
+
variant: "outline",
|
|
3050
|
+
size: "sm",
|
|
3051
|
+
"data-testid": "workflow-dock-launch",
|
|
3052
|
+
onClick: () => {
|
|
3053
|
+
if (sessionId !== null) openCatalog(sessionId);
|
|
3054
|
+
},
|
|
3055
|
+
children: "launch workflow"
|
|
3056
|
+
})
|
|
3057
|
+
});
|
|
3058
|
+
return /* @__PURE__ */ jsx(WorkflowDockView, {
|
|
3059
|
+
runs,
|
|
3060
|
+
run,
|
|
3061
|
+
now,
|
|
3062
|
+
sessionId,
|
|
3063
|
+
onSelectRun: (candidate) => {
|
|
3064
|
+
if (sessionId !== null) focusRun(sessionId, workflowRunIdentity(candidate));
|
|
3065
|
+
},
|
|
3066
|
+
onOpenStep: (job, step) => openTransientTab(stepTerminalTab(run, job, step)),
|
|
3067
|
+
onDeleted: () => {
|
|
3068
|
+
if (sessionId !== null) removeRun(sessionId, workflowRunIdentity(run));
|
|
3069
|
+
},
|
|
3070
|
+
artifacts: /* @__PURE__ */ jsx(ArtifactsPane, {
|
|
3071
|
+
run,
|
|
3072
|
+
sessionId,
|
|
3073
|
+
onOpen: (path) => openTransientTab(artifactTab(run, path))
|
|
3074
|
+
}, `${workflowRunIdentity(run)}/${sessionId}`)
|
|
3075
|
+
});
|
|
3076
|
+
}
|
|
3077
|
+
//#endregion
|
|
3078
|
+
//#region src/extensions/workspaces/sessions/(frontend)/dock/workflow.web.tsx
|
|
3079
|
+
var workflow_web_default$1 = defineDockFace({
|
|
3080
|
+
id: "workflow",
|
|
3081
|
+
label: "workflow",
|
|
3082
|
+
order: 20,
|
|
3083
|
+
autoSelect: true,
|
|
3084
|
+
panel: WorkflowDockPanel,
|
|
3085
|
+
visibility: {
|
|
3086
|
+
subscribe(listener) {
|
|
3087
|
+
const subscription = workflows.store.subscribe(listener);
|
|
3088
|
+
return () => subscription.unsubscribe();
|
|
3089
|
+
},
|
|
3090
|
+
isVisible(sessionId) {
|
|
3091
|
+
return workflows.select(workflows.store.state, sessionId).runs.some((run) => ownsRun(run, sessionId));
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
});
|
|
3095
|
+
//#endregion
|
|
3096
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_lib/launchLine.ts
|
|
3097
|
+
/**
|
|
3098
|
+
* The `/workflow-launch` line the dialog sends.
|
|
3099
|
+
*
|
|
3100
|
+
* WHY THE BUILDER LIVES HERE AND THE PARSER DOES NOT:
|
|
3101
|
+
* Cockpit code is compiled into the host bundle and may import only this
|
|
3102
|
+
* package's own web and type modules, so it cannot share the session's parser.
|
|
3103
|
+
* The two are pinned together by a test that parses what this writes, which is
|
|
3104
|
+
* the only guarantee that matters: the line has to survive the trip.
|
|
3105
|
+
*/
|
|
3106
|
+
const WORKFLOW_LAUNCH_VERB = "/workflow-launch";
|
|
3107
|
+
/** Reserved keys; every other pair is one of the workflow's own inputs. */
|
|
3108
|
+
const RUNNER_KEY = "runner";
|
|
3109
|
+
const COMMAND_KEY = "command";
|
|
3110
|
+
const CHOICE_KEY = "choice";
|
|
3111
|
+
/** Quotes a value that carries whitespace, which is how a value survives tokenizing. */
|
|
3112
|
+
function quote(value) {
|
|
3113
|
+
return /\s/.test(value) ? `"${value}"` : value;
|
|
3114
|
+
}
|
|
3115
|
+
function reservedPair(key, value) {
|
|
3116
|
+
return value === void 0 || value === "" ? [] : [`${key}=${quote(value)}`];
|
|
3117
|
+
}
|
|
3118
|
+
function workflowLaunchLine(request) {
|
|
3119
|
+
const pairs = [
|
|
3120
|
+
...reservedPair(RUNNER_KEY, request.runner),
|
|
3121
|
+
...reservedPair(COMMAND_KEY, request.command),
|
|
3122
|
+
...reservedPair(CHOICE_KEY, request.choice),
|
|
3123
|
+
...Object.entries(request.inputs).filter(([, value]) => value !== "").map(([key, value]) => `${key}=${quote(value)}`)
|
|
3124
|
+
];
|
|
3125
|
+
const prompt = request.prompt?.trim();
|
|
3126
|
+
return [
|
|
3127
|
+
WORKFLOW_LAUNCH_VERB,
|
|
3128
|
+
quote(request.workflow),
|
|
3129
|
+
...pairs,
|
|
3130
|
+
...prompt ? [prompt] : []
|
|
3131
|
+
].join(" ");
|
|
3132
|
+
}
|
|
3133
|
+
/** The trigger that makes a prompt mandatory. */
|
|
3134
|
+
const USER_PROMPT_TRIGGER = "user_prompt";
|
|
3135
|
+
/** What the dialog will not let a reader send, worded the way the session would answer. */
|
|
3136
|
+
function launchProblems(workflow, request) {
|
|
3137
|
+
const problems = [];
|
|
3138
|
+
if (workflow.triggers.includes(USER_PROMPT_TRIGGER) && (request.prompt ?? "").trim() === "") problems.push("This workflow is triggered by a prompt, so it needs one.");
|
|
3139
|
+
const missing = workflow.inputs.filter((input) => input.required === true && (request.inputs[input.name] ?? "") === "").map((input) => input.name);
|
|
3140
|
+
if (missing.length > 0) problems.push(`Missing required input${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}.`);
|
|
3141
|
+
return problems;
|
|
3142
|
+
}
|
|
3143
|
+
/** The default a field starts with: what the workflow declared, or empty. */
|
|
3144
|
+
function initialInputs(workflow) {
|
|
3145
|
+
return Object.fromEntries(workflow.inputs.map((input) => [input.name, input.default ?? ""]));
|
|
3146
|
+
}
|
|
3147
|
+
/** The runner a launch starts on: the workflow's first declared one, when it declares any. */
|
|
3148
|
+
function initialRunner(workflow) {
|
|
3149
|
+
return workflow.runners?.[0];
|
|
3150
|
+
}
|
|
3151
|
+
/** The command templated steps start on: the one their steps list first. */
|
|
3152
|
+
function initialCommand(workflow) {
|
|
3153
|
+
return workflow.commands?.[0];
|
|
3154
|
+
}
|
|
3155
|
+
/** The choice a launch starts on: `default` when declared, else the first. */
|
|
3156
|
+
function initialChoice(workflow) {
|
|
3157
|
+
const choices = workflow.choices ?? [];
|
|
3158
|
+
return choices.includes("default") ? "default" : choices[0];
|
|
3159
|
+
}
|
|
3160
|
+
//#endregion
|
|
3161
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_components/LaunchWorkflowDialog.tsx
|
|
3162
|
+
const PROMPT_ROWS = 3;
|
|
3163
|
+
const BOOLEAN_TYPE = "boolean";
|
|
3164
|
+
const BOOLEAN_VALUES = ["true", "false"];
|
|
3165
|
+
function FieldLabel({ children }) {
|
|
3166
|
+
return /* @__PURE__ */ jsx("span", {
|
|
3167
|
+
className: "text-base font-medium text-doom-text",
|
|
3168
|
+
children
|
|
3169
|
+
});
|
|
3170
|
+
}
|
|
3171
|
+
/** One `workflow_dispatch` input: a picker when the workflow constrains it, a field otherwise. */
|
|
3172
|
+
function InputField({ input, value, onChange }) {
|
|
3173
|
+
const options = input.options ?? (input.type === BOOLEAN_TYPE ? [...BOOLEAN_VALUES] : void 0);
|
|
3174
|
+
const hint = [
|
|
3175
|
+
input.required === true ? "required" : void 0,
|
|
3176
|
+
input.default === void 0 ? void 0 : `default: ${input.default}`,
|
|
3177
|
+
input.description
|
|
3178
|
+
].filter((part) => part !== void 0 && part !== "").join(" · ");
|
|
3179
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
3180
|
+
className: "flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-2.5",
|
|
3181
|
+
"data-testid": `launch-input-${input.name}`,
|
|
3182
|
+
children: [
|
|
3183
|
+
/* @__PURE__ */ jsxs("span", {
|
|
3184
|
+
className: "min-w-0 truncate text-base font-medium text-doom-text sm:w-28 sm:shrink-0",
|
|
3185
|
+
children: [input.name, input.required === true ? /* @__PURE__ */ jsx("span", {
|
|
3186
|
+
className: "text-doom-yellow",
|
|
3187
|
+
children: " *"
|
|
3188
|
+
}) : null]
|
|
3189
|
+
}),
|
|
3190
|
+
options === void 0 ? /* @__PURE__ */ jsx(Input, {
|
|
3191
|
+
className: "min-w-0 flex-1",
|
|
3192
|
+
value,
|
|
3193
|
+
placeholder: input.default ?? "",
|
|
3194
|
+
onChange: (event) => onChange(event.target.value)
|
|
3195
|
+
}) : /* @__PURE__ */ jsxs(Select, {
|
|
3196
|
+
value: value === "" ? void 0 : value,
|
|
3197
|
+
onValueChange: onChange,
|
|
3198
|
+
children: [/* @__PURE__ */ jsx(SelectTrigger, {
|
|
3199
|
+
className: "h-7 w-full min-w-0 sm:min-w-[160px]",
|
|
3200
|
+
children: /* @__PURE__ */ jsx(SelectValue, { placeholder: "choose…" })
|
|
3201
|
+
}), /* @__PURE__ */ jsx(SelectContent, { children: options.map((option) => /* @__PURE__ */ jsx(SelectItem, {
|
|
3202
|
+
value: option,
|
|
3203
|
+
children: option
|
|
3204
|
+
}, option)) })]
|
|
3205
|
+
}),
|
|
3206
|
+
/* @__PURE__ */ jsx("span", {
|
|
3207
|
+
className: "min-w-0 truncate text-sm text-doom-dim sm:w-40 sm:shrink-0",
|
|
3208
|
+
children: hint
|
|
3209
|
+
})
|
|
3210
|
+
]
|
|
3211
|
+
});
|
|
3212
|
+
}
|
|
3213
|
+
/** A reserved launch value the workflow declares options for, such as its command or model choice. */
|
|
3214
|
+
function PickField({ label, testId, options, value, hint, onChange }) {
|
|
3215
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
3216
|
+
className: "flex flex-col gap-1.5",
|
|
3217
|
+
children: [
|
|
3218
|
+
/* @__PURE__ */ jsx(FieldLabel, { children: label }),
|
|
3219
|
+
/* @__PURE__ */ jsxs(Select, {
|
|
3220
|
+
value,
|
|
3221
|
+
onValueChange: onChange,
|
|
3222
|
+
children: [/* @__PURE__ */ jsx(SelectTrigger, {
|
|
3223
|
+
"data-testid": testId,
|
|
3224
|
+
className: "h-7 w-full min-w-0 sm:min-w-[160px]",
|
|
3225
|
+
children: /* @__PURE__ */ jsx(SelectValue, {})
|
|
3226
|
+
}), /* @__PURE__ */ jsx(SelectContent, { children: options.map((option) => /* @__PURE__ */ jsx(SelectItem, {
|
|
3227
|
+
value: option,
|
|
3228
|
+
children: option
|
|
3229
|
+
}, option)) })]
|
|
3230
|
+
}),
|
|
3231
|
+
/* @__PURE__ */ jsx("span", {
|
|
3232
|
+
className: "text-sm text-doom-dim",
|
|
3233
|
+
children: hint
|
|
3234
|
+
})
|
|
3235
|
+
]
|
|
3236
|
+
});
|
|
3237
|
+
}
|
|
3238
|
+
/**
|
|
3239
|
+
* Launches one workflow from the catalog: the prompt, every input the file
|
|
3240
|
+
* declares, the runner when it declares a runner map, then the exact line the
|
|
3241
|
+
* session will parse. Enter launches, since the composer sends the same way.
|
|
3242
|
+
*/
|
|
3243
|
+
function LaunchWorkflowDialog({ sessionId, workflow, cwd, initialPrompt, send, onClose, onLaunched }) {
|
|
3244
|
+
const [prompt, setPrompt] = useState(initialPrompt);
|
|
3245
|
+
const [inputs, setInputs] = useState(() => initialInputs(workflow));
|
|
3246
|
+
const [runner, setRunner] = useState(() => initialRunner(workflow));
|
|
3247
|
+
const [command, setCommand] = useState(() => initialCommand(workflow));
|
|
3248
|
+
const [choice, setChoice] = useState(() => initialChoice(workflow));
|
|
3249
|
+
const request = {
|
|
3250
|
+
workflow: workflow.name === "" ? workflow.relativePath : workflow.name,
|
|
3251
|
+
...runner === void 0 ? {} : { runner },
|
|
3252
|
+
...command === void 0 ? {} : { command },
|
|
3253
|
+
...choice === void 0 ? {} : { choice },
|
|
3254
|
+
inputs,
|
|
3255
|
+
...prompt.trim() === "" ? {} : { prompt: prompt.trim() }
|
|
3256
|
+
};
|
|
3257
|
+
const problems = launchProblems(workflow, request);
|
|
3258
|
+
const line = workflowLaunchLine(request);
|
|
3259
|
+
const launch = () => {
|
|
3260
|
+
if (problems.length > 0) return;
|
|
3261
|
+
requestLaunch(send, sessionId, line, workflow.path);
|
|
3262
|
+
onLaunched();
|
|
3263
|
+
};
|
|
3264
|
+
const onPromptKeyDown = (event) => {
|
|
3265
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
3266
|
+
event.preventDefault();
|
|
3267
|
+
launch();
|
|
3268
|
+
}
|
|
3269
|
+
};
|
|
3270
|
+
return /* @__PURE__ */ jsx(Dialog, {
|
|
3271
|
+
open: true,
|
|
3272
|
+
onOpenChange: (open) => {
|
|
3273
|
+
if (!open) onClose();
|
|
3274
|
+
},
|
|
3275
|
+
children: /* @__PURE__ */ jsxs(DialogContent, {
|
|
3276
|
+
width: "lg",
|
|
3277
|
+
"data-testid": "launch-workflow-dialog",
|
|
3278
|
+
"aria-describedby": void 0,
|
|
3279
|
+
children: [/* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsxs("div", {
|
|
3280
|
+
className: "flex min-w-0 flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2.5",
|
|
3281
|
+
children: [
|
|
3282
|
+
/* @__PURE__ */ jsx("span", {
|
|
3283
|
+
className: "text-sm text-doom-dim",
|
|
3284
|
+
children: "launch workflow"
|
|
3285
|
+
}),
|
|
3286
|
+
/* @__PURE__ */ jsx(DialogTitle, {
|
|
3287
|
+
"data-testid": "launch-workflow-name",
|
|
3288
|
+
className: "max-w-full break-words",
|
|
3289
|
+
children: workflow.name
|
|
3290
|
+
}),
|
|
3291
|
+
/* @__PURE__ */ jsx(Badge, {
|
|
3292
|
+
size: "xs",
|
|
3293
|
+
className: "max-w-full truncate",
|
|
3294
|
+
children: workflow.relativePath
|
|
3295
|
+
})
|
|
3296
|
+
]
|
|
3297
|
+
}) }), /* @__PURE__ */ jsxs(DialogBody, { children: [
|
|
3298
|
+
/* @__PURE__ */ jsx("p", {
|
|
3299
|
+
className: "text-sm leading-relaxed text-doom-dim",
|
|
3300
|
+
children: workflow.description || "This workflow declares no description."
|
|
3301
|
+
}),
|
|
3302
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3303
|
+
className: "flex flex-col gap-1.5",
|
|
3304
|
+
children: [
|
|
3305
|
+
/* @__PURE__ */ jsx(FieldLabel, { children: "Prompt" }),
|
|
3306
|
+
/* @__PURE__ */ jsx(Textarea, {
|
|
3307
|
+
"data-testid": "launch-prompt",
|
|
3308
|
+
autoFocus: true,
|
|
3309
|
+
rows: PROMPT_ROWS,
|
|
3310
|
+
value: prompt,
|
|
3311
|
+
placeholder: "what should this run do? it becomes WORKFLOW_CONTEXT for every job",
|
|
3312
|
+
onChange: (event) => setPrompt(event.target.value),
|
|
3313
|
+
onKeyDown: onPromptKeyDown
|
|
3314
|
+
}),
|
|
3315
|
+
/* @__PURE__ */ jsx("span", {
|
|
3316
|
+
className: "text-sm text-doom-dim",
|
|
3317
|
+
children: "enter launches · shift+enter for a new line"
|
|
3318
|
+
})
|
|
3319
|
+
]
|
|
3320
|
+
}),
|
|
3321
|
+
workflow.inputs.length === 0 ? null : /* @__PURE__ */ jsxs("div", {
|
|
3322
|
+
className: "flex flex-col gap-2",
|
|
3323
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
3324
|
+
className: "flex items-center gap-2",
|
|
3325
|
+
children: [/* @__PURE__ */ jsx(FieldLabel, { children: "Inputs" }), /* @__PURE__ */ jsxs("span", {
|
|
3326
|
+
className: "text-sm text-doom-dim",
|
|
3327
|
+
children: [
|
|
3328
|
+
"workflow_dispatch · ",
|
|
3329
|
+
workflow.inputs.length,
|
|
3330
|
+
" declared"
|
|
3331
|
+
]
|
|
3332
|
+
})]
|
|
3333
|
+
}), workflow.inputs.map((input) => /* @__PURE__ */ jsx(InputField, {
|
|
3334
|
+
input,
|
|
3335
|
+
value: inputs[input.name] ?? "",
|
|
3336
|
+
onChange: (next) => setInputs((current) => ({
|
|
3337
|
+
...current,
|
|
3338
|
+
[input.name]: next
|
|
3339
|
+
}))
|
|
3340
|
+
}, input.name))]
|
|
3341
|
+
}),
|
|
3342
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3343
|
+
className: "flex flex-wrap gap-5",
|
|
3344
|
+
children: [
|
|
3345
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3346
|
+
className: "flex flex-col gap-1.5",
|
|
3347
|
+
children: [
|
|
3348
|
+
/* @__PURE__ */ jsx(FieldLabel, { children: "Runner" }),
|
|
3349
|
+
workflow.runners === void 0 || workflow.runners.length === 0 ? /* @__PURE__ */ jsx("span", {
|
|
3350
|
+
className: "flex h-7 items-center text-base text-doom-dim",
|
|
3351
|
+
children: "whatever the workflow resolves"
|
|
3352
|
+
}) : /* @__PURE__ */ jsxs(Select, {
|
|
3353
|
+
value: runner,
|
|
3354
|
+
onValueChange: setRunner,
|
|
3355
|
+
children: [/* @__PURE__ */ jsx(SelectTrigger, {
|
|
3356
|
+
"data-testid": "launch-runner",
|
|
3357
|
+
className: "h-7 w-full min-w-0 sm:min-w-[160px]",
|
|
3358
|
+
children: /* @__PURE__ */ jsx(SelectValue, {})
|
|
3359
|
+
}), /* @__PURE__ */ jsx(SelectContent, { children: workflow.runners.map((choice) => /* @__PURE__ */ jsx(SelectItem, {
|
|
3360
|
+
value: choice,
|
|
3361
|
+
children: choice
|
|
3362
|
+
}, choice)) })]
|
|
3363
|
+
}),
|
|
3364
|
+
/* @__PURE__ */ jsx("span", {
|
|
3365
|
+
className: "text-sm text-doom-dim",
|
|
3366
|
+
children: workflow.runners === void 0 ? "no runner map declared" : "the runners every step agrees on"
|
|
3367
|
+
})
|
|
3368
|
+
]
|
|
3369
|
+
}),
|
|
3370
|
+
workflow.commands === void 0 || workflow.commands.length === 0 ? null : /* @__PURE__ */ jsx(PickField, {
|
|
3371
|
+
label: "Command",
|
|
3372
|
+
testId: "launch-command",
|
|
3373
|
+
options: workflow.commands,
|
|
3374
|
+
value: command,
|
|
3375
|
+
hint: "what agent steps run through",
|
|
3376
|
+
onChange: setCommand
|
|
3377
|
+
}),
|
|
3378
|
+
workflow.choices === void 0 || workflow.choices.length === 0 ? null : /* @__PURE__ */ jsx(PickField, {
|
|
3379
|
+
label: "Choice",
|
|
3380
|
+
testId: "launch-choice",
|
|
3381
|
+
options: workflow.choices,
|
|
3382
|
+
value: choice,
|
|
3383
|
+
hint: "model and thinking, unless a step pins one",
|
|
3384
|
+
onChange: setChoice
|
|
3385
|
+
}),
|
|
3386
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3387
|
+
className: "flex flex-col gap-1.5",
|
|
3388
|
+
children: [
|
|
3389
|
+
/* @__PURE__ */ jsx(FieldLabel, { children: "Jobs" }),
|
|
3390
|
+
/* @__PURE__ */ jsx("span", {
|
|
3391
|
+
className: "flex h-7 items-center text-base text-doom-dim",
|
|
3392
|
+
children: workflow.jobs.map((job) => job.name).join(" → ") || "none declared"
|
|
3393
|
+
}),
|
|
3394
|
+
/* @__PURE__ */ jsx("span", {
|
|
3395
|
+
className: "text-sm text-doom-dim",
|
|
3396
|
+
children: "in the order the file declares them"
|
|
3397
|
+
})
|
|
3398
|
+
]
|
|
3399
|
+
}),
|
|
3400
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3401
|
+
className: "flex flex-col gap-1.5",
|
|
3402
|
+
children: [
|
|
3403
|
+
/* @__PURE__ */ jsx(FieldLabel, { children: "Cwd" }),
|
|
3404
|
+
/* @__PURE__ */ jsx("span", {
|
|
3405
|
+
className: "flex h-7 items-center font-mono text-sm text-doom-dim",
|
|
3406
|
+
children: cwd
|
|
3407
|
+
}),
|
|
3408
|
+
/* @__PURE__ */ jsx("span", {
|
|
3409
|
+
className: "text-sm text-doom-dim",
|
|
3410
|
+
children: "the session's directory"
|
|
3411
|
+
})
|
|
3412
|
+
]
|
|
3413
|
+
})
|
|
3414
|
+
]
|
|
3415
|
+
}),
|
|
3416
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3417
|
+
className: "flex flex-col gap-1 rounded-md border border-doom-border bg-doom-deep px-3 py-2",
|
|
3418
|
+
children: [/* @__PURE__ */ jsx(FieldLabel, { children: "Sent to the session" }), /* @__PURE__ */ jsx("pre", {
|
|
3419
|
+
"data-testid": "launch-line",
|
|
3420
|
+
className: "whitespace-pre-wrap break-words font-mono text-sm text-doom-green",
|
|
3421
|
+
children: line
|
|
3422
|
+
})]
|
|
3423
|
+
}),
|
|
3424
|
+
problems.length === 0 ? null : /* @__PURE__ */ jsx("p", {
|
|
3425
|
+
"data-testid": "launch-problems",
|
|
3426
|
+
className: "text-base text-doom-yellow",
|
|
3427
|
+
children: problems.join(" ")
|
|
3428
|
+
}),
|
|
3429
|
+
/* @__PURE__ */ jsxs(DialogFooter, {
|
|
3430
|
+
className: "flex-wrap sm:flex-nowrap",
|
|
3431
|
+
children: [
|
|
3432
|
+
/* @__PURE__ */ jsx("span", {
|
|
3433
|
+
className: "w-full text-sm text-doom-dim sm:w-auto",
|
|
3434
|
+
children: "the run appears on the board as soon as it starts"
|
|
3435
|
+
}),
|
|
3436
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
|
|
3437
|
+
/* @__PURE__ */ jsx(Button, {
|
|
3438
|
+
variant: "outline",
|
|
3439
|
+
size: "xs",
|
|
3440
|
+
"data-testid": "launch-cancel",
|
|
3441
|
+
onClick: onClose,
|
|
3442
|
+
children: "cancel"
|
|
3443
|
+
}),
|
|
3444
|
+
/* @__PURE__ */ jsx(Button, {
|
|
3445
|
+
variant: "primary",
|
|
3446
|
+
size: "xs",
|
|
3447
|
+
"data-testid": "launch-submit",
|
|
3448
|
+
disabled: problems.length > 0,
|
|
3449
|
+
onClick: launch,
|
|
3450
|
+
children: "launch"
|
|
3451
|
+
})
|
|
3452
|
+
]
|
|
3453
|
+
})
|
|
3454
|
+
] })]
|
|
3455
|
+
})
|
|
3456
|
+
});
|
|
3457
|
+
}
|
|
3458
|
+
//#endregion
|
|
3459
|
+
//#region src/extensions/workspaces/sessions/(frontend)/_components/WorkflowCatalogDrawer.tsx
|
|
3460
|
+
const INPUT_TAG = "INPUT";
|
|
3461
|
+
/** The unfolded row: what the file declares, read-only, as the TUI catalog's tabs show it. */
|
|
3462
|
+
function Detail({ workflow }) {
|
|
3463
|
+
const rows = [
|
|
3464
|
+
["triggers", workflow.triggers.length > 0 ? workflow.triggers.join(", ") : "none declared"],
|
|
3465
|
+
["inputs", workflow.inputs.length > 0 ? workflow.inputs.map((input) => `${input.name}${input.required ? "*" : ""}${input.default ? ` = ${input.default}` : ""}`).join(", ") : "none declared"],
|
|
3466
|
+
["jobs", workflow.jobs.length > 0 ? workflow.jobs.map((job) => job.name).join(" → ") : "none declared"],
|
|
3467
|
+
["artifacts", workflow.artifacts.length > 0 ? workflow.artifacts.map((entry) => entry.path).join(", ") : "none declared"],
|
|
3468
|
+
["runners", workflow.runners === void 0 ? "any runner" : workflow.runners.join(", ") || "none in common"],
|
|
3469
|
+
["file", workflow.relativePath]
|
|
3470
|
+
];
|
|
3471
|
+
return /* @__PURE__ */ jsx("dl", {
|
|
3472
|
+
"data-testid": `catalog-detail-${workflow.name}`,
|
|
3473
|
+
className: "flex flex-col gap-1 pt-1 text-sm",
|
|
3474
|
+
children: rows.map(([label, value]) => /* @__PURE__ */ jsxs("div", {
|
|
3475
|
+
className: "flex gap-2",
|
|
3476
|
+
children: [/* @__PURE__ */ jsx("dt", {
|
|
3477
|
+
className: "w-16 shrink-0 text-doom-dim",
|
|
3478
|
+
children: label
|
|
3479
|
+
}), /* @__PURE__ */ jsx("dd", {
|
|
3480
|
+
className: "min-w-0 break-words text-doom-dim",
|
|
3481
|
+
children: value
|
|
3482
|
+
})]
|
|
3483
|
+
}, label))
|
|
3484
|
+
});
|
|
3485
|
+
}
|
|
3486
|
+
function WorkflowRow({ workflow, selected, inspected, onSelect, onLaunch, onInspect }) {
|
|
3487
|
+
const jobs = workflow.jobs.length;
|
|
3488
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
3489
|
+
role: "option",
|
|
3490
|
+
"aria-selected": selected,
|
|
3491
|
+
tabIndex: -1,
|
|
3492
|
+
"data-testid": `catalog-workflow-${workflow.name}`,
|
|
3493
|
+
"data-selected": selected,
|
|
3494
|
+
onClick: onSelect,
|
|
3495
|
+
onDoubleClick: onLaunch,
|
|
3496
|
+
className: `flex cursor-pointer flex-col gap-1 border-l-2 px-4 py-2 ${selected ? "border-doom-blue bg-doom-panel" : "border-transparent hover:bg-doom-panel/60"}`,
|
|
3497
|
+
children: [
|
|
3498
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3499
|
+
className: "flex min-w-0 items-center gap-2",
|
|
3500
|
+
children: [
|
|
3501
|
+
/* @__PURE__ */ jsx("span", {
|
|
3502
|
+
className: "truncate text-base font-semibold text-doom-hi",
|
|
3503
|
+
children: workflow.name
|
|
3504
|
+
}),
|
|
3505
|
+
workflow.tags.map((tag) => /* @__PURE__ */ jsx(Badge, {
|
|
3506
|
+
size: "xs",
|
|
3507
|
+
className: "shrink-0",
|
|
3508
|
+
children: tag
|
|
3509
|
+
}, tag)),
|
|
3510
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
|
|
3511
|
+
/* @__PURE__ */ jsx("span", {
|
|
3512
|
+
className: `shrink-0 text-sm ${workflow.error === void 0 ? "text-doom-cyan" : "text-doom-red"}`,
|
|
3513
|
+
children: workflow.error === void 0 ? `${jobs} job${jobs === 1 ? "" : "s"}` : "needs fixing"
|
|
3514
|
+
})
|
|
3515
|
+
]
|
|
3516
|
+
}),
|
|
3517
|
+
/* @__PURE__ */ jsx("span", {
|
|
3518
|
+
className: "truncate text-sm text-doom-dim",
|
|
3519
|
+
children: workflow.description || workflow.relativePath
|
|
3520
|
+
}),
|
|
3521
|
+
workflow.error === void 0 ? /* @__PURE__ */ jsxs("span", {
|
|
3522
|
+
className: "truncate text-sm text-doom-dim",
|
|
3523
|
+
children: [
|
|
3524
|
+
workflow.inputs.length,
|
|
3525
|
+
" input",
|
|
3526
|
+
workflow.inputs.length === 1 ? "" : "s",
|
|
3527
|
+
" ·",
|
|
3528
|
+
" ",
|
|
3529
|
+
workflow.runners === void 0 ? "any runner" : workflow.runners.join(", ") || "no runner in common",
|
|
3530
|
+
workflow.artifacts.length > 0 ? ` · ${workflow.artifacts.length} artifacts` : ""
|
|
3531
|
+
]
|
|
3532
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
3533
|
+
className: "truncate text-base text-doom-red",
|
|
3534
|
+
children: workflow.error
|
|
3535
|
+
}),
|
|
3536
|
+
selected ? /* @__PURE__ */ jsxs("div", {
|
|
3537
|
+
className: "flex items-center gap-3 pt-0.5",
|
|
3538
|
+
children: [/* @__PURE__ */ jsxs(Button, {
|
|
3539
|
+
variant: "link",
|
|
3540
|
+
size: "xs",
|
|
3541
|
+
"data-testid": `catalog-launch-${workflow.name}`,
|
|
3542
|
+
className: "px-0",
|
|
3543
|
+
disabled: workflow.error !== void 0,
|
|
3544
|
+
onClick: (event) => {
|
|
3545
|
+
event.stopPropagation();
|
|
3546
|
+
onLaunch();
|
|
3547
|
+
},
|
|
3548
|
+
children: ["launch ", /* @__PURE__ */ jsx(Kbd, { children: "↵" })]
|
|
3549
|
+
}), /* @__PURE__ */ jsxs(Button, {
|
|
3550
|
+
variant: "link",
|
|
3551
|
+
size: "xs",
|
|
3552
|
+
"data-testid": `catalog-inspect-${workflow.name}`,
|
|
3553
|
+
className: "px-0 text-doom-dim",
|
|
3554
|
+
onClick: (event) => {
|
|
3555
|
+
event.stopPropagation();
|
|
3556
|
+
onInspect();
|
|
3557
|
+
},
|
|
3558
|
+
children: ["inspect ", /* @__PURE__ */ jsx(Kbd, { children: "i" })]
|
|
3559
|
+
})]
|
|
3560
|
+
}) : null,
|
|
3561
|
+
selected && inspected ? /* @__PURE__ */ jsx(Detail, { workflow }) : null
|
|
3562
|
+
]
|
|
3563
|
+
});
|
|
3564
|
+
}
|
|
3565
|
+
/**
|
|
3566
|
+
* The workflow catalog, in the workflows tab's drawer: every workflow the
|
|
3567
|
+
* session's directory declares, with a filter and the keys the TUI catalog
|
|
3568
|
+
* also answers to. Launching hands the chosen workflow to the dialog.
|
|
3569
|
+
*/
|
|
3570
|
+
function WorkflowCatalogDrawer({ sessionId, onClose, onLaunch }) {
|
|
3571
|
+
const state = useStore(catalog.store, (current) => catalog.select(current, sessionId));
|
|
3572
|
+
const shown = filterCatalog(state.workflows, state.filter);
|
|
3573
|
+
const selected = shown.find((workflow) => workflow.path === state.selected) ?? shown[0];
|
|
3574
|
+
const move = (delta) => {
|
|
3575
|
+
if (shown.length === 0) return;
|
|
3576
|
+
const index = selected === void 0 ? -1 : shown.findIndex((workflow) => workflow.path === selected.path);
|
|
3577
|
+
const next = shown[(index + delta + shown.length) % shown.length];
|
|
3578
|
+
if (next) selectWorkflow(sessionId, next.path);
|
|
3579
|
+
};
|
|
3580
|
+
const onKeyDown = (event) => {
|
|
3581
|
+
const typing = event.target.tagName === INPUT_TAG;
|
|
3582
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
3583
|
+
event.preventDefault();
|
|
3584
|
+
move(event.key === "ArrowDown" ? 1 : -1);
|
|
3585
|
+
return;
|
|
3586
|
+
}
|
|
3587
|
+
if (event.key === "Escape") {
|
|
3588
|
+
event.preventDefault();
|
|
3589
|
+
onClose();
|
|
3590
|
+
return;
|
|
3591
|
+
}
|
|
3592
|
+
if (selected === void 0) return;
|
|
3593
|
+
if (event.key === "Enter") {
|
|
3594
|
+
event.preventDefault();
|
|
3595
|
+
if (selected.error === void 0) onLaunch(selected);
|
|
3596
|
+
return;
|
|
3597
|
+
}
|
|
3598
|
+
if (typing) return;
|
|
3599
|
+
if (event.key === "i") toggleInspect(sessionId, selected.path);
|
|
3600
|
+
};
|
|
3601
|
+
return /* @__PURE__ */ jsxs("aside", {
|
|
3602
|
+
"data-testid": "workflow-catalog-drawer",
|
|
3603
|
+
tabIndex: 0,
|
|
3604
|
+
onKeyDown,
|
|
3605
|
+
className: "flex w-[min(440px,calc(100vw-24px))] max-w-full shrink-0 flex-col overflow-hidden border-l border-doom-border bg-doom-rail outline-none",
|
|
3606
|
+
children: [
|
|
3607
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3608
|
+
className: "flex h-11 shrink-0 items-center gap-2.5 border-b border-doom-border px-4",
|
|
3609
|
+
children: [
|
|
3610
|
+
/* @__PURE__ */ jsx("span", {
|
|
3611
|
+
className: "text-lg font-semibold text-doom-hi",
|
|
3612
|
+
children: "workflow catalog"
|
|
3613
|
+
}),
|
|
3614
|
+
/* @__PURE__ */ jsxs(Badge, {
|
|
3615
|
+
size: "xs",
|
|
3616
|
+
"data-testid": "catalog-count",
|
|
3617
|
+
children: [
|
|
3618
|
+
state.workflows.length,
|
|
3619
|
+
" workflow",
|
|
3620
|
+
state.workflows.length === 1 ? "" : "s"
|
|
3621
|
+
]
|
|
3622
|
+
}),
|
|
3623
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
|
|
3624
|
+
/* @__PURE__ */ jsx(Kbd, { children: "SPC w l" }),
|
|
3625
|
+
/* @__PURE__ */ jsx(Button, {
|
|
3626
|
+
variant: "ghost",
|
|
3627
|
+
size: "icon",
|
|
3628
|
+
"data-testid": "catalog-close",
|
|
3629
|
+
title: "close the catalog",
|
|
3630
|
+
onClick: onClose,
|
|
3631
|
+
children: /* @__PURE__ */ jsx(CloseIcon, { className: "h-3 w-3" })
|
|
3632
|
+
})
|
|
3633
|
+
]
|
|
3634
|
+
}),
|
|
3635
|
+
/* @__PURE__ */ jsx("div", {
|
|
3636
|
+
className: "px-4 pt-2.5 pb-1.5",
|
|
3637
|
+
children: /* @__PURE__ */ jsx(Input, {
|
|
3638
|
+
"data-testid": "catalog-filter",
|
|
3639
|
+
value: state.filter,
|
|
3640
|
+
placeholder: "filter by name, tag, or job…",
|
|
3641
|
+
autoFocus: true,
|
|
3642
|
+
onChange: (event) => setCatalogFilter(sessionId, event.target.value)
|
|
3643
|
+
})
|
|
3644
|
+
}),
|
|
3645
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3646
|
+
role: "listbox",
|
|
3647
|
+
className: "flex min-h-0 flex-1 flex-col overflow-y-auto pb-2",
|
|
3648
|
+
children: [shown.length === 0 ? /* @__PURE__ */ jsx("p", {
|
|
3649
|
+
"data-testid": "catalog-empty",
|
|
3650
|
+
className: "px-4 py-3 text-sm text-doom-dim",
|
|
3651
|
+
children: state.warning ?? (state.workflows.length === 0 ? "no workflows found for this session; add one under automations/workflows" : "nothing matches the filter")
|
|
3652
|
+
}) : null, shown.map((workflow) => /* @__PURE__ */ jsx(WorkflowRow, {
|
|
3653
|
+
workflow,
|
|
3654
|
+
selected: selected?.path === workflow.path,
|
|
3655
|
+
inspected: state.inspected === workflow.path,
|
|
3656
|
+
onSelect: () => selectWorkflow(sessionId, workflow.path),
|
|
3657
|
+
onLaunch: () => onLaunch(workflow),
|
|
3658
|
+
onInspect: () => toggleInspect(sessionId, workflow.path)
|
|
3659
|
+
}, workflow.path))]
|
|
3660
|
+
}),
|
|
3661
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3662
|
+
className: "flex h-8 shrink-0 items-center justify-between border-t border-doom-border-soft bg-doom-deep px-4",
|
|
3663
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
3664
|
+
className: "shrink-0 text-sm text-doom-dim",
|
|
3665
|
+
children: "↑↓ choose · enter launch · i inspect · esc close"
|
|
3666
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
3667
|
+
className: "min-w-0 truncate font-mono text-sm text-doom-dim",
|
|
3668
|
+
children: state.cwd
|
|
3669
|
+
})]
|
|
3670
|
+
})
|
|
3671
|
+
]
|
|
3672
|
+
});
|
|
3673
|
+
}
|
|
3674
|
+
//#endregion
|
|
3675
|
+
//#region src/extensions/workspaces/sessions/(frontend)/fill/_components/WorkflowLauncherOverlay.tsx
|
|
3676
|
+
/**
|
|
3677
|
+
* The workflow catalog and its launch dialog, over whatever the session shows.
|
|
3678
|
+
*
|
|
3679
|
+
* Every way in (SPC w l, the Activity launcher, a work item's context action)
|
|
3680
|
+
* only flips the catalog store, so the surface lives in the host's overlay
|
|
3681
|
+
* slot rather than in a tab the reader would have to open first.
|
|
3682
|
+
*/
|
|
3683
|
+
function WorkflowLauncherOverlay({ sessionId, sendSessionFrame }) {
|
|
3684
|
+
const state = useStore(catalog.store, (current) => catalog.select(current, sessionId));
|
|
3685
|
+
if (sessionId === null) return null;
|
|
3686
|
+
const launching = state.workflows.find((workflow) => workflow.path === state.launch);
|
|
3687
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Sheet, {
|
|
3688
|
+
open: state.open,
|
|
3689
|
+
onOpenChange: (open) => {
|
|
3690
|
+
if (!open) closeCatalog(sessionId);
|
|
3691
|
+
},
|
|
3692
|
+
children: /* @__PURE__ */ jsxs(SheetContent, {
|
|
3693
|
+
side: "right",
|
|
3694
|
+
width: "md",
|
|
3695
|
+
"aria-describedby": void 0,
|
|
3696
|
+
className: "border-l-0",
|
|
3697
|
+
children: [/* @__PURE__ */ jsx(SheetTitle, {
|
|
3698
|
+
className: "sr-only",
|
|
3699
|
+
children: "workflow catalog"
|
|
3700
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
3701
|
+
"data-testid": "workflow-launcher-sheet",
|
|
3702
|
+
className: "flex min-h-0 flex-1",
|
|
3703
|
+
children: /* @__PURE__ */ jsx(WorkflowCatalogDrawer, {
|
|
3704
|
+
sessionId,
|
|
3705
|
+
onClose: () => closeCatalog(sessionId),
|
|
3706
|
+
onLaunch: (workflow) => openLaunch(sessionId, workflow.path)
|
|
3707
|
+
})
|
|
3708
|
+
})]
|
|
3709
|
+
})
|
|
3710
|
+
}), launching === void 0 ? null : /* @__PURE__ */ jsx(LaunchWorkflowDialog, {
|
|
3711
|
+
sessionId,
|
|
3712
|
+
workflow: launching,
|
|
3713
|
+
cwd: state.cwd,
|
|
3714
|
+
initialPrompt: state.prompt,
|
|
3715
|
+
send: sendSessionFrame,
|
|
3716
|
+
onClose: () => closeLaunch(sessionId),
|
|
3717
|
+
onLaunched: () => {
|
|
3718
|
+
closeLaunch(sessionId);
|
|
3719
|
+
closeCatalog(sessionId);
|
|
3720
|
+
}
|
|
3721
|
+
})] });
|
|
3722
|
+
}
|
|
3723
|
+
//#endregion
|
|
3724
|
+
//#region src/extensions/workspaces/sessions/(frontend)/fill/WorkflowLauncher.overlay.web.tsx
|
|
3725
|
+
var WorkflowLauncher_overlay_web_default = defineFill({ component: WorkflowLauncherOverlay });
|
|
3726
|
+
//#endregion
|
|
3727
|
+
//#region src/extensions/workspaces/sessions/(frontend)/fill/Workflows.activity.workflows.web.tsx
|
|
3728
|
+
var Workflows_activity_workflows_web_default = defineFill({
|
|
3729
|
+
slot: "activity.workflows",
|
|
3730
|
+
..._lib_default.activitySections[0]
|
|
3731
|
+
});
|
|
3732
|
+
//#endregion
|
|
3733
|
+
//#region src/extensions/workspaces/sessions/(frontend)/leader/catalog.web.ts
|
|
3734
|
+
var catalog_web_default = defineLeaderBinding(_lib_default.leaderBindings[0]);
|
|
3735
|
+
//#endregion
|
|
3736
|
+
//#region src/extensions/workspaces/sessions/(frontend)/leader/toggle.web.ts
|
|
3737
|
+
var toggle_web_default = defineLeaderBinding(_lib_default.leaderBindings[1]);
|
|
3738
|
+
//#endregion
|
|
3739
|
+
//#region src/extensions/workspaces/sessions/(frontend)/mode/workflow/mode.web.ts
|
|
3740
|
+
var mode_web_default = defineMinorModeFile(_lib_default.minorModes[0]);
|
|
3741
|
+
//#endregion
|
|
3742
|
+
//#region src/extensions/workspaces/sessions/(frontend)/tool/workflow.web.tsx
|
|
3743
|
+
var workflow_web_default = defineToolRenderer(_lib_default.toolRenderers[0]);
|
|
3744
|
+
//#endregion
|
|
3745
|
+
//#region generated/web.ts
|
|
3746
|
+
const via = (identity, value) => value === void 0 ? void 0 : {
|
|
3747
|
+
...identity,
|
|
3748
|
+
...value
|
|
3749
|
+
};
|
|
3750
|
+
const webPlugin = defineWebPlugin({
|
|
3751
|
+
id: "workflows",
|
|
3752
|
+
session: {
|
|
3753
|
+
get activityGroups() {
|
|
3754
|
+
return [via({ name: "workflows" }, workflows_web_default)];
|
|
3755
|
+
},
|
|
3756
|
+
get channels() {
|
|
3757
|
+
return [via({ channel: "workflow_catalog" }, workflow_catalog_web_default), via({ channel: "workflow_runs" }, workflow_runs_web_default)];
|
|
3758
|
+
},
|
|
3759
|
+
get contextActions() {
|
|
3760
|
+
return [via({ id: "launch-workflow" }, launch_workflow_web_default)];
|
|
3761
|
+
},
|
|
3762
|
+
get dockFaces() {
|
|
3763
|
+
return [via({ id: "workflow" }, workflow_web_default$1)];
|
|
3764
|
+
},
|
|
3765
|
+
get fills() {
|
|
3766
|
+
return [via({
|
|
3767
|
+
slot: "overlay",
|
|
3768
|
+
id: "workflow-launcher"
|
|
3769
|
+
}, WorkflowLauncher_overlay_web_default), via({
|
|
3770
|
+
slot: "activity.workflows",
|
|
3771
|
+
id: "workflows"
|
|
3772
|
+
}, Workflows_activity_workflows_web_default)];
|
|
3773
|
+
},
|
|
3774
|
+
get leaderBindings() {
|
|
3775
|
+
return [via({ id: "workflows.catalog" }, catalog_web_default), via({ id: "workflows.toggle" }, toggle_web_default)];
|
|
3776
|
+
},
|
|
3777
|
+
get minorModes() {
|
|
3778
|
+
return [via({ name: "workflow" }, mode_web_default)];
|
|
3779
|
+
},
|
|
3780
|
+
get toolRenderers() {
|
|
3781
|
+
return [via({ tools: ["workflow"] }, workflow_web_default)];
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
});
|
|
3785
|
+
//#endregion
|
|
3786
|
+
export { webPlugin };
|
|
3787
|
+
|
|
3788
|
+
//# sourceMappingURL=web.mjs.map
|