@runwisp/ui 1.5.1 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/package.json +5 -7
  2. package/src/lib/actions/fold-to-fit.test.ts +36 -0
  3. package/src/lib/actions/fold-to-fit.ts +89 -0
  4. package/src/lib/actions/modal-dialog.test.ts +47 -0
  5. package/src/lib/actions/modal-dialog.ts +48 -0
  6. package/src/lib/components/AccordionItem.svelte +16 -5
  7. package/src/lib/components/Badge.svelte +3 -24
  8. package/src/lib/components/Button.svelte +2 -9
  9. package/src/lib/components/CodeBlock.svelte +7 -6
  10. package/src/lib/components/Drawer.svelte +22 -30
  11. package/src/lib/components/DurationInput.svelte +6 -25
  12. package/src/lib/components/FilterBar.svelte +2 -6
  13. package/src/lib/components/Link.svelte +9 -4
  14. package/src/lib/components/LogConsole.svelte +95 -418
  15. package/src/lib/components/Modal.svelte +7 -30
  16. package/src/lib/components/Spinner.svelte +10 -5
  17. package/src/lib/components/StatusIndicator.svelte +37 -104
  18. package/src/lib/components/Tabs.svelte +15 -1
  19. package/src/lib/components/dashboard/RunActions.svelte +156 -0
  20. package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
  21. package/src/lib/components/dashboard/RunDetailPanel.svelte +130 -717
  22. package/src/lib/components/dashboard/RunFacts.svelte +119 -0
  23. package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
  24. package/src/lib/components/dashboard/RunFilterPopover.svelte +4 -5
  25. package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
  26. package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
  27. package/src/lib/components/dashboard/RunRow.svelte +202 -0
  28. package/src/lib/components/dashboard/RunsList.svelte +265 -543
  29. package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
  30. package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
  31. package/src/lib/components/dashboard/cron-format.test.ts +9 -0
  32. package/src/lib/components/dashboard/cron-format.ts +5 -1
  33. package/src/lib/components/dashboard/run-filters.test.ts +60 -0
  34. package/src/lib/components/dashboard/run-filters.ts +78 -6
  35. package/src/lib/components/dashboard/run-helpers.test.ts +122 -12
  36. package/src/lib/components/dashboard/run-helpers.ts +122 -6
  37. package/src/lib/components/dashboard/status-config.ts +18 -0
  38. package/src/lib/components/dashboard/types.ts +6 -0
  39. package/src/lib/index.ts +13 -2
  40. package/src/lib/log-console/AnsiLine.svelte +12 -0
  41. package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
  42. package/src/lib/log-console/LogCache.svelte.test.ts +135 -0
  43. package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
  44. package/src/lib/log-console/LogFetcher.svelte.test.ts +159 -0
  45. package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
  46. package/src/lib/log-console/frame-history.svelte.ts +71 -0
  47. package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
  48. package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
  49. package/src/lib/utils/format.test.ts +122 -0
  50. package/src/lib/utils/format.ts +97 -4
  51. package/src/lib/utils/toast.svelte.test.ts +46 -0
  52. package/src/lib/utils/typing-target.ts +10 -0
  53. package/src/stories/Accordion.stories.svelte +60 -1
  54. package/src/stories/CodeBlock.stories.svelte +13 -0
  55. package/src/stories/Link.stories.svelte +25 -1
  56. package/src/stories/Tabs.stories.svelte +17 -0
@@ -1,50 +1,32 @@
1
1
  <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
2
  <!-- SPDX-License-Identifier: Apache-2.0 -->
3
3
 
4
- <script lang="ts" module>
5
- /** A single output-search hit surfaced under its run in the history rail. */
6
- export interface RunOutputMatch {
7
- line: number;
8
- text: string;
9
- }
10
- </script>
11
-
12
4
  <script lang="ts">
13
- import { Clock, ArrowUpDown, X, Square, Trash, RotateCw } from "@lucide/svelte";
5
+ import {
6
+ Clock,
7
+ ArrowUpDown,
8
+ PanelLeftClose,
9
+ PanelLeftOpen,
10
+ Square,
11
+ Trash,
12
+ RotateCw,
13
+ } from "@lucide/svelte";
14
14
  import { untrack } from "svelte";
15
- import { SvelteSet } from "svelte/reactivity";
16
15
  import { createVirtualizer } from "@tanstack/svelte-virtual";
17
16
  import Button from "../Button.svelte";
18
17
  import { arrival, leave, prefersReducedMotion } from "../../actions/row-motion.js";
19
18
  import type { RunMotion } from "../../utils/run-motion.js";
20
19
  import EmptyState from "../EmptyState.svelte";
20
+ import { BulkSelection } from "./bulk-selection.svelte.js";
21
+ import RunFilterChips from "./RunFilterChips.svelte";
21
22
  import RunFilterPopover from "./RunFilterPopover.svelte";
22
- import { displayStatus, type Run, type RunSelector, type RunStatus } from "@runwisp/common";
23
- import { RUN_STATUS_CONFIG } from "./status-config.js";
24
- import {
25
- activeDimensions,
26
- clearDimension,
27
- statusChipLabel,
28
- triggerDescription,
29
- exitCodeRange,
30
- exitCodeChipLabel,
31
- isWholeDay,
32
- type RunsListFilters,
33
- type FilterDimension,
34
- } from "./run-filters.js";
35
- import {
36
- runDuration,
37
- formatTriggeredByLabel,
38
- runRetryLabel,
39
- instanceSuffix,
40
- } from "./run-helpers.js";
41
- import {
42
- formatDateTime,
43
- formatFullDateTime,
44
- formatTimeHM,
45
- formatDayMonth,
46
- formatCalendarDate,
47
- } from "../../utils/format.js";
23
+ import RunListSkeleton from "./RunListSkeleton.svelte";
24
+ import RunRow from "./RunRow.svelte";
25
+ import type { RunOutputMatch } from "./types.js";
26
+ import type { Run, RunSelector } from "@runwisp/common";
27
+ import { runFilterParams, type RunsListFilters } from "./run-filters.js";
28
+ import { instanceSuffix, runUsageScale } from "./run-helpers.js";
29
+ import { formatDateTime } from "../../utils/format.js";
48
30
 
49
31
  type BulkHandler = (selector: RunSelector, affected: Run[]) => void;
50
32
 
@@ -61,7 +43,6 @@
61
43
  tasks = [],
62
44
  showTaskName = false,
63
45
  taskNameFilter,
64
- headerLabel = "Run History",
65
46
  emptyText = "No runs yet",
66
47
  emptyDescription,
67
48
  bulkActions = false,
@@ -74,6 +55,9 @@
74
55
  outputQuery = "",
75
56
  outputMatches = null,
76
57
  outputSearchPending = false,
58
+ usageBars = false,
59
+ folded = false,
60
+ onToggleFold,
77
61
  }: {
78
62
  items: Run[];
79
63
  total: number;
@@ -90,7 +74,6 @@
90
74
  tasks?: { name: string }[];
91
75
  showTaskName?: boolean;
92
76
  taskNameFilter?: string;
93
- headerLabel?: string;
94
77
  emptyText?: string;
95
78
  emptyDescription?: string;
96
79
  bulkActions?: boolean;
@@ -104,10 +87,10 @@
104
87
  // in or out. Page loads, scrolling and filtering never do.
105
88
  motion?: RunMotion;
106
89
  // Output search (history rail): filters runs by what they printed. The
107
- // search box lives in the app header now; the parent owns the box, the
108
- // query, and the async log search. This component just renders the
109
- // matched rows. `outputSearch` enables the mode; `outputQuery` is the
110
- // live query (drives the active state and snippet highlighting).
90
+ // parent owns the search box, the query and the async log search; this
91
+ // component just renders the matched rows. `outputSearch` enables the
92
+ // mode; `outputQuery` is the live query (drives the active state and
93
+ // snippet highlighting).
111
94
  outputSearch?: boolean;
112
95
  outputQuery?: string;
113
96
  // run id → first matching line, supplied by the parent after a search.
@@ -116,25 +99,29 @@
116
99
  // True while a query is typed but its results aren't in yet (debounce
117
100
  // window or request in flight), the rail shows its searching shimmer.
118
101
  outputSearchPending?: boolean;
102
+ // Task rail only: two small bars per finished run, CPU time over RAM,
103
+ // scaled to the largest loaded run and colored against the usual one.
104
+ usageBars?: boolean;
105
+ // Folds the list to a thin strip that still counts the runs, giving the
106
+ // run detail the width. Omitted where the screen keeps the list open.
107
+ folded?: boolean;
108
+ onToggleFold?: (() => void) | undefined;
119
109
  } = $props();
120
110
 
111
+ const usageScale = $derived(usageBars ? runUsageScale(items) : undefined);
112
+
121
113
  // Task-rail rows are one dense line (44px); the cross-task /runs view adds
122
114
  // the task name on a second line (64px).
123
115
  const rowHeight = $derived(showTaskName ? 64 : 44);
124
116
  const OVERSCAN = 8;
125
117
  const LOAD_AHEAD = 10;
126
118
 
127
- // Selection model: two modes,
128
- // 1. explicit: user picked specific rows. explicitIds holds them.
129
- // 2. selectAll: "all matching the current filter". exceptIds holds opt-outs.
130
- let selectAllMode = $state(false);
131
- const explicitIds = new SvelteSet<string>();
132
- const exceptIds = new SvelteSet<string>();
119
+ const selection = new BulkSelection();
133
120
 
134
121
  let scrollElement: HTMLDivElement | undefined = $state();
135
122
 
136
- // Output search filters the rail by what each run printed. The query lives
137
- // in the app header now; this component only renders against it.
123
+ // Output search filters the rail by what each run printed; the query is the
124
+ // parent's, this component only renders against it.
138
125
  const outputSearchActive = $derived(outputSearch && outputQuery.trim().length > 0);
139
126
 
140
127
  // Loaded runs that matched the output search, in list order. A match in a
@@ -144,41 +131,6 @@
144
131
  outputMatches ? items.filter((r: Run) => outputMatches.has(r.id)) : [],
145
132
  );
146
133
 
147
- // Split the matching line into [before, match, after] around the first
148
- // occurrence of the query, windowed to keep the match in view, mirrors the
149
- // artifact's snippet. Rendered as plain text spans (Svelte auto-escapes), so
150
- // no untrusted HTML ever reaches the DOM.
151
- interface HighlightParts {
152
- before: string;
153
- match: string;
154
- after: string;
155
- }
156
-
157
- function highlightParts(text: string, query: string): HighlightParts {
158
- const q = query.trim();
159
- const idx = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;
160
- if (idx === -1) return { before: text, match: "", after: "" };
161
- const start = Math.max(0, idx - 14);
162
- const lead = start > 0 ? "…" : "";
163
- const windowed = text.slice(start);
164
- const fi = windowed.toLowerCase().indexOf(q.toLowerCase());
165
- return {
166
- before: lead + windowed.slice(0, fi),
167
- match: windowed.slice(fi, fi + q.length),
168
- after: windowed.slice(fi + q.length),
169
- };
170
- }
171
-
172
- // Right-hand mono readout on a task-rail row: live / queued / exit N / dur.
173
- function rowRightLabel(run: Run, displayed: RunStatus): string {
174
- if (run.status === "running") return "live";
175
- if (run.status === "pending") return "queued";
176
- if (displayed === "failed" || displayed === "crashed") {
177
- return "exit " + String(run.exitCode);
178
- }
179
- return runDuration(run) ?? "—";
180
- }
181
-
182
134
  const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
183
135
  count: 0,
184
136
  getScrollElement: () => scrollElement ?? null,
@@ -232,45 +184,18 @@
232
184
  untrack(() => $virtualizer.scrollToIndex(index, { align: "auto", behavior }));
233
185
  });
234
186
 
235
- function isRowSelected(id: string): boolean {
236
- return selectAllMode ? !exceptIds.has(id) : explicitIds.has(id);
237
- }
238
-
239
- function toggleRow(id: string) {
240
- if (selectAllMode) {
241
- if (exceptIds.has(id)) exceptIds.delete(id);
242
- else exceptIds.add(id);
243
- } else {
244
- if (explicitIds.has(id)) explicitIds.delete(id);
245
- else explicitIds.add(id);
246
- }
247
- }
248
-
249
- function clearSelection() {
250
- selectAllMode = false;
251
- explicitIds.clear();
252
- exceptIds.clear();
253
- }
254
-
255
- function selectAllVisible() {
256
- selectAllMode = true;
257
- explicitIds.clear();
258
- exceptIds.clear();
259
- }
260
-
261
187
  function selectRun(runId: string) {
262
188
  selectedRunId = runId;
263
189
  onselect?.(runId);
264
190
  }
265
191
 
266
- let selectedRuns = $derived(items.filter((r: Run) => isRowSelected(r.id)));
192
+ let selectedRuns = $derived(items.filter((r: Run) => selection.isSelected(r.id)));
267
193
  let selectionCount = $derived(selectedRuns.length);
268
194
  let hasSelection = $derived(selectionCount > 0);
269
195
  // Task-rail rows overlay the checkbox on the status dot (it appears on hover
270
196
  // or once a selection exists). selectionActive forces all checkboxes visible
271
197
  // so the operator can extend the selection without hunting per-row hovers.
272
198
  let selectionActive = $derived(bulkActions && hasSelection);
273
- let allSelected = $derived(selectAllMode && exceptIds.size === 0);
274
199
 
275
200
  let anyRunning = $derived(selectedRuns.some((r: Run) => r.status === "running"));
276
201
  let anyTerminal = $derived(
@@ -282,90 +207,33 @@
282
207
  // popover dimensions only apply where the popover is shown; the task scope
283
208
  // (page-injected or popover-set) always applies.
284
209
  function buildSelectorFilter(): NonNullable<RunSelector["filter"]> {
285
- const filter: NonNullable<RunSelector["filter"]> = {};
286
- if (taskNameFilter) filter.taskName = taskNameFilter;
287
- else if (filters.taskName) filter.taskName = filters.taskName;
288
- if (!showFilters) return filter;
289
- if (filters.statuses.length > 0) filter.status = filters.statuses.join(",");
290
- const query = filters.search.trim();
291
- if (query) filter.search = query;
292
- if (filters.createdAfter) filter.createdAfter = filters.createdAfter;
293
- if (filters.createdBefore) filter.createdBefore = filters.createdBefore;
294
- if (filters.triggeredBy) filter.triggeredBy = filters.triggeredBy;
295
- const exit = exitCodeRange(filters.exitCode);
296
- if (exit.min !== undefined) filter.exitCodeMin = exit.min;
297
- if (exit.max !== undefined) filter.exitCodeMax = exit.max;
298
- if (filters.retriesOnly) filter.retriesOnly = true;
210
+ const filter: NonNullable<RunSelector["filter"]> = showFilters
211
+ ? runFilterParams(filters)
212
+ : {};
213
+ const taskName = taskNameFilter || filters.taskName;
214
+ if (taskName) filter.taskName = taskName;
299
215
  return filter;
300
216
  }
301
217
 
302
- function buildSelector(): RunSelector {
303
- if (!selectAllMode) {
304
- return { matchAll: false, ids: [...explicitIds] };
305
- }
306
- return {
307
- matchAll: true,
308
- filter: buildSelectorFilter(),
309
- exceptIds: [...exceptIds],
310
- };
311
- }
312
-
313
- // Active-filter chips for the header row. `task` only chips on the
314
- // cross-task view; everywhere else the task name is the page scope.
315
- const filterChips = $derived(
316
- activeDimensions(filters)
317
- .filter((dim) => dim !== "task" || showTask)
318
- .map((dim) => ({ dimension: dim, label: chipLabel(dim) })),
319
- );
320
-
321
- function chipLabel(dim: FilterDimension): string {
322
- switch (dim) {
323
- case "status":
324
- return statusChipLabel(filters.statuses);
325
- case "time":
326
- return timeChipLabel();
327
- case "task":
328
- return filters.taskName ?? "";
329
- case "triggeredBy":
330
- return "Trigger: " + triggerDescription(filters.triggeredBy ?? "");
331
- case "exitCode":
332
- return exitCodeChipLabel(filters.exitCode);
333
- case "retries":
334
- return "Retries only";
335
- }
336
- }
337
-
338
- function timeChipLabel(): string {
339
- const after = filters.createdAfter;
340
- const before = filters.createdBefore;
341
- if (isWholeDay(after, before) && after) return `On ${formatCalendarDate(after)}`;
342
- if (after && before) return `${formatDateTime(after)} – ${formatDateTime(before)}`;
343
- if (after) return `Since ${formatDateTime(after)}`;
344
- if (before) return `Before ${formatDateTime(before)}`;
345
- return "";
346
- }
347
-
348
- function removeChip(dim: FilterDimension) {
349
- filters = clearDimension(filters, dim);
350
- }
351
-
352
218
  function emitBulk(handler: BulkHandler | undefined, predicate: (r: Run) => boolean) {
353
219
  if (!handler) return;
354
220
  const affected = selectedRuns.filter(predicate);
355
221
  if (affected.length === 0) return;
356
- const sel = buildSelector();
357
- // Narrow the selector to the affected predicate when in explicit mode
358
- // so we don't ask the server to operate on rows the UI excluded.
359
- const narrowed: RunSelector = sel.matchAll
360
- ? sel
361
- : { matchAll: false, ids: affected.map((r) => r.id) };
362
- handler(narrowed, affected);
363
- clearSelection();
222
+ // In explicit mode the selector is narrowed to the affected rows, so
223
+ // the server is never asked to touch rows the UI excluded.
224
+ handler(
225
+ selection.selector(
226
+ buildSelectorFilter(),
227
+ affected.map((r) => r.id),
228
+ ),
229
+ affected,
230
+ );
231
+ selection.clear();
364
232
  }
365
233
 
366
234
  function handleMasterToggle() {
367
- if (hasSelection) clearSelection();
368
- else selectAllVisible();
235
+ if (hasSelection) selection.clear();
236
+ else selection.selectAll();
369
237
  }
370
238
 
371
239
  function toggleSortDirection() {
@@ -378,282 +246,229 @@
378
246
  };
379
247
  }
380
248
 
249
+ // The count stands in for a title: "12 runs", or matches while searching.
250
+ // Empty until the first page lands, when the count isn't known yet.
251
+ const countLabel = $derived.by(() => {
252
+ if (outputSearchActive) return `${String(matchedRuns.length)} of ${String(items.length)}`;
253
+ if (loading && items.length === 0) return "";
254
+ return `${String(total)} ${total === 1 ? "run" : "runs"}`;
255
+ });
256
+
381
257
  let masterCheckboxRef: HTMLInputElement | undefined = $state();
382
258
  $effect(() => {
383
259
  if (!masterCheckboxRef) return;
384
- masterCheckboxRef.indeterminate = hasSelection && !allSelected;
260
+ masterCheckboxRef.indeterminate = hasSelection && !selection.allSelected;
385
261
  });
386
262
  </script>
387
263
 
388
- <!-- A borderless rail that fills its column, divided from the detail panel by
264
+ {#if folded && onToggleFold}
265
+ <button
266
+ type="button"
267
+ class="flex w-[30px] shrink-0 flex-col items-center gap-2 border-r border-outline bg-surface pt-2.5 font-mono text-xs text-on-surface-muted hover:bg-surface-sunken hover:text-primary"
268
+ title="Show run list"
269
+ aria-label="Show run list"
270
+ aria-expanded="false"
271
+ onclick={onToggleFold}
272
+ >
273
+ <PanelLeftOpen size={16} />
274
+ <span class="tabular-nums [writing-mode:vertical-rl]">{countLabel}</span>
275
+ </button>
276
+ {:else}
277
+ <!-- A borderless rail that fills its column, divided from the detail panel by
389
278
  a single right border. -->
390
- <div
391
- class="flex h-full w-full flex-col overflow-hidden border-b border-outline bg-surface md:w-[300px] md:shrink-0 md:border-r md:border-b-0"
392
- >
393
279
  <div
394
- class="flex shrink-0 items-center gap-2 border-b px-3 py-2 {hasSelection
395
- ? 'border-outline-faint bg-primary-soft/40'
396
- : 'border-transparent bg-surface'}"
280
+ class="flex h-full w-full flex-col overflow-hidden border-b border-outline bg-surface md:w-[300px] md:shrink-0 md:border-r md:border-b-0"
397
281
  >
398
- {#if bulkActions}
399
- <label
400
- class="flex shrink-0 cursor-pointer items-center"
401
- title={hasSelection ? "Clear selection" : "Select all"}
402
- >
403
- <input
404
- bind:this={masterCheckboxRef}
405
- type="checkbox"
406
- checked={allSelected}
407
- onchange={handleMasterToggle}
408
- class="h-3.5 w-3.5 cursor-pointer rounded border-outline accent-primary"
409
- aria-label={hasSelection ? "Clear selection" : "Select all"}
410
- />
411
- </label>
412
- {/if}
282
+ <div
283
+ class="flex shrink-0 items-center gap-2 border-b px-3 py-2 {hasSelection
284
+ ? 'border-outline-faint bg-primary-soft/40'
285
+ : 'border-transparent bg-surface'}"
286
+ >
287
+ {#if bulkActions}
288
+ <label
289
+ class="flex shrink-0 cursor-pointer items-center"
290
+ title={hasSelection ? "Clear selection" : "Select all"}
291
+ >
292
+ <input
293
+ bind:this={masterCheckboxRef}
294
+ type="checkbox"
295
+ checked={selection.allSelected}
296
+ onchange={handleMasterToggle}
297
+ class="h-3.5 w-3.5 cursor-pointer rounded border-outline accent-primary"
298
+ aria-label={hasSelection ? "Clear selection" : "Select all"}
299
+ />
300
+ </label>
301
+ {/if}
413
302
 
414
- {#if hasSelection}
415
- <span class="font-mono text-xs font-medium text-on-surface tabular-nums">
416
- {selectionCount} selected
417
- </span>
418
- <div class="ml-auto flex items-center gap-1">
419
- {#if onBulkRerun}
420
- <Button
421
- variant="ghost"
422
- size="xs"
423
- class="h-7 w-7 px-0"
424
- onclick={() => emitBulk(onBulkRerun, () => true)}
425
- title="Re-run task{selectionCount === 1 ? '' : 's'}"
426
- >
427
- {#snippet icon()}<RotateCw size={14} />{/snippet}
428
- </Button>
429
- {/if}
430
- {#if onBulkCancel && anyRunning}
303
+ {#if hasSelection}
304
+ <span class="font-mono text-xs font-medium text-on-surface tabular-nums">
305
+ {selectionCount} selected
306
+ </span>
307
+ <div class="ml-auto flex items-center gap-1">
308
+ {#if onBulkRerun}
309
+ <Button
310
+ variant="ghost"
311
+ size="xs"
312
+ class="h-7 w-7 px-0"
313
+ onclick={() => emitBulk(onBulkRerun, () => true)}
314
+ title="Re-run task{selectionCount === 1 ? '' : 's'}"
315
+ >
316
+ {#snippet icon()}<RotateCw size={14} />{/snippet}
317
+ </Button>
318
+ {/if}
319
+ {#if onBulkCancel && anyRunning}
320
+ <Button
321
+ variant="ghost"
322
+ size="xs"
323
+ class="h-7 w-7 px-0"
324
+ onclick={() => emitBulk(onBulkCancel, (r) => r.status === "running")}
325
+ title="Cancel running run{selectionCount === 1 ? '' : 's'}"
326
+ >
327
+ {#snippet icon()}<Square size={14} />{/snippet}
328
+ </Button>
329
+ {/if}
330
+ {#if onBulkDelete && anyTerminal}
331
+ <Button
332
+ variant="danger"
333
+ size="xs"
334
+ class="h-7 w-7 px-0"
335
+ onclick={() =>
336
+ emitBulk(
337
+ onBulkDelete,
338
+ (r) => r.status !== "running" && r.status !== "pending",
339
+ )}
340
+ title="Delete run{selectionCount === 1 ? '' : 's'}"
341
+ >
342
+ {#snippet icon()}<Trash size={14} />{/snippet}
343
+ </Button>
344
+ {/if}
345
+ </div>
346
+ {:else}
347
+ <span
348
+ class="shrink-0 font-mono text-xs whitespace-nowrap text-on-surface-muted tabular-nums"
349
+ >
350
+ {countLabel}
351
+ </span>
352
+ <div class="ml-auto flex items-center gap-1">
353
+ {#if showFilters}
354
+ <RunFilterPopover bind:filters {showTask} {tasks} />
355
+ {/if}
431
356
  <Button
432
357
  variant="ghost"
433
358
  size="xs"
434
359
  class="h-7 w-7 px-0"
435
- onclick={() => emitBulk(onBulkCancel, (r) => r.status === "running")}
436
- title="Cancel running run{selectionCount === 1 ? '' : 's'}"
360
+ onclick={toggleSortDirection}
361
+ title="Toggle sort order"
437
362
  >
438
- {#snippet icon()}<Square size={14} />{/snippet}
363
+ {#snippet icon()}
364
+ <ArrowUpDown
365
+ size={14}
366
+ class="text-on-surface-muted {filters.sortDirection === 'asc'
367
+ ? 'rotate-180'
368
+ : ''}"
369
+ />
370
+ {/snippet}
439
371
  </Button>
440
- {/if}
441
- {#if onBulkDelete && anyTerminal}
442
- <Button
443
- variant="danger"
444
- size="xs"
445
- class="h-7 w-7 px-0"
446
- onclick={() =>
447
- emitBulk(
448
- onBulkDelete,
449
- (r) => r.status !== "running" && r.status !== "pending",
450
- )}
451
- title="Delete run{selectionCount === 1 ? '' : 's'}"
452
- >
453
- {#snippet icon()}<Trash size={14} />{/snippet}
454
- </Button>
455
- {/if}
456
- </div>
457
- {:else}
458
- <span
459
- class="shrink-0 font-mono text-xs font-semibold tracking-[0.14em] whitespace-nowrap text-on-surface-muted uppercase"
460
- >
461
- {headerLabel}
462
- </span>
463
- <span
464
- class="shrink-0 font-mono text-xs whitespace-nowrap text-on-surface-faint tabular-nums"
465
- >
466
- {#if outputSearchActive}
467
- {matchedRuns.length} of {items.length}
468
- {:else if loading && items.length === 0}
469
- <!-- Count unknown until the first page lands. -->
470
- {:else}
471
- {total}
472
- {total === 1 ? "run" : "runs"}
473
- {/if}
474
- </span>
475
- <div class="ml-auto flex items-center gap-1">
476
- {#if showFilters}
477
- <RunFilterPopover bind:filters {showTask} {tasks} />
478
- {/if}
479
- <Button
480
- variant="ghost"
481
- size="xs"
482
- class="h-7 w-7 px-0"
483
- onclick={toggleSortDirection}
484
- title="Toggle sort order"
485
- >
486
- {#snippet icon()}
487
- <ArrowUpDown
488
- size={14}
489
- class="text-on-surface-muted {filters.sortDirection === 'asc'
490
- ? 'rotate-180'
491
- : ''}"
492
- />
493
- {/snippet}
494
- </Button>
495
- </div>
496
- {/if}
497
- </div>
498
-
499
- {#if showFilters && filterChips.length > 0}
500
- <!-- Active-filter chips. Rendered only when filters are set, so the
501
- header stays clean when empty; each chip's X clears its dimension.
502
- The filter controls themselves live in the popover above. -->
503
- <div
504
- class="flex shrink-0 flex-wrap gap-1 border-b border-outline-faint bg-surface-sunken px-3 py-2"
505
- >
506
- {#each filterChips as chip (chip.dimension)}
507
- <span
508
- class="inline-flex items-center gap-1 rounded-[3px] border border-primary-soft-border bg-primary-soft py-0.5 pr-1 pl-2 font-mono text-2xs font-medium text-primary-soft-text"
509
- >
510
- {chip.label}
511
- <button
512
- type="button"
513
- onclick={() => removeChip(chip.dimension)}
514
- class="flex size-3.5 items-center justify-center rounded-[3px] text-primary-soft-text/70 hover:bg-primary/15 hover:text-primary-soft-text"
515
- aria-label="Remove {chip.label} filter"
516
- >
517
- <X size={11} />
518
- </button>
519
- </span>
520
- {/each}
372
+ {#if onToggleFold}
373
+ <Button
374
+ variant="ghost"
375
+ size="xs"
376
+ class="h-7 w-7 px-0"
377
+ onclick={onToggleFold}
378
+ title="Hide run list"
379
+ aria-label="Hide run list"
380
+ aria-expanded="true"
381
+ >
382
+ {#snippet icon()}
383
+ <PanelLeftClose size={14} class="text-on-surface-muted" />
384
+ {/snippet}
385
+ </Button>
386
+ {/if}
387
+ </div>
388
+ {/if}
521
389
  </div>
522
- {/if}
523
390
 
524
- <div bind:this={scrollElement} class="min-h-0 flex-1 overflow-y-auto p-2">
525
- {#if outputSearchActive}
526
- <!-- Output-search results: the rail filters to runs that printed the
391
+ {#if showFilters}
392
+ <RunFilterChips bind:filters {showTask} />
393
+ {/if}
394
+
395
+ <div bind:this={scrollElement} class="min-h-0 flex-1 overflow-y-auto p-2">
396
+ {#if outputSearchActive}
397
+ <!-- Output-search results: the rail filters to runs that printed the
527
398
  query, each annotated with the matching line. -->
528
- {#if outputSearchPending}
529
- {@render skeletonRows("Searching output")}
530
- {:else if matchedRuns.length === 0}
531
- <div class="px-4 py-8 text-center text-xs leading-relaxed text-on-surface-muted">
532
- No output matches
533
- <b class="font-mono text-on-surface">“{outputQuery.trim()}”</b>.<br />
534
- Try a different term.
535
- </div>
399
+ {#if outputSearchPending}
400
+ <RunListSkeleton label="Searching output" {showTaskName} />
401
+ {:else if matchedRuns.length === 0}
402
+ <div
403
+ class="px-4 py-8 text-center text-xs leading-relaxed text-on-surface-muted"
404
+ >
405
+ No output matches
406
+ <b class="font-mono text-on-surface">“{outputQuery.trim()}”</b>.<br />
407
+ Try a different term.
408
+ </div>
409
+ {:else}
410
+ <div class="flex flex-col gap-0.5">
411
+ {#each matchedRuns as run (run.id)}
412
+ <div class="group/row relative flex items-stretch gap-1">
413
+ {#if bulkActions}
414
+ {@render rowCheckboxOverlay(run)}
415
+ {/if}
416
+ {@render runRow(run, outputMatches?.get(run.id))}
417
+ </div>
418
+ {/each}
419
+ </div>
420
+ {/if}
421
+ {:else if items.length === 0 && loading}
422
+ <RunListSkeleton label="Loading runs" {showTaskName} />
423
+ {:else if items.length === 0}
424
+ <EmptyState
425
+ title={emptyText}
426
+ description={emptyDescription}
427
+ icon={Clock}
428
+ iconSize={32}
429
+ class="py-8"
430
+ />
536
431
  {:else}
537
- <div class="flex flex-col gap-0.5">
538
- {#each matchedRuns as run (run.id)}
539
- <div class="group/row relative flex items-stretch gap-1">
540
- {#if bulkActions}
541
- {@render rowCheckboxOverlay(run)}
542
- {/if}
543
- {@render runRowButton(
544
- run,
545
- selectedRunId === run.id,
546
- outputMatches?.get(run.id),
547
- )}
548
- </div>
549
- {/each}
550
- </div>
551
- {/if}
552
- {:else if items.length === 0 && loading}
553
- {@render skeletonRows("Loading runs")}
554
- {:else if items.length === 0}
555
- <EmptyState
556
- title={emptyText}
557
- description={emptyDescription}
558
- icon={Clock}
559
- iconSize={32}
560
- class="py-8"
561
- />
562
- {:else}
563
- <div
564
- style:height="{$virtualizer.getTotalSize()}px"
565
- style:width="100%"
566
- style:position="relative"
567
- >
568
- {#each $virtualizer.getVirtualItems() as row (items[row.index]?.id ?? row.index)}
569
- {@const run = items[row.index]}
570
- {#if run}
571
- <!-- The transform transition slides rows aside when a live
432
+ <div
433
+ style:height="{$virtualizer.getTotalSize()}px"
434
+ style:width="100%"
435
+ style:position="relative"
436
+ >
437
+ {#each $virtualizer.getVirtualItems() as row (items[row.index]?.id ?? row.index)}
438
+ {@const run = items[row.index]}
439
+ {#if run}
440
+ <!-- The transform transition slides rows aside when a live
572
441
  run is inserted or removed. Navigation never animates:
573
442
  filter/sort/task changes remount rows, pagination
574
443
  appends, and scrolling doesn't move a row. `leave`
575
444
  is global because this {#if}, not the row's each
576
445
  item, is the block it would otherwise wait for; it
577
446
  only plays for runs removed live. -->
578
- <div
579
- data-run-id={run.id}
580
- use:arrival={motion?.arrived(run.id) ?? false}
581
- out:leave|global={motion?.removed}
582
- class="group/row flex items-center gap-1 transition-transform duration-200 ease-out motion-reduce:transition-none"
583
- style:transition-delay="var(--rw-shift-delay, 0ms)"
584
- style:position="absolute"
585
- style:top="0"
586
- style:left="0"
587
- style:width="100%"
588
- style:height="{row.size}px"
589
- style:transform="translateY({row.start}px)"
590
- >
591
- {#if bulkActions}
592
- {@render rowCheckboxOverlay(run)}
593
- {/if}
594
- {@render runRowButton(run, selectedRunId === run.id, undefined)}
595
- </div>
596
- {/if}
597
- {/each}
598
- </div>
599
- {/if}
600
- </div>
601
- </div>
602
-
603
- <!-- Status dot. With bulk actions on it fades out, on row hover, or whenever a
604
- selection exists, so the row checkbox can take its place over it. -->
605
- <!-- Placeholders shaped like this list's run rows (same padding, dot, text
606
- and right readout), while a search or the first page is in flight. -->
607
- {#snippet skeletonRows(label: string)}
608
- <div class="flex flex-col gap-0.5" role="status" aria-busy="true" aria-label={label}>
609
- {#each [0, 1, 2, 3, 4, 5] as i (i)}
610
- <div
611
- class="rounded-[3px] border border-transparent {showTaskName
612
- ? 'p-3'
613
- : 'px-3 py-[11px]'}"
614
- >
615
- <div class="flex items-center {showTaskName ? 'gap-2.5' : 'gap-[11px]'}">
616
- <span class="size-[9px] shrink-0 animate-pulse rounded-full bg-outline-hover"
617
- ></span>
618
- {#if showTaskName}
619
- <span class="flex min-w-0 flex-1 flex-col gap-1.5 py-0.5">
620
- <span
621
- class="h-3 animate-pulse rounded-[3px] bg-outline-hover"
622
- style:width="{62 - (i % 3) * 12}%"
623
- ></span>
624
- <span class="h-2 w-2/5 animate-pulse rounded-[3px] bg-outline-hover"
625
- ></span>
626
- </span>
627
- <span
628
- class="h-2.5 w-9 shrink-0 animate-pulse self-start rounded-[3px] bg-outline-hover"
629
- ></span>
630
- {:else}
631
- <span
632
- class="h-3 animate-pulse rounded-[3px] bg-outline-hover"
633
- style:width="{58 - (i % 3) * 8}%"
634
- ></span>
635
- <span
636
- class="ml-auto h-2.5 w-9 shrink-0 animate-pulse rounded-[3px] bg-outline-hover"
637
- ></span>
638
- {/if}
447
+ <div
448
+ data-run-id={run.id}
449
+ use:arrival={motion?.arrived(run.id) ?? false}
450
+ out:leave|global={motion?.removed}
451
+ class="group/row flex items-center gap-1 transition-transform duration-200 ease-out motion-reduce:transition-none"
452
+ style:transition-delay="var(--rw-shift-delay, 0ms)"
453
+ style:position="absolute"
454
+ style:top="0"
455
+ style:left="0"
456
+ style:width="100%"
457
+ style:height="{row.size}px"
458
+ style:transform="translateY({row.start}px)"
459
+ >
460
+ {#if bulkActions}
461
+ {@render rowCheckboxOverlay(run)}
462
+ {/if}
463
+ {@render runRow(run, undefined)}
464
+ </div>
465
+ {/if}
466
+ {/each}
639
467
  </div>
640
- </div>
641
- {/each}
468
+ {/if}
469
+ </div>
642
470
  </div>
643
- {/snippet}
644
-
645
- {#snippet statusDot(colorClass: string, running: boolean)}
646
- <span
647
- class="{colorClass} size-[9px] shrink-0 rounded-full bg-current ring-[3px] ring-current/20 {running
648
- ? 'animate-pulse'
649
- : ''} {bulkActions
650
- ? selectionActive
651
- ? 'opacity-0'
652
- : 'group-hover/row:opacity-0'
653
- : ''}"
654
- aria-hidden="true"
655
- ></span>
656
- {/snippet}
471
+ {/if}
657
472
 
658
473
  <!-- Row checkbox: sits over the status dot (which fades out beneath it) so the
659
474
  row keeps the artifact's geometry. Lives in the row wrapper (not the button)
@@ -667,8 +482,8 @@
667
482
  >
668
483
  <input
669
484
  type="checkbox"
670
- checked={isRowSelected(run.id)}
671
- onchange={() => toggleRow(run.id)}
485
+ checked={selection.isSelected(run.id)}
486
+ onchange={() => selection.toggle(run.id)}
672
487
  onclick={(e) => e.stopPropagation()}
673
488
  aria-label={`Select run from ${formatDateTime(run.startedAt ?? run.createdAt)}`}
674
489
  class="size-3.5 cursor-pointer rounded border-outline accent-primary opacity-0 {selectionActive
@@ -678,110 +493,17 @@
678
493
  </label>
679
494
  {/snippet}
680
495
 
681
- {#snippet runRowButton(run: Run, isActive: boolean, match: RunOutputMatch | undefined)}
682
- {@const dstatus = displayStatus(run.status, run.endReason)}
683
- {@const config = RUN_STATUS_CONFIG[dstatus]}
684
- {@const running = run.status === "running"}
685
- {@const spine = config.dot.replace(" animate-pulse", "")}
686
- {@const startedAt = run.startedAt ?? run.createdAt}
687
- {@const retry = runRetryLabel(run)}
688
- {@const suffix = instanceSuffix(run.instanceIndex, getInstanceCount(run.taskName))}
689
- <button
690
- class="btn-scale group relative w-full rounded-[3px] border text-left select-none {showTaskName
691
- ? 'p-3'
692
- : 'px-3 py-[11px]'} {isActive
693
- ? 'border-outline bg-surface-raised shadow-sm'
694
- : 'border-transparent hover:border-outline-hover hover:bg-surface-sunken'}"
695
- onclick={() => selectRun(run.id)}
696
- onkeydown={(e) => e.key === "Enter" && selectRun(run.id)}
697
- >
698
- {#if showTaskName}
699
- <!-- Cross-task /runs variant: the same readout language as the task
700
- rail, status dot, status-colored outcome, mono right readout,
701
- with the task name carried as the primary. -->
702
- <div class="flex items-center gap-2.5">
703
- {@render statusDot(config.color, running)}
704
- <span class="flex min-w-0 flex-1 flex-col gap-0.5">
705
- <span class="flex items-center gap-1.5">
706
- <span class="truncate font-mono text-[13px] font-semibold text-on-surface">
707
- {run.taskName}{#if suffix}<span class="text-on-surface-muted"
708
- >{suffix}</span
709
- >{/if}
710
- </span>
711
- <span class="shrink-0 text-on-surface-faint">·</span>
712
- <span
713
- class="shrink-0 font-mono text-[12px] font-semibold capitalize {config.color}"
714
- >{dstatus}</span
715
- >
716
- </span>
717
- <span
718
- class="flex min-w-0 items-center gap-1.5 font-mono text-2xs text-on-surface-faint"
719
- title={formatFullDateTime(startedAt)}
720
- >
721
- <span class="truncate">{formatDateTime(startedAt)}</span>
722
- <span class="shrink-0">· {formatTriggeredByLabel(run.triggeredBy)}</span>
723
- {#if retry}
724
- <span class="shrink-0 rounded bg-surface-sunken px-1 font-mono"
725
- >{retry}</span
726
- >
727
- {/if}
728
- </span>
729
- </span>
730
- <span
731
- class="shrink-0 self-start pt-0.5 font-mono text-[11.5px] text-on-surface-faint tabular-nums"
732
- title={retry ?? undefined}
733
- >
734
- {rowRightLabel(run, dstatus)}
735
- </span>
736
- </div>
737
- {:else}
738
- <!-- Task-rail variant (artifact ".run"): a single dense line,
739
- time · date · outcome, with a mono exit/duration readout.
740
- leading-tight matches the artifact's ~1.2 line-height so the
741
- (descender-less) text optically centers instead of riding high
742
- inside Tailwind's default 1.5 line box. -->
743
- <div class="flex items-center gap-[11px] leading-tight">
744
- {@render statusDot(config.color, running)}
745
- <span class="flex min-w-0 flex-1 items-center gap-1.5 truncate">
746
- <span
747
- class="font-mono text-[12.5px] font-semibold tracking-tight text-on-surface tabular-nums"
748
- title={formatFullDateTime(startedAt)}
749
- >
750
- {formatTimeHM(startedAt)} · {formatDayMonth(startedAt)}
751
- </span>
752
- <span class="text-on-surface-faint">·</span>
753
- <span class="font-mono text-[12.5px] font-semibold capitalize {config.color}"
754
- >{dstatus}</span
755
- >
756
- {#if suffix}
757
- <span class="font-mono text-2xs text-on-surface-faint">{suffix}</span>
758
- {/if}
759
- </span>
760
- <span
761
- class="shrink-0 font-mono text-[11.5px] text-on-surface-faint tabular-nums"
762
- title={retry ?? undefined}
763
- >
764
- {rowRightLabel(run, dstatus)}
765
- </span>
766
- </div>
767
- {#if match}
768
- {@const hl = highlightParts(match.text, outputQuery)}
769
- <div
770
- class="mt-1.5 truncate rounded-[3px] border border-outline-faint bg-surface-sunken px-2 py-1 font-mono text-2xs text-on-surface-muted"
771
- >
772
- {hl.before}<mark
773
- class="rounded-[3px] bg-primary-soft px-0.5 text-primary-soft-text"
774
- >{hl.match}</mark
775
- >{hl.after}
776
- </div>
777
- {/if}
778
- {/if}
779
-
780
- <div
781
- class="absolute inset-y-2 left-[-6px] w-[3px] rounded-[3px] {spine} {isActive
782
- ? 'opacity-100'
783
- : 'opacity-0'}"
784
- aria-hidden="true"
785
- ></div>
786
- </button>
496
+ {#snippet runRow(run: Run, match: RunOutputMatch | undefined)}
497
+ <RunRow
498
+ {run}
499
+ active={selectedRunId === run.id}
500
+ suffix={instanceSuffix(run.instanceIndex, getInstanceCount(run.taskName))}
501
+ {showTaskName}
502
+ {match}
503
+ {outputQuery}
504
+ {bulkActions}
505
+ {selectionActive}
506
+ usage={usageBars ? (usageScale ?? null) : undefined}
507
+ onselect={selectRun}
508
+ />
787
509
  {/snippet}