@runwisp/ui 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,39 +2,48 @@
2
2
  <!-- SPDX-License-Identifier: Apache-2.0 -->
3
3
 
4
4
  <script lang="ts" module>
5
- export type RunsListSortDirection = "asc" | "desc" | "";
6
-
7
- export interface RunsListFilters {
8
- search: string;
9
- status: string;
10
- sort_direction: RunsListSortDirection;
5
+ /** A single output-search hit surfaced under its run in the history rail. */
6
+ export interface RunOutputMatch {
7
+ line: number;
8
+ text: string;
11
9
  }
12
10
  </script>
13
11
 
14
12
  <script lang="ts">
15
- import {
16
- Clock,
17
- Timer,
18
- ArrowUpDown,
19
- Search,
20
- Funnel,
21
- Square,
22
- Trash2,
23
- RotateCw,
24
- } from "@lucide/svelte";
13
+ import { Clock, ArrowUpDown, X, Square, Trash2, RotateCw } from "@lucide/svelte";
25
14
  import { untrack } from "svelte";
26
15
  import { SvelteSet } from "svelte/reactivity";
27
16
  import { createVirtualizer } from "@tanstack/svelte-virtual";
28
17
  import Button from "../Button.svelte";
29
- import Badge from "../Badge.svelte";
30
18
  import EmptyState from "../EmptyState.svelte";
31
- import Tooltip from "../Tooltip.svelte";
19
+ import RunFilterPopover from "./RunFilterPopover.svelte";
32
20
  import type { Run } from "./types.js";
33
- import type { RunSelector } from "@runwisp/common";
21
+ import type { RunSelector, RunStatus } from "@runwisp/common";
34
22
  import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
35
- import { runDuration } from "./run-helpers.js";
36
- import { formatRelativeTime } from "../../utils/format.js";
37
- import { formatShortId } from "../../utils/id.js";
23
+ import {
24
+ activeDimensions,
25
+ clearDimension,
26
+ statusChipLabel,
27
+ triggerDescription,
28
+ exitCodeRange,
29
+ exitCodeChipLabel,
30
+ isWholeDay,
31
+ type RunsListFilters,
32
+ type FilterDimension,
33
+ } from "./run-filters.js";
34
+ import {
35
+ runDuration,
36
+ formatTriggeredByLabel,
37
+ runRetryLabel,
38
+ instanceSuffix,
39
+ } from "./run-helpers.js";
40
+ import {
41
+ formatDateTime,
42
+ formatFullDateTime,
43
+ formatTimeHM,
44
+ formatDayMonth,
45
+ formatCalendarDate,
46
+ } from "../../utils/format.js";
38
47
 
39
48
  type BulkHandler = (selector: RunSelector, affected: Run[]) => void;
40
49
 
@@ -47,6 +56,8 @@
47
56
  selectedRunId = $bindable(null),
48
57
  onselect,
49
58
  showFilters = false,
59
+ showTask = false,
60
+ tasks = [],
50
61
  showTaskName = false,
51
62
  taskNameFilter,
52
63
  headerLabel = "Run History",
@@ -56,6 +67,12 @@
56
67
  onBulkCancel,
57
68
  onBulkDelete,
58
69
  onBulkRerun,
70
+ getInstanceCount = () => 1,
71
+ flush = false,
72
+ outputSearch = false,
73
+ outputQuery = "",
74
+ outputMatches = null,
75
+ outputSearchPending = false,
59
76
  }: {
60
77
  items: Run[];
61
78
  total: number;
@@ -65,6 +82,11 @@
65
82
  selectedRunId?: string | null;
66
83
  onselect?: (runId: string) => void;
67
84
  showFilters?: boolean;
85
+ // Cross-task /runs view: lets the filter popover offer a Task select.
86
+ // On a single task's page this stays false (the task is the page scope).
87
+ showTask?: boolean;
88
+ // Task list backing the popover's Task select (cross-task view only).
89
+ tasks?: { name: string }[];
68
90
  showTaskName?: boolean;
69
91
  taskNameFilter?: string;
70
92
  headerLabel?: string;
@@ -74,12 +96,34 @@
74
96
  onBulkCancel?: BulkHandler;
75
97
  onBulkDelete?: BulkHandler;
76
98
  onBulkRerun?: BulkHandler;
99
+ // Resolves a task's currently configured instance count so multi-instance
100
+ // services render a 1-based #N suffix. Defaults to single-instance.
101
+ getInstanceCount?: (taskName: string) => number;
102
+ // Flush rail mode (task detail page): render as a borderless rail that
103
+ // fills its column and is divided from the detail panel by a single right
104
+ // border — no card chrome of its own. Default renders the standalone card
105
+ // the cross-task /runs grid expects.
106
+ flush?: boolean;
107
+ // Output search (history rail): filters runs by what they printed. The
108
+ // search box lives in the app header now; the parent owns the box, the
109
+ // query, and the async log search. This component just renders the
110
+ // matched rows. `outputSearch` enables the mode; `outputQuery` is the
111
+ // live query (drives the active state and snippet highlighting).
112
+ outputSearch?: boolean;
113
+ outputQuery?: string;
114
+ // run id → first matching line, supplied by the parent after a search.
115
+ // null = no active search; the full list shows.
116
+ outputMatches?: Map<string, RunOutputMatch> | null;
117
+ // True while a query is typed but its results aren't in yet (debounce
118
+ // window or request in flight) — the rail shows its searching shimmer.
119
+ outputSearchPending?: boolean;
77
120
  } = $props();
78
121
 
79
- const ROW_HEIGHT = 76;
122
+ // Task-rail rows are a single dense line (artifact ".run", 46px); the
123
+ // cross-task /runs view adds the task name on a second line (64px).
124
+ const rowHeight = $derived(showTaskName ? 64 : 44);
80
125
  const OVERSCAN = 8;
81
126
  const LOAD_AHEAD = 10;
82
- const SEARCH_DEBOUNCE_MS = 250;
83
127
 
84
128
  // Selection model: two modes —
85
129
  // 1. explicit: user picked specific rows. explicitIds holds them.
@@ -89,22 +133,57 @@
89
133
  const exceptIds = new SvelteSet<string>();
90
134
 
91
135
  let scrollElement: HTMLDivElement | undefined = $state();
92
- let searchInput = $state(filters.search);
93
136
 
94
- // Debounce typing in the search box before pushing into shared filters,
95
- // so we don't fire a network request on every keystroke.
96
- $effect(() => {
97
- const next = searchInput;
98
- const id = setTimeout(() => {
99
- if (filters.search !== next) filters.search = next;
100
- }, SEARCH_DEBOUNCE_MS);
101
- return () => clearTimeout(id);
102
- });
137
+ // Output search filters the rail by what each run printed. The query lives
138
+ // in the app header now; this component only renders against it.
139
+ const outputSearchActive = $derived(outputSearch && outputQuery.trim().length > 0);
140
+
141
+ // Loaded runs that matched the output search, in list order. A match in a
142
+ // not-yet-loaded run can't be shown until the rail scrolls far enough to
143
+ // load it — the count reflects what's loaded, not the whole history.
144
+ const matchedRuns = $derived(
145
+ outputMatches ? items.filter((r: Run) => outputMatches.has(r.id)) : [],
146
+ );
147
+
148
+ // Split the matching line into [before, match, after] around the first
149
+ // occurrence of the query, windowed to keep the match in view — mirrors the
150
+ // artifact's snippet. Rendered as plain text spans (Svelte auto-escapes), so
151
+ // no untrusted HTML ever reaches the DOM.
152
+ interface HighlightParts {
153
+ before: string;
154
+ match: string;
155
+ after: string;
156
+ }
157
+
158
+ function highlightParts(text: string, query: string): HighlightParts {
159
+ const q = query.trim();
160
+ const idx = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;
161
+ if (idx === -1) return { before: text, match: "", after: "" };
162
+ const start = Math.max(0, idx - 14);
163
+ const lead = start > 0 ? "…" : "";
164
+ const windowed = text.slice(start);
165
+ const fi = windowed.toLowerCase().indexOf(q.toLowerCase());
166
+ return {
167
+ before: lead + windowed.slice(0, fi),
168
+ match: windowed.slice(fi, fi + q.length),
169
+ after: windowed.slice(fi + q.length),
170
+ };
171
+ }
172
+
173
+ // Right-hand mono readout on a task-rail row: live / queued / exit N / dur.
174
+ function rowRightLabel(run: Run, displayed: RunStatus): string {
175
+ if (run.status === "running") return "live";
176
+ if (run.status === "pending") return "queued";
177
+ if (displayed === "failed" || displayed === "crashed") {
178
+ return "exit " + String(run.exit_code);
179
+ }
180
+ return runDuration(run) ?? "—";
181
+ }
103
182
 
104
183
  const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
105
184
  count: 0,
106
185
  getScrollElement: () => scrollElement ?? null,
107
- estimateSize: () => ROW_HEIGHT,
186
+ estimateSize: () => rowHeight,
108
187
  overscan: OVERSCAN,
109
188
  });
110
189
 
@@ -158,6 +237,10 @@
158
237
  let selectedRuns = $derived(items.filter((r: Run) => isRowSelected(r.id)));
159
238
  let selectionCount = $derived(selectedRuns.length);
160
239
  let hasSelection = $derived(selectionCount > 0);
240
+ // Task-rail rows overlay the checkbox on the status dot (it appears on hover
241
+ // or once a selection exists). selectionActive forces all checkboxes visible
242
+ // so the operator can extend the selection without hunting per-row hovers.
243
+ let selectionActive = $derived(bulkActions && hasSelection);
161
244
  let allSelected = $derived(selectAllMode && exceptIds.size === 0);
162
245
 
163
246
  let anyRunning = $derived(selectedRuns.some((r: Run) => r.status === "running"));
@@ -165,24 +248,78 @@
165
248
  selectedRuns.some((r: Run) => r.status !== "running" && r.status !== "pending"),
166
249
  );
167
250
 
251
+ // The selector's filter is the same RunFilter the list query uses, so a
252
+ // "select all matching" bulk op targets exactly the rows on screen. The
253
+ // popover dimensions only apply where the popover is shown; the task scope
254
+ // (page-injected or popover-set) always applies.
255
+ function buildSelectorFilter(): NonNullable<RunSelector["filter"]> {
256
+ const filter: NonNullable<RunSelector["filter"]> = {};
257
+ if (taskNameFilter) filter.task_name = taskNameFilter;
258
+ else if (filters.task_name) filter.task_name = filters.task_name;
259
+ if (!showFilters) return filter;
260
+ if (filters.statuses.length > 0) filter.status = filters.statuses.join(",");
261
+ const query = filters.search.trim();
262
+ if (query) filter.search = query;
263
+ if (filters.created_after) filter.created_after = filters.created_after;
264
+ if (filters.created_before) filter.created_before = filters.created_before;
265
+ if (filters.triggered_by) filter.triggered_by = filters.triggered_by;
266
+ const exit = exitCodeRange(filters.exit_code);
267
+ if (exit.min !== undefined) filter.exit_code_min = exit.min;
268
+ if (exit.max !== undefined) filter.exit_code_max = exit.max;
269
+ if (filters.retries_only) filter.retries_only = true;
270
+ return filter;
271
+ }
272
+
168
273
  function buildSelector(): RunSelector {
169
274
  if (!selectAllMode) {
170
275
  return { match_all: false, ids: [...explicitIds] };
171
276
  }
172
- const filter: { status?: string; task_name?: string; search?: string } = {};
173
- if (taskNameFilter) filter.task_name = taskNameFilter;
174
- if (showFilters) {
175
- if (filters.status && filters.status !== "all") filter.status = filters.status;
176
- const query = filters.search.trim();
177
- if (query) filter.search = query;
178
- }
179
277
  return {
180
278
  match_all: true,
181
- filter,
279
+ filter: buildSelectorFilter(),
182
280
  except_ids: [...exceptIds],
183
281
  };
184
282
  }
185
283
 
284
+ // Active-filter chips for the header row. `task` only chips on the
285
+ // cross-task view; everywhere else the task name is the page scope.
286
+ const filterChips = $derived(
287
+ activeDimensions(filters)
288
+ .filter((dim) => dim !== "task" || showTask)
289
+ .map((dim) => ({ dimension: dim, label: chipLabel(dim) })),
290
+ );
291
+
292
+ function chipLabel(dim: FilterDimension): string {
293
+ switch (dim) {
294
+ case "status":
295
+ return statusChipLabel(filters.statuses);
296
+ case "time":
297
+ return timeChipLabel();
298
+ case "task":
299
+ return filters.task_name ?? "";
300
+ case "triggered_by":
301
+ return "Trigger: " + triggerDescription(filters.triggered_by ?? "");
302
+ case "exit_code":
303
+ return exitCodeChipLabel(filters.exit_code);
304
+ case "retries":
305
+ return "Retries only";
306
+ }
307
+ }
308
+
309
+ function timeChipLabel(): string {
310
+ const after = filters.created_after;
311
+ const before = filters.created_before;
312
+ if (isWholeDay(after, before) && after) return `On ${formatCalendarDate(after)}`;
313
+ if (after && before) return `${formatDateTime(after)} – ${formatDateTime(before)}`;
314
+ if (after) return `Since ${formatDateTime(after)}`;
315
+ if (before) return `Before ${formatDateTime(before)}`;
316
+ return "";
317
+ }
318
+
319
+ function removeChip(dim: FilterDimension) {
320
+ filters = clearDimension(filters, dim);
321
+ }
322
+
186
323
  function emitBulk(handler: BulkHandler | undefined, predicate: (r: Run) => boolean) {
187
324
  if (!handler) return;
188
325
  const affected = selectedRuns.filter(predicate);
@@ -203,7 +340,13 @@
203
340
  }
204
341
 
205
342
  function toggleSortDirection() {
206
- filters.sort_direction = filters.sort_direction === "asc" ? "desc" : "asc";
343
+ // Reassign the whole object (not just the property): the parent reads
344
+ // `filters` through a multi-level `bind:`, and a fresh reference is what
345
+ // reliably re-triggers its fetch effect.
346
+ filters = {
347
+ ...filters,
348
+ sort_direction: filters.sort_direction === "asc" ? "desc" : "asc",
349
+ };
207
350
  }
208
351
 
209
352
  let masterCheckboxRef: HTMLInputElement | undefined = $state();
@@ -214,13 +357,17 @@
214
357
  </script>
215
358
 
216
359
  <div
217
- class="flex flex-col overflow-hidden rounded-xl border border-outline bg-surface-raised shadow-sm md:col-span-4 lg:col-span-3"
360
+ class={flush
361
+ ? "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"
362
+ : "flex flex-col overflow-hidden rounded-xl border border-outline bg-surface-raised shadow-sm md:col-span-4 lg:col-span-3"}
218
363
  >
219
364
  <!-- Inline heading: master checkbox, label, selection count or controls -->
220
365
  <div
221
- class="flex shrink-0 items-center gap-2 border-b border-outline-faint px-3 py-2 {hasSelection
222
- ? 'bg-primary-soft/40'
223
- : 'bg-surface-sunken'}"
366
+ class="flex shrink-0 items-center gap-2 border-b px-3 py-2 {hasSelection
367
+ ? 'border-outline-faint bg-primary-soft/40'
368
+ : flush
369
+ ? 'border-transparent bg-surface'
370
+ : 'border-outline-faint bg-surface-sunken'}"
224
371
  >
225
372
  {#if bulkActions}
226
373
  <label
@@ -285,8 +432,18 @@
285
432
  <span class="text-xs font-semibold tracking-wider text-on-surface-muted uppercase">
286
433
  {headerLabel}
287
434
  </span>
288
- <Badge variant="default" size="sm">{items.length} of {total}</Badge>
435
+ <span class="text-xs text-on-surface-faint">
436
+ {#if outputSearchActive}
437
+ {matchedRuns.length} of {items.length}
438
+ {:else}
439
+ {total}
440
+ {total === 1 ? "run" : "runs"}
441
+ {/if}
442
+ </span>
289
443
  <div class="ml-auto flex items-center gap-1">
444
+ {#if showFilters}
445
+ <RunFilterPopover bind:filters {showTask} {tasks} />
446
+ {/if}
290
447
  <Button
291
448
  variant="ghost"
292
449
  size="xs"
@@ -307,43 +464,75 @@
307
464
  {/if}
308
465
  </div>
309
466
 
310
- {#if showFilters}
311
- <!-- Filter controls (search + status) -->
312
- <div class="shrink-0 space-y-2 border-b border-outline-faint bg-surface-sunken px-3 py-2">
313
- <div class="relative">
314
- <Search
315
- class="absolute top-1/2 left-2.5 -translate-y-1/2 text-on-surface-faint"
316
- size={14}
317
- />
318
- <input
319
- type="text"
320
- bind:value={searchInput}
321
- placeholder="Search task or ID..."
322
- class="h-8 w-full rounded-md border border-outline bg-surface-raised pr-3 pl-8 text-sm transition-all placeholder:text-on-surface-faint focus:border-ring focus:ring-2 focus:ring-ring/20 focus:outline-none"
323
- />
324
- </div>
325
- <div class="relative">
326
- <select
327
- bind:value={filters.status}
328
- class="h-7 w-full appearance-none rounded-md border border-outline bg-surface-raised pr-6 pl-2 text-xs text-on-surface-muted focus:border-ring focus:outline-none"
467
+ {#if showFilters && filterChips.length > 0}
468
+ <!-- Active-filter chips. Rendered only when filters are set, so the
469
+ header stays clean when empty; each chip's X clears its dimension.
470
+ The filter controls themselves live in the popover above. -->
471
+ <div
472
+ class="flex shrink-0 flex-wrap gap-1 border-b border-outline-faint bg-surface-sunken px-3 py-2"
473
+ >
474
+ {#each filterChips as chip (chip.dimension)}
475
+ <span
476
+ class="inline-flex items-center gap-1 rounded-full bg-surface-raised py-0.5 pr-1 pl-2 text-2xs font-medium text-on-surface-muted ring-1 ring-outline-faint ring-inset"
329
477
  >
330
- <option value="all">All Statuses</option>
331
- <option value="running">Running</option>
332
- <option value="success">Success</option>
333
- <option value="failed">Failed</option>
334
- <option value="crashed">Crashed</option>
335
- <option value="skipped">Skipped</option>
336
- </select>
337
- <Funnel
338
- class="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-on-surface-faint"
339
- size={12}
340
- />
341
- </div>
478
+ {chip.label}
479
+ <button
480
+ type="button"
481
+ onclick={() => removeChip(chip.dimension)}
482
+ class="flex size-3.5 items-center justify-center rounded-full text-on-surface-faint transition-colors hover:bg-surface-sunken hover:text-on-surface"
483
+ aria-label="Remove {chip.label} filter"
484
+ >
485
+ <X size={11} />
486
+ </button>
487
+ </span>
488
+ {/each}
342
489
  </div>
343
490
  {/if}
344
491
 
345
492
  <div bind:this={scrollElement} class="min-h-0 flex-1 overflow-y-auto p-2">
346
- {#if items.length === 0 && !loading}
493
+ {#if outputSearchActive}
494
+ <!-- Output-search results: the rail filters to runs that printed the
495
+ query, each annotated with the matching line. -->
496
+ {#if outputSearchPending}
497
+ <!-- Searching: shimmer placeholders shaped like result rows. -->
498
+ <div class="flex flex-col gap-0.5" aria-busy="true" aria-label="Searching output">
499
+ {#each [0, 1, 2, 3, 4] as i (i)}
500
+ <div class="flex items-center gap-2.5 rounded-[10px] px-3 py-2.5">
501
+ <span
502
+ class="size-[9px] shrink-0 animate-pulse rounded-full bg-surface-sunken"
503
+ ></span>
504
+ <span
505
+ class="h-3 animate-pulse rounded bg-surface-sunken"
506
+ style:width="{70 - i * 8}%"
507
+ ></span>
508
+ <span class="ml-auto h-3 w-9 animate-pulse rounded bg-surface-sunken"
509
+ ></span>
510
+ </div>
511
+ {/each}
512
+ </div>
513
+ {:else if matchedRuns.length === 0}
514
+ <div class="px-4 py-8 text-center text-xs leading-relaxed text-on-surface-muted">
515
+ No output matches
516
+ <b class="text-on-surface">“{outputQuery.trim()}”</b>.<br />
517
+ Try a different term.
518
+ </div>
519
+ {:else}
520
+ <div class="flex flex-col gap-0.5">
521
+ {#each matchedRuns as run (run.id)}
522
+ <div class="group/row relative flex items-stretch gap-1">
523
+ {#if bulkActions}
524
+ {@render rowCheckboxOverlay(run)}
525
+ {/if}
526
+ {@render runRowButton(
527
+ run,
528
+ selectedRunId === run.id,
529
+ outputMatches?.get(run.id),
530
+ )}
531
+ </div>
532
+ {/each}
533
+ </div>
534
+ {/if}
535
+ {:else if items.length === 0 && !loading}
347
536
  <EmptyState
348
537
  title={emptyText}
349
538
  description={emptyDescription}
@@ -360,13 +549,8 @@
360
549
  {#each $virtualizer.getVirtualItems() as row (items[row.index]?.id ?? row.index)}
361
550
  {@const run = items[row.index]}
362
551
  {#if run}
363
- {@const isActive = selectedRunId === run.id}
364
- {@const isChecked = isRowSelected(run.id)}
365
- {@const config = getRunStatusConfig(runDisplayStatus(run))}
366
- {@const Icon = config.icon}
367
- {@const duration = runDuration(run)}
368
552
  <div
369
- class="flex items-stretch gap-1"
553
+ class="group/row flex items-center gap-1"
370
554
  style:position="absolute"
371
555
  style:top="0"
372
556
  style:left="0"
@@ -375,139 +559,9 @@
375
559
  style:transform="translateY({row.start}px)"
376
560
  >
377
561
  {#if bulkActions}
378
- <label
379
- class="flex shrink-0 cursor-pointer items-center px-1.5"
380
- aria-label={`Select run ${formatShortId(run.id)}`}
381
- >
382
- <input
383
- type="checkbox"
384
- checked={isChecked}
385
- onchange={() => toggleRow(run.id)}
386
- onclick={(e) => e.stopPropagation()}
387
- class="h-3.5 w-3.5 cursor-pointer rounded border-outline accent-primary"
388
- />
389
- </label>
562
+ {@render rowCheckboxOverlay(run)}
390
563
  {/if}
391
- <button
392
- class="btn-scale group duration-normal relative w-full rounded-lg border p-3 text-left transition-all select-none
393
- {isActive
394
- ? 'border-primary-soft bg-primary-soft/50 shadow-sm'
395
- : 'border-transparent bg-surface-raised hover:border-outline hover:bg-surface-sunken'}"
396
- onclick={() => selectRun(run.id)}
397
- onkeydown={(e) => e.key === "Enter" && selectRun(run.id)}
398
- >
399
- {#if showTaskName}
400
- <!-- Runs variant: task name as primary, shortId secondary -->
401
- <div class="mb-1 flex items-center justify-between">
402
- <div class="flex min-w-0 items-center gap-2">
403
- <div class="{config.color} shrink-0">
404
- <Icon
405
- size={16}
406
- class={run.status === "running"
407
- ? "animate-spin"
408
- : ""}
409
- />
410
- </div>
411
- <span
412
- class="truncate text-sm font-semibold text-on-surface"
413
- >
414
- {run.task_name}{#if run.instance_index > 0}<span
415
- class="text-on-surface-muted"
416
- >#{run.instance_index}</span
417
- >{/if}
418
- </span>
419
- </div>
420
- <span
421
- class="shrink-0 text-2xs {isActive
422
- ? 'font-medium text-primary'
423
- : 'text-on-surface-faint'}"
424
- >
425
- {formatRelativeTime(run.start_at || run.created_at)}
426
- </span>
427
- </div>
428
-
429
- <div class="flex items-center justify-between pl-6 text-xs">
430
- <div
431
- class="flex items-center gap-1 font-mono text-2xs text-on-surface-muted"
432
- >
433
- #{formatShortId(run.id)}
434
- </div>
435
- {#if duration}
436
- <div
437
- class="flex items-center gap-1 text-on-surface-faint {isActive
438
- ? 'text-primary'
439
- : ''}"
440
- >
441
- <Timer size={10} />
442
- <span class="font-mono">{duration}</span>
443
- </div>
444
- {/if}
445
- </div>
446
- {:else}
447
- <!-- Task variant: shortId as primary, status secondary -->
448
- <div class="mb-1.5 flex items-center justify-between">
449
- <div class="flex items-center gap-2">
450
- <div class={config.color}>
451
- <Icon
452
- size={16}
453
- class={run.status === "running"
454
- ? "animate-spin"
455
- : ""}
456
- />
457
- </div>
458
- <span
459
- class="font-mono text-xs font-medium text-on-surface"
460
- >
461
- #{formatShortId(run.id)}
462
- </span>
463
- </div>
464
- <span
465
- class="text-2xs {isActive
466
- ? 'font-medium text-primary'
467
- : 'text-on-surface-faint'}"
468
- >
469
- {formatRelativeTime(run.start_at || run.created_at)}
470
- </span>
471
- </div>
472
-
473
- <div class="flex items-center justify-between text-xs">
474
- <div class="flex items-center gap-2 text-on-surface-muted">
475
- <Tooltip
476
- content={config.description}
477
- position="right"
478
- wide
479
- >
480
- <span class="capitalize"
481
- >{runDisplayStatus(run)}</span
482
- >
483
- </Tooltip>
484
- {#if run.instance_index > 0}
485
- <span
486
- class="font-mono text-2xs text-on-surface-faint"
487
- >instance #{run.instance_index}</span
488
- >
489
- {/if}
490
- </div>
491
- {#if duration}
492
- <div
493
- class="flex items-center gap-1 text-on-surface-faint {isActive
494
- ? 'text-primary'
495
- : ''}"
496
- >
497
- <Timer size={10} />
498
- <span class="font-mono">{duration}</span>
499
- </div>
500
- {/if}
501
- </div>
502
- {/if}
503
-
504
- <div
505
- class="duration-normal absolute top-1/2 left-0 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary transition-all {isActive
506
- ? 'opacity-100'
507
- : 'opacity-0'}"
508
- aria-hidden="true"
509
- ></div>
510
- </button>
564
+ {@render runRowButton(run, selectedRunId === run.id, undefined)}
511
565
  </div>
512
566
  {/if}
513
567
  {/each}
@@ -515,3 +569,148 @@
515
569
  {/if}
516
570
  </div>
517
571
  </div>
572
+
573
+ <!-- Status dot. With bulk actions on it fades out — on row hover, or whenever a
574
+ selection exists — so the row checkbox can take its place over it. -->
575
+ {#snippet statusDot(colorClass: string, running: boolean)}
576
+ <span
577
+ class="{colorClass} size-[9px] shrink-0 rounded-full bg-current ring-[3px] ring-current/20 {running
578
+ ? 'animate-pulse'
579
+ : ''} {bulkActions
580
+ ? selectionActive
581
+ ? 'opacity-0'
582
+ : 'transition-opacity group-hover/row:opacity-0'
583
+ : ''}"
584
+ aria-hidden="true"
585
+ ></span>
586
+ {/snippet}
587
+
588
+ <!-- Row checkbox: sits over the status dot (which fades out beneath it) so the
589
+ row keeps the artifact's geometry. Lives in the row wrapper (not the button)
590
+ to keep the markup valid, positioned to land on the dot. The 14px box is
591
+ wrapped in a 28px label so the hover/click hitbox is comfortable without
592
+ enlarging the visible checkbox. Used by both the task rail and the
593
+ cross-task /runs grid so selection behaves identically in each. -->
594
+ {#snippet rowCheckboxOverlay(run: Run)}
595
+ <label
596
+ class="absolute top-1/2 left-[2px] z-10 flex size-7 -translate-y-1/2 cursor-pointer items-center justify-center"
597
+ >
598
+ <input
599
+ type="checkbox"
600
+ checked={isRowSelected(run.id)}
601
+ onchange={() => toggleRow(run.id)}
602
+ onclick={(e) => e.stopPropagation()}
603
+ aria-label={`Select run from ${formatDateTime(run.start_at ?? run.created_at)}`}
604
+ class="size-3.5 cursor-pointer rounded border-outline accent-primary opacity-0 transition-opacity {selectionActive
605
+ ? 'opacity-100'
606
+ : 'group-hover/row:opacity-100'}"
607
+ />
608
+ </label>
609
+ {/snippet}
610
+
611
+ {#snippet runRowButton(run: Run, isActive: boolean, match: RunOutputMatch | undefined)}
612
+ {@const dstatus = runDisplayStatus(run)}
613
+ {@const config = getRunStatusConfig(dstatus)}
614
+ {@const running = run.status === "running"}
615
+ {@const spine = config.dot.replace(" animate-pulse", "")}
616
+ {@const startedAt = run.start_at ?? run.created_at}
617
+ {@const retry = runRetryLabel(run)}
618
+ {@const suffix = instanceSuffix(run.instance_index, getInstanceCount(run.task_name))}
619
+ <button
620
+ class="btn-scale group relative w-full rounded-[10px] border text-left transition-all select-none {showTaskName
621
+ ? 'p-3'
622
+ : 'px-3 py-[11px]'} {isActive
623
+ ? 'border-outline bg-surface-raised shadow-sm'
624
+ : 'border-transparent hover:bg-surface-sunken'}"
625
+ onclick={() => selectRun(run.id)}
626
+ onkeydown={(e) => e.key === "Enter" && selectRun(run.id)}
627
+ >
628
+ {#if showTaskName}
629
+ <!-- Cross-task /runs variant: the same readout language as the task
630
+ rail — status dot, status-colored outcome, mono right readout —
631
+ with the task name carried as the primary. -->
632
+ <div class="flex items-center gap-2.5">
633
+ {@render statusDot(config.color, running)}
634
+ <span class="flex min-w-0 flex-1 flex-col gap-0.5">
635
+ <span class="flex items-center gap-1.5">
636
+ <span class="truncate text-[13px] font-semibold text-on-surface">
637
+ {run.task_name}{#if suffix}<span class="text-on-surface-muted"
638
+ >{suffix}</span
639
+ >{/if}
640
+ </span>
641
+ <span class="shrink-0 text-on-surface-faint">·</span>
642
+ <span class="shrink-0 text-[12px] font-semibold capitalize {config.color}"
643
+ >{dstatus}</span
644
+ >
645
+ </span>
646
+ <span
647
+ class="flex min-w-0 items-center gap-1.5 text-2xs text-on-surface-faint"
648
+ title={formatFullDateTime(startedAt)}
649
+ >
650
+ <span class="truncate">{formatDateTime(startedAt)}</span>
651
+ <span class="shrink-0">· {formatTriggeredByLabel(run.triggered_by)}</span>
652
+ {#if retry}
653
+ <span class="shrink-0 rounded bg-surface-sunken px-1 font-mono"
654
+ >{retry}</span
655
+ >
656
+ {/if}
657
+ </span>
658
+ </span>
659
+ <span
660
+ class="shrink-0 self-start pt-0.5 font-mono text-[11.5px] text-on-surface-faint tabular-nums"
661
+ title={retry ?? undefined}
662
+ >
663
+ {rowRightLabel(run, dstatus)}
664
+ </span>
665
+ </div>
666
+ {:else}
667
+ <!-- Task-rail variant (artifact ".run"): a single dense line —
668
+ time · date · outcome, with a mono exit/duration readout.
669
+ leading-tight matches the artifact's ~1.2 line-height so the
670
+ (descender-less) text optically centers instead of riding high
671
+ inside Tailwind's default 1.5 line box. -->
672
+ <div class="flex items-center gap-[11px] leading-tight">
673
+ {@render statusDot(config.color, running)}
674
+ <span class="flex min-w-0 flex-1 items-center gap-1.5 truncate">
675
+ <span
676
+ class="text-[12.5px] font-semibold tracking-tight text-on-surface"
677
+ title={formatFullDateTime(startedAt)}
678
+ >
679
+ {formatTimeHM(startedAt)} · {formatDayMonth(startedAt)}
680
+ </span>
681
+ <span class="text-on-surface-faint">·</span>
682
+ <span class="text-[12.5px] font-semibold capitalize {config.color}"
683
+ >{dstatus}</span
684
+ >
685
+ {#if suffix}
686
+ <span class="font-mono text-2xs text-on-surface-faint">{suffix}</span>
687
+ {/if}
688
+ </span>
689
+ <span
690
+ class="shrink-0 font-mono text-[11.5px] text-on-surface-faint tabular-nums"
691
+ title={retry ?? undefined}
692
+ >
693
+ {rowRightLabel(run, dstatus)}
694
+ </span>
695
+ </div>
696
+ {#if match}
697
+ {@const hl = highlightParts(match.text, outputQuery)}
698
+ <div
699
+ class="mt-1.5 truncate rounded-md bg-surface-sunken px-2 py-1 font-mono text-2xs text-on-surface-muted"
700
+ >
701
+ {hl.before}<mark
702
+ class="rounded-sm bg-primary-soft px-0.5 text-primary-soft-text"
703
+ >{hl.match}</mark
704
+ >{hl.after}
705
+ </div>
706
+ {/if}
707
+ {/if}
708
+
709
+ <div
710
+ class="duration-normal absolute inset-y-2 left-[-6px] w-[3px] rounded-[3px] transition-all {spine} {isActive
711
+ ? 'opacity-100'
712
+ : 'opacity-0'}"
713
+ aria-hidden="true"
714
+ ></div>
715
+ </button>
716
+ {/snippet}