Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/design-loading-states.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/app/src/components/session/session-design-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -45,6 +46,7 @@ export function SessionDesignTab() {
device,
stage,
deck,
loading: previewLoading,
request: (url, init) => {
const headers = new Headers(init?.headers)
if (connection.password)
Expand Down
19 changes: 19 additions & 0 deletions packages/app/src/i18n/design-goal-br.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,24 @@ export const designGoalPortuguese: Record<string, string> = {
"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.",
Expand Down Expand Up @@ -228,6 +246,7 @@ export const designGoalPortuguese: Record<string, string> = {
"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.",
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/i18n/design-goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down
2 changes: 2 additions & 0 deletions packages/app/test-browser/session-design-mount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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],
Expand Down
73 changes: 72 additions & 1 deletion packages/core/src/design/app-binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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.
Expand Down
65 changes: 62 additions & 3 deletions packages/core/src/design/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -154,12 +156,29 @@ export interface EnsureInput {
readonly env?: Record<string, string | undefined>
}

const launches = new Map<string, Promise<{ url: string; token: string }>>()

/**
* 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)
Expand All @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string, string | undefined>
}) {
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,
Expand Down
35 changes: 22 additions & 13 deletions packages/core/src/design/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
29 changes: 29 additions & 0 deletions packages/core/test/design-app-binary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Loading
Loading