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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,7 @@ wwwroot/
# Server runtime data
data

# .NET test output
src/Tests/TestResults/

.agents/
187 changes: 187 additions & 0 deletions docs/spec/canvas-authoring-dx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# 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
`</head>` 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
`</head>` point (`CanvasDocServer.handleCanvasRequest`) — i.e. **after** any `<head>` 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 `<body>plain text</body>` renders dark-themed and
readable with no doc-authored CSS; (b) a doc that declares its own `body { background: … }` in
`<head>` 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', wt, doc, message, source, line, col }` message — where `doc` is the
emitting doc's filename (embedded as a constant when the per-doc overlay is served) and `wt` is the
emitting worktree (read in-iframe from `location.pathname`, mirroring the bridge heartbeat). 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**: the stored error is stamped with the doc that **emitted** it — its
worktree and filename ride along in the message `wt`/`doc` fields and are validated against that
**emitting worktree's** docs (`isKnownCanvasDoc`) before being stored — and the pane shows the banner
**only while that same doc is focused**, so navigating away — by tab (`SelectDoc`), card, or keyboard
focus — never shows a stale error over a different doc (nor over the overview). Stamping with the
emitter (not the doc visible when the message is *processed*) is what keeps an async error from a
hidden/background iframe — visited docs stay mounted and keep running JS — attributed to the doc
that actually threw, and immune to a tab **or worktree** switch racing the Elmish message. An error
whose `wt`/`doc` is not a known doc of that worktree is dropped. `SelectDoc` additionally clears it so
a tab switch (and switching back) never re-shows it. 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 `body`-selector rules win the cascade despite the reset being injected after them at `</head>`.
This per-property override holds only for rules on the element selector itself (e.g. `body{…}`,
specificity 0,0,1) — a box property the `SystemView` template zeroes through a *universal*
`*{margin:0;padding:0}` reset (also 0,0,0) would lose the source-order tiebreak to the later
`:where(body){padding:1rem}`, so `BeadspaceTemplate.html` resets margin/padding on its `body`
selector directly. 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 *function* (AgentDoc arm) installing `window.onerror`
+ `unhandledrejection` → `postMessage({ action: 'canvas-doc-error', wt, doc, ... }, '*')`. The overlay
is served per-doc, so `buildInjection (kind) (filename)` threads the served filename in and the
overlay embeds it (JSON-serialized to a safe, HTML-escaped JS string) as the `doc` field; `wt` is
read in-iframe from `location.pathname` (mirroring the bridge heartbeat). Together they give the
error its full emitter identity (worktree + filename).
- 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 — a record **stamped with the doc
that emitted it** (`DocJsError { ScopedKey; Filename; Message }`, not a bare `Some message`). The
`canvas-doc-error` handler reads **both** the emitting worktree (`wt`) and doc filename (`doc`) from
the message and dispatches the 3-tuple `CanvasDocError (scopedKey, filename, message)`; the reducer
stamps it with the **emitter's** scopedKey only after validating that filename names a real doc of
*that* worktree (`isKnownCanvasDoc model scopedKey filename`), otherwise drops it. This attributes a
hidden/background iframe's async error to the doc that threw (not the active tab) and is immune to a
tab **or worktree** switch racing the queued message — both dimensions are captured at receipt, not
re-derived at process time. Carrying the emitter's worktree (not just the filename) **closes** the
cross-worktree misattribution outright: a background doc of worktree A throwing while the user
switches to worktree B that owns a same-named doc still stamps A's scopedKey, so the banner surfaces
only when A's doc is re-focused, never over B. Add a dismiss action and a
`canvas-doc-error-banner` element next to the existing `errorBanner`, reusing the banner/dismiss CSS
classes (add a doc-error class if a distinct accent is wanted). Wire the dismiss callback through
`CanvasPaneCallbacks`; the listener's own callbacks (`Dispatch`/`SelectDoc`/`OnMorphComplete`/
`OnDocError`) are grouped into a `MessageListenerCallbacks` record so the same-typed handlers are
passed by name. The banner is rendered **only when the stamp matches the focused doc**, so every
navigation path (tab/card/keyboard) hides a stale error with no per-reducer clear; `SelectDoc`
still clears the state outright so a tab switch (and back) never re-shows it. The handler (the
`canvas-doc-error` arm of `CanvasPane.messageListener`) is pane-internal — it must **not** be
forwarded to the session like a normal doc payload.
- **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 (incl. the error
overlay's embedded, escaped `doc` filename); `canvasSend` size-cap boundary; `relativeTimeCompact`
formatting across thresholds; doc-error attribution — a background/hidden doc's error is stamped
with the emitting doc (not the active tab) and an unknown emitter is dropped.
- 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.
2 changes: 1 addition & 1 deletion docs/spec/canvas-pane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions docs/spec/future/canvas-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/Client/App.fs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,10 @@ let update msg model =

| DismissCanvasMessageError -> CanvasUpdate.dismissCanvasMessageError model

| CanvasDocError (scopedKey, filename, message) -> CanvasUpdate.canvasDocError scopedKey filename message model

| DismissCanvasDocError -> CanvasUpdate.dismissCanvasDocError model

| MarkDocViewed (scopedKey, filename) ->
let worktree = findWorktree scopedKey model
let currentHash =
Expand Down
7 changes: 7 additions & 0 deletions src/Client/AppTypes.fs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ type Msg =
| CanvasMessageReceived of payload: string
| CanvasSendResult of CanvasMessageResult * scopedKey: string
| DismissCanvasMessageError
// Doc-side JS error forwarded from an AgentDoc iframe (window.onerror / unhandledrejection via
// 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<string, Map<string, string>>
| BridgeLivenessLoaded of Map<string, BridgeLiveness>
Expand Down
1 change: 1 addition & 0 deletions src/Client/CanvasAwareness.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ module CanvasAwareness

open Shared
open Navigation
open CanvasTypes

type CanvasEventKind = NewDoc | UpdatedDoc

Expand Down
Loading
Loading