Skip to content

fix(preview): converge HTML files on a retained real-URL runtime - #7353

Open
lefarcen wants to merge 141 commits into
mainfrom
fix/streaming-html-preview-bridge
Open

fix(preview): converge HTML files on a retained real-URL runtime#7353
lefarcen wants to merge 141 commits into
mainfrom
fix/streaming-html-preview-bridge

Conversation

@lefarcen

@lefarcen lefarcen commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes #7278

Why

HTML preview had accumulated several mutually exclusive transports: a real project URL for passive viewing, srcdoc/Blob snapshots for injected bridges, and separate recovery/parking paths for Electron. A routine action such as switching File tabs, entering Edit, returning from Code, or receiving an agent-written revision could therefore navigate a different iframe. Users saw blank previews, loading covers, flashes, lost scroll/deck state, stale edit content, and broken relative CSS/JS—especially for large or multi-file prototypes such as #7278.

This PR converges the product on one project-scoped real-URL document runtime per HTML file. Opening the document, enabling capabilities, retaining state, and replacing a changed document are now separate responsibilities. The currently painted document stays on screen while a candidate revision loads, proves its exact identity/capabilities/visible paint, and is then promoted atomically.

What users will see

  • HTML files—including large files and documents that reference relative CSS, support.js, Babel/JSX, images, fonts, or other project files—open from their real project URL instead of falling back to a Blob/srcdoc navigation.
  • Preview/Code, File tab, Project, Edit, Comment, Draw, palette, and Deck transitions keep the same live iframe whenever the document itself has not changed, preserving scroll, page, and in-memory state.
  • Agent/file updates and explicit Reload stage a fresh browsing context behind the last good preview. Users keep seeing the old document until the new one visibly paints; failed or stalled candidates never replace it.
  • Large HTML files receive the runtime bootstrap and requested passive guards through bounded streaming injection, removing the 2 MiB bridge cliff without buffering the full file.
  • Relative resource changes reload through a fresh iframe at the same scoped URL; the valid preview scope is reused instead of reminted.

Surface area

  • UI — changes existing HTML preview behavior in apps/web; no new entry point or control
  • Keyboard shortcut — new or changed
  • CLI / env var — new od subcommand or flag, new tools-dev / tools-pack flag, or new OD_* env var
  • API / contract — adds preview policy/runtime DTOs, scoped document identity, capability and visible-paint protocol
  • Extension point — new entry under skills/, design-systems/, design-templates/, or craft/, or change to the skills protocol
  • i18n keys — added new translation keys (see TRANSLATIONS.md for the locale workflow)
  • New top-level dependency — adding any new entry to the root package.json (dependencies or devDependencies); workspace-package package.json files are out of scope
  • Default behavior change — existing HTML previews use the retained real-URL runtime by default
  • None — internal refactor, docs, tests, or translation update only

Screenshots

No new UI entry point. This changes the behavior of the existing FileViewer. Packaged-Electron evidence was captured against the existing Preview/Code toolbar and file tabs; the acceptance checks below continuously sampled the actual visible iframe during Reload and capability transitions rather than relying on a static screenshot.

Bug fix verification

  • Red specs:
    • apps/daemon/tests/project-file-range.test.ts and apps/daemon/tests/http/html-stream-injection.test.ts cover the former >2 MiB bridge omission, relative resources, chunk boundaries, raw-text elements, malformed heads, ranges, guards, and redirects.
    • apps/web/tests/components/PreviewSessionFrames.test.tsx covers last-good retention, same-version reload attempts, timeout/retry, exact promotion, state suspension, privilege changes, and old-frame eviction.
    • apps/web/tests/components/FileViewer.test.tsx covers terminal real-URL Edit/Comment/Draw/Deck transitions, File/Project switching, agent-written revisions, mint retry, explicit Reload, no redundant scope mint, and stale/aborted navigation rejection.
    • apps/desktop/tests/main/preview-navigation-failure.test.ts covers active real-URL navigation failure forwarding.
    • e2e/tests/artifact-render-parity.test.ts fixes the retained-runtime parity report contract and dynamic-canvas classification.
  • The focused tests failed on the legacy behavior before the matching source changes and pass on this branch.

Validation

  • pnpm guard
  • pnpm typecheck (all workspaces, after merging latest main)
  • Web preview suite: 345 passed (PreviewSessionFrames.test.tsx + FileViewer.test.tsx)
  • Contracts: 511 passed; preview-runtime: 6 passed; E2E parity contract: 9 passed
  • Focused daemon streaming/policy suite: 62 passed; desktop navigation-failure suite: 1 passed
  • Preview Lab product adapter on ordinary HTML, Deck, and WebGL: ordinary HTML perceptually equivalent, Deck exact, WebGL correctly classified dynamic/unstable while preserving iframe, URL, dimensions, and structure
  • Built a fresh macOS DMG from the exact PR head, installed it through tools-pack, and launched the installed Electron app in isolated namespace preview-runtime
  • Packaged fixture with relative style.css + support.js: explicit Reload created and promoted a fresh iframe while reusing the exact scoped URL; CSS and JS re-executed; old frame was removed from DOM and the keep-alive pool
  • Active-view acceptance: 550 high-frequency DOM samples plus 265 requestAnimationFrame samples over 2.2 s observed zero blank frames; desktop log contained no ERR_ABORTED/did-fail-load

@lefarcen lefarcen added size/XL PR changes 700-1500 lines risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps type/bugfix Bug fix labels Aug 25, 2026
@lefarcen
lefarcen requested a review from PerishCode August 25, 2026 04:15
@lefarcen lefarcen added the needs-validation Runtime change detected; needs human or /explore agent validation. label Aug 25, 2026
@lefarcen

Copy link
Copy Markdown
Contributor Author

🧪 This PR changes preview/runtime behavior in a way that should get a manual QA pass before merge, so I’ve added needs-validation. Please hold off self-merging for now; we’ll loop QA in once CI and the remaining review gates are clear.

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Visual regression review

Head: b4eaa06 · Base: 0688faa

46 changed · 7 unchanged · 0 new without baseline · 0 failed

Changed cases

Case Main PR Diff
visual-avatar-local-agent-list
13,991 px (1.08%)
main pr diff
visual-avatar-menu
10,090 px (0.78%)
main pr diff
visual-avatar-open-design-model-picker
11,706 px (0.90%)
main pr diff
visual-critical-settings
6,084 px (0.47%)
main pr diff
visual-critical-workspace
9,119 px (0.70%)
main pr diff
visual-critical-workspace-preview
9,119 px (0.70%)
main pr diff
visual-deepseek-unpaid-campaign-600
3,003 px (0.70%)
main pr diff
visual-deepseek-unpaid-campaign-short-height
3,605 px (1.19%)
main pr diff
visual-design-system-detail
6,084 px (0.47%)
main pr diff
visual-design-systems
6,084 px (0.47%)
main pr diff
visual-home
17,832 px (1.38%)
main pr diff
visual-home-catalog
6,084 px (0.47%)
main pr diff
visual-home-context-picker
34,755 px (2.68%)
main pr diff
visual-home-context-picker-popover
27,310 px (8.82%)
main pr diff
visual-home-plugin-filter
6,084 px (0.47%)
main pr diff
visual-home-plugin-use-staged
69,965 px (5.40%)
main pr diff
visual-home-plugin-use-with-query
69,796 px (5.39%)
main pr diff
visual-home-staged-attachment
29,932 px (2.31%)
main pr diff
visual-integrations
6,084 px (0.47%)
main pr diff
visual-integrations-mcp
6,084 px (0.47%)
main pr diff

26 additional changed case(s) omitted from this comment.

Unchanged cases
Case Main PR Diff
visual-avatar-local-agent-list-panel
145 px (0.16%)
main pr diff
visual-avatar-menu-panel
141 px (0.42%)
main pr diff
visual-new-project-modal
0 px (0.00%)
main pr diff
visual-plugin-share-menu-popover
0 px (0.00%)
main pr diff
visual-project-avatar-model-dropdown-popover
145 px (0.19%)
main pr diff
visual-settings-byok-model-dropdown-popover
0 px (0.00%)
main pr diff
visual-settings-local-cli-model-dropdown-popover
0 px (0.00%)
main pr diff

Visual diff is advisory only and does not block merging.

@PerishCode PerishCode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The streaming approach is well scoped, but the new head scanner can leave valid large documents without their scoped preview base because it exits raw-text state on a tag-name prefix. That affects the containment and relative-resource behavior this fix is intended to preserve.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/daemon/src/http/html-stream-injection.ts Outdated
@lefarcen

Copy link
Copy Markdown
Contributor Author

@lefarcen The current blocker on this head is already well covered by @PerishCode's review: the raw-text scanner can false-close on prefixes like </scripture> / </script-not-a-tag>, which can then suppress the scoped preview-base injection for large documents. Once that delimiter check is tightened — including the split-chunk fixture they called out — this should be in good shape for another pass.

💡 To drive this PR to merge hands-free, paste this to your AI coding agent (Claude Code / Codex / opencode / Cursor …):
Take over nexu-io/open-design#7353 until it merges — read https://raw.githubusercontent.com/nexu-io/looper/main/skills/pr-takeover/SKILL.md and follow it.

@lefarcen
lefarcen requested a review from a team as a code owner August 25, 2026 05:46
@lefarcen

Copy link
Copy Markdown
Contributor Author

Addressed the blocking raw-text delimiter review in a1b14b1. The scanner now accepts a raw-text closing tag only when the tag name is followed by HTML whitespace, /, or >, so prefixes such as </scripture> and </script-not-a-tag> stay inside author script text. Added both ordinary-prefix coverage and a 64 KiB split-chunk fixture where </script ends one chunk and ure> begins the next. Focused daemon suites: 62 passed; all related web/contracts/desktop suites, four package typechecks, and pnpm guard are green.

@PerishCode PerishCode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The streaming and iframe recovery paths are otherwise coherent, but the head scanner still disagrees with the HTML parser for self-closing syntax on raw-text elements. That can suppress the scoped preview base for affected large documents.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/daemon/src/http/html-stream-injection.ts Outdated
@lefarcen

Copy link
Copy Markdown
Contributor Author

@lefarcen Thanks for pushing the follow-up on a1b14b1. The current blocker on this head still looks fully covered by @PerishCode's latest review: HTML raw-text elements ignore the self-closing flag, so cases like <script/>...<base ...>...</script> / <style />... should stay in raw-text state instead of letting that base-shaped text suppress the scoped preview-base injection. Once that parser-alignment fix and the chunk-split fixture are in, this should be ready for another pass.

@lefarcen
lefarcen requested a review from PerishCode August 25, 2026 06:44

@PerishCode PerishCode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The streaming injection and navigation-recovery paths are otherwise coherent, and the two previously reported raw-text scanner issues are fixed on this head. One first-paint identity check still rejects valid same-document URL changes, which can leave the cover stuck in the exact slow-resource case this signal is meant to recover.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/src/components/FileViewer.tsx Outdated
@lefarcen

Copy link
Copy Markdown
Contributor Author

@lefarcen Thanks for pushing the new head 9b7777e. The current blocker on this revision also looks fully covered by @PerishCode's latest review: the new visible_paint identity check is still too strict for valid same-document URL changes such as location.hash = ... or history.replaceState(...), so the first-load cover can stay stuck even after a real paint from the active browsing context. Once that check is narrowed to the stable preview identity they called out — plus the hash/replaceState fixture — this should be ready for another pass.

@lefarcen
lefarcen requested a review from PerishCode August 25, 2026 07:45

@PerishCode PerishCode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The previous raw-text and visible-paint blockers are fixed on this head, but the streaming scanner still does not preserve the existing redirect-loop behavior for scripts outside the head. Large previews can therefore reload-loop where the buffered path remains guarded.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/daemon/src/http/html-stream-injection.ts Outdated
@lefarcen

Copy link
Copy Markdown
Contributor Author

@lefarcen Thanks for pushing the new head 3e0d44c. The current blocker on this revision also looks fully covered by @PerishCode's latest review: the streaming scanner now stops once the head insertion point is known, so it misses body scripts like location.replace('./next.html') and can leave the large-file redirect-loop guard weaker than the buffered path. Once that navigation scan continues through EOF (or an equivalent bounded second pass) and the large-route fixture they called out is in, this should be ready for another pass.

@lefarcen
lefarcen requested a review from PerishCode August 25, 2026 08:37

@PerishCode PerishCode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The streaming parser and iframe recovery fixes are coherent on this head, including the prior raw-text, same-document paint, and body-script scanning follow-ups. One remaining blocker prevents the passive-guard promise from holding for large files when the relevant signal is outside FileViewer's routing prefix.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/src/components/FileViewer.tsx
@lefarcen

Copy link
Copy Markdown
Contributor Author

@lefarcen Thanks for pushing the new head bc2a4d6. The current blocker on this revision also looks fully covered by @PerishCode's latest review: passive guard detection still depends on the 96 KiB routing preview, so large deferred documents can miss the required sandbox/focus/redirect guards when the relevant signal appears later in the file. Once those guard flags are carried beyond the routing prefix (or requested conservatively for deferred large docs) and the FileViewer/route fixture they called out is in, this should be ready for another pass.

@lefarcen
lefarcen requested a review from PerishCode August 25, 2026 09:24
Round one landed the contract and a pure timing module with no call sites.
This wires the phases that the preview runtime itself can observe, and
writes the dashboard queries against them.

PreviewSessionFrames reports bootstrap_handshake, capabilities_applied,
version_promoted, last_good_retained and recovery_attempted;
IframeKeepAlivePool reports cache_reclaimed through the single choke
point every eviction already passes through. navigation_start and
first_visible_paint stay unwired and belong to FileViewer.

Every path out of a staged standby reports, not just promotion. A
version_promoted row is emitted for the daemon refusing a version and for
a standby that never settles, with the gate that was still open named
from the session snapshot -- otherwise promotion success rate has no
denominator and reads 100% forever. last_good_retained likewise reports
retained=false when the previous browsing context is gone, so the
retention rate has negative rows to divide by. Legacy-url documents
promote on browser load, a weaker gate, and deliberately report nothing.

Reporting goes through the consent-gated analytics channel, not
reportSafetyEvent. That bypass exists so stability failures arrive from
users who declined analytics; phase durations of a healthy preview are
operational data about a normal session and do not qualify. Splitting per
phase is not available either: every metric is a ratio, and a numerator
and denominator drawn from different populations is not a rate.

Framing (surface, open kind, attach trigger) is now fixed by whoever
opens the attach instead of being re-supplied per phase, so two
components cannot label one attach two different ways.
The phase timing layer measures every preview phase against an anchor opened
when the host settles on a navigation, and recordPhase fails closed without one.
So until this lands, nothing the runtime components emit reaches the sink:
handshake, capabilities, promotion, retention, recovery and eviction all stay
silent. That failure mode is invisible by construction — the dashboard reads as
a preview nobody opened, not as instrumentation that is half wired.

The anchor opens on every attach including warm ones. Reusing a cold anchor for
a warm re-attach would inherit the cold open's elapsed time and collapse the
"restored within 100 ms" ratio, which reads as a product regression rather than
a measurement bug.

attach_trigger is derived by specificity, first match wins, and unknown is
deliberately not reachable: it counts as an unsanctioned navigation by design,
so labelling something unknown to avoid deciding surfaces as a red target-zero
metric instead of being quietly absorbed.

Warm means this exact document was already attached in this session, which is
what the restore metric is about. The pool exposes no membership query, so this
is tracked here rather than inferred from it.

The spec pins the two labels the metrics turn on rather than merely asserting
something was emitted. Its first case is a diagnostic rather than an independent
control — removing the anchor reddens it too, since the anchor is the switch —
but it separates "the sink was never installed" from "the sink is live and this
phase never fired", which need different fixes.
The component preview understood import/export syntax with four regular
expressions and then downloaded @babel/standalone from unpkg to finish the job
inside the sandbox. Regexes do not understand JavaScript, and the failures were
not subtle. Observed against the previous implementation:

  input   const HINT = "写 export default function 就能预览";
  output  const HINT = "写 就能预览";
          (typeof function !== 'undefined' ? function : null)

The string's contents were edited and the emitted expression was not valid
JavaScript, so a component whose copy happened to mention exporting killed its
own preview outright. `export * from './x'` survived verbatim into a classic
script, which is also a syntax error. A relative import was deleted in silence,
leaving the user with `Button is not defined` and nothing pointing at the cause.

Sucrase parses instead — its parser is a fork of Babel's — and it runs in the
host, so the document receives plain JavaScript. That removes the largest of the
three CDN scripts and lets the compiled source be inlined into a <script> tag
rather than eval'd from a string, which also restores real line numbers in
stack traces. Output is CommonJS; the harness supplies `require` and reads
module.exports. An unresolvable module is now named in the error instead of
disappearing.

Moving compilation into the host moved where a syntax error lands, so it is
caught here and rendered into the same error panel as before. Without that, a
typo in a component would throw inside the viewer's render path and take the
whole file viewer down; the spec covering it went red before this was added.

React and ReactDOM still come from unpkg. Serving them locally is the remaining
half of the offline defect and is left as an explicit todo in the spec rather
than implied by a passing suite.

The existing specs were rewritten rather than deleted: they pinned the old
mechanism, but the authoring shapes they protect — default function export,
named export, renamed hook imports, default re-export — are unchanged and are
now asserted against the compiled output.

Sucrase's CLI depends on commander@4, which took the hoist root and made
@deepseek-ai/dsh-cmdline resolve its types against the wrong major, breaking the
dsh-runtime build. Pinned through pnpm.overrides, alongside the thirteen
overrides already there; only the library entry is imported, never the CLI.
Seven of the eight preview phases already reported; first visible paint did not,
so the one metric users actually feel — how long until something is on screen —
had no source. The capability was already present: the bridge has carried
visiblePaintCount() for white-screen detection all along. It only ever reported
the failure, never the moment it succeeded.

The primary detector relays the browser's own first-contentful-paint entry, a
record it keeps whether or not anyone reads it, so observing costs nothing. A
raf probe covers frames where no paint entry is recorded, and a timeout is the
give-up verdict. In full: one synchronous getEntriesByType at arm, at most one
PerformanceObserver disconnected on its first entry, at most two rAF callbacks,
one cancellable timeout, and at most three of the existing bounded node walks.
No interval, no resident timer, no loop, no DOM observer, no writes.

The probe arms on a weaker predicate than the white-screen check and
deliberately does not wait for readyState complete: a document that never
finishes loading is exactly the one whose measurement is missing, and gating on
complete would have made those rows vanish instead of arriving as timeouts. It
times out ahead of the white-screen window so paint always speaks first, and a
frame taken off screen releases rather than minting a false negative about
something nobody was watching.

The report travels on its own message type rather than as an observability
event. reportPreviewIframeMessage ends in a catch-all that turns any parsed
observability message into a runtime error, so routing it there would have
published one fabricated error per healthy preview.

This phase never gates promotion, retention, discard or reload; the payload
carries observation_only so a dashboard author can see that inside PostHog
without reading the code.
The remaining half of the offline defect stays a todo rather than a passing
assertion, with the approach written where whoever picks it up will read it:
generate the UMD builds from the installed react/react-dom at install and build
time and gitignore the output, so the dependency stays the single source of
truth and no vendored copy can drift. Relocating the fetch to another CDN was
considered and rejected — it moves the external dependency rather than removing
it. Whether that file loads inside the opaque-origin sandbox under the packaged
od:// protocol is unverified and must be run on a real build before claiming it.
The bridge measures the paint and posts it; nothing recorded it. That completes
the eight phases, and it is the one metric a user actually feels — how long
until something is on screen. Leaving it unwired was not a neutral gap: an
empty metric and a fast one look identical on a dashboard, so the absence would
have read as "previews are instant".

The report arrives on its own message type rather than as an observability
event, because that channel ends in a catch-all which turns any parsed
observability message into a runtime error — routing paint through it would
publish one fabricated error for every healthy preview.

Attribution requires a non-null contentWindow before comparing sources. A
detached frame has none and a message with no source has none either, so an
inequality check alone lets anything through at exactly the moment there is no
document to attribute it to.

Also records the working handoff for the pause point, including the measured
compile costs so they are not re-litigated from intuition.
The preview document is its own browsing context with no allow-same-origin, so
it cannot borrow the application's React and has to load a copy over the
network. Loading it from unpkg meant this surface could not render in the
packaged client offline or on machines that cannot reach a CDN — the same
failure class the Preview Lab corpus run attributed 8 of its 12 white screens
to. Compilation had already moved into the host, so the symptom was a readable
"React preview runtime failed to load" rather than a white screen, but the
surface was still unusable.

The UMD builds are staged out of node_modules at install, dev and build time
rather than committed. React is already a dependency of this app, so it stays
the single source of truth: a version bump restages automatically, and there is
no vendored duplicate for anyone to keep in sync. The output is gitignored,
matching how the landing site already handles generated public assets.

Relocating the fetch to another CDN was considered and rejected: it moves the
external dependency instead of removing it, and offline still fails.

Production builds, not development — 139 KB against 1.16 MB, and the harness
renders thrown errors into its own panel either way.

Two things the specs now hold that nothing else would have caught: that the
document reaches no external host at all, and that the paths it references are
actually staged. The second exists because those paths are strings on both
sides with no build-time link between them, so a rename would only ever surface
as a 404 the user meets as a failed runtime.

The staged files are allowlisted in guard as generated vendor output.
…ransport

Presenting a deck promoted the right document and then could not hide its
chrome, because the presentation bridge never reached the document at all.

The bridge was built as a URL-negotiated one (`odPreviewBridge=presentation`),
which works on `/raw` and `/powered` — the paths the runtime convergence moved
off. The settled document now loads from the scoped preview origin, and
`buildPreviewSessionNavigation` only ever appends the passive guards there,
deliberately: "Interactive Deck support is negotiated after navigation; it must
never become part of the document URL." So the token was requested and never
delivered, and measured live the document reported no
`od:deck-presentation-ready` at all.

Putting the switch in the URL is not the fix — it is the thing the convergence
forbids. A URL change is a navigation, so toggling presentation that way would
reload the very document presenting is supposed to keep alive: the audience
would watch it blank and restart at slide one, losing scroll, animation and any
embedded playback.

So it is installed with the rest of the runtime instead, next to
`observability`. That is safe by construction: the bridge only registers a
message listener at parse time and does nothing until the host negotiates, and
the host already posts `od:deck-presentation` on entering presentation. Both
ends existed; only the middle was missing.

This is not a regression — `deck-presentation.ts` does not exist at the merge
base. The bridge is new on this branch and was never wired to the transport it
was built for.

Live, on the deck case, before -> after:

  bridgeReady               false -> true
  acknowledged              false -> true
  receiptAgreesWithDocument false -> true
  hiddenWhilePresenting     false -> true
  restoredAfterExit          true -> true
  document navigations           0 -> 0     (presenting still never navigates)

Red spec carries its own control (`od:preview:hello` must be present, so a
missing bridge is a missing module and not a broken probe) and goes red again
with the module removed. It addresses the scoped origin through a raw Host
header, because `fetch` silently drops one and the first version of the spec
was passing against the wrong endpoint.

Only the chrome-hiding contract is closed here. Click-to-advance now intercepts
during presentation where it previously did not, but does not yet move the
slide (`forwardAdvanced: false`) — not diagnosed, not claimed.

daemon build exit 0, web typecheck exit 0, pnpm guard exit 0; contracts dist
rebuilt for the new capability; 26 tests across the neighbouring daemon suites
green.
@lefarcen lefarcen added size/XXL PR changes 1500+ lines and removed size/XL PR changes 700-1500 lines labels Sep 4, 2026

@mrcfps mrcfps left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@lefarcen thank you for the substantial follow-up on this head. Installing the deck presentation bridge on the scoped origin, serving historical versions as real documents, and staging React from the app instead of a CDN are the right product shape.

I completed a fresh changed-range pass. The retained FileViewer runtime and presentation install look coherent. One merge-safe install issue is inline below: the new web postinstall breaks Docker image builds because the staging script is not in the install layer.

Two related follow-ups, same increment: non-deck plugin example previews now navigate the plugin preview route, so the existing containment CSP blocks CDN Tailwind and fonts that srcdoc used to allow; and Share to Export HTML for React components downloads the in-app vendor script URLs, which do not load outside this app. Relax that plugin preview CSP for iframe navigations, and inline or bundle the staged React files for export.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/package.json
Comment on lines +27 to +30
"postinstall": "pnpm run stage:react-runtime",
"prebuild": "pnpm run stage:react-runtime",
"predev": "pnpm run stage:react-runtime",
"stage:react-runtime": "tsx ./scripts/copy-react-runtime.ts",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The new web postinstall always runs the React runtime copy script. That is correct for a full checkout, but the Docker build still copies only the web package manifest before the frozen lockfile install; the copy script is added later with the rest of apps.

Why it matters: the live image build on this head failed during web postinstall because the script file is not in the install layer, so Docker and Railway images cannot install. The web prebuild hook already restages before the Next build, so postinstall is not required for that later step once install succeeds.

Evidence: this package now declares postinstall plus prebuild and predev staging via the copy script. The Docker install layer special-cases the repo-root postinstall helper, but does not copy this new web script. The failed image job dies in web postinstall before the later apps copy.

Suggested change: drop postinstall and keep predev and prebuild, or copy the web staging script into the Docker install layer next to the web package manifest, or make staging a no-op when the script file is absent so a manifest-only install layer can succeed.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Confirmed still current on head e64849e. Live build-and-push on this head still dies in the install layer: apps/web postinstall runs tsx ./scripts/copy-react-runtime.ts and fails with ERR_MODULE_NOT_FOUND because deploy/Dockerfile copies only apps/web/package.json before pnpm install --frozen-lockfile. The two new commits did not touch this. The suggested fix is unchanged: drop postinstall and keep predev/prebuild, or copy the staging script into the Docker install layer, or make staging a no-op when the script file is absent.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Opening a project URL that names a file could end up showing a different
file — the one with the newest mtime — and then rewrite the URL to match, so
it never self-corrected.

The route-sync effect had no `routeFileName` guard. On the first commit the
tab state has not hydrated, so `target` is null and it navigated with
`fileName: null`, stripping the file out of the URL. That is not a cosmetic
edit: the auto-open guard is `if (routeFileName) return` and it reads the
*live* route, so stripping the file also disarms the only thing stopping
`selectPrimaryProjectFile` — which ranks plain HTML files equally and breaks
the tie on newest mtime — from opening a different file and latching it.

The fix is the idiom already sitting two lines above for the conversation id
(added by #1508 for the same deep-link reason): the routed value keeps
authority until the local state is authoritative.

Reachable with no cached tab state for the project — another machine, another
browser, cleared site data, or a project this browser has never opened — and
needs a slow load, which is why it shows up cold. Cached tab state is what
protects the warm path.

Evidence, by layer, because they prove different things:

* Live, reproduced twice against a real runtime: requested
  `/projects/<p>/files/index.html`, browser settled on
  `/projects/<p>/conversations/<cid>/files/lru-6.html` (the last-created file)
  and stayed there 30s+. The server log shows only two page requests, so the
  navigation was client-side.
* Unit, red before / green after, falsified by reverting the one line: the
  route sync clears the routed file from the URL.
* NOT reproduced at unit level: the end-to-end wrong-file outcome. The
  remaining ordering could not be forced faithfully in jsdom, so that step
  rests on the live reproduction, not on this test.

The existing suite could not have caught any of this:
`ProjectView.tabs-navigation.test.tsx` mocks `navigate` and passes
`routeFileName` as a static prop, so the URL -> `routeFileName` feedback the
defect depends on cannot occur. The new spec wires that loop back up, and its
control confirms auto-selection really does prefer the newest file.

369 tests across all 15 ProjectView suites green; web typecheck exit 0.
Clicking back during a deck presentation could bounce the slide forward again
and settle on the wrong one, roughly one time in three.

A presented deck has more than one voice. The authored document reports its
slide, and so does the injected deck runtime module — instrumenting every
writer showed both answering a single `go`, disagreeing while the move was
still in flight. The host adopted whichever arrived last, and because the
adopted value feeds `deckSlideIndex`, which the transport replays, a stale
answer became a fresh intent. That closes a loop. The measured write trace for
one backward click, with only the transport writing after the two legitimate
moves:

  goToSlide:2, replay:2, goToSlide:1, replay:1,
  replay:2, replay:1, replay:2, replay:1, ...   (up to 59 writes, unbounded)

So a report is only authoritative once it agrees with what the host last asked
for; until then the host is mid-move and a disagreeing report is a stale voice,
not news. The intent expires after 1.5s so a deck that clamps the index, or has
no runtime to answer at all, can never freeze host state — this is a
tie-breaker, not a lock.

Measured on the live product, 8 runs each:

  before   5 pass / 3 fail,  11-59 deck writes per run
  after    7 pass / 0 fail,  exactly 2 writes per run   (1 run blocked)

The write count collapsing to 2 is the real evidence: the loop is gone, not
merely sampled at a luckier moment.

Three earlier theories were tested against the live product and discarded
rather than shipped: the speaker-notes popup (disproven twice — the
oscillation is identical with `window.open` neutralised), a stale closure over
`activeDeckSlideIndex`, and a stale `modeStateRef` in the transport (it is
assigned during render, so it is never stale).

The decision is a pure function with its own spec, including the expiry
boundary, because the wiring itself is not reachable from jsdom.

Pre-existing on this branch, unrelated and not touched here: two FileViewer
tests still assert the old layout where the presentation overlay owned its own
iframe ("allows downloads in the in-tab HTML presentation iframe", "closes deck
in-tab presentation when the present iframe forwards Escape"). Both fail
identically with these changes stashed.

web typecheck exit 0, pnpm guard exit 0, 361 of 363 FileViewer tests pass with
no new failures.
Two FileViewer specs still asserted the pre-convergence layout, where the
presentation overlay owned its own iframe. Presenting now promotes the document
already running, so `.present-overlay iframe` matches nothing and both had been
failing on this branch independently of any recent change.

Neither invariant is stale, only the element they looked at:

* a presented document must still be able to download — checked on the preview
  frame's own sandbox, which carries `allow-downloads`
  (`NORMAL_PREVIEW_FRAME_SANDBOX`), and the overlay is now asserted to own no
  iframe at all
* Escape forwarded from inside the sandboxed document must still tear the
  presentation down — the product handler already accounts for the new layout
  (it matches `ev.source` against the active preview frame); the spec was
  dispatching from the viewer's inert second frame, so it now selects
  `iframe[data-od-active="true"]`

Checked the download case against the product before rewriting it rather than
assuming: had the promoted frame lacked `allow-downloads`, this spec would have
been reporting a real regression instead of a stale selector.

363 of 363 FileViewer tests pass; web typecheck exit 0; pnpm guard exit 0.

@mrcfps mrcfps left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@lefarcen thank you for the deeplink file-wins fix, the deck-slide intent helper, and for pointing the presentation unit specs at the promoted frame. That is careful work on a hard runtime.

I completed a fresh changed-range pass on this head. Three merge-safe items are inline: the required presentation P0 still looks for an overlay iframe the unit tests now assert is gone, the new slide-intent gate is only armed from goToSlide, and exported React HTML still points at in-app /vendor/react-runtime URLs.

The existing Docker postinstall thread on apps/web/package.json is still open and confirmed by live build-and-push: install still runs stage:react-runtime before COPY apps, so copy-react-runtime.ts is missing and the image cannot install.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Comment on lines +4394 to +4404
// The invariant is unchanged — a presented document must still be able to
// download — but the frame it belongs to moved. Presenting promotes the
// document already running instead of minting a second one, so the overlay
// owns no iframe and the sandbox to check is the preview's own.
await waitFor(() => {
const frame = document.body.querySelector('.present-overlay iframe');
expect(frame?.getAttribute('sandbox')).toBe('allow-scripts allow-downloads');
expect(frame?.getAttribute('data-od-render-mode')).toBe('url-load');
expect(container.querySelector('.html-viewer.is-tab-present')).toBeTruthy();
});
expect(container.querySelector('.html-viewer.is-tab-present')).toBeTruthy();
expect(document.body.querySelector('.present-overlay iframe')).toBeNull();
const presentedFrame = container.querySelector('.viewer.is-tab-present iframe');
expect(presentedFrame).not.toBeNull();
expect(presentedFrame?.getAttribute('sandbox')).toContain('allow-downloads');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The unit tests now correctly assert that presenting promotes the running preview: this block expects .present-overlay iframe to be null and checks sandbox on .viewer.is-tab-present iframe instead.

The required P0 was not updated the same way. e2e/ui/app-manual-edit.test.ts:791 still does overlay.frameLocator('iframe[title="present"]') and waits for the "Slide One" heading. Live CI UI P0 (project-workspace-editor) failed that assertion on both the initial run and the retry (element(s) not found, 10s timeout).

Why it matters: this is a required P0. The product change is intentional — these unit tests document that the overlay owns no iframe — so the lane stays red even when in-tab presentation works.

Suggested change: retarget that e2e to the promoted active preview frame, matching this spec and the Escape test below. For example page.locator('.viewer.is-tab-present iframe[data-od-active="true"]') (or artifactPreviewFrame(page)). Keep asserting the overlay is visible and that Exit presentation still closes it.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Confirmed still current on head e64849e. e2e/ui/app-manual-edit.test.ts:791 still does overlay.frameLocator('iframe[title="present"]') and waits for the "Slide One" heading. The unit specs at this FileViewer block still expect .present-overlay iframe to be null. The two new commits did not retarget that P0. Please point the e2e at the promoted active preview frame (for example page.locator('.viewer.is-tab-present iframe[data-od-active="true"]') or artifactPreviewFrame(page)), keep asserting the overlay is visible, and keep Exit presentation closing it.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Comment on lines +12456 to +12462
const decision = decideDeckSlideReport(
pendingDeckSlideIntentRef.current,
data.active,
Date.now(),
);
if (decision === 'ignore') return;
if (decision === 'adopt-and-settle') pendingDeckSlideIntentRef.current = null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This new gate ignores a disagreeing od:slide-state only while pendingDeckSlideIntentRef is set. Two holes keep the measured 1↔2 replay loop reachable.

Pending is armed only in goToSlide (line 13971). Toolbar, keyboard, and the presentation-overlay prev/next controls still call postSlide('next'|'prev') without arming it. The queued-send slideNavRequest effect (lines 15653–15655) writes host slide state — and on the converged path lets PreviewRuntimeTransport replay od:slide go — without arming it either. decideDeckSlideReport(null, …) returns 'adopt', so a stale sibling report becomes host state and is echoed as a fresh go.

On 'adopt-and-settle' this handler also clears pending immediately. The bug this helper documents is two voices per go. If the confirming report arrives first, the late stale report sees pending === null and is adopted — same echo through deckSlideIndexreplayPreviewBridgeModes.

Suggested change: route host-initiated moves through goToSlide (or set pending before postSlide / setSlideState). After a matching confirm, keep ignoring mismatches until DECK_SLIDE_INTENT_TIMEOUT_MS without adopting them. Add a FileViewer test: goToSlide(1), then od:slide-state 1, then 2 within the window must not change host state or post go(2); slideNavRequest to 1 must arm the same gate.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Confirmed still current on head e64849e. pendingDeckSlideIntentRef is still armed only in goToSlide (now around 13978). postSlide('next'|'prev') still posts without arming it, and the slideNavRequest effect (15650–15662) still writes host slide state without arming it. decideDeckSlideReport(null, …) still returns 'adopt', so a stale sibling report can still become host state and echo as a fresh go. The two new commits did not change this path. Please route host-initiated moves through goToSlide (or set pending before postSlide / setSlideState), and after a matching confirm keep ignoring mismatches until DECK_SLIDE_INTENT_TIMEOUT_MS without adopting them.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Comment on lines +15 to +16
const REACT_URL = '/vendor/react-runtime/react.production.min.js';
const REACT_DOM_URL = '/vendor/react-runtime/react-dom.production.min.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These origin-root /vendor/react-runtime/… URLs are correct for the in-app srcdoc: the preview resolves them against the Open Design origin after stage:react-runtime. exportReactComponentAsHtml in apps/web/src/runtime/exports.ts (around line 830) downloads buildReactComponentSrcdoc() unchanged, so a file opened outside this app 404s on React — the same white-screen class the CDN removal was meant to kill. react-component.test.ts currently locks the /vendor/ URL in as the success contract.

Why it matters: Share → Export HTML is a user-facing download. The in-app preview can look fine while the exported file is blank.

Suggested change: keep host-root URLs for in-app preview; for export, inline the staged UMDs or emit sibling files with relative src. Split the test so the in-app contract can stay /vendor/ while exported HTML must not depend on the Open Design origin.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Confirmed still current on head e64849e. REACT_URL / REACT_DOM_URL are still origin-root /vendor/react-runtime/…, and exportReactComponentAsHtml in apps/web/src/runtime/exports.ts still downloads buildReactComponentSrcdoc() unchanged. The two new commits did not split the in-app vs export contracts. Please keep host-root URLs for in-app preview; for export, inline the staged UMDs or emit sibling files with relative src. Split the test so the in-app contract can stay /vendor/ while exported HTML must not depend on the Open Design origin.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Saving from Manual Edit and leaving replaced the whole preview document, so the
page lost everything it was holding — canvas pixels, timers, scroll, form
state. Entering and leaving without saving was fine, which is what made this
look like a scroll-restore problem rather than a document swap.

There is already a mechanism to prevent exactly this. `syncRetainedManualEditDocument`
sets a latch once the Manual Edit bridge has applied the exact persisted bytes
to the live document, and `previewRuntimeCanAdoptPersistedManualEditDocument`
reads it to keep the document identity stable through the watcher echo the save
produces. The comments there say the latch is meant to be consumed after Edit
closes.

`exitManualEditModeAfterFlush` cleared it in the same synchronous block that
closed edit mode, one step before the render that would have read it — so it
was always null at the point of use and the mechanism had never once taken
effect. The latch releases itself when the source genuinely differs, so
clearing it on exit was both redundant and harmful.

Measured on the live product with a temporary probe at the decision point:

  probe   patchKind: set-style, matched: true      <- "no reload needed"
  witness document reborn 2.7s later, canvas ink 0 <- replaced anyway

Set correctly, then discarded before it could be used. User confirmed the fix
against the same reproduction.

The adopt rule is extracted to `manual-edit-document-latch.ts` with its own
spec, including the two self-release cases, because the call site is an inline
expression inside a 20k-line component and the invariant deserves to be
readable on its own.

Also disproven along the way, and worth not re-investigating: `scroll` is in
`BASE_VIEWER_CAPABILITIES` and is never disabled by an edit-mode exit, so the
`&&scrollEnabled` drop in the scroll runtime module is not reachable this way.
Lost scroll was a symptom of the document swap, not a restore failure.

Adjacent, verified, not fixed here: `od:preview-scroll-capture` has no receiver
on the converged transport — only the legacy srcDoc bridge implements it — so
the exact-capture round trip always times out at its 120ms budget and silently
degrades to the last unsolicited scroll report.

369 tests across FileViewer plus the new spec pass; web typecheck exit 0.
… transport

The host cannot read scroll out of an opaque-origin preview document, so before
a mode change it asks the document for its exact position and waits on that
answer, keyed by `requestId`, with a 120ms budget.

Only the legacy srcDoc bridge ever implemented that request
(`preview-runtime/src/srcdoc.ts`). The scroll runtime module — which is what
the converged transport actually installs — handled `-restore`, `-by` and
`-content-size-request` and simply ignored `-capture`, and its `postScroll()`
had no way to carry a `requestId` even if it had. So on the transport this
refactor moved everything onto, every capture burned the full 120ms and then
silently degraded to whatever unsolicited scroll report happened to be most
recent.

Two costs, both silent: a dead 120ms await before every manual-edit commit that
takes this path, and a scroll restore working from an approximate position
instead of the exact one it asked for.

`postScroll` now takes an optional id and echoes it only when answering a
request — an unsolicited report must stay unsolicited, because the host routes
the two through completely different branches.

Red spec drives the module through the real bootstrap and asserts the reply
carries the id; it goes red again with the handler removed. 91 daemon http
tests pass, daemon typecheck 0, daemon build 0, pnpm guard 0.

Found while tracing a different defect (state lost on leaving Manual Edit),
which turned out to be a document swap rather than a restore failure — this one
is real but was not the cause of that.

@mrcfps mrcfps left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@lefarcen thank you for the Manual Edit latch fix and for answering exact-scroll capture on the converged transport. Those two commits look right for ordinary HTML: leaving Edit no longer drops the latch before the next render can freeze document identity, and the scroll module now echoes requestId so the host's 120ms capture can resolve.

I completed another full changed-range pass on this head. One new coupling issue is inline: the runtime adopt path keys the latch on raw source, but a later retire in the same render keys it on livePreviewSource, which undoes the keep-the-document fix for decks.

The four existing threads are still current here (Docker postinstall / image build, presentation P0 overlay iframe, slide-intent arming, and exported React HTML). I am replying on those threads rather than duplicating them.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Comment on lines +9553 to +9560
const previewRuntimeCanAdoptPersistedManualEditDocument =
shouldAdoptPersistedManualEditDocument({
manualEditMode,
manualEditSrcDocActive,
latch: manualEditPersistedDocumentRef.current,
reloadKey,
sourceFingerprint: source === null ? null : previewSourceFingerprint(source),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This new adopt call keys the Manual Edit latch on raw source, but a later retire in the same render still keys it on a different byte domain, so the keep-the-document fix does not hold for decks.

shouldAdoptPersistedManualEditDocument here compares previewSourceFingerprint(source) with the latch armed from persisted file bytes in syncRetainedManualEditDocument. That is the identity freeze dade39a needs. Later in the same HtmlViewer render, unchanged code at 10656–10672 still does:

const canAdoptPersistedManualEditDocument =
  __omp_shell("interactivePreviewModeActive")
  && persistedManualEditDocument?.reloadKey === reloadKey
  && livePreviewSource != null
  && persistedManualEditDocument.sourceFingerprint === previewSourceFingerprint(livePreviewSource);
if (
  __omp_shell("interactivePreviewModeActive")
  && persistedManualEditDocument
  && livePreviewSource != null
  && !canAdoptPersistedManualEditDocument
) {
  manualEditPersistedDocumentRef.current = null;
}

livePreviewSource is inlinedSource ?? deckVisualSource, and for a deck deckVisualSource is normalizeDeckVisualSource(removeSpeakerNotesFromHtml(source)). normalizeDeckVisualSource strips whitespace before </body> and trimEnd()s, so pretty-printed decks have fp(livePreviewSource) !== fp(source) even with no speaker notes.

What happens after a saved deck edit closes:

  1. First render: this adopt is true (source matches) → previewRuntimeRevisionIdentity stays frozen.
  2. Same render: the retire sees a fingerprint miss and clears the latch in place (no setState).
  3. The watcher echo (new mtime / content-refresh, same bytes) re-renders with the latch already gone → identity updates → /preview-url remint → the browsing context is replaced.

That is the document-swap this commit measured and claimed to stop (canvas pixels, timers, scroll, form state). Ordinary personal non-deck HTML is unaffected because livePreviewSource === source there.

Inverse of the same coupling: on the converged URL-load path, setInlinedSource(null) returns immediately, so team HTML with relative assets keeps livePreviewSource === null and the retire never runs. A later revert to the saved bytes can then re-adopt and skip a remint — the case the retire comment says must not happen.

Suggested change: retire the runtime latch with the same fingerprint domain as this adopt (source === null ? null : previewSourceFingerprint(source)), via shouldAdoptPersistedManualEditDocument. Keep the srcDoc freeze on livePreviewSource if that transport still needs it, but do not share that boolean as the runtime latch self-release. A FileViewer regression that saves a pretty-printed deck from Manual Edit and asserts the same iframe node survives the watcher echo would lock this in.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Entering or leaving presentation flashed solid black for ~350 ms, roughly one
attempt in six. Every measurement showed `navigationCount: 0` and
`frameLoads: 0`, so nothing reloaded — the preview frame is relocated to a new
host, the browser recomposites, and for a frame the new box is empty.

The flash was black rather than merely empty because
`.viewer.is-tab-present .viewer-body` changed layout AND painted an opaque
ground in the same rule, so the ground composited while the document had not
yet repainted into its new box. The ground is not decoration — slides that do
not fill the window need it for letterbox bars — so it moves rather than goes:
`.present-backdrop` is its own fixed layer beneath the promoted preview, whose
box never changes, mounted with the presentation and faded in once the document
has painted.

Three earlier attempts are recorded in the handoff because they are the
instructive part. Delaying the ground by a frame fixed entry only. Dropping it
before the layout in the same handler did nothing, since React batches both
updates into one commit — so "first" and "second" were the same render. Two
`requestAnimationFrame`s survived eight deck-only runs, then flashed on the
next FULL run, where deck is the third case and the browser is busier. All
three tried to TIME the ground against a reflow, which is a bet that loses when
load changes; a constant box removes the possibility instead.

Each wrong version was caught by running again, never by re-reading the code.

Live, deck case: before, ~1 in 6 at ~350 ms. After, three full rounds — nine
case-transitions under the load that reproduced it — all 0 ms, with presenting
still hiding authored deck chrome and restoring it on exit.

The underlying relocation flash is NOT fixed and is now documented as one item
rather than five: without an opaque ground the empty frame shows the page
background instead of black, still measurable on file-tab, project and LRU
transitions. Fixing that means changing what the frame shows while it moves,
which touches every preview path and is an architectural decision — five
separate timing patches would repeat exactly the mistake above.

367 FileViewer tests plus the new spec pass; web typecheck 0; pnpm guard 0.

@mrcfps mrcfps left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@lefarcen thank you for the presentation-backdrop follow-up. Moving the letterbox ground onto .present-backdrop so .viewer.is-tab-present .viewer-body can stay transparent is the right shape for the black flash you measured.

I completed another full changed-range pass on this head. The four-file presentation commit does not introduce a new issue. The five existing threads remain current on apps/web/package.json (postinstall), e2e/ui/app-manual-edit.test.ts (presentation P0 iframe), FileViewer.tsx (pendingDeckSlideIntentRef and the Manual Edit latch), and apps/web/src/runtime/react-component.ts (REACT_URL). Detailed confirmation is inline.

Because this reviewer has already reached the three-request-changes cap for this PR, I am leaving these as COMMENT for maintainer follow-up rather than opening another blocking review. Grateful for the careful iteration on a hard runtime.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/package.json
"typecheck": "tsc -b --noEmit",
"test": "vitest run -c vitest.config.ts --maxWorkers=2"
"dev": "next dev --turbopack",
"postinstall": "pnpm run stage:react-runtime",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The new web postinstall still always runs the React runtime copy script. That is correct for a full checkout, but the Docker build still copies only the web package manifest before the frozen lockfile install; the copy script is added later with the rest of apps.

Why it matters: live build-and-push on this head (9a7ee07, run 33908357612) still fails in the install layer. apps/web postinstall runs tsx ./scripts/copy-react-runtime.ts and dies with ERR_MODULE_NOT_FOUND because deploy/Dockerfile copies only apps/web/package.json before pnpm install --frozen-lockfile. Docker and Railway images cannot install. The presentation-backdrop commit did not touch this path. The web prebuild hook already restages before the Next build, so postinstall is not required for that later step once install succeeds.

Evidence: this postinstall line is still present, Dockerfile still copies the web manifest at line 29 then installs at lines 31–33, and only afterwards COPY apps ./apps. The failed job log shows Cannot find module '/app/apps/web/scripts/copy-react-runtime.ts'.

Suggested change: drop postinstall and keep predev/prebuild, or copy apps/web/scripts/copy-react-runtime.ts into the Docker install layer, or make staging a no-op when the script file is absent.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still current on 427ee93. This path-validation commit does not touch the web install layer.

apps/web/package.json still has unconditional postinstall: pnpm run stage:react-runtime, and deploy/Dockerfile still copies only apps/web/package.json before pnpm install --frozen-lockfile. Live build-and-push on this head still fails with ERR_MODULE_NOT_FOUND for /app/apps/web/scripts/copy-react-runtime.ts.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

});
expect(container.querySelector('.html-viewer.is-tab-present')).toBeTruthy();
expect(document.body.querySelector('.present-overlay iframe')).toBeNull();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The unit tests now correctly assert that presenting promotes the running preview: this block expects .present-overlay iframe to be null and checks sandbox on .viewer.is-tab-present iframe instead.

The required P0 was not updated the same way. e2e/ui/app-manual-edit.test.ts:791 still does overlay.frameLocator('iframe[title="present"]') and waits for the "Slide One" heading. The new .present-backdrop portal has no iframe and does not retarget that P0.

Why it matters: this is a required P0. The product change is intentional — presenting no longer mints a second browsing context — so the e2e as written cannot observe the slide and will time out looking for an overlay iframe that this test already asserts is gone.

Evidence: this added assertion is expect(document.body.querySelector('.present-overlay iframe')).toBeNull(), while e2e/ui/app-manual-edit.test.ts lines 789–793 still locate .present-overlay then iframe[title="present"]. Those e2e lines are unchanged on this head.

Suggested change: point the e2e at the promoted active preview frame (for example page.locator('.viewer.is-tab-present iframe[data-od-active="true"]') or artifactPreviewFrame(page)), keep asserting the overlay is visible, and keep Exit presentation closing it.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still current on 427ee93. This commit does not update the presentation P0.

e2e/ui/app-manual-edit.test.ts:791 still does overlay.frameLocator('iframe[title="present"]'). Live UI P0 (project-workspace-editor) on this head still fails waiting for .present-overlay iframe[title="present"] → heading Slide One.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Date.now(),
);
if (decision === 'ignore') return;
if (decision === 'adopt-and-settle') pendingDeckSlideIntentRef.current = null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This gate ignores a disagreeing od:slide-state only while pendingDeckSlideIntentRef is set. Two holes keep the measured 1↔2 replay loop reachable.

Pending is armed only in goToSlide (line 13982). Toolbar, keyboard, and the presentation-overlay prev/next controls still call postSlide('next'|'prev') without arming it. The queued-send slideNavRequest effect (lines 15686–15697) writes host slide state — and on the converged path lets PreviewRuntimeTransport replay od:slide go — without arming it either. decideDeckSlideReport(null, …) returns 'adopt', so a stale sibling report can still become host state and echo as a fresh go.

Why it matters: presenting a deck still has two reporters. A host-initiated prev/next that never sets pending will adopt the in-flight disagreeing report and re-enter the oscillation this helper was added to stop.

Evidence: pendingDeckSlideIntentRef.current = { index: target, atMs: Date.now() } exists only in goToSlide. postSlide at 13958 has no pending write. Keyboard 14251/14254, toolbar 16950/16967, and overlay 18169/18186 still call postSlide('next'|'prev'). The presentation-backdrop commit did not change this path.

Suggested change: route host-initiated moves through goToSlide (or set pending before postSlide / setSlideState), and after a matching confirmation clear the pending intent as this adopt-and-settle branch already does.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still current on 427ee93. This commit does not touch deck slide intent.

pendingDeckSlideIntentRef is still armed only in goToSlide (around 13982). Toolbar, keyboard, and the presentation overlay still call postSlide('next'|'prev') directly, so a disagreeing od:slide-state is still adopted.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

// not committed. Relative to the embedding document, so a preview served from
// any origin resolves them against that origin.
const REACT_URL = '/vendor/react-runtime/react.production.min.js';
const REACT_DOM_URL = '/vendor/react-runtime/react-dom.production.min.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These origin-root /vendor/react-runtime/… URLs are correct for the in-app srcdoc: the preview resolves them against the Open Design origin after stage:react-runtime. exportReactComponentAsHtml in apps/web/src/runtime/exports.ts (around line 830) still downloads buildReactComponentSrcdoc() unchanged, so a file opened outside this app 404s on React — the same white-screen class the CDN removal was meant to kill.

Why it matters: Share → Export HTML is a user-facing download. The in-app preview can look fine while the exported file is blank anywhere that is not this origin.

Evidence: REACT_URL / REACT_DOM_URL are still /vendor/react-runtime/react.production.min.js and …/react-dom.production.min.js. exportReactComponentAsHtml still does const doc = buildReactComponentSrcdoc(source, { title }) with no export-specific URLs. The presentation-backdrop commit did not split the in-app vs export contracts.

Suggested change: keep host-root URLs for in-app preview; for export, inline the staged UMDs or emit sibling files with relative src. Split the test so the in-app contract can stay /vendor/ while exported HTML must not depend on the Open Design origin.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still current on 427ee93. This commit does not touch the React runtime URLs.

REACT_URL / REACT_DOM_URL in apps/web/src/runtime/react-component.ts are still origin-root /vendor/react-runtime/…. exportReactComponentAsHtml still serializes buildReactComponentSrcdoc() unchanged, so a saved export still cannot resolve those scripts.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

manualEditSrcDocActive,
latch: manualEditPersistedDocumentRef.current,
reloadKey,
sourceFingerprint: source === null ? null : previewSourceFingerprint(source),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This adopt call keys the Manual Edit latch on raw source, but a later retire in the same render still keys it on a different byte domain, so the keep-the-document fix does not hold for decks.

shouldAdoptPersistedManualEditDocument here compares previewSourceFingerprint(source) with the latch armed from persisted file bytes in syncRetainedManualEditDocument. That is the identity freeze dade39a needs. Later in the same HtmlViewer render, unchanged code at 10660–10676 still does:

const canAdoptPersistedManualEditDocument =
  __omp_shell("interactivePreviewModeActive")
  && persistedManualEditDocument?.reloadKey === reloadKey
  && livePreviewSource != null
  && persistedManualEditDocument.sourceFingerprint === previewSourceFingerprint(livePreviewSource);

For decks, livePreviewSource is inlinedSource ?? deckVisualSource, not raw source. A matching persist can therefore be adopted here and then retired in the same render because the fingerprints disagree.

Why it matters: leaving Manual Edit on a deck can still drop the latch before the next render consumes it, which is the reload-the-live-document bug this latch was added to stop.

Evidence: adopt fingerprint is source at this line; retire fingerprint is livePreviewSource at 10664. livePreviewSource is assigned at 10212–10214 as inlinedSource ?? deckVisualSource. The presentation-backdrop commit did not change either domain.

Suggested change: key both adopt and retire on the same byte domain the document actually displays (livePreviewSource / previewSource), and do not retire in the same render that just adopted.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still current on 427ee93. This commit does not touch the Manual Edit latch.

shouldAdoptPersistedManualEditDocument here still receives previewSourceFingerprint(source) (raw source). The later retire path still keys a different byte domain, so the keep-the-document fix still does not hold for decks.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

@lefarcen

lefarcen commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Rechecked 9a7ee07. The remaining blockers are already covered by @mrcfps’s latest review/comments on this head: apps/web/package.json still breaks the Docker install layer, e2e/ui/app-manual-edit.test.ts still targets an overlay iframe that the runtime no longer renders, the deck slide-intent guard still misses the toolbar/keyboard/overlay paths, the Manual Edit latch still adopts and retires against different fingerprint domains for decks, and exported React HTML still depends on in-app /vendor/react-runtime/* URLs.

Once those are fixed and the failing checks are green again, this should be ready for another pass.

…rite

Writing a file whose name starts with `./` failed. `./x` is how HTML writes a
sibling reference and names exactly the same file as `x`, so a document could
declare a resource the write API then refused.

Found by running real artifacts through the product: 12 of 400 design-system
artifacts failed on `./DESIGN.md`, and an earlier batch failed on
`./assets/template.html`. Reduced against the running daemon, where the shape
is unmistakable:

    DESIGN.md              -> 200
    ./DESIGN.md            -> 500
    assets/template.html   -> 200
    ./assets/template.html -> 500

`validateProjectPath` rejected `.` and `..` with one pattern, but they are not
the same thing: `..` escapes the project and must stay rejected, while `.` is a
no-op segment. It is now dropped during normalisation rather than rejected.

The traversal guard is unchanged and verified live after the fix, including the
combination that would matter if `.` were handled carelessly:

    ../escape.txt           still rejected
    ./../escape.txt         still rejected   <- `./` cannot smuggle a `..`
    /abs.txt                still rejected
    ./.file-versions/x.html still rejected   <- reserved internal directory

Normalisation is verified by what lands on disk, not just by the status code:
after writing `./DESIGN.md` and `assets/./styles.css` the project lists
`DESIGN.md` and `assets/styles.css`.

Spec goes red again with the filter reverted; 28 tests across the neighbouring
path/security suites pass; daemon typecheck 0, daemon build 0, pnpm guard 0.

Separate and NOT changed here: these validation failures surface as 500 rather
than 400. A rejected path is bad input, not a server fault, and the status
should say so — recorded in the handoff rather than folded into this fix.

@mrcfps mrcfps left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@lefarcen thank you for the careful ./ path follow-up. Dropping no-op . segments while keeping .., empty names, and reserved directories rejected is the right split, and the new project-path-dot-segments cases match the measured DESIGN.md / assets/template.html 500s.

I reviewed the two-file delta on this head (validateProjectPath plus the new daemon tests) and re-checked the open threads against the live tree and CI. This commit does not introduce a new issue.

The five existing threads remain current, so I am not duplicating them as new inline comments:

  • apps/web/package.json still runs stage:react-runtime on every postinstall, and deploy/Dockerfile still copies only the web manifest before pnpm install. Live build-and-push on this head still dies with ERR_MODULE_NOT_FOUND for copy-react-runtime.ts.
  • e2e/ui/app-manual-edit.test.ts still looks for .present-overlay iframe[title="present"]. UI P0 (project-workspace-editor) failed on this head at that locator.
  • pendingDeckSlideIntentRef is still armed only in goToSlide; toolbar/keyboard postSlide('next'|'prev') still skip it.
  • REACT_URL / REACT_DOM_URL in apps/web/src/runtime/react-component.ts are still origin-root /vendor/react-runtime/….
  • Manual Edit latch adopt in FileViewer still fingerprints raw source, so the keep-the-document fix still does not hold for decks.

Happy to re-review once those threads move. The path fix itself is in good shape.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Saving a single HTML file re-navigated the retained previews of every other
open document. Measured live through the L2 harness: saving `index.html`
reloaded `lru-5.html` and `lru-6.html`, each coming back with an unchanged
content hash and a brand-new preview session — proof they were reloaded
because of bookkeeping, not because their content moved. A reload discards
the document's JS heap, timers, canvas and scroll, which is exactly the loss
the retained-frame runtime exists to prevent.

Three writes reach the file watcher for one user-visible save, and only one
of them is user content:

- the daemon's own `.file-versions` snapshot and its manifest, which the
  daemon refuses user writes into and excludes from member mirrors;
- the `<name>.artifact.json` sidecar, generated metadata the daemon hides
  from the file listing and the app hides from the file tree;
- the document itself.

The first is now silenced at the watcher, sourced from the same reserved-
segment set the write API already enforces so the two cannot drift. The
second is attributed to the document it describes. The third scopes to
itself: an HTML document is authored to be opened, not referenced, so
editing one does not entitle it to disturb the others. Appearing and
disappearing files keep the project-wide refresh, because a reference that
just started or stopped resolving leaves an open page showing real breakage
that only a reload repairs.

The retained-frame pool follows the same rule, so a scoped change no longer
destroys the parked browsing contexts of documents that did not change.

Residual risk, narrow and nameable: page A embeds page B, B is edited, and A
keeps showing the previous B until anything else in the project changes or A
is reloaded by hand.

@mrcfps mrcfps left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@lefarcen thank you for pinning one HTML save to that document. ProjectView.handleProjectEvent now scopes an existing HTML change through changeScopesToItself, artifactManifestSubjectPath attributes *.artifact.json to its subject, and makeIgnored / WATCHER_ONLY_IGNORE_NAMES silence RESERVED_PROJECT_FILE_SEGMENTS. The new cases in ProjectView.html-change-scope.test.tsx and project-watchers.test.ts make that line hold.

These two commits do not touch the five still-open threads; the inline notes below are replies on those same locations.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/package.json
"typecheck": "tsc -b --noEmit",
"test": "vitest run -c vitest.config.ts --maxWorkers=2"
"dev": "next dev --turbopack",
"postinstall": "pnpm run stage:react-runtime",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still current on f7e73fd. These two commits do not touch the web install layer.

apps/web/package.json still has unconditional postinstall: pnpm run stage:react-runtime, and deploy/Dockerfile still copies only apps/web/package.json before pnpm install --frozen-lockfile. Live build-and-push on this head still fails with ERR_MODULE_NOT_FOUND for apps/web/scripts/copy-react-runtime.ts during the install layer.

Please drop postinstall (keep prebuild / predev), or copy the staging script into the Docker install layer before pnpm install.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still current on b4eaa06. The new commit is docs-only and does not touch the web install layer.

apps/web/package.json still has unconditional postinstall: pnpm run stage:react-runtime (tsx ./scripts/copy-react-runtime.ts). deploy/Dockerfile still copies only apps/web/package.json at line 29, then runs pnpm install --frozen-lockfile at lines 31–33, and only copies apps afterward at line 35. Live build-and-push on this head (run 33944910082) still fails with ERR_MODULE_NOT_FOUND for apps/web/scripts/copy-react-runtime.ts.

Suggested fix: copy apps/web/scripts/copy-react-runtime.ts into the install layer before pnpm install, or make postinstall a no-op when that script is absent and keep prebuild / predev as the real staging hooks.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

});
expect(container.querySelector('.html-viewer.is-tab-present')).toBeTruthy();
expect(document.body.querySelector('.present-overlay iframe')).toBeNull();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still current on f7e73fd. These two commits do not retarget the presentation P0.

This unit spec still expects .present-overlay iframe to be null and checks sandbox on .viewer.is-tab-present iframe. e2e/ui/app-manual-edit.test.ts:791 still does overlay.frameLocator('iframe[title="present"]') and waits for the "Slide One" heading, so the required UI job is still looking for a frame the unit tests now assert is gone.

Please point that P0 at the promoted active preview frame (for example page.locator('.viewer.is-tab-present iframe')) so the e2e contract matches this block.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still current on b4eaa06. The docs-only commit does not retarget the presentation P0.

This unit spec still expects .present-overlay iframe to be null and checks sandbox on .viewer.is-tab-present iframe. e2e/ui/app-manual-edit.test.ts:791 still does overlay.frameLocator('iframe[title="present"]') and waits for the "Slide One" heading, so the required UI job is still looking for a frame the unit tests now assert is gone. Live UI P0 (project-workspace-editor) is red on this head.

Suggested fix: retarget that P0 to the promoted .viewer.is-tab-present iframe, matching the unit spec in this file.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Date.now(),
);
if (decision === 'ignore') return;
if (decision === 'adopt-and-settle') pendingDeckSlideIntentRef.current = null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still current on f7e73fd. These two commits do not touch deck slide intent.

pendingDeckSlideIntentRef is still armed only in goToSlide (around 13982). Toolbar, keyboard, and the presentation overlay still call postSlide('next'|'prev') without arming it, and the slideNavRequest effect (15686–15698) still writes host slide state without arming it. decideDeckSlideReport(null, …) still returns 'adopt', so a stale sibling report can still become host state and echo.

Please arm the same pending intent from every host-initiated move (postSlide('next'|'prev') and the queued slide-nav effect), not only goToSlide.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still current on b4eaa06. The docs-only commit does not touch deck slide intent.

pendingDeckSlideIntentRef is still armed only in goToSlide (line 13982). postSlide (lines 13958–13974) still posts next / prev without arming it, so toolbar, keyboard, and presentation controls stay unguarded. The slideNavRequest effect (lines 15686–15706) still writes host slide state without arming it, and decideDeckSlideReport(null, …) still receives a disagreeing od:slide-state as news.

Suggested fix: arm pendingDeckSlideIntentRef with the intended index inside postSlide for next / prev, and in the slideNavRequest effect before setSlideState.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

// not committed. Relative to the embedding document, so a preview served from
// any origin resolves them against that origin.
const REACT_URL = '/vendor/react-runtime/react.production.min.js';
const REACT_DOM_URL = '/vendor/react-runtime/react-dom.production.min.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still current on f7e73fd. These two commits do not split the in-app vs export React contracts.

REACT_URL / REACT_DOM_URL here are still origin-root /vendor/react-runtime/…. exportReactComponentAsHtml in apps/web/src/runtime/exports.ts still serializes buildReactComponentSrcdoc() unchanged, so a file opened outside this app still 404s on React — the same white-screen class the CDN removal was meant to close.

Please keep host-root URLs for in-app preview; for export, inline the staged UMDs or rewrite those script tags to file-relative copies that travel with the downloaded HTML.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still current on b4eaa06. The docs-only commit does not split the in-app vs export React contracts.

REACT_URL / REACT_DOM_URL here are still origin-root /vendor/react-runtime/…. exportReactComponentAsHtml in apps/web/src/runtime/exports.ts (lines 829–832) still downloads buildReactComponentSrcdoc() unchanged, so a file opened outside this app 404s on React.

Suggested fix: keep origin-root URLs for in-app srcdoc, and have the export path inline or rewrite to file-relative bundled React so a downloaded HTML file does not 404.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

manualEditSrcDocActive,
latch: manualEditPersistedDocumentRef.current,
reloadKey,
sourceFingerprint: source === null ? null : previewSourceFingerprint(source),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still current on f7e73fd. These two commits do not touch the Manual Edit latch.

shouldAdoptPersistedManualEditDocument here still receives previewSourceFingerprint(source) (raw source). The later retire path still keys livePreviewSource (inlinedSource ?? deckVisualSource), so a deck whose visual bytes differ from persisted file bytes still drops the latch in the same render and replaces the document the bridge already applied.

Please fingerprint the same byte domain on adopt, arm, and retire — livePreviewSource (or the saved bytes the bridge actually applied), not raw source.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still current on b4eaa06. The docs-only commit does not touch the Manual Edit latch.

shouldAdoptPersistedManualEditDocument here still receives previewSourceFingerprint(source) (raw source at line 9563). The later retire path (lines 10660–10676) still keys livePreviewSource (inlinedSource ?? deckVisualSource), so a deck whose visual bytes differ from persisted file bytes still drops the latch in the same render and replaces the document the save just proved exact.

Suggested fix: pass the same fingerprint domain to both adopt and retire — either previewSourceFingerprint(livePreviewSource) at this call, or one shared helper both sites call.

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

@mrcfps mrcfps left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@lefarcen thank you for the handoff note on the dot-entry line and the agent re-probe cost. collectFiles in apps/daemon/src/projects.ts still skips every e.name.startsWith('.') entry at line 306, and detectAgents in apps/daemon/src/runtimes/detection.ts still probes every agent def per request at line 789; those notes correctly stay out of this branch.

I completed a fresh changed-range pass on this head. The only delta since the last review is that docs commit, so there is no new runtime finding. These five existing threads remain current, and I confirmed them there rather than opening duplicates:

  • apps/web/package.json postinstall / stage:react-runtime still runs tsx ./scripts/copy-react-runtime.ts while deploy/Dockerfile copies only the web manifest before pnpm install --frozen-lockfile, so live build-and-push still dies with ERR_MODULE_NOT_FOUND.
  • e2e/ui/app-manual-edit.test.ts line 791 still does overlay.frameLocator('iframe[title="present"]') even though apps/web/tests/components/FileViewer.test.tsx now expects .present-overlay iframe to be null.
  • pendingDeckSlideIntentRef in apps/web/src/components/FileViewer.tsx is still armed only in goToSlide; postSlide('next'|'prev') and the slideNavRequest effect still do not arm it.
  • REACT_URL / REACT_DOM_URL in apps/web/src/runtime/react-component.ts are still origin-root /vendor/react-runtime/…, and exportReactComponentAsHtml in apps/web/src/runtime/exports.ts still serializes buildReactComponentSrcdoc() unchanged.
  • shouldAdoptPersistedManualEditDocument in FileViewer.tsx still fingerprints raw source, while the retire path still fingerprints livePreviewSource (inlinedSource ?? deckVisualSource).

🔁 Powered by Looper · runner=reviewer · agent=omp · An autonomous AI dev team for your GitHub repos.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps size/XXL PR changes 1500+ lines type/bugfix Bug fix validated Runtime change validated (via /explore Pass or manual QA).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug][0.20.2]: Multi-file prototypes white-screen in preview — blob: document has no base URL, so relative subresources never resolve

3 participants