@runwisp/ui 0.4.0 → 0.7.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.
@@ -1,331 +1,500 @@
1
1
  <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
2
  <!-- SPDX-License-Identifier: Apache-2.0 -->
3
3
 
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;
11
+ }
12
+ </script>
13
+
4
14
  <script lang="ts">
5
15
  import {
6
16
  Clock,
7
17
  Timer,
8
- ChevronLeft,
9
- ChevronRight,
10
18
  ArrowUpDown,
11
19
  Search,
12
20
  Funnel,
21
+ Square,
22
+ Trash2,
23
+ RotateCw,
13
24
  } from "@lucide/svelte";
25
+ import { untrack } from "svelte";
26
+ import { SvelteSet } from "svelte/reactivity";
27
+ import { createVirtualizer } from "@tanstack/svelte-virtual";
14
28
  import Button from "../Button.svelte";
15
29
  import Badge from "../Badge.svelte";
30
+ import EmptyState from "../EmptyState.svelte";
16
31
  import type { Run } from "./types.js";
17
- import type { RunStatus } from "@runwisp/common";
32
+ import type { RunSelector } from "@runwisp/common";
18
33
  import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
19
34
  import { runDuration } from "./run-helpers.js";
20
35
  import { formatRelativeTime } from "../../utils/format.js";
21
36
  import { formatShortId } from "../../utils/id.js";
22
37
 
38
+ type BulkHandler = (selector: RunSelector, affected: Run[]) => void;
39
+
23
40
  let {
24
- runs,
41
+ items,
42
+ total,
43
+ loading = false,
44
+ filters = $bindable(),
45
+ onLoadMore,
25
46
  selectedRunId = $bindable(null),
26
47
  onselect,
27
48
  showFilters = false,
28
49
  showTaskName = false,
50
+ taskNameFilter,
29
51
  headerLabel = "Run History",
30
52
  emptyText = "No runs yet",
53
+ bulkActions = false,
54
+ onBulkCancel,
55
+ onBulkDelete,
56
+ onBulkRerun,
31
57
  }: {
32
- runs: Run[];
58
+ items: Run[];
59
+ total: number;
60
+ loading?: boolean;
61
+ filters: RunsListFilters;
62
+ onLoadMore?: () => void;
33
63
  selectedRunId?: string | null;
34
64
  onselect?: (runId: string) => void;
35
65
  showFilters?: boolean;
36
66
  showTaskName?: boolean;
67
+ taskNameFilter?: string;
37
68
  headerLabel?: string;
38
69
  emptyText?: string;
70
+ bulkActions?: boolean;
71
+ onBulkCancel?: BulkHandler;
72
+ onBulkDelete?: BulkHandler;
73
+ onBulkRerun?: BulkHandler;
39
74
  } = $props();
40
75
 
76
+ const ROW_HEIGHT = 76;
77
+ const OVERSCAN = 8;
78
+ const LOAD_AHEAD = 10;
79
+ const SEARCH_DEBOUNCE_MS = 250;
80
+
81
+ // Selection model: two modes —
82
+ // 1. explicit: user picked specific rows. explicitIds holds them.
83
+ // 2. selectAll: "all matching the current filter". exceptIds holds opt-outs.
84
+ let selectAllMode = $state(false);
85
+ const explicitIds = new SvelteSet<string>();
86
+ const exceptIds = new SvelteSet<string>();
87
+
88
+ let scrollElement: HTMLDivElement | undefined = $state();
89
+ let searchInput = $state(filters.search);
90
+
91
+ // Debounce typing in the search box before pushing into shared filters,
92
+ // so we don't fire a network request on every keystroke.
93
+ $effect(() => {
94
+ const next = searchInput;
95
+ const id = setTimeout(() => {
96
+ if (filters.search !== next) filters.search = next;
97
+ }, SEARCH_DEBOUNCE_MS);
98
+ return () => clearTimeout(id);
99
+ });
100
+
101
+ const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
102
+ count: 0,
103
+ getScrollElement: () => scrollElement ?? null,
104
+ estimateSize: () => ROW_HEIGHT,
105
+ overscan: OVERSCAN,
106
+ });
107
+
108
+ // `setOptions` always notifies the store (see @tanstack/svelte-virtual src),
109
+ // so reading $virtualizer here would loop. Untrack the store read.
110
+ $effect(() => {
111
+ const count = items.length;
112
+ untrack(() => $virtualizer.setOptions({ count }));
113
+ });
114
+
115
+ $effect(() => {
116
+ const visible = $virtualizer.getVirtualItems();
117
+ const last = visible.at(-1);
118
+ if (!last) return;
119
+ if (loading) return;
120
+ if (items.length >= total) return;
121
+ if (last.index >= items.length - LOAD_AHEAD) onLoadMore?.();
122
+ });
123
+
124
+ function isRowSelected(id: string): boolean {
125
+ return selectAllMode ? !exceptIds.has(id) : explicitIds.has(id);
126
+ }
127
+
128
+ function toggleRow(id: string) {
129
+ if (selectAllMode) {
130
+ if (exceptIds.has(id)) exceptIds.delete(id);
131
+ else exceptIds.add(id);
132
+ } else {
133
+ if (explicitIds.has(id)) explicitIds.delete(id);
134
+ else explicitIds.add(id);
135
+ }
136
+ }
137
+
138
+ function clearSelection() {
139
+ selectAllMode = false;
140
+ explicitIds.clear();
141
+ exceptIds.clear();
142
+ }
143
+
144
+ function selectAllVisible() {
145
+ selectAllMode = true;
146
+ explicitIds.clear();
147
+ exceptIds.clear();
148
+ }
149
+
41
150
  function selectRun(runId: string) {
42
151
  selectedRunId = runId;
43
152
  onselect?.(runId);
44
153
  }
45
154
 
46
- let searchQuery = $state("");
47
- let statusFilter = $state<RunStatus | "all">("all");
48
- let sortDirection = $state<"asc" | "desc">("desc");
49
- let currentPage = $state(1);
50
- const pageSize = 10;
155
+ let selectedRuns = $derived(items.filter((r: Run) => isRowSelected(r.id)));
156
+ let selectionCount = $derived(selectedRuns.length);
157
+ let hasSelection = $derived(selectionCount > 0);
158
+ let allSelected = $derived(selectAllMode && exceptIds.size === 0);
51
159
 
52
- let filteredRuns = $derived(
53
- showFilters
54
- ? runs.filter((r: Run) => {
55
- if (statusFilter !== "all" && runDisplayStatus(r) !== statusFilter) return false;
56
- if (searchQuery.trim()) {
57
- const q = searchQuery.toLowerCase();
58
- return (
59
- r.id.toLowerCase().includes(q) || r.task_name.toLowerCase().includes(q)
60
- );
61
- }
62
- return true;
63
- })
64
- : runs,
160
+ let anyRunning = $derived(selectedRuns.some((r: Run) => r.status === "running"));
161
+ let anyTerminal = $derived(
162
+ selectedRuns.some((r: Run) => r.status !== "running" && r.status !== "pending"),
65
163
  );
66
164
 
67
- let sortedRuns = $derived(
68
- [...filteredRuns].sort((a: Run, b: Run) => {
69
- const dateA = new Date(a.created_at).getTime();
70
- const dateB = new Date(b.created_at).getTime();
71
- return sortDirection === "desc" ? dateB - dateA : dateA - dateB;
72
- }),
73
- );
165
+ function buildSelector(): RunSelector {
166
+ if (!selectAllMode) {
167
+ return { match_all: false, ids: [...explicitIds] };
168
+ }
169
+ const filter: { status?: string; task_name?: string; search?: string } = {};
170
+ if (taskNameFilter) filter.task_name = taskNameFilter;
171
+ if (showFilters) {
172
+ if (filters.status && filters.status !== "all") filter.status = filters.status;
173
+ const query = filters.search.trim();
174
+ if (query) filter.search = query;
175
+ }
176
+ return {
177
+ match_all: true,
178
+ filter,
179
+ except_ids: [...exceptIds],
180
+ };
181
+ }
74
182
 
75
- let totalPages = $derived(Math.max(1, Math.ceil(filteredRuns.length / pageSize)));
76
- let paginatedRuns = $derived(
77
- sortedRuns.slice((currentPage - 1) * pageSize, currentPage * pageSize),
78
- );
183
+ function emitBulk(handler: BulkHandler | undefined, predicate: (r: Run) => boolean) {
184
+ if (!handler) return;
185
+ const affected = selectedRuns.filter(predicate);
186
+ if (affected.length === 0) return;
187
+ const sel = buildSelector();
188
+ // Narrow the selector to the affected predicate when in explicit mode
189
+ // so we don't ask the server to operate on rows the UI excluded.
190
+ const narrowed: RunSelector = sel.match_all
191
+ ? sel
192
+ : { match_all: false, ids: affected.map((r) => r.id) };
193
+ handler(narrowed, affected);
194
+ clearSelection();
195
+ }
79
196
 
197
+ function handleMasterToggle() {
198
+ if (hasSelection) clearSelection();
199
+ else selectAllVisible();
200
+ }
201
+
202
+ function toggleSortDirection() {
203
+ filters.sort_direction = filters.sort_direction === "asc" ? "desc" : "asc";
204
+ }
205
+
206
+ let masterCheckboxRef: HTMLInputElement | undefined = $state();
80
207
  $effect(() => {
81
- if (!selectedRunId) return;
82
- const idx = sortedRuns.findIndex((r: Run) => r.id === selectedRunId);
83
- if (idx < 0) return;
84
- const targetPage = Math.floor(idx / pageSize) + 1;
85
- if (targetPage !== currentPage) currentPage = targetPage;
208
+ if (!masterCheckboxRef) return;
209
+ masterCheckboxRef.indeterminate = hasSelection && !allSelected;
86
210
  });
87
211
  </script>
88
212
 
89
213
  <div
90
214
  class="flex flex-col overflow-hidden rounded-xl border border-outline bg-surface-raised shadow-sm md:col-span-4 lg:col-span-3"
91
215
  >
92
- {#if showFilters}
93
- <!-- Filter Header -->
94
- <div class="shrink-0 space-y-3 border-b border-outline-faint bg-surface-sunken p-3">
95
- <div class="flex items-center justify-between">
96
- <span class="text-xs font-semibold tracking-wider text-on-surface-muted uppercase"
97
- >{headerLabel}</span
98
- >
99
- <Badge
100
- variant="default"
101
- size="sm"
102
- class="bg-outline text-on-surface hover:bg-outline-faint"
103
- >{filteredRuns.length}</Badge
104
- >
105
- </div>
106
-
107
- <div class="relative">
108
- <Search
109
- class="absolute top-1/2 left-2.5 -translate-y-1/2 text-on-surface-faint"
110
- size={14}
111
- />
216
+ <!-- Inline heading: master checkbox, label, selection count or controls -->
217
+ <div
218
+ class="flex shrink-0 items-center gap-2 border-b border-outline-faint px-3 py-2 {hasSelection
219
+ ? 'bg-primary-soft/40'
220
+ : 'bg-surface-sunken'}"
221
+ >
222
+ {#if bulkActions}
223
+ <label
224
+ class="flex shrink-0 cursor-pointer items-center"
225
+ title={hasSelection ? "Clear selection" : "Select all"}
226
+ >
112
227
  <input
113
- type="text"
114
- bind:value={searchQuery}
115
- placeholder="Search task or ID..."
116
- 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"
228
+ bind:this={masterCheckboxRef}
229
+ type="checkbox"
230
+ checked={allSelected}
231
+ onchange={handleMasterToggle}
232
+ class="h-3.5 w-3.5 cursor-pointer rounded border-outline accent-primary"
233
+ aria-label={hasSelection ? "Clear selection" : "Select all"}
117
234
  />
118
- </div>
235
+ </label>
236
+ {/if}
119
237
 
120
- <div class="flex items-center gap-2">
121
- <div class="relative flex-1">
122
- <select
123
- bind:value={statusFilter}
124
- 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"
238
+ {#if hasSelection}
239
+ <span class="text-xs font-medium text-on-surface">
240
+ {selectionCount} selected
241
+ </span>
242
+ <div class="ml-auto flex items-center gap-1">
243
+ {#if onBulkRerun}
244
+ <Button
245
+ variant="ghost"
246
+ size="xs"
247
+ class="h-7 w-7 px-0"
248
+ onclick={() => emitBulk(onBulkRerun, () => true)}
249
+ title="Re-run task{selectionCount === 1 ? '' : 's'}"
125
250
  >
126
- <option value="all">All Statuses</option>
127
- <option value="running">Running</option>
128
- <option value="success">Success</option>
129
- <option value="failed">Failed</option>
130
- <option value="crashed">Crashed</option>
131
- <option value="skipped">Skipped</option>
132
- </select>
133
- <Funnel
134
- class="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-on-surface-faint"
135
- size={12}
136
- />
137
- </div>
138
-
251
+ {#snippet icon()}<RotateCw size={14} />{/snippet}
252
+ </Button>
253
+ {/if}
254
+ {#if onBulkCancel && anyRunning}
255
+ <Button
256
+ variant="ghost"
257
+ size="xs"
258
+ class="h-7 w-7 px-0"
259
+ onclick={() => emitBulk(onBulkCancel, (r) => r.status === "running")}
260
+ title="Cancel running run{selectionCount === 1 ? '' : 's'}"
261
+ >
262
+ {#snippet icon()}<Square size={14} />{/snippet}
263
+ </Button>
264
+ {/if}
265
+ {#if onBulkDelete && anyTerminal}
266
+ <Button
267
+ variant="danger"
268
+ size="xs"
269
+ class="h-7 w-7 px-0"
270
+ onclick={() =>
271
+ emitBulk(
272
+ onBulkDelete,
273
+ (r) => r.status !== "running" && r.status !== "pending",
274
+ )}
275
+ title="Delete run{selectionCount === 1 ? '' : 's'}"
276
+ >
277
+ {#snippet icon()}<Trash2 size={14} />{/snippet}
278
+ </Button>
279
+ {/if}
280
+ </div>
281
+ {:else}
282
+ <span class="text-xs font-semibold tracking-wider text-on-surface-muted uppercase">
283
+ {headerLabel}
284
+ </span>
285
+ <Badge variant="default" size="sm">{items.length} of {total}</Badge>
286
+ <div class="ml-auto flex items-center gap-1">
139
287
  <Button
140
288
  variant="ghost"
141
289
  size="xs"
142
290
  class="h-7 w-7 px-0"
143
- onclick={() => (sortDirection = sortDirection === "desc" ? "asc" : "desc")}
144
- title="Toggle Sort Order"
291
+ onclick={toggleSortDirection}
292
+ title="Toggle sort order"
145
293
  >
146
294
  {#snippet icon()}
147
295
  <ArrowUpDown
148
296
  size={14}
149
- class="text-on-surface-muted {sortDirection === 'asc'
297
+ class="text-on-surface-muted {filters.sort_direction === 'asc'
150
298
  ? 'rotate-180'
151
299
  : ''}"
152
300
  />
153
301
  {/snippet}
154
302
  </Button>
155
303
  </div>
156
- </div>
157
- {:else}
158
- <!-- Simple Header -->
159
- <div
160
- class="flex shrink-0 items-center justify-between border-b border-outline-faint bg-surface-sunken p-3"
161
- >
162
- <div class="flex items-center gap-2">
163
- <span class="text-xs font-semibold tracking-wider text-on-surface-muted uppercase"
164
- >{headerLabel}</span
165
- >
166
- <Badge variant="default" size="sm">{runs.length}</Badge>
304
+ {/if}
305
+ </div>
306
+
307
+ {#if showFilters}
308
+ <!-- Filter controls (search + status) -->
309
+ <div class="shrink-0 space-y-2 border-b border-outline-faint bg-surface-sunken px-3 py-2">
310
+ <div class="relative">
311
+ <Search
312
+ class="absolute top-1/2 left-2.5 -translate-y-1/2 text-on-surface-faint"
313
+ size={14}
314
+ />
315
+ <input
316
+ type="text"
317
+ bind:value={searchInput}
318
+ placeholder="Search task or ID..."
319
+ 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"
320
+ />
167
321
  </div>
168
- <div class="flex items-center gap-1">
169
- <Button
170
- variant="ghost"
171
- size="xs"
172
- onclick={() => (sortDirection = sortDirection === "desc" ? "asc" : "desc")}
173
- title="Sort by Date"
322
+ <div class="relative">
323
+ <select
324
+ bind:value={filters.status}
325
+ 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"
174
326
  >
175
- {#snippet icon()}
176
- <ArrowUpDown
177
- size={14}
178
- class="text-on-surface-muted {sortDirection === 'asc'
179
- ? 'rotate-180'
180
- : ''}"
181
- />
182
- {/snippet}
183
- </Button>
327
+ <option value="all">All Statuses</option>
328
+ <option value="running">Running</option>
329
+ <option value="success">Success</option>
330
+ <option value="failed">Failed</option>
331
+ <option value="crashed">Crashed</option>
332
+ <option value="skipped">Skipped</option>
333
+ </select>
334
+ <Funnel
335
+ class="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-on-surface-faint"
336
+ size={12}
337
+ />
184
338
  </div>
185
339
  </div>
186
340
  {/if}
187
341
 
188
- <div class="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
189
- {#each paginatedRuns as run (run.id)}
190
- {@const isActive = selectedRunId === run.id}
191
- {@const config = getRunStatusConfig(runDisplayStatus(run))}
192
- {@const Icon = config.icon}
193
- {@const duration = runDuration(run)}
194
- <button
195
- class="group duration-normal relative w-full rounded-lg border p-3 text-left transition-all select-none
196
- {isActive
197
- ? 'border-primary-soft bg-primary-soft/50 shadow-sm'
198
- : 'border-transparent bg-surface-raised hover:border-outline hover:bg-surface-sunken'}"
199
- onclick={() => selectRun(run.id)}
200
- onkeydown={(e) => e.key === "Enter" && selectRun(run.id)}
342
+ <div bind:this={scrollElement} class="min-h-0 flex-1 overflow-y-auto p-2">
343
+ {#if items.length === 0 && !loading}
344
+ <EmptyState title={emptyText} icon={Clock} iconSize={32} class="py-8" />
345
+ {:else}
346
+ <div
347
+ style:height="{$virtualizer.getTotalSize()}px"
348
+ style:width="100%"
349
+ style:position="relative"
201
350
  >
202
- {#if showTaskName}
203
- <!-- Runs variant: task name as primary, shortId secondary -->
204
- <div class="mb-1 flex items-center justify-between">
205
- <div class="flex min-w-0 items-center gap-2">
206
- <div class="{config.color} shrink-0">
207
- <Icon
208
- size={16}
209
- class={run.status === "running" ? "animate-spin" : ""}
210
- />
211
- </div>
212
- <span class="truncate text-sm font-semibold text-on-surface">
213
- {run.task_name}{#if run.instance_index > 0}<span
214
- class="text-on-surface-muted">#{run.instance_index}</span
215
- >{/if}
216
- </span>
217
- </div>
218
- <span
219
- class="shrink-0 text-[10px] {isActive
220
- ? 'font-medium text-primary'
221
- : 'text-on-surface-faint'}"
222
- >
223
- {formatRelativeTime(run.start_at || run.created_at)}
224
- </span>
225
- </div>
226
-
227
- <div class="flex items-center justify-between pl-6 text-xs">
351
+ {#each $virtualizer.getVirtualItems() as row (items[row.index]?.id ?? row.index)}
352
+ {@const run = items[row.index]}
353
+ {#if run}
354
+ {@const isActive = selectedRunId === run.id}
355
+ {@const isChecked = isRowSelected(run.id)}
356
+ {@const config = getRunStatusConfig(runDisplayStatus(run))}
357
+ {@const Icon = config.icon}
358
+ {@const duration = runDuration(run)}
228
359
  <div
229
- class="flex items-center gap-1 font-mono text-[11px] text-on-surface-muted"
230
- >
231
- #{formatShortId(run.id)}
232
- </div>
233
- {#if duration}
234
- <div
235
- class="flex items-center gap-1 text-on-surface-faint {isActive
236
- ? 'text-primary'
237
- : ''}"
238
- >
239
- <Timer size={10} />
240
- <span class="font-mono">{duration}</span>
241
- </div>
242
- {/if}
243
- </div>
244
- {:else}
245
- <!-- Task variant: shortId as primary, status secondary -->
246
- <div class="mb-1.5 flex items-center justify-between">
247
- <div class="flex items-center gap-2">
248
- <div class={config.color}>
249
- <Icon
250
- size={16}
251
- class={run.status === "running" ? "animate-spin" : ""}
252
- />
253
- </div>
254
- <span class="font-mono text-xs font-medium text-on-surface">
255
- #{formatShortId(run.id)}
256
- </span>
257
- </div>
258
- <span
259
- class="text-[10px] {isActive
260
- ? 'font-medium text-primary'
261
- : 'text-on-surface-faint'}"
360
+ class="flex items-stretch gap-1"
361
+ style:position="absolute"
362
+ style:top="0"
363
+ style:left="0"
364
+ style:width="100%"
365
+ style:height="{row.size}px"
366
+ style:transform="translateY({row.start}px)"
262
367
  >
263
- {formatRelativeTime(run.start_at || run.created_at)}
264
- </span>
265
- </div>
266
-
267
- <div class="flex items-center justify-between text-xs">
268
- <div class="flex items-center gap-2 text-on-surface-muted">
269
- <span class="capitalize">{runDisplayStatus(run)}</span>
270
- {#if run.instance_index > 0}
271
- <span class="font-mono text-[11px] text-on-surface-faint"
272
- >instance #{run.instance_index}</span
368
+ {#if bulkActions}
369
+ <label
370
+ class="flex shrink-0 cursor-pointer items-center px-1.5"
371
+ aria-label={`Select run ${formatShortId(run.id)}`}
273
372
  >
373
+ <input
374
+ type="checkbox"
375
+ checked={isChecked}
376
+ onchange={() => toggleRow(run.id)}
377
+ onclick={(e) => e.stopPropagation()}
378
+ class="h-3.5 w-3.5 cursor-pointer rounded border-outline accent-primary"
379
+ />
380
+ </label>
274
381
  {/if}
275
- </div>
276
- {#if duration}
277
- <div
278
- class="flex items-center gap-1 text-on-surface-faint {isActive
279
- ? 'text-primary'
280
- : ''}"
382
+ <button
383
+ class="btn-scale group duration-normal relative w-full rounded-lg border p-3 text-left transition-all select-none
384
+ {isActive
385
+ ? 'border-primary-soft bg-primary-soft/50 shadow-sm'
386
+ : 'border-transparent bg-surface-raised hover:border-outline hover:bg-surface-sunken'}"
387
+ onclick={() => selectRun(run.id)}
388
+ onkeydown={(e) => e.key === "Enter" && selectRun(run.id)}
281
389
  >
282
- <Timer size={10} />
283
- <span class="font-mono">{duration}</span>
284
- </div>
285
- {/if}
286
- </div>
287
- {/if}
390
+ {#if showTaskName}
391
+ <!-- Runs variant: task name as primary, shortId secondary -->
392
+ <div class="mb-1 flex items-center justify-between">
393
+ <div class="flex min-w-0 items-center gap-2">
394
+ <div class="{config.color} shrink-0">
395
+ <Icon
396
+ size={16}
397
+ class={run.status === "running"
398
+ ? "animate-spin"
399
+ : ""}
400
+ />
401
+ </div>
402
+ <span
403
+ class="truncate text-sm font-semibold text-on-surface"
404
+ >
405
+ {run.task_name}{#if run.instance_index > 0}<span
406
+ class="text-on-surface-muted"
407
+ >#{run.instance_index}</span
408
+ >{/if}
409
+ </span>
410
+ </div>
411
+ <span
412
+ class="shrink-0 text-2xs {isActive
413
+ ? 'font-medium text-primary'
414
+ : 'text-on-surface-faint'}"
415
+ >
416
+ {formatRelativeTime(run.start_at || run.created_at)}
417
+ </span>
418
+ </div>
288
419
 
289
- {#if isActive}
290
- <div
291
- class="absolute top-1/2 left-0 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary"
292
- ></div>
293
- {/if}
294
- </button>
295
- {/each}
420
+ <div class="flex items-center justify-between pl-6 text-xs">
421
+ <div
422
+ class="flex items-center gap-1 font-mono text-2xs text-on-surface-muted"
423
+ >
424
+ #{formatShortId(run.id)}
425
+ </div>
426
+ {#if duration}
427
+ <div
428
+ class="flex items-center gap-1 text-on-surface-faint {isActive
429
+ ? 'text-primary'
430
+ : ''}"
431
+ >
432
+ <Timer size={10} />
433
+ <span class="font-mono">{duration}</span>
434
+ </div>
435
+ {/if}
436
+ </div>
437
+ {:else}
438
+ <!-- Task variant: shortId as primary, status secondary -->
439
+ <div class="mb-1.5 flex items-center justify-between">
440
+ <div class="flex items-center gap-2">
441
+ <div class={config.color}>
442
+ <Icon
443
+ size={16}
444
+ class={run.status === "running"
445
+ ? "animate-spin"
446
+ : ""}
447
+ />
448
+ </div>
449
+ <span
450
+ class="font-mono text-xs font-medium text-on-surface"
451
+ >
452
+ #{formatShortId(run.id)}
453
+ </span>
454
+ </div>
455
+ <span
456
+ class="text-2xs {isActive
457
+ ? 'font-medium text-primary'
458
+ : 'text-on-surface-faint'}"
459
+ >
460
+ {formatRelativeTime(run.start_at || run.created_at)}
461
+ </span>
462
+ </div>
296
463
 
297
- {#if paginatedRuns.length === 0}
298
- <div class="flex flex-col items-center gap-3 p-8 text-center text-on-surface-faint">
299
- <div class="rounded-full bg-surface-sunken p-3">
300
- <Clock size={24} class="opacity-50" />
301
- </div>
302
- <span class="text-sm">{emptyText}</span>
464
+ <div class="flex items-center justify-between text-xs">
465
+ <div class="flex items-center gap-2 text-on-surface-muted">
466
+ <span class="capitalize">{runDisplayStatus(run)}</span>
467
+ {#if run.instance_index > 0}
468
+ <span
469
+ class="font-mono text-2xs text-on-surface-faint"
470
+ >instance #{run.instance_index}</span
471
+ >
472
+ {/if}
473
+ </div>
474
+ {#if duration}
475
+ <div
476
+ class="flex items-center gap-1 text-on-surface-faint {isActive
477
+ ? 'text-primary'
478
+ : ''}"
479
+ >
480
+ <Timer size={10} />
481
+ <span class="font-mono">{duration}</span>
482
+ </div>
483
+ {/if}
484
+ </div>
485
+ {/if}
486
+
487
+ <div
488
+ 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
489
+ ? 'opacity-100'
490
+ : 'opacity-0'}"
491
+ aria-hidden="true"
492
+ ></div>
493
+ </button>
494
+ </div>
495
+ {/if}
496
+ {/each}
303
497
  </div>
304
498
  {/if}
305
499
  </div>
306
-
307
- <!-- Pagination Footer -->
308
- <div
309
- class="flex shrink-0 items-center justify-between border-t border-outline-faint bg-surface-sunken p-2"
310
- >
311
- <Button
312
- variant="ghost"
313
- size="xs"
314
- disabled={currentPage === 1}
315
- onclick={() => currentPage--}
316
- >
317
- {#snippet icon()}<ChevronLeft size={14} />{/snippet}
318
- </Button>
319
- <span class="text-[10px] font-medium tracking-wide text-on-surface-muted uppercase">
320
- Page {currentPage} of {totalPages}
321
- </span>
322
- <Button
323
- variant="ghost"
324
- size="xs"
325
- disabled={currentPage === totalPages}
326
- onclick={() => currentPage++}
327
- >
328
- {#snippet icon()}<ChevronRight size={14} />{/snippet}
329
- </Button>
330
- </div>
331
500
  </div>