@runwisp/ui 0.15.0 → 0.16.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@runwisp/ui",
3
3
  "description": "Svelte 5 UI component library for RunWisp dashboards and admin interfaces.",
4
- "version": "0.15.0",
4
+ "version": "0.16.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -49,8 +49,8 @@
49
49
  "devDependencies": {
50
50
  "@chromatic-com/storybook": "^5.2.1",
51
51
  "@eslint/js": "^10.0.1",
52
- "@runwisp/eslint-config": "0.15.0",
53
- "@runwisp/typescript-config": "0.15.0",
52
+ "@runwisp/eslint-config": "0.16.0",
53
+ "@runwisp/typescript-config": "0.16.0",
54
54
  "@storybook/addon-a11y": "^10.5.5",
55
55
  "@storybook/addon-docs": "^10.5.5",
56
56
  "@storybook/addon-svelte-csf": "^5.1.2",
@@ -93,7 +93,7 @@
93
93
  "@fontsource-variable/geist-mono": "^5.3.0",
94
94
  "@fontsource/tasa-orbiter": "^5.3.0",
95
95
  "@lucide/svelte": "^1.28.0",
96
- "@runwisp/common": "0.15.0",
96
+ "@runwisp/common": "0.16.0",
97
97
  "@tanstack/svelte-virtual": "^3.13.35",
98
98
  "ansi-to-html": "^0.7.2",
99
99
  "cron-parser": "^5.7.0",
@@ -0,0 +1,56 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, expect, it } from "vitest";
5
+ import { resolveTrapFocusTarget } from "./focusTrap.js";
6
+
7
+ // resolveTrapFocusTarget is generic and identity-based on purpose: it never
8
+ // touches the DOM, so it's exercised here with plain sentinel objects
9
+ // instead of real elements (this repo's vitest env has no jsdom).
10
+ describe("resolveTrapFocusTarget", () => {
11
+ const first = { id: "first" };
12
+ const middle = { id: "middle" };
13
+ const last = { id: "last" };
14
+ const focusable = [first, middle, last];
15
+
16
+ it("wraps Tab from the last focusable element back to the first", () => {
17
+ const target = resolveTrapFocusTarget({ key: "Tab", shiftKey: false }, focusable, last);
18
+ expect(target).toBe(first);
19
+ });
20
+
21
+ it("wraps Shift+Tab from the first focusable element back to the last", () => {
22
+ const target = resolveTrapFocusTarget({ key: "Tab", shiftKey: true }, focusable, first);
23
+ expect(target).toBe(last);
24
+ });
25
+
26
+ it("does nothing when Tab is pressed from a middle element", () => {
27
+ const target = resolveTrapFocusTarget({ key: "Tab", shiftKey: false }, focusable, middle);
28
+ expect(target).toBeNull();
29
+ });
30
+
31
+ it("does nothing when Shift+Tab is pressed from a middle element", () => {
32
+ const target = resolveTrapFocusTarget({ key: "Tab", shiftKey: true }, focusable, middle);
33
+ expect(target).toBeNull();
34
+ });
35
+
36
+ it("ignores non-Tab keys", () => {
37
+ const target = resolveTrapFocusTarget({ key: "Escape", shiftKey: false }, focusable, last);
38
+ expect(target).toBeNull();
39
+ });
40
+
41
+ it("does nothing when there are no focusable elements", () => {
42
+ const target = resolveTrapFocusTarget({ key: "Tab", shiftKey: false }, [], null);
43
+ expect(target).toBeNull();
44
+ });
45
+
46
+ it("wraps a single focusable element to itself in both directions", () => {
47
+ const solo = [first];
48
+ expect(resolveTrapFocusTarget({ key: "Tab", shiftKey: false }, solo, first)).toBe(first);
49
+ expect(resolveTrapFocusTarget({ key: "Tab", shiftKey: true }, solo, first)).toBe(first);
50
+ });
51
+
52
+ it("does nothing when active is null and doesn't match first or last", () => {
53
+ const target = resolveTrapFocusTarget({ key: "Tab", shiftKey: false }, focusable, null);
54
+ expect(target).toBeNull();
55
+ });
56
+ });
@@ -0,0 +1,115 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ const FOCUSABLE_SELECTOR = [
5
+ "a[href]",
6
+ "button:not([disabled])",
7
+ "input:not([disabled])",
8
+ "select:not([disabled])",
9
+ "textarea:not([disabled])",
10
+ '[tabindex]:not([tabindex="-1"])',
11
+ ].join(",");
12
+
13
+ function isHTMLElement(value: unknown): value is HTMLElement {
14
+ return value instanceof HTMLElement;
15
+ }
16
+
17
+ /** All focusable, visible descendants of `container`, in DOM (tab) order. */
18
+ export function getFocusableElements(container: HTMLElement): HTMLElement[] {
19
+ return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
20
+ (el) => el.offsetParent !== null || el === document.activeElement,
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Pure decision function: given a keydown event, the currently focusable
26
+ * elements (in order), and whatever currently has focus, returns the element
27
+ * that Tab/Shift+Tab should wrap focus to in order to keep it trapped inside
28
+ * the set — or `null` if the key doesn't need handling.
29
+ *
30
+ * Framework- and DOM-free on purpose (elements are compared by identity only)
31
+ * so this can be unit tested without a browser or jsdom.
32
+ */
33
+ export function resolveTrapFocusTarget<T>(
34
+ event: Pick<KeyboardEvent, "key" | "shiftKey">,
35
+ focusable: readonly T[],
36
+ active: T | null,
37
+ ): T | null {
38
+ if (event.key !== "Tab" || focusable.length === 0) return null;
39
+ const first = focusable[0];
40
+ const last = focusable[focusable.length - 1];
41
+ if (first === undefined || last === undefined) return null;
42
+
43
+ if (event.shiftKey && active === first) return last;
44
+ if (!event.shiftKey && active === last) return first;
45
+ return null;
46
+ }
47
+
48
+ export interface TrapFocusOptions {
49
+ /**
50
+ * Move focus to the first focusable descendant (or the container itself)
51
+ * as soon as the trap is created. Defaults to `true`; set to `false` when
52
+ * the caller already manages initial focus (e.g. a searchable combobox
53
+ * that focuses its own search input).
54
+ */
55
+ autoFocus?: boolean;
56
+ }
57
+
58
+ /**
59
+ * Svelte action that keeps keyboard focus inside `node` while it's mounted:
60
+ * Tab from the last focusable descendant wraps to the first (and Shift+Tab
61
+ * from the first wraps to the last), and focus is restored to whatever was
62
+ * focused beforehand once the trap is torn down — unless a click on some
63
+ * other real element already moved focus there, in which case that's left
64
+ * alone.
65
+ */
66
+ export function trapFocus(node: HTMLElement, options: TrapFocusOptions = {}) {
67
+ const autoFocus = options.autoFocus ?? true;
68
+ const previouslyFocused = isHTMLElement(document.activeElement) ? document.activeElement : null;
69
+
70
+ if (autoFocus) {
71
+ // Deferred a tick: `node` may still be moved by a sibling `use:portal`
72
+ // action (appendChild-ing it to <body>) after this action runs, and
73
+ // relocating a focused element resets focus to <body>. Waiting a
74
+ // microtask lets any such move finish first, so the focus target
75
+ // ends up wherever the element actually settles.
76
+ queueMicrotask(() => {
77
+ const target = getFocusableElements(node)[0] ?? node;
78
+ if (target === node && !node.hasAttribute("tabindex")) {
79
+ node.tabIndex = -1;
80
+ }
81
+ target.focus();
82
+ });
83
+ }
84
+
85
+ function handleKeydown(e: KeyboardEvent) {
86
+ const active = isHTMLElement(document.activeElement) ? document.activeElement : null;
87
+ const target = resolveTrapFocusTarget(e, getFocusableElements(node), active);
88
+ if (target) {
89
+ e.preventDefault();
90
+ target.focus();
91
+ }
92
+ }
93
+
94
+ node.addEventListener("keydown", handleKeydown);
95
+
96
+ return {
97
+ destroy() {
98
+ node.removeEventListener("keydown", handleKeydown);
99
+ // By the time an action's destroy() runs, `node` (and whatever
100
+ // inside it held focus) has already been detached — removing a
101
+ // focused element resets focus to <body> as a browser default, so
102
+ // that can't be told apart from an explicit close (Escape, item
103
+ // activation) by checking `node.contains(activeElement)` here.
104
+ // What *can* still be told apart: a deliberate outside click
105
+ // already moved focus to its own (still-attached) target before
106
+ // this runs, so activeElement is a real element other than
107
+ // <body>. Only restore focus when that's not the case.
108
+ const active = document.activeElement;
109
+ const focusWasReset = !isHTMLElement(active) || active === document.body;
110
+ if (focusWasReset && previouslyFocused && document.contains(previouslyFocused)) {
111
+ previouslyFocused.focus();
112
+ }
113
+ },
114
+ };
115
+ }
@@ -54,6 +54,7 @@
54
54
  import Input from "./Input.svelte";
55
55
  import Pagination from "./Pagination.svelte";
56
56
  import Fuse from "fuse.js";
57
+ import { selectionState } from "./data-grid-selection.js";
57
58
 
58
59
  let {
59
60
  columns,
@@ -162,8 +163,9 @@
162
163
  return filteredData.slice(start, start + pageSize);
163
164
  });
164
165
 
165
- let allSelected = $derived(pagedData.length > 0 && selectedRows.length === pagedData.length);
166
- let someSelected = $derived(selectedRows.length > 0 && selectedRows.length < pagedData.length);
166
+ const selection = $derived(selectionState(pagedData, selectedRows, rowKey));
167
+ let allSelected = $derived(selection.allSelected);
168
+ let someSelected = $derived(selection.someSelected);
167
169
  </script>
168
170
 
169
171
  <div
@@ -5,6 +5,7 @@
5
5
  import type { Snippet } from "svelte";
6
6
  import { X } from "@lucide/svelte";
7
7
  import { portal } from "../actions/portal.js";
8
+ import { trapFocus } from "../actions/focusTrap.js";
8
9
  import { fly } from "svelte/transition";
9
10
 
10
11
  type Side = "left" | "right" | "top" | "bottom";
@@ -98,6 +99,7 @@
98
99
  {/if}
99
100
 
100
101
  <div
102
+ use:trapFocus
101
103
  class="
102
104
  absolute z-10 flex flex-col
103
105
  border border-outline bg-surface-overlay shadow-lg
@@ -45,6 +45,7 @@
45
45
  let transformOrigin = $state("top right");
46
46
 
47
47
  import { portal } from "../actions/portal.js";
48
+ import { trapFocus } from "../actions/focusTrap.js";
48
49
 
49
50
  async function setupFloating() {
50
51
  if (!triggerEl || !menuEl) return;
@@ -133,6 +134,7 @@
133
134
  {#if open}
134
135
  <div
135
136
  use:portal
137
+ use:trapFocus
136
138
  bind:this={menuEl}
137
139
  role="menu"
138
140
  class="
@@ -32,7 +32,7 @@
32
32
  ...restProps
33
33
  }: Props = $props();
34
34
 
35
- const generatedId = `duration-input-${Math.random().toString(36).slice(2, 10)}`;
35
+ const generatedId = $props.id();
36
36
  const inputId = $derived(id ?? generatedId);
37
37
 
38
38
  let textValue = $state("");
@@ -30,7 +30,7 @@
30
30
  id,
31
31
  }: Props = $props();
32
32
 
33
- const generatedId = `field-${Math.random().toString(36).slice(2, 10)}`;
33
+ const generatedId = $props.id();
34
34
  const fieldId = $derived(id ?? generatedId);
35
35
  </script>
36
36
 
@@ -33,7 +33,7 @@
33
33
  ...restProps
34
34
  }: Props = $props();
35
35
 
36
- const generatedId = `input-${Math.random().toString(36).slice(2, 10)}`;
36
+ const generatedId = $props.id();
37
37
  const inputId = $derived(id ?? generatedId);
38
38
 
39
39
  const sizeClasses: Record<InputSize, string> = {
@@ -31,6 +31,12 @@
31
31
  // "muted" (default) renders the terminus in the gutter grey; "warn"
32
32
  // tints it amber for runs that ended by intervention rather than naturally.
33
33
  endTone?: "muted" | "warn";
34
+ // Message from the most recent fetchLogs failure (session expiry,
35
+ // daemon restart mid-request, network blip, ...). Rendered as a small
36
+ // banner above the log surface so a fetch failure never looks like a
37
+ // run that simply produced no output. Null/undefined renders nothing;
38
+ // the caller clears it once a subsequent fetch succeeds.
39
+ error?: string | null;
34
40
  // Wrap long lines instead of horizontally scrolling them. When on, each
35
41
  // rendered row's height becomes a multiple of `lineHeight` (one per
36
42
  // wrapped visual row), so the virtualizer switches from the fixed-height
@@ -48,6 +54,7 @@
48
54
  fetchLineHistory,
49
55
  endLabel = "end of output",
50
56
  endTone = "muted",
57
+ error = null,
51
58
  wrap = $bindable(false),
52
59
  }: Props = $props();
53
60
 
@@ -557,6 +564,15 @@
557
564
  >{RULER_SAMPLE}</span
558
565
  >
559
566
 
567
+ {#if error}
568
+ <div
569
+ class="flex shrink-0 items-center gap-2 border-b border-[var(--rw-con-gutter)] px-3.5 py-2 text-[11.5px]"
570
+ style="color: var(--rw-term-bad); background-color: color-mix(in srgb, var(--rw-term-bad) 12%, transparent);"
571
+ >
572
+ Failed to load log: {error}
573
+ </div>
574
+ {/if}
575
+
560
576
  <div bind:this={containerEl} class="flex-1 overflow-auto" onscroll={onScroll}>
561
577
  <div
562
578
  class="relative"
@@ -21,6 +21,7 @@
21
21
  }
22
22
 
23
23
  import { portal } from "../actions/portal.js";
24
+ import { trapFocus } from "../actions/focusTrap.js";
24
25
 
25
26
  let {
26
27
  open = $bindable(false),
@@ -79,6 +80,7 @@
79
80
  {/if}
80
81
 
81
82
  <div
83
+ use:trapFocus
82
84
  class="
83
85
  relative z-10 w-full {sizeClasses[size]}
84
86
  flex max-h-[90vh] flex-col
@@ -8,6 +8,7 @@
8
8
  import { computePosition, autoUpdate, flip, shift, offset } from "@floating-ui/dom";
9
9
  import { fade } from "svelte/transition";
10
10
  import { portal } from "../actions/portal.js";
11
+ import { trapFocus } from "../actions/focusTrap.js";
11
12
 
12
13
  interface Props {
13
14
  open?: boolean;
@@ -144,6 +145,7 @@
144
145
  {/if}
145
146
  <div
146
147
  use:portal
148
+ use:trapFocus
147
149
  bind:this={contentEl}
148
150
  class="
149
151
  z-[9999] max-h-[80vh] overflow-y-auto
@@ -108,6 +108,7 @@
108
108
  });
109
109
 
110
110
  import { portal } from "../actions/portal.js";
111
+ import { trapFocus } from "../actions/focusTrap.js";
111
112
 
112
113
  async function setupFloating() {
113
114
  if (!triggerEl || !menuEl) return;
@@ -299,7 +300,12 @@
299
300
  {/if}
300
301
 
301
302
  {#if isOpen}
302
- <div use:portal bind:this={menuEl} class="z-[9999] min-w-[200px]">
303
+ <div
304
+ use:portal
305
+ use:trapFocus={{ autoFocus: false }}
306
+ bind:this={menuEl}
307
+ class="z-[9999] min-w-[200px]"
308
+ >
303
309
  <div
304
310
  class="
305
311
  overflow-hidden rounded-[4px] border border-outline bg-surface-overlay shadow-md
@@ -30,7 +30,7 @@
30
30
  import Tooltip from "../Tooltip.svelte";
31
31
  import { portal } from "../../actions/portal.js";
32
32
  import type { Run } from "./types.js";
33
- import type { LogEvent, LogSlice } from "../../log-console/types.js";
33
+ import { isLogEvent, type LogEvent, type LogSlice } from "../../log-console/types.js";
34
34
  import { formatClockTime, formatCalendarDate } from "../../utils/format.js";
35
35
  import { formatShortId } from "../../utils/id.js";
36
36
  import { TickingNow } from "../../utils/ticking-now.svelte.js";
@@ -134,10 +134,41 @@
134
134
 
135
135
  let logConsole = $state<{ onStream: (event: LogEvent) => void } | null>(null);
136
136
 
137
+ // Message from the most recent seed-fetch failure (session expiry, daemon
138
+ // restart mid-request, ...), surfaced to LogConsole instead of letting a
139
+ // failed fetch render as an indistinguishable blank "no output" pane.
140
+ let logFetchError = $state<string | null>(null);
141
+
137
142
  // Derive a stable scalar so the effect only re-runs when the id actually
138
143
  // changes, not on every run object reference swap from SSE array updates.
139
144
  let runId = $derived(run?.id);
140
145
 
146
+ // Fetches the batched tail page a run's console opens on (see the effect
147
+ // below) and paints it into the console. Resolves the line the live
148
+ // stream should resume from, or null when there's nothing left to stream
149
+ // (the run already ended, or the caller cancelled while we were waiting).
150
+ // A failed fetch still falls back to an SSE tail backfill from scratch —
151
+ // it does not stop the console from live-tailing — but records the
152
+ // failure so LogConsole can show it instead of looking like a quiet run.
153
+ async function seedConsoleTail(
154
+ id: string,
155
+ isCancelled: () => boolean,
156
+ ): Promise<{ fromLine: number } | null> {
157
+ try {
158
+ const seed = await fetchLogs(id, -TAIL_LINES, -1);
159
+ if (isCancelled()) return null;
160
+ if (!isLogEvent(seed)) return { fromLine: -TAIL_LINES };
161
+ if (logConsole) logConsole.onStream(seed);
162
+ return seed.finished // ended run: nothing live to follow
163
+ ? null
164
+ : { fromLine: seed.sizeLines > 0 ? seed.sizeLines : -TAIL_LINES };
165
+ } catch (err) {
166
+ if (isCancelled()) return null;
167
+ logFetchError = err instanceof Error ? err.message : "Failed to load logs";
168
+ return { fromLine: -TAIL_LINES };
169
+ }
170
+ }
171
+
141
172
  $effect(() => {
142
173
  const id = runId;
143
174
  if (!id) return;
@@ -145,23 +176,33 @@
145
176
  const stream = streamLogs;
146
177
  if (!stream) return;
147
178
 
179
+ logFetchError = null;
180
+
148
181
  let cleanup: (() => void) | undefined;
182
+ let cancelled = false;
149
183
 
150
184
  untrack(() => {
151
- // Single SSE call seeds the viewport via backfill (negative
152
- // fromLine = tail-from-end) and continues with live events on
153
- // the same connection. No tail+stream handoff, no duplicate
154
- // replay, native Last-Event-ID resume on reconnect.
155
- cleanup = stream(
156
- id,
157
- (event: LogEvent) => {
158
- if (logConsole) logConsole.onStream(event);
159
- },
160
- { fromLine: -TAIL_LINES },
161
- );
185
+ void (async () => {
186
+ // Paint the tail in one batched page so the console opens
187
+ // already at the newest output, instead of replaying 1000
188
+ // backfill lines over SSE and visibly scrolling down to reach
189
+ // the end. The live stream then picks up after the seeded tail
190
+ // (its disk backfill from that anchor closes the fetch↔stream
191
+ // race, and the daemon dedupes anything already shown).
192
+ const seeded = await seedConsoleTail(id, () => cancelled);
193
+ if (cancelled || !seeded) return;
194
+ cleanup = stream(
195
+ id,
196
+ (event: LogEvent) => {
197
+ if (logConsole) logConsole.onStream(event);
198
+ },
199
+ { fromLine: seeded.fromLine },
200
+ );
201
+ })();
162
202
  });
163
203
 
164
204
  return () => {
205
+ cancelled = true;
165
206
  if (cleanup) cleanup();
166
207
  };
167
208
  });
@@ -288,7 +329,7 @@
288
329
  run.status === "running" ? durationTicker.now.getTime() : undefined,
289
330
  )}
290
331
  {@const startDelay = runStartDelay(run)}
291
- {@const startedAt = run.startAt ?? run.createdAt}
332
+ {@const startedAt = run.startedAt ?? run.createdAt}
292
333
  {@const retry = runRetryLabel(run)}
293
334
  {@const paramEntries = run.params ? Object.entries(run.params) : []}
294
335
  {@const suffix = instanceSuffix(run.instanceIndex, getInstanceCount(run.taskName))}
@@ -403,7 +444,7 @@
403
444
  repeat it. The line wraps at narrow widths instead of
404
445
  dropping the date and hiding it behind a hover. -->
405
446
  <span data-testid="run-started" class="text-on-surface-muted"
406
- >{run.startAt ? formatClockTime(run.startAt) : "—"}</span
447
+ >{run.startedAt ? formatClockTime(run.startedAt) : "—"}</span
407
448
  >
408
449
  <span class="text-outline-hover" aria-hidden="true">·</span>
409
450
  <span>{formatCalendarDate(startedAt)}</span>
@@ -744,6 +785,7 @@
744
785
  {highlightLine}
745
786
  {endLabel}
746
787
  {endTone}
788
+ error={logFetchError}
747
789
  />
748
790
  {/key}
749
791
  </div>
@@ -40,6 +40,7 @@
40
40
  dayStartIso,
41
41
  dayEndIso,
42
42
  isExitCodeExprValid,
43
+ exitCodePropChanged,
43
44
  type RunsListFilters,
44
45
  } from "./run-filters.js";
45
46
 
@@ -122,9 +123,20 @@
122
123
 
123
124
  // Exit code: a free-form expression (`137`, `>100`, `>100 <150`) normalized
124
125
  // to an inclusive range at the wire. Edited in a local buffer for smooth
125
- // typing and committed on change; the popover content remounts on each open,
126
- // so the buffer re-seeds from `filters` every time it opens.
126
+ // typing and committed on change. `RunFilterPopover` itself is long-lived
127
+ // (its trigger button stays mounted while the popover is closed), so the
128
+ // buffer does NOT re-seed on its own — it must explicitly resync whenever
129
+ // `filters.exitCode` changes from outside (a chip removal, "clear
130
+ // filters"), without clobbering in-progress typing when some unrelated
131
+ // filter dimension changes instead.
127
132
  let exitCodeInput = $state(filters.exitCode ?? "");
133
+ let lastExitCode = filters.exitCode;
134
+ $effect(() => {
135
+ if (exitCodePropChanged(lastExitCode, filters.exitCode)) {
136
+ exitCodeInput = filters.exitCode ?? "";
137
+ }
138
+ lastExitCode = filters.exitCode;
139
+ });
128
140
  const exitCodeValid = $derived(isExitCodeExprValid(exitCodeInput));
129
141
 
130
142
  function commitExitCode() {
@@ -626,7 +626,7 @@
626
626
  checked={isRowSelected(run.id)}
627
627
  onchange={() => toggleRow(run.id)}
628
628
  onclick={(e) => e.stopPropagation()}
629
- aria-label={`Select run from ${formatDateTime(run.startAt ?? run.createdAt)}`}
629
+ aria-label={`Select run from ${formatDateTime(run.startedAt ?? run.createdAt)}`}
630
630
  class="size-3.5 cursor-pointer rounded border-outline accent-primary opacity-0 {selectionActive
631
631
  ? 'opacity-100'
632
632
  : 'group-hover/row:opacity-100'}"
@@ -639,7 +639,7 @@
639
639
  {@const config = getRunStatusConfig(dstatus)}
640
640
  {@const running = run.status === "running"}
641
641
  {@const spine = config.dot.replace(" animate-pulse", "")}
642
- {@const startedAt = run.startAt ?? run.createdAt}
642
+ {@const startedAt = run.startedAt ?? run.createdAt}
643
643
  {@const retry = runRetryLabel(run)}
644
644
  {@const suffix = instanceSuffix(run.instanceIndex, getInstanceCount(run.taskName))}
645
645
  <button
@@ -28,6 +28,7 @@ import {
28
28
  exitCodeRangeActive,
29
29
  isExitCodeExprValid,
30
30
  exitCodeChipLabel,
31
+ exitCodePropChanged,
31
32
  type StatusBucket,
32
33
  type RunsListFilters,
33
34
  } from "./run-filters.js";
@@ -67,7 +68,7 @@ describe("isNeedsAttention", () => {
67
68
 
68
69
  it("is false for a strict subset or a different set", () => {
69
70
  expect(isNeedsAttention(["failed"])).toBe(false);
70
- expect(isNeedsAttention(["success"])).toBe(false);
71
+ expect(isNeedsAttention(["succeeded"])).toBe(false);
71
72
  expect(isNeedsAttention([])).toBe(false);
72
73
  });
73
74
  });
@@ -158,7 +159,7 @@ describe("statusChipLabel", () => {
158
159
  // The Failed bucket is the needs-attention set.
159
160
  expect(statusChipLabel([...NEEDS_ATTENTION_STATUSES])).toBe("Failed");
160
161
  expect(statusChipLabel(["pending", "running"])).toBe("Running");
161
- expect(statusChipLabel(["success"])).toBe("Succeeded");
162
+ expect(statusChipLabel(["succeeded"])).toBe("Succeeded");
162
163
  });
163
164
 
164
165
  it("humanizes a single status that is not a whole bucket", () => {
@@ -200,9 +201,9 @@ describe("bucketState / toggleBucket", () => {
200
201
  });
201
202
 
202
203
  it("clears a fully-selected bucket without touching other statuses", () => {
203
- const f = base({ statuses: [...failed.statuses, "success"] });
204
+ const f = base({ statuses: [...failed.statuses, "succeeded"] });
204
205
  const cleared = toggleBucket(f, failed);
205
- expect(cleared.statuses).toEqual(["success"]);
206
+ expect(cleared.statuses).toEqual(["succeeded"]);
206
207
  });
207
208
  });
208
209
 
@@ -295,3 +296,19 @@ describe("exit-code expression", () => {
295
296
  expect(exitCodeChipLabel(" 137 ")).toBe("Exit 137");
296
297
  });
297
298
  });
299
+
300
+ describe("exitCodePropChanged", () => {
301
+ it("is false when the committed value is unchanged (unrelated filters update)", () => {
302
+ expect(exitCodePropChanged("137", "137")).toBe(false);
303
+ expect(exitCodePropChanged(undefined, undefined)).toBe(false);
304
+ });
305
+
306
+ it("is true when the exit-code filter was cleared externally (chip removed)", () => {
307
+ expect(exitCodePropChanged("137", undefined)).toBe(true);
308
+ });
309
+
310
+ it("is true when the exit-code filter was set to a new committed value", () => {
311
+ expect(exitCodePropChanged(undefined, "200")).toBe(true);
312
+ expect(exitCodePropChanged("137", "200")).toBe(true);
313
+ });
314
+ });
@@ -77,7 +77,7 @@ export interface StatusBucket {
77
77
 
78
78
  export const STATUS_BUCKETS: readonly StatusBucket[] = [
79
79
  { key: "running", label: "Running", dot: "bg-info-surface", statuses: ["pending", "running"] },
80
- { key: "succeeded", label: "Succeeded", dot: "bg-success-surface", statuses: ["success"] },
80
+ { key: "succeeded", label: "Succeeded", dot: "bg-success-surface", statuses: ["succeeded"] },
81
81
  {
82
82
  key: "failed",
83
83
  label: "Failed",
@@ -382,3 +382,18 @@ export function isExitCodeExprValid(expr: string): boolean {
382
382
  export function exitCodeChipLabel(expr: string | undefined): string {
383
383
  return `Exit ${(expr ?? "").trim()}`;
384
384
  }
385
+
386
+ /**
387
+ * Whether the popover's exit-code text buffer must resync to the `filters`
388
+ * prop. True only when the committed `exitCode` value actually moved since
389
+ * the buffer last matched it — e.g. the "Exit code" chip was removed, or
390
+ * "clear filters" ran elsewhere. An unrelated filters update (any other
391
+ * dimension) leaves `exitCode` untouched and must not clobber in-progress
392
+ * typing in the still-open popover.
393
+ */
394
+ export function exitCodePropChanged(
395
+ previous: string | undefined,
396
+ next: string | undefined,
397
+ ): boolean {
398
+ return previous !== next;
399
+ }
@@ -20,7 +20,7 @@ describe("runVerdict", () => {
20
20
  });
21
21
 
22
22
  it("phrases a timed outcome so a duration reads after it", () => {
23
- expect(runVerdict("success")).toEqual({ verb: "succeeded in", timed: true });
23
+ expect(runVerdict("succeeded")).toEqual({ verb: "succeeded in", timed: true });
24
24
  expect(runVerdict("failed")).toEqual({ verb: "failed after", timed: true });
25
25
  });
26
26
 
@@ -36,20 +36,20 @@ describe("runVerdict", () => {
36
36
  });
37
37
 
38
38
  describe("runDuration", () => {
39
- it("returns undefined when startAt is not set", () => {
39
+ it("returns undefined when startedAt is not set", () => {
40
40
  expect(runDuration({})).toBeUndefined();
41
41
  });
42
42
 
43
- it("returns formatted duration when both startAt and endAt are set", () => {
43
+ it("returns formatted duration when both startedAt and endedAt are set", () => {
44
44
  const start = "2024-06-15T12:00:00.000Z";
45
45
  const end = "2024-06-15T12:00:05.000Z";
46
- expect(runDuration({ startAt: start, endAt: end })).toBe("5s");
46
+ expect(runDuration({ startedAt: start, endedAt: end })).toBe("5s");
47
47
  });
48
48
 
49
49
  it("returns ms duration for sub-second runs", () => {
50
50
  const start = "2024-06-15T12:00:00.000Z";
51
51
  const end = "2024-06-15T12:00:00.500Z";
52
- expect(runDuration({ startAt: start, endAt: end })).toBe("500ms");
52
+ expect(runDuration({ startedAt: start, endedAt: end })).toBe("500ms");
53
53
  });
54
54
 
55
55
  describe("with fake clock", () => {
@@ -61,27 +61,27 @@ describe("runDuration", () => {
61
61
  vi.useRealTimers();
62
62
  });
63
63
 
64
- it("uses current time when endAt is not set (run still in progress)", () => {
65
- expect(runDuration({ startAt: "2024-06-15T12:00:00.000Z" })).toBe("2s");
64
+ it("uses current time when endedAt is not set (run still in progress)", () => {
65
+ expect(runDuration({ startedAt: "2024-06-15T12:00:00.000Z" })).toBe("2s");
66
66
  });
67
67
  });
68
68
 
69
69
  it("counts against an injected now for an in-progress run", () => {
70
70
  const start = "2024-06-15T12:00:00.000Z";
71
71
  const now = new Date("2024-06-15T12:00:07.000Z").getTime();
72
- expect(runDuration({ startAt: start }, now)).toBe("7s");
72
+ expect(runDuration({ startedAt: start }, now)).toBe("7s");
73
73
  });
74
74
 
75
75
  it("ignores the injected now once the run has ended", () => {
76
76
  const start = "2024-06-15T12:00:00.000Z";
77
77
  const end = "2024-06-15T12:00:05.000Z";
78
78
  const now = new Date("2024-06-15T12:01:00.000Z").getTime();
79
- expect(runDuration({ startAt: start, endAt: end }, now)).toBe("5s");
79
+ expect(runDuration({ startedAt: start, endedAt: end }, now)).toBe("5s");
80
80
  });
81
81
  });
82
82
 
83
83
  describe("runStartDelay", () => {
84
- it("returns undefined when startAt is not set", () => {
84
+ it("returns undefined when startedAt is not set", () => {
85
85
  expect(runStartDelay({ createdAt: "2024-06-15T12:00:00.000Z" })).toBeUndefined();
86
86
  });
87
87
 
@@ -89,7 +89,7 @@ describe("runStartDelay", () => {
89
89
  expect(
90
90
  runStartDelay({
91
91
  createdAt: "2024-06-15T12:00:00.000Z",
92
- startAt: "2024-06-15T12:00:00.300Z",
92
+ startedAt: "2024-06-15T12:00:00.300Z",
93
93
  }),
94
94
  ).toBeUndefined();
95
95
  });
@@ -98,7 +98,7 @@ describe("runStartDelay", () => {
98
98
  expect(
99
99
  runStartDelay({
100
100
  createdAt: "2024-06-15T03:00:00.000Z",
101
- startAt: "2024-06-15T03:07:12.000Z",
101
+ startedAt: "2024-06-15T03:07:12.000Z",
102
102
  }),
103
103
  ).toBe("7m 12s");
104
104
  });
@@ -21,7 +21,7 @@ export interface RunVerdict {
21
21
  * separate duration readout for the same glance.
22
22
  */
23
23
  const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
24
- success: { verb: "succeeded in", timed: true },
24
+ succeeded: { verb: "succeeded in", timed: true },
25
25
  failed: { verb: "failed after", timed: true },
26
26
  crashed: { verb: "crashed after", timed: true },
27
27
  timeout: { verb: "timed out after", timed: true },
@@ -43,26 +43,26 @@ export function runVerdict(status: RunStatus): RunVerdict {
43
43
  }
44
44
 
45
45
  export function runDuration(
46
- run: Pick<Run, "startAt" | "endAt">,
46
+ run: Pick<Run, "startedAt" | "endedAt">,
47
47
  now: number = Date.now(),
48
48
  ): string | undefined {
49
- if (!run.startAt) return undefined;
50
- const start = new Date(run.startAt).getTime();
51
- const end = run.endAt ? new Date(run.endAt).getTime() : now;
49
+ if (!run.startedAt) return undefined;
50
+ const start = new Date(run.startedAt).getTime();
51
+ const end = run.endedAt ? new Date(run.endedAt).getTime() : now;
52
52
  return formatDuration(end - start);
53
53
  }
54
54
 
55
55
  /**
56
56
  * Gap between when a run was scheduled (`createdAt`, the cron tick) and when
57
- * it actually started (`startAt`), formatted — or undefined when the two are
57
+ * it actually started (`startedAt`), formatted — or undefined when the two are
58
58
  * within a second of each other. This is the visible face of `jitter`: a
59
59
  * jittered run is created at its tick but starts later inside the window, and
60
60
  * this is by how much. It also surfaces queue-wait, since a queued run is
61
61
  * created when it joins the line and started when the line clears.
62
62
  */
63
- export function runStartDelay(run: Pick<Run, "createdAt" | "startAt">): string | undefined {
64
- if (!run.startAt) return undefined;
65
- const delay = new Date(run.startAt).getTime() - new Date(run.createdAt).getTime();
63
+ export function runStartDelay(run: Pick<Run, "createdAt" | "startedAt">): string | undefined {
64
+ if (!run.startedAt) return undefined;
65
+ const delay = new Date(run.startedAt).getTime() - new Date(run.createdAt).getTime();
66
66
  if (delay < 1000) return undefined;
67
67
  return formatDuration(delay);
68
68
  }
@@ -106,7 +106,7 @@ export function runRetryLabel(run: Pick<Run, "retryAttempt" | "retryOfRunId">):
106
106
  /**
107
107
  * Monotonic position of a run's status in its lifecycle. Used to reject stale
108
108
  * updates (e.g. a `pending` HTTP response arriving after an SSE already
109
- * advanced the row to `success`). Higher = further along.
109
+ * advanced the row to `succeeded`). Higher = further along.
110
110
  */
111
111
  export function runPhaseOrder(status: string): number {
112
112
  if (status === "pending") return 0;
@@ -38,7 +38,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
38
38
  badge: "bg-info-soft text-info-soft-text",
39
39
  description: "This run is executing right now.",
40
40
  },
41
- success: {
41
+ succeeded: {
42
42
  icon: CircleCheck,
43
43
  color: "text-success-surface",
44
44
  bg: "bg-success-soft",
@@ -111,7 +111,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
111
111
  border: "border-warning-soft",
112
112
  dot: "bg-warning-surface",
113
113
  badge: "bg-warning-soft text-warning-soft-text",
114
- description: "Skipped because the task's queue was already at queue_max.",
114
+ description: "Skipped because the task's queue was already at max_queued.",
115
115
  },
116
116
  dst_skipped: {
117
117
  icon: SkipForward,
@@ -0,0 +1,68 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, expect, it } from "vitest";
5
+ import { selectionState } from "./data-grid-selection.js";
6
+
7
+ interface Row {
8
+ id: string;
9
+ }
10
+
11
+ const rows = (...ids: string[]): Row[] => ids.map((id) => ({ id }));
12
+
13
+ describe("selectionState", () => {
14
+ it("is unchecked when nothing is selected", () => {
15
+ expect(selectionState(rows("a", "b", "c"), [], "id")).toEqual({
16
+ allSelected: false,
17
+ someSelected: false,
18
+ });
19
+ });
20
+
21
+ it("is checked when every paged row is selected", () => {
22
+ const paged = rows("a", "b", "c");
23
+ expect(selectionState(paged, paged, "id")).toEqual({
24
+ allSelected: true,
25
+ someSelected: false,
26
+ });
27
+ });
28
+
29
+ it("is indeterminate when some but not all paged rows are selected", () => {
30
+ const paged = rows("a", "b", "c");
31
+ expect(selectionState(paged, rows("b"), "id")).toEqual({
32
+ allSelected: false,
33
+ someSelected: true,
34
+ });
35
+ });
36
+
37
+ it("is NOT checked when selectedRows matches the paged count but is a different set of rows", () => {
38
+ // Regression: page 1 selected [a, b, c]; the grid then paged/filtered
39
+ // to a different page showing [d, e, f]. A count comparison
40
+ // (selectedRows.length === pagedRows.length) would wrongly report
41
+ // "all selected" here even though none of the visible rows are
42
+ // members of the selection.
43
+ const paged = rows("d", "e", "f");
44
+ const selected = rows("a", "b", "c");
45
+ expect(selectionState(paged, selected, "id")).toEqual({
46
+ allSelected: false,
47
+ someSelected: false,
48
+ });
49
+ });
50
+
51
+ it("is indeterminate, not checked, when the overlap coincidentally matches the paged count minus one", () => {
52
+ const paged = rows("a", "b", "c");
53
+ // Carried-over selection from another page plus one row that is
54
+ // actually on this page.
55
+ const selected = rows("x", "y", "b");
56
+ expect(selectionState(paged, selected, "id")).toEqual({
57
+ allSelected: false,
58
+ someSelected: true,
59
+ });
60
+ });
61
+
62
+ it("is unchecked when there are no paged rows, regardless of selection", () => {
63
+ expect(selectionState([], rows("a"), "id")).toEqual({
64
+ allSelected: false,
65
+ someSelected: false,
66
+ });
67
+ });
68
+ });
@@ -0,0 +1,34 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // DataGrid's header checkbox (checked / indeterminate / unchecked) must
5
+ // reflect which rows on the current page are ACTUALLY selected, not merely
6
+ // how many rows are selected. A bare count comparison is wrong the moment
7
+ // `selectedRows` holds a different set of rows than `pagedRows` but happens
8
+ // to be the same size — e.g. rows selected on a page the grid has since
9
+ // paged/filtered away from. Extracted so the membership check is unit
10
+ // testable without a component-render harness.
11
+
12
+ export interface SelectionState {
13
+ allSelected: boolean;
14
+ someSelected: boolean;
15
+ }
16
+
17
+ /**
18
+ * Header checkbox state for `pagedRows`: fully checked only when every row
19
+ * currently on the page is selected, indeterminate when some (but not all)
20
+ * of them are. Matches rows by `rowKey`, not by array position or count.
21
+ */
22
+ export function selectionState<T>(
23
+ pagedRows: readonly T[],
24
+ selectedRows: readonly T[],
25
+ rowKey: keyof T,
26
+ ): SelectionState {
27
+ if (pagedRows.length === 0) return { allSelected: false, someSelected: false };
28
+ const selectedKeys = new Set(selectedRows.map((row) => row[rowKey]));
29
+ const selectedOnPage = pagedRows.filter((row) => selectedKeys.has(row[rowKey])).length;
30
+ return {
31
+ allSelected: selectedOnPage === pagedRows.length,
32
+ someSelected: selectedOnPage > 0 && selectedOnPage < pagedRows.length,
33
+ };
34
+ }
@@ -35,9 +35,7 @@
35
35
  --rw-font-sans: "TASA Orbiter", system-ui, -apple-system, sans-serif;
36
36
  --rw-font-mono:
37
37
  "Geist Mono Variable", "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
38
- }
39
38
 
40
- :root {
41
39
  /* Primary — Wisp Teal (brand = #0E7C86, the artifact's deep teal). Deeper
42
40
  * and less cyan than Aurora (running/info) so the two stay distinct. */
43
41
  --rw-color-wisp-50: oklch(0.97 0.02 205);
@@ -1,35 +0,0 @@
1
- <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
- <!-- SPDX-License-Identifier: Apache-2.0 -->
3
-
4
- <script lang="ts">
5
- import type { Component, Snippet } from "svelte";
6
-
7
- interface Props {
8
- title: string;
9
- icon?: Component;
10
- children?: Snippet;
11
- class?: string;
12
- }
13
-
14
- let { title, icon: Icon, children, class: className = "" }: Props = $props();
15
- </script>
16
-
17
- <article
18
- class="overflow-hidden rounded-[4px] border border-outline bg-surface-raised p-6 hover:border-outline-hover {className}"
19
- >
20
- <div class="flex flex-col gap-3">
21
- {#if Icon}
22
- <span
23
- class="inline-flex h-10 w-10 items-center justify-center rounded-[3px] border border-primary-soft-border bg-primary-soft text-primary-soft-text"
24
- >
25
- <Icon size={20} />
26
- </span>
27
- {/if}
28
- <h3 class="font-mono text-base text-primary">{title}</h3>
29
- {#if children}
30
- <div class="font-sans text-sm leading-relaxed text-on-surface-muted">
31
- {@render children()}
32
- </div>
33
- {/if}
34
- </div>
35
- </article>
@@ -1,37 +0,0 @@
1
- <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
- <!-- SPDX-License-Identifier: Apache-2.0 -->
3
-
4
- <script lang="ts">
5
- import type { Snippet } from "svelte";
6
-
7
- interface Props {
8
- columns?: 1 | 2 | 3 | 4;
9
- gap?: "sm" | "md" | "lg";
10
- children?: Snippet;
11
- class?: string;
12
- }
13
-
14
- let { columns = 3, gap = "md", children, class: className = "" }: Props = $props();
15
-
16
- const colClasses: Record<1 | 2 | 3 | 4, string> = {
17
- 1: "grid-cols-1",
18
- 2: "grid-cols-1 sm:grid-cols-2",
19
- 3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
20
- 4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
21
- };
22
-
23
- const gapClasses: Record<"sm" | "md" | "lg", string> = {
24
- sm: "gap-3",
25
- md: "gap-4",
26
- lg: "gap-6",
27
- };
28
-
29
- const colClass = $derived(colClasses[columns]);
30
- const gapClass = $derived(gapClasses[gap]);
31
- </script>
32
-
33
- <div class="grid {colClass} {gapClass} {className}">
34
- {#if children}
35
- {@render children()}
36
- {/if}
37
- </div>