@runwisp/ui 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,32 +4,68 @@
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,
15
+ RotateCw,
16
+ RefreshCcw,
17
+ ChevronDown,
18
+ History,
19
+ PanelLeftClose,
20
+ SlidersHorizontal,
21
+ Maximize2,
22
+ Minimize2,
23
+ TextWrap,
13
24
  } from "@lucide/svelte";
14
- import Badge from "../Badge.svelte";
15
25
  import Button from "../Button.svelte";
16
26
  import EmptyState from "../EmptyState.svelte";
17
27
  import LogConsole from "../LogConsole.svelte";
28
+ import Popover from "../Popover.svelte";
18
29
  import Tooltip from "../Tooltip.svelte";
30
+ import { portal } from "../../actions/portal.js";
19
31
  import type { Run } from "./types.js";
20
32
  import type { LogEvent, LogSlice } from "../../log-console/types.js";
21
- import { formatDateTime } from "../../utils/format.js";
33
+ import {
34
+ formatDateTime,
35
+ formatFullDateTime,
36
+ formatClockTime,
37
+ formatCalendarDate,
38
+ formatTimeHM,
39
+ } from "../../utils/format.js";
22
40
  import { formatShortId } from "../../utils/id.js";
23
41
  import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
24
- import { runDuration, runStartDelay } from "./run-helpers.js";
42
+ import {
43
+ runDuration,
44
+ runStartDelay,
45
+ formatTriggeredByLabel,
46
+ runRetryLabel,
47
+ instanceSuffix,
48
+ } from "./run-helpers.js";
25
49
 
26
50
  let {
27
51
  run,
28
52
  fetchLogs,
29
53
  streamLogs,
54
+ fetchLineHistory,
30
55
  showTaskName = false,
31
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,
32
67
  highlightLine = null,
68
+ getInstanceCount = () => 1,
33
69
  }: {
34
70
  run: Run | undefined;
35
71
  fetchLogs: (
@@ -42,9 +78,39 @@
42
78
  onEvent: (event: LogEvent) => void,
43
79
  initialState?: { fromLine: number },
44
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[][]>;
45
84
  showTaskName?: boolean;
46
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;
47
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;
48
114
  } = $props();
49
115
 
50
116
  let canDelete = $derived.by(() => {
@@ -88,211 +154,750 @@
88
154
  if (cleanup) cleanup();
89
155
  };
90
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
+ }
91
294
  </script>
92
295
 
296
+ <svelte:window onkeydown={handleConsoleKeydown} />
297
+
93
298
  {#if run}
94
- {@const config = getRunStatusConfig(runDisplayStatus(run))}
299
+ {@const status = runDisplayStatus(run)}
300
+ {@const config = getRunStatusConfig(status)}
95
301
  {@const DetailIcon = config.icon}
96
302
  {@const duration = runDuration(run)}
97
303
  {@const startDelay = runStartDelay(run)}
98
- <!-- Detailed Header -->
99
- <div class="@container shrink-0 border-b border-outline-faint bg-surface-raised p-6">
100
- <div class="flex flex-col justify-between gap-6 @4xl:flex-row @4xl:items-start">
101
- <div class="flex items-start gap-4">
102
- <!-- Large Status Icon -->
103
- <div
104
- class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl {config.bg} {config.color}"
105
- >
106
- <DetailIcon size={32} class={run.status === "running" ? "animate-spin" : ""} />
107
- </div>
304
+ {@const startedAt = run.start_at ?? run.created_at}
305
+ {@const retry = runRetryLabel(run)}
306
+ {@const paramEntries = run.params ? Object.entries(run.params) : []}
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>
108
350
 
109
- <div>
110
- <div class="mb-1 flex items-center gap-3">
111
- <h2 class="text-xl font-bold text-on-surface">
112
- {#if showTaskName}
113
- {run.task_name}{#if run.instance_index > 0}<span
114
- class="text-on-surface-muted">#{run.instance_index}</span
115
- >{/if}
116
- {:else}
117
- Run #{formatShortId(run.id)}{#if run.instance_index > 0}
118
- <span class="text-on-surface-muted"
119
- >· instance #{run.instance_index}</span
120
- >
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}
121
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>
122
390
  {/if}
123
- </h2>
124
- <Tooltip content={config.description} position="bottom" wide>
125
- <Badge
126
- variant={runDisplayStatus(run) === "success"
127
- ? "success"
128
- : run.status === "running"
129
- ? "info"
130
- : "danger"}
131
- >
132
- {runDisplayStatus(run).toUpperCase()}
133
- </Badge>
134
- </Tooltip>
135
- {#if canDelete}
136
- <Button
137
- variant="ghost"
138
- size="xs"
139
- class="ml-1 text-danger-surface hover:bg-danger-soft"
140
- onclick={() => onDelete?.(run.id)}
141
- title="Delete this run"
142
- 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"
143
397
  >
144
- {#snippet icon()}<Trash2 size={14} />{/snippet}
145
- </Button>
146
- {/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>
147
452
  </div>
148
- <div
149
- class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-on-surface-muted"
150
- >
151
- {#if showTaskName}
152
- <div
153
- 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"}
154
466
  >
155
- <Hash size={12} />
156
- Run ID: <span class="text-on-surface-muted">{run.id}</span>
157
- </div>
158
- <div
159
- class="hidden h-1 w-1 rounded-full bg-outline-faint sm:block"
160
- ></div>
161
- <div class="flex items-center gap-1.5">
162
- <Server size={14} />
163
- <span
164
- >Triggered by <span class="font-medium text-on-surface"
165
- >{run.triggered_by}</span
166
- ></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"
167
496
  >
168
- </div>
497
+ <Square size={15} fill="currentColor" stroke="none" />
498
+ <span class="@max-md:hidden">Stop Service</span>
499
+ </button>
500
+ {/if}
169
501
  {:else}
170
- <div class="flex items-center gap-1.5">
171
- <Server size={14} />
172
- <span
173
- >Triggered by <span class="font-medium text-on-surface"
174
- >{run.triggered_by}</span
175
- ></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"
176
507
  >
177
- </div>
178
- <div
179
- class="hidden h-1 w-1 rounded-full bg-outline-faint sm:block"
180
- ></div>
181
- <div
182
- class="flex items-center gap-1.5 font-mono text-xs text-on-surface-faint"
183
- >
184
- <Hash size={12} />
185
- {run.id}
186
- </div>
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"
559
+ >
560
+ <Square size={15} fill="currentColor" stroke="none" />
561
+ <span class="@max-md:hidden">Stop</span>
562
+ </button>
563
+ {/if}
564
+ {/if}
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">
578
+ {#snippet trigger()}
579
+ <span
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"
583
+ >
584
+ <Trash2 size={15} />
585
+ </span>
586
+ {/snippet}
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>
614
+ </div>
615
+ </Popover>
187
616
  {/if}
188
617
  </div>
189
618
  </div>
190
- </div>
191
619
 
192
- <!-- Stats Grid -->
193
- <div
194
- 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"
195
- >
196
- <div class="flex flex-col gap-1">
197
- <span
198
- class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
199
- >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"
200
627
  >
201
- <span class="font-mono text-sm font-medium whitespace-nowrap text-on-surface">
202
- {run.start_at ? formatDateTime(run.start_at) : "--:--"}
203
- </span>
204
- {#if startDelay}
205
628
  <span
206
- class="font-mono text-xs whitespace-nowrap text-on-surface-faint"
207
- title="Scheduled at {formatDateTime(run.created_at)}"
629
+ class="text-2xs font-bold tracking-wider text-on-surface-faint uppercase"
630
+ >Queued</span
631
+ >
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)}
208
635
  >
209
- +{startDelay} after scheduled
636
+ {formatClockTime(run.created_at)}
210
637
  </span>
211
- {/if}
212
- </div>
638
+ <span class="text-2xs text-on-surface-muted"
639
+ >+{startDelay ?? "0s"} queued</span
640
+ >
641
+ </div>
213
642
 
214
- <div
215
- 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"
216
- >
217
- <span
218
- class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
219
- >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"
220
645
  >
221
- <span class="font-mono text-sm font-medium text-on-surface">
222
- {duration ?? "--"}
223
- </span>
224
- </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>
225
659
 
226
- <div
227
- 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"
228
- >
229
- <span
230
- class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
231
- >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"
232
679
  >
233
- <span
234
- class="text-sm font-medium {run.exit_code === 0
235
- ? 'text-success-surface'
236
- : '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"
237
704
  >
238
- {typeof run.exit_code === "number" ? `Code ${run.exit_code}` : "-"}
239
- </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>
240
717
  </div>
241
718
  </div>
242
719
  </div>
243
- </div>
244
720
 
245
- <!-- Console View -->
246
- <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}
247
730
  <div
248
- 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)]"}
249
735
  >
250
- <div class="flex items-center gap-2">
251
- <TerminalIcon size={14} />
252
- <span>Console Output</span>
253
- </div>
254
- <div class="flex items-center gap-3">
255
- {#if run.status === "running"}
256
- <div class="text-info-surface flex items-center gap-1.5">
257
- <div class="bg-info-surface h-1.5 w-1.5 animate-pulse rounded-full"></div>
258
- Live
259
- </div>
260
- {:else if run.status === "pending"}
261
- <div class="flex items-center gap-1.5 text-warning-surface">
262
- <div
263
- class="h-1.5 w-1.5 animate-pulse rounded-full bg-warning-surface"
264
- ></div>
265
- Pending
266
- </div>
267
- {/if}
268
- <a
269
- href="/api/tasks/{encodeURIComponent(run.task_name)}/runs/{encodeURIComponent(
270
- run.id,
271
- )}/log/raw"
272
- download="{run.task_name}-{run.id}.log"
273
- 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"
274
- title="Download the full log (rotated and current parts as one file)"
275
- >
276
- <Download size={12} />
277
- Download
278
- </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>
279
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}
280
808
  </div>
281
- {#key run.id}
282
- <LogConsole
283
- bind:this={logConsole}
284
- fetchLogs={(f: number, t: number) => fetchLogs(run.id, f, t)}
285
- class="min-h-0 flex-1"
286
- {highlightLine}
287
- />
288
- {/key}
289
809
  </div>
290
810
  {:else}
291
- <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
+ >
292
814
  <EmptyState
293
- title="Select a run"
294
- 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."}
295
819
  icon={MousePointerClick}
296
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}
297
831
  </div>
298
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>