diff --git a/web/.storybook/WorkflowRunReplay.svelte b/web/.storybook/WorkflowRunReplay.svelte
new file mode 100644
index 000000000..52fbd2941
--- /dev/null
+++ b/web/.storybook/WorkflowRunReplay.svelte
@@ -0,0 +1,60 @@
+
+
+
+ {@render children({ steps, status })}
+
+ {status}
+
+
+
diff --git a/web/eslint.config.js b/web/eslint.config.js
index c13cd4782..cd9022542 100644
--- a/web/eslint.config.js
+++ b/web/eslint.config.js
@@ -37,7 +37,8 @@ export default defineConfig(
projectService: {
allowDefaultProject: [
".storybook/MockAuth.svelte",
- ".storybook/BoneCaptureDecorator.svelte"
+ ".storybook/BoneCaptureDecorator.svelte",
+ ".storybook/WorkflowRunReplay.svelte"
]
},
extraFileExtensions: [".svelte"],
diff --git a/web/src/fixtures/pipelines.ts b/web/src/fixtures/pipelines.ts
index 4dbd7c036..73ede6062 100644
--- a/web/src/fixtures/pipelines.ts
+++ b/web/src/fixtures/pipelines.ts
@@ -1,7 +1,7 @@
// fixture pipelines for the pipeline and commit-list stories. the app reads real
// runs from a spindle, so nothing here ever reaches the bundle
-import type { PipelineSummary } from "$lib/components/repo/types";
+import type { PipelineStatus, PipelineSummary } from "$lib/components/repo/types";
import type { LogControl, LogFrame } from "$lib/api/spindleLogs";
import { createLogAccumulator, type LogStep } from "$lib/components/repo/pipelines/logs";
@@ -178,6 +178,53 @@ export const runningWorkflowLog: LogStep[] = drain([
output(1, "\u001b[32m✓\u001b[0m src/lib/format.test.ts (12)\n")
]);
+/** a run that died on its last step: every step closed, and nothing after it */
+export const failedWorkflowLog: LogStep[] = drain([
+ control(-1, "microVM setup", { kind: "system" }),
+ output(-1, "starting microVM image alpine\n"),
+ finish(-1, 3),
+ control(0, "Clone repository into workspace", {
+ kind: "system",
+ command: "git init\ngit fetch --depth=1 origin 9f2c1ab\ngit checkout FETCH_HEAD"
+ }),
+ output(0, "HEAD is now at 9f2c1ab\n"),
+ finish(0, 9),
+ control(1, "install dependencies", { command: "pnpm install --frozen-lockfile" }),
+ output(1, "\u001b[90m+\u001b[0m package-a \u001b[32m1.0.0\u001b[0m\n"),
+ output(1, "\u001b[90m+\u001b[0m package-b \u001b[32m2.1.0\u001b[0m\n"),
+ output(1, "\u001b[90m+\u001b[0m package-c \u001b[32m0.4.2\u001b[0m\n"),
+ finish(1, 22),
+ control(2, "run tests", { command: "pnpm test" }),
+ output(2, "\u001b[1mRUN\u001b[0m v4.1.10\n"),
+ output(2, "\u001b[32m✓\u001b[0m src/lib/format.test.ts (12)\n"),
+ output(2, "\u001b[31m✗\u001b[0m src/lib/api/spindleLogs.test.ts (1 failed)\n"),
+ output(2, "\u001b[31mAssertionError: expected 3 to be 4\u001b[0m\n"),
+ output(
+ 2,
+ "\u001b[2mTests\u001b[0m \u001b[31m1 failed\u001b[0m | \u001b[32m11 passed\u001b[0m (12)\n"
+ ),
+ finish(2, 58)
+]);
+
+/** killed mid-step: the last step never got its end frame */
+export const cancelledWorkflowLog: LogStep[] = drain([
+ control(-1, "microVM setup", { kind: "system" }),
+ output(-1, "starting microVM image alpine\n"),
+ finish(-1, 3),
+ control(0, "Clone repository into workspace", {
+ kind: "system",
+ command: "git init\ngit fetch --depth=1 origin 9f2c1ab\ngit checkout FETCH_HEAD"
+ }),
+ output(0, "HEAD is now at 9f2c1ab\n"),
+ finish(0, 9),
+ control(1, "install dependencies", { command: "pnpm install --frozen-lockfile" }),
+ output(1, "\u001b[90m+\u001b[0m package-a \u001b[32m1.0.0\u001b[0m\n"),
+ finish(1, 22),
+ control(2, "build the site", { command: "pnpm build" }),
+ output(2, "[0] \u001b[36mbundle\u001b[0m src/module-0.ts\n"),
+ output(2, "[1] \u001b[36mbundle\u001b[0m src/module-1.ts\n")
+]);
+
const tailFrames: LogFrame[] = [
control(-1, "microVM setup", { kind: "system" }),
output(-1, "starting microVM image alpine\n"),
@@ -236,3 +283,156 @@ export const longWorkflowLog: LogStep[] = drain([
output(1, `[${index}] \u001b[36mCC\u001b[0m src/module-${index}.c\n`)
)
]);
+
+// a whole run, scripted step by step, for the stories that watch the rail fill in
+const runScript = [
+ {
+ name: "microVM setup",
+ system: true,
+ seconds: 3,
+ lines: ["starting microVM image alpine\n", "agent connected\n"]
+ },
+ {
+ name: "Clone repository into workspace",
+ system: true,
+ command: "git init\ngit fetch --depth=1 origin 9f2c1ab\ngit checkout FETCH_HEAD",
+ seconds: 9,
+ lines: ["warning: templates not found\n", "HEAD is now at 9f2c1ab\n"]
+ },
+ {
+ name: "install dependencies",
+ command: "pnpm install --frozen-lockfile",
+ seconds: 41,
+ lines: [
+ "\u001b[90m+\u001b[0m package-a \u001b[32m1.0.0\u001b[0m\n",
+ "\u001b[90m+\u001b[0m package-b \u001b[32m2.1.0\u001b[0m\n"
+ ]
+ },
+ {
+ name: "run tests",
+ command: "pnpm test",
+ seconds: 96,
+ lines: [
+ "\u001b[1mRUN\u001b[0m v4.1.10\n",
+ "\u001b[32m✓\u001b[0m src/lib/format.test.ts (12)\n"
+ ]
+ },
+ {
+ name: "build the site",
+ command: "pnpm build",
+ seconds: 58,
+ lines: ["[0] \u001b[36mbundle\u001b[0m src/module-0.ts\n", "wrote dist/index.html\n"]
+ }
+];
+
+const FAILURE_LINE = "\u001b[31merror: step failed with exit code 1\u001b[0m\n";
+
+type RunAction =
+ | { kind: "start"; step: number; content: string; command?: string; system?: boolean }
+ | { kind: "line"; step: number; content: string }
+ | { kind: "end"; step: number; seconds: number };
+
+export interface WorkflowRun {
+ /** pushes the next frame and hands back the log as the view sees it */
+ advance: () => LogStep[];
+ exhausted: () => boolean;
+ /** running until the last frame lands, then whatever the run came to */
+ status: () => PipelineStatus;
+}
+
+/**
+ * replays a workflow frame by frame, the way a live tail arrives. `failAt` is the
+ * index of the step that dies — the spindle closes it and starts nothing after it —
+ * and `cancelAt` is a step killed mid-flight, so it never gets its end frame.
+ */
+export const createWorkflowRun = (
+ options: { failAt?: number; cancelAt?: number; steps?: number } = {}
+): WorkflowRun => {
+ const { failAt, cancelAt, steps = runScript.length } = options;
+ const actions: RunAction[] = [];
+ for (const [index, step] of runScript.slice(0, steps).entries()) {
+ // the runner's own setup step is numbered below the workflow's first
+ const id = index - 1;
+ actions.push({
+ kind: "start",
+ step: id,
+ content: step.name,
+ command: step.command,
+ system: step.system
+ });
+ for (const line of step.lines) actions.push({ kind: "line", step: id, content: line });
+ if (index === cancelAt) break;
+ if (index === failAt) {
+ actions.push({ kind: "line", step: id, content: FAILURE_LINE });
+ actions.push({ kind: "end", step: id, seconds: step.seconds });
+ break;
+ }
+ actions.push({ kind: "end", step: id, seconds: step.seconds });
+ }
+
+ const accumulator = createLogAccumulator();
+ const startedAt = new Map();
+ const at = (ms: number) => new Date(ms).toISOString();
+ let cursor = 0;
+
+ const push = (action: RunAction) => {
+ if (action.kind === "line") {
+ accumulator.push({
+ kind: "data",
+ data: {
+ time: at(Date.now()),
+ workflow: "build",
+ step: action.step,
+ content: action.content,
+ stream: "stdout"
+ }
+ });
+ return;
+ }
+ if (action.kind === "start") {
+ startedAt.set(action.step, Date.now());
+ accumulator.push({
+ kind: "control",
+ control: {
+ time: at(Date.now()),
+ workflow: "build",
+ step: action.step,
+ content: action.content,
+ command: action.command,
+ kind: action.system ? "system" : "user",
+ status: "start"
+ }
+ });
+ return;
+ }
+ // the scripted duration is what the rail reads out, so the end stamp is measured
+ // off the step's own start and not off the replay's wall clock
+ accumulator.push({
+ kind: "control",
+ control: {
+ time: at((startedAt.get(action.step) ?? Date.now()) + action.seconds * 1000),
+ workflow: "build",
+ step: action.step,
+ content: "",
+ status: "end"
+ }
+ });
+ };
+
+ return {
+ advance() {
+ const action = actions[cursor];
+ if (action) {
+ cursor += 1;
+ push(action);
+ }
+ return [...accumulator.steps];
+ },
+ exhausted: () => cursor >= actions.length,
+ status: () => {
+ if (cursor < actions.length) return "running";
+ if (cancelAt !== undefined) return "cancelled";
+ return failAt !== undefined ? "failed" : "success";
+ }
+ };
+};
diff --git a/web/src/lib/components/repo/pipelines/Elapsed.stories.svelte b/web/src/lib/components/repo/pipelines/Elapsed.stories.svelte
new file mode 100644
index 000000000..4a9451eca
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/Elapsed.stories.svelte
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/components/repo/pipelines/Elapsed.svelte b/web/src/lib/components/repo/pipelines/Elapsed.svelte
index 42204187a..e55d08d3f 100644
--- a/web/src/lib/components/repo/pipelines/Elapsed.svelte
+++ b/web/src/lib/components/repo/pipelines/Elapsed.svelte
@@ -7,22 +7,28 @@
start?: number;
/** epoch ms; while absent the timer keeps counting */
end?: number;
+ /** off once the step can no longer finish; a step that never reported an end then shows nothing, because nobody knows when it died */
+ running?: boolean;
class?: ClassValue | null;
}
- let { start, end, class: className }: Props = $props();
+ let { start, end, running = true, class: className }: Props = $props();
let now = $state(Date.now());
$effect(() => {
- if (end !== undefined || start === undefined) return;
+ if (!running || end !== undefined || start === undefined) return;
const tick = setInterval(() => (now = Date.now()), 1000);
return () => clearInterval(tick);
});
- const elapsed = $derived(
- start === undefined || Number.isNaN(start) ? null : Math.max(0, (end ?? now) - start)
- );
+ const elapsed = $derived.by(() => {
+ if (start === undefined || Number.isNaN(start)) return null;
+ // counting from a start whose end never came measures how long ago the step
+ // began, which grows forever and is not a duration
+ if (end === undefined && !running) return null;
+ return Math.max(0, (end ?? now) - start);
+ });
{#if elapsed !== null}
diff --git a/web/src/lib/components/repo/pipelines/StepStatusIcon.stories.svelte b/web/src/lib/components/repo/pipelines/StepStatusIcon.stories.svelte
new file mode 100644
index 000000000..7000edc36
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/StepStatusIcon.stories.svelte
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+ {#snippet template()}
+
+
+ {cycling}
+
+ {/snippet}
+
diff --git a/web/src/lib/components/repo/pipelines/StepStatusIcon.svelte b/web/src/lib/components/repo/pipelines/StepStatusIcon.svelte
new file mode 100644
index 000000000..e581386e1
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/StepStatusIcon.svelte
@@ -0,0 +1,116 @@
+
+
+
+
+
+ {#if status === "done"}
+
+
+
+ {:else if status === "error"}
+
+
+
+ {:else if status === "active"}
+
+ {:else}
+
+ {/if}
+
+
+
diff --git a/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte b/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte
index e2ebd527c..ebb53aca4 100644
--- a/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte
+++ b/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte
@@ -1,20 +1,38 @@
@@ -44,6 +62,41 @@
};
};
+ // a flush has to land after the click, so the play function drives it rather than
+ // a timer
+ const drivenLog = (initial: LogStep[]) => {
+ let steps = $state.raw(initial);
+ const last = () => steps[steps.length - 1];
+ return {
+ get steps() {
+ return steps;
+ },
+ flush: (line: string) => {
+ steps = [...steps.slice(0, -1), { ...last(), lines: [...last().lines, line] }];
+ },
+ startStep: (name: string) => {
+ const id = Math.max(...steps.map((step) => step.id)) + 1;
+ steps = [...steps, { id, name, system: false, startedAt: Date.now(), lines: [] }];
+ }
+ };
+ };
+
+ const unfoldedByHand = drivenLog(runningWorkflowLog);
+ const foldedByHand = drivenLog(runningWorkflowLog);
+
+ // the rail drives the log through this, the way WorkflowView wires it
+ let railJump: ((id: number) => void) | undefined;
+
+ const landedOnTop = (element: Element | null) => {
+ if (!(element instanceof HTMLElement)) return false;
+ const scroller = element.closest(".overflow-y-auto");
+ if (!scroller) return false;
+ return (
+ Math.abs(element.getBoundingClientRect().top - scroller.getBoundingClientRect().top) <=
+ 2
+ );
+ };
+
const live = feed();
const replayed = feed();
// the app flushes once a frame, so a busy step lands several lines a tick
@@ -52,11 +105,17 @@
-
+
-
+
-
+
@@ -66,21 +125,90 @@
you scroll or select, then hands the viewport over and offers the way back.
the steps go through the template, because args are a snapshot: storybook
reads them once per story and never sees the feed move -->
-
+
{#snippet template(args)}
{/snippet}
-
+
{#snippet template(args)}
{/snippet}
-
+
{#snippet template(args)}
{/snippet}
+
+
+
+
+
+ {
+ const header = () => canvas.getAllByRole("button", { name: /install dependencies/ })[0];
+ await waitFor(() => expect(header()).toHaveAttribute("aria-expanded", "false"));
+ await waitFor(() => expect(railJump).toBeTruthy());
+ railJump?.(1);
+ await waitFor(() =>
+ expect(canvas.getByText("pnpm install --frozen-lockfile")).toBeVisible()
+ );
+ await waitFor(() => {
+ expect(document.activeElement?.textContent).toContain("install dependencies");
+ expect(landedOnTop(document.activeElement)).toBe(true);
+ });
+ }}
+>
+ {#snippet template(args)}
+ (railJump = jump)} />
+ {/snippet}
+
+
+ {
+ const header = () =>
+ canvas.getAllByRole("button", { name: /Clone repository into workspace/ })[0];
+ await waitFor(() => expect(header()).toHaveAttribute("aria-expanded", "false"));
+ await userEvent.click(header());
+ await waitFor(() => expect(canvas.getByText("git init")).toBeVisible());
+ unfoldedByHand.flush("one more line");
+ await waitFor(() => expect(canvas.getByText("one more line")).toBeVisible());
+ await waitFor(() => expect(header()).toHaveAttribute("aria-expanded", "true"));
+ expect(canvas.getByText("git init")).toBeVisible();
+ }}
+>
+ {#snippet template(args)}
+
+ {/snippet}
+
+
+ {
+ const header = () => canvas.getAllByRole("button", { name: /run tests/ })[0];
+ await waitFor(() => expect(header()).toHaveAttribute("aria-expanded", "true"));
+ await userEvent.click(header());
+ await waitFor(() => expect(header()).toHaveAttribute("aria-expanded", "false"));
+ foldedByHand.startStep("build the site");
+ await waitFor(() =>
+ expect(canvas.getByRole("button", { name: /build the site/ })).toBeVisible()
+ );
+ await waitFor(() => expect(header()).toHaveAttribute("aria-expanded", "false"));
+ expect(canvas.queryByText(/format.test.ts/)).toBeNull();
+ }}
+>
+ {#snippet template(args)}
+
+ {/snippet}
+
diff --git a/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte b/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte
index 3933b4f08..53ad1ace5 100644
--- a/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte
+++ b/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte
@@ -9,7 +9,7 @@
row: "absolute top-0 left-0 flex w-full",
header: "sticky top-0 z-10 flex w-full cursor-pointer items-center gap-2 rounded-t border-x border-t border-border-default bg-background-default px-3 text-left font-sans typography-paragraph-regular",
chevron: "size-4 shrink-0 text-foreground-muted",
- name: "truncate font-semibold text-foreground-default",
+ name: "truncate text-foreground-default",
elapsed: "ml-auto shrink-0 text-foreground-muted tabular-nums",
body: "w-full min-w-0 border-x border-border-default bg-background-default px-3",
command: "break-all whitespace-pre-wrap text-foreground-info",
@@ -22,10 +22,20 @@
collapsed: { true: { header: "rounded-b border-b" }, false: { header: "border-b" } },
first: { true: { body: "pt-1.5" }, false: {} },
// last row rounds bottom corners
- last: { true: { body: "rounded-b border-b pb-1.5" }, false: {} }
+ last: { true: { body: "rounded-b border-b pb-1.5" }, false: {} },
+ // the chip and the label carry the state on their own. tinting the row
+ // would too: background-danger-subtle is far too loud over the card
+ stepStatus: {
+ pending: { name: "text-foreground-muted" },
+ active: { name: "font-semibold" },
+ done: { name: "text-foreground-default" },
+ error: { name: "font-semibold text-foreground-danger" }
+ }
}
});
+ type HeaderSlots = ReturnType;
+
// initial row heights, measurement corrects wrapped lines after mount
const ROW_HEIGHT: Record = {
step: 44,
@@ -50,8 +60,8 @@
+
+
+
+
+ {#snippet template(args)}
+
+
+ {#snippet activeExtra()}
+
+ {/snippet}
+
+
+ {/snippet}
+
diff --git a/web/src/lib/components/repo/pipelines/WorkflowSidebar.svelte b/web/src/lib/components/repo/pipelines/WorkflowSidebar.svelte
index b578ae66f..b6d738136 100644
--- a/web/src/lib/components/repo/pipelines/WorkflowSidebar.svelte
+++ b/web/src/lib/components/repo/pipelines/WorkflowSidebar.svelte
@@ -1,24 +1,35 @@
+{#snippet cardBody(workflow: WorkflowSummary)}
+
+ {workflow.name}
+
+
+
+
+
+
{STATUS_LABELS[workflow.status]}
+
+ {#if workflow.duration > 0}
+
{formatDuration(workflow.duration)}
+ {/if}
+
+{/snippet}
+
{#each workflows as workflow (workflow.name)}
- {@const slots = classes({ active: workflow.name === active })}
-
-
- {workflow.name}
-
-
-
-
-
-
{STATUS_LABELS[workflow.status]}
-
- {#if workflow.duration > 0}
-
{formatDuration(workflow.duration)}
- {/if}
+ {#if workflow.name === active && activeExtra}
+
-
+ {:else}
+ {@const slots = classes({ active: workflow.name === active })}
+
+ {@render cardBody(workflow)}
+
+ {/if}
{/each}
diff --git a/web/src/lib/components/repo/pipelines/WorkflowSteps.stories.svelte b/web/src/lib/components/repo/pipelines/WorkflowSteps.stories.svelte
new file mode 100644
index 000000000..98b4d41e8
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/WorkflowSteps.stories.svelte
@@ -0,0 +1,163 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ console.log("select step", id)
+ }}
+/>
+
+
+ {
+ await waitFor(() => expect(canvas.getByText("14s")).toBeVisible());
+ }}
+>
+ {#snippet template(args)}
+
+ {/snippet}
+
+
+
+
+ {#snippet template()}
+ createWorkflowRun()}>
+ {#snippet children({ steps, status })}
+
+ {/snippet}
+
+ {/snippet}
+
+
+
+
+ {#snippet template()}
+ createWorkflowRun({ failAt: 3 })}>
+ {#snippet children({ steps, status })}
+
+ {/snippet}
+
+ {/snippet}
+
+
+
+
+ {#snippet template()}
+ createWorkflowRun({ failAt: 0 })}>
+ {#snippet children({ steps, status })}
+
+ {/snippet}
+
+ {/snippet}
+
+
+
+
+ {#snippet template()}
+ createWorkflowRun({ cancelAt: 3 })}>
+ {#snippet children({ steps, status })}
+
+ {/snippet}
+
+ {/snippet}
+
+
+
+
+ {#snippet template()}
+ createWorkflowRun()} from={9}>
+ {#snippet children({ steps, status })}
+
+ {/snippet}
+
+ {/snippet}
+
diff --git a/web/src/lib/components/repo/pipelines/WorkflowSteps.svelte b/web/src/lib/components/repo/pipelines/WorkflowSteps.svelte
new file mode 100644
index 000000000..56d0edb0f
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/WorkflowSteps.svelte
@@ -0,0 +1,142 @@
+
+
+
+
+{#if steps.length > 0}
+
+ {#each rows as row (row.id)}
+ {@const revealed = row.duration !== ""}
+ {@const slots = classes({
+ interactive: Boolean(onselect),
+ status: row.status,
+ revealed
+ })}
+ {#snippet content()}
+
+
+ {#if row.status === "active"}
+ {row.name}
+ {:else}
+ {row.name}
+ {/if}
+
+
+
+ {row.duration}
+
+ {/snippet}
+ -
+ {#if onselect}
+
+ {:else}
+
+ {@render content()}
+
+ {/if}
+
+ {/each}
+
+{/if}
+
+
+{spoken}
diff --git a/web/src/lib/components/repo/pipelines/WorkflowView.stories.svelte b/web/src/lib/components/repo/pipelines/WorkflowView.stories.svelte
index 9a499cf41..b3fed12d6 100644
--- a/web/src/lib/components/repo/pipelines/WorkflowView.stories.svelte
+++ b/web/src/lib/components/repo/pipelines/WorkflowView.stories.svelte
@@ -3,6 +3,9 @@
import type { WorkflowSummary } from "$lib/components/repo/types";
import WorkflowView from "./WorkflowView.svelte";
import {
+ cancelledWorkflowLog,
+ createLogTail,
+ failedWorkflowLog,
longWorkflowLog,
pipelines,
runningWorkflowLog,
@@ -10,6 +13,7 @@
} from "$fixtures/pipelines";
const [running, mixed, passed] = pipelines;
+ const killed = pipelines[5];
// the spindle rejected this one before it could produce output, so the banner
// carries the whole story and the log pane stays empty
@@ -36,8 +40,35 @@
});
+
+
+
-
+
-
-
+
+
+
+
@@ -64,3 +105,19 @@
+
+
+ {#snippet template(args)}
+
+ {/snippet}
+
diff --git a/web/src/lib/components/repo/pipelines/WorkflowView.svelte b/web/src/lib/components/repo/pipelines/WorkflowView.svelte
index 52cf8a6da..715693e5c 100644
--- a/web/src/lib/components/repo/pipelines/WorkflowView.svelte
+++ b/web/src/lib/components/repo/pipelines/WorkflowView.svelte
@@ -3,7 +3,8 @@
import type { PipelineSummary, WorkflowSummary } from "$lib/components/repo/types";
import WorkflowLogs from "./WorkflowLogs.svelte";
import WorkflowSidebar from "./WorkflowSidebar.svelte";
- import { errorDetails, errorMessage, isLive } from "./pipeline";
+ import WorkflowSteps from "./WorkflowSteps.svelte";
+ import { errorDetails, errorMessage } from "./pipeline";
import type { LogStep } from "./logs";
import type { LogStreamStatus } from "./logStream.svelte";
@@ -29,9 +30,21 @@
error = null
}: Props = $props();
- const live = $derived(isLive(workflow.status));
+ // the two levels talk through here: the log says what it is showing, the rail says
+ // where to go
+ let visibleStep = $state(undefined);
+ let scrollToStep = $state<((id: number) => void) | undefined>(undefined);
+{#snippet stepRail()}
+ scrollToStep?.(id)}
+ />
+{/snippet}
+
0 ? stepRail : undefined}
/>
@@ -62,6 +76,13 @@
{/if}
-
+ (scrollToStep = jump)}
+ onvisiblestep={(id) => (visibleStep = id)}
+ />
diff --git a/web/src/lib/components/repo/pipelines/logs.test.ts b/web/src/lib/components/repo/pipelines/logs.test.ts
index b8a175919..87961bf68 100644
--- a/web/src/lib/components/repo/pipelines/logs.test.ts
+++ b/web/src/lib/components/repo/pipelines/logs.test.ts
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import type { LogFrame } from "$lib/api/spindleLogs";
-import { createLogAccumulator, isLastRow, toRows, type LogStep } from "./logs";
+import {
+ createLogAccumulator,
+ isLastRow,
+ narrate,
+ stepStatuses,
+ toRows,
+ type LogStep
+} from "./logs";
const start = (step: number, name: string, over: Record = {}): LogFrame => ({
kind: "control",
@@ -142,3 +149,113 @@ describe("toRows", () => {
expect(isLastRow(rows, rows.length - 1)).toBe(true);
});
});
+
+describe("stepStatuses", () => {
+ it("marks closed steps done and the open last step active on a running workflow", () => {
+ const steps = drain([start(1, "One"), end(1, "2026-08-06T12:00:10Z"), start(2, "Two")]);
+ expect(stepStatuses(steps, "running")).toEqual(["done", "active"]);
+ });
+
+ it("marks the last step an error on a failed workflow, even though it has a finishedAt", () => {
+ const steps = drain([
+ start(1, "One"),
+ end(1, "2026-08-06T12:00:10Z"),
+ start(2, "Two"),
+ end(2, "2026-08-06T12:00:20Z")
+ ]);
+ expect(stepStatuses(steps, "failed")).toEqual(["done", "error"]);
+ });
+
+ it("treats cancelled like failed", () => {
+ const steps = drain([start(1, "One"), start(2, "Two"), end(2, "2026-08-06T12:00:20Z")]);
+ expect(stepStatuses(steps, "cancelled")).toEqual(["done", "error"]);
+ });
+
+ it("treats timeout like failed", () => {
+ const steps = drain([start(1, "One"), start(2, "Two"), end(2, "2026-08-06T12:00:20Z")]);
+ expect(stepStatuses(steps, "timeout")).toEqual(["done", "error"]);
+ });
+
+ it("marks every step done on a successful workflow", () => {
+ const steps = drain([
+ start(1, "One"),
+ end(1, "2026-08-06T12:00:10Z"),
+ start(2, "Two"),
+ end(2, "2026-08-06T12:00:20Z")
+ ]);
+ expect(stepStatuses(steps, "success")).toEqual(["done", "done"]);
+ });
+
+ it("treats an unclosed step that isn't last as a lost end frame, not the active one", () => {
+ const steps = drain([start(1, "One"), start(2, "Two"), start(3, "Three")]);
+ expect(stepStatuses(steps, "running")).toEqual(["done", "done", "active"]);
+ });
+
+ it("returns an empty list when there are no steps", () => {
+ expect(stepStatuses([], "running")).toEqual([]);
+ });
+});
+
+describe("narrate", () => {
+ it("says it's waiting for the spindle when no steps have arrived on a live workflow", () => {
+ expect(narrate([], "pending")).toBe("Waiting for the spindle");
+ });
+
+ it("says nothing when a settled workflow never produced a step", () => {
+ expect(narrate([], "cancelled")).toBe("");
+ });
+
+ it("names the active step and its position", () => {
+ const steps = drain([
+ start(1, "One"),
+ end(1, "2026-08-06T12:00:10Z"),
+ start(2, "Run tests")
+ ]);
+ expect(narrate(steps, "running")).toBe("Run tests, step 2 of 2");
+ });
+
+ it("names the step that failed", () => {
+ const steps = drain([
+ start(1, "One"),
+ end(1, "2026-08-06T12:00:10Z"),
+ start(2, "Run tests"),
+ end(2, "2026-08-06T12:00:20Z")
+ ]);
+ expect(narrate(steps, "failed")).toBe("Failed at Run tests");
+ });
+
+ it("names the step a cancelled run was killed on, not a generic failure", () => {
+ const steps = drain([
+ start(1, "One"),
+ end(1, "2026-08-06T12:00:10Z"),
+ start(2, "build the site"),
+ end(2, "2026-08-06T12:00:20Z")
+ ]);
+ expect(narrate(steps, "cancelled")).toBe("Cancelled at build the site");
+ });
+
+ it("names the step a timed-out run was killed on, not a generic failure", () => {
+ const steps = drain([
+ start(1, "One"),
+ end(1, "2026-08-06T12:00:10Z"),
+ start(2, "build the site"),
+ end(2, "2026-08-06T12:00:20Z")
+ ]);
+ expect(narrate(steps, "timeout")).toBe("Timed out at build the site");
+ });
+
+ it("announces completion once every step is done and the workflow succeeded", () => {
+ const steps = drain([
+ start(1, "One"),
+ end(1, "2026-08-06T12:00:10Z"),
+ start(2, "Two"),
+ end(2, "2026-08-06T12:00:20Z")
+ ]);
+ expect(narrate(steps, "success")).toBe("All 2 steps complete");
+ });
+
+ it("keeps the completion sentence singular for a one-step run", () => {
+ const steps = drain([start(1, "One"), end(1, "2026-08-06T12:00:10Z")]);
+ expect(narrate(steps, "success")).toBe("All 1 step complete");
+ });
+});
diff --git a/web/src/lib/components/repo/pipelines/logs.ts b/web/src/lib/components/repo/pipelines/logs.ts
index 71270b472..6de7143b4 100644
--- a/web/src/lib/components/repo/pipelines/logs.ts
+++ b/web/src/lib/components/repo/pipelines/logs.ts
@@ -2,6 +2,8 @@
import { AnsiUp } from "ansi_up";
import type { LogFrame } from "$lib/api/spindleLogs";
+import type { PipelineStatus } from "$lib/components/repo/types";
+import { isLive, STATUS_LABELS } from "./pipeline";
export interface LogStep {
id: number;
@@ -120,3 +122,43 @@ export const rowKey = (row: LogRow): string => {
return `gap:${row.stepId}`;
}
};
+
+// "pending" never comes out of a live stream — a step only exists once its first frame arrives — it's here for a future plan-driven skeleton
+export type StepStatus = "pending" | "active" | "done" | "error";
+
+// the spindle writes a step's end frame unconditionally, before it checks whether the step errored,
+// and stops starting new steps the moment one does — so the failing step is always the last one present
+export const stepStatuses = (steps: LogStep[], workflow: PipelineStatus): StepStatus[] => {
+ if (steps.length === 0) return [];
+ const last = steps.length - 1;
+
+ if (isLive(workflow)) {
+ return steps.map((step, i) => (step.finishedAt || i !== last ? "done" : "active"));
+ }
+
+ if (workflow === "success") return steps.map(() => "done");
+
+ return steps.map((_, i) => (i === last ? "error" : "done"));
+};
+
+// one sentence for a screen-reader live region
+export const narrate = (steps: LogStep[], workflow: PipelineStatus): string => {
+ if (steps.length === 0) return isLive(workflow) ? "Waiting for the spindle" : "";
+
+ const statuses = stepStatuses(steps, workflow);
+
+ const activeIndex = statuses.indexOf("active");
+ if (activeIndex !== -1) {
+ return `${steps[activeIndex].name}, step ${activeIndex + 1} of ${steps.length}`;
+ }
+
+ const errorIndex = statuses.indexOf("error");
+ if (errorIndex !== -1) return `${STATUS_LABELS[workflow]} at ${steps[errorIndex].name}`;
+
+ if (workflow === "success" && statuses.every((status) => status === "done")) {
+ const step = steps.length === 1 ? "step" : "steps";
+ return `All ${steps.length} ${step} complete`;
+ }
+
+ return "";
+};
diff --git a/web/src/lib/components/welcome/Steps.svelte b/web/src/lib/components/welcome/Steps.svelte
index 3855d9ba6..107adc67b 100644
--- a/web/src/lib/components/welcome/Steps.svelte
+++ b/web/src/lib/components/welcome/Steps.svelte
@@ -54,7 +54,7 @@