@runwisp/ui 1.5.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/package.json +5 -7
  2. package/src/lib/actions/fold-to-fit.test.ts +36 -0
  3. package/src/lib/actions/fold-to-fit.ts +89 -0
  4. package/src/lib/actions/modal-dialog.test.ts +47 -0
  5. package/src/lib/actions/modal-dialog.ts +48 -0
  6. package/src/lib/components/AccordionItem.svelte +16 -5
  7. package/src/lib/components/Badge.svelte +3 -24
  8. package/src/lib/components/Button.svelte +2 -9
  9. package/src/lib/components/CodeBlock.svelte +7 -6
  10. package/src/lib/components/Drawer.svelte +22 -30
  11. package/src/lib/components/DurationInput.svelte +6 -25
  12. package/src/lib/components/FilterBar.svelte +2 -6
  13. package/src/lib/components/Link.svelte +9 -4
  14. package/src/lib/components/LogConsole.svelte +95 -418
  15. package/src/lib/components/Modal.svelte +7 -30
  16. package/src/lib/components/Spinner.svelte +10 -5
  17. package/src/lib/components/StatusIndicator.svelte +37 -104
  18. package/src/lib/components/Tabs.svelte +15 -1
  19. package/src/lib/components/dashboard/RunActions.svelte +156 -0
  20. package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
  21. package/src/lib/components/dashboard/RunDetailPanel.svelte +130 -717
  22. package/src/lib/components/dashboard/RunFacts.svelte +119 -0
  23. package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
  24. package/src/lib/components/dashboard/RunFilterPopover.svelte +4 -5
  25. package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
  26. package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
  27. package/src/lib/components/dashboard/RunRow.svelte +202 -0
  28. package/src/lib/components/dashboard/RunsList.svelte +265 -543
  29. package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
  30. package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
  31. package/src/lib/components/dashboard/cron-format.test.ts +9 -0
  32. package/src/lib/components/dashboard/cron-format.ts +5 -1
  33. package/src/lib/components/dashboard/run-filters.test.ts +60 -0
  34. package/src/lib/components/dashboard/run-filters.ts +78 -6
  35. package/src/lib/components/dashboard/run-helpers.test.ts +122 -12
  36. package/src/lib/components/dashboard/run-helpers.ts +122 -6
  37. package/src/lib/components/dashboard/status-config.ts +18 -0
  38. package/src/lib/components/dashboard/types.ts +6 -0
  39. package/src/lib/index.ts +13 -2
  40. package/src/lib/log-console/AnsiLine.svelte +12 -0
  41. package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
  42. package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
  43. package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
  44. package/src/lib/log-console/frame-history.svelte.ts +71 -0
  45. package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
  46. package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
  47. package/src/lib/utils/format.test.ts +122 -0
  48. package/src/lib/utils/format.ts +97 -4
  49. package/src/lib/utils/typing-target.ts +10 -0
  50. package/src/stories/Accordion.stories.svelte +60 -1
  51. package/src/stories/CodeBlock.stories.svelte +13 -0
  52. package/src/stories/Link.stories.svelte +25 -1
  53. package/src/stories/Tabs.stories.svelte +17 -0
@@ -2,54 +2,27 @@
2
2
  <!-- SPDX-License-Identifier: Apache-2.0 -->
3
3
 
4
4
  <script lang="ts">
5
- import { untrack } from "svelte";
6
5
  import {
7
- Hash,
8
- Check,
9
6
  Terminal as TerminalIcon,
10
- Download,
11
- Trash,
12
7
  Play,
13
- Square,
14
8
  MousePointerClick,
15
- RotateCw,
16
- RefreshCcw,
17
- ChevronDown,
18
9
  ArrowLeft,
19
- PanelLeftClose,
20
- PanelLeftOpen,
21
- SlidersHorizontal,
22
- Maximize2,
23
- Minimize2,
24
- TextWrap,
25
10
  SearchX,
26
11
  } from "@lucide/svelte";
27
- import Button from "../Button.svelte";
28
12
  import EmptyState from "../EmptyState.svelte";
29
- import Kbd from "../Kbd.svelte";
30
13
  import LogConsole from "../LogConsole.svelte";
31
- import Popover from "../Popover.svelte";
32
14
  import Tooltip from "../Tooltip.svelte";
33
- import { portal } from "../../actions/portal.js";
34
15
  import { prefersReducedMotion } from "../../actions/row-motion.js";
35
16
  import type { RunMotion } from "../../utils/run-motion.js";
36
- import { isLogEvent, type LogEvent, type LogSlice } from "../../log-console/types.js";
37
- import { formatBytes, formatClockTime, formatCalendarDate } from "../../utils/format.js";
38
- import { formatShortId } from "../../utils/id.js";
17
+ import type { LogEvent, LogSlice } from "../../log-console/types.js";
39
18
  import { TickingNow } from "../../utils/ticking-now.svelte.js";
40
- import { CopyFeedback } from "../../utils/clipboard.svelte.js";
41
19
  import { displayStatus, type ResourceUsage, type Run } from "@runwisp/common";
20
+ import RunActions from "./RunActions.svelte";
21
+ import RunConsoleFrame from "./RunConsoleFrame.svelte";
22
+ import RunFacts from "./RunFacts.svelte";
23
+ import RunHeaderLine from "./RunHeaderLine.svelte";
42
24
  import { RUN_STATUS_CONFIG } from "./status-config.js";
43
- import {
44
- runDuration,
45
- runStartDelay,
46
- runVerdict,
47
- formatTriggeredByLabel,
48
- runRetryLabel,
49
- runUsageLabel,
50
- runLogDownloadUrl,
51
- instanceSuffix,
52
- } from "./run-helpers.js";
25
+ import { runDuration, RUN_VERDICTS, instanceSuffix } from "./run-helpers.js";
53
26
 
54
27
  let {
55
28
  run,
@@ -62,19 +35,17 @@
62
35
  onRunAgain,
63
36
  onStop,
64
37
  onRunTask,
65
- onStopService,
66
- onRestartService,
67
- serviceStopped = false,
68
- serviceBusy = false,
69
38
  onBack,
70
- onToggleList,
71
- listVisible = true,
72
39
  highlightLine = null,
73
40
  getInstanceCount = () => 1,
74
41
  getLiveUsage = () => undefined,
75
42
  motion,
76
43
  notFound = false,
77
44
  loading = false,
45
+ headerLayout = "stacked",
46
+ stopLabel,
47
+ emptyTitle,
48
+ emptyDescription,
78
49
  }: {
79
50
  run: Run | undefined;
80
51
  fetchLogs: (
@@ -91,7 +62,7 @@
91
62
  // redraw line so the log console can offer inline rewind. Optional.
92
63
  fetchLineHistory?: (runId: string, lineNum: number) => Promise<string[][]>;
93
64
  showTaskName?: boolean;
94
- onDelete?: (runId: string) => void;
65
+ onDelete?: ((runId: string) => void) | undefined;
95
66
  // Start a new run with default options. When set, the action cluster's
96
67
  // primary control is a Run button (the cold-start / re-trigger path).
97
68
  onRun?: (() => void) | undefined;
@@ -106,19 +77,9 @@
106
77
  // Trigger the task from the empty state (no run selected yet), the
107
78
  // cold-start path so a never-run task is still launchable from here.
108
79
  onRunTask?: (() => void) | undefined;
109
- // Service lifecycle controls. When either is set the cluster swaps the
110
- // Run control for Stop / Restart (chosen by serviceStopped).
111
- onStopService?: (() => void) | undefined;
112
- onRestartService?: (() => void) | undefined;
113
- serviceStopped?: boolean;
114
- serviceBusy?: boolean;
115
80
  // Return to the run list when the panel replaces it (a phone), shown as
116
81
  // a back arrow in the header's top-left corner.
117
82
  onBack?: (() => void) | undefined;
118
- // Fold the run list away (or back) when it sits beside the panel on a
119
- // screen too narrow to keep both comfortably, in the same corner.
120
- onToggleList?: (() => void) | undefined;
121
- listVisible?: boolean;
122
83
  highlightLine?: number | null;
123
84
  // Resolves a task's currently configured instance count so multi-instance
124
85
  // services render a 1-based #N suffix. Defaults to single-instance.
@@ -138,6 +99,15 @@
138
99
  // True while there is no run to show *yet* (the run list or a deep-linked
139
100
  // run is still loading), so the empty state doesn't claim "No runs yet".
140
101
  loading?: boolean;
102
+ // "stacked": verdict over a fact line, with room for the task name.
103
+ // "line": one line whose facts fold into ⋯ when it runs out of room,
104
+ // for a page that already names the task.
105
+ headerLayout?: "stacked" | "line";
106
+ // Label of the stop button, "Stop" by default.
107
+ stopLabel?: string;
108
+ // Empty-state copy for when no run is selected, replacing the default.
109
+ emptyTitle?: string;
110
+ emptyDescription?: string;
141
111
  } = $props();
142
112
 
143
113
  function easeInFresh(node: HTMLElement, runId: string) {
@@ -173,195 +143,8 @@
173
143
  if (run?.status !== "running") return;
174
144
  return durationTicker.start();
175
145
  });
176
-
177
- const TAIL_LINES = 1000;
178
-
179
- let logConsole = $state<{ onStream: (event: LogEvent) => void } | null>(null);
180
-
181
- // Message from the most recent seed-fetch failure (session expiry, daemon
182
- // restart mid-request, ...), surfaced to LogConsole instead of letting a
183
- // failed fetch render as an indistinguishable blank "no output" pane.
184
- let logFetchError = $state<string | null>(null);
185
-
186
- // Derive a stable scalar so the effect only re-runs when the id actually
187
- // changes, not on every run object reference swap from SSE array updates.
188
- let runId = $derived(run?.id);
189
-
190
- // The run whose seed fetch has settled. Until it matches the shown run the
191
- // console says it's loading, not "No output yet".
192
- let seededRunId = $state<string | null>(null);
193
-
194
- // Fetches the batched tail page a run's console opens on (see the effect
195
- // below) and paints it into the console. Resolves the line the live
196
- // stream should resume from, or null when there's nothing left to stream
197
- // (the run already ended, or the caller cancelled while we were waiting).
198
- // A failed fetch still falls back to an SSE tail backfill from scratch,
199
- // it does not stop the console from live-tailing, but records the
200
- // failure so LogConsole can show it instead of looking like a quiet run.
201
- async function seedConsoleTail(
202
- id: string,
203
- isCancelled: () => boolean,
204
- ): Promise<{ fromLine: number } | null> {
205
- try {
206
- const seed = await fetchLogs(id, -TAIL_LINES, -1);
207
- if (isCancelled()) return null;
208
- if (!isLogEvent(seed)) return { fromLine: -TAIL_LINES };
209
- if (logConsole) logConsole.onStream(seed);
210
- return seed.finished // ended run: nothing live to follow
211
- ? null
212
- : { fromLine: seed.sizeLines > 0 ? seed.sizeLines : -TAIL_LINES };
213
- } catch (err) {
214
- if (isCancelled()) return null;
215
- logFetchError = err instanceof Error ? err.message : "Failed to load logs";
216
- return { fromLine: -TAIL_LINES };
217
- }
218
- }
219
-
220
- $effect(() => {
221
- const id = runId;
222
- if (!id) return;
223
-
224
- const stream = streamLogs;
225
- if (!stream) {
226
- seededRunId = id;
227
- return;
228
- }
229
-
230
- logFetchError = null;
231
-
232
- let cleanup: (() => void) | undefined;
233
- let cancelled = false;
234
-
235
- untrack(() => {
236
- void (async () => {
237
- // Paint the tail in one batched page so the console opens
238
- // already at the newest output, instead of replaying 1000
239
- // backfill lines over SSE and visibly scrolling down to reach
240
- // the end. The live stream then picks up after the seeded tail
241
- // (its disk backfill from that anchor closes the fetch↔stream
242
- // race, and the daemon dedupes anything already shown).
243
- const seeded = await seedConsoleTail(id, () => cancelled);
244
- if (cancelled) return;
245
- seededRunId = id;
246
- if (!seeded) return;
247
- cleanup = stream(
248
- id,
249
- (event: LogEvent) => {
250
- if (logConsole) logConsole.onStream(event);
251
- },
252
- { fromLine: seeded.fromLine },
253
- );
254
- })();
255
- });
256
-
257
- return () => {
258
- cancelled = true;
259
- if (cleanup) cleanup();
260
- };
261
- });
262
-
263
- // Maximize the console to a full-bleed overlay. The console wrapper is
264
- // never remounted, `maximizePortal` relocates the *live* node to <body>
265
- // (escaping any transformed/blurred ancestor that would otherwise trap a
266
- // position:fixed child) and moves it back on restore, so the SSE stream,
267
- // scroll position, and the {#key run.id} mount all survive the toggle.
268
- let consoleMaximized = $state(false);
269
- // Wrap long console lines instead of horizontally scrolling them. Owned
270
- // here so the toolbar toggle and the <LogConsole> share one source of truth.
271
- let consoleWrap = $state(false);
272
-
273
- // A run going away (deselect) tears down the console; drop the overlay too.
274
- $effect(() => {
275
- if (!run) consoleMaximized = false;
276
- });
277
-
278
- // Inline-confirm popover for delete, and a transient "Copied" on the run-id chip.
279
- let confirmDeleteOpen = $state(false);
280
- // Dropdown half of the Run split button (the "reuse parameters" variant).
281
- let runMenuOpen = $state(false);
282
- const runIdCopy = new CopyFeedback(1200);
283
-
284
- // The accent for a status that means "something to triage", or undefined
285
- // when there is nothing wrong. Tint is reserved for alarms (DESIGN.md) so a
286
- // failure is the one lit thing on the page, a deliberate stop or a skip is
287
- // not a failure, and success least of all. Doubles as the switch for the
288
- // verdict phrase's colour, so wash and wording light up together.
289
- function alarmAccent(displayed: string): string | undefined {
290
- if (
291
- displayed === "failed" ||
292
- displayed === "crashed" ||
293
- displayed === "log_overflow" ||
294
- displayed === "missed" ||
295
- displayed === "start_failed" ||
296
- displayed === "unhealthy"
297
- )
298
- return "var(--color-danger-surface)";
299
- if (displayed === "timeout" || displayed === "daemon_stopped" || displayed === "queue_full")
300
- return "var(--color-warning-surface)";
301
- return undefined;
302
- }
303
-
304
- function handleConsoleKeydown(event: KeyboardEvent) {
305
- if (!run) return;
306
- if (event.key === "Escape" && consoleMaximized) {
307
- consoleMaximized = false;
308
- return;
309
- }
310
- // `F` toggles focus mode, the way a media player does, but never while
311
- // the operator is typing into a field (search box, etc.).
312
- if (
313
- (event.key === "f" || event.key === "F") &&
314
- !event.metaKey &&
315
- !event.ctrlKey &&
316
- !event.altKey
317
- ) {
318
- const target = event.target;
319
- if (
320
- target instanceof HTMLElement &&
321
- (target.tagName === "INPUT" ||
322
- target.tagName === "TEXTAREA" ||
323
- target.isContentEditable)
324
- )
325
- return;
326
- event.preventDefault();
327
- consoleMaximized = !consoleMaximized;
328
- }
329
- }
330
-
331
- function maximizePortal(node: HTMLElement, active: boolean) {
332
- const anchor = document.createComment("maximized-console");
333
- function moveOut() {
334
- const parent = node.parentNode;
335
- if (parent && parent !== document.body) {
336
- parent.insertBefore(anchor, node);
337
- document.body.appendChild(node);
338
- }
339
- }
340
- function moveBack() {
341
- const parent = anchor.parentNode;
342
- if (parent) {
343
- parent.insertBefore(node, anchor);
344
- anchor.remove();
345
- }
346
- }
347
- if (active) moveOut();
348
- return {
349
- update(next: boolean) {
350
- if (next) moveOut();
351
- else moveBack();
352
- },
353
- destroy() {
354
- // Mirror the `portal` action: if still parked on <body>, remove
355
- // the node so Svelte's own teardown doesn't leak it there.
356
- if (node.parentNode === document.body) node.remove();
357
- anchor.remove();
358
- },
359
- };
360
- }
361
146
  </script>
362
147
 
363
- <svelte:window onkeydown={handleConsoleKeydown} />
364
-
365
148
  {#if run}
366
149
  {@const status = displayStatus(run.status, run.endReason)}
367
150
  {@const config = RUN_STATUS_CONFIG[status]}
@@ -370,32 +153,14 @@
370
153
  run,
371
154
  run.status === "running" ? durationTicker.now.getTime() : undefined,
372
155
  )}
373
- {@const startDelay = runStartDelay(run)}
374
- {@const startedAt = run.startedAt ?? run.createdAt}
375
- {@const retry = runRetryLabel(run)}
376
- {@const usage = runUsageLabel(run)}
377
- {@const live = run.status === "running" ? getLiveUsage(run.id) : undefined}
378
- {@const paramEntries = run.params ? Object.entries(run.params) : []}
379
156
  {@const suffix = instanceSuffix(run.instanceIndex, getInstanceCount(run.taskName))}
380
- {@const spine = config.dot.replace(" animate-pulse", "")}
157
+ {@const spine = config.solidDot}
381
158
  {@const isRunning = run.status === "running"}
382
159
  <!-- A code is worth the ink only when it is news: `exit 0` restates
383
160
  "succeeded", while a non-zero code is the first thing to triage on. -->
384
161
  {@const showCode = status === "failed"}
385
- {@const verdict = runVerdict(status)}
386
- {@const endLabel =
387
- status === "stopped"
388
- ? "run stopped by operator"
389
- : status === "daemon_stopped"
390
- ? "daemon stopped mid-run"
391
- : status === "timeout"
392
- ? "run timed out"
393
- : "end of output"}
394
- {@const endTone =
395
- status === "stopped" || status === "daemon_stopped" || status === "timeout"
396
- ? "warn"
397
- : "muted"}
398
- {@const alarm = alarmAccent(status)}
162
+ {@const verdict = RUN_VERDICTS[status]}
163
+ {@const alarm = config.alarm}
399
164
  <!-- The panel: a status spine runs the full left edge across both the header
400
165
  readout and the console below, hugging the rail divider (artifact
401
166
  ".detail .spine"). -->
@@ -409,458 +174,133 @@
409
174
  class="absolute inset-y-0 left-0 z-[3] w-1 {spine} {isRunning ? 'spine-flow' : ''}"
410
175
  aria-hidden="true"
411
176
  ></div>
412
- <!-- Detailed header: a readout whose ink scales with how much went wrong.
177
+ {#snippet actions()}
178
+ <RunActions
179
+ runId={run.id}
180
+ taskName={run.taskName}
181
+ {isRunning}
182
+ {canDelete}
183
+ {onRun}
184
+ {onRunAgain}
185
+ {onStop}
186
+ {onDelete}
187
+ {stopLabel}
188
+ />
189
+ {/snippet}
190
+ {#if headerLayout === "line"}
191
+ <RunHeaderLine
192
+ {run}
193
+ {status}
194
+ {duration}
195
+ {suffix}
196
+ live={isRunning ? getLiveUsage(run.id) : undefined}
197
+ {onBack}
198
+ {actions}
199
+ />
200
+ {:else}
201
+ <!-- Detailed header: a readout whose ink scales with how much went wrong.
413
202
  Every run states its outcome in one phrase over one quiet fact line;
414
203
  only a run worth triaging colours the phrase and tints the surface.
415
204
  Nothing here is a fixed slot, every fact renders only when it is
416
205
  true, so the header's height is itself a signal. -->
417
- <div
418
- class="head-region @container relative shrink-0 border-b border-outline-faint"
419
- style="--rw-oc: {alarm ?? 'var(--color-surface-raised)'}"
420
- >
421
- <div class="pt-[18px] pr-[22px] pb-[16px] pl-[26px]">
422
- <!-- The actions wrap onto their own row rather than squeeze the
206
+ <div
207
+ class="head-region @container relative shrink-0 border-b border-outline-faint"
208
+ style="--rw-oc: {alarm ?? 'var(--color-surface-raised)'}"
209
+ >
210
+ <div class="pt-[18px] pr-[22px] pb-[16px] pl-[26px]">
211
+ <!-- The actions wrap onto their own row rather than squeeze the
423
212
  readout below a legible width on a narrow panel. -->
424
- <div class="flex flex-wrap items-start gap-x-3 gap-y-3">
425
- {#if onBack}
426
- <button
427
- type="button"
428
- onclick={() => onBack()}
429
- class="-mt-1 -ml-2 shrink-0 rounded-[3px] p-1.5 text-on-surface-muted hover:bg-surface-sunken hover:text-primary"
430
- title="Back to runs"
431
- aria-label="Back to runs"
432
- >
433
- <ArrowLeft size={20} />
434
- </button>
435
- {:else if onToggleList}
436
- <button
437
- type="button"
438
- onclick={() => onToggleList()}
439
- class="-mt-1 -ml-2 shrink-0 rounded-[3px] p-1.5 text-on-surface-muted hover:bg-surface-sunken hover:text-primary"
440
- title={listVisible ? "Hide run list" : "Show run list"}
441
- aria-label={listVisible ? "Hide run list" : "Show run list"}
442
- aria-expanded={listVisible}
443
- >
444
- {#if listVisible}
445
- <PanelLeftClose size={20} />
446
- {:else}
447
- <PanelLeftOpen size={20} />
448
- {/if}
449
- </button>
450
- {/if}
451
- <div class="min-w-60 flex-1">
452
- <!-- Identity, but only where the page around the panel isn't
453
- already saying it: the cross-task runs list needs the task
454
- name, a task's own page already has it in the breadcrumb. -->
455
- {#if showTaskName || suffix}
456
- <div
457
- class="mb-1.5 font-mono text-[11.5px] font-medium tracking-[0.06em] text-on-surface-muted"
213
+ <div class="flex flex-wrap items-start gap-x-3 gap-y-3">
214
+ {#if onBack}
215
+ <button
216
+ type="button"
217
+ onclick={() => onBack()}
218
+ class="-mt-1 -ml-2 shrink-0 rounded-[3px] p-1.5 text-on-surface-muted hover:bg-surface-sunken hover:text-primary"
219
+ title="Back to runs"
220
+ aria-label="Back to runs"
458
221
  >
459
- {showTaskName ? `${run.taskName}${suffix}` : `instance ${suffix}`}
460
- </div>
222
+ <ArrowLeft size={20} />
223
+ </button>
461
224
  {/if}
225
+ <div class="min-w-60 flex-1">
226
+ <!-- Identity, but only where the page around the panel isn't
227
+ already saying it: the cross-task runs list needs the task
228
+ name, a task's own page already has it in the breadcrumb. -->
229
+ {#if showTaskName || suffix}
230
+ <div
231
+ class="mb-1.5 font-mono text-[11.5px] font-medium tracking-[0.06em] text-on-surface-muted"
232
+ >
233
+ {showTaskName
234
+ ? `${run.taskName}${suffix}`
235
+ : `instance ${suffix}`}
236
+ </div>
237
+ {/if}
462
238
 
463
- <!-- Verdict: the outcome as one sentence, and the only large
239
+ <!-- Verdict: the outcome as one sentence, and the only large
464
240
  type in the panel. It is prose, so the phrase is sans and
465
241
  only the tokens inside it, the duration, a failing exit,
466
242
  are mono (DESIGN.md's mono-vs-sans rule). The glyph is bare
467
243
  (no plate, no ring) so it reads as part of the sentence,
468
244
  and it alone carries the outcome colour on a healthy run:
469
245
  green words are ink spent on nothing being wrong. -->
470
- <Tooltip content={config.description} position="right" wide>
471
- <h2
472
- class="flex flex-wrap items-center gap-x-2.5 text-[22px] @max-2xl:text-[18px] @max-xs:text-[16px]"
473
- data-testid="run-verdict"
474
- data-status={status}
475
- >
476
- <DetailIcon
477
- size={18}
478
- strokeWidth={2.25}
479
- class="shrink-0 {config.color} {isRunning
480
- ? 'animate-spin'
481
- : ''}"
482
- />
483
- <span
484
- class="font-sans font-semibold tracking-[-0.01em] {alarm
485
- ? config.color
486
- : 'text-on-surface'}">{verdict.verb}</span
246
+ <Tooltip content={config.description} position="right" wide>
247
+ <h2
248
+ class="flex flex-wrap items-center gap-x-2.5 text-[22px] @max-2xl:text-[18px] @max-xs:text-[16px]"
249
+ data-testid="run-verdict"
250
+ data-status={status}
487
251
  >
488
- {#if verdict.timed && duration}
489
- <span
490
- class="font-mono text-[0.86em] font-medium text-on-surface tabular-nums"
491
- data-testid="run-duration">{duration}</span
492
- >
493
- {/if}
494
- {#if showCode}
495
- <!-- Grouped with its own separator so a wrap can
496
- never leave the dot dangling at a line end. -->
252
+ <DetailIcon
253
+ size={18}
254
+ strokeWidth={2.25}
255
+ class="shrink-0 {config.color} {isRunning
256
+ ? 'animate-spin'
257
+ : ''}"
258
+ />
497
259
  <span
498
- class="inline-flex items-center gap-x-2.5 font-mono text-[0.86em] font-medium tabular-nums"
260
+ class="font-sans font-semibold tracking-[-0.01em] {alarm
261
+ ? config.color
262
+ : 'text-on-surface'}">{verdict.verb}</span
499
263
  >
500
- <span class="text-on-surface-faint" aria-hidden="true"
501
- >·</span
502
- >
503
- <span class="text-danger-surface" data-testid="run-exit"
504
- >exit {run.exitCode}</span
505
- >
506
- </span>
507
- {/if}
508
- </h2>
509
- </Tooltip>
510
-
511
- <!-- Facts: when it happened and what shaped it. Queue wait,
512
- retries and parameters are absent on an ordinary run and
513
- appear inline the moment they exist. -->
514
- <div
515
- class="mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[11.5px] text-on-surface-faint tabular-nums"
516
- >
517
- <!-- No hover-for-the-full-timestamp here: the date sits
518
- right beside the clock time, so a tooltip could only
519
- repeat it. The line wraps at narrow widths instead of
520
- dropping the date and hiding it behind a hover. -->
521
- <span data-testid="run-started" class="text-on-surface-muted"
522
- >{run.startedAt ? formatClockTime(run.startedAt) : "—"}</span
523
- >
524
- <span class="text-outline-hover" aria-hidden="true">·</span>
525
- <span>{formatCalendarDate(startedAt)}</span>
526
- <span class="text-outline-hover" aria-hidden="true">·</span>
527
- <span>via {formatTriggeredByLabel(run.triggeredBy).toLowerCase()}</span>
528
- {#if startDelay}
529
- <span class="text-outline-hover" aria-hidden="true">·</span>
530
- <span
531
- class="text-warning-soft-text"
532
- title="Waited between its scheduled tick and actually starting"
533
- >{startDelay} queued</span
534
- >
535
- {/if}
536
- {#if retry}
537
- <span class="text-outline-hover" aria-hidden="true">·</span>
538
- <span class="inline-flex items-center gap-1">
539
- <RotateCw size={11} />
540
- retry #{run.retryAttempt}{#if run.retryOfRunId}&nbsp;of
541
- {formatShortId(run.retryOfRunId)}{/if}
542
- </span>
543
- {/if}
544
- {#if paramEntries.length > 0}
545
- <span class="text-outline-hover" aria-hidden="true">·</span>
546
- <Popover placement="bottom-start">
547
- {#snippet trigger()}
264
+ {#if verdict.timed && duration}
548
265
  <span
549
- class="inline-flex cursor-pointer items-center gap-1 border-b border-dotted border-outline-hover text-on-surface-muted hover:text-primary"
266
+ class="font-mono text-[0.86em] font-medium text-on-surface tabular-nums"
267
+ data-testid="run-duration">{duration}</span
550
268
  >
551
- <SlidersHorizontal size={11} />
552
- {paramEntries.length}
553
- {paramEntries.length === 1 ? "parameter" : "parameters"}
554
- </span>
555
- {/snippet}
556
- <div class="max-w-md min-w-48">
269
+ {/if}
270
+ {#if showCode}
271
+ <!-- Grouped with its own separator so a wrap can
272
+ never leave the dot dangling at a line end. -->
557
273
  <span
558
- class="font-mono text-xs font-semibold tracking-[0.14em] text-on-surface-faint uppercase"
559
- >Parameters</span
274
+ class="inline-flex items-center gap-x-2.5 font-mono text-[0.86em] font-medium tabular-nums"
560
275
  >
561
- <dl
562
- class="mt-2 grid max-h-72 grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 overflow-y-auto"
563
- >
564
- {#each paramEntries as [key, value] (key)}
565
- <dt class="font-mono text-xs text-on-surface-muted">
566
- {key}
567
- </dt>
568
- <dd
569
- class="font-mono text-xs font-medium break-all text-on-surface"
570
- >
571
- {value}
572
- </dd>
573
- {/each}
574
- </dl>
575
- </div>
576
- </Popover>
577
- {/if}
578
- {#if live}
579
- <span class="text-outline-hover" aria-hidden="true">·</span>
580
- <span
581
- data-testid="run-live-usage"
582
- class="text-on-surface-muted"
583
- title="Live CPU (100% = one core) and memory of the run's processes"
584
- >CPU {Math.round(live.cpuPercent)}% · {formatBytes(
585
- live.memoryBytes,
586
- )}</span
587
- >
588
- {/if}
589
- {#if usage}
590
- <span class="text-outline-hover" aria-hidden="true">·</span>
591
- <span
592
- data-testid="run-usage"
593
- title="Peak memory and CPU time of the run's processes"
594
- >{usage}</span
595
- >
596
- {/if}
597
- <!-- Run-id chip: click to copy the full ULID. Last, because
598
- it is the fact you reach for least and the only one you
599
- interact with. -->
600
- <button
601
- type="button"
602
- onclick={() => void runIdCopy.copy(run.id)}
603
- title="Copy run ID"
604
- class="inline-flex items-center gap-1 rounded-[3px] border border-outline-faint bg-surface-sunken px-1.5 text-on-surface-faint hover:border-outline-hover hover:text-primary"
605
- >
606
- {#if runIdCopy.copied}
607
- <Check size={11} class="text-success-surface" />Copied
608
- {:else}
609
- <Hash size={11} />{run.id}
610
- {/if}
611
- </button>
612
- </div>
613
- </div>
614
-
615
- <!-- Actions: run (split) / service lifecycle ·
616
- stop · download · delete (delete behind an inline confirm) -->
617
- <div class="flex flex-wrap items-center gap-2">
618
- {#if onStopService || onRestartService}
619
- <!-- Service lifecycle replaces the run control.
620
- Restart is always available (it cancels + respawns
621
- all instances in one call); Stop appears only while
622
- the service is running. Each still confirms, so
623
- neither fires accidentally. -->
624
- {#if onRestartService}
625
- <button
626
- type="button"
627
- onclick={() => onRestartService()}
628
- disabled={serviceBusy}
629
- title={serviceStopped
630
- ? "Starts the service"
631
- : "Cancels and respawns every instance"}
632
- class="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-[3px] border border-primary-soft-border bg-surface-raised px-3 font-mono text-sm font-medium text-primary hover:bg-primary-soft active:translate-y-px disabled:cursor-not-allowed disabled:opacity-60"
633
- >
634
- {#if serviceStopped}
635
- <Play size={15} fill="currentColor" stroke="none" />
636
- {:else}
637
- <RefreshCcw size={15} />
638
- {/if}
639
- <span class="@max-xs:hidden"
640
- >{serviceStopped ? "Start" : "Restart"}</span
641
- >
642
- </button>
643
- {/if}
644
- {#if !serviceStopped && onStopService}
645
- <button
646
- type="button"
647
- onclick={() => onStopService()}
648
- disabled={serviceBusy}
649
- title="Stops the service for the rest of the daemon's lifetime"
650
- class="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-[3px] border border-danger-soft-border bg-surface-raised px-3 font-mono text-sm font-medium text-danger-surface hover:bg-danger-soft active:translate-y-px disabled:cursor-not-allowed disabled:opacity-60"
651
- >
652
- <Square size={15} fill="currentColor" stroke="none" />
653
- <span class="@max-xs:hidden">Stop</span>
654
- </button>
655
- {/if}
656
- {:else}
657
- {#if onRun}
658
- <!-- Split button: Run (defaults) + an arrow for the
659
- reuse-parameters variant, shown only when there is one. -->
660
- <div
661
- class="inline-flex overflow-hidden rounded-[3px] border border-primary-soft-border"
662
- >
663
- <button
664
- type="button"
665
- onclick={() => onRun()}
666
- title="Run this task now with default options"
667
- class="inline-flex h-9 cursor-pointer items-center gap-1.5 bg-surface-raised px-3 font-mono text-sm font-medium text-primary hover:bg-primary-soft active:translate-y-px"
668
- >
669
- <Play size={15} fill="currentColor" stroke="none" />
670
- <span class="@max-xs:hidden">Run</span>
671
- </button>
672
- {#if onRunAgain}
673
- <Popover bind:open={runMenuOpen} placement="bottom-end">
674
- {#snippet trigger()}
675
- <span
676
- class="flex h-9 cursor-pointer items-center border-l border-primary-soft-border bg-surface-raised px-2 text-primary hover:bg-primary-soft"
677
- title="More run options"
678
- aria-label="More run options"
679
- >
680
- <ChevronDown size={15} />
681
- </span>
682
- {/snippet}
683
- <button
684
- type="button"
685
- onclick={() => {
686
- runMenuOpen = false;
687
- onRunAgain?.();
688
- }}
689
- class="flex w-56 items-start gap-2.5 text-left"
276
+ <span class="text-on-surface-faint" aria-hidden="true"
277
+ >·</span
278
+ >
279
+ <span class="text-danger-surface" data-testid="run-exit"
280
+ >exit {run.exitCode}</span
690
281
  >
691
- <RotateCw
692
- size={15}
693
- class="mt-0.5 shrink-0 text-on-surface-muted"
694
- />
695
- <span class="flex flex-col">
696
- <span
697
- class="font-mono text-sm font-medium text-on-surface"
698
- >Run again</span
699
- >
700
- <span class="text-xs text-on-surface-muted"
701
- >Reuse this run's parameters</span
702
- >
703
- </span>
704
- </button>
705
- </Popover>
282
+ </span>
706
283
  {/if}
707
- </div>
708
- {/if}
709
- {#if isRunning && onStop}
710
- <button
711
- type="button"
712
- onclick={() => onStop(run.id)}
713
- class="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-[3px] border border-danger-soft-border bg-surface-raised px-3 font-mono text-sm font-medium text-danger-surface hover:bg-danger-soft active:translate-y-px"
714
- >
715
- <Square size={15} fill="currentColor" stroke="none" />
716
- <span class="@max-xs:hidden">Stop</span>
717
- </button>
718
- {/if}
719
- {/if}
720
- <a
721
- href={runLogDownloadUrl(run.id)}
722
- download="{run.taskName}-{run.id}.log"
723
- class="inline-flex items-center justify-center rounded-[3px] border border-outline-faint bg-surface-raised p-2 text-on-surface-muted hover:border-outline-hover hover:bg-surface-sunken hover:text-primary"
724
- title="Download the full log (rotated and current parts as one file)"
725
- aria-label="Download log"
726
- >
727
- <Download size={15} />
728
- </a>
729
- {#if canDelete}
730
- <Popover bind:open={confirmDeleteOpen} placement="bottom-end">
731
- {#snippet trigger()}
732
- <span
733
- class="flex cursor-pointer items-center justify-center rounded-[3px] border border-outline-faint bg-surface-raised p-2 text-on-surface-muted hover:border-danger-soft-border hover:bg-danger-soft hover:text-danger-surface"
734
- title="Delete this run"
735
- aria-label="Delete run"
736
- >
737
- <Trash size={15} />
738
- </span>
739
- {/snippet}
740
- <div class="w-60">
741
- <div class="font-mono text-sm font-semibold text-on-surface">
742
- Delete this run?
743
- </div>
744
- <div class="mt-1 text-xs leading-relaxed text-on-surface-muted">
745
- Its captured output is removed from disk. This can't be
746
- undone.
747
- </div>
748
- <div class="mt-3 flex justify-end gap-2">
749
- <Button
750
- variant="ghost"
751
- size="sm"
752
- onclick={() => (confirmDeleteOpen = false)}
753
- >
754
- Cancel
755
- </Button>
756
- <Button
757
- variant="danger"
758
- size="sm"
759
- onclick={() => {
760
- confirmDeleteOpen = false;
761
- onDelete?.(run.id);
762
- }}
763
- >
764
- Delete run
765
- </Button>
766
- </div>
767
- </div>
768
- </Popover>
769
- {/if}
284
+ </h2>
285
+ </Tooltip>
286
+
287
+ <RunFacts {run} live={isRunning ? getLiveUsage(run.id) : undefined} />
288
+ </div>
289
+
290
+ {@render actions()}
770
291
  </div>
771
292
  </div>
772
293
  </div>
773
- </div>
774
-
775
- <!-- Console hero, maximizes to a full-bleed overlay via portal -->
776
- {#if consoleMaximized}
777
- <div
778
- use:portal
779
- class="console-backdrop fixed inset-0 z-40 bg-backdrop backdrop-blur-sm"
780
- onclick={() => (consoleMaximized = false)}
781
- role="presentation"
782
- ></div>
783
294
  {/if}
784
- <div
785
- use:maximizePortal={consoleMaximized}
786
- class={consoleMaximized
787
- ? "console-zoom fixed inset-3 z-50 flex flex-col overflow-hidden rounded-[4px] bg-[var(--rw-con-bg)] shadow-2xl ring-1 ring-[var(--rw-con-gutter)] sm:inset-6"
788
- : "ml-1 flex min-h-[300px] flex-1 flex-col overflow-hidden border-t border-[var(--rw-con-gutter)] bg-[var(--rw-con-bg)]"}
789
- >
790
- <div
791
- class="@container flex shrink-0 items-center justify-between gap-3 border-b border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)] px-3.5 py-[9px] font-mono text-[11.5px] text-[var(--rw-con-dim)]"
792
- >
793
- <div class="flex min-w-0 items-center gap-3">
794
- {#if consoleMaximized}
795
- <!-- Focus-mode identity: which run is filling the screen -->
796
- <span class="flex min-w-0 items-center gap-2">
797
- <span class="h-2 w-2 shrink-0 rounded-full {spine}"></span>
798
- <span class="truncate font-medium text-[var(--rw-con-text)]">
799
- {run.taskName}{suffix}
800
- </span>
801
- <span class="text-[var(--rw-con-gutter)]">·</span>
802
- <span class="font-semibold capitalize {config.color}">{status}</span>
803
- </span>
804
- {:else}
805
- <span
806
- class="flex items-center gap-2 font-semibold whitespace-nowrap text-[var(--rw-con-text)]"
807
- >
808
- <TerminalIcon size={14} class="opacity-70" />
809
- Console output
810
- </span>
811
- <span class="hidden text-[var(--rw-con-gutter)] @lg:inline"
812
- >stdout + stderr</span
813
- >
814
- {/if}
815
- </div>
816
- <div class="flex shrink-0 items-center gap-3">
817
- <button
818
- type="button"
819
- onclick={() => (consoleWrap = !consoleWrap)}
820
- class="flex cursor-pointer items-center gap-1.5 rounded-[3px] px-2 py-1 hover:bg-[var(--rw-con-gutter)]/30 hover:text-[var(--rw-con-text)] {consoleWrap
821
- ? 'text-[var(--rw-con-text)]'
822
- : 'text-[var(--rw-con-dim)]'}"
823
- title={consoleWrap ? "Unwrap lines" : "Wrap long lines"}
824
- aria-pressed={consoleWrap}
825
- aria-label="Toggle line wrapping"
826
- >
827
- <TextWrap size={13} />
828
- <span class="hidden @sm:inline">Wrap</span>
829
- </button>
830
- <button
831
- type="button"
832
- onclick={() => (consoleMaximized = !consoleMaximized)}
833
- class="flex cursor-pointer items-center gap-1.5 rounded-[3px] px-2 py-1 text-[var(--rw-con-dim)] hover:bg-[var(--rw-con-gutter)]/30 hover:text-[var(--rw-con-text)]"
834
- title={consoleMaximized ? "Restore console (Esc)" : "Maximize console · F"}
835
- aria-label={consoleMaximized ? "Restore console" : "Maximize console"}
836
- >
837
- {#if consoleMaximized}
838
- <Minimize2 size={13} />
839
- <Kbd keys="Esc" size="xs" tone="console" />
840
- {:else}
841
- <Maximize2 size={13} />
842
- Expand
843
- {/if}
844
- </button>
845
- </div>
846
- </div>
847
- {#key run.id}
848
- <LogConsole
849
- bind:this={logConsole}
850
- fetchLogs={(f: number, t: number) => fetchLogs(run.id, f, t)}
851
- fetchLineHistory={fetchLineHistory
852
- ? (n: number) => fetchLineHistory(run.id, n)
853
- : undefined}
854
- bind:wrap={consoleWrap}
855
- class="min-h-0 flex-1"
856
- {highlightLine}
857
- {endLabel}
858
- {endTone}
859
- error={logFetchError}
860
- loading={seededRunId !== run.id}
861
- />
862
- {/key}
863
- </div>
295
+
296
+ <RunConsoleFrame
297
+ {run}
298
+ {suffix}
299
+ {fetchLogs}
300
+ {streamLogs}
301
+ {fetchLineHistory}
302
+ {highlightLine}
303
+ />
864
304
  </div>
865
305
  {:else if loading && !notFound}
866
306
  <!-- Loading: the panel's own frame (verdict header over the console) with
@@ -899,12 +339,15 @@
899
339
  class="flex min-w-0 flex-1 flex-col items-center justify-center gap-4 bg-surface-sunken/30"
900
340
  >
901
341
  <EmptyState
902
- title={notFound ? "Run not found" : onRunTask ? "No runs yet" : "Select a run"}
342
+ title={notFound
343
+ ? "Run not found"
344
+ : (emptyTitle ?? (onRunTask ? "No runs yet" : "Select a run"))}
903
345
  description={notFound
904
346
  ? "This run doesn't exist. It may have been deleted by retention, or the link is wrong. Pick a run from the list to continue."
905
- : onRunTask
906
- ? "This task hasn't run yet. Trigger it to see its output here."
907
- : "Pick a run from the list to view details and logs."}
347
+ : (emptyDescription ??
348
+ (onRunTask
349
+ ? "This task hasn't run yet. Trigger it to see its output here."
350
+ : "Pick a run from the list to view details and logs."))}
908
351
  icon={notFound ? SearchX : MousePointerClick}
909
352
  />
910
353
  {#if onRunTask && !notFound}
@@ -929,34 +372,6 @@
929
372
  background: color-mix(in srgb, var(--rw-oc) 7%, var(--color-surface-raised));
930
373
  }
931
374
 
932
- /* Gentle fade for the maximize backdrop; disabled under reduced motion. */
933
- .console-backdrop {
934
- animation: console-backdrop-in 150ms;
935
- }
936
- @keyframes console-backdrop-in {
937
- from {
938
- opacity: 0;
939
- }
940
- to {
941
- opacity: 1;
942
- }
943
- }
944
-
945
- /* The console zooms up as it takes over the viewport. */
946
- .console-zoom {
947
- animation: console-zoom-in 260ms cubic-bezier(0.33, 0.05, 0.2, 1);
948
- }
949
- @keyframes console-zoom-in {
950
- from {
951
- opacity: 0.4;
952
- transform: scale(0.96);
953
- }
954
- to {
955
- opacity: 1;
956
- transform: none;
957
- }
958
- }
959
-
960
375
  /* Living spine: a light sweep travels down the edge while a run is in flight. */
961
376
  .spine-flow {
962
377
  overflow: hidden;
@@ -984,8 +399,6 @@
984
399
  }
985
400
 
986
401
  @media (prefers-reduced-motion: reduce) {
987
- .console-backdrop,
988
- .console-zoom,
989
402
  .spine-flow::after {
990
403
  animation: none;
991
404
  }