From df9ccfc5b4c458869d6e13ad883cd2a56c0cadc7 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Mon, 22 Jun 2026 11:33:28 +0200 Subject: [PATCH 01/11] docs(canvas): add canvas-authoring-dx spec + roadmap Phase 8 Feature spec for Canvas Authoring DX & Pane UX (parent tm-canvas-improvements-kqo) and the roadmap Phase 8 entry these tasks implement against. --- docs/spec/canvas-authoring-dx.md | 152 +++++++++++++++++++++++++++++ docs/spec/future/canvas-roadmap.md | 27 +++++ 2 files changed, 179 insertions(+) create mode 100644 docs/spec/canvas-authoring-dx.md diff --git a/docs/spec/canvas-authoring-dx.md b/docs/spec/canvas-authoring-dx.md new file mode 100644 index 0000000..0579543 --- /dev/null +++ b/docs/spec/canvas-authoring-dx.md @@ -0,0 +1,152 @@ +# Canvas Authoring DX & Pane UX + +Implements roadmap **Phase 6** (Canvas Doc Authoring DX) and **Phase 8** (Pane UX) from +`docs/spec/future/canvas-roadmap.md`. Full feature spec for the pane: `docs/spec/canvas-pane.md`. + +## Goals + +- Lower the bar to author an effective canvas doc: an on-theme doc with zero CSS boilerplate + and a first-class message helper instead of hand-rolled `postMessage`. +- Make doc-side JS failures visible instead of silently breaking inside the iframe. +- Always surface the active doc as a labeled tab — even when there's only one — and show how + fresh it is on disk. +- No regressions to existing canvas behavior (ownership routing, morph, liveness, the beads + SystemView, keep-iframes-alive). + +## Expected Behavior + +All four items extend existing, well-isolated seams. Today every served doc is rewritten at the +`` injection point in `CanvasDocServer.handleCanvasRequest`, with the per-kind injection +chosen by `buildInjection` (`src/Server/CanvasDocServer.fs`): + +| Doc kind | Injected today | +|---|---| +| `SystemView` | `baseStyle` (scrollbar CSS only) + `linkInterceptor` | +| `AgentDoc` | the above + `bridgeScript` + idiomorph runtime + morph controller | + +### 1. Base dark-theme CSS reset (Phase 6.1) + +`baseStyle` grows from scrollbar-only CSS to a small **zero-specificity** base reset: dark +background/foreground, system font stack, and sensible defaults for `body`, headings, code, +tables, and links — injected for **both** doc kinds (same slot as today). + +- Defaults must be overridable: a doc that sets its own styles wins. The reset is injected at the + `` point (`CanvasDocServer.handleCanvasRequest`) — i.e. **after** any `` styles the + doc (or the `SystemView` template) already declares — so an equal-specificity element rule in the + doc would otherwise *lose* the source-order tiebreak to the injected reset. To guarantee the doc + always wins, wrap every reset selector in **`:where(...)`** so it carries **zero specificity** + (like the existing `*` scrollbar rule); any real doc rule — even a bare `body { }` element + selector — then overrides it regardless of source order. Use **no `!important`**. +- This is also what keeps the beads `SystemView` (`BeadspaceTemplate.html`) visually unchanged: its + own `body { background: var(--bg-deep) }` (element specificity 0,0,1) beats the `:where(body)` + reset (specificity 0,0,0), so the injected dark default never paints over the dashboard. +- **Acceptance:** (a) a canvas doc whose body is `plain text` renders dark-themed and + readable with no doc-authored CSS; (b) a doc that declares its own `body { background: … }` in + `` keeps *its* colour, not the reset's; (c) the beads `SystemView` body background is + unchanged (`var(--bg-deep)`). + +### 2. Injected `window.canvasSend(action, payload)` helper (Phase 6.2) + +A tiny script injected **alongside `bridgeScript` (AgentDoc only)** exposes +`window.canvasSend(action, payload)` that wraps the existing flat message contract +`window.parent.postMessage({ action, ...payload }, '*')`. + +- The message shape stays flat: `canvasSend('navigate-canvas-doc', { filename })` posts + `{ action: 'navigate-canvas-doc', filename }` — byte-identical in effect to the raw message the + pane already handles. +- Validates serialized payload size against the client cap (`MaxPayloadBytes = 64_000`, + `src/Client/CanvasPane.fs`) using the **same metric the client enforces** — + `JSON.stringify({ action, ...payload }).length` (UTF-16 code units, the JS `String.length` the + client checks at the `postMessage DROPPED: payload too large` path) — so the doc-side verdict is + identical to the client's drop decision. Oversized messages are **not** posted; the helper logs a + clear console error doc-side so the author gets immediate feedback instead of the silent + client-side drop. (See open question on whether the cap should become a true UTF-8 byte count.) +- `src/Extension/skill/SKILL.md` is updated to teach `canvasSend` as the **primary** API; the raw + `window.parent.postMessage` shape stays documented as the underlying contract / fallback. +- **Acceptance:** a doc that calls `canvasSend('navigate-canvas-doc', { filename })` switches tabs + identically to the current raw-`postMessage` path. + +### 3. JS error overlay (Phase 6.3) + +An injected handler (**AgentDoc only**, alongside the bridge) installs `window.onerror` and an +`unhandledrejection` listener that forward doc-side JS errors to the parent as a +`{ action: 'canvas-doc-error', message, source, line, col }` message. The pane surfaces them as a +**non-blocking, dismissible banner**, reusing the existing `canvas-error-banner` visual pattern +(`src/Client/CanvasPane.fs`) but driven by a **distinct source** — doc JS errors, separate from +the message-delivery failures already shown via `CanvasSendState.Failed`. + +- The banner must never cover doc content and must be dismissible. +- The banner is **doc-scoped**: switching to another tab (`SelectDoc`) clears it, so a stale + error from a doc you've navigated away from is never shown. It reappears only if a doc throws + again. +- **Acceptance:** a doc that throws on load shows the error text in a banner, and the pane (tabs, + position controls, other docs) keeps working. + +### 4. Always-visible doc tab with last-modified age (Phase 8) + +Two coupled tab-bar changes in `src/Client/CanvasPane.fs`: + +1. **Always render the active doc's tab — even for a single doc.** Today the `tabs` binding + renders tabs only when `wt.CanvasDocs.Length > 1 || hasSystemView`, so a lone **AgentDoc** + shows no tab and its iframe fills the pane. Change the condition so the active doc always has a + visible, labeled tab. (The header bar itself already always renders; only the tab buttons were + suppressed.) +2. **Show on-disk freshness inside each AgentDoc tab.** Render a compact relative age next to the + tab label from `doc.LastModified` (already on `CanvasDoc`, `src/Shared/Types.fs`) — e.g. `3m`, + `2h`, `2d`. Computed from `System.DateTimeOffset.Now` at render time (same pattern as the + dashboard's `relativeTime System.DateTimeOffset.Now wt.LastCommitTime`), so it refreshes on the + pane's existing render cadence. + +- The age is scoped to **AgentDoc** tabs, matching the AgentDoc-only liveness dot. The + `SystemView` "BD" badge is data-driven and carries no authored-file age. +- **Acceptance:** a worktree with a single canvas doc shows its tab button (not a bare iframe), and + each AgentDoc tab shows a compact age (`3m`, `2d`) reflecting the file's `LastModified`. + +## Technical Approach + +### Server injection — `src/Server/CanvasDocServer.fs` +- **Base CSS reset:** extend the `baseStyle` string literal. Wrap every reset selector in + `:where(...)` (zero specificity), no `!important`, so doc rules and the `SystemView` template's + own element rules always win the cascade despite the reset being injected after them at + ``. Still injected for both kinds via `buildInjection`. +- **`canvasSend`:** add a new injected script constant (mirroring `bridgeScript`'s IIFE style) and + append it in the `AgentDoc` arm of `buildInjection` only. Implement the size check with the same + metric the client uses — `JSON.stringify({ action, ...payload }).length` compared against + `64_000` — so the helper never blocks a payload the client would accept nor passes one it would + drop. +- **Error overlay:** add a new injected script constant (AgentDoc arm) installing `window.onerror` + + `unhandledrejection` → `postMessage({ action: 'canvas-doc-error', ... }, '*')`. +- Keep `MaxPayloadBytes` as the single source of truth for the cap; reference its value in the + injected helper (literal kept in sync with `CanvasPane.fs`). + +### Client — `src/Client/CanvasPane.fs` + `src/Client/Components.fs` +- **Doc-error banner:** add model state for the latest doc error (e.g. `Some message`), a message + handler for `canvas-doc-error`, a dismiss action, and a `canvas-doc-error-banner` element next + to the existing `errorBanner`. Reuse the banner/dismiss CSS classes (add a doc-error class if a + distinct accent is wanted). Wire the new callback through `CanvasPaneCallbacks`. `SelectDoc` + clears the doc-error state so the banner never carries a stale error across tabs. +- **Always-visible tab:** change the `tabs` condition so the active doc's tab always renders; + preserve the existing SystemView-first ordering and the lone-SystemView behavior. +- **Compact age:** add `Components.relativeTimeCompact` (a sibling of `relativeTime`) returning + `now`/`3m`/`2h`/`2d` (no `" ago"`). Render it inside `agentTab` from + `System.DateTimeOffset.Now` and `d.LastModified`. + +### Docs — `src/Extension/skill/SKILL.md` +- Replace the primary `postMessage` example with `canvasSend`; keep the raw contract documented as + the underlying mechanism. + +## Verification + +End-to-end Playwright coverage modeled on `src/Tests/BeadspaceCanvasTests.fs` (route interception, +self-contained, CI `Category=E2E`), plus unit tests for pure logic: +- Unit: `buildInjection` output per kind contains/omits the right injected pieces; `canvasSend` + size-cap boundary; `relativeTimeCompact` formatting across thresholds. +- E2E: the four acceptance criteria above (themed plain doc, `canvasSend` tab switch, error banner + on throwing doc, single-doc tab + visible age), plus the two cascade guards for item 1 — a doc + that sets its own `body` background keeps it, and the beads `SystemView` body background stays + `var(--bg-deep)`. + +## Related Specs +- `docs/spec/canvas-pane.md` — the canvas pane feature this extends. +- `docs/spec/future/canvas-roadmap.md` — source roadmap (Phases 6 & 8). +- `docs/spec/beadspace-canvas.md` — the beads `SystemView` that must stay visually unchanged. diff --git a/docs/spec/future/canvas-roadmap.md b/docs/spec/future/canvas-roadmap.md index 107455e..a8a715a 100644 --- a/docs/spec/future/canvas-roadmap.md +++ b/docs/spec/future/canvas-roadmap.md @@ -79,6 +79,33 @@ Playwright coverage that each bundled template renders and its interactions work `src/Tests/BeadspaceCanvasTests.fs`, which uses Playwright **route interception** to serve a template + mock data from disk (self-contained, no live server — runs in CI under `Category=E2E`). +## Phase 8 — Pane UX + +Goal: polish the canvas pane chrome itself (independent of doc authoring/templates). + +### Always-visible doc tab with last-modified age — effort S + +Two tightly-coupled tab-bar tweaks that ship together: + +1. **Always render the doc tab — even for a single doc.** Today `CanvasPane.fs` (the `tabs` + binding, ~line 269) only renders tabs when `wt.CanvasDocs.Length > 1 || hasSystemView`, + so a lone **AgentDoc** shows *no* tab and its iframe fills the pane (a lone SystemView + already renders, to keep its beads badge). Change the condition so the active doc's tab + always renders. + +2. **Show the doc's on-disk freshness inside the tab.** Render a compact relative age next to + the tab label from `doc.LastModified` (already on `CanvasDoc`, `Types.fs:117`) — e.g. + `3m`, `2d`. A `relativeTime` formatter already exists (`Components.fs:8`) but emits the + `"3m ago"` long form; add/adapt a **compact** variant (`3m`, `2h`, `2d`, no `" ago"`) for + the tab. The age recomputes on the pane's existing render cadence (the dashboard already + calls `relativeTime` with `System.DateTimeOffset.Now` per render). + +- Scope the age to **AgentDoc** tabs (consistent with the AgentDoc-only liveness dot); the + SystemView "BD" badge is data-driven and carries no authored-file age. +- Acceptance: a worktree with a single canvas doc shows its tab button (not a bare iframe), + and each AgentDoc tab shows a compact age label reflecting the file's `LastModified` + (e.g. `3m`, `2d`) that updates as the pane re-renders. + ## Considered but not carried forward Recorded so future readers know these were evaluated, not missed. From 7b60bb340017d2af2641c4a876bee3d1baad2ea5 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Mon, 22 Jun 2026 11:33:36 +0200 Subject: [PATCH 02/11] [tm-canvas-improvements-a76] Inject base dark-theme CSS reset Extend baseStyle with a :where()-wrapped, zero-specificity dark-theme reset (body bg/fg #1e1e2e/#cdd6f4 + system font, headings, code/pre, table, links; no !important), injected for both doc kinds via buildInjection. Adds BuildInjectionTests covering both-kind injection and the zero-specificity guarantee. --- src/Server/CanvasDocServer.fs | 11 +++++++- src/Tests/CanvasDocServerTests.fs | 44 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/Server/CanvasDocServer.fs b/src/Server/CanvasDocServer.fs index f27f43a..6d30ca4 100644 --- a/src/Server/CanvasDocServer.fs +++ b/src/Server/CanvasDocServer.fs @@ -206,7 +206,16 @@ let private bridgeScript = "})()" ] |> String.concat "" -let private baseStyle = "" +/// Scrollbar styling + a small dark-theme base reset, injected for BOTH doc kinds at the +/// slot (handleCanvasRequest). Because the injection lands AFTER any styles the doc/template +/// already declares, an equal-specificity element rule here would win the source-order tiebreak and +/// stomp the doc. So every reset selector is wrapped in :where(...) — carrying ZERO specificity like +/// the universal `*` scrollbar rule — and no rule uses !important. Then any real doc rule (even a +/// bare body{}) and the SystemView template's own element rules (e.g. BeadspaceTemplate.html's +/// body{background:var(--bg-deep)}, specificity 0,0,1) override the reset (specificity 0,0,0), +/// regardless of source order. Literal colours mirror the app theme (Catppuccin --bg-deep / +/// --text-primary / --accent) since a plain doc never defines those CSS variables. +let private baseStyle = "" /// Intercepts in-doc link clicks: same-origin .html links become navigate-canvas-doc messages /// (tab switch), everything else opens in a new tab. The target filename is taken from a.pathname diff --git a/src/Tests/CanvasDocServerTests.fs b/src/Tests/CanvasDocServerTests.fs index edf6a98..e3d4550 100644 --- a/src/Tests/CanvasDocServerTests.fs +++ b/src/Tests/CanvasDocServerTests.fs @@ -1,6 +1,7 @@ module Tests.CanvasDocServerTests open System.IO +open System.Text.RegularExpressions open NUnit.Framework open Shared open Server @@ -14,6 +15,25 @@ let private baseStyleMarker = "scrollbar-color" // unique to baseStyle let private linkInterceptorMarker = "navigate-canvas-doc" // unique to linkInterceptor let private bridgeMarker = "/bridge/heartbeat" // unique to bridgeScript +// ── Item 1: dark-theme base reset markers ───────────────────────────────────── +let private resetWrapMarker = ":where(body)" // reset selectors are :where()-wrapped (zero specificity) +let private resetDarkBgMarker = "#1e1e2e" // the dark background the reset paints on a plain doc + +// Element-name selectors that, if they appeared *bare* (name directly followed by `{`), would carry +// non-zero specificity and could beat a doc's own rule via the source-order tiebreak (the reset is +// injected AFTER the doc's styles). Every reset selector must instead be :where()-wrapped, so +// none of these may appear in bare `name{` form. Inside :where(...) each name is followed by `,`/`)` +// (never `{`), so this only fires on a genuinely unwrapped selector. +let private bareElementSelector = + Regex(@"(? block content from an injection so specificity assertions never see the +/// injected " +/// window.canvasSend(action, payload): the first-class doc→pane message helper, injected in the +/// AgentDoc arm only (a SystemView is server-generated and posts nothing, so it never gets the +/// helper). It wraps the existing FLAT message contract the pane already handles — +/// canvasSend('navigate-canvas-doc',{filename}) posts {action:'navigate-canvas-doc', filename} via +/// window.parent.postMessage(...,'*'), identical in effect to a hand-rolled postMessage. +/// +/// The size guard mirrors the client EXACTLY. CanvasPane.fs computes JSON.stringify(me.data).length +/// — where me.data IS the posted {action,...payload} object and .length is UTF-16 code units (the JS +/// String.length) — and DROPS the message when that exceeds MaxPayloadBytes (the "postMessage +/// DROPPED: payload too large" path). The helper measures the identical metric on the identical +/// object (var size=JSON.stringify(msg).length) and refuses to post when size>MAX, so the doc-side +/// verdict equals the client's drop decision — accept iff length<=cap, drop iff length>cap — but the +/// author gets an immediate doc-side console.error instead of a silent client-side drop. The cap is +/// applied uniformly to every action; the navigate/morph payloads the client special-cases ahead of +/// its size check are tiny, so the uniform guard never diverges in practice. UTF-8 byte length is +/// deliberately NOT used: it would disagree with the client's String.length check and could block a +/// payload the client accepts (or pass one it drops). The 64000 literal mirrors MaxPayloadBytes in +/// src/Client/CanvasPane.fs and is kept in sync by hand (CanvasDocServerTests pins the two together). +let private canvasSendScript = + [ "" ] + |> String.concat "" + /// Choose the style/script injection for a served canvas doc based on its kind. /// Both kinds get baseStyle + linkInterceptor. AgentDocs additionally get the message-bridge -/// heartbeat and the idiomorph runtime + morph controller. SystemViews (e.g. the beads dashboard) -/// are server-generated and data-driven with no owner session: they drive their own refresh and -/// must never morph (a morph would stomp the live, JS-rendered dashboard back to the empty -/// template shell), and nothing routes session→doc messages to them, so those three are omitted. +/// heartbeat, the window.canvasSend helper, and the idiomorph runtime + morph controller. +/// SystemViews (e.g. the beads dashboard) are server-generated and data-driven with no owner +/// session: they drive their own refresh and must never morph (a morph would stomp the live, +/// JS-rendered dashboard back to the empty template shell), nothing routes session→doc messages to +/// them, and they post nothing back — so the bridge, canvasSend, and morph pieces are all omitted. let buildInjection (kind: CanvasDocKind) : string = match kind with | SystemView -> baseStyle + linkInterceptor - | AgentDoc -> baseStyle + linkInterceptor + bridgeScript + IdiomorphScript.idiomorphJs + IdiomorphScript.morphController + | AgentDoc -> baseStyle + linkInterceptor + bridgeScript + canvasSendScript + IdiomorphScript.idiomorphJs + IdiomorphScript.morphController let private handleCanvasRequest (agent: MailboxProcessor) (ctx: HttpContext) : System.Threading.Tasks.Task = task { let catchAll = ctx.Request.RouteValues["path"] :?> string diff --git a/src/Tests/CanvasDocServerTests.fs b/src/Tests/CanvasDocServerTests.fs index e3d4550..0f684a1 100644 --- a/src/Tests/CanvasDocServerTests.fs +++ b/src/Tests/CanvasDocServerTests.fs @@ -34,6 +34,24 @@ let private styleBlock (injection: string) = Assert.That(m.Success, Is.True, "injection must contain a owned" + let served = injectInto AgentDoc "owned.html" doc + do! this.ServeAndGoto "**/owned.html" "http://127.0.0.1:5002/wt/owned.html" served + + let! bg = this.Page.EvaluateAsync("() => getComputedStyle(document.body).backgroundColor") + // The doc's own element rule body{} (0,0,1) must beat the injected :where(body) reset (0,0,0) + // even though the reset is spliced AFTER the doc's style. + Assert.That(bg, Is.EqualTo("rgb(0, 128, 0)"), $"doc's own body rule must win over the :where() reset (was {bg})") + Assert.That(bg, Is.Not.EqualTo("rgb(30, 30, 46)"), "the reset dark colour must NOT win over the doc's own rule") + } + + [] + member this.``Item 5b: the beads SystemView body background stays var(--bg-deep) under the reset``() = + task { + let templatePath = + Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "Server", "BeadspaceTemplate.html")) + let! template = File.ReadAllTextAsync(templatePath) + let served = injectInto SystemView "beads.html" template + + // The dashboard fetches beads-data on load; stub it so the page settles cleanly. + do! this.Page.RouteAsync("**/beads-data", fun route -> + route.FulfillAsync(RouteFulfillOptions(ContentType = "application/json", Body = "[]"))) + do! this.ServeAndGoto "**/beads.html" "http://127.0.0.1:5002/wt/beads.html" served + + let! bg = this.Page.EvaluateAsync("() => getComputedStyle(document.body).backgroundColor") + // BeadspaceTemplate.html declares :root{--bg-deep:#1e1e2e} and body{background:var(--bg-deep)}; + // the template's element rule must keep painting the dashboard, unchanged by the reset. + Assert.That(bg, Is.EqualTo("rgb(30, 30, 46)"), $"SystemView body must stay var(--bg-deep) #1e1e2e (was {bg})") + } + +// ============================================================================ +// Item 2 (canvasSend tab switch) + Item 3 (doc JS error banner) +// +// Full-app pane E2E (server + Fable + Vite via ServerFixture.GlobalSetup). We +// intercept the canvas-doc-server iframe requests (http://127.0.0.1:5002/.../) +// and serve the REAL injected doc, so the genuine in-iframe window.canvasSend / +// window.onerror / unhandledrejection drive the genuine Elmish pane. +// ============================================================================ +[] +[] +[] +[] +type CanvasAuthoringDxPaneE2ETests() = + inherit PageTest() + + let baseUrl = ServerFixture.viteUrl + let [] MultiDocBranch = "feature-multidoc" + + /// The HTML a real AgentDoc iframe receives: full injection at plus `bodyScript` running + /// in AFTER the injected helpers (canvasSend / window.onerror / unhandledrejection) exist. + let injectedDoc (filename: string) (bodyScript: string) = + let head = Server.CanvasDocServer.buildInjection AgentDoc filename + String.concat "" [ + ""; filename; ""; head; "" + "

doc body: "; filename; "

" ] + + override this.ContextOptions() = + let opts = base.ContextOptions() + opts.IgnoreHTTPSErrors <- true + opts + + [] + member this.NavigateToDashboard() = + task { + let! _ = this.Page.GotoAsync(baseUrl) + do! this.Page.Locator(".wt-card .branch-name").First.WaitForAsync(LocatorWaitForOptions(Timeout = 15000.0f)) + } + + /// Register canvas-doc-server iframe interception BEFORE the pane opens: overview.html runs + /// `overviewScript`; the sibling docs are inert (just the injection) so navigating to them loads. + member private this.RouteDocs (overviewScript: string) = + task { + do! this.Page.RouteAsync("**/overview.html", fun route -> + route.FulfillAsync(RouteFulfillOptions( + ContentType = "text/html; charset=utf-8", + Body = injectedDoc "overview.html" overviewScript))) + do! this.Page.RouteAsync("**/details.html", fun route -> + route.FulfillAsync(RouteFulfillOptions( + ContentType = "text/html; charset=utf-8", + Body = injectedDoc "details.html" ""))) + do! this.Page.RouteAsync("**/metrics.html", fun route -> + route.FulfillAsync(RouteFulfillOptions( + ContentType = "text/html; charset=utf-8", + Body = injectedDoc "metrics.html" ""))) + } + + member private this.OpenMultiDocPane() = + task { + do! focusCanvasCard this.Page MultiDocBranch + do! ensureCanvasPaneOpen this.Page + do! (this.Page.Locator(".canvas-pane .canvas-iframe").First).WaitForAsync(LocatorWaitForOptions(Timeout = 10000.0f)) + } + + [] + member this.``Item 2: an in-doc canvasSend('navigate-canvas-doc') switches the active tab``() = + task { + do! this.RouteDocs "window.canvasSend('navigate-canvas-doc',{filename:'details.html'});" + do! this.OpenMultiDocPane() + + // Overview iframe loads and calls the injected canvasSend → pane switches active tab to details. + let! _ = this.Page.WaitForFunctionAsync( + "() => { const t = document.querySelector('.canvas-pane .canvas-tab.active'); return !!t && t.textContent.indexOf('details') >= 0; }", + null, PageWaitForFunctionOptions(Timeout = 10000.0f)) + let! activeText = (this.Page.Locator(".canvas-pane .canvas-tab.active").First).TextContentAsync() + Assert.That(activeText, Does.Contain("details"), "canvasSend('navigate-canvas-doc') must switch the active tab to details") + } + + [] + member this.``Item 2 control: a raw window.parent.postMessage('navigate-canvas-doc') switches the active tab identically``() = + task { + do! this.RouteDocs "window.parent.postMessage({action:'navigate-canvas-doc',filename:'details.html'},'*');" + do! this.OpenMultiDocPane() + + let! _ = this.Page.WaitForFunctionAsync( + "() => { const t = document.querySelector('.canvas-pane .canvas-tab.active'); return !!t && t.textContent.indexOf('details') >= 0; }", + null, PageWaitForFunctionOptions(Timeout = 10000.0f)) + let! activeText = (this.Page.Locator(".canvas-pane .canvas-tab.active").First).TextContentAsync() + Assert.That(activeText, Does.Contain("details"), "raw postMessage navigate must switch the active tab to details — identical to canvasSend") + } + + [] + member this.``Item 3: a doc that throws on load shows a dismissible doc-error banner and the pane stays interactive``() = + task { + do! this.RouteDocs "throw new Error('boom-on-load');" + do! this.OpenMultiDocPane() + + // Banner appears carrying the thrown message (attributed to the emitting doc, overview). + let banner = this.Page.Locator(".canvas-pane .canvas-doc-error-banner") + do! banner.WaitForAsync(LocatorWaitForOptions(Timeout = 10000.0f)) + let! bannerText = banner.First.TextContentAsync() + Assert.That(bannerText, Does.Contain("boom-on-load"), "doc-error banner must contain the thrown error text") + + // Banner must not cover the doc: the active iframe stays rendered/visible. + let! iframeVisible = (this.Page.Locator(".canvas-pane .canvas-iframe-active").First).IsVisibleAsync() + Assert.That(iframeVisible, Is.True, "the doc iframe must remain visible (banner must not cover content)") + + // Pane stays interactive while the banner is up: changing the dock position still works. + do! (this.Page.Locator(".canvas-tab-bar .canvas-pos-btn").Nth(0)).ClickAsync() // Dock left + do! this.Page.Locator(".app-layout.canvas-left").WaitForAsync(LocatorWaitForOptions(Timeout = 5000.0f)) + + // Banner is dismissible. + do! (this.Page.Locator(".canvas-doc-error-dismiss").First).ClickAsync() + do! banner.WaitForAsync(LocatorWaitForOptions(State = WaitForSelectorState.Detached, Timeout = 5000.0f)) + let! afterDismiss = banner.CountAsync() + Assert.That(afterDismiss, Is.EqualTo(0), "doc-error banner must be dismissible") + } + + [] + member this.``Item 3: an unhandled promise rejection in a doc surfaces the doc-error banner``() = + task { + do! this.RouteDocs "Promise.reject(new Error('boom-reject'));" + do! this.OpenMultiDocPane() + + let banner = this.Page.Locator(".canvas-pane .canvas-doc-error-banner") + do! banner.WaitForAsync(LocatorWaitForOptions(Timeout = 10000.0f)) + let! bannerText = banner.First.TextContentAsync() + Assert.That(bannerText, Does.Contain("boom-reject"), "an unhandledrejection must surface in the doc-error banner") + } + + [] + member this.``Item 3: switching tabs clears the doc-error banner (SelectDoc)``() = + task { + do! this.RouteDocs "throw new Error('boom-on-load');" + do! this.OpenMultiDocPane() + + let banner = this.Page.Locator(".canvas-pane .canvas-doc-error-banner") + do! banner.WaitForAsync(LocatorWaitForOptions(Timeout = 10000.0f)) + + // Switch to the details tab → SelectDoc clears the doc error (and doc-scoped gating hides it). + do! (this.Page.Locator(".canvas-pane .canvas-tab").Nth(1)).ClickAsync() + do! banner.WaitForAsync(LocatorWaitForOptions(State = WaitForSelectorState.Detached, Timeout = 5000.0f)) + let! afterSwitch = banner.CountAsync() + Assert.That(afterSwitch, Is.EqualTo(0), "doc-error banner must be cleared after switching tabs") + } diff --git a/src/Tests/Tests.fsproj b/src/Tests/Tests.fsproj index 4136a9e..7af122c 100644 --- a/src/Tests/Tests.fsproj +++ b/src/Tests/Tests.fsproj @@ -43,6 +43,7 @@ + From 77ba4ad7df576b581008c1da1fa506dc56f53e9f Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Mon, 22 Jun 2026 17:14:49 +0200 Subject: [PATCH 10/11] Address focused-review findings on the canvas branch - Components: extract a shared formatTimeDelta helper behind relativeTime/relativeTimeCompact, removing the duplicated time-bucketing ladder. - Move DocJsError + CanvasSendState out of the focus-scoped Navigation module into a new CanvasTypes module. - CanvasDocServer: restrict canvas-doc framing with a CSP frame-ancestors header (loopback origins only) so untrusted pages can't frame docs and harvest postMessages. - Doc-error attribution: the overlay now derives its own worktree from location.pathname and posts a wt field; CanvasDocError carries the scopedKey; the reducer stamps from the emitter, not FocusedElement (fixes cross-worktree misattribution). - Pane message listener: drop generic-forward and navigate-canvas-doc messages that originate from a hidden (non-active) canvas iframe. - Update canvas-pane spec (tab-bar behavior) and tests, including a cross-worktree doc-error attribution regression test. --- docs/spec/canvas-pane.md | 2 +- src/Client/App.fs | 2 +- src/Client/AppTypes.fs | 10 +++--- src/Client/CanvasAwareness.fs | 1 + src/Client/CanvasPane.fs | 41 ++++++++++++++++------- src/Client/CanvasState.fs | 1 + src/Client/CanvasTypes.fs | 28 ++++++++++++++++ src/Client/CanvasUpdate.fs | 33 ++++++++++--------- src/Client/Client.fsproj | 1 + src/Client/Components.fs | 29 ++++++++-------- src/Client/Navigation.fs | 22 ------------- src/Server/CanvasDocServer.fs | 27 ++++++++++----- src/Tests/CanvasAwarenessTests.fs | 55 +++++++++++++++++-------------- src/Tests/CanvasDocServerTests.fs | 13 +++++--- 14 files changed, 156 insertions(+), 109 deletions(-) create mode 100644 src/Client/CanvasTypes.fs diff --git a/docs/spec/canvas-pane.md b/docs/spec/canvas-pane.md index 1a6ceab..ac1d199 100644 --- a/docs/spec/canvas-pane.md +++ b/docs/spec/canvas-pane.md @@ -56,7 +56,7 @@ A `SystemView` drives its own updates: the beads dashboard polls `/beads-data` e - Open or closed state persists in global config. - Position selector supports left, right, top, and bottom docking, and the selected position persists. - The pane is scoped to the focused worktree. If that worktree has docs, the pane shows its active doc. -- Worktrees with multiple docs show tab buttons. A single `AgentDoc` skips the tab bar; a lone `SystemView` still shows its `.canvas-system-tab` entry so its beads-count badge stays visible. +- Worktrees with multiple docs show tab buttons. The active doc's tab always renders — a lone `AgentDoc` now gets a labeled tab (with a compact last-modified age) instead of a bare iframe, and a lone `SystemView` still shows its `.canvas-system-tab` entry so its beads-count badge stays visible. (See also `docs/spec/canvas-authoring-dx.md`.) - Selecting a tab marks that doc viewed. - Viewed but inactive tabs render at 0.5 opacity. The active tab stays full opacity. - The archive button moves the active doc to `.agents/canvas/archive/`. It is shown only when the active doc is an `AgentDoc` — a `SystemView` is server-regenerated, not user-owned, so it has no archive button. diff --git a/src/Client/App.fs b/src/Client/App.fs index 7037759..fc65dac 100644 --- a/src/Client/App.fs +++ b/src/Client/App.fs @@ -533,7 +533,7 @@ let update msg model = | DismissCanvasMessageError -> CanvasUpdate.dismissCanvasMessageError model - | CanvasDocError (filename, message) -> CanvasUpdate.canvasDocError filename message model + | CanvasDocError (scopedKey, filename, message) -> CanvasUpdate.canvasDocError scopedKey filename message model | DismissCanvasDocError -> CanvasUpdate.dismissCanvasDocError model diff --git a/src/Client/AppTypes.fs b/src/Client/AppTypes.fs index 32fcaa4..27e101d 100644 --- a/src/Client/AppTypes.fs +++ b/src/Client/AppTypes.fs @@ -81,11 +81,11 @@ type Msg = | CanvasSendResult of CanvasMessageResult * scopedKey: string | DismissCanvasMessageError // Doc-side JS error forwarded from an AgentDoc iframe (window.onerror / unhandledrejection via - // the injected errorOverlayScript). `filename` is the emitting doc (carried in the postMessage - // `doc` field) so the reducer stamps the error with the doc that actually threw, not the active - // tab. Carried as its own message + model field, distinct from CanvasSendState.Failed (which is a - // pane→session *delivery* failure, a different source). - | CanvasDocError of filename: string * message: string + // the injected errorOverlayScript). `scopedKey`+`filename` are the emitting worktree + doc + // (carried in the postMessage `wt`/`doc` fields) so the reducer stamps the error with the doc that + // actually threw, not the active tab. Carried as its own message + model field, distinct from + // CanvasSendState.Failed (which is a pane→session *delivery* failure, a different source). + | CanvasDocError of scopedKey: string * filename: string * message: string | DismissCanvasDocError | MarkDocViewed of scopedKey: string * filename: string | LoadLastViewedHashes of Map> diff --git a/src/Client/CanvasAwareness.fs b/src/Client/CanvasAwareness.fs index fc92b4a..cd0ea2e 100644 --- a/src/Client/CanvasAwareness.fs +++ b/src/Client/CanvasAwareness.fs @@ -2,6 +2,7 @@ module CanvasAwareness open Shared open Navigation +open CanvasTypes type CanvasEventKind = NewDoc | UpdatedDoc diff --git a/src/Client/CanvasPane.fs b/src/Client/CanvasPane.fs index 39ecf96..eaf4aa3 100644 --- a/src/Client/CanvasPane.fs +++ b/src/Client/CanvasPane.fs @@ -2,6 +2,7 @@ module CanvasPane open Shared open Navigation +open CanvasTypes open Feliz open Browser @@ -370,8 +371,8 @@ type MessageListenerCallbacks = SelectDoc: string -> unit /// The active doc finished an idiomorph (morph-complete). OnMorphComplete: unit -> unit - /// A doc-side JS error arrived: (emitting filename, display message). - OnDocError: string -> string -> unit } + /// A doc-side JS error arrived: (emitting worktree scopedKey, emitting filename, display message). + OnDocError: string -> string -> string -> unit } let messageListener (callbacks: MessageListenerCallbacks) = let { Dispatch = dispatch @@ -384,12 +385,24 @@ let messageListener (callbacks: MessageListenerCallbacks) = if me.origin = CanvasOrigin && Fable.Core.JsInterop.emitJsExpr me.data "$0 != null && typeof $0 === 'object' && typeof $0.action === 'string'" then + // True when THIS message came from a mounted-but-HIDDEN canvas iframe (a visited doc that + // stays mounted and keeps running JS) rather than the active one. The origin check above + // already proves the sender is a canvas doc iframe, so a hidden-iframe match means a + // background/co-resident doc is posting. Session forwarding and navigate-canvas-doc honor + // only the active doc, so such a message is dropped — a hidden doc can't inject a payload + // (or force a tab switch) attributed to the active doc's owner session. The per-doc error + // path is exempt: it self-identifies via wt/doc and may legitimately report from any iframe. + let isFromHiddenCanvasIframe () = + Fable.Core.JsInterop.emitJsExpr me "Array.prototype.some.call(document.querySelectorAll('.canvas-iframe:not(.canvas-iframe-active)'), function(f){return f.contentWindow === $0.source})" let action = Fable.Core.JsInterop.emitJsExpr me.data "$0.action" if action = "navigate-canvas-doc" then match Fable.Core.JsInterop.emitJsExpr me.data "$0.filename" |> Option.ofObj with | Some filename when filename <> "" -> - Fable.Core.JS.console.log ($"[canvas] navigate-canvas-doc: filename={filename}") - selectDoc filename + if isFromHiddenCanvasIframe () then + Fable.Core.JS.console.warn "[canvas] navigate-canvas-doc DROPPED: from a hidden background doc iframe" + else + Fable.Core.JS.console.log ($"[canvas] navigate-canvas-doc: filename={filename}") + selectDoc filename | _ -> () elif action = "morph-complete" then Fable.Core.JS.console.log "[canvas] morph-complete received" @@ -397,11 +410,12 @@ let messageListener (callbacks: MessageListenerCallbacks) = elif action = "canvas-doc-error" then // Doc-side JS error from the iframe (errorOverlayScript). Pane-internal — surfaced // in the doc-error banner, never forwarded to the session like a normal payload. - // The `doc` field is the emitting doc's filename, threaded so the reducer can stamp - // the error with the doc that threw (not the active tab); it is re-validated against - // the focused worktree's docs there. message/line/col cross an untrusted '*' + // The `wt`/`doc` fields are the emitting worktree + filename, so the reducer stamps + // the error with the doc that threw (not the active tab); they are re-validated + // against that worktree's docs there. wt/message/line/col cross an untrusted '*' // boundary, so each field is read with a null-safe String() coercion and the display // string "msg (line N:C)" is assembled here in F#. + let scopedKey = Fable.Core.JsInterop.emitJsExpr me.data "typeof $0.wt==='string'?$0.wt:''" let filename = Fable.Core.JsInterop.emitJsExpr me.data "typeof $0.doc==='string'?$0.doc:''" let rawMessage = Fable.Core.JsInterop.emitJsExpr me.data "$0.message==null?'Unknown error':String($0.message)" let line = Fable.Core.JsInterop.emitJsExpr me.data "$0.line==null?'':String($0.line)" @@ -411,14 +425,17 @@ let messageListener (callbacks: MessageListenerCallbacks) = if line = "" then body elif col = "" then $"{body} (line {line})" else $"{body} (line {line}:{col})" - Fable.Core.JS.console.warn ($"[canvas] canvas-doc-error received from {filename}: {message}") - onDocError filename message + Fable.Core.JS.console.warn ($"[canvas] canvas-doc-error received from {scopedKey}/{filename}: {message}") + onDocError scopedKey filename message else let payload = Fable.Core.JS.JSON.stringify me.data Fable.Core.JS.console.log ($"[canvas] postMessage received: origin={me.origin}, action={action}, payload length={payload.Length}") - if payload.Length <= MaxPayloadBytes - then dispatch payload - else Fable.Core.JS.console.warn ($"[canvas] postMessage DROPPED: payload too large ({payload.Length} > {MaxPayloadBytes})") + if isFromHiddenCanvasIframe () then + Fable.Core.JS.console.warn ($"[canvas] postMessage DROPPED: from a hidden background doc iframe (action={action})") + elif payload.Length <= MaxPayloadBytes then + dispatch payload + else + Fable.Core.JS.console.warn ($"[canvas] postMessage DROPPED: payload too large ({payload.Length} > {MaxPayloadBytes})") Dom.window.addEventListener ("message", handler) diff --git a/src/Client/CanvasState.fs b/src/Client/CanvasState.fs index 7408cf2..529decf 100644 --- a/src/Client/CanvasState.fs +++ b/src/Client/CanvasState.fs @@ -2,6 +2,7 @@ module CanvasState open Shared open Navigation +open CanvasTypes open CanvasAwareness open Elmish diff --git a/src/Client/CanvasTypes.fs b/src/Client/CanvasTypes.fs new file mode 100644 index 0000000..cacf842 --- /dev/null +++ b/src/Client/CanvasTypes.fs @@ -0,0 +1,28 @@ +module CanvasTypes + +// Canvas-specific shared types, owned here (compiled after Navigation, before CanvasPane) rather than +// parked in the focus/navigation-scoped Navigation module. Imported by the canvas pane, state, update +// and awareness modules. + +[] +type CanvasSendState = + // scopedKey identifies the target worktree the message was queued for, so the "Waiting for + // session…" banner can be cleared only by *that* worktree's session activity, never by an + // unrelated worktree's doc change. + | Idle + | Waiting of scopedKey: string + | Failed of message: string + +/// A doc-side JS error (window.onerror / unhandledrejection forwarded from an AgentDoc iframe via the +/// injected errorOverlayScript), stamped with the worktree + the doc that EMITTED it. Both ride along +/// in the postMessage: the worktree in the `wt` field (the overlay derives it from its own +/// location.pathname, mirroring the bridge heartbeat) and the filename in the `doc` field (the overlay +/// is served per-doc). They are validated against that worktree's docs before being stored, so the +/// error is attributed to the doc that actually threw — independent of the active tab — and a +/// stale/forged identity is dropped. The pane shows the banner only while that same doc is focused +/// (CanvasPane.docErrorBanner gates on ScopedKey+Filename), so card/tab/keyboard navigation to any +/// OTHER doc auto-hides a stale error — truly doc-scoped — without a clear in every focus reducer. +type DocJsError = + { ScopedKey: string + Filename: string + Message: string } diff --git a/src/Client/CanvasUpdate.fs b/src/Client/CanvasUpdate.fs index ba16e87..58b98be 100644 --- a/src/Client/CanvasUpdate.fs +++ b/src/Client/CanvasUpdate.fs @@ -9,6 +9,7 @@ module CanvasUpdate open Shared open Navigation +open CanvasTypes open Elmish open Browser open AppTypes @@ -185,22 +186,22 @@ let dismissCanvasMessageError (model: Model) = { model with Canvas = { model.Canvas with CanvasSendState = CanvasSendState.Idle } }, Cmd.none /// Record a doc-side JS error (window.onerror / unhandledrejection) forwarded from an AgentDoc -/// iframe. `filename` is the EMITTING doc, carried in the postMessage `doc` field and threaded -/// through the listener, so the error is stamped with the doc that actually threw — not the active -/// tab. This matters because visited docs stay mounted as hidden iframes and keep running JS, so an -/// async error from a hidden doc must not be attributed to the focused tab (focused-review A-02). -/// The emitter is validated against the focused worktree's docs (isKnownCanvasDoc) before being -/// stored, so a stale/forged filename — e.g. from an archived doc — can never raise a banner. The -/// stamp drives doc-scoped display: the banner shows only while that doc stays focused; navigating -/// to another doc/card hides it (the view gates on the stamp). Kept separate from CanvasSendState so -/// the doc-error and message-delivery banners never overwrite each other; the newest error wins. If -/// there is no focused card, or the emitter is not a known doc of it, the error is dropped. (Arrival -/// is already logged in CanvasPane.messageListener.) -let canvasDocError (filename: string) (message: string) (model: Model) = - match model.FocusedElement with - | Some (Card scopedKey) when isKnownCanvasDoc model scopedKey filename -> +/// iframe. `scopedKey` and `filename` are the EMITTING worktree + doc, carried in the postMessage +/// `wt`/`doc` fields and threaded through the listener, so the error is stamped with the doc that +/// actually threw — independent of the active tab. This matters because visited docs stay mounted as +/// hidden iframes and keep running JS, so an async error from a hidden doc (even in a non-focused +/// worktree) must not be attributed to the focused tab (focused-review A-02, C-06). The emitter is +/// validated against that worktree's docs (isKnownCanvasDoc) before being stored, so a stale/forged +/// identity — e.g. from an archived doc — can never raise a banner. The stamp drives doc-scoped +/// display: the banner shows only while that doc stays focused; navigating to another doc/card hides +/// it (the view gates on the stamp). Kept separate from CanvasSendState so the doc-error and +/// message-delivery banners never overwrite each other; the newest error wins. If the emitter is not +/// a known doc of a known worktree, the error is dropped. (Arrival is already logged in +/// CanvasPane.messageListener.) +let canvasDocError (scopedKey: string) (filename: string) (message: string) (model: Model) = + if isKnownCanvasDoc model scopedKey filename then { model with Canvas = { model.Canvas with DocError = Some { ScopedKey = scopedKey; Filename = filename; Message = message } } }, Cmd.none - | _ -> + else model, Cmd.none let dismissCanvasDocError (model: Model) = @@ -222,4 +223,4 @@ let messageListener (dispatch: Dispatch) = { Dispatch = CanvasMessageReceived >> dispatch SelectDoc = NavigateCanvasDoc >> dispatch OnMorphComplete = fun () -> dispatch MorphComplete - OnDocError = fun filename message -> dispatch (CanvasDocError (filename, message)) } + OnDocError = fun scopedKey filename message -> dispatch (CanvasDocError (scopedKey, filename, message)) } diff --git a/src/Client/Client.fsproj b/src/Client/Client.fsproj index f6cdd0f..8907d3e 100644 --- a/src/Client/Client.fsproj +++ b/src/Client/Client.fsproj @@ -6,6 +6,7 @@ + diff --git a/src/Client/Components.fs b/src/Client/Components.fs index aacdb95..e6450c8 100644 --- a/src/Client/Components.fs +++ b/src/Client/Components.fs @@ -5,24 +5,23 @@ open Feliz open Fable.Core open Fable.Core.JsInterop -let relativeTime (now: System.DateTimeOffset) (dt: System.DateTimeOffset) = +/// Shared time-bucketing ladder for relativeTime / relativeTimeCompact: the same four thresholds and +/// the same int truncation, parameterised only by the sub-minute label and the per-bucket suffix. +let private formatTimeDelta (subMinute: string) (suffix: string) (now: System.DateTimeOffset) (dt: System.DateTimeOffset) = let diff = now - dt match diff with - | d when d.TotalMinutes < 1.0 -> "just now" - | d when d.TotalMinutes < 60.0 -> $"{int d.TotalMinutes}m ago" - | d when d.TotalHours < 24.0 -> $"{int d.TotalHours}h ago" - | d -> $"{int d.TotalDays}d ago" - -/// Compact sibling of relativeTime for tight UI like the canvas tab strip: same thresholds and -/// the same int truncation, but renders "now"/"3m"/"2h"/"2d" (sub-minute is "now", not "just now", -/// and the "m"/"h"/"d" buckets carry no " ago" suffix). + | d when d.TotalMinutes < 1.0 -> subMinute + | d when d.TotalMinutes < 60.0 -> $"{int d.TotalMinutes}m{suffix}" + | d when d.TotalHours < 24.0 -> $"{int d.TotalHours}h{suffix}" + | d -> $"{int d.TotalDays}d{suffix}" + +let relativeTime (now: System.DateTimeOffset) (dt: System.DateTimeOffset) = + formatTimeDelta "just now" " ago" now dt + +/// Compact sibling of relativeTime for tight UI like the canvas tab strip: renders +/// "now"/"3m"/"2h"/"2d" (sub-minute is "now", not "just now", and the buckets carry no " ago" suffix). let relativeTimeCompact (now: System.DateTimeOffset) (dt: System.DateTimeOffset) = - let diff = now - dt - match diff with - | d when d.TotalMinutes < 1.0 -> "now" - | d when d.TotalMinutes < 60.0 -> $"{int d.TotalMinutes}m" - | d when d.TotalHours < 24.0 -> $"{int d.TotalHours}h" - | d -> $"{int d.TotalDays}d" + formatTimeDelta "now" "" now dt let cardTitle (wt: WorktreeStatus) = if wt.Branch = WorktreeStatus.DetachedBranchName then WorktreePath.displayName wt.Path diff --git a/src/Client/Navigation.fs b/src/Client/Navigation.fs index fa39da4..bd1b3f9 100644 --- a/src/Client/Navigation.fs +++ b/src/Client/Navigation.fs @@ -8,28 +8,6 @@ type FocusTarget = | RepoHeader of RepoId | Card of path: string -[] -type CanvasSendState = - // scopedKey identifies the target worktree the message was queued for, so the "Waiting for - // session…" banner can be cleared only by *that* worktree's session activity, never by an - // unrelated worktree's doc change. - | Idle - | Waiting of scopedKey: string - | Failed of message: string - -/// A doc-side JS error (window.onerror / unhandledrejection forwarded from an AgentDoc iframe via -/// the injected errorOverlayScript), stamped with the worktree + the doc that EMITTED it. The -/// emitting doc's filename rides along in the postMessage `doc` field (the overlay is served per-doc) -/// and is validated against the focused worktree's docs before being stored, so an error from a -/// hidden/background iframe is attributed to the doc that actually threw — not the active tab. -/// The pane shows the banner only while that same doc is focused (CanvasPane.docErrorBanner gates on -/// ScopedKey+Filename), so card/tab/keyboard navigation to any OTHER doc auto-hides a stale error — -/// truly doc-scoped — without a clear in every focus-changing reducer. -type DocJsError = - { ScopedKey: string - Filename: string - Message: string } - type RepoModel = { RepoId: RepoId Name: string diff --git a/src/Server/CanvasDocServer.fs b/src/Server/CanvasDocServer.fs index b9ace08..cfb6b79 100644 --- a/src/Server/CanvasDocServer.fs +++ b/src/Server/CanvasDocServer.fs @@ -267,7 +267,7 @@ let private canvasSendScript = /// Doc-side JS error overlay, injected in the AgentDoc arm only (a SystemView is server-generated /// and runs no author JS, so it never gets the overlay). Installs window.onerror plus an /// unhandledrejection listener and forwards each doc-side failure to the pane as the FLAT -/// {action:'canvas-doc-error', doc, message, source, line, col} message the client surfaces in a +/// {action:'canvas-doc-error', wt, doc, message, source, line, col} message the client surfaces in a /// non-blocking, dismissible banner (src/Client/CanvasPane.fs). window.onerror's /// (message, source, lineno, colno, error) signature maps 1:1 onto that payload; error.message is /// preferred when present (the author's bare "boom" over the host's "Uncaught Error: boom"), else @@ -278,12 +278,14 @@ let private canvasSendScript = /// and spin an error loop. unhandledrejection reports reason.message (or String(reason)) with no /// source/line/col, matching the same flat shape. /// -/// The `doc` field carries THIS doc's filename (the overlay is served per-doc, so the emitter is -/// known at injection time) so the pane attributes the error to the doc that actually threw — not the -/// active tab. Visited docs stay mounted as hidden iframes and keep running JS, so an async error -/// from a hidden doc would otherwise be stamped onto whatever tab is active when the message is -/// processed (focused-review A-02). The pane re-validates `doc` against the focused worktree's docs -/// before showing a banner, so a stale/forged filename can't surface one. +/// The `wt` and `doc` fields carry THIS doc's emitting identity — the worktree (derived in-iframe from +/// location.pathname, mirroring the bridge heartbeat) and the filename (the overlay is served per-doc). +/// The pane stamps the error from `wt`+`doc`, so it attributes the failure to the doc that actually +/// threw — independent of the active tab. Visited docs stay mounted as hidden iframes and keep running +/// JS, so an async error from a hidden/background doc (even in a different worktree) is no longer +/// misattributed to whatever tab is focused when the message is processed (focused-review A-02, C-06). +/// The reducer re-validates `wt`+`doc` against that worktree's docs before showing a banner, so a +/// stale/forged identity can't surface one. let private errorOverlayScript (filename: string) = // JsonSerializer.Serialize yields a properly-escaped, HTML-safe JS string literal (e.g. // "status.html"; <,>,& and quotes are \uXXXX-escaped so a crafted filename can neither close the @@ -291,8 +293,9 @@ let private errorOverlayScript (filename: string) = let docLiteral = JsonSerializer.Serialize(filename) [ "