# Right Rail

## Context

OpenWriter accumulated contextual UI as ad-hoc dropdowns hanging off the titlebar (`VersionPanel`, `ExportPanel`, `PluginPanel`, `AppearancePanel`, `ConnectionsPanel`) plus a floating action bar at the bottom of the editor (`ReviewPanel`). Each dropdown owned its own open-state, its own click-outside listener, and its own absolute-positioned overlay. Each new contextual surface (backlinks panel, activity log, future per-doc context) faced a choice between adding yet another titlebar dropdown or wedging itself into the left sidebar — neither of which scales.

The right rail consolidates every contextual surface into a single tabbed sidebar peer to the left navigation rail. The left rail answers *where am I in the workspace*; the right rail answers *what is happening with the doc I'm looking at, what does the agent know about it, and how do I act on it*. Splitting navigation from context lets both rails stay open while the user reads the editor between them, which is what makes the activity feed useful in the first place — entries in the feed point at docs in the tree, and clicking an entry shouldn't collapse the tree to read the feed.

## Current invariants

- **The rail container is a single peer of `.app-main`**, not a child of it. Layout is `.app > [.sidebar | .app-main | .right-rail]` as horizontal flex children. The editor stays centered between the two rails when both are open.
- **All tabs live in one registry.** Adding a new tab is a single entry in `right-rail/tabs.ts` — id, label, icon, scope (`'doc' | 'workspace' | 'settings'`), component. Trigger sites elsewhere (titlebar buttons, the bell, auto-open hooks) reference tabs by id only. No tab may carry its own open/closed state, click-outside handler, or absolute-positioned overlay; that chrome belongs to the rail.
- **Rail state is exactly three values**: `open: boolean`, `activeTab: TabId | null`, `width: number`. Persisted to `localStorage` under `ow-right-rail` so opening the app restores the prior state. Anything more (per-doc tab memory, per-workspace rail width) gets added only when the absence is causing a real problem.
- **The bell is the only chrome that broadcasts activity arrival.** A live `activity-event` WebSocket message pulses the bell IF (rail is closed) OR (rail is open but Activity is not the active tab). Pulse is purely visual (`scale 1 → 1.05 → 1` over ~400ms); no counter, no badge, no read/unread bookkeeping. Recency in the feed is communicated by relative timestamps and a fading accent bar on newly-arrived rows, not by a "new" badge.
- **Activity persists to disk.** Append-only JSONL at `~/.openwriter/profiles/<profile>/activity.log`, never truncated. Client reads the tail on connect (last 500 entries via WS `activity-log` on connection) and consumes live `activity-event` broadcasts thereafter. The on-disk log is the source of truth — in-memory state in the client is a cache.
- **Only agent-attributed actions become activity entries.** A human edit doesn't need to grab the human's attention via the feed. The `recordActivity()` server helper is called from broadcast sites that are themselves agent-attributed (`broadcastWritingStarted/Finished`, enrichment writes, references-frontmatter changes from minion sweeps). Plain `broadcastPendingDocsChanged` from human typing does not record activity.
- **Tab scope determines display, not behavior.** Doc-scoped tabs (Review, Versions, Backlinks, Exports) read the active filename from app state and update on doc switch. Workspace-scoped tabs (Activity) ignore the active doc. Settings tabs (Plugins, Connections, Appearance) ignore both. The rail container never re-mounts the tab body on doc switch — tabs handle their own re-fetch via the props they accept.
- **Auto-open on pending writes is the one exception to "rail only opens when the user opens it."** When `pendingDocs.filenames` goes from empty to non-empty, the rail opens to the Review tab automatically. Closing the rail does NOT dismiss the pending writes — they remain in Review, the user just hid the surface. Re-arrival of pending writes will re-open the rail.

## Decision log (append-only)

### 2026-06-09 — Responsive overlay mode: intent vs drawer split

- Narrow windows float the rail over the doc instead of pushing it — full
  design + invariants in [responsive-overlay-layout.md](responsive-overlay-layout.md).
  What changed here: context gains transient `overlay`/`drawerOpen` and derived
  `visible`; `openTab`/`closeRail`/`toggleRail` route to the drawer when
  `overlay` is set, leaving persisted `open` intent untouched. All chrome that
  used to gate on `open` (titlebar buttons, icon-strip selected state,
  keepalive mount) now gates on `visible`. Focus-mode exit skips the rail
  restore while in overlay so exiting focus lands on a clean doc.

### 2026-06-07 — Scrub pricing-model strategy from this public ADR

- **Trigger.** The repo is public; this ADR (and the changelog that linked it) exposed the AV pricing model and specific rejected per-edit cent values. Adopted two-tier changelog governance — public `CHANGELOG.md` / ADRs carry no business strategy; the *why* lives in gitignored `docs/releases.md`. See the "Changelog governance" rule in `CLAUDE.md`.
- **Change.** Removed the cost-model framing and the specific cent figures from the 2026-06-05 entry; left the UI architecture decision (qualitative tiers, collapsed top-up, section order) intact. Pricing-model detail relocated to `docs/releases.md` "Internal Release Notes". No public ADR should restate margins, multipliers, or per-unit economics.

### 2026-06-05 — AV plugin panel: reorder (model → balance → settings), collapse top-up, drop hard cents

- **Trigger.** Travis on the live AV plugin panel: *"We don't have fixed pricing, remember? These numbers can be wrong."* (the per-model legend showed hard per-edit cents, which read as a fixed menu when there is no fixed per-edit price — actual charge varies with edit length). Plus: the top-up buttons showed all the time (*"Should be just a single button topup balance... Then more options show?"*) and section order should be *"writing model as top most section. Then balance, then settings."* (Pricing-model specifics are internal — see `docs/releases.md`.)
- **Change 1: qualitative cost tiers, no cents.** `AV_MODEL_COSTS` legend (`PluginsTab.tsx`) now shows relative tiers (`free / $ / $$ / $$$`) instead of hard per-edit cents. There is no fixed price to quote; relative tiers convey "Opus costs more than Fast" without implying a per-edit menu price. `utils/av-billing.ts` header framing comment updated to match.
- **Change 2: top-up collapsed behind one "Add balance" disclosure.** `CreditsSection` gains `topupOpen` state; the three `$5/$10/$20` buttons are hidden until the user clicks "Add balance" (chevron toggle, same chrome as `.plugin-config-toggle`). Three always-on top-up buttons read as a hard sell every time the panel is open. New CSS: `.av-credits-topup-toggle`.
- **Change 3: section order is model → balance → settings, generically.** Previously the rich panel (AV wallet / publish billing / github settings) rendered ABOVE the config IIFE, so AV showed balance → model → settings. The three special-panel renders are now folded INTO the config IIFE as a `richPanel` node placed BETWEEN the top-level `select` fields and the Settings collapse. Result order: writing-model select → balance panel → Settings (secrets) collapse. Generic, but a no-op for publish/github — neither has `select` config fields (verified), so their panel still renders first. Extends the 2026-06-03 "selects at top level" invariant: **selects lead, then the plugin's rich panel, then secrets behind the collapse.**
- **Guard.** The config IIFE is now gated only on `p.enabled` (was `Object.keys(configSchema).length > 0`) so the `richPanel` always gets a chance to render; it early-returns `null` when there are no config entries AND no rich panel, so plugins with neither don't emit an empty `.plugin-config-section` box.
- **Files touched** (`packages/openwriter/src/`): `right-rail/tabs/PluginsTab.tsx` (legend tiers; `topupOpen` disclosure; fold special panels into the IIFE as `richPanel` between selects and collapse; empty-section guard), `right-rail/RightRail.css` (`.av-credits-topup-toggle`), `utils/av-billing.ts` (framing comment).

### 2026-06-03 — Plugins tab: surface `select` config fields at top level + inline save feedback

- **Trigger.** The Author's Voice model picker (a `select` config field — Fast/Balanced/Strongest) was buried inside the Plugins tab's collapsed "Settings" section alongside the API key, so changing the writing model meant expanding a collapse to find it. And `handleConfigBlur` POSTed to `/api/plugins/config` then swallowed the result (`.catch(() => {})`) — a config change gave zero confirmation it saved.
- **Invariant added: config-field altitude is type-driven, generically, for every plugin.** `PluginsTab` now partitions `configSchema` entries into `select`-type (rendered at the **top level** of the plugin item, always visible, above the Settings toggle) and everything else (text/password — API keys, secrets, URLs — kept **inside** the collapse). Chosen over a key-name heuristic (`/model/i`) because the type split is the structural truth: a `select` is a bounded, safe-to-surface choice; free-text fields are where secrets live. Against the real AV schema this surfaces exactly the model picker and nothing sensitive. Runs identically for all plugins — no per-plugin special-casing.
- **Invariant added: the save ✓ is trustworthy — driven by `res.ok`, never optimistic.** `handleConfigBlur` is now `async`, awaits the POST, and drives a per-field status (`idle → saving → saved → auto-clear ~2s`, or `error` on `!res.ok`/throw). The `.catch(() => {})` is gone. An inline `ConfigSaveStatus` indicator (Saving… / green ✓ Saved / red ✗ Failed) renders next to the field label, mirroring the saving/saved/error stages + green-checkmark language of `src/connections/ConnectionConfigModal.tsx`. The 2s auto-clear is guarded (`prev[key] === 'saved'`) so a newer edit mid-window isn't clobbered by a stale timer.
- **Verified live** (worktree server on :5051 against the real AV plugin): model `select` sits above the "Settings" collapse; API key (password) + backend URL stay inside it; changing the model shows Saving… → ✓ on a real 200; a forced 500 shows ✗ "Failed" (not ✓), proving the checkmark only appears on a confirmed save; ✓ auto-clears after ~2s.
- **Files touched** (`packages/openwriter/src/right-rail/`): `tabs/PluginsTab.tsx` (partition select vs collapsed fields; `ConfigSaveStatus` component + `saveStatus` state; rewrite `handleConfigBlur` to await + report), `RightRail.css` (`.plugin-config-status` variants; `.plugin-config-label` → flex row so the indicator sits inline).

### 2026-05-25 — Wire missing emitters: doc-created + doc-deleted (writing-finished deferred)

- **Trigger.** Parent agent created a new article doc via `create_document` + `write_to_pad`, then renamed it. The right-rail Activity tab showed only the transient `writing-started` entries — no `doc-created` row. Inbox brief flagged that `'writing-finished'`, `'doc-created'`, `'doc-deleted'` are wired into the `ActivityKind` enum + styled in the client but never emitted by the server.
- **Change 1: `doc-created` emitted from both `create_document` paths.** The `empty: true` immediate-switch path (`packages/openwriter/server/mcp.ts` ~L644) and the two-step `createDocumentFile` path (~L676) both call `broadcastActivityEvent({ kind: 'doc-created', headline: ``Created ${title}``, detail: content_type !== 'document' ? content_type : undefined, docId, filename })`. The MCP tool boundary IS the agent attribution — every call through here is by definition agent-driven, so no source-distinction guard is needed.
- **Change 2: `doc-deleted` emitted from `delete_document`.** Title captured via `resolveDocTarget(docId)` *before* `deleteDocument(filename)` runs (the file is in the trash after, so the read would fail). Headline: `Deleted ${title}`. The entry records `docId` + `filename` for back-compat; the client already treats `doc-deleted` rows as non-clickable (`ActivityTab.tsx:109`), so the missing live filename is fine.
- **Change 3: Workspace cascade as one summary entry, not N per-doc.** `delete_workspace` emits a single `doc-deleted` entry headlined `Deleted workspace ${title}` with `detail: '${N} docs'`. Per-doc entries would flood the log for large workspaces (the user just complained about MISSING entries — flooding the surface on a single user action would invert the problem).
- **Change 4: `writing-finished` deferred.** `writing-started` already records one entry per write batch; pairing 1:1 with a finished entry doubles every write into two rows, and the spinner-clear already communicates completion to the user. A grouped end-of-session variant (debounce until `pendingWrites.size === 0`) is a cleaner future change. Kept the enum + client styling so a future emit-site doesn't break.
- **Change 5: `doc-renamed` deferred to sibling chip.** A concurrent chip (`chip/rename-pending-overlay`) is routing renames through the same pending-overlay gate as body edits. The natural emit-site is the *accept* of a pending rename, which lives in that chip's territory. This chip leaves the kind unsdded; the rename chip can add `'doc-renamed'` to the enum + style it + emit it as one self-contained change.
- **Best-effort discipline.** Activity broadcasts are happy-path only — `recordActivity` already swallows disk errors (`activity-log.ts:135-137`). The new sites mirror that by NOT throwing on `broadcastActivityEvent` failure; if the entry doesn't land the underlying create/delete still completes.
- **Files touched** (`packages/openwriter/server/`): `mcp.ts` (three new `broadcastActivityEvent` call sites in `create_document` × 2 + `delete_document` + `delete_workspace`).

- **Trigger.** Travis: *"For our right rail, activity icon and panel. This needs to be clickable. If the agent works n a doc, I should be able to click it and goto the doc. [...] If deleted etc, nothing happens on click. Also, activity needs to switch places with review. (in the order of icons in the right rail)."* Follow-up: *"I can't click the recent activity from yesterday, the docs still show"* (revealed the writing-started broadcaster wasn't recording any link reference at all for orphan / sidebar-action / Untitled writes). Final correction: *"we should be using doc ids, no? Filenames are brittle on linking."*
- **Change 1: Activity entries are clickable navigators, identified by docId.** Each row resolves through a live `docId → current-filename` map (fetched once from `/api/documents` and refreshed on `ow-documents-changed`). A successful resolution makes the row a button-role + Enter/Space-keyable element calling `onSwitchDocument(filename)`. Workspace-wide log now doubles as a jump-list: see the agent wrote `Ch 7`, click the row, land on `Ch 7`. Resolving at click time means a renamed doc stays reachable even after the activity entry's filename text has gone stale.
- **Change 2: Deletion is implicit, not bookkept.** If the entry's docId isn't in the live doc map, the row silently isn't clickable. No `deletedFilenames` set, no `doc-deleted` event scanning — the doc map IS the source of truth for "still exists." The `doc-deleted` entry kind itself is hard-coded as non-clickable since the doc it announces is gone by definition. Out-of-band deletions (file removed on disk, rename, profile switch) all resolve correctly through the same map.
- **Change 3: Stable id everywhere on the activity record.** `ActivityEvent` gains an optional `docId` field, parallel to the existing `filename` and `nodeId`. All broadcasters were updated to record it: `broadcastWritingStarted` takes a new `activityDocId` arg; the enrichment and backlinks-added broadcasts in `mcp.ts` pass their respective docIds. `filename` is kept for back-compat (older log lines, fallback when docId resolution misses) but new entries record both — the client prefers docId.
- **Change 4: Seed backfill resolves missing ids.** Older log lines (recorded before broadcasters threaded docId or even filename consistently) get patched in `backfillActivityFilenames` before the seed leaves the server: filename → docId via `listDocuments`, then headline-title → `{docId, filename}` via a title index. Disk log stays append-only; only the in-flight seed payload is enriched. Entries whose title is *Untitled* stay un-resolved (no unique target) and the client correctly renders them as non-clickable.
- **Change 5: Strip order Review → Activity.** Flipped back to the launch order from 2026-05-22. Review leads because pending agent writes are the most time-sensitive signal in the rail; Activity sits second as the workspace-wide log. Reverses the 2026-05-23 *"Activity first"* decision below. First-run default tab stays `'activity'` (the rail still opens to Activity on first launch via `RightRailContext`); strip order and default-tab choice are now treated as independent decisions instead of one constraining the other.
- **Why docId over filename for activity links.** Filenames in OpenWriter are derived from titles and sanitized — every rename rewrites the filename. An activity log keyed on filename would silently break whenever the user renamed a doc the log mentioned. The docId is stamped into the doc's frontmatter at creation and survives rename, content rewrite, and profile move. Same reasoning as the prose-link system (`doc:DOCID` everywhere, never raw filename).
- **Files touched.** Server (`packages/openwriter/server/`): `activity-log.ts` (`docId?` field on `ActivityEvent` + persisted by `recordActivity`), `ws.ts` (`broadcastWritingStarted` takes `activityFilename`/`activityDocId`; `backfillActivityFilenames` now resolves docId from filename or headline title; importing `listDocuments`), `mcp.ts` (enrichment + backlinks-added broadcasts pass `docId`; create_document + declare_writes pass `result.docId` through to `broadcastWritingStarted`), `index.ts` (sidebar-action passes `filename` + `sourceDocId` to `broadcastWritingStarted`). Client (`packages/openwriter/src/right-rail/`): `tabs.tsx` (swap Review/Activity entries + rewrite header comment), `activity-store.ts` (add `docId?` to `ActivityEvent`, drop `deletedFilenames` field — superseded by the live doc map), `tabs/ActivityTab.tsx` (new `useDocsIndex` hook fetching `/api/documents` + listening for `ow-documents-changed`; `resolveEntryFilename` prefers `docId`, falls back to filename existence; clickability + click handler use the resolved current filename).

### 2026-05-23 — Backlinks: stale frontmatter + cache-leak fix + boot heal

- **Trigger.** Travis: *"This doc says an outbound exists to [target]. But when I click into the target doc, I do not see an inbound. [...] I haven't been able to see a single inbound actually on any doc."*
- **Two root causes, compounding.**
  1. **Stale references on disk.** The inverse-index scan in `backlinks.ts` reads each doc's `references:` frontmatter array. Older docs (created before the prose-link auto-sync pipeline existed, or imported from legacy formats) had prose `doc:` links in the body but no matching `references:` in the frontmatter. The sync runs on every save (`state.ts:writeToDisk` → `syncReferencesFromProse`), but docs that hadn't been re-saved since the sync landed kept their stale frontmatter. Result: `references` was incomplete → inverse scan returned 0 for the target doc → BacklinksTab showed "No docs link to this one yet."
  2. **Backlinks cache survived profile switches.** Module-level `backlinksCache` in `backlinks.ts` was invalidated by every save but NOT by `clearAllCaches()` on profile switch. After switching from Default → Demo → Default, the new profile served the previous profile's inverse map (or an empty map built during a transient bad state).
- **Fix part 1 (heal on boot).** Added a fire-and-forget `rebuildAllReferences()` to the server boot sequence (after `server.listen`). Walks every doc, extracts prose `doc:` links from the body, merges their targets into the `references:` frontmatter. Idempotent — second run does no writes. <1s for ~200-doc corpora. Logs `[Boot] Healed references frontmatter on N/M docs` when N > 0. Runs in background so it never blocks startup. Travis's Default corpus had 7 docs needing healing on first boot post-fix.
- **Fix part 2 (cache reset on profile switch).** Added `invalidateBacklinksCache()` to `state.clearAllCaches()` next to the activity-buffer reset from the prior change. Same class of bug, same hook, same fix.
- **Verified.** After restart, `/api/backlinks/120fca30` (Tokenized Displays) returned 6 inbounds correctly on the first read. Browser BacklinksTab on that doc shows **INBOUND 6 + OUTBOUND 8** with the expected entries (Ch 6 — Reconstructed Contests appears as inbound, exactly as Travis's outbound view promised).
- **Files touched** (`packages/openwriter/server/`): `state.ts` (`invalidateBacklinksCache` import + call in `clearAllCaches`), `index.ts` (boot-time `rebuildAllReferences` dynamic import + fire-and-forget).

### 2026-05-23 — Activity log: profile-scope leak

- **Trigger.** Travis after testing the Demo profile then switching back: *"Activity needs to be profile scoped. Its showing in default, your activity in the new profile you made."*
- **Root cause.** `activity-log.ts` keeps an in-memory ring buffer (`buffer: ActivityEvent[]`) plus a `bufferSeeded: boolean` flag at module scope. Disk writes were already per-profile (`getLogPath()` builds off `getDataDir()`, which respects the active profile), but the in-memory cache lived for the lifetime of the server process. After a profile switch, `seedBuffer()` short-circuited on `bufferSeeded === true`, so `loadActivityTail()` kept returning the previous profile's events. On the next WS connect for the new profile, the seed message carried the old profile's entries.
- **Fix part 1 (cache reset).** New `clearActivityBuffer()` in `activity-log.ts` that sets `buffer = []` and `bufferSeeded = false`. Wired into `state.clearAllCaches()` alongside the existing `docCache`/`pendingDocCache`/`externalDocs` resets — that function is already the canonical "profile is switching, drop everything" hook called from `/api/profiles/switch`.
- **Fix part 2 (client re-seed).** Clearing the buffer fixes the server, but connected browsers still hold the previous profile's list in `activity-store.ts`. Added `broadcastActivityLogSeed()` in `ws.ts` that pushes a fresh `activity-log` message (with the new profile's tail) to every open client. The client's `ow-activity-seed` handler already replaces the entire list, so the leak clears on the same tick as the profile switch — no reconnect required. Called from the profile-switch endpoint right after the existing `broadcast*` calls.
- **Verified.** Switched Default → Demo: Activity tab showed 3 entries from the Demo profile's earlier `declare_writes`. Switched Demo → Default: Activity tab showed "No activity yet" (Default has an `events.log` but no `activity.log` yet, which is correct). No cross-profile bleed in either direction.
- **Files touched** (`packages/openwriter/server/`): `activity-log.ts` (`clearActivityBuffer` export), `state.ts` (import + call in `clearAllCaches`), `ws.ts` (`broadcastActivityLogSeed` export), `index.ts` (import + call in profile-switch endpoint).

### 2026-05-23 — Review panel: scope toggle (All / Workspace)

- **Trigger.** Travis: *"Review panel doesn't have ability to toggle document range strictly to a workspace or not (we just discussed this)."* The prior pass implemented "always cycle every pending doc in the profile"; this pass adds the inverse — restrict navigation to the current doc's workspace when the user wants to focus.
- **Decision.** Two-state segmented control above the Document section: `All | Workspace`. Persisted to `localStorage` under `ow-review-scope`; default is `'all'` (matches the prior behavior, so no surprise on first encounter). When `'workspace'` is active, `pendingDocs.filenames` is filtered to docs whose path appears in the current doc's workspace tree (walked recursively through `containers`).
- **Workspace lookup is client-side.** Review tab fetches `/api/workspaces` on mount and subscribes to `ow-workspaces-changed` for refetch on tree edits. Building a per-doc-to-workspace map here (rather than pushing it through the WebSocket pending payload) keeps the server's `getPendingDocInfo` shape stable and confines the workspace concept to the one tab that needs it.
- **Edge case: current doc not in any workspace.** Orphan docs (untitled-workspace items, docs created outside a workspace) have no parent to scope to. When the active doc lacks a workspace, the Workspace toggle button is disabled with a tooltip explaining why; the filter falls through to `'all'` regardless of the persisted preference so the user is never stranded.
- **Edge case: workspace mode active, zero pending in this workspace, profile has pending elsewhere.** A new empty state appears: "No pending in this workspace — N docs have pending elsewhere. Switch to All to review them." The scope toggle remains visible so the switch is one click. Without this branch the user would see no UI, no toggle, no path forward.
- **Files touched** (`packages/openwriter/src/`): `right-rail/tabs/ReviewTab.tsx` (scope state + workspace fetch + filter + toggle UI + new empty branch).

### 2026-05-23 — Review panel: pending-docs scope bug + Accept All button styling

- **Trigger.** Travis: *"Review panel does not hold state of all docs that need review. I loaded up website demo profile, started going through review items, and then they ran out 'says all caught up' but there's tons of pendings. [...] Accept all should be an outlined green, not a solid green to delineate from single accept."*
- **Scope bug root cause.** `ReviewTab` had a single `if (!hasPending) → "All caught up"` early return. `hasPending` reflects the current in-memory TipTap editor's pending nodes — it goes false the moment the current doc is fully resolved, regardless of how many other docs are still in `pendingDocs.filenames`. When the user accepted all changes in doc A and the server removed it from `pendingDocCache`, the browser received an updated `pendingDocs.filenames` with B, C, D still pending — but `hasPending` was already false, so the "All caught up" screen appeared with no navigation.
- **Fix: split the early return into three cases.**
  1. `!hasPending && totalPendingDocs === 0` → genuine "All caught up" (nothing pending anywhere in the profile).
  2. `!hasPending && totalPendingDocs > 0` → "This doc is up to date" + the doc-navigation arrows (`goToPreviousDoc` / `goToNextDoc` work correctly at `currentDocIndex = -1`: next → index 0, prev → last). No phantom "all caught up" shown when other docs still have changes.
  3. `hasPending` → full review UI (unchanged).
  - The doc navigation in case 2 shows `? / N` because the current doc is not in the pending list; both arrow callbacks still navigate correctly — `currentDocIndex >= totalPendingDocs - 1 ? 0 : currentDocIndex + 1` evaluates to 0 at index -1 (first pending doc) and `-1 <= 0 ? totalPendingDocs - 1 : ...` wraps to the last.
- **Accept All styling.** Changed `.review-panel__accept-all` from solid green (`background: var(--color-pending-insert); color: white; border: none`) to outlined green (`background: transparent; color: var(--color-pending-insert); border: 1px solid var(--color-pending-insert)`). Hover fills it solid (same as the single Accept hover). Dark mode override updated to match: default state inherits the outlined treatment; hover adds `background: var(--color-pending-insert); color: white; filter: brightness(1.15)`.
- **Files touched** (`packages/openwriter/src/`): `right-rail/tabs/ReviewTab.tsx` (restructure early return; add `.review-tab__empty--inline` class), `right-rail/RightRail.css` (add `.review-tab__empty--inline { padding-top: 16px }`), `decorations/styles.css` (`.review-panel__accept-all` + dark mode overrides).

### 2026-05-23 — Remove click-to-close; Activity is default tab and first in strip

- **Trigger.** Travis, after using the rail for a session: *"I was wrong. The click same icon to close behaviour is annoying. Left sidebar the click icon again brings it back to the default sidebar state: filetree. What's the default right sidebar state? I say activity. Perhaps activity first then review (icon order)."*
- **Change 1: Remove click-active-icon-to-close.** The prior `onClick` in `RailIconStrip` had `if (open && activeTab === tab.id) closeRail()`. Removed — clicking any icon (including the already-active one) now always calls `openTab(tab.id)`. Closing requires the `HideRailIcon` button in the rail topbar. This matches the left sidebar's click-same-icon-again behavior, which returns to the default tree view rather than collapsing the sidebar.
- **Change 2: Activity is the default tab, first in strip.** Tab order was `Review → Activity → …`. Flipped to `Activity → Review → Backlinks → Exports → Versions → Plugins → Connections → Appearance`. Activity is the natural default: it's workspace-scoped and always-relevant, while Review is doc-scoped and only urgent when pending writes arrive. The first-run default (`activeTab: 'activity'`) in `RightRailContext` was already set this way; the strip order now matches the intent.
- **Files touched** (`packages/openwriter/src/`): `right-rail/RailIconStrip.tsx` (remove `closeRail` import + close branch from onClick), `right-rail/tabs.tsx` (swap Activity and Review entries).

### 2026-05-23 — Format toggle fully collapses bar; add Focus mode

- **Trigger 1.** Travis: *"Shouldn't format bar hide the bar also on hide? The bar visually stays, the format options are removed. It used to hide."*
- **Fix.** Drop the `.format-toolbar-empty` 36px placeholder that was kept "to preserve the search-row rhythm." When the format toolbar is collapsed, no element renders in its slot — the editor extends straight up to the bottom of the titlebar. Symmetry across the three columns at search-row height is sacrificed (sidebar's search + rail's strip still occupy 36px while the middle column doesn't), but matching the user's mental model of "hide = gone, not gone-but-empty" wins. The asymmetry only manifests when the user has explicitly hidden the format bar, which is itself a focus-leaning state.
- **Trigger 2.** Travis: *"Left of format icon should be focus mode. Focus mode closes both sidebars and hides format bar."*
- **Decision.** Add a focus-mode toggle that collapses left sidebar + right rail + format bar in one click. Snapshots prior state on entry and restores on exit (sidebar open/closed, rail open/closed + active tab, format bar visible/hidden).
- **Button placement** — same pattern as the format toggle: in the rail topbar to the LEFT of the format icon when the rail is open, in the titlebar to the LEFT of the format toggle when the rail is closed. Always reachable.
- **Snapshot ownership split.** `App.tsx` snapshots sidebar + toolbar state (it owns those). `RightRail.tsx` snapshots rail state (`open`, `activeTab`) because the rail context is inside the provider and App can't read those values directly. Both snapshots restore on exit via the same `focusMode` boolean propagating through props + a `useEffect` in RightRail that fires on the `focusMode` transition (intentionally NOT depending on `open`/`activeTab` so the effect doesn't re-fire mid-snapshot).
- **Icon.** `FocusModeIcon` — four corner brackets pointing outward. Universal "fullscreen / focus / expand canvas" pictogram. Active state uses the same accent-tinted background as the format toggle.
- **Files touched** (`packages/openwriter/src/`): `App.tsx` (focusMode state + toggleFocusMode + snapshot/restore for sidebar+toolbar; drop format-toolbar-empty render), `App.css` (drop `.format-toolbar-empty` styles), `right-rail/icons.tsx` (`FocusModeIcon` added), `right-rail/RightRail.tsx` (button in topbar; useEffect snapshots/restores rail state), `titlebar/Titlebar.tsx` (button next to format toggle when rail closed).

### 2026-05-23 — Right-align sync; keep format toggle reachable when rail is closed

- **Trigger.** Travis after seeing the first cut: *"Sync should be right aligned? And when closed, right side bar open icon should have format icon beside it (slight difference to left side icon closed, which has nothing else). We need to be able to close the formatting bar if open in full closed mode."*
- **Decision.** Two refinements on top of the prior "move chrome into rail topbar" change:
  1. Sync moves to the right cluster of the rail topbar. HideRail + format-toolbar toggle stay on the left (inward edge); sync sits on the right (outward edge near the viewport edge). The split mirrors the pre-rail titlebar's left-cluster (nav + undo/redo) vs. right-cluster (sync) grouping — preserved inside the rail topbar so the eye still finds sync "near the right."
  2. Format-toolbar toggle becomes the one piece of rail chrome that *survives* the rail closing: when the rail is closed it reappears in the global titlebar's right side, next to the OpenRail icon. Sync does NOT survive — opening the rail is still required to trigger a sync.
- **Why the asymmetry vs. the left sidebar.** The left sidebar's collapse hides search + tree + workspace controls outright, with the global titlebar exposing only an "open sidebar" button. The right side does the same EXCEPT that the format bar is independent of the rail conceptually — the user may keep the rail closed for long stretches while still wanting to collapse the format bar mid-write. Hiding the format toggle behind a rail-open would make the format bar feel modal. The format toggle is therefore the one always-on chrome control on the right side, accepting a small symmetry cost vs. the left.
- **Implementation note.** `App.tsx` passes `onToggleToolbar` + `toolbarOpen` to BOTH `Titlebar` and `RightRail`. Each component renders the button conditionally: `Titlebar` only when `!railOpen`, `RightRail` always (since rendering the rail topbar implies the rail is open). At any given moment exactly one of the two locations shows the button.
- **CSS change.** `.right-rail-topbar` flips from `justify-content: flex-start` to `space-between`; the two child `.right-rail-topbar-actions` divs (`--start` and `--end`) flank the empty middle. `margin-right: auto` on `--start` is removed since `space-between` handles the separation.
- **Files touched** (`packages/openwriter/src/`): `App.tsx` (re-pass toolbar props to Titlebar), `titlebar/Titlebar.tsx` (re-accept toolbar props, render format toggle when rail closed), `right-rail/RightRail.tsx` (split actions into start/end clusters), `right-rail/RightRail.css` (space-between layout).

### 2026-05-23 — Move sync + format-toolbar toggle into rail topbar

- **Trigger.** Travis: *"Sync and formatting icon need to be inside right side topbar, just like other icons are in leftside topbar, Open close icon sits at the far left of the icons in the right topbar. When closed, only the open right sidebar icon shows (matching left side behaviour). When closed, they all hide, then click to open."*
- **Decision.** Mirror the left sidebar's collapse pattern exactly. The rail's chrome (close button + format-toolbar toggle + sync button cluster) lives in the rail's own topbar. When the rail closes the entire topbar goes with it; the global titlebar shows only an "Open right rail" button on its right side.
- **Topbar layout** (left → right inside the topbar): `HideRail`, format-toolbar toggle, sync button cluster. HideRail at the far left puts it at the inward (toward-the-editor) edge of the rail topbar — mirror of the sidebar's collapse button which sits on the sidebar topbar's right (inward) edge. The actions cluster is left-aligned via `justify-content: flex-start` + `margin-right: auto` on `.right-rail-topbar-actions--start`, leaving the right side of the topbar empty for a future workspace-name label that mirrors the sidebar's "Documents" pinned-workspace header.
- **Titlebar after the move.** `Titlebar.tsx` no longer carries `syncStatus`, `onSync`, `onToggleToolbar`, or `toolbarOpen`. Its `titlebar-right` slot contains a single conditional button: the `OpenRailIcon` shown only when the rail is closed. All sync state and dropdown bookkeeping (`showPending`, `pendingFiles`, `loadingPending`, the close-on-outside effect, the cloud icons) moved to a new `src/sync/SyncButton.tsx` component.
- **Tradeoff acknowledged.** When the rail is closed the user cannot trigger sync without opening the rail first — identical to how the left sidebar's collapse hides search + tree + workspace controls until you reopen it. Travis: *"When closed, they all hide, then click to open."* Symmetry beat "always-visible sync" on this call.
- **Files touched** (`packages/openwriter/src/`): `App.tsx` (route sync/toolbar props from Titlebar to RightRail), `right-rail/RightRail.tsx` (topbar renders the new actions cluster), `right-rail/RightRail.css` (`flex-start` + active-state button variant), `titlebar/Titlebar.tsx` (strip sync + toolbar toggle, keep only OpenRail), `sync/SyncButton.tsx` (created — extracted from Titlebar).

### 2026-05-23 — Rebuild rail as full-height column peer of `.app-main`

- **Trigger.** Travis after seeing the strip-only variant: *"You see how ugly this rail bar is right? Doesn't look at all like the other side. Has no resize either. Doesn't extend the left edge to the top to parse the right side all the way to the top as the right side (like left side does with openwriter logo and the icons in that side of the panel)."* The previous shape — strip embedded in the editor's toolbar row, body embedded in the content row, no top header — broke the symmetry with the left sidebar.
- **Decision.** Replace the embed-in-rows model with a true column container. Rail is now a peer of `.app-main` (not nested inside it). The column owns three stacked sections that mirror the left sidebar's structural rhythm exactly:
  - `right-rail-topbar` — 48px, matches `.sidebar-topbar` height/bg/border. Currently holds the `HideRailIcon` close button on the right. Future home for the symmetric counterpart of the sidebar's icon cluster (the workspace-name-style label was discussed and deferred).
  - `rail-icon-strip` — 36px, search-row height. Drops its own `border-left` since the column carries it.
  - `rail-body` — fills the remaining height; scrolls internally.
- **Resize handle moves to the column.** Previously owned by `RailBody`, now lives on `.right-rail-column` so it spans the full height of the rail (matches sidebar's full-height resize handle).
- **Default state on first run flipped to OPEN with Activity active.** Discoverability win — the whole reason the rail exists is so agent activity is visible; hiding it on first load undercuts that. User can collapse it (state persists).
- **Titlebar gains an `OpenRailIcon` toggle** (mirror of the sidebar's open icon). Visible only when the rail is closed; clicking re-opens to the last active tab (defaulting to `activity`). Lives between the format-toolbar toggle and the sync button. The previous "panel icons in titlebar" pattern was already stripped — this is the *one* persistent rail-control affordance that survives.
- **CSS cleanup.** `.middle-row`, `.middle-row-empty`, `.content-row` deleted from `App.css`. `.editor-container` gets `flex: 1; min-height: 0` so it expands inside the now-flat `.app-main` column. A small `.format-toolbar-empty` placeholder keeps the 36px search-row rhythm when the toolbar is collapsed.
- **Files touched** (`packages/openwriter/src/`): `App.tsx`, `App.css`, `right-rail/RightRail.tsx` (created), `right-rail/RightRail.css`, `right-rail/RailIconStrip.tsx`, `right-rail/RailBody.tsx`, `right-rail/RightRailContext.tsx`, `right-rail/icons.tsx` (HideRailIcon + OpenRailIcon added), `titlebar/Titlebar.tsx`.
- **Deferred.** The rail topbar still only has the close button — no symmetric "Documents"-style label, no icon cluster mirroring the sidebar topbar's. Travis: *"Just build closest effort on this, we can move around once it's wired up properly."* Iteration happens against this working baseline.

### 2026-05-22 — Reorder tabs to user priority (post-launch tweak)

- Travis after seeing the strip live: *"Should go review, activity, links, download, then the rest."*
- New order applied in `tabs.tsx`: Review → Activity → Backlinks → Exports → Versions → Plugins → Connections → Appearance.
- Scope-based dividers replaced by a single "primary vs settings" divider, fired by `.right-rail-tab--scope-doc + .right-rail-tab--scope-settings` and `.right-rail-tab--scope-workspace + .right-rail-tab--scope-settings`. The first settings tab (Plugins) gets the divider regardless of which non-settings tab precedes it.
- Titlebar icon order updated to mirror the new strip order (bell → exports → versions → plugins → connections → appearance). Review and Backlinks stay rail-only (Review auto-opens; Backlinks via the strip).

### 2026-05-22 — Initial design and migration

- **Trigger.** Travis: *"You know what would be good, some sort of activity feed, just written, of agentic behaviour, docs modified, backlinks added, etc. Looking at the sidebar and doc view, it's working behind the scenes but I can't see anything."* Initial proposal was a new sidebar mode on the left, rejected in favor of a separate right sidebar so the file tree stays visible while reading the feed. Scope expanded mid-discussion to *every* contextual dropdown migrating into the rail — Travis: *"Yes all dropdowns should live in the right rail."*
- **Tab list at launch.** Eight tabs in user-priority order: Review, Activity, Backlinks, Exports, Versions, Plugins, Connections, Appearance. The first four are day-to-day primary actions; the last four are settings / history. A single visual divider in the strip marks the transition (between Exports and Versions). Order is intentionally NOT scope-based — Activity (workspace-scoped) sits at #2 because it's a high-attention surface, not because it groups with other workspace tabs.
- **Why not a left-sidebar mode.** A mode swaps out the file tree. Activity entries point AT docs in the tree, so swapping the tree to read the feed is backwards. The right rail lets both surfaces stay open simultaneously, which is the whole reason the feed is useful.
- **Why not single-purpose Activity panel.** Travis chose Path 2 (generic tabbed right rail) over Path 1 (Activity-only) explicitly. Path 1 would force a layout fight the next time a contextual surface needed a home; Path 2 absorbs each new surface as one entry in the tab registry.
- **Persistence: forever on disk, last 500 in memory.** JSONL log never truncates. At ~100 bytes per entry, heavy use is under 2MB/year. If a hard cap becomes useful later it can be added without migration since old entries are just lines in a file. Travis: *"Definite activity persistence, how long I don't know."* — disk-backed, future-extensible answers both halves.
- **No read/unread bookkeeping.** Travis explicitly rejected the unread-counter framing: *"Not sure read/unread is appropriate, its more just some sort of animation behaviour when activity happens, some sort of prominence, with timestamp."* The bell pulses on arrival, rows show an accent bar that fades over ~30s, timestamps decay through relative tiers (just now → Nm ago → Nh ago → Nd ago → date). No state to maintain, nothing to clear.
- **Bell trigger semantics.** Bell click opens the rail to Activity tab. If the rail is already open on another tab, bell switches the active tab to Activity. The bell pulses only when activity arrives while either (rail closed) or (rail open but Activity inactive). Pulse decays automatically; no manual dismissal.
- **Auto-open on pending writes.** ReviewPanel was previously a floating bar that auto-appeared when `pendingDocs.filenames.length > 0`. To preserve that "agent just wrote — act on it" affordance, the rail auto-opens to Review tab on the same transition. Migrating Review into a tab without this auto-open would silently lose the user's primary signal that the agent finished writing.
- **Layout file**: `packages/openwriter/src/App.tsx` adds `<RightRail />` as a sibling of `.app-main` (not inside it). The existing `.app:has(.review-panel) .editor-container { padding-bottom: 5rem; }` rule in `App.css` is removed since Review is no longer a floating overlay at the bottom of the editor.
- **Style mirroring.** `RightRail.css` uses the same tokens as `Sidebar.css` (`--bg-sidebar`, `--border`, `--ink-dark`, `--accent`) with `border-left` substituted for `border-right`. Width defaults to 280px (slightly wider than the left rail's 260px since tab bodies tend to need more horizontal space than a tree).
