diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts
index 181f313a..6a8de6be 100644
--- a/apps/web/src/config.ts
+++ b/apps/web/src/config.ts
@@ -133,6 +133,16 @@ export const wording = {
unknown:
"The app builder's progress cannot be read right now; your app shows here once it can.",
notWritten: "The app builder has not written any of your app yet.",
+ /** The build so far: "12 files written · last: routines · 20 s ago". */
+ progress: (files: number, last: string | undefined, ago: string) =>
+ `${String(files)} ${files === 1 ? "file" : "files"} written` +
+ (last === undefined ? "" : ` · last: ${last}`) +
+ ` · ${ago}`,
+ nothingYet: (ago: string) =>
+ `No files written yet · last activity ${ago}`,
+ justNow: "just now",
+ secondsAgo: (seconds: number) => `${String(seconds)} s ago`,
+ minutesAgo: (minutes: number) => `${String(minutes)} min ago`,
},
phase: {
installing: "Installing the app's packages",
diff --git a/apps/web/src/pages/workspace/agent-activity.tsx b/apps/web/src/pages/workspace/agent-activity.tsx
index 1ee43c6b..77c87c0d 100644
--- a/apps/web/src/pages/workspace/agent-activity.tsx
+++ b/apps/web/src/pages/workspace/agent-activity.tsx
@@ -17,8 +17,8 @@ const text = wording.activity;
const ASKS = new Set(["instruct_coder", "request_approval", "design_app"]);
const CHECK_SCRIPT = /\bcheck-app\b/;
-// A person sees what the file is, never where it lives: no folders, no internal ids.
-function fileLabel(path: string): string {
+/** A person sees what the file is, never where it lives: no folders, no internal ids. */
+export function fileLabel(path: string): string {
if (path.startsWith("tool-output:")) return text.files.earlierOutput;
const name = path.split("/").filter(Boolean).at(-1) ?? path;
if (/[0-9a-f]{12,}/.test(name)) return text.files.generic;
diff --git a/apps/web/src/pages/workspace/live-app.tsx b/apps/web/src/pages/workspace/live-app.tsx
index c392e13b..a0b7f277 100644
--- a/apps/web/src/pages/workspace/live-app.tsx
+++ b/apps/web/src/pages/workspace/live-app.tsx
@@ -40,6 +40,7 @@ import {
import { keys } from "../../queries/keys.ts";
import { wording } from "../../config.ts";
import { activityPhrase, useProjectActivity } from "./agent-activity.tsx";
+import { setupProgressLine } from "./setup-progress.ts";
import { AppSketch } from "./design-concepts.tsx";
import { clock, useElapsedMs } from "./elapsed.tsx";
import { DevicePreview, type PreviewDevice } from "./device-preview.tsx";
@@ -126,6 +127,10 @@ function BuilderSetup({
(item) => item.runId === builder.runId,
);
const phrase = agent === undefined ? undefined : activityPhrase(agent).phrase;
+ // What the build has produced so far, with the time since its last event ticking, so the pane
+ // moves every second even while one long step says nothing.
+ const quietMs = useElapsedMs(builder.progress?.lastActivityAt);
+ const progress = setupProgressLine(builder.progress, quietMs);
return (
@@ -135,6 +140,9 @@ function BuilderSetup({
{phrase}
)}
+ {progress === undefined ? null : (
+
{progress}
+ )}
{text.setup.note}{" "}
@@ -465,8 +473,7 @@ function checklistEntries(markdown: string): ChecklistEntry[] {
const open: { indent: number; item: ChecklistItem }[] = [];
let note: ChecklistNote | undefined;
let fenced:
- | { target: ChecklistItem | ChecklistNote; fence: string }
- | undefined;
+ { target: ChecklistItem | ChecklistNote; fence: string } | undefined;
for (const line of markdown.split("\n")) {
if (fenced !== undefined) {
append(fenced.target, line);
diff --git a/apps/web/src/pages/workspace/setup-progress.test.ts b/apps/web/src/pages/workspace/setup-progress.test.ts
new file mode 100644
index 00000000..8ae3ee73
--- /dev/null
+++ b/apps/web/src/pages/workspace/setup-progress.test.ts
@@ -0,0 +1,33 @@
+import { expect, test } from "bun:test";
+import { ago, setupProgressLine } from "./setup-progress.ts";
+
+const progress = {
+ turns: 3,
+ recent: ["write_file client/src/pages/routines.tsx"],
+ filesWritten: 12,
+ files: ["client/src/api.ts", "client/src/pages/routines.tsx"],
+ lastActivityAt: "2026-11-02T02:23:22.000Z",
+};
+
+test("the first build's line names the count, the last file in plain words, and the quiet time", () => {
+ expect(setupProgressLine(progress, 20_000)).toBe(
+ "12 files written · last: routines · 20 s ago",
+ );
+ expect(setupProgressLine({ ...progress, filesWritten: 1 }, 2_000)).toBe(
+ "1 file written · last: routines · just now",
+ );
+});
+
+test("before the first write the line still says how long it has been", () => {
+ expect(
+ setupProgressLine({ ...progress, filesWritten: 0, files: [] }, 45_000),
+ ).toBe("No files written yet · last activity 45 s ago");
+ expect(setupProgressLine(undefined, 45_000)).toBeUndefined();
+});
+
+test("quiet time reads in seconds under two minutes, then in minutes", () => {
+ expect(ago(1_000)).toBe("just now");
+ expect(ago(59_000)).toBe("59 s ago");
+ expect(ago(119_000)).toBe("119 s ago");
+ expect(ago(200_000)).toBe("3 min ago");
+});
diff --git a/apps/web/src/pages/workspace/setup-progress.ts b/apps/web/src/pages/workspace/setup-progress.ts
new file mode 100644
index 00000000..856cfe4f
--- /dev/null
+++ b/apps/web/src/pages/workspace/setup-progress.ts
@@ -0,0 +1,34 @@
+/**
+ * The first build's progress in one line, from what the hub saw of the run's
+ * own write events: how many files it has written, the last of them, and how
+ * long since anything happened. The quiet time ticks on the caller's clock,
+ * so the line changes every second even while one long step says nothing.
+ */
+import type { RunProgress } from "@corbits-builder/schemas/artifact";
+import { wording } from "../../config.ts";
+import { fileLabel } from "./agent-activity.tsx";
+
+const text = wording.liveApp.setup;
+
+/** How long ago, in a person's words: "just now", "40 s ago", "3 min ago". */
+export function ago(ms: number): string {
+ const seconds = Math.floor(ms / 1_000);
+ if (seconds < 5) return text.justNow;
+ if (seconds < 120) return text.secondsAgo(seconds);
+ return text.minutesAgo(Math.floor(seconds / 60));
+}
+
+export function setupProgressLine(
+ progress: RunProgress | undefined,
+ quietMs: number,
+): string | undefined {
+ if (progress === undefined) return undefined;
+ const last = progress.files.at(-1);
+ return progress.filesWritten === 0
+ ? text.nothingYet(ago(quietMs))
+ : text.progress(
+ progress.filesWritten,
+ last === undefined ? undefined : fileLabel(last),
+ ago(quietMs),
+ );
+}