diff --git a/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte b/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte
index 3be5de5c..de90b9fd 100644
--- a/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte
+++ b/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte
@@ -1,7 +1,12 @@
+
+
@@ -26,3 +58,8 @@
+
+
+
+
diff --git a/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte b/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte
index 4f11fd6a..47becc80 100644
--- a/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte
+++ b/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte
@@ -33,6 +33,15 @@
line: 22,
gap: 8
};
+
+ // slack for a fractional scrollTop. it has to stay under one line, or a single
+ // appended line still reads as "at the end" and the tail creeps along after the
+ // reader took over
+ const END_SLACK = 8;
+
+ // a scroll event lands a frame or two after the virtualizer sets scrollTop, so
+ // anything inside this window is ours and never counts as the reader scrolling
+ const COMMAND_WINDOW = 120;
+
+
{#if steps.length === 0}
{#if status === "connecting"}
@@ -175,94 +245,108 @@
{/if}
{:else}
-
- {#if stickyInfo}
- {@const slots = classes({
- collapsed: collapsed.has(stickyInfo.step.id),
- first: rows[stickyInfo.index - 1]?.kind === "step",
- last: isLastRow(rows, stickyInfo.index)
- })}
-
-
{/if}
-
+
diff --git a/web/src/lib/components/repo/pipelines/mock.ts b/web/src/lib/components/repo/pipelines/mock.ts
index d5fe9890..a290ecac 100644
--- a/web/src/lib/components/repo/pipelines/mock.ts
+++ b/web/src/lib/components/repo/pipelines/mock.ts
@@ -36,7 +36,12 @@ export const pipelines: PipelineSummary[] = [
},
workflows: [
{ name: "build", status: "success", duration: minutes(2) + 4_000 },
- { name: "test", status: "failed", duration: minutes(6) + 31_000, error: "3 tests failed" },
+ {
+ name: "test",
+ status: "failed",
+ duration: minutes(6) + 31_000,
+ error: "3 tests failed"
+ },
{ name: "lint", status: "success", duration: 41_000 }
]
},
@@ -130,7 +135,10 @@ const drain = (frames: LogFrame[]): LogStep[] => {
export const workflowLog: LogStep[] = drain([
control(-1, "microVM setup", { kind: "system" }),
output(0, "starting microVM image alpine\n"),
- output(0, "agent connected; serial log: /tmp/spindle-microvm-knot1.tangled.sh-3msfzd67fzh22-deploy-web-dev.yml-95471715/serial.log\n"),
+ output(
+ 0,
+ "agent connected; serial log: /tmp/spindle-microvm-knot1.tangled.sh-3msfzd67fzh22-deploy-web-dev.yml-95471715/serial.log\n"
+ ),
finish(-1, 3),
control(0, "Clone repository into workspace", {
kind: "system",
@@ -170,6 +178,57 @@ export const runningWorkflowLog: LogStep[] = drain([
output(1, "\u001b[32m✓\u001b[0m src/lib/format.test.ts (12)\n")
]);
+const tailFrames: LogFrame[] = [
+ control(-1, "microVM setup", { kind: "system" }),
+ output(-1, "starting microVM image alpine\n"),
+ finish(-1, 3),
+ control(0, "install dependencies", { command: "pnpm install --frozen-lockfile" }),
+ ...Array.from({ length: 30 }, (_, index) =>
+ output(0, `\u001b[90m+\u001b[0m package-${index} \u001b[32m1.${index}.0\u001b[0m\n`)
+ ),
+ finish(0, 41),
+ control(1, "run tests", { command: "pnpm test" }),
+ output(1, "\u001b[1mRUN\u001b[0m v4.1.10\n"),
+ ...Array.from({ length: 40 }, (_, index) =>
+ output(1, `\u001b[32m✓\u001b[0m src/lib/suite-${index}.test.ts (${index + 2})\n`)
+ ),
+ finish(1, 96),
+ control(2, "build the site", { command: "pnpm build" }),
+ ...Array.from({ length: 40 }, (_, index) =>
+ output(2, `[${index}] \u001b[36mbundle\u001b[0m src/module-${index}.ts\n`)
+ )
+];
+
+// the tail is replayed live, so its stamps have to be too, or every step reads
+// as an hour long
+const restamp = (frame: LogFrame): LogFrame => {
+ const time = new Date().toISOString();
+ if (frame.kind === "control") return { kind: "control", control: { ...frame.control, time } };
+ if (frame.kind === "data") return { kind: "data", data: { ...frame.data, time } };
+ return frame;
+};
+
+/**
+ * hands out a growing log, one frame per call, through the real accumulator. a
+ * story drives it on a timer, so the scroll behavior can be watched against a
+ * moving tail instead of a fixed fixture.
+ */
+export const createLogTail = (): { advance: () => LogStep[]; exhausted: () => boolean } => {
+ const accumulator = createLogAccumulator();
+ let index = 0;
+ return {
+ advance() {
+ const frame = tailFrames[index];
+ if (frame) {
+ index += 1;
+ accumulator.push(restamp(frame));
+ }
+ return [...accumulator.steps];
+ },
+ exhausted: () => index >= tailFrames.length
+ };
+};
+
/** a step big enough that the viewport can only hold a slice of it */
export const longWorkflowLog: LogStep[] = drain([
control(1, "build everything", { command: "make -j8" }),