Wiki-browser UI integration — Design
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.
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:
- Resolve mode — a state where the central iframe slot is replaced by a diff view + toolbar for a chosen Proposal. Exiting Resolve mode restores the iframe.
- Rail — the 16-pixel collapsed state of a sidebar. Visible at the edge; clicking it expands the sidebar as a transient overlay.
- Margin glyph — the speech-bubble icon rendered in the right gutter of the prose column for every anchored Topic, vertically aligned with its anchor.
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.
Sidebar state model
Each sidebar moves through three states:
- Expanded — in the grid (default).
- Rail — collapsed to a 16-pixel strip at the screen edge with a chevron.
- Overlay — transient, floating over the main area; dismissed by click-outside, Escape, or re-click of the rail.
| Sidebar | User toggle | Auto-collapse | Persistence |
|---|---|---|---|
| Left (nav) | Yes — topbar hamburger | None | localStorage["wb.sidebar.left"] |
| Right (topics) | None | Only when viewport-width + Resolve-mode require it | Derived 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:
- Browse mode (default) — a single
<iframe id="wb-content">pointing at/content/{path}, exactly as today. - Resolve mode — a wrapper containing the diff toolbar, an optional stale banner, and either a Tier-1
<pre>(unified diff fetched fromGET /api/proposals/{id}/diff) or two Tier-2 iframes (/content/{path}+/content/preview/proposals/{id}).
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.
"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…
"Perspectives never carry conversation state"
Can we phrase this as an invariant rather than a rule? It's stronger.
Whole doc is too dense — break the long ordered list into subsections.
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:
- Background tint on the anchored region. Resting = ~12% tint of
--accent-light+ a dashed accent underline. Hover = ~30% tint. Selected = full--accent-light. - 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 onResizeObserverticks; the layer toggles visibility, not layout. - Wide-child overflow. Mermaid SVGs and other wide content grow past the prose column rightward (the existing
prose.cssallows the iframe body to scroll horizontally). Glyphs are positioned in a layer pinned to the viewport right edge of the iframe (CSSposition: fixedwithin 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.
- Effective-width fallback. The gutter only exists when
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.
💬Bidirectional anchor ↔ Topic navigation
| User action | Effect in iframe | Effect in right sidebar |
|---|---|---|
| Click Topic card | Scroll anchor into view (centered if possible); apply persistent selected highlight | Card selected; thread opens below |
| Click anchor text or glyph | Apply persistent selected highlight | Card 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 focused | Clear persistent highlight | Deselect 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.
Resolve flow
Thread header
When a Topic is selected, the right-sidebar thread shows a slim header with the two Topic-level CTAs:
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.
Discard confirmation
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.
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.
First attempt — phrased the invariant as a numbered list. Replaced by revision 2.
Looked good at the time, but another Incorporation landed before this one was approved. Click Rewrite to ask for a fresh proposal.
| Status | Derived from | Review opens | Approve button |
|---|---|---|---|
| Pending review | Latest incorporation_proposals row for this Topic, no stale_reasons | Approvable diff | Shown |
| Superseded | A higher revision_number exists for the same topic_id | Read-only diff | Hidden |
| Stale — source changed | stale_reasons includes "source_sha" | Read-only diff | Hidden |
| Stale — new Topic opened | stale_reasons includes "missing_topic_markers" | Read-only diff | Hidden |
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.
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.
/content/{path}; right iframe = /content/preview/proposals/{id}. Toolbar sticks; the diff scrolls beneath it.@@ -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.
localStorage["wb.diff.tier"]).Stale / superseded banner
Current
(updated source)
Proposed
(old proposal)
Three banner variants:
| Reason | Copy |
|---|---|
| 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
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
| Trigger | Effect |
|---|---|
| Click Review changes | Enter Resolve mode; iframe slot → diff. Fetch /api/proposals/{id}/diff (Tier 1) and /content/preview/proposals/{id} (Tier 2). Right sidebar stays. |
| Tier toggle | Show/hide pane variants; persist preference. |
| Approve → Commit & approve | POST /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 Document | Exit Resolve mode; SSE switches to the new Document. |
SSE topic.created on this Source | Stay 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 Topic | Stay in Resolve mode; refetch proposals; reload both Tier-2 iframes; stale banner surfaces; Approve hidden. |
Realtime consumer
Lifecycle
- On Document load (authenticated only): chrome fetches
GET /api/agent/jobs?source_path=…once to bootstrap job state (truth for the Rewrite button), then opensEventSource('/api/stream?source_path=…'). - On every
subscribedframe (initial connect and every reconnect): capturesubscriber_idand store it as the current per-source subscriber. It is used only forPOST /api/stream/focus; mutation events do not carry it. - Cache
self_user_idfrom the initial/auth/meresponse (theuser.user_idfield). 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_idis non-null for human messages and null foragent-proposalrows; the latter is not toast-eligible regardless.proposal.createdandjob.updatedcarry no actor field, so neither participates in originator-suppression logic. - 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_idmatters only for toast policy. - On navigation: close EventSource; reopen for the new Document.
- On
EventSource.onerror: probe/auth/me. Anonymous /401→location.reload(). Authenticated → treat as transient (EventSource auto-retries). - 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.
| Event | Refetch | Toast? | 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_appended | If thread open: /api/topics/{id}/messages | No | Auto-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}/proposals — base_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}/proposals | No | If the topic thread is open, the agent-proposal message (already inserted by the prior topic.message_appended) updates its status pill. |
job.updated | If failed/timed_out: /api/agent/jobs/{id} for error_tail | On failed / timed_out → toast "Rewrite failed on ' | Update Rewrite button state in place. |
presence.updated | None (full snapshot in payload) | No | Re-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):
404 unknown_subscriber— the cachedsubscriber_idis stale (server restart or eviction). Chrome drops the cached ID and re-POSTs the current focus after the nextsubscribedevent lands.404 unknown_topic— the Topic was just removed; chrome treats it as if the response had silently coerced totopic_id = "".429 too_many_focus_calls— silently dropped; the debouncer keeps us at 1/sec, so 429 is a defense-in-depth case, not the normal flow.
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.
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
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…
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
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.
- Browse mode on mobile: unchanged from today (hamburger + iframe; no right sidebar in the grid). Authenticated users get a second hamburger on the right side of the topbar that opens the Topic sidebar as an overlay.
- Margin glyphs are dropped. Background tint stays as the only anchor indicator.
- Resolve mode (≤700px): toolbar reflows to one row of icons; no Approve button; subject/body editor is not shown. Rewrite + Discard remain visible in the thread.
- Tier 2 on mobile collapses to a tab strip; one iframe at a time.
- Tier 1 on mobile renders the unified diff in a horizontally-scrollable
<pre>. - Presence chips, toasts, new-messages pill work as-is. Toasts stack from the bottom; max 2 visible.
HTTP and SSE surface (recap)
No new server-side endpoints. #8 consumes the surface already specified by #2, #4, #6, and #7:
- Topics:
POST /api/topics,GET /api/topics,GET /api/topics/{id}/messages,POST /api/topics/{id}/messages. - Resolution:
POST /api/topics/{id}/proposals,GET /api/topics/{id}/proposals,POST /api/topics/{id}/discard,GET /api/proposals/{id}/diff,GET /content/preview/proposals/{id},POST /api/proposals/{id}/incorporate. - Jobs:
GET /api/agent/jobs?source_path=…(bootstrap on Document load),GET /api/agent/jobs/{id}(one-shoterror_tailfetch). - Realtime:
GET /api/stream?source_path=…,POST /api/stream/focus. - Auth:
/auth/me,/auth/login,/auth/logout— already wired into the chrome.
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:
templates/shell.html— topbar slots (presence chips, right-sidebar toggle); Resolve-mode mount point inside.wb-main.static/chrome.css— sidebar rail/overlay states, Resolve mode containers, Topic-card visual polish, presence chips, toasts, agent-proposal styling.static/chrome.js— sidebar state machine, Resolve mode controller, EventSource lifecycle, focus-POST debouncer, toast queue, dead-doc state.static/content.js— margin-glyph layer,anchor:scrollhandler, overlap-menu replacing the current "firstdata-topic-idswins" click behaviour. The existing selection-composer flow is unchanged.static/prose.css— anchor resting/hover/selected states; glyph gutter layout.
No collaborative-DB schema change. No new SQL migration.
Cross-cutting open questions
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.
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.
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:
- 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 andhiddenin 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. - 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
- Perspectives — switcher, persona editor, regenerating indicator,
perspective.refreshedevent. Deferred to the future Perspectives sub-project (the #5 successor) which ships its own UI. - Discarded-topics archive view — discarded Topics drop out of the open list with no UI to surface them. Their thread + reason persists in the DB; the read-side will land in a future polish project.
- Cross-Document notifications — toasts and presence are document-scoped only.
- Inline diff-annotation comments for rework feedback — rework lives as plain
'human'messages per #4. - Mobile Resolve-mode Approve editor — explicitly excluded; mobile Resolve is read-only. The thread-header Rewrite and Discard buttons (with their inline-confirmation panels on the thread, not on the diff) remain functional on mobile.
- Keyboard shortcuts for Resolve mode (e.g.
aApprove,dDiscard) — deferred to a polish pass. - Batched incorporation (one rewrite + one commit for N Topics) — owned by sub-project #9. See the Forward-compatibility section above for the two minimal touches in #8.
Parking lot — for future sub-projects
For the future Perspectives sub-project (successor to #5)
- Perspective switcher widget — likely lives in the topbar between Title and Search. Mobile placement TBD.
- Persona editor UI — full-page or modal? Per-Document scoping per #1's
perspective_defsschema. - "Regenerating Perspective…" indicator — covers the lazy-refresh read-to-render gap.
perspective.refreshedevent consumption — reserved event name from #6; swap the iframe content when a refreshed Perspective lands.- Per-persona presence semantics — do chips show the audience the user is reading from?
For a future polish project
- Discarded-topics archive view.
- Cross-Document notifications (the second
/api/stream/globalendpoint #6 left room for). - Keyboard shortcuts across the chrome — Resolve, Topic navigation, sidebar toggles.
- Richer rework affordance — inline annotations on the proposed Source.
- Tier-3 inline rendered diff (Google-Docs-style).