@runwisp/ui 0.10.0 → 0.11.0

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