diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 3a1f766d..9d4963e1 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -134,6 +134,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..084c2f8d 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}{" "} 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), + ); +}