@fungi.computer/trellis 0.2.1 → 0.2.3
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/package.json +8 -5
- package/src/history/relative-time.ts +17 -0
- package/src/runs/input-form.tsx +545 -0
- package/src/runs/node-status.tsx +41 -0
- package/src/runs/output-preview.tsx +181 -0
- package/src/runs/public.ts +5 -0
- package/src/runs/review-dock.tsx +147 -0
- package/src/runs/run-state.ts +187 -0
- package/src/runs/run-timeline.css +46 -0
- package/src/runs/run-timeline.tsx +156 -0
- package/src/runs/run-workflow.tsx +199 -0
- package/src/runs/runs.tsx +544 -0
- package/src/runs/timeline-items.ts +196 -0
- package/src/runs/use-run-summaries.ts +84 -0
- package/src/runs/use-run.ts +39 -0
- package/templates/bonsai-build-on-merge.json +134 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import { formatMoney } from "@fungi.computer/caps/lib/format-money";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogDescription,
|
|
7
|
+
DialogFooter,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
DialogTrigger,
|
|
11
|
+
} from "@fungi.computer/caps/components/dialog";
|
|
12
|
+
import { ActionRow } from "@fungi.computer/caps/components/action-row";
|
|
13
|
+
import { Button } from "@fungi.computer/caps/components/button";
|
|
14
|
+
import {
|
|
15
|
+
DataList,
|
|
16
|
+
DataListHeading,
|
|
17
|
+
DataRow,
|
|
18
|
+
} from "@fungi.computer/caps/components/data-list";
|
|
19
|
+
import { Surface } from "@fungi.computer/caps/components/surface";
|
|
20
|
+
import { EmptyState } from "@fungi.computer/caps/blocks/empty-state";
|
|
21
|
+
import type { WorkflowRunListItem } from "@fungi.computer/workflows-wire";
|
|
22
|
+
import { versionRelativeTime } from "../history/relative-time.js";
|
|
23
|
+
import { nodePorts } from "../editor/graph.js";
|
|
24
|
+
import type { Draft, DraftCatalog } from "../editor/draft-store.js";
|
|
25
|
+
import type { WorkflowClient } from "../workflow-client.js";
|
|
26
|
+
import { definitionAppIds } from "../../backend/catalog.js";
|
|
27
|
+
import { useChrome } from "../chrome.js";
|
|
28
|
+
import { ReviewDock } from "./review-dock.js";
|
|
29
|
+
import { NodeStatus } from "./node-status.js";
|
|
30
|
+
import { OutputError, type ReadMedia } from "./output-preview.js";
|
|
31
|
+
import {
|
|
32
|
+
runBeadState,
|
|
33
|
+
runLatestLine,
|
|
34
|
+
runProgress,
|
|
35
|
+
runSection,
|
|
36
|
+
runSteps,
|
|
37
|
+
type RunStatus,
|
|
38
|
+
} from "./run-state.js";
|
|
39
|
+
import { RunTimeline, type OpenInApp } from "./run-timeline.js";
|
|
40
|
+
import { formatDuration, runFinish, timelineItems } from "./timeline-items.js";
|
|
41
|
+
import { useRun } from "./use-run.js";
|
|
42
|
+
import { useRunSummaries } from "./use-run-summaries.js";
|
|
43
|
+
export type { OpenInApp } from "./run-timeline.js";
|
|
44
|
+
const sourceLabel = {
|
|
45
|
+
manual: "Manual",
|
|
46
|
+
schedule: "Schedule",
|
|
47
|
+
event: "Event",
|
|
48
|
+
agent: "Agent",
|
|
49
|
+
key: "Key",
|
|
50
|
+
} satisfies Record<WorkflowRunListItem["source"], string>;
|
|
51
|
+
/**
|
|
52
|
+
* The trigger's own label, or the source's generic name while it loads.
|
|
53
|
+
* An owner now sees every run in the installation, so a manual run's
|
|
54
|
+
* "Started by" must name who actually ran it: "You" for the viewer's own
|
|
55
|
+
* run, the other member's id otherwise. `viewerId` is null until the
|
|
56
|
+
* viewer's own identity loads, so nothing is misreported as "You" early.
|
|
57
|
+
*/
|
|
58
|
+
function startedByLabel(
|
|
59
|
+
item: WorkflowRunListItem,
|
|
60
|
+
definition:
|
|
61
|
+
| { triggers?: readonly { id: string; on: string; event?: string }[] }
|
|
62
|
+
| undefined,
|
|
63
|
+
viewerId: string | null,
|
|
64
|
+
): string {
|
|
65
|
+
if (item.source === "manual") {
|
|
66
|
+
if (item.initiatedBy.kind !== "member") return "Unknown";
|
|
67
|
+
return item.initiatedBy.memberId === viewerId
|
|
68
|
+
? "You"
|
|
69
|
+
: item.initiatedBy.memberId;
|
|
70
|
+
}
|
|
71
|
+
if (item.source === "agent")
|
|
72
|
+
return item.initiatedBy.kind === "agent"
|
|
73
|
+
? `Agent ${item.initiatedBy.agentId} with ${item.initiatedBy.grantorMemberId}'s access`
|
|
74
|
+
: "Agent";
|
|
75
|
+
if (item.source === "key")
|
|
76
|
+
return item.initiatedBy.kind === "key"
|
|
77
|
+
? `Key ${item.initiatedBy.keyId} with ${item.initiatedBy.grantorMemberId}'s access`
|
|
78
|
+
: "Key";
|
|
79
|
+
const trigger = definition?.triggers?.find((t) => t.id === item.triggerId);
|
|
80
|
+
if (!trigger) return sourceLabel[item.source];
|
|
81
|
+
if (trigger.on === "schedule") return "schedule";
|
|
82
|
+
if (trigger.on === "event" && trigger.event !== undefined)
|
|
83
|
+
return trigger.event;
|
|
84
|
+
return trigger.on;
|
|
85
|
+
}
|
|
86
|
+
function RunDetail({
|
|
87
|
+
client,
|
|
88
|
+
item,
|
|
89
|
+
readMedia,
|
|
90
|
+
openIn,
|
|
91
|
+
onStatus,
|
|
92
|
+
viewerId,
|
|
93
|
+
}: {
|
|
94
|
+
client: WorkflowClient;
|
|
95
|
+
item: WorkflowRunListItem;
|
|
96
|
+
readMedia?: ReadMedia;
|
|
97
|
+
openIn?: OpenInApp;
|
|
98
|
+
onStatus: (run: RunStatus) => void;
|
|
99
|
+
viewerId: string | null;
|
|
100
|
+
}) {
|
|
101
|
+
const { run: loaded, error, refresh } = useRun(client, item.runId);
|
|
102
|
+
const run = loaded ?? undefined;
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (run) onStatus(run);
|
|
105
|
+
}, [run, onStatus]);
|
|
106
|
+
const [graph, setGraph] = useState<
|
|
107
|
+
(Pick<DraftCatalog, "catalog" | "appDisplay"> & { draft: Draft }) | null
|
|
108
|
+
>(null);
|
|
109
|
+
const [graphError, setGraphError] = useState(false);
|
|
110
|
+
const [confirming, setConfirming] = useState(false);
|
|
111
|
+
const [cancelling, setCancelling] = useState(false);
|
|
112
|
+
const [cancelError, setCancelError] = useState(false);
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
let active = true;
|
|
115
|
+
setGraph(null);
|
|
116
|
+
setGraphError(false);
|
|
117
|
+
void client
|
|
118
|
+
.loadWorkflow(item.workflowId, { commit: item.commit })
|
|
119
|
+
.then(async (draft) => {
|
|
120
|
+
if (!draft) {
|
|
121
|
+
if (active) setGraphError(true);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const { catalog, apps } = await client.catalog(
|
|
125
|
+
definitionAppIds(draft.definition),
|
|
126
|
+
);
|
|
127
|
+
if (!active) return;
|
|
128
|
+
setGraph({
|
|
129
|
+
draft,
|
|
130
|
+
catalog,
|
|
131
|
+
appDisplay: Object.fromEntries(
|
|
132
|
+
apps.map(({ appId, displayName, icon }) => [
|
|
133
|
+
appId,
|
|
134
|
+
icon === undefined ? { displayName } : { displayName, icon },
|
|
135
|
+
]),
|
|
136
|
+
),
|
|
137
|
+
});
|
|
138
|
+
})
|
|
139
|
+
.catch((cause: unknown) => {
|
|
140
|
+
void cause;
|
|
141
|
+
if (active) setGraphError(true);
|
|
142
|
+
});
|
|
143
|
+
return () => {
|
|
144
|
+
active = false;
|
|
145
|
+
};
|
|
146
|
+
}, [client, item.workflowId, item.commit]);
|
|
147
|
+
async function cancel() {
|
|
148
|
+
setCancelling(true);
|
|
149
|
+
setCancelError(false);
|
|
150
|
+
try {
|
|
151
|
+
const result = await client.cancel(item.runId);
|
|
152
|
+
if ("error" in result || result.kind === "refused") setCancelError(true);
|
|
153
|
+
else {
|
|
154
|
+
setConfirming(false);
|
|
155
|
+
refresh();
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
setCancelError(true);
|
|
159
|
+
} finally {
|
|
160
|
+
setCancelling(false);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const progress = runProgress(run?.events ?? [], run?.outcome ?? "active");
|
|
164
|
+
const steps = runSteps(
|
|
165
|
+
run?.events ?? [],
|
|
166
|
+
run?.outcome ?? "active",
|
|
167
|
+
run?.reviews ?? [],
|
|
168
|
+
);
|
|
169
|
+
const items = graph
|
|
170
|
+
? timelineItems(steps, graph.draft.definition, graph.appDisplay)
|
|
171
|
+
: [];
|
|
172
|
+
const pending = (run?.outcome === "active" ? run.reviews : []).filter(
|
|
173
|
+
(review) => review.status === "pending",
|
|
174
|
+
);
|
|
175
|
+
const finished = run !== undefined && runSection(run) === "finished";
|
|
176
|
+
const finish = runFinish(steps, items, progress.costMilliunits);
|
|
177
|
+
const scroller = useRef<HTMLDivElement>(null);
|
|
178
|
+
const newest = `${String(items.length)}:${String(pending.length)}`;
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
const element = scroller.current;
|
|
181
|
+
if (element && newest !== "") element.scrollTop = element.scrollHeight;
|
|
182
|
+
}, [newest]);
|
|
183
|
+
return (
|
|
184
|
+
<section
|
|
185
|
+
className="wf-run-detail"
|
|
186
|
+
aria-label="Selected run"
|
|
187
|
+
data-run-id={item.runId}
|
|
188
|
+
>
|
|
189
|
+
<header className="wf-actions">
|
|
190
|
+
<h2>{run?.title ?? item.title ?? "Workflow title unavailable"}</h2>
|
|
191
|
+
<span className="wf-muted">
|
|
192
|
+
Started by {startedByLabel(item, graph?.draft.definition, viewerId)} ·{" "}
|
|
193
|
+
<time dateTime={new Date(item.startedAt).toISOString()}>
|
|
194
|
+
{versionRelativeTime(new Date(item.startedAt).toISOString())}
|
|
195
|
+
</time>
|
|
196
|
+
</span>
|
|
197
|
+
{run?.outcome === "active" ? (
|
|
198
|
+
<Dialog
|
|
199
|
+
open={confirming}
|
|
200
|
+
onOpenChange={(next, details) => {
|
|
201
|
+
if (cancelling) {
|
|
202
|
+
details.cancel();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
setConfirming(next);
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
<DialogTrigger render={<Button variant="ghost" size="sm" />}>
|
|
209
|
+
Cancel run
|
|
210
|
+
</DialogTrigger>
|
|
211
|
+
<DialogContent>
|
|
212
|
+
<DialogHeader>
|
|
213
|
+
<DialogTitle>Cancel this run?</DialogTitle>
|
|
214
|
+
<DialogDescription>
|
|
215
|
+
Cancellation cannot be undone. Completed work is kept;
|
|
216
|
+
unfinished steps stop.
|
|
217
|
+
</DialogDescription>
|
|
218
|
+
</DialogHeader>
|
|
219
|
+
{cancelError ? (
|
|
220
|
+
<p role="alert">Run could not be cancelled. Try again.</p>
|
|
221
|
+
) : null}
|
|
222
|
+
<DialogFooter>
|
|
223
|
+
<Button
|
|
224
|
+
variant="ghost"
|
|
225
|
+
disabled={cancelling}
|
|
226
|
+
onClick={() => {
|
|
227
|
+
setConfirming(false);
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
Keep running
|
|
231
|
+
</Button>
|
|
232
|
+
<Button
|
|
233
|
+
variant="destructive"
|
|
234
|
+
disabled={cancelling}
|
|
235
|
+
onClick={() => void cancel()}
|
|
236
|
+
>
|
|
237
|
+
{cancelling ? "Cancelling…" : "Confirm cancellation"}
|
|
238
|
+
</Button>
|
|
239
|
+
</DialogFooter>
|
|
240
|
+
</DialogContent>
|
|
241
|
+
</Dialog>
|
|
242
|
+
) : null}
|
|
243
|
+
</header>
|
|
244
|
+
{error ? (
|
|
245
|
+
<p role="alert">
|
|
246
|
+
Run could not be loaded.{" "}
|
|
247
|
+
<Button variant="ghost" onClick={refresh}>
|
|
248
|
+
Retry
|
|
249
|
+
</Button>
|
|
250
|
+
</p>
|
|
251
|
+
) : null}
|
|
252
|
+
{graphError ? (
|
|
253
|
+
<p role="alert">
|
|
254
|
+
The published graph or App catalog could not be loaded.
|
|
255
|
+
</p>
|
|
256
|
+
) : null}
|
|
257
|
+
<div className="wf-run-timeline" ref={scroller}>
|
|
258
|
+
{!graph && !graphError ? (
|
|
259
|
+
<p className="wf-muted">Loading steps…</p>
|
|
260
|
+
) : null}
|
|
261
|
+
<RunTimeline items={items} readMedia={readMedia} openIn={openIn} />
|
|
262
|
+
{run?.error !== null && run?.error !== undefined ? (
|
|
263
|
+
<OutputError error={run.error} />
|
|
264
|
+
) : null}
|
|
265
|
+
{finished ? (
|
|
266
|
+
<p className="wf-finish">
|
|
267
|
+
<NodeStatus
|
|
268
|
+
state={runBeadState(run)}
|
|
269
|
+
bare
|
|
270
|
+
label={run.outcome === "cancelled" ? "cancelled" : run.status}
|
|
271
|
+
/>
|
|
272
|
+
<span>
|
|
273
|
+
{run.outcome === "cancelled"
|
|
274
|
+
? "Cancelled"
|
|
275
|
+
: runBeadState(run) === "failed"
|
|
276
|
+
? "Failed"
|
|
277
|
+
: `Done${finish.elapsed === undefined ? "" : ` in ${formatDuration(finish.elapsed)}`}`}
|
|
278
|
+
{finish.apps > 0
|
|
279
|
+
? ` · ${String(finish.apps)} ${finish.apps === 1 ? "App" : "Apps"}`
|
|
280
|
+
: ""}
|
|
281
|
+
{finish.cost === null
|
|
282
|
+
? ""
|
|
283
|
+
: ` · ${formatMoney(finish.cost / 100_000)}`}
|
|
284
|
+
</span>
|
|
285
|
+
</p>
|
|
286
|
+
) : null}
|
|
287
|
+
</div>
|
|
288
|
+
{pending.map(({ review }) => {
|
|
289
|
+
const definition = graph?.draft.definition;
|
|
290
|
+
const candidates = definition
|
|
291
|
+
? [
|
|
292
|
+
definition,
|
|
293
|
+
...Object.values(definition.subgraphs ?? {}).map((subgraph) => ({
|
|
294
|
+
...definition,
|
|
295
|
+
...subgraph,
|
|
296
|
+
})),
|
|
297
|
+
].flatMap((scope) =>
|
|
298
|
+
scope.nodes
|
|
299
|
+
.filter(
|
|
300
|
+
(node) => node.kind === "review" && node.id === review.nodeId,
|
|
301
|
+
)
|
|
302
|
+
.map((node) => ({ scope, node })),
|
|
303
|
+
)
|
|
304
|
+
: [];
|
|
305
|
+
// Run reviews identify a node and iteration; repeated ids cannot identify a graph.
|
|
306
|
+
const resolved = candidates.length === 1 ? candidates[0] : undefined;
|
|
307
|
+
const node = resolved?.node;
|
|
308
|
+
const ref = node?.kind === "review" ? node.show[0] : undefined;
|
|
309
|
+
const format =
|
|
310
|
+
ref && graph && resolved
|
|
311
|
+
? Object.entries(
|
|
312
|
+
nodePorts(resolved.scope, graph.catalog, ref.node).outputs,
|
|
313
|
+
).find(([port]) => port === ref.port)?.[1].format
|
|
314
|
+
: undefined;
|
|
315
|
+
return (
|
|
316
|
+
<ReviewDock
|
|
317
|
+
key={`${review.nodeId}:${review.iteration}`}
|
|
318
|
+
runId={item.runId}
|
|
319
|
+
review={review}
|
|
320
|
+
detail={node?.kind === "review" ? node.detail : undefined}
|
|
321
|
+
choices={node?.kind === "review" ? node.choices : undefined}
|
|
322
|
+
format={format}
|
|
323
|
+
readMedia={readMedia}
|
|
324
|
+
answerReview={client.answerReview}
|
|
325
|
+
onAnswered={refresh}
|
|
326
|
+
/>
|
|
327
|
+
);
|
|
328
|
+
})}
|
|
329
|
+
</section>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const sectionTitles = {
|
|
334
|
+
"needs-you": "Needs you",
|
|
335
|
+
running: "Running",
|
|
336
|
+
finished: "Finished",
|
|
337
|
+
} as const;
|
|
338
|
+
|
|
339
|
+
/** True at phone width, where the list and a run are separate pages. */
|
|
340
|
+
function useCompact() {
|
|
341
|
+
const query = "(max-width: 720px)";
|
|
342
|
+
const [compact, setCompact] = useState(
|
|
343
|
+
() => window.matchMedia(query).matches,
|
|
344
|
+
);
|
|
345
|
+
useEffect(() => {
|
|
346
|
+
const list = window.matchMedia(query);
|
|
347
|
+
const change = () => {
|
|
348
|
+
setCompact(list.matches);
|
|
349
|
+
};
|
|
350
|
+
list.addEventListener("change", change);
|
|
351
|
+
return () => {
|
|
352
|
+
list.removeEventListener("change", change);
|
|
353
|
+
};
|
|
354
|
+
}, []);
|
|
355
|
+
return compact;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function Runs({
|
|
359
|
+
client,
|
|
360
|
+
workflowId,
|
|
361
|
+
selectedRun,
|
|
362
|
+
onSelect,
|
|
363
|
+
readMedia,
|
|
364
|
+
openIn,
|
|
365
|
+
}: {
|
|
366
|
+
client: WorkflowClient;
|
|
367
|
+
workflowId?: string;
|
|
368
|
+
selectedRun: string | null;
|
|
369
|
+
onSelect: (id: string, workflowId: string) => void;
|
|
370
|
+
readMedia?: ReadMedia;
|
|
371
|
+
openIn?: OpenInApp;
|
|
372
|
+
}) {
|
|
373
|
+
const [items, setItems] = useState<WorkflowRunListItem[] | null>(null);
|
|
374
|
+
const [error, setError] = useState(false);
|
|
375
|
+
const [attempt, setAttempt] = useState(0);
|
|
376
|
+
const [viewerId, setViewerId] = useState<string | null>(null);
|
|
377
|
+
const compact = useCompact();
|
|
378
|
+
const { runs, record, readErrors } = useRunSummaries(client, items);
|
|
379
|
+
const recordStatus = useCallback(
|
|
380
|
+
(run: RunStatus) => {
|
|
381
|
+
record(run);
|
|
382
|
+
},
|
|
383
|
+
[record],
|
|
384
|
+
);
|
|
385
|
+
useEffect(() => {
|
|
386
|
+
let active = true;
|
|
387
|
+
// Optional, like chatChoices: a failed read only means "Started by"
|
|
388
|
+
// shows an id instead of "You" a little longer, never a page failure.
|
|
389
|
+
void client.viewerId().then(
|
|
390
|
+
(reply) => {
|
|
391
|
+
if (active && "memberId" in reply) setViewerId(reply.memberId);
|
|
392
|
+
},
|
|
393
|
+
() => undefined,
|
|
394
|
+
);
|
|
395
|
+
return () => {
|
|
396
|
+
active = false;
|
|
397
|
+
};
|
|
398
|
+
}, [client]);
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
let active = true;
|
|
401
|
+
setItems(null);
|
|
402
|
+
setError(false);
|
|
403
|
+
void client.listRuns(workflowId).then(
|
|
404
|
+
(rows) => {
|
|
405
|
+
if (active) setItems(rows);
|
|
406
|
+
},
|
|
407
|
+
() => {
|
|
408
|
+
if (active) setError(true);
|
|
409
|
+
},
|
|
410
|
+
);
|
|
411
|
+
return () => {
|
|
412
|
+
active = false;
|
|
413
|
+
};
|
|
414
|
+
}, [client, workflowId, selectedRun, attempt]);
|
|
415
|
+
const chosen = items?.find((item) => item.runId === selectedRun);
|
|
416
|
+
// Resolve a run-only deep link using this list's existing metadata, without
|
|
417
|
+
// another lookup or a guessed workflow scope.
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
if (chosen && workflowId === undefined)
|
|
420
|
+
onSelect(chosen.runId, chosen.workflowId);
|
|
421
|
+
}, [chosen, workflowId, onSelect]);
|
|
422
|
+
// A person's own pick wins; otherwise the first run waiting on them, then the newest.
|
|
423
|
+
const preferred =
|
|
424
|
+
items?.find((item) => runSection(runs[item.runId]) === "needs-you") ??
|
|
425
|
+
items?.[0];
|
|
426
|
+
const selected = chosen ?? (compact ? undefined : preferred);
|
|
427
|
+
useChrome("runs", {
|
|
428
|
+
crumbs: chosen
|
|
429
|
+
? [
|
|
430
|
+
{
|
|
431
|
+
id: "run",
|
|
432
|
+
label: `Run · ${versionRelativeTime(new Date(chosen.startedAt).toISOString())}`,
|
|
433
|
+
},
|
|
434
|
+
]
|
|
435
|
+
: [],
|
|
436
|
+
});
|
|
437
|
+
const showList = !compact || selected === undefined;
|
|
438
|
+
const showDetail = !compact || selected !== undefined;
|
|
439
|
+
return (
|
|
440
|
+
<main className="wf-runs" data-compact={compact ? "" : undefined}>
|
|
441
|
+
{showList ? (
|
|
442
|
+
<Surface placement="embedded" size="sm" className="wf-run-list">
|
|
443
|
+
{error ? (
|
|
444
|
+
<p role="alert">
|
|
445
|
+
Runs could not be loaded.{" "}
|
|
446
|
+
<Button
|
|
447
|
+
variant="ghost"
|
|
448
|
+
onClick={() => {
|
|
449
|
+
setAttempt((value) => value + 1);
|
|
450
|
+
}}
|
|
451
|
+
>
|
|
452
|
+
Retry
|
|
453
|
+
</Button>
|
|
454
|
+
</p>
|
|
455
|
+
) : null}
|
|
456
|
+
{!items && !error ? <p>Loading runs…</p> : null}
|
|
457
|
+
{items?.length === 0 ? (
|
|
458
|
+
<EmptyState
|
|
459
|
+
headingLevel={3}
|
|
460
|
+
variant="quiet"
|
|
461
|
+
title="No runs yet"
|
|
462
|
+
description="Runs from every workflow appear here."
|
|
463
|
+
/>
|
|
464
|
+
) : null}
|
|
465
|
+
{(["needs-you", "running", "finished"] as const).map((section) => {
|
|
466
|
+
const rows = (items ?? []).filter(
|
|
467
|
+
(item) => runSection(runs[item.runId]) === section,
|
|
468
|
+
);
|
|
469
|
+
return rows.length === 0 ? null : (
|
|
470
|
+
<section key={section} aria-label={sectionTitles[section]}>
|
|
471
|
+
<DataListHeading level={3}>
|
|
472
|
+
{sectionTitles[section]}
|
|
473
|
+
</DataListHeading>
|
|
474
|
+
<DataList density="compact">
|
|
475
|
+
{rows.map((item) => {
|
|
476
|
+
const when = new Date(item.startedAt).toISOString();
|
|
477
|
+
const line =
|
|
478
|
+
runLatestLine(runs[item.runId]) ??
|
|
479
|
+
readErrors.get(item.runId)?.message ??
|
|
480
|
+
sourceLabel[item.source];
|
|
481
|
+
return (
|
|
482
|
+
<DataRow key={item.runId} data-run-id={item.runId}>
|
|
483
|
+
<ActionRow
|
|
484
|
+
appearance="plain"
|
|
485
|
+
density="compact"
|
|
486
|
+
variant="ghost"
|
|
487
|
+
className={
|
|
488
|
+
selected?.runId === item.runId
|
|
489
|
+
? "wf-run-choice bg-base-300"
|
|
490
|
+
: "wf-run-choice"
|
|
491
|
+
}
|
|
492
|
+
aria-current={
|
|
493
|
+
selected?.runId === item.runId ? "page" : undefined
|
|
494
|
+
}
|
|
495
|
+
onClick={() => {
|
|
496
|
+
onSelect(item.runId, item.workflowId);
|
|
497
|
+
}}
|
|
498
|
+
label={`${item.title ?? "Workflow title unavailable"}${item.version ? ` · v${String(item.version)}` : ""}`}
|
|
499
|
+
description={
|
|
500
|
+
item.source === "manual"
|
|
501
|
+
? line
|
|
502
|
+
: `${sourceLabel[item.source]} · ${line}`
|
|
503
|
+
}
|
|
504
|
+
trailing={
|
|
505
|
+
<span className="inline-flex items-center gap-1.5">
|
|
506
|
+
<NodeStatus
|
|
507
|
+
state={runBeadState(runs[item.runId])}
|
|
508
|
+
bare
|
|
509
|
+
/>
|
|
510
|
+
<time dateTime={when}>
|
|
511
|
+
{versionRelativeTime(when)}
|
|
512
|
+
</time>
|
|
513
|
+
</span>
|
|
514
|
+
}
|
|
515
|
+
/>
|
|
516
|
+
</DataRow>
|
|
517
|
+
);
|
|
518
|
+
})}
|
|
519
|
+
</DataList>
|
|
520
|
+
</section>
|
|
521
|
+
);
|
|
522
|
+
})}
|
|
523
|
+
</Surface>
|
|
524
|
+
) : null}
|
|
525
|
+
{showDetail && selected ? (
|
|
526
|
+
<RunDetail
|
|
527
|
+
key={selected.runId}
|
|
528
|
+
client={client}
|
|
529
|
+
item={selected}
|
|
530
|
+
readMedia={readMedia}
|
|
531
|
+
openIn={openIn}
|
|
532
|
+
onStatus={recordStatus}
|
|
533
|
+
viewerId={viewerId}
|
|
534
|
+
/>
|
|
535
|
+
) : showDetail ? (
|
|
536
|
+
<EmptyState
|
|
537
|
+
variant="panel"
|
|
538
|
+
title="Choose a run"
|
|
539
|
+
description="Select a run from the list to see what happened."
|
|
540
|
+
/>
|
|
541
|
+
) : null}
|
|
542
|
+
</main>
|
|
543
|
+
);
|
|
544
|
+
}
|