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