diff --git a/.changeset/design-loading-states.md b/.changeset/design-loading-states.md new file mode 100644 index 000000000000..0875e556f3ec --- /dev/null +++ b/.changeset/design-loading-states.md @@ -0,0 +1,6 @@ +--- +"@reddb-io/redcode": patch +"@reddb-io/redcode-design-app": patch +--- + +Design's review page no longer shows a blank white box while a revision loads. The preview area keeps the theme background and a skeleton of the target (slide strip and 16:9 canvas, phone frame, or page) with the current stage and its elapsed time — waiting for the build, preparing the design tools on first use, building, loading assets and fonts, starting the preview — and fades the frame in once its runtime is ready. Before the first revision it says the agent is preparing it and shows what the agent is doing; a failed build shows its summary with Retry. The presenter windows wait the same way. The first download of the design app shows its progress in the TUI, and a review link opened meanwhile shows a page that follows the download instead of a connection error. diff --git a/packages/app/src/components/session/session-design-tab.tsx b/packages/app/src/components/session/session-design-tab.tsx index b62b25885167..37e84cbf9e0e 100644 --- a/packages/app/src/components/session/session-design-tab.tsx +++ b/packages/app/src/components/session/session-design-tab.tsx @@ -9,6 +9,7 @@ import { viewports } from "@reddb-io/redcode-design/viewports" import { device } from "@reddb-io/redcode-design/devices" import { stage } from "@reddb-io/redcode-design/stage" import { deck } from "@reddb-io/redcode-design/slides" +import { previewLoading } from "@reddb-io/redcode-design/loading" import { createSessionDesignMount } from "./session-design-mount" export function SessionDesignTab() { @@ -45,6 +46,7 @@ export function SessionDesignTab() { device, stage, deck, + loading: previewLoading, request: (url, init) => { const headers = new Headers(init?.headers) if (connection.password) diff --git a/packages/app/src/i18n/design-goal-br.ts b/packages/app/src/i18n/design-goal-br.ts index e91d9c6f651b..14278fa49d5d 100644 --- a/packages/app/src/i18n/design-goal-br.ts +++ b/packages/app/src/i18n/design-goal-br.ts @@ -129,6 +129,24 @@ export const designGoalPortuguese: Record = { "session.design.studio.requestVariant": "Solicitar variante", "session.design.studio.variantRequested": "Variante solicitada. O agente publicará uma nova revisão aqui.", "session.design.studio.noVariants": "Esta revisão não tem variantes separadas.", + "session.design.studio.loadingRevision": "Carregando a revisão…", + "session.design.studio.loadingQueued": "Aguardando a build do preview…", + "session.design.studio.loadingTools": "Preparando as ferramentas de design (só no primeiro uso)…", + "session.design.studio.loadingBuild": "Gerando o preview…", + "session.design.studio.loadingAssets": "Carregando recursos e fontes…", + "session.design.studio.loadingRuntime": "Iniciando o preview…", + "session.design.studio.loadingElapsed": "{{seconds}} s", + "session.design.studio.zeroRevision": "O agente está preparando a primeira revisão…", + "session.design.studio.agentWaiting": "Aguardando o agente começar", + "session.design.studio.agentThinking": "O agente está pensando…", + "session.design.studio.agentTool": "O agente está executando {{tool}}…", + "session.design.studio.previewFailed": "Não foi possível gerar o preview.", + "session.design.studio.previewRetry": "Tentar novamente", + "session.design.studio.appWaiting": "Abrindo a revisão de design", + "session.design.studio.appDownloading": "Baixando redcode-design {{version}}… {{progress}}", + "session.design.studio.appStarting": "Iniciando o app de design…", + "session.design.studio.appFailed": "O app de design não iniciou.", + "session.design.studio.appReload": "Esta página se atualiza sozinha.", "session.design.studio.organizeVariants": "Separar variantes existentes", "session.design.studio.organizeRequested": "Foi solicitado ao agente que separe este protótipo em variantes selecionáveis.", @@ -228,6 +246,7 @@ export const designGoalPortuguese: Record = { "session.design.studio.presentHint": "F tela cheia · ← → navegar · P visão do apresentador", "session.design.studio.presentEmpty": "Este design ainda não tem uma revisão publicada para apresentar.", "session.design.studio.presentSyncPaused": "Sincronização pausada — pressione uma tecla para retomar", + "session.design.studio.presentLoading": "Carregando a apresentação…", "session.design.studio.slides": "Slides", "session.design.studio.pdf": "Slides em PDF", "session.design.studio.feedbackRequired": "Escreva uma nota antes de enviar feedback.", diff --git a/packages/app/src/i18n/design-goal.test.ts b/packages/app/src/i18n/design-goal.test.ts index 145e9fc02317..b5cac17a4973 100644 --- a/packages/app/src/i18n/design-goal.test.ts +++ b/packages/app/src/i18n/design-goal.test.ts @@ -14,7 +14,7 @@ import { DESKTOP_NATIVE_LOCALES, DESKTOP_NATIVE_LOCALE_TAGS } from "./desktop-na describe("Design and Goal localization coverage", () => { test("declares English fallback separately from completed translations", () => { expect(designGoalLocales).toEqual(["en", "br"]) - expect(designGoalKeys).toHaveLength(254) + expect(designGoalKeys).toHaveLength(273) expect(designGoalCoverage("en")).toEqual({ sourceLocale: "en", translated: [], fallback: [] }) expect(designGoalCoverage("br")).toEqual({ sourceLocale: "br", translated: designGoalKeys, fallback: [] }) for (const locale of DESKTOP_NATIVE_LOCALES.filter((locale) => locale !== "en" && locale !== "br")) { diff --git a/packages/app/test-browser/session-design-mount.test.ts b/packages/app/test-browser/session-design-mount.test.ts index 8dbab1aeefdd..d6a15ac00270 100644 --- a/packages/app/test-browser/session-design-mount.test.ts +++ b/packages/app/test-browser/session-design-mount.test.ts @@ -3,6 +3,7 @@ import { createRoot } from "solid-js" import { createStore } from "solid-js/store" import { mountReview, type ReviewOptions } from "@reddb-io/redcode-design/review" import { stage } from "@reddb-io/redcode-design/stage" +import { previewLoading } from "@reddb-io/redcode-design/loading" import { createSessionDesignMount } from "@/components/session/session-design-mount" import { designGoalDictionary } from "@/i18n/design-goal" import type { DesktopNativeLocale } from "@/i18n/desktop-native" @@ -237,6 +238,7 @@ function createFixture( base: "http://design-fixture.invalid", sessionID: "design_locale_fixture", stage, + loading: previewLoading, request, }), translate: (key) => designGoalDictionary(state.locale)[key], diff --git a/packages/core/src/design/app-binary.ts b/packages/core/src/design/app-binary.ts index de8027d70435..8b48cbbf448b 100644 --- a/packages/core/src/design/app-binary.ts +++ b/packages/core/src/design/app-binary.ts @@ -21,6 +21,52 @@ export const MINIMUM = typeof REDCODE_DESIGN_APP_VERSION === "string" ? REDCODE_ export const DOWNLOAD = "https://github.com/reddb-io/redcode/releases/download" export const RELEASES = "https://api.github.com/repos/reddb-io/redcode/releases" +/** + * Where a first-use download or a start of the design app stands, shared by the whole process: the TUI + * reports it, and a review link opened meanwhile shows it on a waiting page. + */ +export interface Progress { + readonly phase: "download" | "start" + readonly version?: string + readonly received: number + /** The archive's size, when the release server says it. */ + readonly total?: number + readonly started: number +} + +const tracker = { current: undefined as Progress | undefined, listeners: new Set<(progress?: Progress) => void>() } + +/** The download or start in progress in this process, if any. */ +export function progress() { + return tracker.current +} + +/** Calls the listener on every progress change, and with nothing once the app runs or failed to. */ +export function watch(listener: (progress?: Progress) => void) { + tracker.listeners.add(listener) + return () => { + tracker.listeners.delete(listener) + } +} + +export function report(next?: Progress) { + tracker.current = next + tracker.listeners.forEach((listener) => listener(next)) +} + +/** How much arrived: a percentage when the size is known, megabytes otherwise. */ +export function amount(value: Progress) { + if (value.total) return `${Math.min(100, Math.floor((value.received / value.total) * 100))}%` + return `${(value.received / 1_000_000).toFixed(1)} MB` +} + +/** One status line, such as "Downloading redcode-design 0.1.0… 45%". */ +export function describe(value: Progress) { + const name = value.version ? `redcode-design ${value.version}` : "redcode-design" + if (value.phase === "start") return `Starting ${name}…` + return `Downloading ${name}… ${amount(value)}` +} + /** What a release says about itself; the archive is installed only when it speaks redcode's protocol. */ export const Manifest = Schema.Struct({ version: Schema.String, protocol: Schema.Int }) export type Manifest = typeof Manifest.Type @@ -155,6 +201,8 @@ async function fetchManifest(options: Options, version: string) { async function download(input: Options & { version: string; platform: string; bin: string; file: string }) { const asset = archive(input.platform) + const started = tracker.current?.started ?? Date.now() + report({ phase: "download", version: input.version, received: 0, started }) // The manifest is checked before the archive is fetched: a release of another protocol is refused outright. const remote = await fetchManifest(input, input.version) if (remote.protocol !== input.protocol) throw mismatch(input.version, remote.protocol, input.protocol) @@ -166,8 +214,9 @@ async function download(input: Options & { version: string; platform: string; bi .find(([, name]) => name === file)?.[0] ?.toLowerCase() verify(input.version, "manifest.json", remote.bytes, expected("manifest.json")) - const bytes = await (await get(input, `${base(input, input.version)}/${asset}`)).bytes() + const bytes = await receive(await get(input, `${base(input, input.version)}/${asset}`), input.version, started) verify(input.version, asset, bytes, expected(asset)) + report({ phase: "start", version: input.version, received: 0, started }) await mkdir(input.bin, { recursive: true }) const temporary = path.join(input.bin, `.redcode-design-${input.version}-${crypto.randomUUID()}`) await mkdir(temporary) @@ -186,6 +235,28 @@ async function download(input: Options & { version: string; platform: string; bi } } +/** Reads an archive, reporting each whole percent (or tenth of a megabyte) at most five times a second. */ +async function receive(response: Response, version: string, started: number) { + const reader = response.body?.getReader() + if (!reader) return response.bytes() + const total = Number(response.headers.get("content-length")) || undefined + const chunks: Uint8Array[] = [] + const state = { received: 0, shown: "", at: 0 } + for (;;) { + const chunk = await reader.read() + if (chunk.done) break + chunks.push(chunk.value) + state.received += chunk.value.byteLength + const next: Progress = { phase: "download", version, received: state.received, total, started } + const complete = total !== undefined && state.received >= total + if (amount(next) === state.shown || (!complete && Date.now() - state.at < 200)) continue + state.shown = amount(next) + state.at = Date.now() + report(next) + } + return new Uint8Array(Bun.concatArrayBuffers(chunks)) +} + async function extract(directory: string, asset: string) { // GNU tar (first on PATH under Git for Windows) reads a drive letter such as `C:` as a remote host, // so tar only ever sees a name relative to the directory it runs in. diff --git a/packages/core/src/design/app.ts b/packages/core/src/design/app.ts index f5d7c20730e0..0e86d61d0325 100644 --- a/packages/core/src/design/app.ts +++ b/packages/core/src/design/app.ts @@ -8,6 +8,8 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto" import { setTimeout as sleep } from "node:timers/promises" import { Effect, Option, Schema } from "effect" import { Design } from "@reddb-io/redcode-schema/design" +import { reviewCopy } from "@reddb-io/redcode-design/copy" +import { designWaiting, WAITING_CSP } from "@reddb-io/redcode-design/waiting" import { Global } from "../global" import { Database } from "../database/database" import { InstallationVersion } from "../installation/version" @@ -154,12 +156,29 @@ export interface EnsureInput { readonly env?: Record } +const launches = new Map>() + /** * The running design app, started when none answers. A registration counts only while its process * lives, it answers its health check with this token, and it speaks this protocol; an app of another - * protocol is asked to stop and a new one takes over the registration. + * protocol is asked to stop and a new one takes over the registration. Callers in this process share + * one launch, so a review link opened during a first-use download waits on that download. */ -export async function ensure(input: EnsureInput) { +export function ensure(input: EnsureInput) { + const key = paths(input.state).registration + const pending = launches.get(key) + if (pending) return pending + const launch = start(input) + launches.set(key, launch) + // A failure stays for a moment, so a page waiting on this launch shows it instead of starting another. + launch.then( + () => launches.delete(key), + () => setTimeout(() => launches.delete(key), 5_000).unref(), + ) + return launch +} + +async function start(input: EnsureInput) { const files = paths(input.state) const secret = await token(files.token) const current = await reusable(files.registration, secret) @@ -170,6 +189,7 @@ export async function ensure(input: EnsureInput) { // Another redcode may have started one while this one waited for the lock. const started = await reusable(files.registration, secret) if (started) return { url: started.url, token: secret } + DesignAppBinary.report({ phase: "start", received: 0, started: Date.now() }) const launch = typeof input.command === "function" ? await input.command() @@ -208,7 +228,7 @@ export async function ensure(input: EnsureInput) { throw new Error(`The design app did not register in time; see ${files.log}`) }, { timeoutMs: (input.timeout ?? 30_000) + 5_000 }, - ) + ).finally(() => DesignAppBinary.report()) } async function reusable(file: string, secret: string) { @@ -311,6 +331,45 @@ export const link = Effect.fn("DesignApp.link")(function* ( return url.toString() }) +/** + * Where a review link sends the browser: the page on the design app once it runs, or, while this process + * still downloads or starts it, a waiting page that shows how far that got and reloads itself; a failure + * to start shows why, with Retry, instead of a connection error. + */ +export const open = Effect.fn("DesignApp.open")(function* (input: { + readonly host: Host + readonly sessionID: string + readonly route: string + readonly search?: Record +}) { + const attempt = yield* connect({ host: input.host }).pipe( + Effect.flatMap((connection) => link(connection, input.sessionID, input.route, input.search)), + Effect.map((url): { readonly url?: string; readonly error?: string } => ({ url })), + Effect.timeoutOption("2 seconds"), + Effect.catch((error) => + Effect.succeed(Option.some<{ readonly url?: string; readonly error?: string }>({ error: error.message })), + ), + ) + const outcome = Option.getOrUndefined(attempt) + const url = outcome?.url + if (url) return { kind: "redirect" as const, url } + const progress = DesignAppBinary.progress() + const html = designWaiting(reviewCopy, { + phase: outcome?.error ? "failed" : (progress?.phase ?? "start"), + version: progress?.version, + amount: progress?.phase === "download" ? DesignAppBinary.amount(progress) : undefined, + percent: progress?.total ? (progress.received / progress.total) * 100 : undefined, + elapsed: progress ? Math.floor((Date.now() - progress.started) / 1000) : undefined, + message: outcome?.error, + }) + return { + kind: "page" as const, + html, + status: outcome?.error ? 503 : 200, + headers: { "cache-control": "no-store", "content-security-policy": WAITING_CSP }, + } +}) + export const publish = ( connection: Connection, sessionID: string, diff --git a/packages/core/src/design/runtime.ts b/packages/core/src/design/runtime.ts index 65e6ee44f743..d56bc07e5b18 100644 --- a/packages/core/src/design/runtime.ts +++ b/packages/core/src/design/runtime.ts @@ -33,26 +33,35 @@ type Modules = { const runtime = makeRuntime(Npm.Service, LayerNode.compile(Npm.node)) const fingerprint = Bun.hash(JSON.stringify(versions)).toString(16) +const installs = { active: 0 } + +/** Whether the Design tools are being installed now, as they are the first time a compiled redcode needs them. */ +export function installing() { + return installs.active > 0 +} export async function resolve(name: keyof Modules, signal?: AbortSignal) { signal?.throwIfAborted() if (typeof REDCODE_DESIGN_RUNTIME === "undefined") return createRequire(import.meta.url).resolve(name) const directory = path.join(Global.Path.cache, "design-runtime", fingerprint) await mkdir(directory, { recursive: true }) - await runtime.runPromise( - (npm) => - npm.install(directory, { add: Object.entries(versions).map(([name, version]) => ({ name, version })) }).pipe( - Effect.timeout("5 minutes"), - Effect.mapError( - (error) => - new Design.Error({ - code: "unavailable", - message: `Unable to prepare Design tools in ${directory}: ${String(error)}. Check registry access and retry.`, - }), + installs.active++ + await runtime + .runPromise( + (npm) => + npm.install(directory, { add: Object.entries(versions).map(([name, version]) => ({ name, version })) }).pipe( + Effect.timeout("5 minutes"), + Effect.mapError( + (error) => + new Design.Error({ + code: "unavailable", + message: `Unable to prepare Design tools in ${directory}: ${String(error)}. Check registry access and retry.`, + }), + ), ), - ), - { signal }, - ) + { signal }, + ) + .finally(() => installs.active--) signal?.throwIfAborted() return createRequire(path.join(directory, "package.json")).resolve(name) } diff --git a/packages/core/test/design-app-binary.test.ts b/packages/core/test/design-app-binary.test.ts index d54f8ec6a2cf..84d24e65deea 100644 --- a/packages/core/test/design-app-binary.test.ts +++ b/packages/core/test/design-app-binary.test.ts @@ -188,3 +188,32 @@ test("names archives the way the design app release publishes them", () => { expect(DesignAppBinary.archive("darwin-arm64")).toBe("redcode-design-darwin-arm64.zip") expect(DesignAppBinary.archive("windows-x64")).toBe("redcode-design-windows-x64.zip") }) + +test("reports a first-use download's progress, which the TUI shows as a status line", async () => { + await using bin = await tmpdir() + await publish({ version: "0.2.0" }) + const seen: (DesignAppBinary.Progress | undefined)[] = [] + const stop = DesignAppBinary.watch((progress) => seen.push(progress)) + try { + await DesignAppBinary.command(options(bin.path, { version: "0.2.0" })) + } finally { + stop() + } + const downloads = seen.filter((item) => item?.phase === "download") + expect(downloads[0]).toMatchObject({ version: "0.2.0", received: 0 }) + const last = downloads.at(-1)! + expect(last.total).toBeGreaterThan(0) + expect(last.received).toBe(last.total!) + expect(DesignAppBinary.describe(last)).toBe("Downloading redcode-design 0.2.0… 100%") + // Once the archive is verified the app is being started. + expect(seen.at(-1)).toMatchObject({ phase: "start", version: "0.2.0" }) + expect(DesignAppBinary.describe(seen.at(-1)!)).toBe("Starting redcode-design 0.2.0…") + expect(DesignAppBinary.describe({ phase: "download", version: "0.2.0", received: 3_250_000, started: 0 })).toBe( + "Downloading redcode-design 0.2.0… 3.3 MB", + ) + expect( + DesignAppBinary.describe({ phase: "download", version: "0.2.0", received: 450, total: 1000, started: 0 }), + ).toBe("Downloading redcode-design 0.2.0… 45%") + DesignAppBinary.report() + expect(DesignAppBinary.progress()).toBeUndefined() +}) diff --git a/packages/core/test/design-loading.test.ts b/packages/core/test/design-loading.test.ts new file mode 100644 index 000000000000..3cddae990ad7 --- /dev/null +++ b/packages/core/test/design-loading.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test" +import { previewLoading, type LoadingEvent, type LoadingState } from "@reddb-io/redcode-design/loading" +import { designWaiting } from "@reddb-io/redcode-design/waiting" +import { reviewCopy } from "@reddb-io/redcode-design/copy" + +const logic = previewLoading() +const run = (events: LoadingEvent[], from: LoadingState = logic.initial(0)) => events.reduce(logic.reduce, from) +const visible = (state: LoadingState) => ({ phase: state.phase, stage: state.stage }) + +describe("preview loading states", () => { + test("a page opens loading the revision, never on a blank frame", () => { + expect(visible(logic.initial(0))).toEqual({ phase: "loading", stage: "revision" }) + }) + + test("a design without a revision shows the zero state with the agent's live activity", () => { + const empty = run([{ type: "design", revision: undefined, at: 1 }]) + expect(empty.phase).toBe("empty") + expect(empty.agent).toBe("idle") + const thinking = run([{ type: "agent", state: "working" }], empty) + expect(thinking.agent).toBe("thinking") + const tool = run([{ type: "tool", tool: "design_preview", status: "running" }], thinking) + expect({ agent: tool.agent, tool: tool.tool }).toEqual({ agent: "tool", tool: "design_preview" }) + // Another tool finishing leaves the running one in place; its own end goes back to thinking. + expect(run([{ type: "tool", tool: "read", status: "done" }], tool).tool).toBe("design_preview") + expect(run([{ type: "tool", tool: "design_preview", status: "done" }], tool).agent).toBe("thinking") + expect(run([{ type: "agent", state: "idle" }], tool)).toMatchObject({ agent: "idle", tool: "" }) + // The first revision leaves the zero state. + expect(visible(run([{ type: "design", revision: "rev_1", at: 2 }], tool))).toEqual({ + phase: "loading", + stage: "revision", + }) + }) + + test("a preview walks through the build stages to a ready frame", () => { + const steps: LoadingEvent[] = [ + { type: "design", revision: "rev_1", at: 1 }, + { type: "load", revision: "rev_1", at: 2 }, + { type: "build", revision: "rev_1", stage: "queued" }, + { type: "build", revision: "rev_1", stage: "tools" }, + { type: "build", revision: "rev_1", stage: "building" }, + { type: "document", revision: "rev_1" }, + { type: "frame" }, + { type: "ready" }, + ] + const seen = steps.map((_, index) => visible(run(steps.slice(0, index + 1)))) + expect(seen).toEqual([ + { phase: "loading", stage: "revision" }, + { phase: "loading", stage: "queued" }, + { phase: "loading", stage: "queued" }, + { phase: "loading", stage: "tools" }, + { phase: "loading", stage: "build" }, + { phase: "loading", stage: "assets" }, + { phase: "loading", stage: "runtime" }, + { phase: "ready", stage: "runtime" }, + ]) + }) + + test("a late build status never moves a load back, and the runtime may be ready before the frame's load event", () => { + const assets = run([ + { type: "load", revision: "rev_1", at: 0 }, + { type: "build", revision: "rev_1", stage: "building" }, + { type: "document", revision: "rev_1" }, + ]) + expect(run([{ type: "build", revision: "rev_1", stage: "tools" }], assets).stage).toBe("assets") + expect(run([{ type: "build", revision: "rev_1", stage: "queued" }], assets).stage).toBe("assets") + expect(run([{ type: "ready" }], assets).phase).toBe("ready") + // Ready before any document means nothing: the frame on screen is not this revision yet. + expect(run([{ type: "load", revision: "rev_1", at: 0 }, { type: "ready" }]).phase).toBe("loading") + // Another revision's status is ignored. + expect( + run([ + { type: "load", revision: "rev_2", at: 0 }, + { type: "build", revision: "rev_1", stage: "tools" }, + ]).stage, + ).toBe("queued") + }) + + test("a failed build shows its summary; a status left over from an earlier attempt does not", () => { + const loading = run([{ type: "load", revision: "rev_1", at: 0 }]) + expect(run([{ type: "build", revision: "rev_1", stage: "failed", message: "old" }], loading).phase).toBe("loading") + const failed = run([{ type: "failed", revision: "rev_1", message: "missing.css" }], loading) + expect({ phase: failed.phase, message: failed.message }).toEqual({ phase: "error", message: "missing.css" }) + // A late document of the failed load does not hide the error; Retry loads again. + expect(run([{ type: "document", revision: "rev_1" }], failed).phase).toBe("error") + expect(visible(run([{ type: "load", revision: "rev_1", at: 5 }], failed))).toEqual({ + phase: "loading", + stage: "queued", + }) + }) + + test("a live reload keeps the frame on screen until the new document arrives", () => { + const ready = run([ + { type: "load", revision: "rev_1", at: 0 }, + { type: "document", revision: "rev_1" }, + { type: "ready" }, + ]) + const reloading = run([{ type: "load", revision: "rev_2", at: 10, quiet: true }], ready) + expect(reloading.phase).toBe("ready") + expect(run([{ type: "build", revision: "rev_2", stage: "tools" }], reloading).phase).toBe("ready") + expect(visible(run([{ type: "document", revision: "rev_2" }], reloading))).toEqual({ + phase: "loading", + stage: "assets", + }) + // A reload the reader asked for shows the loading state at once. + expect(run([{ type: "load", revision: "rev_1", at: 10 }], ready).phase).toBe("loading") + }) + + test("counts elapsed seconds from the start of the load", () => { + const loading = run([{ type: "load", revision: "rev_1", at: 1000 }]) + expect(logic.elapsed(loading, 13_900)).toBe(12) + expect(logic.elapsed(loading, 0)).toBe(0) + }) + + test("survives serialization into the review page", () => { + const serialized = (new Function(`return (${previewLoading.toString()})`)() as typeof previewLoading)() + const state = [ + { type: "load", revision: "rev_1", at: 0 }, + { type: "document", revision: "rev_1" }, + { type: "ready" }, + ].reduce((current, event) => serialized.reduce(current, event as LoadingEvent), serialized.initial(0)) + expect(state.phase).toBe("ready") + }) +}) + +describe("design app waiting page", () => { + test("shows a download's progress and reloads itself", () => { + const html = designWaiting(reviewCopy, { + phase: "download", + version: "0.1.0", + amount: "45%", + percent: 45, + elapsed: 12, + }) + expect(html).toContain("Downloading redcode-design 0.1.0… 45%") + expect(html).toContain(' { + const html = designWaiting(reviewCopy, { phase: "failed", message: "Could not download " }) + expect(html).toContain(reviewCopy.appFailed) + expect(html).toContain("Could not download <archive>") + expect(html).toContain(reviewCopy.previewRetry) + expect(html).not.toContain('http-equiv="refresh"') + }) + + test("starting the app says so without a size", () => { + const html = designWaiting(reviewCopy, { phase: "start" }) + expect(html).toContain(reviewCopy.appStarting) + expect(html).toContain(">() const jobs = new Map; readonly designID: Design.ID }>() const activity = { tabs: 0, requests: 0, last: Date.now() } + /** Preview builds by revision, which the review page follows while it waits for a preview. */ + const previews = new Map() const fallback: DesignAppHost.Host = { url: options.host, authorization: ServerAuth.header() } const run = async (effect: Effect.Effect) => { @@ -205,7 +209,7 @@ export async function start(options: Options) { const breakpoints = (await exec(store.configured(sessionID).pipe(Effect.catch(() => Effect.succeed(undefined))))) ?.breakpoints return html( - `Design · Redcode
`, + `Design · Redcode
`, ) } if (method === "GET" && parts[3] === "whiteboard" && parts.length === 4) return html(await DesignWhiteboard.frame()) @@ -238,9 +242,23 @@ export async function start(options: Options) { const build = await building(Schema.Struct({ revision: Schema.String })) return Response.json(await exec(store.restore(id, build.input.revision, build.read, build.tooling))) } + // A preview not asked for yet is queued; one whose build runs installs the Design tools first on first use. + if (parts[4] === "revision" && parts[5] && parts[6] === "status" && !parts[7] && method === "GET") { + const preview = previews.get(parts[5]) + return Response.json({ + stage: !preview ? "queued" : preview === "building" && DesignRuntime.installing() ? "tools" : preview, + }) + } if (parts[4] === "revision" && parts[6] === "preview" && method === "GET") { const revision = await exec(store.revision(id, parts[5])) - const directory = await exec(renderer.directory(revision)) + previews.delete(revision.id) + previews.set(revision.id, "building") + if (previews.size > 500) previews.delete(previews.keys().next().value!) + const directory = await exec(renderer.directory(revision)).catch((cause: unknown) => { + previews.set(revision.id, "failed") + throw cause + }) + previews.set(revision.id, "ready") const content = await DesignExport.html( directory, revision.document.engine === "html" ? revision.document.entry : "index.html", diff --git a/packages/design-app/test/app.test.ts b/packages/design-app/test/app.test.ts index 0d0b2f4a6496..6b1ead66762b 100644 --- a/packages/design-app/test/app.test.ts +++ b/packages/design-app/test/app.test.ts @@ -204,6 +204,24 @@ test("publishes and exports through HTTP, and the job is what the store reads", expect(file.status).toBe(200) }, 150_000) +test("reports a preview's build stage, which the review page follows while it waits", async () => { + const document = state.document! + const published = await call(`/${document.id}/revision`, { + method: "POST", + body: JSON.stringify({ name: "Status", tooling: false }), + }) + expect(published.status).toBe(200) + const revision = (await published.json()) as Design.Revision + const stage = async () => + ((await (await call(`/${document.id}/revision/${revision.id}/status`)).json()) as { stage: string }).stage + // Nothing asked for its preview yet. + expect(await stage()).toBe("queued") + const preview = await call(`/${document.id}/revision/${revision.id}/preview`) + expect(preview.status).toBe(200) + expect(await preview.text()).toContain("Exported by the design app.") + expect(await stage()).toBe("ready") +}, 60_000) + test("a review link lets a browser in, and feedback and the feed go through design.host", async () => { const app = state.app! const document = state.document! diff --git a/packages/design/src/copy.ts b/packages/design/src/copy.ts index fb3b292eb849..0728e4d73203 100644 --- a/packages/design/src/copy.ts +++ b/packages/design/src/copy.ts @@ -158,6 +158,24 @@ export const reviewCopy = { requestVariant: "Request variant", variantRequested: "Variant requested. The agent will publish a new revision here.", noVariants: "This revision has no separate variants.", + loadingRevision: "Loading the revision…", + loadingQueued: "Waiting for the preview build…", + loadingTools: "Preparing design tools (first use only)…", + loadingBuild: "Building the preview…", + loadingAssets: "Loading assets and fonts…", + loadingRuntime: "Starting the preview…", + loadingElapsed: "{{seconds}} s", + zeroRevision: "The agent is preparing the first revision…", + agentWaiting: "Waiting for the agent to start", + agentThinking: "The agent is thinking…", + agentTool: "The agent is running {{tool}}…", + previewFailed: "The preview could not be built.", + previewRetry: "Retry", + appWaiting: "Opening the design review", + appDownloading: "Downloading redcode-design {{version}}… {{progress}}", + appStarting: "Starting the design app…", + appFailed: "The design app did not start.", + appReload: "This page updates by itself.", organizeVariants: "Separate existing variants", organizeRequested: "Asked the agent to separate this prototype into selectable variants.", variantActions: "Variant actions", @@ -220,6 +238,7 @@ export const reviewCopy = { presentHint: "F full screen · ← → move · P presenter view", presentEmpty: "This design has no published revision to present yet.", presentSyncPaused: "Sync paused — press a key to resume", + presentLoading: "Loading the deck…", slides: "Slides", pdf: "Slides to PDF", feedbackRequired: "Write a note before sending feedback.", diff --git a/packages/design/src/loading.ts b/packages/design/src/loading.ts new file mode 100644 index 000000000000..28dedcfe29d3 --- /dev/null +++ b/packages/design/src/loading.ts @@ -0,0 +1,98 @@ +/** + * What the review's preview area shows while a revision is on its way: the zero state before the first + * revision, the loading stages of a preview (build, assets, runtime), a failure, or the ready frame. + */ +export type LoadingStage = "revision" | "queued" | "tools" | "build" | "assets" | "runtime" + +export interface LoadingState { + readonly phase: "empty" | "loading" | "error" | "ready" + readonly stage: LoadingStage + /** The revision being loaded or shown. */ + readonly revision: string + /** When the current load started, for the elapsed time. */ + readonly since: number + /** The failure summary in the error phase. */ + readonly message: string + /** The agent's live activity from the conversation feed, shown in the zero state. */ + readonly agent: "idle" | "thinking" | "tool" + readonly tool: string +} + +export type LoadingEvent = + /** The design list answered: the design's latest revision, or none yet. */ + | { readonly type: "design"; readonly revision: string | undefined; readonly at: number } + /** + * The page asked for a revision's preview. A quiet load (a live reload of the frame on screen) keeps + * the current frame visible until the new document arrives. + */ + | { readonly type: "load"; readonly revision: string; readonly at: number; readonly quiet?: boolean } + /** + * The host's preview build status: queued, tools (installed on first use), building, ready or failed. + * A failure is the preview response's to report; a status can be left over from an earlier attempt. + */ + | { readonly type: "build"; readonly revision: string; readonly stage: string; readonly message?: string } + /** The preview document arrived; the frame is loading its assets and fonts. */ + | { readonly type: "document"; readonly revision: string } + /** The frame fired its load event. */ + | { readonly type: "frame" } + /** The frame's runtime (screens, slides) reported ready, fonts included. */ + | { readonly type: "ready" } + | { readonly type: "failed"; readonly revision: string; readonly message: string } + | { readonly type: "agent"; readonly state: "working" | "idle" } + | { readonly type: "tool"; readonly tool: string; readonly status: "running" | "done" | "failed" } + +/** + * The preview area's state machine: observed events in, the state to draw out. The standalone review + * page serializes this function, so keep it self-contained. + */ +export function previewLoading() { + const order: LoadingStage[] = ["revision", "queued", "tools", "build", "assets", "runtime"] + const initial = (at: number): LoadingState => ({ + phase: "loading", + stage: "revision", + revision: "", + since: at, + message: "", + agent: "idle", + tool: "", + }) + const reduce = (state: LoadingState, event: LoadingEvent): LoadingState => { + if (event.type === "agent") + return event.state === "idle" + ? { ...state, agent: "idle", tool: "" } + : { ...state, agent: state.tool ? "tool" : "thinking" } + if (event.type === "tool") { + if (event.status === "running") return { ...state, agent: "tool", tool: event.tool } + if (event.tool !== state.tool) return state + return { ...state, agent: "thinking", tool: "" } + } + if (event.type === "design") { + if (!event.revision) return { ...state, phase: "empty", stage: "revision", revision: "", message: "" } + if (state.phase !== "empty") return state + return { ...state, phase: "loading", stage: "revision", since: event.at } + } + if (event.type === "load") { + const next = { ...state, stage: "queued" as const, revision: event.revision, since: event.at, message: "" } + return event.quiet && state.phase === "ready" ? next : { ...next, phase: "loading" } + } + if (event.type === "failed") + return event.revision === state.revision ? { ...state, phase: "error", message: event.message } : state + if (event.type === "document") + return event.revision === state.revision && state.phase !== "error" && state.phase !== "empty" + ? { ...state, phase: "loading", stage: "assets" } + : state + if (state.phase !== "loading") return state + if (event.type === "build") { + if (event.revision !== state.revision || event.stage === "failed") return state + const stage: LoadingStage = + event.stage === "tools" ? "tools" : event.stage === "building" || event.stage === "ready" ? "build" : "queued" + // Status polls race the preview response: a load never goes back to an earlier stage. + return order.indexOf(stage) > order.indexOf(state.stage) ? { ...state, stage } : state + } + if (event.type === "frame") return state.stage === "assets" ? { ...state, stage: "runtime" } : state + // The runtime can report ready before the frame's own load event reaches the page. + return state.stage === "assets" || state.stage === "runtime" ? { ...state, phase: "ready" } : state + } + const elapsed = (state: LoadingState, now: number) => Math.max(0, Math.floor((now - state.since) / 1000)) + return { initial, reduce, elapsed } +} diff --git a/packages/design/src/present.ts b/packages/design/src/present.ts index 497edadfae7f..061d94b6eb80 100644 --- a/packages/design/src/present.ts +++ b/packages/design/src/present.ts @@ -44,10 +44,12 @@ export function mountPresent(host: HTMLElement, options: PresentOptions) { host: logic.start(self, location.hash.slice(1), presenter ? Date.now() : 0), notes: {} as Record, } - const frame = `position:absolute;left:0;top:0;width:${SLIDE.width}px;height:${SLIDE.height}px;border:0;transform-origin:0 0;background:#fff` + const frame = `position:absolute;left:0;top:0;width:${SLIDE.width}px;height:${SLIDE.height}px;border:0;transform-origin:0 0;background:#fff;opacity:0;transition:opacity .24s ease-out` + // The slides stay out of sight until their frame has painted, so a window never shows a blank white box. + const waiting = `.frame iframe[data-ready]{opacity:1}.loading{position:absolute;inset:0;z-index:1;display:grid;place-items:center;margin:0;padding:24px;text-align:center;color:#a8a8a8}.retry{margin-left:12px}@media(prefers-reduced-motion:reduce){.frame iframe{transition:none}}` host.innerHTML = presenter - ? `
–0:00

${escape(copy.presentUpNext)}

${escape(copy.presentNotes)}

` - : `

${escape(copy.presentHint)}

` + ? `
–0:00

${escape(copy.presentLoading)}

${escape(copy.presentUpNext)}

${escape(copy.presentNotes)}

` + : `

${escape(copy.presentLoading)}

${escape(copy.presentHint)}

` const element = (id: string) => host.querySelector(`#${id}`)! const slide = element("slide") const upcoming = presenter ? element("upcoming") : undefined @@ -105,10 +107,30 @@ export function mountPresent(host: HTMLElement, options: PresentOptions) { const started = state.host.show.started if (presenter) element("timer").textContent = logic.clock(started ? Date.now() - started : 0) } + /** A failure to load the deck, with Retry. */ const status = (text: string) => { - element("status").textContent = text + element("loading").hidden = true + const retry = document.createElement("button") + retry.type = "button" + retry.className = "retry" + retry.textContent = copy.previewRetry + retry.onclick = () => { + element("status").hidden = true + element("loading").hidden = false + void load().catch(() => status(copy.failure)) + } + element("status").replaceChildren(text, retry) element("status").hidden = false } + /** Shows a frame once its runtime reported ready, or a moment after it loaded when it never does. */ + const shown = (target: HTMLIFrameElement) => { + target.dataset.ready = "" + if (target === slide) element("loading").hidden = true + } + for (const target of upcoming ? [slide, upcoming] : [slide]) + target.addEventListener("load", () => { + if (target.srcdoc) setTimeout(() => shown(target), 3000) + }) const view = (name: PresentOptions["view"]) => { const url = new URL(location.href) url.searchParams.set("view", name) @@ -135,10 +157,12 @@ export function mountPresent(host: HTMLElement, options: PresentOptions) { const data = event.data // The next-slide frame only needs to be kept on the next slide once it can take it. if (upcoming && event.source === upcoming.contentWindow) { + if (data?.type === "design:ready") shown(upcoming) if (data?.type === "design:screens") draw() return } if (event.source !== slide.contentWindow) return + if (data?.type === "design:ready") return shown(slide) if (data?.type === "design:slides" && Array.isArray(data.slides)) { state.notes = Object.fromEntries( data.slides.flatMap((item: unknown) => @@ -237,7 +261,10 @@ export function mountPresent(host: HTMLElement, options: PresentOptions) { const preview = await transport( `${options.endpoint}/${encodeURIComponent(options.designID)}/revision/${encodeURIComponent(revision)}/preview`, ) - if (!preview.ok) return status(`${copy.failure} (${preview.status})`) + if (!preview.ok) { + const body = await preview.json().catch(() => undefined) + return status(typeof body?.message === "string" ? body.message : `${copy.failure} (${preview.status})`) + } const html = await preview.text() slide.srcdoc = html if (upcoming) upcoming.srcdoc = html diff --git a/packages/design/src/review.ts b/packages/design/src/review.ts index f06aecf676ec..a6526d6df66e 100644 --- a/packages/design/src/review.ts +++ b/packages/design/src/review.ts @@ -4,6 +4,7 @@ import type { viewports } from "./viewports" import type { device } from "./devices" import type { stage } from "./stage" import type { deck } from "./slides" +import type { LoadingEvent, previewLoading } from "./loading" export interface ReviewOptions { base: string @@ -35,6 +36,8 @@ export interface ReviewOptions { stage: typeof stage /** Deck logic from ./slides; with it the arrow keys, Space, Page Up/Down, Home and End move between a presentation's slides. */ deck?: typeof deck + /** The preview area's loading, zero and error states from ./loading. */ + loading: typeof previewLoading /** Follows the server's conversation feed; absent when the host renders the conversation itself. */ feed?: ( url: string, @@ -199,6 +202,12 @@ export function mountReview(host: HTMLElement, options: ReviewOptions) { strip: "", } const geometry = options.stage() + const loader = options.loading() + const loading = { + view: loader.initial(Date.now()), + /** Shows a frame whose runtime never reports ready (a page without the design runtime) after a while. */ + reveal: undefined as ReturnType | undefined, + } const escape = (value: string) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll('"', """) const thumbnails = new Map() const controller = new AbortController() @@ -314,9 +323,10 @@ details{border-top:1px solid var(--edge);padding:14px 0}summary{cursor:pointer;f .sends{display:flex;gap:8px;margin:14px 0 8px}.sends>*{flex:1;min-width:0}#send{width:auto;margin:0}#send-end{white-space:nowrap}#send-hint{margin-bottom:8px} #inbox{margin-top:8px}#inbox summary{display:flex;align-items:center;gap:8px}#inbox-count{font-size:11px;font-weight:600;padding:1px 8px;border-radius:999px;background:var(--panel);border:1px solid var(--edge);color:var(--muted)}#inbox-count[data-open="true"]{color:var(--accent-ink);background:var(--accent);border-color:var(--accent)}.finding{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 8px;padding:8px 0;border-bottom:1px solid var(--edge);font-size:12px;overflow-wrap:anywhere}.finding input{margin-top:3px}.finding .finding-tag{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;padding:1px 6px;border-radius:4px;border:1px solid var(--edge);color:var(--muted);align-self:start;margin-top:2px}.finding[data-severity=warn] .finding-tag{color:var(--reddb-color-feedback-danger-foreground);border-color:currentColor}.finding[data-status=resolved]{color:var(--muted)}.finding .finding-body{display:grid;gap:2px}.finding .finding-actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:4px}.finding .finding-actions button{padding:2px 7px;font-size:11px}.finding .finding-status{font-size:11px;color:var(--muted)}#queue-fixes{margin-top:10px}#inbox-empty{margin:6px 0 0} #variant-menu{position:absolute;right:0;top:calc(100% + 4px);z-index:5;min-width:200px;padding:4px;display:grid;background:var(--surface);color:var(--ink);border:1px solid var(--edge);border-radius:var(--reddb-radius-md);box-shadow:0 8px 28px color-mix(in oklch,var(--ink) 18%,transparent)}#variant-menu button{border:0;background:transparent;text-align:left;border-radius:4px;padding:6px 10px;min-height:0;white-space:nowrap;font-size:12px}#variant-menu button:hover,#variant-menu button:focus-visible{background:var(--panel);outline-offset:-2px}.op-badge{margin-left:6px;font-size:10px;font-weight:600;line-height:16px;padding:0 6px;border-radius:999px;border:1px solid currentColor;color:var(--accent);white-space:nowrap}.variant-bar .tabs button[data-operation]{color:var(--accent)}#merge-bar{display:flex;align-items:center;gap:10px;min-width:0;overflow:auto;font-size:12px}#merge-options{display:flex;gap:10px}#merge-bar label{margin:0;display:flex;gap:6px;align-items:center;font-weight:400;white-space:nowrap}#merge-bar button{min-height:24px;padding:1px 8px;font-size:12px;white-space:nowrap}#operation-state{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:8px 10px;margin:0 0 12px;border-radius:var(--reddb-radius-md);background:var(--panel);color:var(--reddb-color-feedback-danger-foreground);overflow-wrap:anywhere}#operation-state button{padding:2px 8px;font-size:12px;color:var(--ink)}.note .note-orphaned{font-size:10px;font-weight:600;padding:0 6px;border-radius:4px;border:1px solid currentColor;color:var(--reddb-color-feedback-danger-foreground)}#approval-reselect{color:var(--reddb-color-feedback-danger-foreground)} +.canvas{position:relative}.preview-state{position:absolute;inset:0;z-index:3;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:24px;background:var(--canvas);pointer-events:none;overflow:hidden}.preview-state button{pointer-events:auto}.canvas[data-phase=ready] .preview-state{display:none}.canvas:not([data-phase=ready]) .preview-pane{opacity:0}.canvas[data-phase=ready] .preview-pane{opacity:1;transition:opacity .24s ease-out}.skeleton{display:flex;flex-direction:column;align-items:center;gap:10px;width:min(100%,760px)}.sk-strip{display:none;gap:8px;align-self:stretch;overflow:hidden}.sk-strip i{flex:none;width:80px;aspect-ratio:16/9;border-radius:4px}.sk-frame{width:min(100%,calc(50vh * 4 / 3));aspect-ratio:4/3;border-radius:var(--reddb-radius-md);background:var(--surface);box-shadow:0 0 0 1px var(--edge);display:flex;flex-direction:column;gap:12px;padding:6%}.sk-frame i{display:block;height:10px;border-radius:4px}.sk-frame i:first-child{height:18px;width:45%}.sk-frame i:nth-child(2){width:80%}.sk-frame i:nth-child(3){width:62%}.sk-strip i,.sk-frame i{background:linear-gradient(90deg,var(--panel) 25%,color-mix(in oklch,var(--panel) 55%,var(--canvas)) 50%,var(--panel) 75%);background-size:300% 100%;animation:shimmer 1.6s ease-in-out infinite}@keyframes shimmer{from{background-position:100% 0}to{background-position:0 0}}.preview-state[data-target=presentation] .sk-strip{display:flex}.preview-state[data-target=presentation] .sk-frame{width:min(100%,calc(50vh * 16 / 9));aspect-ratio:16/9}.preview-state[data-target=app] .skeleton{width:auto}.preview-state[data-target=app] .sk-frame{width:auto;height:min(52vh,520px);aspect-ratio:9/19.5;border-radius:34px;box-shadow:0 0 0 8px var(--panel),0 0 0 9px var(--edge);padding:48px 18px}.preview-note{display:grid;justify-items:center;gap:4px;text-align:center;max-width:520px}#preview-stage{margin:0;font-weight:600}#preview-agent{margin:0}#preview-elapsed{font-variant-numeric:tabular-nums}#preview-elapsed:empty{display:none}#preview-error{margin:4px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--reddb-color-feedback-danger-foreground)}#preview-retry{margin-top:8px}.canvas[data-phase=error] .skeleton{display:none}.canvas[data-phase=empty] :is(.sk-strip,.sk-frame) i{animation:none}#no-variants{font-size:11px;opacity:.75}@media(prefers-reduced-motion:reduce){.canvas[data-phase=ready] .preview-pane{transition:none}.sk-strip i,.sk-frame i{animation:none}}

${options.appearance ? `RedDB` : ""}${copy.title}

-

${copy.create}

-