@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.
@@ -4,40 +4,69 @@
4
4
  <script lang="ts">
5
5
  import { untrack } from "svelte";
6
6
  import {
7
- Server,
8
7
  Hash,
8
+ Check,
9
9
  Terminal as TerminalIcon,
10
10
  Download,
11
11
  Trash2,
12
+ Play,
13
+ Square,
12
14
  MousePointerClick,
13
15
  RotateCw,
16
+ RefreshCcw,
17
+ ChevronDown,
18
+ History,
19
+ PanelLeftClose,
14
20
  SlidersHorizontal,
21
+ Maximize2,
22
+ Minimize2,
23
+ TextWrap,
15
24
  } from "@lucide/svelte";
16
- import Badge from "../Badge.svelte";
17
25
  import Button from "../Button.svelte";
18
26
  import EmptyState from "../EmptyState.svelte";
19
27
  import LogConsole from "../LogConsole.svelte";
20
28
  import Popover from "../Popover.svelte";
21
29
  import Tooltip from "../Tooltip.svelte";
30
+ import { portal } from "../../actions/portal.js";
22
31
  import type { Run } from "./types.js";
23
32
  import type { LogEvent, LogSlice } from "../../log-console/types.js";
24
- import { formatDateTime, formatFullDateTime } from "../../utils/format.js";
33
+ import {
34
+ formatDateTime,
35
+ formatFullDateTime,
36
+ formatClockTime,
37
+ formatCalendarDate,
38
+ formatTimeHM,
39
+ } from "../../utils/format.js";
25
40
  import { formatShortId } from "../../utils/id.js";
41
+ import { TickingNow } from "../../utils/ticking-now.svelte.js";
26
42
  import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
27
43
  import {
28
44
  runDuration,
29
45
  runStartDelay,
30
46
  formatTriggeredByLabel,
31
47
  runRetryLabel,
48
+ instanceSuffix,
32
49
  } from "./run-helpers.js";
33
50
 
34
51
  let {
35
52
  run,
36
53
  fetchLogs,
37
54
  streamLogs,
55
+ fetchLineHistory,
38
56
  showTaskName = false,
39
57
  onDelete,
58
+ onRun,
59
+ onRunAgain,
60
+ onStop,
61
+ onRunTask,
62
+ onStopService,
63
+ onRestartService,
64
+ serviceStopped = false,
65
+ serviceBusy = false,
66
+ onToggleHistory,
67
+ historyVisible = false,
40
68
  highlightLine = null,
69
+ getInstanceCount = () => 1,
41
70
  }: {
42
71
  run: Run | undefined;
43
72
  fetchLogs: (
@@ -50,9 +79,39 @@
50
79
  onEvent: (event: LogEvent) => void,
51
80
  initialState?: { fromLine: number },
52
81
  ) => () => void;
82
+ // Resolves the prior whole-region frames of a settled progress bar /
83
+ // redraw line so the log console can offer inline rewind. Optional.
84
+ fetchLineHistory?: (runId: string, lineNum: number) => Promise<string[][]>;
53
85
  showTaskName?: boolean;
54
86
  onDelete?: (runId: string) => void;
87
+ // Start a new run with default options. When set, the action cluster's
88
+ // primary control is a Run button (the cold-start / re-trigger path).
89
+ onRun?: (() => void) | undefined;
90
+ // Re-run reusing the selected run's parameters. Passed only when the task
91
+ // actually has parameters (otherwise it is identical to onRun); it becomes
92
+ // the dropdown half of the Run split button. Omitted on the cross-task
93
+ // runs page or for tasks whose API trigger is disabled.
94
+ onRunAgain?: (() => void) | undefined;
95
+ // Stop the selected run while it is live. When provided and the run is
96
+ // running, the action cluster shows Stop alongside Run.
97
+ onStop?: ((runId: string) => void) | undefined;
98
+ // Trigger the task from the empty state (no run selected yet) — the
99
+ // cold-start path so a never-run task is still launchable from here.
100
+ onRunTask?: (() => void) | undefined;
101
+ // Service lifecycle controls. When either is set the cluster swaps the
102
+ // Run control for Stop Service / Restart Service (chosen by serviceStopped).
103
+ onStopService?: (() => void) | undefined;
104
+ onRestartService?: (() => void) | undefined;
105
+ serviceStopped?: boolean;
106
+ serviceBusy?: boolean;
107
+ // Toggle the (single-instance service) history rail from the panel header,
108
+ // since these tasks hide the rail by default and there is no top bar.
109
+ onToggleHistory?: (() => void) | undefined;
110
+ historyVisible?: boolean;
55
111
  highlightLine?: number | null;
112
+ // Resolves a task's currently configured instance count so multi-instance
113
+ // services render a 1-based #N suffix. Defaults to single-instance.
114
+ getInstanceCount?: (taskName: string) => number;
56
115
  } = $props();
57
116
 
58
117
  let canDelete = $derived.by(() => {
@@ -61,6 +120,15 @@
61
120
  return status !== "running" && status !== "pending";
62
121
  });
63
122
 
123
+ // Optimistic per-second clock so a live run's "Ran for" duration counts up
124
+ // between SSE events. The interval only runs while the run is in-flight; it
125
+ // is torn down as soon as the run ends (or the panel is destroyed).
126
+ const durationTicker = new TickingNow(1000);
127
+ $effect(() => {
128
+ if (run?.status !== "running") return;
129
+ return durationTicker.start();
130
+ });
131
+
64
132
  const TAIL_LINES = 1000;
65
133
 
66
134
  let logConsole = $state<{ onStream: (event: LogEvent) => void } | null>(null);
@@ -96,267 +164,765 @@
96
164
  if (cleanup) cleanup();
97
165
  };
98
166
  });
167
+
168
+ // Maximize the console to a full-bleed overlay. The console wrapper is
169
+ // never remounted — `maximizePortal` relocates the *live* node to <body>
170
+ // (escaping any transformed/blurred ancestor that would otherwise trap a
171
+ // position:fixed child) and moves it back on restore, so the SSE stream,
172
+ // scroll position, and the {#key run.id} mount all survive the toggle.
173
+ let consoleMaximized = $state(false);
174
+ // Wrap long console lines instead of horizontally scrolling them. Owned
175
+ // here so the toolbar toggle and the <LogConsole> share one source of truth.
176
+ let consoleWrap = $state(false);
177
+
178
+ // A run going away (deselect) tears down the console; drop the overlay too.
179
+ $effect(() => {
180
+ if (!run) consoleMaximized = false;
181
+ });
182
+
183
+ // Inline-confirm popover for delete, and a transient "Copied" on the run-id chip.
184
+ let confirmDeleteOpen = $state(false);
185
+ // Dropdown half of the Run split button (the "reuse parameters" variant).
186
+ let runMenuOpen = $state(false);
187
+ let copiedId = $state(false);
188
+ let copyTimer: ReturnType<typeof setTimeout> | null = null;
189
+
190
+ async function copyRunId(id: string) {
191
+ try {
192
+ await navigator.clipboard.writeText(id);
193
+ copiedId = true;
194
+ if (copyTimer) clearTimeout(copyTimer);
195
+ copyTimer = setTimeout(() => (copiedId = false), 1200);
196
+ } catch {
197
+ // Clipboard blocked (insecure context / denied) — leave the chip as-is.
198
+ }
199
+ }
200
+
201
+ // One-word context line under the Exit value. Only success/failed carry a
202
+ // real process exit code; the others end on a synthetic sentinel, so the
203
+ // cell reads the reason ("stopped before exit") rather than "Code -1".
204
+ function exitSubLabel(displayed: string): string {
205
+ if (displayed === "running" || displayed === "pending") return "in progress";
206
+ if (displayed === "success") return "clean";
207
+ if (displayed === "failed") return "non-zero";
208
+ if (displayed === "stopped" || displayed === "daemon_stopped") return "stopped before exit";
209
+ if (displayed === "timeout") return "timed out";
210
+ if (displayed === "crashed") return "killed";
211
+ return "no exit code";
212
+ }
213
+
214
+ // One-word context line under each Trigger value, mirroring the source.
215
+ function triggerSub(triggeredBy: Run["triggered_by"]): string {
216
+ if (triggeredBy === "cron") return "schedule";
217
+ if (triggeredBy === "api") return "manual";
218
+ if (triggeredBy === "service") return "supervised";
219
+ if (triggeredBy === "startup") return "on boot";
220
+ return "remote";
221
+ }
222
+
223
+ // The status's accent color as a runtime CSS variable, used to wash the
224
+ // header in a faint tint of the outcome (the "black-box readout" look).
225
+ // Neutral statuses resolve to the surface itself, so the tint vanishes.
226
+ function accentVar(displayed: string): string {
227
+ if (displayed === "running" || displayed === "pending") return "var(--color-info)";
228
+ if (displayed === "success") return "var(--color-success-surface)";
229
+ if (
230
+ displayed === "stopped" ||
231
+ displayed === "timeout" ||
232
+ displayed === "daemon_stopped" ||
233
+ displayed === "queue_full"
234
+ )
235
+ return "var(--color-warning-surface)";
236
+ if (
237
+ displayed === "failed" ||
238
+ displayed === "crashed" ||
239
+ displayed === "log_overflow" ||
240
+ displayed === "missed" ||
241
+ displayed === "start_failed"
242
+ )
243
+ return "var(--color-danger-surface)";
244
+ return "var(--color-surface-raised)";
245
+ }
246
+
247
+ function handleConsoleKeydown(event: KeyboardEvent) {
248
+ if (!run) return;
249
+ if (event.key === "Escape" && consoleMaximized) {
250
+ consoleMaximized = false;
251
+ return;
252
+ }
253
+ // `F` toggles focus mode, the way a media player does — but never while
254
+ // the operator is typing into a field (search box, etc.).
255
+ if (
256
+ (event.key === "f" || event.key === "F") &&
257
+ !event.metaKey &&
258
+ !event.ctrlKey &&
259
+ !event.altKey
260
+ ) {
261
+ const target = event.target;
262
+ if (
263
+ target instanceof HTMLElement &&
264
+ (target.tagName === "INPUT" ||
265
+ target.tagName === "TEXTAREA" ||
266
+ target.isContentEditable)
267
+ )
268
+ return;
269
+ event.preventDefault();
270
+ consoleMaximized = !consoleMaximized;
271
+ }
272
+ }
273
+
274
+ function maximizePortal(node: HTMLElement, active: boolean) {
275
+ const anchor = document.createComment("maximized-console");
276
+ function moveOut() {
277
+ const parent = node.parentNode;
278
+ if (parent && parent !== document.body) {
279
+ parent.insertBefore(anchor, node);
280
+ document.body.appendChild(node);
281
+ }
282
+ }
283
+ function moveBack() {
284
+ const parent = anchor.parentNode;
285
+ if (parent) {
286
+ parent.insertBefore(node, anchor);
287
+ anchor.remove();
288
+ }
289
+ }
290
+ if (active) moveOut();
291
+ return {
292
+ update(next: boolean) {
293
+ if (next) moveOut();
294
+ else moveBack();
295
+ },
296
+ destroy() {
297
+ // Mirror the `portal` action: if still parked on <body>, remove
298
+ // the node so Svelte's own teardown doesn't leak it there.
299
+ if (node.parentNode === document.body) node.remove();
300
+ anchor.remove();
301
+ },
302
+ };
303
+ }
99
304
  </script>
100
305
 
306
+ <svelte:window onkeydown={handleConsoleKeydown} />
307
+
101
308
  {#if run}
102
- {@const config = getRunStatusConfig(runDisplayStatus(run))}
309
+ {@const status = runDisplayStatus(run)}
310
+ {@const config = getRunStatusConfig(status)}
103
311
  {@const DetailIcon = config.icon}
104
- {@const duration = runDuration(run)}
312
+ {@const duration = runDuration(
313
+ run,
314
+ run.status === "running" ? durationTicker.now.getTime() : undefined,
315
+ )}
105
316
  {@const startDelay = runStartDelay(run)}
106
317
  {@const startedAt = run.start_at ?? run.created_at}
107
318
  {@const retry = runRetryLabel(run)}
108
319
  {@const paramEntries = run.params ? Object.entries(run.params) : []}
109
- <!-- Detailed Header -->
110
- <div class="@container shrink-0 border-b border-outline-faint bg-surface-raised p-6">
111
- <div class="flex flex-col justify-between gap-6 @4xl:flex-row @4xl:items-start">
112
- <div class="flex items-start gap-4">
113
- <!-- Large Status Icon -->
114
- <div
115
- class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl {config.bg} {config.color}"
116
- >
117
- <DetailIcon size={32} class={run.status === "running" ? "animate-spin" : ""} />
118
- </div>
320
+ {@const suffix = instanceSuffix(run.instance_index, getInstanceCount(run.task_name))}
321
+ {@const spine = config.dot.replace(" animate-pulse", "")}
322
+ {@const isRunning = run.status === "running"}
323
+ {@const showCode = status === "success" || status === "failed"}
324
+ {@const exitClean = status === "success"}
325
+ {@const exitFail = status === "failed"}
326
+ {@const endLabel =
327
+ status === "stopped"
328
+ ? "run stopped by operator"
329
+ : status === "daemon_stopped"
330
+ ? "daemon stopped mid-run"
331
+ : status === "timeout"
332
+ ? "run timed out"
333
+ : "end of output"}
334
+ {@const endTone =
335
+ status === "stopped" || status === "daemon_stopped" || status === "timeout"
336
+ ? "warn"
337
+ : "muted"}
338
+ {@const accent = accentVar(status)}
339
+ <!-- The panel: a status spine runs the full left edge across both the header
340
+ readout and the console below, hugging the rail divider (artifact
341
+ ".detail .spine"). -->
342
+ <div class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
343
+ <!-- Status spine: a vivid edge coloring the panel by outcome; it streams
344
+ a light sweep while a run is live. -->
345
+ <div
346
+ class="absolute inset-y-0 left-0 z-[3] w-1 {spine} {isRunning ? 'spine-flow' : ''}"
347
+ aria-hidden="true"
348
+ ></div>
349
+ <!-- Detailed header: a black-box readout washed in a faint tint of the
350
+ run's outcome. -->
351
+ <div
352
+ class="head-region @container relative shrink-0 border-b border-outline-faint"
353
+ style="--rw-oc: {accent}"
354
+ >
355
+ <div class="pt-[18px] pr-[22px] pb-[14px] pl-[26px]">
356
+ <div class="flex items-start gap-4">
357
+ <!-- Verdict tile -->
358
+ <div
359
+ class="flex h-[38px] w-[38px] shrink-0 items-center justify-center rounded-[11px] {config.bg} {config.color} ring-1 ring-current/25 ring-inset"
360
+ >
361
+ <DetailIcon size={20} class={isRunning ? "animate-spin" : ""} />
362
+ </div>
119
363
 
120
- <div>
121
- <div class="mb-1 flex items-center gap-3">
122
- <h2 class="text-xl font-bold text-on-surface">
123
- {#if showTaskName}
124
- {run.task_name}{#if run.instance_index > 0}<span
125
- class="text-on-surface-muted">#{run.instance_index}</span
126
- >{/if}
127
- {:else}
128
- <span title={formatFullDateTime(startedAt)}
129
- >Run · {formatDateTime(startedAt)}</span
130
- >{#if run.instance_index > 0}
131
- <span class="text-on-surface-muted"
132
- >· instance #{run.instance_index}</span
133
- >
364
+ <div class="min-w-0 flex-1">
365
+ <div class="flex flex-wrap items-center gap-2.5">
366
+ <h2 class="text-lg font-[650] tracking-tight text-on-surface">
367
+ {#if showTaskName}
368
+ {run.task_name}{#if suffix}<span class="text-on-surface-muted"
369
+ >{suffix}</span
370
+ >{/if}
371
+ {:else}
372
+ <span title={formatFullDateTime(startedAt)}
373
+ >Run · {formatDateTime(startedAt)}</span
374
+ >{#if suffix}
375
+ <span class="text-on-surface-muted"
376
+ >· instance {suffix}</span
377
+ >
378
+ {/if}
134
379
  {/if}
380
+ </h2>
381
+ <!-- Status word: the pill is colored by the run's own
382
+ outcome, so stopped reads amber, not red. -->
383
+ <Tooltip content={config.description} position="bottom" wide>
384
+ <span
385
+ class="rounded-full border px-2.5 py-0.5 text-2xs font-bold tracking-wider uppercase {config.color} {config.bg} {config.border}"
386
+ >
387
+ {status.toUpperCase()}
388
+ </span>
389
+ </Tooltip>
390
+ </div>
391
+
392
+ <div
393
+ class="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm text-on-surface-muted"
394
+ >
395
+ {#if showTaskName}
396
+ <!-- When the title carries the task name, the run's
397
+ date/time lives here in the sub-line (otherwise it's
398
+ already the title, so don't repeat it). -->
399
+ <span title={formatFullDateTime(startedAt)}>
400
+ Run · {formatCalendarDate(startedAt)}
401
+ {formatTimeHM(startedAt)}
402
+ </span>
135
403
  {/if}
136
- </h2>
137
- <Tooltip content={config.description} position="bottom" wide>
138
- <Badge
139
- variant={runDisplayStatus(run) === "success"
140
- ? "success"
141
- : run.status === "running"
142
- ? "info"
143
- : "danger"}
144
- >
145
- {runDisplayStatus(run).toUpperCase()}
146
- </Badge>
147
- </Tooltip>
148
- {#if canDelete}
149
- <Button
150
- variant="ghost"
151
- size="xs"
152
- class="ml-1 text-danger-surface hover:bg-danger-soft"
153
- onclick={() => onDelete?.(run.id)}
154
- title="Delete this run"
155
- aria-label="Delete run"
404
+ <!-- Run-id chip: click to copy the full ULID -->
405
+ <button
406
+ type="button"
407
+ onclick={() => copyRunId(run.id)}
408
+ title="Copy run ID"
409
+ class="inline-flex items-center gap-1.5 rounded-md border border-outline-faint bg-surface-sunken px-1.5 py-0.5 font-mono text-2xs text-on-surface-faint transition-colors hover:border-outline hover:text-on-surface-muted"
156
410
  >
157
- {#snippet icon()}<Trash2 size={14} />{/snippet}
158
- </Button>
159
- {/if}
411
+ {#if copiedId}
412
+ <Check size={11} class="text-success-surface" />Copied
413
+ {:else}
414
+ <Hash size={11} />{run.id}
415
+ {/if}
416
+ </button>
417
+ {#if retry}
418
+ <span class="hidden h-1 w-1 rounded-full bg-outline-faint sm:block"
419
+ ></span>
420
+ <span class="inline-flex items-center gap-1.5">
421
+ <RotateCw size={14} />
422
+ Retry #{run.retry_attempt}{#if run.retry_of_run_id}
423
+ <span class="text-on-surface-faint"
424
+ >of run {formatShortId(run.retry_of_run_id)}</span
425
+ >{/if}
426
+ </span>
427
+ {/if}
428
+ {#if paramEntries.length > 0}
429
+ <span class="hidden h-1 w-1 rounded-full bg-outline-faint sm:block"
430
+ ></span>
431
+ <Popover placement="bottom-start">
432
+ {#snippet trigger()}
433
+ <span
434
+ class="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-outline-faint bg-surface-sunken px-2.5 py-0.5 text-xs font-medium text-on-surface-muted transition-colors hover:border-outline hover:text-on-surface"
435
+ title="View run parameters"
436
+ >
437
+ <SlidersHorizontal size={12} />
438
+ {paramEntries.length}
439
+ {paramEntries.length === 1 ? "parameter" : "parameters"}
440
+ </span>
441
+ {/snippet}
442
+ <div class="max-w-md min-w-48">
443
+ <span
444
+ class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
445
+ >Parameters</span
446
+ >
447
+ <dl
448
+ class="mt-2 grid max-h-72 grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 overflow-y-auto"
449
+ >
450
+ {#each paramEntries as [key, value] (key)}
451
+ <dt class="font-mono text-xs text-on-surface-muted">
452
+ {key}
453
+ </dt>
454
+ <dd
455
+ class="font-mono text-xs font-medium break-all text-on-surface"
456
+ >
457
+ {value}
458
+ </dd>
459
+ {/each}
460
+ </dl>
461
+ </div>
462
+ </Popover>
463
+ {/if}
464
+ </div>
160
465
  </div>
161
- <div
162
- class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-on-surface-muted"
163
- >
164
- {#if showTaskName}
165
- <div
166
- class="flex items-center gap-1.5 font-mono text-xs text-on-surface-faint"
466
+
467
+ <!-- Actions: history toggle · run (split) / service lifecycle ·
468
+ stop · download · delete (delete behind an inline confirm) -->
469
+ <div class="flex shrink-0 items-center gap-2">
470
+ {#if onToggleHistory}
471
+ <button
472
+ type="button"
473
+ onclick={() => onToggleHistory()}
474
+ class="inline-flex items-center justify-center rounded-lg border border-outline-faint bg-surface-raised p-2 text-on-surface-muted transition-colors hover:bg-surface-sunken hover:text-on-surface"
475
+ title={historyVisible ? "Hide run history" : "Show run history"}
476
+ aria-label={historyVisible
477
+ ? "Hide run history"
478
+ : "Show run history"}
167
479
  >
168
- <Hash size={12} />
169
- Run ID: <span class="text-on-surface-muted">{run.id}</span>
170
- </div>
171
- <div
172
- class="hidden h-1 w-1 rounded-full bg-outline-faint sm:block"
173
- ></div>
174
- <div class="flex items-center gap-1.5">
175
- <Server size={14} />
176
- <span
177
- >Triggered by <span class="font-medium text-on-surface"
178
- >{formatTriggeredByLabel(run.triggered_by)}</span
179
- ></span
480
+ {#if historyVisible}
481
+ <PanelLeftClose size={15} />
482
+ {:else}
483
+ <History size={15} />
484
+ {/if}
485
+ </button>
486
+ {/if}
487
+
488
+ {#if onStopService || onRestartService}
489
+ <!-- Service lifecycle replaces the run control.
490
+ Restart is always available (it cancels + respawns
491
+ all instances in one call); Stop appears only while
492
+ the service is running. Each still confirms, so
493
+ neither fires accidentally. -->
494
+ {#if onRestartService}
495
+ <button
496
+ type="button"
497
+ onclick={() => onRestartService()}
498
+ disabled={serviceBusy}
499
+ title={serviceStopped
500
+ ? "Starts the service"
501
+ : "Cancels and respawns every instance"}
502
+ class="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border border-primary-soft bg-surface-raised px-3 text-sm font-medium text-primary transition-colors hover:bg-primary-soft disabled:cursor-not-allowed disabled:opacity-60"
503
+ >
504
+ {#if serviceStopped}
505
+ <Play size={15} fill="currentColor" stroke="none" />
506
+ {:else}
507
+ <RefreshCcw size={15} />
508
+ {/if}
509
+ <span class="@max-md:hidden"
510
+ >{serviceStopped ? "Start" : "Restart"}</span
511
+ >
512
+ </button>
513
+ {/if}
514
+ {#if !serviceStopped && onStopService}
515
+ <button
516
+ type="button"
517
+ onclick={() => onStopService()}
518
+ disabled={serviceBusy}
519
+ title="Stops the service for the rest of the daemon's lifetime"
520
+ class="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border border-danger-soft bg-surface-raised px-3 text-sm font-medium text-danger-surface transition-colors hover:bg-danger-soft disabled:cursor-not-allowed disabled:opacity-60"
180
521
  >
181
- </div>
522
+ <Square size={15} fill="currentColor" stroke="none" />
523
+ <span class="@max-md:hidden">Stop Service</span>
524
+ </button>
525
+ {/if}
182
526
  {:else}
183
- <div class="flex items-center gap-1.5">
184
- <Server size={14} />
185
- <span
186
- >Triggered by <span class="font-medium text-on-surface"
187
- >{formatTriggeredByLabel(run.triggered_by)}</span
188
- ></span
527
+ {#if onRun}
528
+ <!-- Split button: Run (defaults) + an arrow for the
529
+ reuse-parameters variant, shown only when there is one. -->
530
+ <div
531
+ class="inline-flex overflow-hidden rounded-lg border border-primary-soft"
189
532
  >
190
- </div>
191
- <div
192
- class="hidden h-1 w-1 rounded-full bg-outline-faint sm:block"
193
- ></div>
194
- <div
195
- class="flex items-center gap-1.5 font-mono text-xs text-on-surface-faint"
196
- >
197
- <Hash size={12} />
198
- {run.id}
199
- </div>
200
- {/if}
201
- {#if retry}
202
- <div
203
- class="hidden h-1 w-1 rounded-full bg-outline-faint sm:block"
204
- ></div>
205
- <div class="flex items-center gap-1.5">
206
- <RotateCw size={14} />
207
- <span
208
- >Retry #{run.retry_attempt}{#if run.retry_of_run_id}
209
- <span class="text-on-surface-faint"
210
- >of run {formatShortId(run.retry_of_run_id)}</span
211
- >{/if}</span
533
+ <button
534
+ type="button"
535
+ onclick={() => onRun()}
536
+ title="Run this task now with default options"
537
+ class="inline-flex h-9 cursor-pointer items-center gap-1.5 bg-surface-raised px-3 text-sm font-medium text-primary transition-colors hover:bg-primary-soft"
538
+ >
539
+ <Play size={15} fill="currentColor" stroke="none" />
540
+ <span class="@max-md:hidden">Run</span>
541
+ </button>
542
+ {#if onRunAgain}
543
+ <Popover bind:open={runMenuOpen} placement="bottom-end">
544
+ {#snippet trigger()}
545
+ <span
546
+ class="flex h-9 cursor-pointer items-center border-l border-primary-soft bg-surface-raised px-2 text-primary transition-colors hover:bg-primary-soft"
547
+ title="More run options"
548
+ aria-label="More run options"
549
+ >
550
+ <ChevronDown size={15} />
551
+ </span>
552
+ {/snippet}
553
+ <button
554
+ type="button"
555
+ onclick={() => {
556
+ runMenuOpen = false;
557
+ onRunAgain?.();
558
+ }}
559
+ class="flex w-56 items-start gap-2.5 text-left"
560
+ >
561
+ <RotateCw
562
+ size={15}
563
+ class="mt-0.5 shrink-0 text-on-surface-muted"
564
+ />
565
+ <span class="flex flex-col">
566
+ <span
567
+ class="text-sm font-medium text-on-surface"
568
+ >Run again</span
569
+ >
570
+ <span class="text-xs text-on-surface-muted"
571
+ >Reuse this run's parameters</span
572
+ >
573
+ </span>
574
+ </button>
575
+ </Popover>
576
+ {/if}
577
+ </div>
578
+ {/if}
579
+ {#if isRunning && onStop}
580
+ <button
581
+ type="button"
582
+ onclick={() => onStop(run.id)}
583
+ class="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border border-danger-soft bg-surface-raised px-3 text-sm font-medium text-danger-surface transition-colors hover:bg-danger-soft"
212
584
  >
213
- </div>
585
+ <Square size={15} fill="currentColor" stroke="none" />
586
+ <span class="@max-md:hidden">Stop</span>
587
+ </button>
588
+ {/if}
214
589
  {/if}
215
- {#if paramEntries.length > 0}
216
- <div
217
- class="hidden h-1 w-1 rounded-full bg-outline-faint sm:block"
218
- ></div>
219
- <Popover placement="bottom-start">
590
+ <a
591
+ href="/api/tasks/{encodeURIComponent(
592
+ run.task_name,
593
+ )}/runs/{encodeURIComponent(run.id)}/log/raw"
594
+ download="{run.task_name}-{run.id}.log"
595
+ class="inline-flex items-center justify-center rounded-lg border border-outline-faint bg-surface-raised p-2 text-on-surface-muted transition-colors hover:bg-surface-sunken hover:text-on-surface"
596
+ title="Download the full log (rotated and current parts as one file)"
597
+ aria-label="Download log"
598
+ >
599
+ <Download size={15} />
600
+ </a>
601
+ {#if canDelete}
602
+ <Popover bind:open={confirmDeleteOpen} placement="bottom-end">
220
603
  {#snippet trigger()}
221
604
  <span
222
- class="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-outline-faint bg-surface-sunken px-2.5 py-0.5 text-xs font-medium text-on-surface-muted transition-colors hover:border-outline hover:text-on-surface"
223
- title="View run parameters"
605
+ class="flex cursor-pointer items-center justify-center rounded-lg border border-outline-faint bg-surface-raised p-2 text-on-surface-muted transition-colors hover:border-danger-soft hover:bg-danger-soft hover:text-danger-surface"
606
+ title="Delete this run"
607
+ aria-label="Delete run"
224
608
  >
225
- <SlidersHorizontal size={12} />
226
- {paramEntries.length}
227
- {paramEntries.length === 1 ? "parameter" : "parameters"}
609
+ <Trash2 size={15} />
228
610
  </span>
229
611
  {/snippet}
230
- <div class="max-w-md min-w-48">
231
- <span
232
- class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
233
- >Parameters</span
234
- >
235
- <dl
236
- class="mt-2 grid max-h-72 grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 overflow-y-auto"
237
- >
238
- {#each paramEntries as [key, value] (key)}
239
- <dt class="font-mono text-xs text-on-surface-muted">
240
- {key}
241
- </dt>
242
- <dd
243
- class="font-mono text-xs font-medium break-all text-on-surface"
244
- >
245
- {value}
246
- </dd>
247
- {/each}
248
- </dl>
612
+ <div class="w-60">
613
+ <div class="text-sm font-semibold text-on-surface">
614
+ Delete this run?
615
+ </div>
616
+ <div class="mt-1 text-xs leading-relaxed text-on-surface-muted">
617
+ Its captured output is removed from disk. This can't be
618
+ undone.
619
+ </div>
620
+ <div class="mt-3 flex justify-end gap-2">
621
+ <Button
622
+ variant="ghost"
623
+ size="sm"
624
+ onclick={() => (confirmDeleteOpen = false)}
625
+ >
626
+ Cancel
627
+ </Button>
628
+ <Button
629
+ variant="danger"
630
+ size="sm"
631
+ onclick={() => {
632
+ confirmDeleteOpen = false;
633
+ onDelete?.(run.id);
634
+ }}
635
+ >
636
+ Delete run
637
+ </Button>
638
+ </div>
249
639
  </div>
250
640
  </Popover>
251
641
  {/if}
252
642
  </div>
253
643
  </div>
254
- </div>
255
644
 
256
- <!-- Stats Grid -->
257
- <div
258
- class="grid grid-cols-1 gap-3 rounded-xl border border-outline-faint bg-surface-sunken px-6 py-3 @lg:grid-cols-3 @lg:gap-6 @4xl:shrink-0"
259
- >
260
- <div class="flex flex-col gap-1">
261
- <span
262
- class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
263
- >Started</span
645
+ <!-- Instrument cluster: the run's vital readout — value over a one-word
646
+ context line, hairline-divided. -->
647
+ <div
648
+ class="mt-4 flex flex-wrap overflow-hidden rounded-xl border border-outline-faint bg-surface-raised"
649
+ >
650
+ <div
651
+ class="flex min-w-30 flex-1 flex-col gap-0.5 border-l border-outline-faint px-4 py-2.5 first:border-l-0"
264
652
  >
265
- <span class="font-mono text-sm font-medium whitespace-nowrap text-on-surface">
266
- {run.start_at ? formatDateTime(run.start_at) : "--:--"}
267
- </span>
268
- {#if startDelay}
269
653
  <span
270
- class="font-mono text-xs whitespace-nowrap text-on-surface-faint"
271
- title="Scheduled at {formatDateTime(run.created_at)}"
654
+ class="text-2xs font-bold tracking-wider text-on-surface-faint uppercase"
655
+ >Queued</span
272
656
  >
273
- +{startDelay} after scheduled
657
+ <span
658
+ class="font-mono text-base font-medium tracking-tight whitespace-nowrap text-on-surface tabular-nums"
659
+ title={formatFullDateTime(run.created_at)}
660
+ >
661
+ {formatClockTime(run.created_at)}
274
662
  </span>
275
- {/if}
276
- </div>
663
+ <span class="text-2xs text-on-surface-muted"
664
+ >+{startDelay ?? "0s"} queued</span
665
+ >
666
+ </div>
277
667
 
278
- <div
279
- class="flex flex-col gap-1 border-t border-outline pt-3 @lg:border-t-0 @lg:border-l @lg:pt-0 @lg:pl-6"
280
- >
281
- <span
282
- class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
283
- >Duration</span
668
+ <div
669
+ class="flex min-w-30 flex-1 flex-col gap-0.5 border-l border-outline-faint px-4 py-2.5 first:border-l-0"
284
670
  >
285
- <span class="font-mono text-sm font-medium text-on-surface">
286
- {duration ?? "--"}
287
- </span>
288
- </div>
671
+ <span
672
+ class="text-2xs font-bold tracking-wider text-on-surface-faint uppercase"
673
+ >Started</span
674
+ >
675
+ <span
676
+ class="font-mono text-base font-medium tracking-tight whitespace-nowrap text-on-surface tabular-nums"
677
+ >
678
+ {run.start_at ? formatClockTime(run.start_at) : "—"}
679
+ </span>
680
+ <span class="text-2xs text-on-surface-muted">
681
+ {run.start_at ? formatCalendarDate(run.start_at) : "not started"}
682
+ </span>
683
+ </div>
289
684
 
290
- <div
291
- class="flex flex-col gap-1 border-t border-outline pt-3 @lg:border-t-0 @lg:border-l @lg:pt-0 @lg:pl-6"
292
- >
293
- <span
294
- class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
295
- >Exited</span
685
+ <div
686
+ class="flex min-w-30 flex-1 flex-col gap-0.5 border-l border-outline-faint px-4 py-2.5 first:border-l-0"
296
687
  >
297
- <span
298
- class="text-sm font-medium {run.exit_code === 0
299
- ? 'text-success-surface'
300
- : 'text-on-surface'} font-mono"
688
+ <span
689
+ class="text-2xs font-bold tracking-wider text-on-surface-faint uppercase"
690
+ >Ran for</span
691
+ >
692
+ <span
693
+ class="font-mono text-base font-medium tracking-tight whitespace-nowrap text-on-surface tabular-nums"
694
+ >
695
+ {duration ?? "—"}
696
+ </span>
697
+ <span class="text-2xs text-on-surface-muted">
698
+ {isRunning ? "and counting" : run.end_at ? "wall clock" : "—"}
699
+ </span>
700
+ </div>
701
+
702
+ <div
703
+ class="flex min-w-30 flex-1 flex-col gap-0.5 border-l border-outline-faint px-4 py-2.5 first:border-l-0"
301
704
  >
302
- {typeof run.exit_code === "number" ? `Code ${run.exit_code}` : "-"}
303
- </span>
705
+ <span
706
+ class="text-2xs font-bold tracking-wider text-on-surface-faint uppercase"
707
+ >Exit</span
708
+ >
709
+ <span
710
+ class="flex items-baseline gap-1.5 font-mono text-base font-medium tracking-tight tabular-nums {exitClean
711
+ ? 'text-success-surface'
712
+ : exitFail
713
+ ? 'text-danger-surface'
714
+ : 'text-on-surface'}"
715
+ >
716
+ <span>{showCode ? run.exit_code : "—"}</span>
717
+ {#if exitFail}
718
+ <span
719
+ class="rounded bg-danger-soft px-1 py-px text-[9px] font-bold tracking-wide text-danger-surface uppercase"
720
+ >fail</span
721
+ >
722
+ {/if}
723
+ </span>
724
+ <span class="text-2xs text-on-surface-muted">{exitSubLabel(status)}</span>
725
+ </div>
726
+
727
+ <div
728
+ class="flex min-w-30 flex-1 flex-col gap-0.5 border-l border-outline-faint px-4 py-2.5 first:border-l-0"
729
+ >
730
+ <span
731
+ class="text-2xs font-bold tracking-wider text-on-surface-faint uppercase"
732
+ >Trigger</span
733
+ >
734
+ <span
735
+ class="font-mono text-base font-medium tracking-tight whitespace-nowrap text-on-surface tabular-nums"
736
+ >{formatTriggeredByLabel(run.triggered_by)}</span
737
+ >
738
+ <span class="text-2xs text-on-surface-muted"
739
+ >{triggerSub(run.triggered_by)}</span
740
+ >
741
+ </div>
304
742
  </div>
305
743
  </div>
306
744
  </div>
307
- </div>
308
745
 
309
- <!-- Console View -->
310
- <div class="flex min-h-0 flex-1 flex-col bg-mist-950">
746
+ <!-- Console hero — maximizes to a full-bleed overlay via portal -->
747
+ {#if consoleMaximized}
748
+ <div
749
+ use:portal
750
+ class="console-backdrop fixed inset-0 z-40 bg-backdrop backdrop-blur-sm"
751
+ onclick={() => (consoleMaximized = false)}
752
+ role="presentation"
753
+ ></div>
754
+ {/if}
311
755
  <div
312
- class="flex shrink-0 items-center justify-between border-b border-mist-800 bg-mist-900/50 px-4 py-2 font-mono text-xs text-mist-400"
756
+ use:maximizePortal={consoleMaximized}
757
+ class={consoleMaximized
758
+ ? "console-zoom fixed inset-3 z-50 flex flex-col overflow-hidden rounded-xl bg-[var(--rw-con-bg)] shadow-2xl ring-1 ring-[rgb(255_255_255_/_0.08)] sm:inset-6"
759
+ : "ml-1 flex min-h-[300px] flex-1 flex-col overflow-hidden border-t border-[rgb(255_255_255_/_0.06)] bg-[var(--rw-con-bg)]"}
313
760
  >
314
- <div class="flex items-center gap-2">
315
- <TerminalIcon size={14} />
316
- <span>Console Output</span>
317
- </div>
318
- <div class="flex items-center gap-3">
319
- {#if run.status === "running"}
320
- <div class="text-info-surface flex items-center gap-1.5">
321
- <div class="bg-info-surface h-1.5 w-1.5 animate-pulse rounded-full"></div>
322
- Live
323
- </div>
324
- {:else if run.status === "pending"}
325
- <div class="flex items-center gap-1.5 text-warning-surface">
326
- <div
327
- class="h-1.5 w-1.5 animate-pulse rounded-full bg-warning-surface"
328
- ></div>
329
- Pending
330
- </div>
331
- {/if}
332
- <a
333
- href="/api/tasks/{encodeURIComponent(run.task_name)}/runs/{encodeURIComponent(
334
- run.id,
335
- )}/log/raw"
336
- download="{run.task_name}-{run.id}.log"
337
- class="flex items-center gap-1.5 rounded border border-mist-700 px-2 py-1 text-mist-200 transition-colors hover:border-mist-500 hover:bg-mist-800 hover:text-mist-100"
338
- title="Download the full log (rotated and current parts as one file)"
339
- >
340
- <Download size={12} />
341
- Download
342
- </a>
761
+ <div
762
+ class="flex shrink-0 items-center justify-between gap-3 border-b border-[rgb(255_255_255_/_0.06)] bg-[var(--rw-con-panel)] px-3.5 py-[9px] font-mono text-[11.5px] text-[var(--rw-con-dim)]"
763
+ >
764
+ <div class="flex min-w-0 items-center gap-3">
765
+ {#if consoleMaximized}
766
+ <!-- Focus-mode identity: which run is filling the screen -->
767
+ <span class="flex min-w-0 items-center gap-2">
768
+ <span class="h-2 w-2 shrink-0 rounded-full {spine}"></span>
769
+ <span class="truncate font-medium text-[#b8c5d1]">
770
+ {run.task_name}{suffix}
771
+ </span>
772
+ <span class="text-[var(--rw-con-gutter)]">·</span>
773
+ <span class="font-semibold capitalize {config.color}">{status}</span>
774
+ </span>
775
+ {:else}
776
+ <span class="flex items-center gap-2 font-semibold text-[#98a7b5]">
777
+ <TerminalIcon size={14} class="opacity-70" />
778
+ Console output
779
+ </span>
780
+ <span class="hidden text-[var(--rw-con-gutter)] sm:inline"
781
+ >stdout + stderr</span
782
+ >
783
+ {/if}
784
+ </div>
785
+ <div class="flex shrink-0 items-center gap-3">
786
+ <button
787
+ type="button"
788
+ onclick={() => (consoleWrap = !consoleWrap)}
789
+ class="flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 transition-colors hover:bg-[rgb(255_255_255_/_0.05)] hover:text-[#b8c5d1] {consoleWrap
790
+ ? 'text-[#b8c5d1]'
791
+ : 'text-[var(--rw-con-dim)]'}"
792
+ title={consoleWrap ? "Unwrap lines" : "Wrap long lines"}
793
+ aria-pressed={consoleWrap}
794
+ aria-label="Toggle line wrapping"
795
+ >
796
+ <TextWrap size={13} />
797
+ <span class="hidden sm:inline">Wrap</span>
798
+ </button>
799
+ <button
800
+ type="button"
801
+ onclick={() => (consoleMaximized = !consoleMaximized)}
802
+ class="flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-[var(--rw-con-dim)] transition-colors hover:bg-[rgb(255_255_255_/_0.05)] hover:text-[#b8c5d1]"
803
+ title={consoleMaximized ? "Restore console (Esc)" : "Maximize console · F"}
804
+ aria-label={consoleMaximized ? "Restore console" : "Maximize console"}
805
+ >
806
+ {#if consoleMaximized}
807
+ <Minimize2 size={13} />
808
+ <span
809
+ class="rounded border border-[var(--rw-con-gutter)] px-1.5 text-[10px] tracking-wide"
810
+ >Esc</span
811
+ >
812
+ {:else}
813
+ <Maximize2 size={13} />
814
+ Expand
815
+ {/if}
816
+ </button>
817
+ </div>
343
818
  </div>
819
+ {#key run.id}
820
+ <LogConsole
821
+ bind:this={logConsole}
822
+ fetchLogs={(f: number, t: number) => fetchLogs(run.id, f, t)}
823
+ fetchLineHistory={fetchLineHistory
824
+ ? (n: number) => fetchLineHistory(run.id, n)
825
+ : undefined}
826
+ bind:wrap={consoleWrap}
827
+ class="min-h-0 flex-1"
828
+ {highlightLine}
829
+ {endLabel}
830
+ {endTone}
831
+ />
832
+ {/key}
344
833
  </div>
345
- {#key run.id}
346
- <LogConsole
347
- bind:this={logConsole}
348
- fetchLogs={(f: number, t: number) => fetchLogs(run.id, f, t)}
349
- class="min-h-0 flex-1"
350
- {highlightLine}
351
- />
352
- {/key}
353
834
  </div>
354
835
  {:else}
355
- <div class="flex flex-1 items-center justify-center bg-surface-sunken/30">
836
+ <div
837
+ class="flex min-w-0 flex-1 flex-col items-center justify-center gap-4 bg-surface-sunken/30"
838
+ >
356
839
  <EmptyState
357
- title="Select a run"
358
- description="Pick a run from the list to view details and logs."
840
+ title={onRunTask ? "No runs yet" : "Select a run"}
841
+ description={onRunTask
842
+ ? "This task hasn't run yet. Trigger it to see its output here."
843
+ : "Pick a run from the list to view details and logs."}
359
844
  icon={MousePointerClick}
360
845
  />
846
+ {#if onRunTask}
847
+ <button
848
+ type="button"
849
+ onclick={() => onRunTask()}
850
+ class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary-soft bg-surface-raised px-3 py-2 text-sm font-medium text-primary transition-colors hover:bg-primary-soft"
851
+ >
852
+ <Play size={15} fill="currentColor" stroke="none" />
853
+ Run task
854
+ </button>
855
+ {/if}
361
856
  </div>
362
857
  {/if}
858
+
859
+ <style>
860
+ /* The header is washed in a faint tint of the run's outcome colour
861
+ (--rw-oc, set inline). Neutral statuses pass the surface itself, so the
862
+ mix collapses to a plain surface with no visible tint. */
863
+ .head-region {
864
+ background: color-mix(in srgb, var(--rw-oc) 4.5%, var(--color-surface-raised));
865
+ }
866
+
867
+ /* Gentle fade for the maximize backdrop; disabled under reduced motion. */
868
+ .console-backdrop {
869
+ animation: console-backdrop-in 150ms ease-out;
870
+ }
871
+ @keyframes console-backdrop-in {
872
+ from {
873
+ opacity: 0;
874
+ }
875
+ to {
876
+ opacity: 1;
877
+ }
878
+ }
879
+
880
+ /* The console zooms up as it takes over the viewport. */
881
+ .console-zoom {
882
+ animation: console-zoom-in 260ms cubic-bezier(0.33, 0.05, 0.2, 1);
883
+ }
884
+ @keyframes console-zoom-in {
885
+ from {
886
+ opacity: 0.4;
887
+ transform: scale(0.96);
888
+ }
889
+ to {
890
+ opacity: 1;
891
+ transform: none;
892
+ }
893
+ }
894
+
895
+ /* Living spine: a light sweep travels down the edge while a run is in flight. */
896
+ .spine-flow {
897
+ overflow: hidden;
898
+ }
899
+ .spine-flow::after {
900
+ content: "";
901
+ position: absolute;
902
+ inset: 0;
903
+ height: 40%;
904
+ background: linear-gradient(
905
+ 180deg,
906
+ transparent,
907
+ color-mix(in srgb, #fff 70%, transparent),
908
+ transparent
909
+ );
910
+ animation: spine-flow 1.9s linear infinite;
911
+ }
912
+ @keyframes spine-flow {
913
+ from {
914
+ transform: translateY(-100%);
915
+ }
916
+ to {
917
+ transform: translateY(300%);
918
+ }
919
+ }
920
+
921
+ @media (prefers-reduced-motion: reduce) {
922
+ .console-backdrop,
923
+ .console-zoom,
924
+ .spine-flow::after {
925
+ animation: none;
926
+ }
927
+ }
928
+ </style>