Wiki-browser UI integration — Design
Design

Wiki-browser UI integration — Design

2026-05-15DanielCollaborative annotations · sub-project #8

Purpose

Wire the data layers and event surface specified in sub-projects #1–#7 into the wiki-browser chrome the operator and a small collaborator allowlist already use. The implementation work is mostly client-side: shell additions, an iframe-slot Resolve mode, Topic affordances inside the prose, and the realtime consumer that drops the legacy polling loop in favour of the SSE hub from #6.

Cross-reference: Domain model · Decisions & parking lot. Read the parking-lot items addressed to #8 in #2, #3, #4, #6, and #7 before reading this spec.

Scope decision

Perspectives UI — switcher, persona editor, regenerating indicator, perspective.refreshed event consumption — is deferred. The future Perspectives sub-project (the #5 successor) ships its UI alongside its data model. #8 ships every other parked UI item that depends on the data layers already in place.

Vocabulary delta

No new domain terms. This spec uses the existing vocabulary from the domain model and introduces UI-only terms:

Shell structure

Topbar

The existing topbar (hamburger, title, search, sign-in) grows two pieces — presence chips between search and sign-in, and a right-sidebar toggle that only appears when the right sidebar has auto-collapsed.

/doc/some/path.md
M D
Sign out
iframe — current source
Default desktop shell. Both sidebars in-grid; presence chips show one circle per remote collaborator.

Sidebar state model

Each sidebar moves through three states:

/doc/some/path.md
M
Sign out
iframe (wider — left collapsed)
Left collapsed to rail. Topbar hamburger flips to a chevron and re-expands when clicked.
/doc/some/path.md
M
Sign out
Resolve mode — Tier 2 diff fills the slot
Both sidebars at rail. The right-sidebar topbar toggle appears because the right is collapsed; both rails accept a click to peek as overlay.
SidebarUser toggleAuto-collapsePersistence
Left (nav)Yes — topbar hamburgerNonelocalStorage["wb.sidebar.left"]
Right (topics)NoneOnly when viewport-width + Resolve-mode require itDerived from viewport + mode; not persisted

Auto-collapse threshold (right sidebar): in Resolve mode with Tier 2, if total viewport width minus left-sidebar width minus 2 × 360-pixel minimum diff pane is less than the right sidebar's 320 pixels, the right sidebar auto-collapses to rail. Exiting Resolve mode (or switching to Tier 1) auto-restores it.

localStorage tolerance: localStorage["wb.sidebar.left"] accepts "expanded" or "rail"; missing, unknown, or corrupted values default to "expanded". Same convention applies to localStorage["wb.diff.tier"] (default "side-by-side").

Iframe slot modes

The central iframe slot has two modes, switched in place without leaving the document URL:

The transition is driven by chrome state, not by URL changes. The document URL (/doc/{path}) stays put; back-button behavior is unchanged — pressing Back while in Resolve mode navigates to the previous Document, not back to Browse mode for the current Document. Resolve state is ephemeral and intentionally not in the history stack; exit explicitly via the toolbar Close button, Approve, or Discard.

Topic affordances

Topic card

Topic cards in the right sidebar tighten today's three-line layout into a four-line, two-zone card: header (kind dot + label, with readers in the right corner), then optional anchored quote, then first-message preview.

Anchored
M

"a Document springs into existence the moment someone opens the first Topic on it"

Should we keep this opt-in by action? It feels right but I want to double-check the implications for…

Anchored — selected

"Perspectives never carry conversation state"

Can we phrase this as an invariant rather than a rule? It's stronger.

Global

Whole doc is too dense — break the long ordered list into subsections.

Three topic-card variants: anchored with one reader, anchored selected (accent border), and global (no quote, muted dot).

Global Topics list first under a small GLOBAL subheader; anchored Topics follow. Selected card uses the existing aria-selected="true" treatment. The existing #wb-new-global-topic button (already mounted in shell.html) persists in the sidebar header; styling moves to the design system primary-button token. The current window.prompt() entry path stays in v1 — a richer composer is a polish-pass item.

Anchors inside the iframe

Two redundant indicators mark every open non-global Topic's anchor in the rendered prose:

  1. Background tint on the anchored region. Resting = ~12% tint of --accent-light + a dashed accent underline. Hover = ~30% tint. Selected = full --accent-light.
  2. Margin glyph — a small speech-bubble icon rendered in a fixed-width gutter to the right of the prose column (the prose is constrained to 800 pixels max-width via prose.css; the gutter occupies the natural breathing space). One glyph per anchor, vertically aligned with the anchor's top. Click → focuses the Topic, same as click anchor.
    • Effective-width fallback. The gutter only exists when iframe-width − prose-padding − 800px ≥ 32px. Below that threshold (narrow viewport, both sidebars expanded), glyphs are not rendered — the background tint is the sole indicator. The renderer computes this on load and on ResizeObserver ticks; the layer toggles visibility, not layout.
    • Wide-child overflow. Mermaid SVGs and other wide content grow past the prose column rightward (the existing prose.css allows the iframe body to scroll horizontally). Glyphs are positioned in a layer pinned to the viewport right edge of the iframe (CSS position: fixed within the iframe document; right: 12px), not to the column edge. Horizontal scroll of the iframe does not move the glyph; the glyph stays at the visible right edge so a clicked glyph still routes to the correct Topic regardless of scroll position.

The Agent owns the Document. Humans never write to the Source directly — they propose changes through Topics.

💬

Resolution mutates the Source, then propagates: rewrite Source → refresh affected Perspectives → re-anchor surviving open Topics.

💬

Perspectives never carry conversation state. Anything stateful (a Topic, a draft, a Resolution decision) lives at the Document level.

💬
Three anchor states inside the iframe — resting (top), hover (middle), selected (bottom) — with margin glyphs in the right gutter.

Bidirectional anchor ↔ Topic navigation

User actionEffect in iframeEffect in right sidebar
Click Topic cardScroll anchor into view (centered if possible); apply persistent selected highlightCard selected; thread opens below
Click anchor text or glyphApply persistent selected highlightCard selected + scrolled into view; thread opens
Click overlapping anchor (data-topic-ids with N ≥ 2)Pop a tiny menu listing the topics(no change until user picks one)
Click global Topic card(no scroll target)Card selected; thread opens
Click empty area in iframe / Escape with sidebar focusedClear persistent highlightDeselect card; thread collapses

The contract uses the existing postMessage bridge in content.js with two existing kinds (topic:focus, topic:create-from-selection) plus one new kind: anchor:scroll {topic_id} sent from chrome to iframe.

Selection composer

The floating selection composer (content.js + prose.css) gets a visual pass: subtle border, soft shadow, primary "Save" button matching the design system. No structural change to the snapshot or POST contract.

Cancel Save
Selection composer after the "+ Topic" trigger is clicked. The trigger button styling is unchanged.

Resolve flow

Thread header

When a Topic is selected, the right-sidebar thread shows a slim header with the two Topic-level CTAs:

Anchored Topic
Rewrite Discard
Topic-level actions are always two: Rewrite and Discard. Both prompt confirmation.

The Rewrite button transitions to a disabled spinner while a wb-incorporate job for this Topic is queued or running; the state is driven by SSE job.updated events plus the active-job bootstrap (see Realtime consumer).

Rewrite confirmation

Ask the Agent to rewrite?

Queues a wb-incorporate job using the discussion so far. The proposal lands in the thread when the Agent finishes.

Cancel Rewrite
Lightweight confirmation. No body text input — feedback rides in the regular message thread.

Discard confirmation

Cancel Discard Topic
The reason becomes the final kind='human' message body. The Topic drops out of the open list after submit.

Compose-and-propose

The existing reply form (#wb-topic-reply-form) gains one checkbox under the textarea: Ask Agent to rewrite after sending. When checked, submit fires two API calls in sequence — POST /api/topics/{id}/messages then POST /api/topics/{id}/proposals. Per #4, no atomicity needed; the message just becomes part of the thread the Agent reads.

Agent-proposal messages in the thread

Rows with kind='agent-proposal' render distinctly from human messages: left accent stripe, light tint, "Agent" label in the header, status pill, and a footer Review changes button.

Agent Pending review

I read this Topic as asking to phrase the resolution mutation invariant as a sequence rather than a description. I tightened the bullet to "rewrite Source → refresh affected Perspectives → re-anchor surviving open Topics" and re-anchored the two other open Topics on this Source under their original phrases.

Agent · revision 1 Superseded

First attempt — phrased the invariant as a numbered list. Replaced by revision 2.

Agent Stale — source changed

Looked good at the time, but another Incorporation landed before this one was approved. Click Rewrite to ask for a fresh proposal.

Three agent-proposal states. Pending review opens an approvable diff; Superseded and Stale open a read-only diff with no Approve button.
StatusDerived fromReview opensApprove button
Pending reviewLatest incorporation_proposals row for this Topic, no stale_reasonsApprovable diffShown
SupersededA higher revision_number exists for the same topic_idRead-only diffHidden
Stale — source changedstale_reasons includes "source_sha"Read-only diffHidden
Stale — new Topic openedstale_reasons includes "missing_topic_markers"Read-only diffHidden

Resolve mode shell

Clicking Review changes swaps the iframe slot for the diff toolbar + diff area. The right Topic sidebar continues to show the thread; the user keeps full context (explanation + discussion) while reviewing.

× Close 1 Topic
Unified Side-by-side
Approve

Current

Resolution mutates the Source, then propagates. Incorporation: rewrite Source → refresh affected Perspectives → re-anchor surviving open Topics.

Proposed

Resolution mutates the Source, then propagates. Sequence: rewrite Source → refresh affected Perspectives → re-anchor surviving open Topics.

Tier 2 (default). Left iframe = /content/{path}; right iframe = /content/preview/proposals/{id}. Toolbar sticks; the diff scrolls beneath it.
× Close
Unified Side-by-side
Approve
@@ -312,3 +312,3 @@
 Resolution mutates the Source, then propagates.
-Incorporation: rewrite Source → refresh affected Perspectives → re-anchor surviving open Topics.
+Sequence: rewrite Source → refresh affected Perspectives → re-anchor surviving open Topics.
Tier 1 unified diff (user's preference is sticky in localStorage["wb.diff.tier"]).

Stale / superseded banner

× Close 1 Topic
Unified Side-by-side
Stale — the source changed. Another Incorporation landed before this one was approved. Click Rewrite on the Topic to ask the Agent for a fresh proposal.

Current

(updated source)

Proposed

(old proposal)

Banner present → Approve hidden entirely. The user can still inspect the diff for context.

Three banner variants:

ReasonCopy
Superseded"A newer proposal exists below in this thread. This one can no longer be approved."
Stale — source changed"Stale — the source changed. Another Incorporation landed before this one was approved. Click Rewrite to ask for a fresh proposal."
Stale — new Topic opened"Stale — a new Topic was opened during this proposal. Click Rewrite to refresh."

Approve inline editor

× Close 1 Topic
Unified Side-by-side
Cancel Commit & approve
Clicking Approve replaces the Approve button with this inline editor (toolbar Approve button collapses). The subject defaults to the #4-derived "Incorporate Topic: <summary>" (60-rune truncated); the user can edit before committing.

Submit → POST /api/proposals/{id}/incorporate {subject, body}. On 409 stale_proposal, the editor collapses and the stale banner re-renders with the freshly-returned stale_reasons; Approve disappears entirely.

Resolve lifecycle summary

TriggerEffect
Click Review changesEnter Resolve mode; iframe slot → diff. Fetch /api/proposals/{id}/diff (Tier 1) and /content/preview/proposals/{id} (Tier 2). Right sidebar stays.
Tier toggleShow/hide pane variants; persist preference.
Approve → Commit & approvePOST /api/proposals/{id}/incorporate. On success, the SSE topic.incorporated event triggers iframe reload + Resolve mode exit + Topic closes.
Discard (any state)POST /api/topics/{id}/discard. On success, the Topic closes; exit Resolve mode if active.
Close (×)Exit Resolve mode; iframe restores. Topic remains selected.
Navigate to a different DocumentExit Resolve mode; SSE switches to the new Document.
SSE topic.created on this SourceStay in Resolve mode; refetch /api/topics/{id}/proposals; banner re-renders with missing_topic_markers if the proposal now stales.
SSE topic.incorporated for a sibling TopicStay in Resolve mode; refetch proposals; reload both Tier-2 iframes; stale banner surfaces; Approve hidden.

Realtime consumer

Lifecycle

  1. On Document load (authenticated only): chrome fetches GET /api/agent/jobs?source_path=… once to bootstrap job state (truth for the Rewrite button), then opens EventSource('/api/stream?source_path=…').
  2. On every subscribed frame (initial connect and every reconnect): capture subscriber_id and store it as the current per-source subscriber. It is used only for POST /api/stream/focus; mutation events do not carry it.
  3. Cache self_user_id from the initial /auth/me response (the user.user_id field). The cache is only used for toast suppression on the three events that actually carry an actor field — topic.created.created_by, topic.incorporated.incorporated_by, topic.discarded.discarded_by. topic.message_appended.author_user_id is non-null for human messages and null for agent-proposal rows; the latter is not toast-eligible regardless. proposal.created and job.updated carry no actor field, so neither participates in originator-suppression logic.
  4. Self-event handling: per the #6 "publisher receives its own events" decision, the hub does not filter the originator. Convergence happens by record-ID dedup — every event handler reads the canonical record (via the REST refetch listed in the table below or via the already-rendered state) and renders only if the record is new or its monotonic key advanced. Already-rendered topics, messages, proposals, and jobs are no-ops on event arrival. This is sufficient on its own; self_user_id matters only for toast policy.
  5. On navigation: close EventSource; reopen for the new Document.
  6. On EventSource.onerror: probe /auth/me. Anonymous / 401location.reload(). Authenticated → treat as transient (EventSource auto-retries).
  7. Initial-connect 404 unknown_source → terminal; render the dead-doc state below and stop reconnecting.

Event handlers

Per the #6 "REST authoritative, events advise" rule, every handler refetches REST state. Payloads are used only as previews (≤160 chars) and for routing dispatch.

EventRefetchToast?Extra effect
topic.created/api/topics?source_path=…. If Resolve mode is open, also /api/topics/{resolve_topic_id}/proposals — a new sibling Topic stales the in-flight proposal (missing_topic_markers).If originator ≠ self → toast "X opened a Topic"Card appears in sidebar list. In Resolve mode, the freshly-fetched proposals trigger stale-banner re-render and Approve hide.
topic.message_appendedIf thread open: /api/topics/{id}/messagesNoAuto-append if at bottom; pill if scrolled up. Closed thread → unread dot on card. proposal_id in payload routes to agent-proposal rendering.
topic.incorporated/api/topics?source_path=…. If Resolve mode is open for the same Topic, no extra refetch needed (Resolve exits). If Resolve mode is open for a different Topic on this Source, also /api/topics/{resolve_topic_id}/proposalsbase_source_sha just drifted.If originator ≠ self → toast "X incorporated …"Reload the Browse-mode iframe (source_path in payload). Resolve mode for the matched Topic exits. Resolve mode for a sibling Topic stays open; reload both Tier-2 diff iframes (/content/{path} + /content/preview/proposals/{id}) so the stale banner reflects the post-commit state; the proposals refetch surfaces the banner and hides Approve.
topic.discarded/api/topics?source_path=…If originator ≠ self → toast "X discarded …"Topic drops from open list; exit Resolve mode if the topic matched. A sibling Topic discard does not stale a Resolve view (only Source-mutating events do).
proposal.created/api/topics/{id}/proposalsNoIf the topic thread is open, the agent-proposal message (already inserted by the prior topic.message_appended) updates its status pill.
job.updatedIf failed/timed_out: /api/agent/jobs/{id} for error_tailOn failed / timed_out → toast "Rewrite failed on ''" (no originator filter — the event carries no actor field, and either collaborator may want to know)Update Rewrite button state in place.
presence.updatedNone (full snapshot in payload)NoRe-render topbar chips + per-card readers.

Focus protocol

Topic focus changes POST /api/stream/focus {subscriber_id, topic_id}; closing the thread posts {topic_id: ""}. Chrome debounces rapid changes with a trailing-edge 1-second timer to stay under the server's per-subscriber rate limit. Focus on a discarded/incorporated Topic returns 200 (per #6's silent coercion); chrome doesn't special-case the response.

Error handling (per #6):

Presence chips (topbar)

Dedup by user_id: one chip per remote person. The tooltip carries full display_name + tab count when N > 1. The current user does not render a self-chip.

M D ↑ hover for name + tab count
Two collaborators present. Chip colors are deterministic from user_id (warm palette only).

Per-Topic reader indicator

Each Topic card shows initials (max 3 stacked, then +N) for every subscription whose focused_topic_id matches. The indicator updates live from presence.updated.

Toast

M Max opened a Topic on this Document. Focus ×
Bottom-right stack, max 3 visible, FIFO drop. Auto-dismiss 6s; pause-on-hover extends to 12s; manual ✕ always available.

Toasts fire for: topic.created, topic.incorporated, and topic.discarded from another user (originator filter compares the event's actor field to the cached self_user_id); and all job.updated{failed,timed_out} events (which carry no actor field, so they surface unconditionally — both collaborators care about a failed rewrite). Everything else — topic.message_appended, proposal.created, job.updated{queued,running,succeeded}, presence.updated — is silent.

"New messages above" pill

…earlier messages…

3 new messages ↓
Threshold: scroll-from-bottom > 40px. Click → scroll to bottom + clear.

Dead-doc state

This document no longer exists.

It was removed from the repository. The Topics that existed for it are no longer reachable through this URL.

Back to index

Replaces the iframe slot when SSE returns 404 unknown_source. The right sidebar hides; presence chips clear.

What goes away

The existing client-side polling loop against GET /api/agent/jobs/{id} is removed. job.updated covers every transition the poll used to detect. The endpoint stays for the one-shot error_tail fetch on failed/timed_out events.

Mobile

Mobile (≤768px, the existing chrome breakpoint) is read-only first, diff-eval second. Authentication still gates Topic affordances; collaborators on mobile can read threads, reply, create Topics via the existing selection composer, and inspect proposals — but cannot approve. The Resolve-mode tab-strip threshold (≤700px) and the right-sidebar hide threshold (≤900px in chrome.css) remain as today; they can be re-tuned during implementation if real-world widths argue for it.

×
Unified Tabs
Current
Proposed
/content/{path}
Mobile Tier 2 = tabs. No Approve in the toolbar.

HTTP and SSE surface (recap)

No new server-side endpoints. #8 consumes the surface already specified by #2, #4, #6, and #7:

One new client → iframe message kind: anchor:scroll {topic_id}. Existing kinds topic:focus and topic:create-from-selection are reused unchanged.

All POST endpoints require the X-CSRF-Token header (already plumbed by chrome.js's csrfHeaders()). New POST sites — POST /api/topics/{id}/proposals, POST /api/topics/{id}/discard, POST /api/proposals/{id}/incorporate, POST /api/stream/focus — go through the same helper.

Implementation surface (informative)

The work is mostly within internal/server/static/ (chrome JS/CSS, content JS/CSS) and internal/server/templates/shell.html. No new Go files are required; small additions only:

No collaborative-DB schema change. No new SQL migration.

Cross-cutting open questions

Tier-1 diff rendering

The GET /api/proposals/{id}/diff response from #4 is unified-diff text. v1 renders it as a plain <pre> with line-prefix CSS classes (.add, .del, .hunk). Side-by-side syntax-aware highlighting is deferred.

Margin-glyph repositioning on resize

Glyphs are absolutely positioned based on each anchor's getBoundingClientRect().top relative to the prose container. A ResizeObserver on .wb-prose + a debounced recompute on font-load and image-load handles reflow.

localStorage namespace

wb.sidebar.left (expanded|rail), wb.diff.tier (unified|side-by-side). These are non-sensitive UI preferences; chrome does not clear them on logout. The post-logout location.reload() rerenders the public shell, which simply ignores these keys (the topic sidebar is gone, the diff tier never reads).

Forward-compatibility for batched incorporation (sub-project #9)

Batched incorporation — one Agent rewrite + one commit incorporating N Topics — is its own sub-project (#9) and is deferred. Two small touches in #8 make the future delta cheaper without committing to anything today:

  1. Topic-card markup reserves space for a future selection control. The card root has a <span class="wb-topic-card__select" hidden></span> slot in the top-left corner. Empty and hidden in v1; the future #9 spec drops a checkbox into the slot and unhides it. This keeps the card's spatial layout stable across the transition.
  2. Resolve view shows a "1 Topic" label in the toolbar. The toolbar gains a small muted label between Close and the tier toggle: 1 Topic. With N=1 today, the label is informational; when #9 lands, the label becomes the click target that expands the batch list. Cost in v1 is one rendered element.

These are the only forward-compat touches. Schema, endpoints, skill prompts, and the rest of the UI stay strictly single-Topic — #9 does the migration when it lands.

Out of scope

Parking lot — for future sub-projects

For the future Perspectives sub-project (successor to #5)

For a future polish project