diff --git a/.gitignore b/.gitignore index c092d45..9268e5d 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,7 @@ wwwroot/ # Server runtime data data +# .NET test output +src/Tests/TestResults/ + .agents/ diff --git a/docs/spec/canvas-authoring-dx.md b/docs/spec/canvas-authoring-dx.md new file mode 100644 index 0000000..7a08da8 --- /dev/null +++ b/docs/spec/canvas-authoring-dx.md @@ -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 +`` 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', 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 ``. + 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. diff --git a/docs/spec/canvas-pane.md b/docs/spec/canvas-pane.md index 8f611b7..1eb1bb1 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/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. diff --git a/src/Client/App.fs b/src/Client/App.fs index ed59de4..82de6cf 100644 --- a/src/Client/App.fs +++ b/src/Client/App.fs @@ -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 = diff --git a/src/Client/AppTypes.fs b/src/Client/AppTypes.fs index d16ee3b..b2e3e6e 100644 --- a/src/Client/AppTypes.fs +++ b/src/Client/AppTypes.fs @@ -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> | BridgeLivenessLoaded 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 2625c07..eaf4aa3 100644 --- a/src/Client/CanvasPane.fs +++ b/src/Client/CanvasPane.fs @@ -2,10 +2,16 @@ module CanvasPane open Shared open Navigation +open CanvasTypes open Feliz open Browser let [] CanvasOrigin = "http://127.0.0.1:5002" +// Doc→pane message size cap, in UTF-16 code units (JS String.length): the listener below drops a +// message when JSON.stringify(me.data).length exceeds this. The injected window.canvasSend helper +// enforces the SAME cap doc-side (var MAX=64000 in canvasSendScript, src/Server/CanvasDocServer.fs) +// so its accept/drop verdict matches this check — keep the two literals in sync if you change this +// value (CanvasDocServerTests pins the helper's copy). let [] private MaxPayloadBytes = 64_000 let private isDocAlive (bridgeLiveness: Map) (doc: CanvasDoc) = @@ -145,15 +151,17 @@ type CanvasPaneCallbacks = OnOverviewDocClick: string -> string -> unit ArchiveDoc: string -> unit DismissError: unit -> unit + DismissDocError: unit -> unit LaunchSession: unit -> unit } -let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus * CanvasDoc) option) (allRepos: RepoModel list) (sendState: CanvasSendState) (bridgeLiveness: Map) (unviewedFilenames: Set) (visitedDocs: string list) (callbacks: CanvasPaneCallbacks) = +let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus * CanvasDoc) option) (allRepos: RepoModel list) (sendState: CanvasSendState) (docError: DocJsError option) (bridgeLiveness: Map) (unviewedFilenames: Set) (visitedDocs: string list) (callbacks: CanvasPaneCallbacks) = let { SetPosition = setPosition SelectDoc = selectDoc OnOverviewClick = onOverviewClick OnOverviewDocClick = onOverviewDocClick ArchiveDoc = archiveDoc DismissError = dismissError + DismissDocError = dismissDocError LaunchSession = launchSession } = callbacks let positionButton (canvasPosition: CanvasPosition) (label: string) (title: string) = Html.button [ @@ -223,6 +231,32 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus ] | _ -> Html.none + // Doc-side JS error banner — a distinct source from CanvasSendState.Failed (which is a + // pane→session *delivery* failure). Rendered as a normal flex child of the column-layout pane + // (never absolutely positioned), so it pushes the iframe down instead of covering doc content. + // Doc-scoped: shown ONLY when the stored error's stamp matches the currently focused doc, so a + // stale error from a doc you navigated away from (tab, card, or keyboard focus) is never shown + // over a different doc — and never over the overview (focusedDoc = None). + let docErrorBanner = + match docError, focusedDoc with + | Some err, Some (wt, doc) when WorktreePath.value wt.Path = err.ScopedKey && doc.Filename = err.Filename -> + Html.div [ + prop.className "canvas-doc-error-banner" + prop.children [ + Html.span [ + prop.className "canvas-doc-error-text" + prop.title err.Message + prop.text $"Doc error: {err.Message}" + ] + Html.button [ + prop.className "canvas-doc-error-dismiss" + prop.onClick (fun _ -> dismissDocError ()) + prop.text "✕" + ] + ] + ] + | _ -> Html.none + let waitingBanner = match sendState with | CanvasSendState.Waiting _ -> @@ -244,10 +278,9 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus | Some (wt, doc) -> let isFocusedDocAlive = isDocAlive bridgeLiveness doc // The SystemView (beads) entry gets a distinct affordance pinned to the far left of the - // strip; AgentDocs keep the normal tab treatment. The strip also renders for a lone - // SystemView so its beads-count badge stays visible, while a lone AgentDoc still shows - // no tabs (its iframe fills the pane). - let hasSystemView = wt.CanvasDocs |> List.exists (fun d -> d.Kind = SystemView) + // strip; AgentDocs keep the normal tab treatment. The strip always renders the active + // doc's tab — including a lone AgentDoc (so it gets a labeled tab instead of a bare + // iframe) and a lone SystemView (so its beads-count badge stays visible). let agentTab (d: CanvasDoc) = let isActive = d.Filename = doc.Filename let isViewed = not (Set.contains d.Filename unviewedFilenames) @@ -263,17 +296,24 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus prop.children [ livenessDotFor bridgeLiveness d Html.text (d.Filename.Replace(".html", "")) + // On-disk freshness of the authored file, refreshed on the pane's existing + // render cadence. Scoped to AgentDoc tabs (a SystemView is server-generated + // and carries no authored-file age). + Html.span [ + prop.className "canvas-tab-age" + prop.text (Components.relativeTimeCompact System.DateTimeOffset.Now d.LastModified) + ] ] ] + // Always render the active doc's tab — no lone-AgentDoc suppression — while preserving + // the SystemView-first ordering (so the beads "BD" tab stays pinned left when present). let tabs = - if wt.CanvasDocs.Length > 1 || hasSystemView then - wt.CanvasDocs - |> List.sortBy (fun d -> match d.Kind with SystemView -> 0 | AgentDoc -> 1) - |> List.map (fun d -> - match d.Kind with - | SystemView -> systemViewTab wt (d.Filename = doc.Filename) selectDoc d - | AgentDoc -> agentTab d) - else [] + wt.CanvasDocs + |> List.sortBy (fun d -> match d.Kind with SystemView -> 0 | AgentDoc -> 1) + |> List.map (fun d -> + match d.Kind with + | SystemView -> systemViewTab wt (d.Filename = doc.Filename) selectDoc d + | AgentDoc -> agentTab d) // Render iframes for all visited docs; active is visible, others are hidden. // Ensure the active doc is always included even if not yet in visitedDocs. let docsToRender = @@ -297,6 +337,7 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus React.fragment [ headerBar tabs (Some doc) (doc.Kind = AgentDoc && not isFocusedDocAlive) errorBanner + docErrorBanner waitingBanner yield! iframes ] @@ -304,6 +345,7 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus React.fragment [ headerBar [] None false errorBanner + docErrorBanner waitingBanner overviewView allRepos bridgeLiveness onOverviewClick onOverviewDocClick ] @@ -318,29 +360,82 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus prop.children [ content ] ] -let messageListener (dispatch: string -> unit) (selectDoc: string -> unit) (onMorphComplete: unit -> unit) = +/// Callbacks the pane-internal `messageListener` raises for each recognized doc→pane message. +/// Grouped into a record (mirroring CanvasPaneCallbacks) so they are passed by name: Dispatch and +/// SelectDoc both share the type `string -> unit`, so positional passing let a silent argument +/// transposition compile and surface only at runtime. +type MessageListenerCallbacks = + { /// Forward an unrecognized (normal) doc payload on to the session. + Dispatch: string -> unit + /// Switch the active tab to the named doc (navigate-canvas-doc). + SelectDoc: string -> unit + /// The active doc finished an idiomorph (morph-complete). + OnMorphComplete: unit -> 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 + SelectDoc = selectDoc + OnMorphComplete = onMorphComplete + OnDocError = onDocError } = callbacks let handler = fun (e: Browser.Types.Event) -> let me = e :?> Browser.Types.MessageEvent 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" onMorphComplete () + 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 `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)" + let col = Fable.Core.JsInterop.emitJsExpr me.data "$0.col==null?'':String($0.col)" + let body = if rawMessage.Length > 500 then rawMessage.Substring(0, 500) else rawMessage + let message = + 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 {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 2f7c4b6..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 @@ -17,6 +18,14 @@ type CanvasState = PreviousCanvasHashes: Map> CanvasEvents: Map CanvasSendState: CanvasSendState + // Latest doc-side JS error from a focused AgentDoc's iframe, stamped with the doc that EMITTED + // it — its filename is carried in the postMessage and validated against the focused worktree's + // docs (DocJsError). The banner is shown only while that same doc is focused (CanvasPane gates + // on it), so navigating to another doc/card auto-hides a stale error — doc-scoped without a + // clear in every focus reducer. SelectCanvasDoc additionally clears it so a tab switch (and + // switch back) never re-shows it. Distinct from CanvasSendState.Failed, which models + // pane→session message-delivery failures. + DocError: DocJsError option BridgeLiveness: Map } /// Initial canvas state: pane closed on the right, all maps empty, send state idle. @@ -30,6 +39,7 @@ let empty : CanvasState = PreviousCanvasHashes = Map.empty CanvasEvents = Map.empty CanvasSendState = CanvasSendState.Idle + DocError = None BridgeLiveness = Map.empty } let [] private MaxLiveIframes = 3 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 547dd21..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 @@ -63,6 +64,10 @@ let selectCanvasDoc (scopedKey: string) (filename: string) (model: Model) = { model with Canvas = { model.Canvas with + // Doc-scoped error: a tab switch must never carry a stale error from the doc we're + // leaving into the one we're showing, so clear it here (it reappears only if the new + // doc throws again). + DocError = None ActiveCanvasDoc = model.Canvas.ActiveCanvasDoc |> Map.add scopedKey filename VisitedCanvasDocs = CanvasState.touchVisitedDoc scopedKey filename model.Canvas.VisitedCanvasDocs } }, Cmd.batch [ @@ -180,6 +185,28 @@ let canvasSendResult (result: CanvasMessageResult) (scopedKey: string) (model: M 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. `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) = + { model with Canvas = { model.Canvas with DocError = None } }, Cmd.none + let morphActiveDoc (model: Model) = model, Cmd.ofEffect (fun _ -> @@ -192,4 +219,8 @@ let morphComplete (model: Model) = model, markVisibleDocCmd model let messageListener (dispatch: Dispatch) = - CanvasPane.messageListener (CanvasMessageReceived >> dispatch) (NavigateCanvasDoc >> dispatch) (fun () -> dispatch MorphComplete) + CanvasPane.messageListener + { Dispatch = CanvasMessageReceived >> dispatch + SelectDoc = NavigateCanvasDoc >> dispatch + OnMorphComplete = fun () -> dispatch MorphComplete + OnDocError = fun scopedKey filename message -> dispatch (CanvasDocError (scopedKey, filename, message)) } diff --git a/src/Client/CanvasView.fs b/src/Client/CanvasView.fs index c599af3..54b08dd 100644 --- a/src/Client/CanvasView.fs +++ b/src/Client/CanvasView.fs @@ -71,6 +71,7 @@ let view (model: Model) (dispatch: Dispatch) = OnOverviewDocClick = onOverviewDocClick ArchiveDoc = archiveCanvasDoc DismissError = (fun () -> dispatch DismissCanvasMessageError) + DismissDocError = (fun () -> dispatch DismissCanvasDocError) LaunchSession = launchCanvasSession } - CanvasPane.view model.Canvas.CanvasPaneOpen model.Canvas.CanvasPosition (focusedWorktreeCanvasDoc model) model.Repos model.Canvas.CanvasSendState model.Canvas.BridgeLiveness focusedUnviewedFilenames focusedVisitedDocs canvasCallbacks + CanvasPane.view model.Canvas.CanvasPaneOpen model.Canvas.CanvasPosition (focusedWorktreeCanvasDoc model) model.Repos model.Canvas.CanvasSendState model.Canvas.DocError model.Canvas.BridgeLiveness focusedUnviewedFilenames focusedVisitedDocs canvasCallbacks diff --git a/src/Client/Client.fsproj b/src/Client/Client.fsproj index 6852c1a..d6cad92 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 8645cc7..19cf893 100644 --- a/src/Client/Components.fs +++ b/src/Client/Components.fs @@ -5,13 +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" + | 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) = + 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 78020cc..bd1b3f9 100644 --- a/src/Client/Navigation.fs +++ b/src/Client/Navigation.fs @@ -8,15 +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 - type RepoModel = { RepoId: RepoId Name: string diff --git a/src/Client/index.html b/src/Client/index.html index 51c3290..d9898b3 100644 --- a/src/Client/index.html +++ b/src/Client/index.html @@ -537,6 +537,26 @@ font-size: 1em; padding: 0 4px; opacity: 0.6; } .canvas-waiting-dismiss:hover { opacity: 1; } + /* Doc-side JS error banner. Amber accent keeps it visually distinct from the red + message-delivery failure banner (.canvas-error-banner) and the blue waiting banner. + As a normal flex child of the column-layout .canvas-pane it pushes the iframe down + rather than overlaying it; the single-line text ellipsizes (full text in the title) + so a long error can never grow the banner over the doc content. */ + .canvas-doc-error-banner { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + background: #3a2e1e; color: #fab387; padding: 4px 12px; + font-size: 0.85em; line-height: 1.4; border-bottom: 1px solid #5a4420; + } + .canvas-doc-error-text { + flex: 1; min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-family: 'Consolas', 'Cascadia Mono', monospace; + } + .canvas-doc-error-dismiss { + background: none; border: none; color: #fab387; cursor: pointer; + font-size: 1em; padding: 0 4px; opacity: 0.6; flex-shrink: 0; + } + .canvas-doc-error-dismiss:hover { opacity: 1; } .canvas-iframe { width: 100%; height: 100%; @@ -561,6 +581,11 @@ .canvas-tab:hover { background: #45475a; border-color: #585b70; } .canvas-tab.active { background: #2e3452; border-color: #89b4fa; color: #89b4fa; } .canvas-tab-viewed { opacity: 0.5; } + .canvas-tab-age { + margin-left: 6px; font-size: 0.8em; color: #6c7086; + font-variant-numeric: tabular-nums; + } + .canvas-tab.active .canvas-tab-age { color: #7f9bd6; } .canvas-system-tab { display: flex; align-items: center; gap: 5px; flex-shrink: 0; margin-right: 6px; diff --git a/src/Extension/skill/SKILL.md b/src/Extension/skill/SKILL.md index e73ec7f..94016bb 100644 --- a/src/Extension/skill/SKILL.md +++ b/src/Extension/skill/SKILL.md @@ -37,7 +37,15 @@ button:hover { background: #585b70; } ## Interactivity -Canvas docs can send messages back to the agent session via `postMessage`. Treemon validates the origin and forwards the message. +Canvas docs send messages back to the agent session with the injected **`canvasSend(action, payload)`** helper. Treemon validates the origin and forwards the message to the session that owns the doc. + +```js +canvasSend('my-action', { payload: 'data' }); +``` + +`canvasSend` is the primary API. It builds the flat message shape, posts it to the pane, and — before sending — checks the serialized size against the pane's limit (`JSON.stringify(message).length`, i.e. **64000 UTF-16 code units**). An oversized message is **not** sent; instead it logs a `console.error` in the doc so you get immediate feedback instead of a silent drop. `canvasSend` returns `true` when the message was posted and `false` when it was too large. + +The message shape is flat: `canvasSend('navigate-canvas-doc', { filename })` posts `{ action: 'navigate-canvas-doc', filename }` (which switches the active tab); `canvasSend('comment', { text })` posts `{ action: 'comment', text }`. That raw `postMessage` shape is the underlying contract and still works directly if you ever need it (e.g. the helper isn't available) — but it sends without the size check: ```js window.parent.postMessage({ action: 'my-action', payload: 'data' }, '*'); @@ -45,13 +53,13 @@ window.parent.postMessage({ action: 'my-action', payload: 'data' }, '*'); ### Don't block the conversation when the doc collects the answer -If the canvas doc itself gathers the user's input — choices, a form, buttons, a comment box — **do not** also call `ask_user` (or any other blocking prompt). The doc's `postMessage` reply *is* the channel for the answer. Calling `ask_user` at the same time pops a separate blocking modal, freezes the session, and prevents the user from responding through the doc you just built. +If the canvas doc itself gathers the user's input — choices, a form, buttons, a comment box — **do not** also call `ask_user` (or any other blocking prompt). The doc's `canvasSend` reply *is* the channel for the answer. Calling `ask_user` at the same time pops a separate blocking modal, freezes the session, and prevents the user from responding through the doc you just built. -Instead: write the doc, briefly tell the user it's ready for their input, then **end your turn and leave the conversation open**. The user's selection arrives as a normal message via `postMessage`, and you continue from there. Only use `ask_user` when there is no canvas doc collecting the response. +Instead: write the doc, briefly tell the user it's ready for their input, then **end your turn and leave the conversation open**. The user's selection arrives as a normal message via `canvasSend`, and you continue from there. Only use `ask_user` when there is no canvas doc collecting the response. ## Ownership -When you create or update a canvas doc, your session is automatically recorded as that doc's **owner**. That ownership is what routes the user's `postMessage` replies back to *your* session — even when several agent sessions are running in the same worktree. +When you create or update a canvas doc, your session is automatically recorded as that doc's **owner**. That ownership is what routes the user's message replies back to *your* session — even when several agent sessions are running in the same worktree. You never need to know or send your own session ID: writing the `.html` file with the **create** or **edit** tool *is* the ownership declaration — the extension stamps in the session ID and reports it to Treemon for you. So always author canvas docs with those tools under `.agents/canvas/`. Don't shell out to write the file (e.g. redirecting command output into it); the declaration only fires for the create/edit tools, and without it the doc's messages may reach the wrong session. @@ -59,7 +67,7 @@ Editing a doc another session created transfers ownership to you (most recent au ## Updating -Overwrite the file — Treemon detects content changes (via hash) and reloads the pane automatically. If Treemon isn't monitoring the directory, the extension serves canvas files over HTTP and returns the browser URL in the tool result right after you create or edit a canvas file (open it for the user or share the ctrl+clickable URL). `postMessage` interactions work identically in both modes — no changes needed in your HTML. +Overwrite the file — Treemon detects content changes (via hash) and reloads the pane automatically. If Treemon isn't monitoring the directory, the extension serves canvas files over HTTP and returns the browser URL in the tool result right after you create or edit a canvas file (open it for the user or share the ctrl+clickable URL). `canvasSend` interactions work identically in both modes — no changes needed in your HTML. ## Multiple docs @@ -115,7 +123,7 @@ Users can archive docs to `.agents/canvas/archive/`. Don't rely on canvas docs f function send() { const text = document.getElementById('msg').value.trim(); if (text) { - window.parent.postMessage({ action: 'comment', text }, '*'); + canvasSend('comment', { text }); document.getElementById('msg').value = ''; } } diff --git a/src/Server/BeadspaceTemplate.html b/src/Server/BeadspaceTemplate.html index f3aae03..0038a4d 100644 --- a/src/Server/BeadspaceTemplate.html +++ b/src/Server/BeadspaceTemplate.html @@ -38,6 +38,8 @@ * { margin: 0; padding: 0; box-sizing: border-box; } body { + margin: 0; + padding: 0; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; background: var(--bg-deep); color: var(--text-primary); diff --git a/src/Server/CanvasDocServer.fs b/src/Server/CanvasDocServer.fs index f27f43a..8af6d53 100644 --- a/src/Server/CanvasDocServer.fs +++ b/src/Server/CanvasDocServer.fs @@ -206,7 +206,20 @@ 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 body-SELECTOR rules (e.g. BeadspaceTemplate.html's +/// body{background:var(--bg-deep);margin:0;padding:0}, specificity 0,0,1) override the reset +/// (specificity 0,0,0) regardless of source order. The override is per-property and only via a rule on +/// the `body` selector: a box property the template zeroes through the universal `*{margin:0;padding:0}` +/// reset (also 0,0,0) would otherwise LOSE the source-order tiebreak to this later-injected +/// :where(body){padding:1rem}, so the template resets margin/padding on `body` directly to keep its +/// padding:0. 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 @@ -217,16 +230,100 @@ let private baseStyle = "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})") + + // The body box reset must survive too: the template zeroes padding on its `body` selector + // directly, so the injected zero-specificity :where(body){padding:1rem} (which would win the + // source-order tiebreak against the universal *{padding:0}) can't add a 1rem gap. + let! padding = this.Page.EvaluateAsync("() => getComputedStyle(document.body).padding") + Assert.That(padding, Is.EqualTo("0px"), $"SystemView body padding must stay 0 under the reset (was {padding})") + } + +// ============================================================================ +// 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/CanvasAwarenessTests.fs b/src/Tests/CanvasAwarenessTests.fs index 8d6455f..114825b 100644 --- a/src/Tests/CanvasAwarenessTests.fs +++ b/src/Tests/CanvasAwarenessTests.fs @@ -8,6 +8,7 @@ open App open AppTypes open CanvasUpdate open Navigation +open CanvasTypes open CanvasState open CanvasAwareness @@ -758,3 +759,105 @@ type NavigateCanvasDocTests() = Repos = [ makeRepo "myrepo" [ makeWorktree "myrepo" "feat" [ makeDoc "status.html" "h1" ] ] ] } Assert.That(isKnownCanvasDoc model "myrepo/ghost" "status.html", Is.False, "No worktree for the scoped key means nothing is known, so the navigation is dropped") + + +// ── Doc-side JS error (item 3: error overlay → canvas-doc-error banner) ─────── +// A doc-side JS error is stored in Canvas.DocError stamped with the worktree + doc that EMITTED it — +// both ride along in the message (wt/doc fields) and are validated against that worktree's docs +// (DocJsError { ScopedKey; Filename; Message }) so the pane can show the banner only while that doc +// stays focused — navigating away auto-hides a stale error (the view gates on the stamp). Stamping +// with the emitter (not the active tab) means an async error from a hidden/background iframe — even in +// a non-focused worktree — is attributed correctly (focused-review A-02, C-06). It is a distinct +// source from CanvasSendState.Failed, and SelectCanvasDoc clears it so a tab switch never re-shows it. + +[] +[] +[] +type DocErrorTests() = + + // A model whose focused card names a worktree carrying the given docs, so canvasDocError can + // validate the emitting filename against them and stamp the error. + let focusedModel (canvasDocs: CanvasDoc list) = + { defaultModel with + Repos = [ makeRepo "r" [ makeWorktree "r" "feat" canvasDocs ] ] + FocusedElement = Some (Card "r/feat") } + + [] + member _.``CanvasDocError stamps the error with the emitting doc``() = + let model = focusedModel [ makeDoc "a.html" "ha" ] + let updated = tryUpdateModel (CanvasDocError ("r/feat", "a.html", "Uncaught Error: boom (line 3:5)")) model + Assert.That(updated.Canvas.DocError, + Is.EqualTo(Some { ScopedKey = "r/feat"; Filename = "a.html"; Message = "Uncaught Error: boom (line 3:5)" }), + "A doc-side JS error must be stored, stamped with the doc that emitted it (so the banner is doc-scoped)") + + [] + member _.``an error is attributed to the emitter's worktree, not the focused one``() = + // Visited docs stay mounted as hidden iframes and keep running JS, so an async error from a + // HIDDEN doc in a DIFFERENT worktree (r/other → b.html) must be stamped with the emitter carried + // in the message, even while a doc in r/feat is focused. (Pre-fix the reducer stamped the + // FOCUSED worktree, misattributing a hidden worktree's error to the foreground — C-06.) + let model = + { defaultModel with + Repos = [ makeRepo "r" [ makeWorktree "r" "feat" [ makeDoc "a.html" "ha" ] + makeWorktree "r" "other" [ makeDoc "b.html" "hb" ] ] ] + FocusedElement = Some (Card "r/feat") } + let updated = tryUpdateModel (CanvasDocError ("r/other", "b.html", "boom from hidden")) model + Assert.That(updated.Canvas.DocError, + Is.EqualTo(Some { ScopedKey = "r/other"; Filename = "b.html"; Message = "boom from hidden" }), + "The error must be attributed to the emitter's worktree (r/other), not the focused worktree (r/feat)") + + [] + member _.``CanvasDocError is dropped when the emitter's worktree is unknown``() = + let model = focusedModel [ makeDoc "a.html" "ha" ] // only r/feat exists + let updated = tryUpdateModel (CanvasDocError ("r/ghost", "a.html", "boom")) model + Assert.That(updated.Canvas.DocError, Is.EqualTo(None), + "An error whose worktree is not known has nothing to attribute it to, so it is dropped") + + [] + member _.``CanvasDocError is dropped when the emitter is not a known doc of its worktree``() = + // Validation guard (mirrors NavigateCanvasDoc): only a filename naming a real doc of the + // emitting worktree may raise a banner, so a stale/forged filename — e.g. from an archived or + // deleted doc still posting from a lingering iframe — is dropped, never attributed. + let model = focusedModel [ makeDoc "a.html" "ha" ] + let updated = tryUpdateModel (CanvasDocError ("r/feat", "ghost.html", "boom")) model + Assert.That(updated.Canvas.DocError, Is.EqualTo(None), + "An error whose emitting filename is not a known doc of its worktree is dropped") + + [] + member _.``CanvasDocError does NOT touch CanvasSendState (distinct source)``() = + let baseModel = focusedModel [ makeDoc "a.html" "ha" ] + let model = { baseModel with Canvas = { baseModel.Canvas with CanvasSendState = CanvasSendState.Waiting "r/feat" } } + let updated = tryUpdateModel (CanvasDocError ("r/feat", "a.html", "boom")) model + Assert.That(updated.Canvas.CanvasSendState, Is.EqualTo(CanvasSendState.Waiting "r/feat"), + "Doc JS errors and message-delivery state are independent and must not overwrite each other") + Assert.That(updated.Canvas.DocError |> Option.map _.Message, Is.EqualTo(Some "boom")) + + [] + member _.``the newest doc error wins``() = + let model = focusedModel [ makeDoc "a.html" "ha" ] + let afterFirst = tryUpdateModel (CanvasDocError ("r/feat", "a.html", "first")) model + let afterSecond = tryUpdateModel (CanvasDocError ("r/feat", "a.html", "second")) afterFirst + Assert.That(afterSecond.Canvas.DocError |> Option.map _.Message, Is.EqualTo(Some "second"), + "A fresh error replaces the prior one") + + [] + member _.``DismissCanvasDocError clears the doc error``() = + let baseModel = focusedModel [ makeDoc "a.html" "ha" ] + let model = { baseModel with Canvas = { baseModel.Canvas with DocError = Some { ScopedKey = "r/feat"; Filename = "a.html"; Message = "boom" } } } + let updated = tryUpdateModel DismissCanvasDocError model + Assert.That(updated.Canvas.DocError, Is.EqualTo(None), "Dismiss must clear the banner") + + [] + member _.``DismissCanvasDocError leaves CanvasSendState untouched``() = + let model = { defaultModel with Canvas = { defaultModel.Canvas with DocError = Some { ScopedKey = "r/feat"; Filename = "a.html"; Message = "boom" }; CanvasSendState = CanvasSendState.Failed "send failed" } } + let updated = tryUpdateModel DismissCanvasDocError model + Assert.That(updated.Canvas.CanvasSendState, Is.EqualTo(CanvasSendState.Failed "send failed"), + "Dismissing the doc-error banner must not clear an unrelated send failure") + + [] + member _.``SelectCanvasDoc clears a stale doc error (so a tab switch back never re-shows it)``() = + let baseModel = focusedModel [ makeDoc "a.html" "ha"; makeDoc "b.html" "hb" ] + let model = { baseModel with Canvas = { baseModel.Canvas with DocError = Some { ScopedKey = "r/feat"; Filename = "a.html"; Message = "stale" } } } + let updated = tryUpdateModel (SelectCanvasDoc ("r/feat", "b.html")) model + Assert.That(updated.Canvas.DocError, Is.EqualTo(None), + "Switching tabs clears the stored error so it can never re-show when you switch back") diff --git a/src/Tests/CanvasDocServerTests.fs b/src/Tests/CanvasDocServerTests.fs index edf6a98..02d2ecf 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,46 @@ 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 or a quote + // can neither close the script element nor terminate the JS string literal early. + let injection = buildInjection AgentDoc "a\".html" + Assert.That(injection, Does.Not.Contain("var DOC=\"a\""), + "a raw quote/script tag must not appear unescaped in the embedded doc literal") + Assert.That(injection, Does.Not.Contain(".html"), + "the embedded filename must be HTML-escaped so it cannot close the injected script") + // ── End-to-end of the server's decision: classify(filename) -> injection ── [] member _.``beads.html classifies as a SystemView and gets the stripped injection``() = - let injection = buildInjection (CanvasDocKind.classify "beads.html") + let injection = buildInjection (CanvasDocKind.classify "beads.html") "beads.html" Assert.That(injection, Does.Not.Contain(Server.IdiomorphScript.idiomorphJs)) Assert.That(injection, Does.Not.Contain(Server.IdiomorphScript.morphController)) [] member _.``an agent .html classifies as an AgentDoc and gets the full injection``() = - let injection = buildInjection (CanvasDocKind.classify "status.html") + let injection = buildInjection (CanvasDocKind.classify "status.html") "status.html" Assert.That(injection, Does.Contain(Server.IdiomorphScript.idiomorphJs)) Assert.That(injection, Does.Contain(Server.IdiomorphScript.morphController)) diff --git a/src/Tests/CanvasPaneTests.fs b/src/Tests/CanvasPaneTests.fs index 44d797d..83ed7a4 100644 --- a/src/Tests/CanvasPaneTests.fs +++ b/src/Tests/CanvasPaneTests.fs @@ -405,13 +405,14 @@ type CanvasPaneTests() = let! tabCount = tabs.CountAsync() Assert.That(tabCount, Is.EqualTo(3), "Tab bar should have one button per canvas doc (3 docs in fixture)") - // Verify tab labels match filenames (without .html extension) + // Verify tab labels match filenames (without .html extension). Each AgentDoc tab also + // appends a compact last-modified age, so the label is a prefix of the tab's text. let! tab0Text = tabs.Nth(0).TextContentAsync() let! tab1Text = tabs.Nth(1).TextContentAsync() let! tab2Text = tabs.Nth(2).TextContentAsync() - Assert.That(tab0Text, Is.EqualTo("overview"), "First tab should be 'overview'") - Assert.That(tab1Text, Is.EqualTo("details"), "Second tab should be 'details'") - Assert.That(tab2Text, Is.EqualTo("metrics"), "Third tab should be 'metrics'") + Assert.That(tab0Text, Does.StartWith("overview"), "First tab should be 'overview'") + Assert.That(tab1Text, Does.StartWith("details"), "Second tab should be 'details'") + Assert.That(tab2Text, Does.StartWith("metrics"), "Third tab should be 'metrics'") } [] @@ -450,7 +451,7 @@ type CanvasPaneTests() = } [] - member this.``Single-doc worktree shows header bar but no tabs``() = + member this.``Single-doc worktree shows a labeled tab with a compact age``() = task { do! focusCanvasCard this.Page FixtureCanvasBranch do! ensureCanvasPaneOpen this.Page @@ -464,10 +465,20 @@ type CanvasPaneTests() = let! tabBarCount = tabBarEl.CountAsync() Assert.That(tabBarCount, Is.EqualTo(1), "Header bar should always render") - // But no tab buttons for single-doc worktree + // The lone AgentDoc now gets its own labeled tab button (no more bare-iframe suppression). let tabs = canvasTabs this.Page let! tabCount = tabs.CountAsync() - Assert.That(tabCount, Is.EqualTo(0), "Single-doc worktree should not show doc tabs") + Assert.That(tabCount, Is.EqualTo(1), "Single-doc worktree should show a tab button for its one doc") + + let! tabText = tabs.First.TextContentAsync() + Assert.That(tabText, Does.Contain("e2e-test"), "The single tab should be labeled with the doc filename (without .html)") + + // And it shows the file's compact last-modified age (now / Nm / Nh / Nd). + let age = this.Page.Locator(".canvas-pane .canvas-tab .canvas-tab-age") + let! ageCount = age.CountAsync() + Assert.That(ageCount, Is.EqualTo(1), "The AgentDoc tab should render a compact age element") + let! ageText = age.First.TextContentAsync() + Assert.That(ageText, Does.Match("^(now|\\d+[mhd])$"), $"Age '{ageText}' should be a compact relative time (now/Nm/Nh/Nd)") } // ── Empty Canvas Overview ─────────────────────────────────────────── diff --git a/src/Tests/ComponentsTests.fs b/src/Tests/ComponentsTests.fs new file mode 100644 index 0000000..d1b2c95 --- /dev/null +++ b/src/Tests/ComponentsTests.fs @@ -0,0 +1,80 @@ +module Tests.ComponentsTests + +open System +open NUnit.Framework +open Components + +/// Unit coverage for Components.relativeTimeCompact — the compact ("now"/"3m"/"2h"/"2d") sibling of +/// relativeTime used in the canvas tab strip. Pins the threshold boundaries (minute/hour/day) and +/// the int truncation, plus the contract that it drops the " ago" suffix relativeTime carries. +[] +[] +[] +type RelativeTimeCompactTests() = + + // Fixed reference instant; each case renders `now - span` so the test is deterministic. + let now = DateTimeOffset(2026, 6, 22, 12, 0, 0, TimeSpan.Zero) + let ago (span: TimeSpan) = relativeTimeCompact now (now - span) + + [] + member _.``zero elapsed renders now``() = + Assert.That(ago TimeSpan.Zero, Is.EqualTo("now")) + + [] + member _.``thirty seconds renders now``() = + Assert.That(ago (TimeSpan.FromSeconds 30.0), Is.EqualTo("now")) + + [] + member _.``fifty-nine seconds (just under a minute) renders now``() = + Assert.That(ago (TimeSpan.FromSeconds 59.0), Is.EqualTo("now")) + + [] + member _.``exactly one minute renders 1m``() = + Assert.That(ago (TimeSpan.FromMinutes 1.0), Is.EqualTo("1m")) + + [] + member _.``three minutes renders 3m``() = + Assert.That(ago (TimeSpan.FromMinutes 3.0), Is.EqualTo("3m")) + + [] + member _.``ninety seconds truncates to 1m (int truncation, not rounding)``() = + Assert.That(ago (TimeSpan.FromSeconds 90.0), Is.EqualTo("1m")) + + [] + member _.``ninety minutes truncates to 1h (hour-bucket truncation)``() = + Assert.That(ago (TimeSpan.FromMinutes 90.0), Is.EqualTo("1h")) + + [] + member _.``fifty-nine minutes (just under an hour) renders 59m``() = + Assert.That(ago (TimeSpan.FromMinutes 59.0), Is.EqualTo("59m")) + + [] + member _.``exactly one hour renders 1h``() = + Assert.That(ago (TimeSpan.FromHours 1.0), Is.EqualTo("1h")) + + [] + member _.``two hours renders 2h``() = + Assert.That(ago (TimeSpan.FromHours 2.0), Is.EqualTo("2h")) + + [] + member _.``twenty-three hours (just under a day) renders 23h``() = + Assert.That(ago (TimeSpan.FromHours 23.0), Is.EqualTo("23h")) + + [] + member _.``exactly twenty-four hours renders 1d``() = + Assert.That(ago (TimeSpan.FromHours 24.0), Is.EqualTo("1d")) + + [] + member _.``two days renders 2d``() = + Assert.That(ago (TimeSpan.FromDays 2.0), Is.EqualTo("2d")) + + [] + member _.``thirty-six hours truncates to 1d (day-bucket truncation)``() = + Assert.That(ago (TimeSpan.FromHours 36.0), Is.EqualTo("1d")) + + [] + member _.``compact form drops the ' ago' suffix that relativeTime carries``() = + // Same threshold input, different suffix: guards the compact/verbose contract. + let earlier = now - TimeSpan.FromMinutes 3.0 + Assert.That(relativeTimeCompact now earlier, Is.EqualTo("3m")) + Assert.That(relativeTime now earlier, Is.EqualTo("3m ago")) diff --git a/src/Tests/Tests.fsproj b/src/Tests/Tests.fsproj index d2d6636..402909a 100644 --- a/src/Tests/Tests.fsproj +++ b/src/Tests/Tests.fsproj @@ -27,6 +27,7 @@ + @@ -45,6 +46,7 @@ +