@runwisp/ui 0.10.0 → 0.12.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,37 +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";
19
+ import RunFilterPopover from "./RunFilterPopover.svelte";
31
20
  import type { Run } from "./types.js";
32
- import type { RunSelector } from "@runwisp/common";
21
+ import type { RunSelector, RunStatus } from "@runwisp/common";
33
22
  import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
34
- import { runDuration, formatTriggeredByLabel, runRetryLabel } from "./run-helpers.js";
35
- import { formatRelativeTime, formatDateTime, formatFullDateTime } from "../../utils/format.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";
36
47
 
37
48
  type BulkHandler = (selector: RunSelector, affected: Run[]) => void;
38
49
 
@@ -45,6 +56,8 @@
45
56
  selectedRunId = $bindable(null),
46
57
  onselect,
47
58
  showFilters = false,
59
+ showTask = false,
60
+ tasks = [],
48
61
  showTaskName = false,
49
62
  taskNameFilter,
50
63
  headerLabel = "Run History",
@@ -54,6 +67,12 @@
54
67
  onBulkCancel,
55
68
  onBulkDelete,
56
69
  onBulkRerun,
70
+ getInstanceCount = () => 1,
71
+ flush = false,
72
+ outputSearch = false,
73
+ outputQuery = "",
74
+ outputMatches = null,
75
+ outputSearchPending = false,
57
76
  }: {
58
77
  items: Run[];
59
78
  total: number;
@@ -63,6 +82,11 @@
63
82
  selectedRunId?: string | null;
64
83
  onselect?: (runId: string) => void;
65
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 }[];
66
90
  showTaskName?: boolean;
67
91
  taskNameFilter?: string;
68
92
  headerLabel?: string;
@@ -72,12 +96,34 @@
72
96
  onBulkCancel?: BulkHandler;
73
97
  onBulkDelete?: BulkHandler;
74
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;
75
120
  } = $props();
76
121
 
77
- const ROW_HEIGHT = 76;
122
+ // Task-rail rows are one dense line (44px); the cross-task /runs view adds
123
+ // the task name on a second line (64px).
124
+ const rowHeight = $derived(showTaskName ? 64 : 44);
78
125
  const OVERSCAN = 8;
79
126
  const LOAD_AHEAD = 10;
80
- const SEARCH_DEBOUNCE_MS = 250;
81
127
 
82
128
  // Selection model: two modes —
83
129
  // 1. explicit: user picked specific rows. explicitIds holds them.
@@ -87,22 +133,57 @@
87
133
  const exceptIds = new SvelteSet<string>();
88
134
 
89
135
  let scrollElement: HTMLDivElement | undefined = $state();
90
- let searchInput = $state(filters.search);
91
136
 
92
- // Debounce typing in the search box before pushing into shared filters,
93
- // so we don't fire a network request on every keystroke.
94
- $effect(() => {
95
- const next = searchInput;
96
- const id = setTimeout(() => {
97
- if (filters.search !== next) filters.search = next;
98
- }, SEARCH_DEBOUNCE_MS);
99
- return () => clearTimeout(id);
100
- });
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
+ }
101
182
 
102
183
  const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
103
184
  count: 0,
104
185
  getScrollElement: () => scrollElement ?? null,
105
- estimateSize: () => ROW_HEIGHT,
186
+ estimateSize: () => rowHeight,
106
187
  overscan: OVERSCAN,
107
188
  });
108
189
 
@@ -122,6 +203,29 @@
122
203
  if (last.index >= items.length - LOAD_AHEAD) onLoadMore?.();
123
204
  });
124
205
 
206
+ // Scroll a programmatically-selected run (deep link, detail-panel click) into
207
+ // view, since its row may be outside the virtual window. Tracked on
208
+ // selectedRunId/items only; the virtualizer access is untracked to avoid the
209
+ // setOptions-style notify loop (effect_update_depth_exceeded, see above).
210
+ let lastScrolled: string | null = null;
211
+ $effect(() => {
212
+ const id = selectedRunId;
213
+ const index = items.findIndex((r: Run) => r.id === id);
214
+ if (!id) {
215
+ lastScrolled = null;
216
+ return;
217
+ }
218
+ // -1: not loaded yet. The effect re-runs as items grows, so a deep link
219
+ // resolves once its page arrives; we don't chase it with onLoadMore.
220
+ if (index === -1) return;
221
+ if (id === lastScrolled) return; // already handled; don't re-yank on re-render
222
+ if (!scrollElement) return;
223
+ lastScrolled = id;
224
+ // "auto" only scrolls when the row is off-screen, so visible selections
225
+ // (and manual clicks) don't jump.
226
+ untrack(() => $virtualizer.scrollToIndex(index, { align: "auto" }));
227
+ });
228
+
125
229
  function isRowSelected(id: string): boolean {
126
230
  return selectAllMode ? !exceptIds.has(id) : explicitIds.has(id);
127
231
  }
@@ -156,6 +260,10 @@
156
260
  let selectedRuns = $derived(items.filter((r: Run) => isRowSelected(r.id)));
157
261
  let selectionCount = $derived(selectedRuns.length);
158
262
  let hasSelection = $derived(selectionCount > 0);
263
+ // Task-rail rows overlay the checkbox on the status dot (it appears on hover
264
+ // or once a selection exists). selectionActive forces all checkboxes visible
265
+ // so the operator can extend the selection without hunting per-row hovers.
266
+ let selectionActive = $derived(bulkActions && hasSelection);
159
267
  let allSelected = $derived(selectAllMode && exceptIds.size === 0);
160
268
 
161
269
  let anyRunning = $derived(selectedRuns.some((r: Run) => r.status === "running"));
@@ -163,24 +271,78 @@
163
271
  selectedRuns.some((r: Run) => r.status !== "running" && r.status !== "pending"),
164
272
  );
165
273
 
274
+ // The selector's filter is the same RunFilter the list query uses, so a
275
+ // "select all matching" bulk op targets exactly the rows on screen. The
276
+ // popover dimensions only apply where the popover is shown; the task scope
277
+ // (page-injected or popover-set) always applies.
278
+ function buildSelectorFilter(): NonNullable<RunSelector["filter"]> {
279
+ const filter: NonNullable<RunSelector["filter"]> = {};
280
+ if (taskNameFilter) filter.task_name = taskNameFilter;
281
+ else if (filters.task_name) filter.task_name = filters.task_name;
282
+ if (!showFilters) return filter;
283
+ if (filters.statuses.length > 0) filter.status = filters.statuses.join(",");
284
+ const query = filters.search.trim();
285
+ if (query) filter.search = query;
286
+ if (filters.created_after) filter.created_after = filters.created_after;
287
+ if (filters.created_before) filter.created_before = filters.created_before;
288
+ if (filters.triggered_by) filter.triggered_by = filters.triggered_by;
289
+ const exit = exitCodeRange(filters.exit_code);
290
+ if (exit.min !== undefined) filter.exit_code_min = exit.min;
291
+ if (exit.max !== undefined) filter.exit_code_max = exit.max;
292
+ if (filters.retries_only) filter.retries_only = true;
293
+ return filter;
294
+ }
295
+
166
296
  function buildSelector(): RunSelector {
167
297
  if (!selectAllMode) {
168
298
  return { match_all: false, ids: [...explicitIds] };
169
299
  }
170
- const filter: { status?: string; task_name?: string; search?: string } = {};
171
- if (taskNameFilter) filter.task_name = taskNameFilter;
172
- if (showFilters) {
173
- if (filters.status && filters.status !== "all") filter.status = filters.status;
174
- const query = filters.search.trim();
175
- if (query) filter.search = query;
176
- }
177
300
  return {
178
301
  match_all: true,
179
- filter,
302
+ filter: buildSelectorFilter(),
180
303
  except_ids: [...exceptIds],
181
304
  };
182
305
  }
183
306
 
307
+ // Active-filter chips for the header row. `task` only chips on the
308
+ // cross-task view; everywhere else the task name is the page scope.
309
+ const filterChips = $derived(
310
+ activeDimensions(filters)
311
+ .filter((dim) => dim !== "task" || showTask)
312
+ .map((dim) => ({ dimension: dim, label: chipLabel(dim) })),
313
+ );
314
+
315
+ function chipLabel(dim: FilterDimension): string {
316
+ switch (dim) {
317
+ case "status":
318
+ return statusChipLabel(filters.statuses);
319
+ case "time":
320
+ return timeChipLabel();
321
+ case "task":
322
+ return filters.task_name ?? "";
323
+ case "triggered_by":
324
+ return "Trigger: " + triggerDescription(filters.triggered_by ?? "");
325
+ case "exit_code":
326
+ return exitCodeChipLabel(filters.exit_code);
327
+ case "retries":
328
+ return "Retries only";
329
+ }
330
+ }
331
+
332
+ function timeChipLabel(): string {
333
+ const after = filters.created_after;
334
+ const before = filters.created_before;
335
+ if (isWholeDay(after, before) && after) return `On ${formatCalendarDate(after)}`;
336
+ if (after && before) return `${formatDateTime(after)} – ${formatDateTime(before)}`;
337
+ if (after) return `Since ${formatDateTime(after)}`;
338
+ if (before) return `Before ${formatDateTime(before)}`;
339
+ return "";
340
+ }
341
+
342
+ function removeChip(dim: FilterDimension) {
343
+ filters = clearDimension(filters, dim);
344
+ }
345
+
184
346
  function emitBulk(handler: BulkHandler | undefined, predicate: (r: Run) => boolean) {
185
347
  if (!handler) return;
186
348
  const affected = selectedRuns.filter(predicate);
@@ -201,7 +363,13 @@
201
363
  }
202
364
 
203
365
  function toggleSortDirection() {
204
- filters.sort_direction = filters.sort_direction === "asc" ? "desc" : "asc";
366
+ // Reassign the whole object (not just the property): the parent reads
367
+ // `filters` through a multi-level `bind:`, and a fresh reference is what
368
+ // reliably re-triggers its fetch effect.
369
+ filters = {
370
+ ...filters,
371
+ sort_direction: filters.sort_direction === "asc" ? "desc" : "asc",
372
+ };
205
373
  }
206
374
 
207
375
  let masterCheckboxRef: HTMLInputElement | undefined = $state();
@@ -212,13 +380,16 @@
212
380
  </script>
213
381
 
214
382
  <div
215
- class="flex flex-col overflow-hidden rounded-xl border border-outline bg-surface-raised shadow-sm md:col-span-4 lg:col-span-3"
383
+ class={flush
384
+ ? "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"
385
+ : "flex flex-col overflow-hidden rounded-xl border border-outline bg-surface-raised shadow-sm md:col-span-4 lg:col-span-3"}
216
386
  >
217
- <!-- Inline heading: master checkbox, label, selection count or controls -->
218
387
  <div
219
- class="flex shrink-0 items-center gap-2 border-b border-outline-faint px-3 py-2 {hasSelection
220
- ? 'bg-primary-soft/40'
221
- : 'bg-surface-sunken'}"
388
+ class="flex shrink-0 items-center gap-2 border-b px-3 py-2 {hasSelection
389
+ ? 'border-outline-faint bg-primary-soft/40'
390
+ : flush
391
+ ? 'border-transparent bg-surface'
392
+ : 'border-outline-faint bg-surface-sunken'}"
222
393
  >
223
394
  {#if bulkActions}
224
395
  <label
@@ -283,8 +454,18 @@
283
454
  <span class="text-xs font-semibold tracking-wider text-on-surface-muted uppercase">
284
455
  {headerLabel}
285
456
  </span>
286
- <Badge variant="default" size="sm">{items.length} of {total}</Badge>
457
+ <span class="text-xs text-on-surface-faint">
458
+ {#if outputSearchActive}
459
+ {matchedRuns.length} of {items.length}
460
+ {:else}
461
+ {total}
462
+ {total === 1 ? "run" : "runs"}
463
+ {/if}
464
+ </span>
287
465
  <div class="ml-auto flex items-center gap-1">
466
+ {#if showFilters}
467
+ <RunFilterPopover bind:filters {showTask} {tasks} />
468
+ {/if}
288
469
  <Button
289
470
  variant="ghost"
290
471
  size="xs"
@@ -305,43 +486,75 @@
305
486
  {/if}
306
487
  </div>
307
488
 
308
- {#if showFilters}
309
- <!-- Filter controls (search + status) -->
310
- <div class="shrink-0 space-y-2 border-b border-outline-faint bg-surface-sunken px-3 py-2">
311
- <div class="relative">
312
- <Search
313
- class="absolute top-1/2 left-2.5 -translate-y-1/2 text-on-surface-faint"
314
- size={14}
315
- />
316
- <input
317
- type="text"
318
- bind:value={searchInput}
319
- placeholder="Search task or ID..."
320
- 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"
321
- />
322
- </div>
323
- <div class="relative">
324
- <select
325
- bind:value={filters.status}
326
- 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"
489
+ {#if showFilters && filterChips.length > 0}
490
+ <!-- Active-filter chips. Rendered only when filters are set, so the
491
+ header stays clean when empty; each chip's X clears its dimension.
492
+ The filter controls themselves live in the popover above. -->
493
+ <div
494
+ class="flex shrink-0 flex-wrap gap-1 border-b border-outline-faint bg-surface-sunken px-3 py-2"
495
+ >
496
+ {#each filterChips as chip (chip.dimension)}
497
+ <span
498
+ 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"
327
499
  >
328
- <option value="all">All Statuses</option>
329
- <option value="running">Running</option>
330
- <option value="success">Success</option>
331
- <option value="failed">Failed</option>
332
- <option value="crashed">Crashed</option>
333
- <option value="skipped">Skipped</option>
334
- </select>
335
- <Funnel
336
- class="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-on-surface-faint"
337
- size={12}
338
- />
339
- </div>
500
+ {chip.label}
501
+ <button
502
+ type="button"
503
+ onclick={() => removeChip(chip.dimension)}
504
+ 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"
505
+ aria-label="Remove {chip.label} filter"
506
+ >
507
+ <X size={11} />
508
+ </button>
509
+ </span>
510
+ {/each}
340
511
  </div>
341
512
  {/if}
342
513
 
343
514
  <div bind:this={scrollElement} class="min-h-0 flex-1 overflow-y-auto p-2">
344
- {#if items.length === 0 && !loading}
515
+ {#if outputSearchActive}
516
+ <!-- Output-search results: the rail filters to runs that printed the
517
+ query, each annotated with the matching line. -->
518
+ {#if outputSearchPending}
519
+ <!-- Searching: shimmer placeholders shaped like result rows. -->
520
+ <div class="flex flex-col gap-0.5" aria-busy="true" aria-label="Searching output">
521
+ {#each [0, 1, 2, 3, 4] as i (i)}
522
+ <div class="flex items-center gap-2.5 rounded-[10px] px-3 py-2.5">
523
+ <span
524
+ class="size-[9px] shrink-0 animate-pulse rounded-full bg-surface-sunken"
525
+ ></span>
526
+ <span
527
+ class="h-3 animate-pulse rounded bg-surface-sunken"
528
+ style:width="{70 - i * 8}%"
529
+ ></span>
530
+ <span class="ml-auto h-3 w-9 animate-pulse rounded bg-surface-sunken"
531
+ ></span>
532
+ </div>
533
+ {/each}
534
+ </div>
535
+ {:else if matchedRuns.length === 0}
536
+ <div class="px-4 py-8 text-center text-xs leading-relaxed text-on-surface-muted">
537
+ No output matches
538
+ <b class="text-on-surface">“{outputQuery.trim()}”</b>.<br />
539
+ Try a different term.
540
+ </div>
541
+ {:else}
542
+ <div class="flex flex-col gap-0.5">
543
+ {#each matchedRuns as run (run.id)}
544
+ <div class="group/row relative flex items-stretch gap-1">
545
+ {#if bulkActions}
546
+ {@render rowCheckboxOverlay(run)}
547
+ {/if}
548
+ {@render runRowButton(
549
+ run,
550
+ selectedRunId === run.id,
551
+ outputMatches?.get(run.id),
552
+ )}
553
+ </div>
554
+ {/each}
555
+ </div>
556
+ {/if}
557
+ {:else if items.length === 0 && !loading}
345
558
  <EmptyState
346
559
  title={emptyText}
347
560
  description={emptyDescription}
@@ -358,15 +571,8 @@
358
571
  {#each $virtualizer.getVirtualItems() as row (items[row.index]?.id ?? row.index)}
359
572
  {@const run = items[row.index]}
360
573
  {#if run}
361
- {@const isActive = selectedRunId === run.id}
362
- {@const isChecked = isRowSelected(run.id)}
363
- {@const config = getRunStatusConfig(runDisplayStatus(run))}
364
- {@const Icon = config.icon}
365
- {@const duration = runDuration(run)}
366
- {@const retry = runRetryLabel(run)}
367
- {@const startedAt = run.start_at ?? run.created_at}
368
574
  <div
369
- class="flex items-stretch gap-1"
575
+ class="group/row flex items-center gap-1"
370
576
  style:position="absolute"
371
577
  style:top="0"
372
578
  style:left="0"
@@ -375,154 +581,9 @@
375
581
  style:transform="translateY({row.start}px)"
376
582
  >
377
583
  {#if bulkActions}
378
- <label
379
- class="flex shrink-0 cursor-pointer items-center px-1.5"
380
- aria-label={`Select run from ${formatDateTime(run.start_at ?? run.created_at)}`}
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>
584
+ {@render rowCheckboxOverlay(run)}
390
585
  {/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
- title={formatFullDateTime(startedAt)}
425
- >
426
- {formatRelativeTime(startedAt)}
427
- </span>
428
- </div>
429
-
430
- <div class="flex items-center justify-between pl-6 text-xs">
431
- <div
432
- class="flex min-w-0 items-center gap-1.5 text-2xs text-on-surface-muted"
433
- >
434
- <span class="truncate">{formatDateTime(startedAt)}</span
435
- >
436
- <span class="text-on-surface-faint"
437
- >· {formatTriggeredByLabel(run.triggered_by)}</span
438
- >
439
- {#if retry}
440
- <span
441
- class="shrink-0 rounded bg-surface-sunken px-1 font-mono text-2xs text-on-surface-faint"
442
- >{retry}</span
443
- >
444
- {/if}
445
- </div>
446
- {#if duration}
447
- <div
448
- class="flex items-center gap-1 text-on-surface-faint {isActive
449
- ? 'text-primary'
450
- : ''}"
451
- >
452
- <Timer size={10} />
453
- <span class="font-mono">{duration}</span>
454
- </div>
455
- {/if}
456
- </div>
457
- {:else}
458
- <!-- Task variant: when it ran as primary, outcome secondary -->
459
- <div class="mb-1.5 flex items-center justify-between">
460
- <div class="flex min-w-0 items-center gap-2">
461
- <div class="{config.color} shrink-0">
462
- <Icon
463
- size={16}
464
- class={run.status === "running"
465
- ? "animate-spin"
466
- : ""}
467
- />
468
- </div>
469
- <span
470
- class="truncate text-sm font-semibold text-on-surface"
471
- >
472
- {formatDateTime(startedAt)}
473
- </span>
474
- </div>
475
- <span
476
- class="shrink-0 text-2xs {isActive
477
- ? 'font-medium text-primary'
478
- : 'text-on-surface-faint'}"
479
- title={formatFullDateTime(startedAt)}
480
- >
481
- {formatRelativeTime(startedAt)}
482
- </span>
483
- </div>
484
-
485
- <div class="flex items-center justify-between pl-6 text-xs">
486
- <div
487
- class="flex min-w-0 items-center gap-2 text-on-surface-muted"
488
- >
489
- <span class="capitalize">{runDisplayStatus(run)}</span>
490
- <span class="text-on-surface-faint"
491
- >· {formatTriggeredByLabel(run.triggered_by)}</span
492
- >
493
- {#if retry}
494
- <span
495
- class="shrink-0 rounded bg-surface-sunken px-1 font-mono text-2xs text-on-surface-faint"
496
- >{retry}</span
497
- >
498
- {/if}
499
- {#if run.instance_index > 0}
500
- <span
501
- class="font-mono text-2xs text-on-surface-faint"
502
- >instance #{run.instance_index}</span
503
- >
504
- {/if}
505
- </div>
506
- {#if duration}
507
- <div
508
- class="flex items-center gap-1 text-on-surface-faint {isActive
509
- ? 'text-primary'
510
- : ''}"
511
- >
512
- <Timer size={10} />
513
- <span class="font-mono">{duration}</span>
514
- </div>
515
- {/if}
516
- </div>
517
- {/if}
518
-
519
- <div
520
- 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
521
- ? 'opacity-100'
522
- : 'opacity-0'}"
523
- aria-hidden="true"
524
- ></div>
525
- </button>
586
+ {@render runRowButton(run, selectedRunId === run.id, undefined)}
526
587
  </div>
527
588
  {/if}
528
589
  {/each}
@@ -530,3 +591,148 @@
530
591
  {/if}
531
592
  </div>
532
593
  </div>
594
+
595
+ <!-- Status dot. With bulk actions on it fades out — on row hover, or whenever a
596
+ selection exists — so the row checkbox can take its place over it. -->
597
+ {#snippet statusDot(colorClass: string, running: boolean)}
598
+ <span
599
+ class="{colorClass} size-[9px] shrink-0 rounded-full bg-current ring-[3px] ring-current/20 {running
600
+ ? 'animate-pulse'
601
+ : ''} {bulkActions
602
+ ? selectionActive
603
+ ? 'opacity-0'
604
+ : 'transition-opacity group-hover/row:opacity-0'
605
+ : ''}"
606
+ aria-hidden="true"
607
+ ></span>
608
+ {/snippet}
609
+
610
+ <!-- Row checkbox: sits over the status dot (which fades out beneath it) so the
611
+ row keeps the artifact's geometry. Lives in the row wrapper (not the button)
612
+ to keep the markup valid, positioned to land on the dot. The 14px box is
613
+ wrapped in a 28px label so the hover/click hitbox is comfortable without
614
+ enlarging the visible checkbox. Used by both the task rail and the
615
+ cross-task /runs grid so selection behaves identically in each. -->
616
+ {#snippet rowCheckboxOverlay(run: Run)}
617
+ <label
618
+ class="absolute top-1/2 left-[2px] z-10 flex size-7 -translate-y-1/2 cursor-pointer items-center justify-center"
619
+ >
620
+ <input
621
+ type="checkbox"
622
+ checked={isRowSelected(run.id)}
623
+ onchange={() => toggleRow(run.id)}
624
+ onclick={(e) => e.stopPropagation()}
625
+ aria-label={`Select run from ${formatDateTime(run.start_at ?? run.created_at)}`}
626
+ class="size-3.5 cursor-pointer rounded border-outline accent-primary opacity-0 transition-opacity {selectionActive
627
+ ? 'opacity-100'
628
+ : 'group-hover/row:opacity-100'}"
629
+ />
630
+ </label>
631
+ {/snippet}
632
+
633
+ {#snippet runRowButton(run: Run, isActive: boolean, match: RunOutputMatch | undefined)}
634
+ {@const dstatus = runDisplayStatus(run)}
635
+ {@const config = getRunStatusConfig(dstatus)}
636
+ {@const running = run.status === "running"}
637
+ {@const spine = config.dot.replace(" animate-pulse", "")}
638
+ {@const startedAt = run.start_at ?? run.created_at}
639
+ {@const retry = runRetryLabel(run)}
640
+ {@const suffix = instanceSuffix(run.instance_index, getInstanceCount(run.task_name))}
641
+ <button
642
+ class="btn-scale group relative w-full rounded-[10px] border text-left transition-all select-none {showTaskName
643
+ ? 'p-3'
644
+ : 'px-3 py-[11px]'} {isActive
645
+ ? 'border-outline bg-surface-raised shadow-sm'
646
+ : 'border-transparent hover:bg-surface-sunken'}"
647
+ onclick={() => selectRun(run.id)}
648
+ onkeydown={(e) => e.key === "Enter" && selectRun(run.id)}
649
+ >
650
+ {#if showTaskName}
651
+ <!-- Cross-task /runs variant: the same readout language as the task
652
+ rail — status dot, status-colored outcome, mono right readout —
653
+ with the task name carried as the primary. -->
654
+ <div class="flex items-center gap-2.5">
655
+ {@render statusDot(config.color, running)}
656
+ <span class="flex min-w-0 flex-1 flex-col gap-0.5">
657
+ <span class="flex items-center gap-1.5">
658
+ <span class="truncate text-[13px] font-semibold text-on-surface">
659
+ {run.task_name}{#if suffix}<span class="text-on-surface-muted"
660
+ >{suffix}</span
661
+ >{/if}
662
+ </span>
663
+ <span class="shrink-0 text-on-surface-faint">·</span>
664
+ <span class="shrink-0 text-[12px] font-semibold capitalize {config.color}"
665
+ >{dstatus}</span
666
+ >
667
+ </span>
668
+ <span
669
+ class="flex min-w-0 items-center gap-1.5 text-2xs text-on-surface-faint"
670
+ title={formatFullDateTime(startedAt)}
671
+ >
672
+ <span class="truncate">{formatDateTime(startedAt)}</span>
673
+ <span class="shrink-0">· {formatTriggeredByLabel(run.triggered_by)}</span>
674
+ {#if retry}
675
+ <span class="shrink-0 rounded bg-surface-sunken px-1 font-mono"
676
+ >{retry}</span
677
+ >
678
+ {/if}
679
+ </span>
680
+ </span>
681
+ <span
682
+ class="shrink-0 self-start pt-0.5 font-mono text-[11.5px] text-on-surface-faint tabular-nums"
683
+ title={retry ?? undefined}
684
+ >
685
+ {rowRightLabel(run, dstatus)}
686
+ </span>
687
+ </div>
688
+ {:else}
689
+ <!-- Task-rail variant (artifact ".run"): a single dense line —
690
+ time · date · outcome, with a mono exit/duration readout.
691
+ leading-tight matches the artifact's ~1.2 line-height so the
692
+ (descender-less) text optically centers instead of riding high
693
+ inside Tailwind's default 1.5 line box. -->
694
+ <div class="flex items-center gap-[11px] leading-tight">
695
+ {@render statusDot(config.color, running)}
696
+ <span class="flex min-w-0 flex-1 items-center gap-1.5 truncate">
697
+ <span
698
+ class="text-[12.5px] font-semibold tracking-tight text-on-surface"
699
+ title={formatFullDateTime(startedAt)}
700
+ >
701
+ {formatTimeHM(startedAt)} · {formatDayMonth(startedAt)}
702
+ </span>
703
+ <span class="text-on-surface-faint">·</span>
704
+ <span class="text-[12.5px] font-semibold capitalize {config.color}"
705
+ >{dstatus}</span
706
+ >
707
+ {#if suffix}
708
+ <span class="font-mono text-2xs text-on-surface-faint">{suffix}</span>
709
+ {/if}
710
+ </span>
711
+ <span
712
+ class="shrink-0 font-mono text-[11.5px] text-on-surface-faint tabular-nums"
713
+ title={retry ?? undefined}
714
+ >
715
+ {rowRightLabel(run, dstatus)}
716
+ </span>
717
+ </div>
718
+ {#if match}
719
+ {@const hl = highlightParts(match.text, outputQuery)}
720
+ <div
721
+ class="mt-1.5 truncate rounded-md bg-surface-sunken px-2 py-1 font-mono text-2xs text-on-surface-muted"
722
+ >
723
+ {hl.before}<mark
724
+ class="rounded-sm bg-primary-soft px-0.5 text-primary-soft-text"
725
+ >{hl.match}</mark
726
+ >{hl.after}
727
+ </div>
728
+ {/if}
729
+ {/if}
730
+
731
+ <div
732
+ class="duration-normal absolute inset-y-2 left-[-6px] w-[3px] rounded-[3px] transition-all {spine} {isActive
733
+ ? 'opacity-100'
734
+ : 'opacity-0'}"
735
+ aria-hidden="true"
736
+ ></div>
737
+ </button>
738
+ {/snippet}