Skip to content

Launchpad: the first paint is the final page - #270

Merged
agentrozjedemeai merged 4 commits into
mainfrom
agent/launchpad-first-paint
Oct 8, 2026
Merged

agentrozjedemeai merged 4 commits into
mainfrom
agent/launchpad-first-paint

Conversation

@immakermatty

@immakermatty immakermatty commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Status: ready for review. Head 4b141ba adds the reviewer's fix to the setup line on Chat's first load (point 4 and Verification below).

Why

Opening the Launchpad of a hosted Environment (here a personal Remote Environment with a Czech profile) passed through two wrong states before the final one:

  1. Every frame at once, in English. The Settings navigation (General / This Environment / Tools / Recovery) in the middle of the left column, a "Settings / Launchpad" header with Refresh / Reload profile / Refresh status / Check again, "Reading Organizations…", no rail. The page is one static document for every route; nothing hid the other frames until the module script chose the route, and every text was English until POST /api/profile answered.
  2. The right words, still without the rail and the column head (<lazurio-rail>, the Environment picker with Chat · Apps · Automate). They draw from /.lazurio/shell.json, which awaited gh auth status and the content routes' GitHub reads for a setup line the Launchpad page never shows: seconds.
  3. Finally correct.

On top of that every asset (≈880 KB of unminified JS, fonts, stones) was downloaded again on every hosted load, because the hosted Launchpad wrapped every answer in no-store, and the startup ran request chains (entry → prompt hand-off, profile → a second shell read), sign-in probes on every route and about ten full rebuilds of the Apps body.

Goal: the first paint is the final layout (language, section, rail, column head), in milliseconds, without needless redraws.

What changes

  1. The frame before anything runs (index.html, routes.ts, shell.ts). A tiny inline script at the top of <head> (no request) writes the frame of the path and its Settings section on <html data-frame data-section>, the same mapping as the new pageFrame in routes.ts; tests/launchpad-first-paint.test.ts evaluates the script against pageFrame for 31 paths and fails when they drift. The stylesheet shows only that frame and section (and only the matching header action); shell.ts keeps both attributes as the page moves. The page stays hidden until ui.ts sets data-ready (language known, shell document drawn or known missing, the catalog on Apps, the profile on Settings), at the latest a second after the script ran; a CSS animation shows it after two seconds should the script never do so, and <noscript> at once. The rail's and the column head's places are reserved (:not(:defined)), and the four @font-face rules are declared in the page itself, byte-equal to fontFaceCss() (tested), so the elements add none.
  2. Hosted boot document (boot.ts, boot-document.ts, server.ts). After the gateway's admission, the HTML answer (only the HTML, never an asset) gets <script type="application/json" id="lazurio-boot"> at the end of <head>: {schema: "lazurio.launchpad-boot.v1", locale, shell, catalog, entry} — the Folder's language, the same lazurio.shell.v1 document as /.lazurio/shell.json but without setup and computed without any gh/network call (including offlineGuide, so F41's registration is unchanged), the catalog of POST /api/catalog and publicEntry of GET /api/entry. HTMLRewriter also writes <html lang>, every data-message text and the catalog's loading line from the same messages catalog, so even the skeleton is in the right language. Read under one short Folder read (≤ 250 ms for the lock, ≤ 500 ms in all); on any error or delay the page is served exactly as before. <, U+2028 and U+2029 are escaped. Never the local token, the Machine binding, the auth endpoint or cookie name, the account document or tool output. Recovery mode injects nothing (it serves the same page with 503 and no readable Folder); a workstation's page, served without its token, gets none.
  3. Client (ui.ts, catalog-panel.ts). The page reads #lazurio-boot first: locale and copy, provideShell(boot.shell) before defineShellElements() (so the rail, the column head and F36's new tab icon draw in the first paint), the catalog and the entry drawn without reading them again. The duplicate /.lazurio/shell.json read after the profile is gone (it follows a Folder change only); entry and prompt hand-off are asked side by side; tools.refresh() with its sign-in probes and content.refresh() run at once on Settings routes and elsewhere once the page is idle. Apps is rebuilt only when what it shows changed: no forced redraw per Tools reading, none for an Owner answer that changes nothing, none at the start of a later catalog read, none when a re-read entry or shell document is unchanged. The credential helper is now defined before the page's first drawing — this also fixes a pre-existing bug: a direct load of /settings/recovery never asked /api/recovery (the bundled helper was still undefined at that moment, so the view said it could not read the check).
  4. /.lazurio/shell.json never waits for GitHub (also for the forks). setup comes from createSetupCache: the last reading, re-read in the background after 30 s (stale-while-revalidate), omitted until the first reading ends (optional in v1). The first reading starts when someone opens the Launchpad's page, so Chat and Automate (whose column head reads the shell document once per load) find it when they open; not as the Launchpad starts, because a mutation of the Folder refuses a held lock at once and the start is when other Folder operations run. Dropped and read again at once where github.forget() already ran (gh sign-in/out through the Launchpad), on a Tools reading that changes GitHub's state (remember now reports a change), when a content installation starts and ends, and on a profile change.
  5. Hosted asset caching (server.ts, page.ts). Hashed bundle files keep the inner listener's public, max-age=31536000, immutable, fonts and stones public, max-age=604800, immutable; /.lazurio/shell.js carries an ETag and answers 304 to an unchanged copy (locally too); the HTML stays no-store, as do API answers, data documents and missing files. The offline guide's headers (F41) are untouched.

What does not change

  • lazurio.shell.v1 (additive only: setup was already optional) and interface v1 of the elements; the forks need no release.
  • The admission of every route; the boot document exists only in the admitted hosted page.
  • The local token is never inlined; a workstation's page keeps fetching (it benefits from 1, 3 and 4).
  • Recovery mode: same page, 503, no boot document (checked in the browser and by a test).

Measurements

scripts/preview-launchpad.ts … cs, its stub gh slowed to answer after 1 s as GitHub would, each scenario on a freshly started preview; Chrome 1440×900; hosted runs with 60 ms latency and 25 Mbit/s (CDP emulation), workstation on loopback. The page's state was sampled in every animation frame from the document's start (a Playwright init script), requests from CDP. "Later load" = the same browser 62 s later (past the gh probe's cache).

Scenario Before (main 4dab914) After (this branch)
Hosted Apps /, first load English all-frames page at 0.16 s → Czech without rail/head at 0.53 s → final at 2.65 s; 30 requests, 1166 KiB nothing painted until the final page at 0.50 s; 25 requests, 1170 KiB
Hosted Apps /, later load same sequence, final at 2.50 s; 1162 KiB downloaded again final page in the first frame at 0.11 s; 101 KiB (HTML and data only)
Hosted Settings → Nástroje, first load wrong frames from 0.21 s, final at 2.60 s final at 0.62 s
Workstation Apps, first load English all-frames page at 0.12 s, final at 2.37 s final at 0.28 s
Data requests on the way to the first paint (hosted) profile, catalog, entry, shell.json ×2 (each ~2 s with slow gh), then prompt hand-off, tools with sign-in probes, content none — boot document; profile, account, Owner answer and the rest after the paint, tools/content when idle

Wrong states painted (sampled): before 4 (hosted Apps), 3 (hosted Tools), 5 (workstation); after 0 in every scenario. What still changes after the first paint, by design, with its own answer: the "+ Nový modul" tile (GitHub's Owner answer, ~1 RTT later), the favourites hint/list (the person's account through the gateway relay), and avatars/stones as images load.

Verification

  • bun run check:static ✓ (lint, typecheck, check:public, proof build and smoke).
  • bun test --parallel=4: 1564 pass, 7 skip, 0 fail on the code of this branch; a rerun on the final HEAD gave 1563 pass and 1 fail, the free-port race of launchpad-shell-routes.test.ts (EADDRINUSE on a port the test probed and released while another worker bound it), which passes alone 3/3. The known load-sensitive application-environment flake did not occur.
  • New tests: tests/launchpad-first-paint.test.ts (inline script ⇔ pageFrame parity on 31 paths; fonts ⇔ fontFaceCss; both broken once on purpose and failing), tests/launchpad-boot.test.ts (boot JSON escaping and round trip; builder contents with the shell document minus setup, catalog and only public entry parts, no auth endpoint/cookie/port/token; injection only into the admitted HTML with lang="cs" and Czech words, 401 without admission, /files page too, assets never rewritten; asset cache headers, shell.js ETag/304; a Folder held by another operation still serves the page at once without boot; a slow gh (1.5 s per call) delays neither the page nor shell.json, and setup appears once read; the setup cache's SWR/forget semantics; remember change reporting; a workstation's page without boot; hosted Recovery page served 503 without boot). tests/first-run-shell.test.ts now waits for the background reading.
  • Reviewer's pass on 4b141ba (Matěj's Task Agent): read the whole diff; found that Chat's column head could miss "what is missing" on its first load, because the setup reading only started with a fork's own read. Fixed in 4b141ba (reading on page open, re-reading at once after a change). Two new assertions fail without the fix: hosted: opening the page reads what the Environment lacks, so Chat finds it when it opens and the forget step of the setup cache test. A first attempt that read at Launchpad start made a Folder held by another operation… fail on the held lock, which is why the reading waits for a page. bun run check:static ✓; bun test --parallel=4: 1565 pass, 7 skip, 0 fail. Independent frame sampling against the personal Czech preview (60 ms, 25 Mbit/s): first load nothing visible until the final page at 0.52 s (rail, column head, Apps, Czech), later load final at 0.11 s with 99 KiB; tools and content requests start after the paint. Checked in the in-app browser: Apps with the first-run tour and the GitHub line, Settings → Nástroje showing only its section, lang="cs", the boot document present.
  • Browser (decision 0178), hosted and workstation previews in Chrome: every route (/, /settings/general|machine|tools|recovery, /files, /marketplace, /o/example/deals) shows its final frame, language, rail and column head in its first visible frame; in-page moves (Files → gear → Tools → Back → history back) keep data-frame/data-section and the shown frame in step; 390 px narrow layout unchanged; no page errors (only the preview's expected 404/503 answers of the account relay and update pill); a workstation Recovery-mode page renders and reveals at ~0.2 s.

Risks

  • The page is hidden until drawn. If the module script fails after loading, the page appears after 2 s as the static skeleton (correct frame, server-written language on hosted); without JS at once.
  • One short Folder read per hosted page load (≤ 250 ms lock wait, 500 ms budget). Under contention the page falls back to the previous behaviour (no boot, English until the profile).
  • setup in shell.json is absent on a fork's load only before anyone opened the Launchpad's page since it started, or within the seconds of a re-reading after a change; that fork shows the line from its next load. A sign-in done outside the Launchpad (a terminal gh auth login) shows up within ~30 s plus one load.
  • Assets are now cached by the browser: hashed files forever (their names change with every build), fonts/stones for a week (as the inner listener already declared).
  • An inline <script>: there is no CSP on the page today; should a gateway add script-src 'self', the page degrades to the catalog frame until the module runs (the boot data block is not a script and is unaffected).

Not done / follow-ups

  • The JS bundle is served unminified (~880 KB); now cached, but a first load still downloads it. A --minify of the HTML bundle in the release build would be a separate, release-pipeline change.
  • A <link rel="preload"> for the Latin font: Bun's HTML bundler refuses absolute /.lazurio/… link targets, and the reveal waits for the script anyway (fonts finish first even on a first load), so it would not move the first paint.
  • The account's favourites and the Owner answer still arrive after the first paint (gateway relay, GitHub); they could join the boot document only from a server-side cache.

Commits: server side (boot document, caching, setup cache) · page side (first paint, client boot path, requests) · docs (F36 addendum, launchpad-development notes) · the setup reading on page open and re-reading after a change (4b141ba).

🤖 Generated with Claude Code

immakermatty and others added 3 commits October 8, 2026 23:51
…ets cached, never wait for GitHub in the shell document (F36 addendum)

Opening a hosted Environment's Launchpad showed English chrome without the
rail for seconds: the page's first reads (profile, shell document, catalog,
entry) each waited for a round trip through the gateway, the shell
document awaited gh (up to 5 s) for a setup line the Launchpad page never
shows, and every asset was downloaded again on each load because the
hosted Launchpad wrapped every answer in no-store.

A hosted Launchpad now puts a boot document into the page it serves after
the gateway's admission (<script type="application/json"
id="lazurio-boot">, lazurio.launchpad-boot.v1): the Folder's language, the
lazurio.shell.v1 document without setup, the catalog and the entry's
public parts, read from the Folder alone under one short read of its state
(250 ms for the lock, 500 ms in all) and escaped for its script element.
It writes <html lang> and every data-message text in that language with
Bun's HTMLRewriter. Nothing from GitHub, no tool output, never the Machine
binding, the auth endpoint, the cookie name or the person's account; on any
failure or delay the page is served as before. Recovery mode and a
workstation's page, served without the token its reads need, carry none.

/.lazurio/shell.json takes setup from a cache read in the background
(stale while revalidating after 30 s): absent until the first reading
ends, which v1 allows, and dropped by a gh sign-in or sign-out, a Tools
reading that changes GitHub's state, a content installation and a profile
change.

Behind the gateway the page's hashed bundle files, the fonts and the
stones keep the inner listener's immutable caching; /.lazurio/shell.js
carries an ETag and answers 304 to an unchanged copy, locally too. The
page, every data document and anything missing stay no-store; the offline
guide's headers (F41) are unchanged.

Tests: tests/launchpad-boot.test.ts (the boot document's escaping and
contents without secrets, its injection only into the admitted page, the
assets' caching, a Folder held by another operation, a slow gh that neither
the page nor the shell document waits for, the setup cache, Recovery mode
and a workstation's page without it); tests/first-run-shell.test.ts waits
for the setup's background reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Until its module script chose the route, the page showed every frame at
once in English: the Settings navigation in the middle of the column, its
header with four actions, "Reading Organizations…". Then the right words
without the rail and the column head until the shell document arrived.

An inline script at the top of index.html, with no request, names the
frame of the path and its Settings section on <html data-frame
data-section> as pageFrame in routes.ts does (a test keeps the two equal);
the stylesheet shows only that frame, and shell.ts keeps both attributes
as the page moves. Nothing shows until ui.ts sets data-ready (the language
known, the shell document drawn or known to be missing, the catalog on
Apps, the profile on Settings), at the latest a second after it ran; the
stylesheet shows the page after two seconds without it, and at once
without scripts. The rail and the column head have their places before
they are defined, and the brand fonts are declared in the page itself.

The page reads its boot document first: it hands the shell document to the
elements before it defines them, so the rail and the column head draw in
the first paint, and draws the catalog and the entry from it without
reading them again. The second shell read after the profile is gone; the
entry and the prompt hand-off are asked side by side; the tools' sign-in
probes and the content, which may ask GitHub, are read at once on a
Settings route and elsewhere once the page is idle. Apps is drawn again
only when what it shows changed: no forced redraw per Tools reading, none
for an Owner answer that changes nothing, none at the start of a later
catalog read.

The credential helper is now defined before the page's first drawing,
which already reads with it: a direct load of /settings/recovery never
asked /api/recovery before (the bundled helper was still undefined there),
and the Owner question of a home drawn from the boot document needs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Records why a hosted Launchpad page passed through two wrong states (every
frame at once in English, then the right words without the rail and the
column head while the shell document waited for GitHub) and the design that
removes them: the frame named by an inline script before anything runs and
kept equal to routes.ts by a test, the page hidden until it is drawn with a
one-second cap and a two-second stylesheet fallback, the hosted boot
document (lazurio.launchpad-boot.v1: language, shell document without
setup, catalog, the entry's public parts; never secrets, the binding, the
account or tool output), the page's requests off the first paint's path,
setup served from a cache that never waits for GitHub, and the assets'
caching behind the gateway. With the alternatives considered, what does not
change (lazurio.shell.v1, interface v1, admission, a workstation's token,
Recovery mode) and what is left (an unminified bundle, the account and an
Owner's answer arriving after the first paint).

Measured in the preview with gh answering after a second: hosted Apps went
from the static English page at 0.16 s and the final page at 2.65 s (2.50 s
on a later load, 1.16 MB each time) to nothing until the final page at
0.50 s (0.11 s and 101 KB on a later load); Settings → Nástroje 2.60 s to
0.62 s; a workstation's Apps 2.37 s to 0.28 s.

launchpad-development.md notes the first paint for whoever changes a route
or adds a text to the page, and that the shell document's setup is never
waited for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…36 addendum)

The shell document no longer waits for GitHub, so its `setup` comes from the
last reading. Chat and Automate read the shell document once per load, and a
reading that only started with their read left the column head without
"what is missing" until the next load: on a new Environment the person
would not see that GitHub or the content is missing.

The first reading now starts when someone opens the Launchpad's page, and a
change that drops it (a gh sign-in or sign-out, a Tools reading that changes
GitHub's state, a content installation, a profile change) reads it again at
once. Not as the Launchpad starts: its Folder operations need the lock then,
and a mutation refuses a held lock at once. A new test shows that opening
the page reads it before any shell document is asked for, and the cache test
that a forget reads again at once; both fail without the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@immakermatty Exact-head review of 4b141ba: APPROVED. The hosted boot HTML is constructed only after gateway admission, is no-store, and contains the same admitted shell/catalog/public-entry data without token, binding, auth endpoint, cookie name, account or tool output; JSON escapes script delimiters. The frame/section mapping and CSS reveal fallbacks are covered; the setup cache starts only on page open, refreshes after changes, and does not hold the shell response for GitHub. Hashed assets alone retain immutable caching; shell.js revalidates. In an isolated clone I ran check:static, 23 boot/first-paint/setup tests and 40 adjacent hosted/admission/files/recovery/route tests (all pass); git diff --check clean. Head CI passes; zero unresolved threads at review gate. I did not independently repeat the browser frame sampling.

@agentrozjedemeai
agentrozjedemeai merged commit ad60018 into main Oct 8, 2026
7 checks passed
@immakermatty
immakermatty deleted the agent/launchpad-first-paint branch October 8, 2026 22:47
immakermatty added a commit that referenced this pull request Oct 11, 2026
… failing busy (#272) (#317)

Since #270 the shell setup cache starts a background read right after a
profile change, under the Folder's operation lock. A person's next change, or
a tools change after a sign-in, then found the lock held and answered
400 operation-failed with recoveryMayBeRequired, although it would have
succeeded a moment later. The same race failed CI often enough on 2026-10-11
to block merges (main's rerun and PR #316, three times).

withFolderOperationLock now waits, at most 3 s, while the holder is this
process's own short read (withFolderReadLock counts its holds per state
directory until the lock is released). Another process's holder, or this
process's own mutation, is still refused at once, so the kernel lock stays
exclusive and a mutation from elsewhere keeps the immediate refusal.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants