Skip to content

Add daemon WebSocket event hub#744

Merged
backnotprop merged 10 commits into
feat/runtime-frontend-shellfrom
feat/websocket-event-hub
May 27, 2026
Merged

Add daemon WebSocket event hub#744
backnotprop merged 10 commits into
feat/runtime-frontend-shellfrom
feat/websocket-event-hub

Conversation

@backnotprop
Copy link
Copy Markdown
Owner

Summary

  • Replace persistent SSE streams with a single WebSocket connection per frontend instance
  • Daemon multiplexes session-scoped events (annotations, agent jobs, lifecycle) through /daemon/ws
  • Subscribe/unsubscribe messaging with connection-local subscriptions
  • Session actions over WebSocket for the approval loop
  • Reconnect/resync with snapshot-before-delta ordering
  • Auth enforcement on daemon-scope subscriptions
  • Polling fallback for WebSocket-blocked environments
  • Complete goal-setup protocol typing, feature registration, and daemon test coverage

Test plan

  • bun run typecheck passes
  • bun run test — 1,399 tests pass
  • bun run dev:debug-stack — sessions stream events over WebSocket
  • Goal-setup interview and facts scenarios complete through TUI

Single daemon process per machine manages session lifecycle, serves
browser UIs at /s/<sessionId>, and exposes control APIs. CLI commands
auto-start the daemon and create sessions via HTTP. Includes session
store, auth tokens, lock files, event broadcasting, and goal-setup
daemon integration.
Debug frontend (apps/debug-frontend) served by the daemon as the session
browser shell. Agent simulator TUI (apps/debug-tui) exercises all agent
protocols with concurrent session support. Includes reactive session
dashboard, event log, prominent session action buttons, and goal-setup
scenarios.
Replace persistent SSE streams with a single WebSocket connection per
frontend instance. Daemon multiplexes session-scoped events (annotations,
agent jobs, lifecycle) through /daemon/ws with subscribe/unsubscribe
messaging. Includes session actions over WebSocket, reconnect/resync,
auth enforcement, and polling fallback.
The goal-setup daemon integration from layers 734/738 was lost during
re-squash cascades. This commit restores all missing pieces:

- PluginGoalSetupRequest type and goal-setup action in plugin protocol
- goal-setup case in daemon session factory
- setup-goal CLI subcommand routed through runDaemonSessionRequest
- goal-setup-submit/goal-setup-exit debug frontend actions
- TUI scenarios for interview and facts with fixture bundles
- TUI completion for goal-setup sessions
- Fix getRepoInfo to use session cwd in createGoalSetupSession
- Standardize naming: "goal-setup" everywhere (was "setup-goal" in
  daemon protocol and debug frontend)
The source shim resolves workspace imports through package exports.
Without these entries, `bun apps/hook/server/index.ts` fails to find
daemon modules even though the files exist on disk.
- Add PluginGoalSetupResult to PluginActionResult union
- Add goal-setup to PLANNOTATOR_PLUGIN_FEATURES
- Use typed result in CLI instead of cast
- Add session factory tests: interview submit and facts exit
- Resolve relative bundle paths against getInvocationCwd() so
  hook/wrapper invocations find the file in the project directory
- Remove goal-setup from PLANNOTATOR_PLUGIN_FEATURES since it is
  a direct CLI command invoked by agent skills, not a plugin action
  dispatched through the plugin protocol
The onerror and onclose socket handlers both called scheduleReconnect(),
but the handleMessage error branch tore down the socket without
scheduling a reconnect — leaving the client permanently dead.
* Add production frontend app with daemon project registry

Daemon:
- Project registry persisted to ~/.plannotator/projects.json
- Auto-registers projects from session cwd on creation
- GET/POST/DELETE /daemon/projects endpoints
- cwd exposed on DaemonSessionSummary
- project-registry feature flag + 9 unit tests

Frontend (apps/frontend/):
- TanStack Router, Zustand+Immer, Tailwind v4, oxlint/oxfmt
- Daemon API client with project + session creation methods
- WebSocket hub client with event stream + polling fallback
- Landing page: project selector table + Code Review/Archive actions
- Offcanvas sidebar: sessions grouped by mode (matching prototype)
- Add project dialog
- DiffKit theme + prototype shell pattern (bg-muted → card → content)
- 13 shadcn primitives vendored in components/ui/
- Single-file HTML build (viteSingleFile)
- Dev script: daemon + Vite with auto-proxy (scripts/dev-frontend.ts)

* Remove unused shadcn components and hooks

badge, card, dialog, dropdown-menu, label — not imported by any app code.
use-project-actions, use-sessions-by-project — orphaned after sidebar
and landing page rewrites.

* Rename diffkit theme to neutral

* Add active sessions list to landing page

* Embed code review surface in frontend app (#755)

* Copy review-editor and editor as new embeddable packages

packages/plannotator-code-review — copy of packages/review-editor
packages/plannotator-plan-review — copy of packages/editor

Unmodified copies to start. These will be refactored to strip
standalone providers (ThemeProvider, TooltipProvider, Toaster)
and accept session-scoped API context from the frontend shell.
The original packages remain untouched for the legacy single-file
HTML flow.

* Add useSessionFetch hook and SessionProvider

React context that scopes fetch calls to a daemon session.
When inside a SessionProvider, fetch("/api/diff") rewrites to
fetch("/s/:sessionId/api/diff"). Without a provider, returns
the global fetch unchanged.

* Migrate shared hooks to useSessionFetch

Add const fetch = useSessionFetch() to 10 hooks in packages/ui/hooks/.
The shadowed fetch variable routes /api/ calls through the session
context when a SessionProvider is present, and falls back to global
fetch when not.

configStore.ts uses apiFetch (import-based) since it's a class method.

* Migrate code review package to useSessionFetch

Add const fetch = useSessionFetch() to all 11 files in
packages/plannotator-code-review/ that call fetch("/api/...").
The shadowed fetch variable routes calls through the session
context. No fetch call sites were modified — only the function
that provides the fetch was changed.

* Export ReviewAppEmbedded without standalone providers

ReviewApp accepts __embedded prop to skip ThemeProvider,
TooltipProvider, and Toaster (shell provides these). Uses h-full
instead of h-screen when embedded. ReviewAppEmbedded is a named
export that passes the prop. Default export unchanged.

Shell Layout gains TooltipProvider for code review tooltips.

* Mount code review surface in frontend session route

When session.mode === "review", the /s/:sessionId route wraps
ReviewAppEmbedded in a SessionProvider and renders the full
code review UI. Other modes keep the placeholder.

- Added @plannotator/code-review as frontend dependency
- Created App.d.ts type declaration for the code review package
- Added plannotator-ui.d.ts for SessionProvider and ThemeProvider types
- Added PNG module declaration for asset imports
- Fixed settings.ts satisfies type for strict-mode compatibility
- Vite alias resolves to package source for bundling
- TypeScript uses .d.ts for type checking (avoids strict-checking loose package source)

* Fix session surface integration issues

- Add @custom-variant dark to code-review CSS for .light class toggle
- Remove forced theme cookies from main.tsx (use defaultColorTheme prop)
- Clean up document.title on review unmount (restore previous)
- Clean up CSS custom properties on review unmount
- Define __APP_VERSION__ from root package.json in vite config
- Narrow Vite proxy to /s/:id/api/ only (page loads stay with Vite SPA)
- Skip auto-registering temp directory projects in session factory
- Add max-height scroll to project table for overflow
- Add headerLeft prop to ReviewAppEmbedded for global sidebar trigger
- Fix header padding when sidebar trigger is present

* Resolve ~ to home directory in addProject

* Fix duplicate Tailwind build causing style conflicts

The code review's index.css had its own @import "tailwindcss" with
separate @source and @theme directives, producing a second Tailwind
build that competed with the frontend's styles.css. This caused
buttons, dialogs, and other components to render with wrong styles.

Fix: remove Tailwind, theme import, and @source from the code
review's index.css (keep only dockview + custom CSS). Add @source
directives to the frontend's styles.css to scan the code review
package and shared UI components. One Tailwind build, one theme,
no conflicts.

* Clean up CSS: remove duplications, fix keyframe collision

- Rename code review's @keyframes fade-in to cr-fade-in to avoid
  collision with the frontend's fade-in (different animation)
- Remove redundant panel scrollbar rules from styles.css (global
  scrollbar rules already cover all elements)
- Add comment noting intentional scrollbar override of theme.css
- Single Tailwind build, single theme import, zero duplications

* Make sidebar logo link to homepage

* Match sidebar trigger hover style to code review buttons

* Fix sidebar session labels and badge overlap

- Strip machine-generated prefixes from session labels (plugin-review-,
  claude-code-, etc.) to show just the project/PR name
- Add pr-7 padding to menu buttons so truncation ellipsis doesn't
  overlap with the status badge dot
- Full label still visible on hover via tooltip

* Fix formatting

* Fix test suite: restore globalThis.fetch after useSessionFetch tests

The useSessionFetch test replaced globalThis.fetch with a mock in
beforeEach but never restored it. Other test files running in the
same process (daemon runtime tests) got the mock instead of real
fetch, causing JSON parse failures on "ok" responses.

Added afterEach to restore the original fetch. All 1,463 tests pass.

* Add React Activity keep-alive for session surfaces

Sessions now stay alive when the user navigates away. Instead of
unmounting and remounting on each navigation, visited sessions are
hidden via React's <Activity mode="hidden"> and restored instantly
when the user returns.

- AppStore tracks visitedSessions (keyed by session ID) and activeSessionId
- Layout renders all visited sessions in <Activity> wrappers — only
  the active one is visible, the rest are hidden but preserved
- Session route registers its bootstrap data with the store and
  renders nothing — Layout owns the rendering
- Landing page deactivates the current session when navigated to
- SessionSurface component extracted to handle mode-based rendering

Effects clean up when hidden (WebSocket subs, timers stop) and
restart when visible. DOM, React state, scroll position, annotations,
dock layout all survive navigation.

* Fix: show error state when session load fails during active session

* Fix: deactivate session from Layout instead of inside hidden Activity

* Dispatch resize event when session becomes visible to fix Pierre diffs

* Replace Activity with visibility:hidden for session keep-alive

Activity uses display:none which breaks Pierre diffs' virtualizer —
it measures the container at zero height and renders no content.
When made visible again, the virtualizer doesn't recalculate.

Switch to visibility:hidden + position:absolute which preserves
element dimensions. Pierre diffs keeps its measurements, Dockview
keeps its layout. The tradeoff is effects don't pause for hidden
sessions, but that's preferable to broken diffs.

* Fix: derive landing page visibility from route match synchronously

* Set router pendingMs to 0 to eliminate navigation delay

* Auto-restore code review drafts silently, remove restore dialog

Drafts are keyed by diff content hash on the server. Same diff = same
draft. When a draft exists on mount, it's now restored automatically
with a subtle toast notification instead of a blocking dialog.

- useCodeAnnotationDraft takes an onRestore callback instead of
  returning draftBanner/restoreDraft/dismissDraft
- ConfirmDialog for draft restore removed from App.tsx
- Toast shows "Restored N annotations" on auto-restore

* Fix flash of unstyled sidebar on page load

* Style Toaster with theme tokens

* Add rounded top-left corner to embedded code review

* Move rounded corner to Layout session container and clip overflow

* Fix curved border: move border from sidebar to session container

* Only show curved border when sidebar is open

* Fix: use useSidebar hook for conditional curved border

* Fix project registry: key by cwd, defer registration until session succeeds

- registerProject now finds existing entries by cwd (not name), so two
  repos with the same name get separate entries
- removeProject takes cwd instead of name for unambiguous deletion
- Server DELETE /daemon/projects now accepts JSON body with cwd
- Session factory defers registerProject until after session creation
  succeeds, avoiding phantom entries from failed requests
- Hub-client: add missing scheduleReconnect after protocol error frames

* Embed plan review surface and fix cross-surface issues (#758)

* Migrate missed shared UI components to useSessionFetch

8 component/hook files in packages/ui/ still had bare fetch('/api/...')
calls that weren't caught during the code review migration:

- Settings.tsx, InlineMarkdown.tsx, AttachmentsButton.tsx, ExportModal.tsx
- settings/HooksTab.tsx, goal-setup/GoalSetupSurface.tsx
- plan-diff/PlanDiffViewer.tsx, hooks/useLinkedDoc.ts

Also set window.__PLANNOTATOR_API_BASE__ in SessionProvider so
non-hook consumers (apiPath for <img src> in ImageThumbnail) get
session-scoped paths.

* Migrate plan review App.tsx to useSessionFetch + title cleanup

* Auto-restore plan review drafts silently, remove restore dialog

Rewrite useAnnotationDraft with onRestore callback pattern (same as
useCodeAnnotationDraft). Legacy tuple format preserved. Toast on
restore. ConfirmDialog removed from plan review App.tsx.

* Export PlanAppEmbedded without standalone providers

Strip ThemeProvider, TooltipProvider, Toaster when __embedded.
h-full instead of h-screen. headerLeft prop passed through to
AppHeader for sidebar trigger. App.d.ts type declaration added.

* Strip Tailwind from plan review CSS, add @source to frontend

* Remove dead toast animation classes that conflicted with tailwindcss-animate

* Add visibility guards to keyboard handlers on both surfaces

When keep-alive hides a surface with visibility:hidden, its keyboard
listeners on window/document stay active. Without guards, Mod+Enter
on the visible code review would also fire the hidden plan review's
submit handler.

Both App.tsx files now check getComputedStyle(rootRef).visibility
at the start of every keyboard handler. If hidden, return early.

* Wire plan review surface into frontend app

All session modes now render production surfaces:
- review → ReviewAppEmbedded
- plan, annotate, archive, goal-setup → PlanAppEmbedded

Added @plannotator/plan-review dependency, Vite aliases, and styles.
SessionSurface simplified — review gets code review, everything else
gets plan review (which determines its mode from /api/plan response).

* Fix: pass session fetch to submitGoalSetup helper

* Replace full-screen completion overlay with inline banner in embedded mode

When running inside the frontend app (__embedded), the CompletionOverlay
blocked the entire viewport including the sidebar. Now:
- Embedded surfaces show a CompletionBanner (colored bar below the header)
- Action buttons hide after submission (plan review hides via AppHeader
  submitted prop, code review hides via !submitted guard)
- Standalone mode keeps the original full-screen overlay with auto-close
- No window.close() fires in embedded mode since useAutoClose lives
  inside CompletionOverlay which is skipped

* Serve production frontend from daemon, debug shell via env var only

The daemon now serves the production frontend HTML (apps/frontend/) at
session URLs. The debug frontend is only loaded when PLANNOTATOR_DEBUG_SHELL=1,
read from disk at runtime — never bundled in the compiled binary.

* Session lifecycle, worktree projects, and directory picker (#759)

* Add frontend visibility and focus reporting to daemon WebSocket

The daemon now tracks per-connection client state: tab visibility and
active session ID. The frontend reports these via a new `client-state`
WebSocket message type on connect, visibility change, and route
navigation. The event hub exposes `getFrontendState()` which returns
whether any frontend is connected, any tab is visible, and which
sessions are actively being viewed.

This is the foundation for smart session opening — the daemon will use
this state to decide between opening a browser and sending an in-app
notification.

* Move browser opening from CLI to daemon with smart presentation

The daemon now decides how to present new sessions based on frontend
connection state. If a frontend tab is connected and visible, it sends
a notification event (no new tab). If no frontend is connected or the
tab is backgrounded, it opens a browser.

- Add presentSession() to daemon runtime with decision matrix
- Add legacyTabMode config: always opens browser when enabled
- Remove handleServerReady/handleReviewServerReady/handleAnnotateServerReady
  calls from CLI hook — the daemon handles it
- Add browserAction field to POST /daemon/sessions response
- CLI sessions --open command kept as-is (explicit user action)

* Add session notification toasts and keep completed sessions in sidebar

Phase 3: When the daemon notifies instead of opening a browser, it
publishes a session-notify event. The frontend shows an auto-dismissing
toast (8s) with mode, project, and an Open button. Toasts are gated on
document.visibilityState — queued when tab is backgrounded, flushed on
return.

Phase 4: Completed sessions no longer disappear from the sidebar.
The terminal-status splice in event-store was removed — sessions now
update in-place with their new status. Only explicit session-removed
events cause removal.

* Collapse sidebar on direct session links, open on landing page

SidebarProvider defaultOpen is now based on the initial route: collapsed
when loading /s/:id directly, open when loading /. Users can still
toggle the sidebar manually after the initial render.

* Add disk-backed session snapshots for completed session persistence

When a session completes, the daemon writes a content snapshot to
~/.plannotator/sessions/<id>.json before disposing the handler.
Snapshots capture the plan markdown, diff data, or annotation content —
everything the frontend needs to render the session read-only.

The daemon server serves snapshot content when a request hits a disposed
or missing session. This means completed sessions survive page refresh
and daemon restart. Snapshots are capped at 5MB to avoid oversized
review diffs.

Each session type provides a snapshot callback in the factory that
closes over its content at creation time.

* Wire legacy tab mode through server config to surface overlays

When legacyTabMode is set in config.json, the daemon always opens a
browser (already wired in Phase 2), and both surfaces render the
full-screen CompletionOverlay with auto-close instead of the inline
CompletionBanner — even in embedded mode. This preserves the old
tab-per-session + auto-close experience for users who prefer it.

The legacyTabMode flag flows through getServerConfig() → /api/plan
and /api/diff responses → surface state.

* Document legacyTabMode config setting in AGENTS.md

* Load session snapshots from disk on daemon startup

Completed sessions from previous daemon runs now appear in the sidebar
immediately. On startup, the daemon reads all snapshots from
~/.plannotator/sessions/ and creates completed records in the store.
These records have no handlers but serve content via the snapshot
fallback in the server.

* Add worktree-aware project hierarchy to landing page

Projects that are git worktrees auto-detect their parent repo and nest
underneath it. The landing page shows projects as collapsible tree nodes
— expanding a project fetches its worktrees via git worktree list and
shows them with branch names.

- DaemonProjectEntry gains optional parentCwd and branch fields
- addProject detects worktrees via git rev-parse --git-common-dir and
  auto-registers the parent repo
- New GET /daemon/projects/worktrees?cwd= endpoint lists worktrees
- Frontend ProjectTable refactored to collapsible tree with worktree
  children, selection passes cwd to session creation
- Session labels include branch name when created from a worktree cwd

* Fix parent project registration dedup and add branch to all session labels

- Parent auto-registration now adds directly to the flat array instead
  of calling registerProject, avoiding name-based dedup that could
  overwrite unrelated projects with the same derived name
- All session modes (annotate, archive, goal-setup) now include the
  branch name in their labels, matching plan and review

* Fix blank page when adding a worktree project

When adding a directory that is a worktree, the daemon auto-creates
the parent project. But the store only added the returned entry (the
worktree child), leaving the parent missing from the frontend state.
Since the worktree has parentCwd set, the topLevel filter found zero
entries and nothing rendered.

Fix: when the added entry has parentCwd, re-fetch the full project
list so the auto-created parent is included.

* Filter temp directory worktrees from project listing

* Sort worktrees by last activity (index mtime > commit time > dir mtime)

Each worktree gets a lastActive timestamp derived from:
1. Git index file mtime (updates on add, checkout, stash — reflects
   active work even without commits)
2. Last commit timestamp (fallback if index unavailable)
3. Directory mtime (fallback for brand new worktrees)

All three signals are cross-platform (fs.statSync + git log).
Worktrees are sorted most-recently-active first.

* Fix toast: skip for frontend-initiated sessions, clean label, fix colors

- Don't call presentSession for origin "plannotator-frontend" — the
  frontend already navigates to the session it just created
- Strip internal prefixes from session label in toast description,
  suppress description when it matches the project name
- Style toast action button with theme primary colors
- Widen project selector to max-w-2xl
- Remove opacity-50 from worktree icons

* Replace manual project input with searchable directory picker

The Add Project dialog is now a searchable directory browser inspired
by OpenCode's project picker:

- Type a path (~/work/, /Users/...) and see child directories listed
- Arrow keys to navigate, Enter to select, Tab to navigate into a dir
- Recent projects shown at top for quick re-selection
- ~ expansion handled server-side
- Hidden directories (.git, .cache, etc.) filtered out
- 150ms debounced directory listing for responsive typeahead

New daemon endpoint: GET /daemon/fs/list?path= returns child
directories for any path with ~ expansion.

* Only show worktree chevron when worktrees exist, add Worktrees label

- Fetch worktrees eagerly on mount instead of on expand, so the chevron
  only appears when there are actual worktrees to show
- Projects without worktrees get a plain spacer instead of the chevron
- Add a "Worktrees" section label above the expanded list

* Fix: add missing useEffect import in LandingPage

* Fix project row layout: chevron to right, remove branch icons, align folders

- The whole project row is now one selectable button with folder icon
  consistently at the left
- Worktree expand chevron moved to the right end, only visible on hover
  area — doesn't block the selectable feel
- Removed all GitBranch icons from worktree entries — just indentation
  and the branch/worktree name
- Projects without worktrees have no chevron at all, no spacer needed

* Add ASCII art Plannotator banner to landing page

* Increase ASCII banner opacity to 70%

* Remove redundant Plannotator label from landing page nav

* Make Add Project buttons more visible

* Design audit: fix color contrast, remove opacity abuse, fix a11y

Applied Emil's design engineering principles:

- Interactive rows use text-foreground by default, not text-muted-foreground.
  Muted text is only for metadata (paths, timestamps, section labels).
  Items should look clickable at rest, not disabled.
- Replaced all opacity-60 on secondary text with text-muted-foreground
  (semantic token instead of raw opacity)
- Borders use border-border (full opacity) not border-border/40 — borders
  should be visible enough to serve their structural purpose
- Removed transition-colors (was a transition: all risk) — hover states
  are instant by design for frequently-used UI
- Changed expand <span role="button"> to proper <button> with aria-label
- Added select-none to ASCII banner (decorative element)
- Used proper ellipsis character (…) instead of ...
- Selected state uses bg-primary/10 instead of bg-primary/8 for clearer
  feedback

* Design audit: fix directory picker dialog contrast and accessibility

Applied Emil's design engineering principles to AddProjectDialog:

- Interactive rows use text-foreground by default, not text-muted-foreground
- Removed all /50 and /60 opacity modifiers on borders and text —
  borders use border-border, metadata uses text-muted-foreground
- Input uses text-base (16px) to prevent iOS zoom on focus, with
  sm:text-[13px] for desktop
- Fixed nested <button> inside <button> (invalid HTML) — directory
  rows now use a <div> wrapper with two sibling buttons
- Navigate-into chevron is always visible (was opacity-0 hover-only,
  violating "never rely on hover for core functionality")
- Added aria-labels to close button and navigate buttons
- Removed transition-colors from interactive rows (speed over delight)
- Used proper ellipsis character (…) in placeholder and loading text
- Removed unused displayName prop from DirectoryRow

* Add goal files for session lifecycle and worktree projects

* Unified settings, performance optimizations, and Zustand review store (#766)

* Add shadcn Dialog and Tabs primitives to frontend

Dialog wraps @radix-ui/react-dialog with DiffKit prototype styling:
bg-black/55 backdrop, rounded-2xl card, entrance animation with
reduced-motion support. Sized at max-w-4xl for the settings dialog.

Tabs wraps @radix-ui/react-tabs with vertical orientation support.
Tab triggers use text-foreground by default (per design audit).

Also backlogged AddProjectDialog migration to use these primitives.

* Add unified settings dialog with vertical tabs layout

The frontend app now has a single app-level settings dialog accessible
via Cmd+, (Ctrl+, on Windows/Linux) or the sidebar Settings button.
It uses a wide Radix Dialog (896px) with vertical tabs grouped into
four sections: General, Plan Review, Code Review, and Integrations.

Tab content components are imported from the shared UI package:
- ThemeTab, KeyboardShortcuts, HooksTab (already separate files)
- GitTab, ReviewDisplayTab, CommentsTab (newly exported from Settings.tsx)
- SegmentedControl, ToggleSwitch (extracted to settings/shared.tsx)

The existing per-surface Settings modals remain for standalone mode.

* Wire per-surface gear icon to unified settings dialog when embedded

Both PlanAppEmbedded and ReviewAppEmbedded now accept an onOpenSettings
callback. When provided (embedded mode), the gear icon opens the
app-level unified settings dialog instead of the per-surface modal.
Standalone mode is unchanged — no callback means the built-in modal
opens as before.

* Complete settings dialog: all 4 sections, 13 tabs, AI tab

Dialog now has all four sections from the plan:
- General: General (placeholder), Theme, Shortcuts
- Plan Review: Display (placeholder), Saving (placeholder), Labels
  (placeholder), Hooks
- Code Review: Git, Display, Comments, AI
- Integrations: Files (placeholder), Obsidian (placeholder)

7 tabs have full content (Theme, Shortcuts, Hooks, Git, Review Display,
Comments, AI). 6 tabs show placeholder text indicating what settings
will go there — these need the inline content extracted from the
monolithic Settings.tsx in a follow-up.

Reduced motion is already handled globally via theme.css and
tailwindcss-animate.

* Extract settings tabs: General, PlanGeneral, PlanDisplay, Saving

Extracted four tab components from the Settings.tsx monolith into
self-contained files in packages/ui/components/settings/:

- GeneralTab: identity + auto-close (shared across all modes)
- PlanGeneralTab: permission mode (Claude Code) + agent switching
  (OpenCode) — plan-specific, moved out of General
- PlanDisplayTab: TOC, sticky actions, plan width with preview
- SavingTab: plan save toggle/path, default notes app, integration
  quick-nav buttons with onNavigateTab callback

Dialog now has 16 tabs across 4 sections. 11 have full content,
5 remain as placeholders (Labels, Files, Obsidian, Bear, Octarine).

Tab grouping updated per design review:
- Permission Mode and Agent Switching moved from General to Plan Review
- Bear and Octarine added to Integrations section

* Complete all 16 settings tabs — zero placeholders remaining

Extracted remaining tab components from the Settings.tsx monolith:
- LabelsTab: quick annotation labels with emoji, color, AI tips, shortcuts
- FilesTab: file browser enable + directory list management
- ObsidianTab: vault detection, path/folder/filename config, auto-save,
  vault browser toggle
- BearTab: custom tags, tag position, auto-save
- OctarineTab: workspace, folder, auto-save

All 16 tabs across 4 sections now render full settings content:
- General (3): General, Theme, Shortcuts
- Plan Review (5): General, Display, Saving, Labels, Hooks
- Code Review (4): Git, Display, Comments, AI
- Integrations (4): Files, Obsidian, Bear, Octarine

Each extracted tab is self-contained — manages its own state reads/writes
via the existing utility functions and configStore.

* Fix critical settings dialog issues found in eight-agent audit

Critical fixes:
- AISettingsTab now receives required props (providers, selectedProviderId,
  onProviderChange). Fetches /api/ai/capabilities when dialog opens.
- PlanGeneralTab now receives origin from active session so Permission
  Mode and Agent Switching tabs actually render.

Functional fixes:
- Stale state on re-open: mountKey increments when dialog opens, forcing
  Radix Tabs to re-mount tab content so useState initializers re-read
  fresh cookie values.
- Cmd+, now toggles (close if open, open if closed) instead of only
  opening.

Remaining items deferred: Tater Mode toggle in PlanDisplayTab, ThemeTab
preview mode, diffTabSize in ReviewDisplayTab, content gaps in Obsidian/
Octarine/Saving helper text.

* Self-review fixes: stale AI provider, type safety, ObsidianTab fetch

- Re-read aiProviderId from cookies on each dialog open (was stale
  if changed via per-surface settings between opens)
- Remove `as any` cast on origin prop — PlanGeneralTab now accepts
  Origin | string | null
- ObsidianTab no longer depends on useSessionFetch (breaks outside
  SessionProvider). Uses fetchFn prop with globalThis.fetch default.
  Vault detection gracefully falls back to manual input if fetch fails.

* Fix critical issues from second eight-agent audit

1. AI capabilities fetch: route through active session API path
   (/s/:id/api/ai/capabilities) instead of broken root /api/ path.
   Skips fetch when no session is active — AI tab shows empty state.

2. Z-index: dialog overlay and content bumped from z-50 to z-[110],
   above CompletionOverlay's z-[100]. Settings dialog now renders
   on top of everything except toasts.

3. Keyboard shortcuts: unified dialog now shows BOTH plan and code
   review shortcuts with section labels, not just plan mode.

* Full parity: every setting from original Settings.tsx now in extractions

Parity fixes:
- Tater Mode toggle added to PlanDisplayTab (with optional props)
- diffTabSize stepper added to ReviewDisplayTab (was only in popover)
- SavingTab: description text under Default Save Action select restored
- SavingTab: auto-reset effect when integration becomes unavailable
- ObsidianTab: filename format variables hint line restored
- ObsidianTab: filename separator helper text restored
- ObsidianTab: frontmatter preview block restored
- OctarineTab: workspace and folder helper text restored
- LabelsTab: tip editor onFocus cursor handler restored
- PlanGeneralTab: agent warning SVG icon restored
- PlanGeneralTab: stale agent "not found" disabled option restored

Resource cleanup:
- Hidden <Settings> no longer mounts in embedded mode — both plan
  review (skipBuiltInSettings prop) and code review (guard on
  externalOpenSettings) skip rendering when the unified dialog handles it

* Add daemon global settings API — settings work without active sessions

Five new daemon endpoints for settings that previously required a
session-scoped API path:

- GET/POST /daemon/config — read/write ~/.plannotator/config.json
- GET /daemon/git/user — detect git config user.name
- GET /daemon/vaults — detect Obsidian vaults
- GET /daemon/hooks/status — hook file status + PFM reminder

All are thin wrappers around existing functions in packages/shared/
with no session context needed.

Frontend routing: added globalFetchBase fallback to apiFetch. When
__PLANNOTATOR_API_BASE__ is unset (landing page, no session), config
writes route through /daemon/config. Session base still takes
precedence when set.

Settings dialog: fetches gitUser from /daemon/git/user on open,
initializes configStore from /daemon/config, passes daemon-routed
fetch to ObsidianTab and HooksTab so vault detection and hook status
work from the landing page.

* Fix ObsidianTab vault URL: accept both /daemon/vaults and /daemon/obsidian/vaults

* Fix theme preview mode and slow theme switching with keep-alive

Theme preview: wired onPreview to ThemeTab in unified dialog. Clicking
"Launch Preview Mode" hides the dialog and opens a bottom drawer with
a compact ThemeTab. Escape or Done button returns to the dialog.

Theme switching performance: hidden keep-alive surfaces now skip color
transitions entirely via [style*="visibility: hidden"] * { transition-
duration: 0s }. Only visible content animates, eliminating the lag from
thousands of elements transitioning simultaneously.

* Add performance scope documents: global keyboard registry + configStore Zustand migration

* Move performance scope docs to backlog directory

* Document performance findings: 15 identified issues ranked by impact

* Update performance findings: 22 issues across 4 tiers, prioritized for normal use

* Add 3 new findings from agent sweep: layout thrashing, context invalidation, getComputedStyle

* Add memory leak findings: draft maps, uncancelled rAF, WS subscription accumulation

* Final sweep: 39 total findings incl bundle size, static imports, backdrop-blur, monolith components

* Performance Phase 1: stop hidden sessions from degrading active session

Fix 1 — React.memo on SessionSurface: Layout re-renders (sidebar
toggle, dialog open, session switch) no longer cascade into every
mounted session's component tree. The bootstrap prop is reference-
stable so memo always bails out for hidden sessions.

Fix 3 — Scope document.querySelector to session container:
- StickyHeaderLane: new containerRef prop, queries within the session's
  root instead of the global document. Prevents hidden sessions from
  attaching ResizeObservers to the active session's elements.
- TableOfContents: scopes block-id query to scrollViewport instead of
  document. Prevents hidden session TOC clicks from scrolling the
  active session.
- PlanCleanDiffView: new containerRef prop for diff-block-index query.
  Prevents hidden session annotation selections from highlighting
  blocks in the active session.

Fix 4 — Gate document-level mutations on visibility:
- CSS custom properties (--diff-font-override etc.) now set on
  rootRef.current instead of document.documentElement. Each session
  scopes its own font/tab-size vars. No more global style recalc
  from hidden sessions writing to :root.
- document.title gated with isVisible() in both surfaces. Hidden
  sessions no longer overwrite the active session's title.

* Self-review fixes: PlanCleanDiffView self-scoping, font-size CSS selectors

- PlanCleanDiffView now has its own rootRef on its wrapper div, so the
  querySelector for diff-block-index elements scopes to its own tree
  without needing containerRef threaded from the parent
- CSS font-size-override selectors changed from :root[style*="..."]
  to .has-font-size-override class — the :root selectors broke when
  CSS vars moved from document.documentElement to rootRef.current
- Cleanup removes the class on effect teardown

* Fix tab size not applying to diffs and title not updating on session switch

Tab size: moved from CSS variable on rootRef (which didn't penetrate
Pierre's shadow DOM) to direct unsafeCSS injection via usePierreTheme,
matching how font-family and font-size already work.

Title: replaced static isVisible callback (empty deps, never re-triggered)
with useSessionVisible hook that uses MutationObserver to reactively
detect when Layout.tsx toggles the parent's visibility style.

* Use content-visibility: hidden on inactive sessions to skip layout/paint

The browser was doing full layout and style recalc on every hidden
session's DOM (60-120k nodes with 3+ sessions). content-visibility: hidden
tells the browser to skip all rendering work on inactive subtrees while
preserving DOM state, scroll positions, and React component state.

Also runs oxfmt on frontend files to fix pre-existing formatting drift.

* Scroll to annotation line when clicking sidebar comment in all-files view

Previously only scrolled to the file header. Now finds the
[data-annotation-id] element and scrolls to it directly, with a
retry loop for lazy-mounted diffs and file header fallback.

* Add Zustand review store with per-session scoping

Introduces a Zustand store for the code review surface, replacing
annotation useState calls with store state. Panels can now subscribe
to specific slices via selectors instead of re-rendering on every
unrelated state change through the ReviewStateContext god object.

Phase 1: annotations slice (hot path), diff-options slice, files slice.
DiffHunkPreview migrated as first panel consumer. ReviewStateContext
stays alive for unmigrated panels during the transition.

* Fix deleted annotations reappearing after refresh

When all annotations were deleted, the draft auto-save skipped the
save entirely, leaving the stale draft on the server. On refresh it
restored the deleted annotations. Now tracks whether a draft exists
on the server and issues a DELETE when annotations drop to zero.

* Stabilize callbacks via getState() and memo FileTreeNodeItem

Migrate files and activeFileIndex to store-owned state (remove sync
bridge). Stabilize 7 callbacks + keyboard handler by reading
pendingSelection, files, activeFileIndex, externalAnnotations,
selectedAnnotationId, and allAnnotations from storeApi.getState()
instead of closing over them. Callbacks now have stable references
that don't recreate on every hover or file switch.

Wrap FileTreeNodeItem in React.memo — 531 instances were re-rendering
29 times each (15,399 total, 1,202ms) due to parent cascades.

Baseline: 55% of render time (4,825ms) was full-tree cascades
triggered by ReviewApp. These changes eliminate the callback
instability that caused most of those cascades.

* Add React.memo to shared UI components and stabilize plan review props

Wrap BlockRenderer, InlineMarkdown, CodeFileLink, and TableBlock in
React.memo. These render hundreds of times per plan/review session
due to parent cascades — profiler showed 778 BlockRenderer renders
and 888 InlineMarkdown renders in a single session. Memo prevents
re-renders when the block content and callbacks haven't changed.

Stabilize plan review App.tsx props to Viewer: replace inline arrow
functions (onPlanDiffToggle) with useCallback, replace inline
linkedDocInfo object with useMemo. These created new references on
every render, defeating any memo on child components.

* Fix backLabel reference-before-initialization in plan review

The linkedDocInfo useMemo was placed above the backLabel variable
it depends on, causing a crash on plan session load. Moved the
useMemo below backLabel's declaration.

* Unified frontend: Git Dashboard, PR listing, sidebar redesign, and settings cleanup (#765)

* Add PR listing, multi-select launch, stacked PR grouping, and project management to frontend landing page

- Fix worktree project registration: session factory now uses addProject() instead of registerProject() so worktrees nest under parent projects
- Fix directory typeahead: handle partial paths by splitting into parent dir + prefix filter
- Add daemon endpoint GET /daemon/projects/prs for listing PRs via GitHub/GitLab CLI
- Add PR list with stacked PR detection using headBranch/baseBranch chain matching
- Add multi-select for PRs and worktrees with parallel session launch via Promise.allSettled
- Restructure worktrees from badge pills to row layout matching PR list style
- Add tabbed expansion panel (PRs default, Worktrees) under each project
- Add shared formatSessionLabel() for clean display names across sidebar, landing page, and toasts
- Add right-click context menu to remove projects with session cancellation and history cleanup
- Add PullRequestIcon with state-based coloring (open/merged/closed)

* Clean up settings dialog: proper header, version info, compact display tab layout

- Replace absolute-positioned close button with dedicated header bar to prevent toggle overlap
- Remove settings cog icon, add version number and send feedback link
- Rewrite Code Review Display tab with grouped sections (Typography, Layout, Options)
- Replace font size slider with +/- stepper matching tab size pattern
- Make segmented controls inline with labels for compact layout
- Add live preview showing both font family and size together
- Fix Theme tab spacing: add divider and gap between Mode and Theme sections
- Move Line Backgrounds toggle to end of Options (sub-setting at bottom)

* Git dashboard, settings cleanup, sidebar redesign, and PR data pipeline

- Add PRDetailedListItem type and fetchPRDetailedList for dashboard-specific PR data
- Add daemon endpoint GET /daemon/projects/prs/detailed with 30s cache
- Add git-dashboard-store (Zustand) with parallel multi-project fetch and dedup
- Build Git Dashboard with PRRow, PRGroup, MetricCards components
- Dashboard groups PRs into Open/Draft/Recently Merged with scroll-to navigation
- Clicking a PR row launches a review session via createReviewSession
- Add CSS translateX slide transition between landing page and dashboard
- Clean up settings dialog: proper header bar, version info, send feedback link
- Rewrite Code Review Display tab with grouped sections and compact inline controls
- Fix Theme tab spacing between Mode and Theme sections
- Redesign sidebar: sprite mascot header, Instrument Sans brand font, version display
- Remove session status dots/checkmarks, move mode icons to group headers
- Compact sidebar session rows (text-xs, h-7) with alignment spacers
- Embed sidebar trigger in landing page card instead of dedicated nav bar
- Add right-click context menu for project removal with session/history cleanup
- Fix directory typeahead prefix filtering for partial path input
- Fix worktree project registration via addProject() instead of registerProject()
- Add multi-select session launch with parallel Promise.allSettled
- Add stacked PR grouping via headBranch/baseBranch chain detection
- Add shared formatSessionLabel() for clean display across sidebar/landing/toasts
- Deduplicate PRs across projects sharing the same remote

* Add hover-triggered peek sidebar when sidebar is closed

- Extract AppSidebarContent for reuse between real sidebar and peek
- SidebarPeek renders a floating panel on left-edge hover (80vh, centered)
- Backdrop overlay fades in at bg-black/30 behind the panel
- Panel slides in/out at 150ms, backdrop at 200ms
- Uses bg-sidebar token for consistent background with real sidebar
- Remove session tooltip hovers
- Only active when sidebar is collapsed

* Remove unused asset files (banners, mascot, sprite backups, index.html)

* Address PR review findings: security fix, dedup, lazy loading, error surfacing

- Fix path traversal: sanitize project name before use in rmSync path
- Switch project deletion from name-based to cwd-based identification
- Deduplicate archive launches by cwd when multiple selections share a project
- Gate dashboard PR fetch on visibility (only fetch when dashboard slide is active)
- Gate worktree fetch on expanded state (don't shell out until user expands)
- Track project count in dashboard store for cache invalidation on add/remove
- Surface auth and CLI errors in dashboard instead of showing misleading empty state
- Consolidate duplicate PR ref resolution into single handler for both endpoints
- Delete unused carousel.tsx and remove motion dependency
- Move @radix-ui/react-context-menu from devDependencies to dependencies

* Tighten path sanitization: reject dot-only names in history cleanup

* Fix review findings: timer leak, stale tabs, blank dashboard, clear on remove, path guard

- Fix SidebarPeek timer leak: clear existing timeout before setting new one in hide()
- PR tabs now refetch after 30s instead of being permanently cached
- Dashboard isEmpty derived from visible groups, not raw array (fixes blank state for closed-only repos)
- Dashboard clears stale PRs when all projects are removed
- Defense-in-depth: verify resolved rmSync path is inside history root before deleting
- Fix handleRemove missing project.cwd in useCallback dependency array

* Move tater mode to configStore and fix formatting from L10 merge

Tater mode was useState inside plan review App.tsx — unreachable from
the unified settings dialog. Now lives in the global configStore as a
cookie-backed setting. PlanDisplayTab reads it directly via
useConfigValue, no props needed from AppSettingsDialog.

Also runs oxfmt on files from the L10 merge (git dashboard, sidebar,
settings dialog).

* Address PR review findings: stale closures, dead code, missing deps

- Fix stale closure in applyPRResponse: read currentPath before setFiles
- Deduplicate allAnnotations: App.tsx useMemo now calls selectAllAnnotations
- Remove dead fields from files slice (viewedFiles, stagedFiles, reviewBase,
  activeDiffBase and their setters — never called, data still flows through
  ReviewStateContext)
- Wrap default ReviewApp export in ReviewStoreProvider
- Add missing daemon endpoints to AGENTS.md
- Add dependency array to SavingTab useEffect (was firing every render)
- Add activeSessionId to AppSettingsDialog AI capabilities fetch deps

* Fix fetchDiffSwitch stale closure + unstable deps, correct AGENTS.md allowlist

- Read currentPath from getState() before setFiles in preserveFile
  branch (same pattern as applyPRResponse fix)
- Remove files and activeFileIndex from fetchDiffSwitch dep array —
  last unstable callback, now reads from getState() consistently
- Fix AGENTS.md: POST /daemon/config allows conventionalComments and
  conventionalLabels, not legacyTabMode and verifyAttestation

* Fix L10 bugs: stale selections, PR error clearing, dashboard cache

- Clean up selections when projects are removed — prevents stale
  selections from enabling launch buttons for deleted projects
- Clear PR error on successful fetch — errors no longer stick after
  the user fixes their CLI setup
- Dashboard cache invalidates on project identity (cwd set), not just
  count — swapping projects within the same count now triggers refresh

* Add legacy tab mode toggle to settings dialog

Adds "Open sessions in new tabs" toggle to General settings. When
enabled, every session opens in a separate browser tab with the
full-screen completion overlay and auto-close, like the classic
Plannotator experience.

The daemon already supports this via legacyTabMode in config.json —
this just adds the UI toggle and wires it through POST /daemon/config.

* Add remaining backlog items: GitLab detection, stack splitting, configStore migration, keyboard registry

* Session persistence: denied sessions stay alive for resubmission (#770)

* Add session persistence foundation: suspend, reactivate, awaiting-resubmission

Protocol: add "awaiting-resubmission" status (non-terminal), add
"session-revision" event family, bump protocol version to 2.

Session store: add suspend() (resolves waiters WITHOUT disposing
resources — session stays alive), reactivate() (transitions back
to active), and matchKey field on records.

Server: skip the 2-second deletion timer on result endpoint for
awaiting-resubmission sessions — they need to stay alive for the
agent to resubmit.

* Add cycle-based decision model and updateContent to plan server

Replace one-shot resolveDecision with a cycle system — each deny
resolves the current cycle and starts a new one. Approve resolves
the final cycle. Agent-originated sessions return awaitingResubmission
in the deny response.

Add updateContent(newPlan) method that updates plan content, saves
to version history, resets draft state, and publishes a
session-revision event via the WebSocket hub.

Add slug and getSnapshot to PlannotatorSession interface so the
factory can match resubmissions and serve correct snapshots.

Add session-revision to SessionEventFamily type.

* Add session matching, persistent decision loop, and CLI support

Session factory: add sessionRefs registry and findAwaitingSession()
matching by matchKey. Plan sessions compute matchKey as
plan:project:slug. On resubmission, existing awaiting session is
matched and reactivated instead of creating a new one.

Add registerPersistentDecision() — loops on waitForDecision(),
suspending on deny and completing on approve. Replaces one-shot
registerSessionDecision for plan sessions.

Fix unhandled promise rejection: add .catch() to disposed promise
in both registerSessionDecision and registerPersistentDecision.

CLI: accept "awaiting-resubmission" as valid non-error status so
denied sessions output feedback and exit 0 instead of failing.

* Add awaiting-resubmission UI state to plan review

Extend CompletionBanner with 'awaiting' variant — amber spinner with
"Feedback sent — waiting for agent to revise..." message and optional
cancel button.

Plan review deny handler now checks response for awaitingResubmission
flag. When true, shows the awaiting banner instead of the completion
overlay.

Subscribe to session-revision events via daemon WebSocket. When the
agent resubmits and the session reactivates, the event carries the
new plan content. The UI updates: markdown refreshes, previousPlan
updates for diff, all annotations clear, awaiting state resets.

* Document session persistence: AGENTS.md and backlog updates

Add session persistence and resubmission section to AGENTS.md
covering the awaiting-resubmission status, matchKey matching, and
session-revision event family.

Update backlog: mark #3 (live plan updates) and #4 (session
persistence after completion) as done.

* Self-review fixes: operator precedence bug and sessionRefs cleanup

Fix operator precedence in registerPersistentDecision catch handler —
was evaluating as (A && B) || C instead of A && (B || C).

Extend findAwaitingSession to prune completed/expired/failed/cancelled
entries from sessionRefs map, preventing slow memory growth over
daemon lifetime.

* Wire all three session types for persistence

Extract createDecisionScope helper to eliminate duplication between
registerSessionDecision and registerPersistentDecision. Define
SessionDecisionResult type for explicit contracts.

Annotate server: cycle-based decisions, updateContent for file-based
modes, awaitingResubmission in /api/feedback response.

Review server: cycle-based decisions, updateContent(rawPatch, gitRef),
awaitingResubmission for non-approved feedback.

Factory: generalize sessionRefs to PersistableSession interface.
Add matchKey computation and matching for annotate (annotate:filepath)
and review (review:project:branch or review:prUrl). Wire both with
registerPersistentDecision. URL and annotate-last modes keep one-shot
behavior (no persistent source to refresh).

* Add awaiting-resubmission frontend state for annotate and code review

Annotate: handleAnnotateFeedback now checks response for
awaitingResubmission flag, same pattern as plan deny handler.
Session-revision subscription already handles both plan and annotate
since they share App.tsx.

Code review: handleSendFeedback checks awaitingResubmission response.
Add session-revision event subscription that refreshes diff data,
clears annotations, and resets awaiting state. CompletionBanner shows
awaiting variant for all three surfaces.

* Quality fixes: extract updateContent, document findAwaitingSession

Move plan server's inline updateContent closure to a named function
handleUpdateContent for readability. Document findAwaitingSession's
side effect of pruning terminal sessions during search.

* Extract shared decision cycle helper, consistent updateContent naming

Add createDecisionCycle<T>() and resolveAndCycle() to session-handler.ts.
All three servers (plan, annotate, review) now use the shared helper
instead of copy-pasting the cycle setup, resolve, and startNewCycle
pattern. Deny handlers collapse to a single resolveAndCycle() call.

Extract updateContent to named handleUpdateContent functions in all
three servers for consistency — inline closures in return blocks
replaced with named functions defined above the return.

* Fix 5 review findings: snapshot provider, origin check, exit handling, empty content, TTL

1. Register no-op snapshot provider for session-revision in all three
   servers — prevents WebSocket disconnect when frontend subscribes.

2. Exclude plannotator-frontend from agent origins in resolveAndCycle
   — dashboard-created sessions complete on deny instead of hanging.

3. Complete session on exit: true in registerPersistentDecision —
   Exit button now properly terminates instead of suspending.

4. Check revision.plan !== undefined instead of truthiness — empty
   diffs and empty annotate content no longer ignored.

5. Store ttlMs on session record and restore it on reactivate() —
   reactivated sessions expire normally if abandoned.

* Add session persistence design docs and decisions

Captures the overview of what session persistence does, the technical
architecture, and active design decisions from PR #770 triage — notably
removing persistence from code review and redesigning the awaiting state.

* Remove redundant HTML overview from tracking

* Add version history and diff support to annotate sessions

Reuses the plan review's version infrastructure for file-based annotate
sessions. Revisions now save to history, the session-revision event
carries previousPlan and versionInfo, and the frontend's diff badge,
diff view, and version browser activate automatically. Also updates
decisions.md with product facts and revised design decisions.

* Long-lived code review sessions with idle status

Replace the awaiting-resubmission persistence model for code review
with a new "idle" session status. After sending feedback, the session
stays alive and interactive — the user can annotate and send again.
Agent reviews from the same directory attach to the idle session
instead of creating a new one. The frontend shows a calm "Feedback
sent" banner instead of a spinner.

* Hide submit buttons while idle, no auto-dismiss

After feedback is sent and the session is idle (no agent listening),
the Send Feedback and Approve buttons disappear. They reappear when
the agent reactivates the session via session-revision. Keyboard
shortcuts are also blocked while idle.

* Update decisions.md with implemented code review lifecycle

Document the actual idle flow, resolve open questions, and mark
Decision 1 and 3 as implemented.

* Fix 4 review findings: idle TTL, late waiter, self-refresh, temp cleanup

1. Idle sessions with no ttlMs now get a fallback TTL instead of
   living forever (uses AWAITING_RESUBMISSION_TTL_MS as fallback)
2. waitForResult returns immediately for idle sessions with results
   instead of hanging forever on late agent checks
3. Review handleUpdateContent re-runs the diff with current user
   settings instead of accepting a pre-computed patch, and clears
   currentError. Preserves user's diff type and base branch choice.
4. Temp worktree cleanup on the review session reuse path

* Fix PR mode review reuse: pass fetched patch instead of local diff

For PR reviews, the diff comes from the GitHub/GitLab API, not from
local git. handleUpdateContent now accepts an optional pre-computed
patch for PR mode, falling back to self-refresh for local reviews.

* Fix plan diff base tracking and linked-doc annotation leak

1. usePlanDiff now updates the diff base on every revision, not just
   the first. Previously the diff always compared against v1 after
   multiple deny/resubmit cycles.
2. Clear linked-doc annotation cache on session revision so stale
   annotations from linked files don't persist into the next plan
   version.

* Exclude folder annotate from persistence, fix review loop survival

1. Folder-mode annotate sessions no longer participate in session
   matching or resubmission — they have no single document to track.
   Prevents empty content being pushed on resubmission.
2. Review decision loop uses promise identity tracking instead of
   idle() return value to detect cycle exhaustion. Survives double-
   submissions while still exiting cleanly for non-agent origins.

* Scope annotate matchKey by project to prevent cross-project collisions

The "document" filePath fallback created a global collision bucket
for fileless annotate sessions. Now scoped as annotate:project:path.

* Update decisions.md with cross-cutting facts and current open items

Add cross-cutting requirements from review triage: external annotation
flushing, waitForResult consistency, plan action disabling, snapshot
provider, and architectural gaps (HTML pipeline, PR metadata). Replace
outdated bug table with current open items reflecting what's fixed,
what's deferred, and what's accepted.

* Fix 5 cross-cutting issues: external annotations, waitForResult, plan actions, snapshots, docs

1. Clear external annotations on revision for all three servers
   (plan, annotate, review) — stale lint/agent comments no longer
   persist with wrong line numbers after content refresh.
2. waitForResult short-circuits for awaiting-resubmission sessions
   with results, matching the existing idle behavior.
3. Plan/annotate actions disabled during awaitingResubmission —
   keyboard shortcuts and header buttons gated.
4. session-revision snapshot providers return current content instead
   of null — late WebSocket subscribers get the latest state.
5. Comment on registerReviewDecision explaining intentional idle
   lifecycle (reviews stay alive, cleanup via TTL).

* Collapse duplicate decision loops into shared registerDecisionLoop

The persistent and review decision handlers shared ~80% of their structure.
Extract a parameterized registerDecisionLoop that takes an onResult callback
and activeStatuses set. Also drop an unnecessary cast and extract a named
boolean in waitForResult for readability.

* Fix 3 review findings: protocol test, legacy overlay, smart viewed-files retention

1. Update daemon-protocol test to expect session-revision event family
2. Wire feedbackSent to CompletionOverlay so legacy tab mode shows the
   full-screen close experience after sending review feedback
3. Add retainUnchangedViewedFiles() — on diff revision, only un-hide files
   whose patch actually changed; unchanged files stay hidden
4. Document viewed-files and legacy-mode facts in decisions.md

* Fix CompletionOverlay feedback-sent visual to match CompletionBanner

Both components now show success colors and checkmark icon for
feedback-sent state. Previously the overlay used accent/chat-bubble
while the banner used success/check — inconsistent for the same action.

* Code quality: exit cycle, util location, docs, decision cycle comment

1. Review /api/exit uses direct resolve instead of resolveAndCycle —
   no dangling cycle left for the decision loop to block on
2. Move retainUnchangedViewedFiles from types.ts to utils/diffFiles.ts
3. Document createDecisionCycle promise identity invariant
4. Update AGENTS.md: correct annotate match key format, add idle status
   lifecycle for code review, add version history endpoints to annotate
   server table
5. Add session lifetime and timeout facts to decisions.md

* Sessions never die: remove TTL, persistent all annotate types, fix 8 divergences

1. Remove AWAITING_RESUBMISSION_TTL_MS — suspend(), idle(), reactivate()
   no longer set expiresAt. Non-terminal sessions live until daemon restart.
2. registerPersistentDecision never calls store.complete() — approve and
   exit suspend the session like deny does. The agent still gets the result
   via resolveWaiters.
3. All annotate types (folder, last, URL) use registerPersistentDecision
   instead of one-shot registerSessionDecision.
4. Annotate /api/feedback returns feedbackSent instead of awaitingResubmission
   for non-revisable sessions (folder, last).
5. Plan review frontend handles feedbackSent state with feedback-sent banner.
6. Annotate handleUpdateContent accepts optional rawHtml for --render-html
   revision support.
7. Review /api/feedback ties feedbackDelivered to resolveAndCycle return
   value — dashboard sessions no longer get stuck.
8. Update decisions.md and AGENTS.md with sessions-never-die principle.

* Self-review: pass rawHtml through annotate reuse path, fix interface and comment

The factory's annotate reuse path called updateContent(input.markdown)
without forwarding input.rawHtml. Updated to pass both. Also updated
the AnnotateSession interface to match the new signature and fixed
a stale comment about TTL expiry.

* Clean up decisions.md: mark fixed items, update stale decisions, add backlog

- Mark 5 open items as Fixed (external annotations, actions disabled,
  waitForResult, snapshot providers, render-html)
- Update Decisions 4/5/6 to reflect current implementation
- Fix Decision 1 cleanup line (sessions persist, no TTL)
- Add gate flag resubmission gap to backlog
- Add provenance data collection to backlog

* Fix zombie listener, folder reuse, and button state on refresh

- Approve/exit paths now use resolveAndCycle() so the decision loop
  stays alive for future resubmissions (fixes agent hang after approve)
- Folder annotate sessions get a match key so re-running the same
  folder reuses the existing session instead of creating duplicates
- All three servers track lastDecision and include it in the initial
  API response so frontends restore correct button state on refresh
- Session-revision listeners accept snapshots and guard annotation
  clearing behind a content-change check to prevent data loss on
  WebSocket reconnect
- Annotate updateContent is always provided (not just for file-based)
  so folder sessions can publish reactivation events

* Fix snapshot state wipe, editor annotations, remote share, HTML draft key

- Session-revision handlers only reset awaiting/feedback/submitted state
  when content actually changed, preventing WebSocket snapshots from
  wiping lastDecision-restored state on tab refresh
- Add feedbackSent to annotate action bar disabled state so buttons
  hide after folder/annotate-last feedback
- Add clearAll() to editor annotation handler; call it in plan and
  review handleUpdateContent so VS Code annotations don't survive
  across revisions
- Regenerate remoteShare URL in all three session reuse paths (plan,
  annotate, review) so remote users get current share links
- Fix raw-HTML annotate draft key to hash rawHtml instead of empty
  markdown, preventing cross-session draft collisions
- Update overview.md: remove all stale 10-minute TTL references

* Fix session-revision handler: distinguish snapshots from live events

State resets (feedbackSent, awaitingResubmission, submitted) now fire
on content change OR live events, but not on snapshots with unchanged
content. This fixes two competing requirements:
- Tab refresh: snapshot has same content, skip state reset (preserves
  lastDecision-restored state)
- Folder reactivation: live event has same content, reset state
  (re-enables buttons after feedbackSent)

* Fix dead import, HTML→markdown switching, standalone awaiting overlay

- Remove unused PlannotatorSession type import from session-factory
- Always assign rawHtml in annotate updateContent so clearing works
  when a file switches from --render-html to plain markdown
- Wire awaitingResubmission into CompletionOverlay for standalone and
  legacy tab mode so deny shows a waiting screen instead of nothing

* Fix stale docs: decision cycle, annotate match keys, persistence scope

- overview.md: approve/exit now also cycle (not final), annotate match
  keys include project scope and folder path, URL/last sessions persist
  but aren't reusable, frontend always subscribes in API mode
- decisions.md: Decision 2 updated for folder reusability with matchKey
Resolve conflicts by keeping the latest versions from the upper stack.
Delete apps/debug-frontend and apps/debug-tui — the production frontend
(apps/frontend) replaces them. Replace daemon-shell-html with a minimal
stub since the debug shell is no longer needed.
@backnotprop backnotprop merged commit 4f4c70b into feat/runtime-frontend-shell May 27, 2026
@backnotprop backnotprop deleted the feat/websocket-event-hub branch May 27, 2026 00:35
backnotprop added a commit that referenced this pull request May 27, 2026
* Add long-running Plannotator daemon runtime

Single daemon process per machine manages session lifecycle, serves
browser UIs at /s/<sessionId>, and exposes control APIs. CLI commands
auto-start the daemon and create sessions via HTTP. Includes session
store, auth tokens, lock files, event broadcasting, and goal-setup
daemon integration.

* Add daemon debug shell and simulator

Debug frontend (apps/debug-frontend) served by the daemon as the session
browser shell. Agent simulator TUI (apps/debug-tui) exercises all agent
protocols with concurrent session support. Includes reactive session
dashboard, event log, prominent session action buttons, and goal-setup
scenarios.

* Add daemon WebSocket event hub (#744)

* Add long-running Plannotator daemon runtime

Single daemon process per machine manages session lifecycle, serves
browser UIs at /s/<sessionId>, and exposes control APIs. CLI commands
auto-start the daemon and create sessions via HTTP. Includes session
store, auth tokens, lock files, event broadcasting, and goal-setup
daemon integration.

* Add daemon debug shell and simulator

Debug frontend (apps/debug-frontend) served by the daemon as the session
browser shell. Agent simulator TUI (apps/debug-tui) exercises all agent
protocols with concurrent session support. Includes reactive session
dashboard, event log, prominent session action buttons, and goal-setup
scenarios.

* Add daemon WebSocket event hub

Replace persistent SSE streams with a single WebSocket connection per
frontend instance. Daemon multiplexes session-scoped events (annotations,
agent jobs, lifecycle) through /daemon/ws with subscribe/unsubscribe
messaging. Includes session actions over WebSocket, reconnect/resync,
auth enforcement, and polling fallback.

* Restore goal-setup integration to WebSocket layer

The goal-setup daemon integration from layers 734/738 was lost during
re-squash cascades. This commit restores all missing pieces:

- PluginGoalSetupRequest type and goal-setup action in plugin protocol
- goal-setup case in daemon session factory
- setup-goal CLI subcommand routed through runDaemonSessionRequest
- goal-setup-submit/goal-setup-exit debug frontend actions
- TUI scenarios for interview and facts with fixture bundles
- TUI completion for goal-setup sessions
- Fix getRepoInfo to use session cwd in createGoalSetupSession
- Standardize naming: "goal-setup" everywhere (was "setup-goal" in
  daemon protocol and debug frontend)

* Add daemon subpath exports to server package

The source shim resolves workspace imports through package exports.
Without these entries, `bun apps/hook/server/index.ts` fails to find
daemon modules even though the files exist on disk.

* Complete goal-setup protocol typing and daemon test coverage

- Add PluginGoalSetupResult to PluginActionResult union
- Add goal-setup to PLANNOTATOR_PLUGIN_FEATURES
- Use typed result in CLI instead of cast
- Add session factory tests: interview submit and facts exit

* Fix goal-setup bundle path resolution and plugin feature list

- Resolve relative bundle paths against getInvocationCwd() so
  hook/wrapper invocations find the file in the project directory
- Remove goal-setup from PLANNOTATOR_PLUGIN_FEATURES since it is
  a direct CLI command invoked by agent skills, not a plugin action
  dispatched through the plugin protocol

* Fix hub-client: reconnect after protocol-level error frames

The onerror and onclose socket handlers both called scheduleReconnect(),
but the handleMessage error branch tore down the socket without
scheduling a reconnect — leaving the client permanently dead.

* Add production frontend with initial view (#753)

* Add production frontend app with daemon project registry

Daemon:
- Project registry persisted to ~/.plannotator/projects.json
- Auto-registers projects from session cwd on creation
- GET/POST/DELETE /daemon/projects endpoints
- cwd exposed on DaemonSessionSummary
- project-registry feature flag + 9 unit tests

Frontend (apps/frontend/):
- TanStack Router, Zustand+Immer, Tailwind v4, oxlint/oxfmt
- Daemon API client with project + session creation methods
- WebSocket hub client with event stream + polling fallback
- Landing page: project selector table + Code Review/Archive actions
- Offcanvas sidebar: sessions grouped by mode (matching prototype)
- Add project dialog
- DiffKit theme + prototype shell pattern (bg-muted → card → content)
- 13 shadcn primitives vendored in components/ui/
- Single-file HTML build (viteSingleFile)
- Dev script: daemon + Vite with auto-proxy (scripts/dev-frontend.ts)

* Remove unused shadcn components and hooks

badge, card, dialog, dropdown-menu, label — not imported by any app code.
use-project-actions, use-sessions-by-project — orphaned after sidebar
and landing page rewrites.

* Rename diffkit theme to neutral

* Add active sessions list to landing page

* Embed code review surface in frontend app (#755)

* Copy review-editor and editor as new embeddable packages

packages/plannotator-code-review — copy of packages/review-editor
packages/plannotator-plan-review — copy of packages/editor

Unmodified copies to start. These will be refactored to strip
standalone providers (ThemeProvider, TooltipProvider, Toaster)
and accept session-scoped API context from the frontend shell.
The original packages remain untouched for the legacy single-file
HTML flow.

* Add useSessionFetch hook and SessionProvider

React context that scopes fetch calls to a daemon session.
When inside a SessionProvider, fetch("/api/diff") rewrites to
fetch("/s/:sessionId/api/diff"). Without a provider, returns
the global fetch unchanged.

* Migrate shared hooks to useSessionFetch

Add const fetch = useSessionFetch() to 10 hooks in packages/ui/hooks/.
The shadowed fetch variable routes /api/ calls through the session
context when a SessionProvider is present, and falls back to global
fetch when not.

configStore.ts uses apiFetch (import-based) since it's a class method.

* Migrate code review package to useSessionFetch

Add const fetch = useSessionFetch() to all 11 files in
packages/plannotator-code-review/ that call fetch("/api/...").
The shadowed fetch variable routes calls through the session
context. No fetch call sites were modified — only the function
that provides the fetch was changed.

* Export ReviewAppEmbedded without standalone providers

ReviewApp accepts __embedded prop to skip ThemeProvider,
TooltipProvider, and Toaster (shell provides these). Uses h-full
instead of h-screen when embedded. ReviewAppEmbedded is a named
export that passes the prop. Default export unchanged.

Shell Layout gains TooltipProvider for code review tooltips.

* Mount code review surface in frontend session route

When session.mode === "review", the /s/:sessionId route wraps
ReviewAppEmbedded in a SessionProvider and renders the full
code review UI. Other modes keep the placeholder.

- Added @plannotator/code-review as frontend dependency
- Created App.d.ts type declaration for the code review package
- Added plannotator-ui.d.ts for SessionProvider and ThemeProvider types
- Added PNG module declaration for asset imports
- Fixed settings.ts satisfies type for strict-mode compatibility
- Vite alias resolves to package source for bundling
- TypeScript uses .d.ts for type checking (avoids strict-checking loose package source)

* Fix session surface integration issues

- Add @custom-variant dark to code-review CSS for .light class toggle
- Remove forced theme cookies from main.tsx (use defaultColorTheme prop)
- Clean up document.title on review unmount (restore previous)
- Clean up CSS custom properties on review unmount
- Define __APP_VERSION__ from root package.json in vite config
- Narrow Vite proxy to /s/:id/api/ only (page loads stay with Vite SPA)
- Skip auto-registering temp directory projects in session factory
- Add max-height scroll to project table for overflow
- Add headerLeft prop to ReviewAppEmbedded for global sidebar trigger
- Fix header padding when sidebar trigger is present

* Resolve ~ to home directory in addProject

* Fix duplicate Tailwind build causing style conflicts

The code review's index.css had its own @import "tailwindcss" with
separate @source and @theme directives, producing a second Tailwind
build that competed with the frontend's styles.css. This caused
buttons, dialogs, and other components to render with wrong styles.

Fix: remove Tailwind, theme import, and @source from the code
review's index.css (keep only dockview + custom CSS). Add @source
directives to the frontend's styles.css to scan the code review
package and shared UI components. One Tailwind build, one theme,
no conflicts.

* Clean up CSS: remove duplications, fix keyframe collision

- Rename code review's @keyframes fade-in to cr-fade-in to avoid
  collision with the frontend's fade-in (different animation)
- Remove redundant panel scrollbar rules from styles.css (global
  scrollbar rules already cover all elements)
- Add comment noting intentional scrollbar override of theme.css
- Single Tailwind build, single theme import, zero duplications

* Make sidebar logo link to homepage

* Match sidebar trigger hover style to code review buttons

* Fix sidebar session labels and badge overlap

- Strip machine-generated prefixes from session labels (plugin-review-,
  claude-code-, etc.) to show just the project/PR name
- Add pr-7 padding to menu buttons so truncation ellipsis doesn't
  overlap with the status badge dot
- Full label still visible on hover via tooltip

* Fix formatting

* Fix test suite: restore globalThis.fetch after useSessionFetch tests

The useSessionFetch test replaced globalThis.fetch with a mock in
beforeEach but never restored it. Other test files running in the
same process (daemon runtime tests) got the mock instead of real
fetch, causing JSON parse failures on "ok" responses.

Added afterEach to restore the original fetch. All 1,463 tests pass.

* Add React Activity keep-alive for session surfaces

Sessions now stay alive when the user navigates away. Instead of
unmounting and remounting on each navigation, visited sessions are
hidden via React's <Activity mode="hidden"> and restored instantly
when the user returns.

- AppStore tracks visitedSessions (keyed by session ID) and activeSessionId
- Layout renders all visited sessions in <Activity> wrappers — only
  the active one is visible, the rest are hidden but preserved
- Session route registers its bootstrap data with the store and
  renders nothing — Layout owns the rendering
- Landing page deactivates the current session when navigated to
- SessionSurface component extracted to handle mode-based rendering

Effects clean up when hidden (WebSocket subs, timers stop) and
restart when visible. DOM, React state, scroll position, annotations,
dock layout all survive navigation.

* Fix: show error state when session load fails during active session

* Fix: deactivate session from Layout instead of inside hidden Activity

* Dispatch resize event when session becomes visible to fix Pierre diffs

* Replace Activity with visibility:hidden for session keep-alive

Activity uses display:none which breaks Pierre diffs' virtualizer —
it measures the container at zero height and renders no content.
When made visible again, the virtualizer doesn't recalculate.

Switch to visibility:hidden + position:absolute which preserves
element dimensions. Pierre diffs keeps its measurements, Dockview
keeps its layout. The tradeoff is effects don't pause for hidden
sessions, but that's preferable to broken diffs.

* Fix: derive landing page visibility from route match synchronously

* Set router pendingMs to 0 to eliminate navigation delay

* Auto-restore code review drafts silently, remove restore dialog

Drafts are keyed by diff content hash on the server. Same diff = same
draft. When a draft exists on mount, it's now restored automatically
with a subtle toast notification instead of a blocking dialog.

- useCodeAnnotationDraft takes an onRestore callback instead of
  returning draftBanner/restoreDraft/dismissDraft
- ConfirmDialog for draft restore removed from App.tsx
- Toast shows "Restored N annotations" on auto-restore

* Fix flash of unstyled sidebar on page load

* Style Toaster with theme tokens

* Add rounded top-left corner to embedded code review

* Move rounded corner to Layout session container and clip overflow

* Fix curved border: move border from sidebar to session container

* Only show curved border when sidebar is open

* Fix: use useSidebar hook for conditional curved border

* Fix project registry: key by cwd, defer registration until session succeeds

- registerProject now finds existing entries by cwd (not name), so two
  repos with the same name get separate entries
- removeProject takes cwd instead of name for unambiguous deletion
- Server DELETE /daemon/projects now accepts JSON body with cwd
- Session factory defers registerProject until after session creation
  succeeds, avoiding phantom entries from failed requests
- Hub-client: add missing scheduleReconnect after protocol error frames

* Embed plan review surface and fix cross-surface issues (#758)

* Migrate missed shared UI components to useSessionFetch

8 component/hook files in packages/ui/ still had bare fetch('/api/...')
calls that weren't caught during the code review migration:

- Settings.tsx, InlineMarkdown.tsx, AttachmentsButton.tsx, ExportModal.tsx
- settings/HooksTab.tsx, goal-setup/GoalSetupSurface.tsx
- plan-diff/PlanDiffViewer.tsx, hooks/useLinkedDoc.ts

Also set window.__PLANNOTATOR_API_BASE__ in SessionProvider so
non-hook consumers (apiPath for <img src> in ImageThumbnail) get
session-scoped paths.

* Migrate plan review App.tsx to useSessionFetch + title cleanup

* Auto-restore plan review drafts silently, remove restore dialog

Rewrite useAnnotationDraft with onRestore callback pattern (same as
useCodeAnnotationDraft). Legacy tuple format preserved. Toast on
restore. ConfirmDialog removed from plan review App.tsx.

* Export PlanAppEmbedded without standalone providers

Strip ThemeProvider, TooltipProvider, Toaster when __embedded.
h-full instead of h-screen. headerLeft prop passed through to
AppHeader for sidebar trigger. App.d.ts type declaration added.

* Strip Tailwind from plan review CSS, add @source to frontend

* Remove dead toast animation classes that conflicted with tailwindcss-animate

* Add visibility guards to keyboard handlers on both surfaces

When keep-alive hides a surface with visibility:hidden, its keyboard
listeners on window/document stay active. Without guards, Mod+Enter
on the visible code review would also fire the hidden plan review's
submit handler.

Both App.tsx files now check getComputedStyle(rootRef).visibility
at the start of every keyboard handler. If hidden, return early.

* Wire plan review surface into frontend app

All session modes now render production surfaces:
- review → ReviewAppEmbedded
- plan, annotate, archive, goal-setup → PlanAppEmbedded

Added @plannotator/plan-review dependency, Vite aliases, and styles.
SessionSurface simplified — review gets code review, everything else
gets plan review (which determines its mode from /api/plan response).

* Fix: pass session fetch to submitGoalSetup helper

* Replace full-screen completion overlay with inline banner in embedded mode

When running inside the frontend app (__embedded), the CompletionOverlay
blocked the entire viewport including the sidebar. Now:
- Embedded surfaces show a CompletionBanner (colored bar below the header)
- Action buttons hide after submission (plan review hides via AppHeader
  submitted prop, code review hides via !submitted guard)
- Standalone mode keeps the original full-screen overlay with auto-close
- No window.close() fires in embedded mode since useAutoClose lives
  inside CompletionOverlay which is skipped

* Serve production frontend from daemon, debug shell via env var only

The daemon now serves the production frontend HTML (apps/frontend/) at
session URLs. The debug frontend is only loaded when PLANNOTATOR_DEBUG_SHELL=1,
read from disk at runtime — never bundled in the compiled binary.

* Session lifecycle, worktree projects, and directory picker (#759)

* Add frontend visibility and focus reporting to daemon WebSocket

The daemon now tracks per-connection client state: tab visibility and
active session ID. The frontend reports these via a new `client-state`
WebSocket message type on connect, visibility change, and route
navigation. The event hub exposes `getFrontendState()` which returns
whether any frontend is connected, any tab is visible, and which
sessions are actively being viewed.

This is the foundation for smart session opening — the daemon will use
this state to decide between opening a browser and sending an in-app
notification.

* Move browser opening from CLI to daemon with smart presentation

The daemon now decides how to present new sessions based on frontend
connection state. If a frontend tab is connected and visible, it sends
a notification event (no new tab). If no frontend is connected or the
tab is backgrounded, it opens a browser.

- Add presentSession() to daemon runtime with decision matrix
- Add legacyTabMode config: always opens browser when enabled
- Remove handleServerReady/handleReviewServerReady/handleAnnotateServerReady
  calls from CLI hook — the daemon handles it
- Add browserAction field to POST /daemon/sessions response
- CLI sessions --open command kept as-is (explicit user action)

* Add session notification toasts and keep completed sessions in sidebar

Phase 3: When the daemon notifies instead of opening a browser, it
publishes a session-notify event. The frontend shows an auto-dismissing
toast (8s) with mode, project, and an Open button. Toasts are gated on
document.visibilityState — queued when tab is backgrounded, flushed on
return.

Phase 4: Completed sessions no longer disappear from the sidebar.
The terminal-status splice in event-store was removed — sessions now
update in-place with their new status. Only explicit session-removed
events cause removal.

* Collapse sidebar on direct session links, open on landing page

SidebarProvider defaultOpen is now based on the initial route: collapsed
when loading /s/:id directly, open when loading /. Users can still
toggle the sidebar manually after the initial render.

* Add disk-backed session snapshots for completed session persistence

When a session completes, the daemon writes a content snapshot to
~/.plannotator/sessions/<id>.json before disposing the handler.
Snapshots capture the plan markdown, diff data, or annotation content —
everything the frontend needs to render the session read-only.

The daemon server serves snapshot content when a request hits a disposed
or missing session. This means completed sessions survive page refresh
and daemon restart. Snapshots are capped at 5MB to avoid oversized
review diffs.

Each session type provides a snapshot callback in the factory that
closes over its content at creation time.

* Wire legacy tab mode through server config to surface overlays

When legacyTabMode is set in config.json, the daemon always opens a
browser (already wired in Phase 2), and both surfaces render the
full-screen CompletionOverlay with auto-close instead of the inline
CompletionBanner — even in embedded mode. This preserves the old
tab-per-session + auto-close experience for users who prefer it.

The legacyTabMode flag flows through getServerConfig() → /api/plan
and /api/diff responses → surface state.

* Document legacyTabMode config setting in AGENTS.md

* Load session snapshots from disk on daemon startup

Completed sessions from previous daemon runs now appear in the sidebar
immediately. On startup, the daemon reads all snapshots from
~/.plannotator/sessions/ and creates completed records in the store.
These records have no handlers but serve content via the snapshot
fallback in the server.

* Add worktree-aware project hierarchy to landing page

Projects that are git worktrees auto-detect their parent repo and nest
underneath it. The landing page shows projects as collapsible tree nodes
— expanding a project fetches its worktrees via git worktree list and
shows them with branch names.

- DaemonProjectEntry gains optional parentCwd and branch fields
- addProject detects worktrees via git rev-parse --git-common-dir and
  auto-registers the parent repo
- New GET /daemon/projects/worktrees?cwd= endpoint lists worktrees
- Frontend ProjectTable refactored to collapsible tree with worktree
  children, selection passes cwd to session creation
- Session labels include branch name when created from a worktree cwd

* Fix parent project registration dedup and add branch to all session labels

- Parent auto-registration now adds directly to the flat array instead
  of calling registerProject, avoiding name-based dedup that could
  overwrite unrelated projects with the same derived name
- All session modes (annotate, archive, goal-setup) now include the
  branch name in their labels, matching plan and review

* Fix blank page when adding a worktree project

When adding a directory that is a worktree, the daemon auto-creates
the parent project. But the store only added the returned entry (the
worktree child), leaving the parent missing from the frontend state.
Since the worktree has parentCwd set, the topLevel filter found zero
entries and nothing rendered.

Fix: when the added entry has parentCwd, re-fetch the full project
list so the auto-created parent is included.

* Filter temp directory worktrees from project listing

* Sort worktrees by last activity (index mtime > commit time > dir mtime)

Each worktree gets a lastActive timestamp derived from:
1. Git index file mtime (updates on add, checkout, stash — reflects
   active work even without commits)
2. Last commit timestamp (fallback if index unavailable)
3. Directory mtime (fallback for brand new worktrees)

All three signals are cross-platform (fs.statSync + git log).
Worktrees are sorted most-recently-active first.

* Fix toast: skip for frontend-initiated sessions, clean label, fix colors

- Don't call presentSession for origin "plannotator-frontend" — the
  frontend already navigates to the session it just created
- Strip internal prefixes from session label in toast description,
  suppress description when it matches the project name
- Style toast action button with theme primary colors
- Widen project selector to max-w-2xl
- Remove opacity-50 from worktree icons

* Replace manual project input with searchable directory picker

The Add Project dialog is now a searchable directory browser inspired
by OpenCode's project picker:

- Type a path (~/work/, /Users/...) and see child directories listed
- Arrow keys to navigate, Enter to select, Tab to navigate into a dir
- Recent projects shown at top for quick re-selection
- ~ expansion handled server-side
- Hidden directories (.git, .cache, etc.) filtered out
- 150ms debounced directory listing for responsive typeahead

New daemon endpoint: GET /daemon/fs/list?path= returns child
directories for any path with ~ expansion.

* Only show worktree chevron when worktrees exist, add Worktrees label

- Fetch worktrees eagerly on mount instead of on expand, so the chevron
  only appears when there are actual worktrees to show
- Projects without worktrees get a plain spacer instead of the chevron
- Add a "Worktrees" section label above the expanded list

* Fix: add missing useEffect import in LandingPage

* Fix project row layout: chevron to right, remove branch icons, align folders

- The whole project row is now one selectable button with folder icon
  consistently at the left
- Worktree expand chevron moved to the right end, only visible on hover
  area — doesn't block the selectable feel
- Removed all GitBranch icons from worktree entries — just indentation
  and the branch/worktree name
- Projects without worktrees have no chevron at all, no spacer needed

* Add ASCII art Plannotator banner to landing page

* Increase ASCII banner opacity to 70%

* Remove redundant Plannotator label from landing page nav

* Make Add Project buttons more visible

* Design audit: fix color contrast, remove opacity abuse, fix a11y

Applied Emil's design engineering principles:

- Interactive rows use text-foreground by default, not text-muted-foreground.
  Muted text is only for metadata (paths, timestamps, section labels).
  Items should look clickable at rest, not disabled.
- Replaced all opacity-60 on secondary text with text-muted-foreground
  (semantic token instead of raw opacity)
- Borders use border-border (full opacity) not border-border/40 — borders
  should be visible enough to serve their structural purpose
- Removed transition-colors (was a transition: all risk) — hover states
  are instant by design for frequently-used UI
- Changed expand <span role="button"> to proper <button> with aria-label
- Added select-none to ASCII banner (decorative element)
- Used proper ellipsis character (…) instead of ...
- Selected state uses bg-primary/10 instead of bg-primary/8 for clearer
  feedback

* Design audit: fix directory picker dialog contrast and accessibility

Applied Emil's design engineering principles to AddProjectDialog:

- Interactive rows use text-foreground by default, not text-muted-foreground
- Removed all /50 and /60 opacity modifiers on borders and text —
  borders use border-border, metadata uses text-muted-foreground
- Input uses text-base (16px) to prevent iOS zoom on focus, with
  sm:text-[13px] for desktop
- Fixed nested <button> inside <button> (invalid HTML) — directory
  rows now use a <div> wrapper with two sibling buttons
- Navigate-into chevron is always visible (was opacity-0 hover-only,
  violating "never rely on hover for core functionality")
- Added aria-labels to close button and navigate buttons
- Removed transition-colors from interactive rows (speed over delight)
- Used proper ellipsis character (…) in placeholder and loading text
- Removed unused displayName prop from DirectoryRow

* Add goal files for session lifecycle and worktree projects

* Unified settings, performance optimizations, and Zustand review store (#766)

* Add shadcn Dialog and Tabs primitives to frontend

Dialog wraps @radix-ui/react-dialog with DiffKit prototype styling:
bg-black/55 backdrop, rounded-2xl card, entrance animation with
reduced-motion support. Sized at max-w-4xl for the settings dialog.

Tabs wraps @radix-ui/react-tabs with vertical orientation support.
Tab triggers use text-foreground by default (per design audit).

Also backlogged AddProjectDialog migration to use these primitives.

* Add unified settings dialog with vertical tabs layout

The frontend app now has a single app-level settings dialog accessible
via Cmd+, (Ctrl+, on Windows/Linux) or the sidebar Settings button.
It uses a wide Radix Dialog (896px) with vertical tabs grouped into
four sections: General, Plan Review, Code Review, and Integrations.

Tab content components are imported from the shared UI package:
- ThemeTab, KeyboardShortcuts, HooksTab (already separate files)
- GitTab, ReviewDisplayTab, CommentsTab (newly exported from Settings.tsx)
- SegmentedControl, ToggleSwitch (extracted to settings/shared.tsx)

The existing per-surface Settings modals remain for standalone mode.

* Wire per-surface gear icon to unified settings dialog when embedded

Both PlanAppEmbedded and ReviewAppEmbedded now accept an onOpenSettings
callback. When provided (embedded mode), the gear icon opens the
app-level unified settings dialog instead of the per-surface modal.
Standalone mode is unchanged — no callback means the built-in modal
opens as before.

* Complete settings dialog: all 4 sections, 13 tabs, AI tab

Dialog now has all four sections from the plan:
- General: General (placeholder), Theme, Shortcuts
- Plan Review: Display (placeholder), Saving (placeholder), Labels
  (placeholder), Hooks
- Code Review: Git, Display, Comments, AI
- Integrations: Files (placeholder), Obsidian (placeholder)

7 tabs have full content (Theme, Shortcuts, Hooks, Git, Review Display,
Comments, AI). 6 tabs show placeholder text indicating what settings
will go there — these need the inline content extracted from the
monolithic Settings.tsx in a follow-up.

Reduced motion is already handled globally via theme.css and
tailwindcss-animate.

* Extract settings tabs: General, PlanGeneral, PlanDisplay, Saving

Extracted four tab components from the Settings.tsx monolith into
self-contained files in packages/ui/components/settings/:

- GeneralTab: identity + auto-close (shared across all modes)
- PlanGeneralTab: permission mode (Claude Code) + agent switching
  (OpenCode) — plan-specific, moved out of General
- PlanDisplayTab: TOC, sticky actions, plan width with preview
- SavingTab: plan save toggle/path, default notes app, integration
  quick-nav buttons with onNavigateTab callback

Dialog now has 16 tabs across 4 sections. 11 have full content,
5 remain as placeholders (Labels, Files, Obsidian, Bear, Octarine).

Tab grouping updated per design review:
- Permission Mode and Agent Switching moved from General to Plan Review
- Bear and Octarine added to Integrations section

* Complete all 16 settings tabs — zero placeholders remaining

Extracted remaining tab components from the Settings.tsx monolith:
- LabelsTab: quick annotation labels with emoji, color, AI tips, shortcuts
- FilesTab: file browser enable + directory list management
- ObsidianTab: vault detection, path/folder/filename config, auto-save,
  vault browser toggle
- BearTab: custom tags, tag position, auto-save
- OctarineTab: workspace, folder, auto-save

All 16 tabs across 4 sections now render full settings content:
- General (3): General, Theme, Shortcuts
- Plan Review (5): General, Display, Saving, Labels, Hooks
- Code Review (4): Git, Display, Comments, AI
- Integrations (4): Files, Obsidian, Bear, Octarine

Each extracted tab is self-contained — manages its own state reads/writes
via the existing utility functions and configStore.

* Fix critical settings dialog issues found in eight-agent audit

Critical fixes:
- AISettingsTab now receives required props (providers, selectedProviderId,
  onProviderChange). Fetches /api/ai/capabilities when dialog opens.
- PlanGeneralTab now receives origin from active session so Permission
  Mode and Agent Switching tabs actually render.

Functional fixes:
- Stale state on re-open: mountKey increments when dialog opens, forcing
  Radix Tabs to re-mount tab content so useState initializers re-read
  fresh cookie values.
- Cmd+, now toggles (close if open, open if closed) instead of only
  opening.

Remaining items deferred: Tater Mode toggle in PlanDisplayTab, ThemeTab
preview mode, diffTabSize in ReviewDisplayTab, content gaps in Obsidian/
Octarine/Saving helper text.

* Self-review fixes: stale AI provider, type safety, ObsidianTab fetch

- Re-read aiProviderId from cookies on each dialog open (was stale
  if changed via per-surface settings between opens)
- Remove `as any` cast on origin prop — PlanGeneralTab now accepts
  Origin | string | null
- ObsidianTab no longer depends on useSessionFetch (breaks outside
  SessionProvider). Uses fetchFn prop with globalThis.fetch default.
  Vault detection gracefully falls back to manual input if fetch fails.

* Fix critical issues from second eight-agent audit

1. AI capabilities fetch: route through active session API path
   (/s/:id/api/ai/capabilities) instead of broken root /api/ path.
   Skips fetch when no session is active — AI tab shows empty state.

2. Z-index: dialog overlay and content bumped from z-50 to z-[110],
   above CompletionOverlay's z-[100]. Settings dialog now renders
   on top of everything except toasts.

3. Keyboard shortcuts: unified dialog now shows BOTH plan and code
   review shortcuts with section labels, not just plan mode.

* Full parity: every setting from original Settings.tsx now in extractions

Parity fixes:
- Tater Mode toggle added to PlanDisplayTab (with optional props)
- diffTabSize stepper added to ReviewDisplayTab (was only in popover)
- SavingTab: description text under Default Save Action select restored
- SavingTab: auto-reset effect when integration becomes unavailable
- ObsidianTab: filename format variables hint line restored
- ObsidianTab: filename separator helper text restored
- ObsidianTab: frontmatter preview block restored
- OctarineTab: workspace and folder helper text restored
- LabelsTab: tip editor onFocus cursor handler restored
- PlanGeneralTab: agent warning SVG icon restored
- PlanGeneralTab: stale agent "not found" disabled option restored

Resource cleanup:
- Hidden <Settings> no longer mounts in embedded mode — both plan
  review (skipBuiltInSettings prop) and code review (guard on
  externalOpenSettings) skip rendering when the unified dialog handles it

* Add daemon global settings API — settings work without active sessions

Five new daemon endpoints for settings that previously required a
session-scoped API path:

- GET/POST /daemon/config — read/write ~/.plannotator/config.json
- GET /daemon/git/user — detect git config user.name
- GET /daemon/vaults — detect Obsidian vaults
- GET /daemon/hooks/status — hook file status + PFM reminder

All are thin wrappers around existing functions in packages/shared/
with no session context needed.

Frontend routing: added globalFetchBase fallback to apiFetch. When
__PLANNOTATOR_API_BASE__ is unset (landing page, no session), config
writes route through /daemon/config. Session base still takes
precedence when set.

Settings dialog: fetches gitUser from /daemon/git/user on open,
initializes configStore from /daemon/config, passes daemon-routed
fetch to ObsidianTab and HooksTab so vault detection and hook status
work from the landing page.

* Fix ObsidianTab vault URL: accept both /daemon/vaults and /daemon/obsidian/vaults

* Fix theme preview mode and slow theme switching with keep-alive

Theme preview: wired onPreview to ThemeTab in unified dialog. Clicking
"Launch Preview Mode" hides the dialog and opens a bottom drawer with
a compact ThemeTab. Escape or Done button returns to the dialog.

Theme switching performance: hidden keep-alive surfaces now skip color
transitions entirely via [style*="visibility: hidden"] * { transition-
duration: 0s }. Only visible content animates, eliminating the lag from
thousands of elements transitioning simultaneously.

* Add performance scope documents: global keyboard registry + configStore Zustand migration

* Move performance scope docs to backlog directory

* Document performance findings: 15 identified issues ranked by impact

* Update performance findings: 22 issues across 4 tiers, prioritized for normal use

* Add 3 new findings from agent sweep: layout thrashing, context invalidation, getComputedStyle

* Add memory leak findings: draft maps, uncancelled rAF, WS subscription accumulation

* Final sweep: 39 total findings incl bundle size, static imports, backdrop-blur, monolith components

* Performance Phase 1: stop hidden sessions from degrading active session

Fix 1 — React.memo on SessionSurface: Layout re-renders (sidebar
toggle, dialog open, session switch) no longer cascade into every
mounted session's component tree. The bootstrap prop is reference-
stable so memo always bails out for hidden sessions.

Fix 3 — Scope document.querySelector to session container:
- StickyHeaderLane: new containerRef prop, queries within the session's
  root instead of the global document. Prevents hidden sessions from
  attaching ResizeObservers to the active session's elements.
- TableOfContents: scopes block-id query to scrollViewport instead of
  document. Prevents hidden session TOC clicks from scrolling the
  active session.
- PlanCleanDiffView: new containerRef prop for diff-block-index query.
  Prevents hidden session annotation selections from highlighting
  blocks in the active session.

Fix 4 — Gate document-level mutations on visibility:
- CSS custom properties (--diff-font-override etc.) now set on
  rootRef.current instead of document.documentElement. Each session
  scopes its own font/tab-size vars. No more global style recalc
  from hidden sessions writing to :root.
- document.title gated with isVisible() in both surfaces. Hidden
  sessions no longer overwrite the active session's title.

* Self-review fixes: PlanCleanDiffView self-scoping, font-size CSS selectors

- PlanCleanDiffView now has its own rootRef on its wrapper div, so the
  querySelector for diff-block-index elements scopes to its own tree
  without needing containerRef threaded from the parent
- CSS font-size-override selectors changed from :root[style*="..."]
  to .has-font-size-override class — the :root selectors broke when
  CSS vars moved from document.documentElement to rootRef.current
- Cleanup removes the class on effect teardown

* Fix tab size not applying to diffs and title not updating on session switch

Tab size: moved from CSS variable on rootRef (which didn't penetrate
Pierre's shadow DOM) to direct unsafeCSS injection via usePierreTheme,
matching how font-family and font-size already work.

Title: replaced static isVisible callback (empty deps, never re-triggered)
with useSessionVisible hook that uses MutationObserver to reactively
detect when Layout.tsx toggles the parent's visibility style.

* Use content-visibility: hidden on inactive sessions to skip layout/paint

The browser was doing full layout and style recalc on every hidden
session's DOM (60-120k nodes with 3+ sessions). content-visibility: hidden
tells the browser to skip all rendering work on inactive subtrees while
preserving DOM state, scroll positions, and React component state.

Also runs oxfmt on frontend files to fix pre-existing formatting drift.

* Scroll to annotation line when clicking sidebar comment in all-files view

Previously only scrolled to the file header. Now finds the
[data-annotation-id] element and scrolls to it directly, with a
retry loop for lazy-mounted diffs and file header fallback.

* Add Zustand review store with per-session scoping

Introduces a Zustand store for the code review surface, replacing
annotation useState calls with store state. Panels can now subscribe
to specific slices via selectors instead of re-rendering on every
unrelated state change through the ReviewStateContext god object.

Phase 1: annotations slice (hot path), diff-options slice, files slice.
DiffHunkPreview migrated as first panel consumer. ReviewStateContext
stays alive for unmigrated panels during the transition.

* Fix deleted annotations reappearing after refresh

When all annotations were deleted, the draft auto-save skipped the
save entirely, leaving the stale draft on the server. On refresh it
restored the deleted annotations. Now tracks whether a draft exists
on the server and issues a DELETE when annotations drop to zero.

* Stabilize callbacks via getState() and memo FileTreeNodeItem

Migrate files and activeFileIndex to store-owned state (remove sync
bridge). Stabilize 7 callbacks + keyboard handler by reading
pendingSelection, files, activeFileIndex, externalAnnotations,
selectedAnnotationId, and allAnnotations from storeApi.getState()
instead of closing over them. Callbacks now have stable references
that don't recreate on every hover or file switch.

Wrap FileTreeNodeItem in React.memo — 531 instances were re-rendering
29 times each (15,399 total, 1,202ms) due to parent cascades.

Baseline: 55% of render time (4,825ms) was full-tree cascades
triggered by ReviewApp. These changes eliminate the callback
instability that caused most of those cascades.

* Add React.memo to shared UI components and stabilize plan review props

Wrap BlockRenderer, InlineMarkdown, CodeFileLink, and TableBlock in
React.memo. These render hundreds of times per plan/review session
due to parent cascades — profiler showed 778 BlockRenderer renders
and 888 InlineMarkdown renders in a single session. Memo prevents
re-renders when the block content and callbacks haven't changed.

Stabilize plan review App.tsx props to Viewer: replace inline arrow
functions (onPlanDiffToggle) with useCallback, replace inline
linkedDocInfo object with useMemo. These created new references on
every render, defeating any memo on child components.

* Fix backLabel reference-before-initialization in plan review

The linkedDocInfo useMemo was placed above the backLabel variable
it depends on, causing a crash on plan session load. Moved the
useMemo below backLabel's declaration.

* Unified frontend: Git Dashboard, PR listing, sidebar redesign, and settings cleanup (#765)

* Add PR listing, multi-select launch, stacked PR grouping, and project management to frontend landing page

- Fix worktree project registration: session factory now uses addProject() instead of registerProject() so worktrees nest under parent projects
- Fix directory typeahead: handle partial paths by splitting into parent dir + prefix filter
- Add daemon endpoint GET /daemon/projects/prs for listing PRs via GitHub/GitLab CLI
- Add PR list with stacked PR detection using headBranch/baseBranch chain matching
- Add multi-select for PRs and worktrees with parallel session launch via Promise.allSettled
- Restructure worktrees from badge pills to row layout matching PR list style
- Add tabbed expansion panel (PRs default, Worktrees) under each project
- Add shared formatSessionLabel() for clean display names across sidebar, landing page, and toasts
- Add right-click context menu to remove projects with session cancellation and history cleanup
- Add PullRequestIcon with state-based coloring (open/merged/closed)

* Clean up settings dialog: proper header, version info, compact display tab layout

- Replace absolute-positioned close button with dedicated header bar to prevent toggle overlap
- Remove settings cog icon, add version number and send feedback link
- Rewrite Code Review Display tab with grouped sections (Typography, Layout, Options)
- Replace font size slider with +/- stepper matching tab size pattern
- Make segmented controls inline with labels for compact layout
- Add live preview showing both font family and size together
- Fix Theme tab spacing: add divider and gap between Mode and Theme sections
- Move Line Backgrounds toggle to end of Options (sub-setting at bottom)

* Git dashboard, settings cleanup, sidebar redesign, and PR data pipeline

- Add PRDetailedListItem type and fetchPRDetailedList for dashboard-specific PR data
- Add daemon endpoint GET /daemon/projects/prs/detailed with 30s cache
- Add git-dashboard-store (Zustand) with parallel multi-project fetch and dedup
- Build Git Dashboard with PRRow, PRGroup, MetricCards components
- Dashboard groups PRs into Open/Draft/Recently Merged with scroll-to navigation
- Clicking a PR row launches a review session via createReviewSession
- Add CSS translateX slide transition between landing page and dashboard
- Clean up settings dialog: proper header bar, version info, send feedback link
- Rewrite Code Review Display tab with grouped sections and compact inline controls
- Fix Theme tab spacing between Mode and Theme sections
- Redesign sidebar: sprite mascot header, Instrument Sans brand font, version display
- Remove session status dots/checkmarks, move mode icons to group headers
- Compact sidebar session rows (text-xs, h-7) with alignment spacers
- Embed sidebar trigger in landing page card instead of dedicated nav bar
- Add right-click context menu for project removal with session/history cleanup
- Fix directory typeahead prefix filtering for partial path input
- Fix worktree project registration via addProject() instead of registerProject()
- Add multi-select session launch with parallel Promise.allSettled
- Add stacked PR grouping via headBranch/baseBranch chain detection
- Add shared formatSessionLabel() for clean display across sidebar/landing/toasts
- Deduplicate PRs across projects sharing the same remote

* Add hover-triggered peek sidebar when sidebar is closed

- Extract AppSidebarContent for reuse between real sidebar and peek
- SidebarPeek renders a floating panel on left-edge hover (80vh, centered)
- Backdrop overlay fades in at bg-black/30 behind the panel
- Panel slides in/out at 150ms, backdrop at 200ms
- Uses bg-sidebar token for consistent background with real sidebar
- Remove session tooltip hovers
- Only active when sidebar is collapsed

* Remove unused asset files (banners, mascot, sprite backups, index.html)

* Address PR review findings: security fix, dedup, lazy loading, error surfacing

- Fix path traversal: sanitize project name before use in rmSync path
- Switch project deletion from name-based to cwd-based identification
- Deduplicate archive launches by cwd when multiple selections share a project
- Gate dashboard PR fetch on visibility (only fetch when dashboard slide is active)
- Gate worktree fetch on expanded state (don't shell out until user expands)
- Track project count in dashboard store for cache invalidation on add/remove
- Surface auth and CLI errors in dashboard instead of showing misleading empty state
- Consolidate duplicate PR ref resolution into single handler for both endpoints
- Delete unused carousel.tsx and remove motion dependency
- Move @radix-ui/react-context-menu from devDependencies to dependencies

* Tighten path sanitization: reject dot-only names in history cleanup

* Fix review findings: timer leak, stale tabs, blank dashboard, clear on remove, path guard

- Fix SidebarPeek timer leak: clear existing timeout before setting new one in hide()
- PR tabs now refetch after 30s instead of being permanently cached
- Dashboard isEmpty derived from visible groups, not raw array (fixes blank state for closed-only repos)
- Dashboard clears stale PRs when all projects are removed
- Defense-in-depth: verify resolved rmSync path is inside history root before deleting
- Fix handleRemove missing project.cwd in useCallback dependency array

* Move tater mode to configStore and fix formatting from L10 merge

Tater mode was useState inside plan review App.tsx — unreachable from
the unified settings dialog. Now lives in the global configStore as a
cookie-backed setting. PlanDisplayTab reads it directly via
useConfigValue, no props needed from AppSettingsDialog.

Also runs oxfmt on files from the L10 merge (git dashboard, sidebar,
settings dialog).

* Address PR review findings: stale closures, dead code, missing deps

- Fix stale closure in applyPRResponse: read currentPath before setFiles
- Deduplicate allAnnotations: App.tsx useMemo now calls selectAllAnnotations
- Remove dead fields from files slice (viewedFiles, stagedFiles, reviewBase,
  activeDiffBase and their setters — never called, data still flows through
  ReviewStateContext)
- Wrap default ReviewApp export in ReviewStoreProvider
- Add missing daemon endpoints to AGENTS.md
- Add dependency array to SavingTab useEffect (was firing every render)
- Add activeSessionId to AppSettingsDialog AI capabilities fetch deps

* Fix fetchDiffSwitch stale closure + unstable deps, correct AGENTS.md allowlist

- Read currentPath from getState() before setFiles in preserveFile
  branch (same pattern as applyPRResponse fix)
- Remove files and activeFileIndex from fetchDiffSwitch dep array —
  last unstable callback, now reads from getState() consistently
- Fix AGENTS.md: POST /daemon/config allows conventionalComments and
  conventionalLabels, not legacyTabMode and verifyAttestation

* Fix L10 bugs: stale selections, PR error clearing, dashboard cache

- Clean up selections when projects are removed — prevents stale
  selections from enabling launch buttons for deleted projects
- Clear PR error on successful fetch — errors no longer stick after
  the user fixes their CLI setup
- Dashboard cache invalidates on project identity (cwd set), not just
  count — swapping projects within the same count now triggers refresh

* Add legacy tab mode toggle to settings dialog

Adds "Open sessions in new tabs" toggle to General settings. When
enabled, every session opens in a separate browser tab with the
full-screen completion overlay and auto-close, like the classic
Plannotator experience.

The daemon already supports this via legacyTabMode in config.json —
this just adds the UI toggle and wires it through POST /daemon/config.

* Add remaining backlog items: GitLab detection, stack splitting, configStore migration, keyboard registry

* Session persistence: denied sessions stay alive for resubmission (#770)

* Add session persistence foundation: suspend, reactivate, awaiting-resubmission

Protocol: add "awaiting-resubmission" status (non-terminal), add
"session-revision" event family, bump protocol version to 2.

Session store: add suspend() (resolves waiters WITHOUT disposing
resources — session stays alive), reactivate() (transitions back
to active), and matchKey field on records.

Server: skip the 2-second deletion timer on result endpoint for
awaiting-resubmission sessions — they need to stay alive for the
agent to resubmit.

* Add cycle-based decision model and updateContent to plan server

Replace one-shot resolveDecision with a cycle system — each deny
resolves the current cycle and starts a new one. Approve resolves
the final cycle. Agent-originated sessions return awaitingResubmission
in the deny response.

Add updateContent(newPlan) method that updates plan content, saves
to version history, resets draft state, and publishes a
session-revision event via the WebSocket hub.

Add slug and getSnapshot to PlannotatorSession interface so the
factory can match resubmissions and serve correct snapshots.

Add session-revision to SessionEventFamily type.

* Add session matching, persistent decision loop, and CLI support

Session factory: add sessionRefs registry and findAwaitingSession()
matching by matchKey. Plan sessions compute matchKey as
plan:project:slug. On resubmission, existing awaiting session is
matched and reactivated instead of creating a new one.

Add registerPersistentDecision() — loops on waitForDecision(),
suspending on deny and completing on approve. Replaces one-shot
registerSessionDecision for plan sessions.

Fix unhandled promise rejection: add .catch() to disposed promise
in both registerSessionDecision and registerPersistentDecision.

CLI: accept "awaiting-resubmission" as valid non-error status so
denied sessions output feedback and exit 0 instead of failing.

* Add awaiting-resubmission UI state to plan review

Extend CompletionBanner with 'awaiting' variant — amber spinner with
"Feedback sent — waiting for agent to revise..." message and optional
cancel button.

Plan review deny handler now checks response for awaitingResubmission
flag. When true, shows the awaiting banner instead of the completion
overlay.

Subscribe to session-revision events via daemon WebSocket. When the
agent resubmits and the session reactivates, the event carries the
new plan content. The UI updates: markdown refreshes, previousPlan
updates for diff, all annotations clear, awaiting state resets.

* Document session persistence: AGENTS.md and backlog updates

Add session persistence and resubmission section to AGENTS.md
covering the awaiting-resubmission status, matchKey matching, and
session-revision event family.

Update backlog: mark #3 (live plan updates) and #4 (session
persistence after completion) as done.

* Self-review fixes: operator precedence bug and sessionRefs cleanup

Fix operator precedence in registerPersistentDecision catch handler —
was evaluating as (A && B) || C instead of A && (B || C).

Extend findAwaitingSession to prune completed/expired/failed/cancelled
entries from sessionRefs map, preventing slow memory growth over
daemon lifetime.

* Wire all three session types for persistence

Extract createDecisionScope helper to eliminate duplication between
registerSessionDecision and registerPersistentDecision. Define
SessionDecisionResult type for explicit contracts.

Annotate server: cycle-based decisions, updateContent for file-based
modes, awaitingResubmission in /api/feedback response.

Review server: cycle-based decisions, updateContent(rawPatch, gitRef),
awaitingResubmission for non-approved feedback.

Factory: generalize sessionRefs to PersistableSession interface.
Add matchKey computation and matching for annotate (annotate:filepath)
and review (review:project:branch or review:prUrl). Wire both with
registerPersistentDecision. URL and annotate-last modes keep one-shot
behavior (no persistent source to refresh).

* Add awaiting-resubmission frontend state for annotate and code review

Annotate: handleAnnotateFeedback now checks response for
awaitingResubmission flag, same pattern as plan deny handler.
Session-revision subscription already handles both plan and annotate
since they share App.tsx.

Code review: handleSendFeedback checks awaitingResubmission response.
Add session-revision event subscription that refreshes diff data,
clears annotations, and resets awaiting state. CompletionBanner shows
awaiting variant for all three surfaces.

* Quality fixes: extract updateContent, document findAwaitingSession

Move plan server's inline updateContent closure to a named function
handleUpdateContent for readability. Document findAwaitingSession's
side effect of pruning terminal sessions during search.

* Extract shared decision cycle helper, consistent updateContent naming

Add createDecisionCycle<T>() and resolveAndCycle() to session-handler.ts.
All three servers (plan, annotate, review) now use the shared helper
instead of copy-pasting the cycle setup, resolve, and startNewCycle
pattern. Deny handlers collapse to a single resolveAndCycle() call.

Extract updateContent to named handleUpdateContent functions in all
three servers for consistency — inline closures in return blocks
replaced with named functions defined above the return.

* Fix 5 review findings: snapshot provider, origin check, exit handling, empty content, TTL

1. Register no-op snapshot provider for session-revision in all three
   servers — prevents WebSocket disconnect when frontend subscribes.

2. Exclude plannotator-frontend from agent origins in resolveAndCycle
   — dashboard-created sessions complete on deny instead of hanging.

3. Complete session on exit: true in registerPersistentDecision —
   Exit button now properly terminates instead of suspending.

4. Check revision.plan !== undefined instead of truthiness — empty
   diffs and empty annotate content no longer ignored.

5. Store ttlMs on session record and restore it on reactivate() —
   reactivated sessions expire normally if abandoned.

* Add session persistence design docs and decisions

Captures the overview of what session persistence does, the technical
architecture, and active design decisions from PR #770 triage — notably
removing persistence from code review and redesigning the awaiting state.

* Remove redundant HTML overview from tracking

* Add version history and diff support to annotate sessions

Reuses the plan review's version infrastructure for file-based annotate
sessions. Revisions now save to history, the session-revision event
carries previousPlan and versionInfo, and the frontend's diff badge,
diff view, and version browser activate automatically. Also updates
decisions.md with product facts and revised design decisions.

* Long-lived code review sessions with idle status

Replace the awaiting-resubmission persistence model for code review
with a new "idle" session status. After sending feedback, the session
stays alive and interactive — the user can annotate and send again.
Agent reviews from the same directory attach to the idle session
instead of creating a new one. The frontend shows a calm "Feedback
sent" banner instead of a spinner.

* Hide submit buttons while idle, no auto-dismiss

After feedback is sent and the session is idle (no agent listening),
the Send Feedback and Approve buttons disappear. They reappear when
the agent reactivates the session via session-revision. Keyboard
shortcuts are also blocked while idle.

* Update decisions.md with implemented code review lifecycle

Document the actual idle flow, resolve open questions, and mark
Decision 1 and 3 as implemented.

* Fix 4 review findings: idle TTL, late waiter, self-refresh, temp cleanup

1. Idle sessions with no ttlMs now get a fallback TTL instead of
   living forever (uses AWAITING_RESUBMISSION_TTL_MS as fallback)
2. waitForResult returns immediately for idle sessions with results
   instead of hanging forever on late agent checks
3. Review handleUpdateContent re-runs the diff with current user
   settings instead of accepting a pre-computed patch, and clears
   currentError. Preserves user's diff type and base branch choice.
4. Temp worktree cleanup on the review session reuse path

* Fix PR mode review reuse: pass fetched patch instead of local diff

For PR reviews, the diff comes from the GitHub/GitLab API, not from
local git. handleUpdateContent now accepts an optional pre-computed
patch for PR mode, falling back to self-refresh for local reviews.

* Fix plan diff base tracking and linked-doc annotation leak

1. usePlanDiff now updates the diff base on every revision, not just
   the first. Previously the diff always compared against v1 after
   multiple deny/resubmit cycles.
2. Clear linked-doc annotation cache on session revision so stale
   annotations from linked files don't persist into the next plan
   version.

* Exclude folder annotate from persistence, fix review loop survival

1. Folder-mode annotate sessions no longer participate in session
   matching or resubmission — they have no single document to track.
   Prevents empty content being pushed on resubmission.
2. Review decision loop uses promise identity tracking instead of
   idle() return value to detect cycle exhaustion. Survives double-
   submissions while still exiting cleanly for non-agent origins.

* Scope annotate matchKey by project to prevent cross-project collisions

The "document" filePath fallback created a global collision bucket
for fileless annotate sessions. Now scoped as annotate:project:path.

* Update decisions.md with cross-cutting facts and current open items

Add cross-cutting requirements from review triage: external annotation
flushing, waitForResult consistency, plan action disabling, snapshot
provider, and architectural gaps (HTML pipeline, PR metadata). Replace
outdated bug table with current open items reflecting what's fixed,
what's deferred, and what's accepted.

* Fix 5 cross-cutting issues: external annotations, waitForResult, plan actions, snapshots, docs

1. Clear external annotations on revision for all three servers
   (plan, annotate, review) — stale lint/agent comments no longer
   persist with wrong line numbers after content refresh.
2. waitForResult short-circuits for awaiting-resubmission sessions
   with results, matching the existing idle behavior.
3. Plan/annotate actions disabled during awaitingResubmission —
   keyboard shortcuts and header buttons gated.
4. session-revision snapshot providers return current content instead
   of null — late WebSocket subscribers get the latest state.
5. Comment on registerReviewDecision explaining intentional idle
   lifecycle (reviews stay alive, cleanup via TTL).

* Collapse duplicate decision loops into shared registerDecisionLoop

The persistent and review decision handlers shared ~80% of their structure.
Extract a parameterized registerDecisionLoop that takes an onResult callback
and activeStatuses set. Also drop an unnecessary cast and extract a named
boolean in waitForResult for readability.

* Fix 3 review findings: protocol test, legacy overlay, smart viewed-files retention

1. Update daemon-protocol test to expect session-revision event family
2. Wire feedbackSent to CompletionOverlay so legacy tab mode shows the
   full-screen close experience after sending review feedback
3. Add retainUnchangedViewedFiles() — on diff revision, only un-hide files
   whose patch actually changed; unchanged files stay hidden
4. Document viewed-files and legacy-mode facts in decisions.md

* Fix CompletionOverlay feedback-sent visual to match CompletionBanner

Both components now show success colors and checkmark icon for
feedback-sent state. Previously the overlay used accent/chat-bubble
while the banner used success/check — inconsistent for the same action.

* Code quality: exit cycle, util location, docs, decision cycle comment

1. Review /api/exit uses direct resolve instead of resolveAndCycle —
   no dangling cycle left for the decision loop to block on
2. Move retainUnchangedViewedFiles from types.ts to utils/diffFiles.ts
3. Document createDecisionCycle promise identity invariant
4. Update AGENTS.md: correct annotate match key format, add idle status
   lifecycle for code review, add version history endpoints to annotate
   server table
5. Add session lifetime and timeout facts to decisions.md

* Sessions never die: remove TTL, persistent all annotate types, fix 8 divergences

1. Remove AWAITING_RESUBMISSION_TTL_MS — suspend(), idle(), reactivate()
   no longer set expiresAt. Non-terminal sessions live until daemon restart.
2. registerPersistentDecision never calls store.complete() — approve and
   exit suspend the session like deny does. The agent still gets the result
   via resolveWaiters.
3. All annotate types (folder, last, URL) use registerPersistentDecision
   instead of one-shot registerSessionDecision.
4. Annotate /api/feedback returns feedbackSent instead of awaitingResubmission
   for non-revisable sessions (folder, last).
5. Plan review frontend handles feedbackSent state with feedback-sent banner.
6. Annotate handleUpdateContent accepts optional rawHtml for --render-html
   revision support.
7. Review /api/feedback ties feedbackDelivered to resolveAndCycle return
   value — dashboard sessions no longer get stuck.
8. Update decisions.md and AGENTS.md with sessions-never-die principle.

* Self-review: pass rawHtml through annotate reuse path, fix interface and comment

The factory's annotate reuse path called updateContent(input.markdown)
without forwarding input.rawHtml. Updated to pass both. Also updated
the AnnotateSession interface to match the new signature and fixed
a stale comment about TTL expiry.

* Clean up decisions.md: mark fixed items, update stale decisions, add backlog

- Mark 5 open items as Fixed (external annotations, actions disabled,
  waitForResult, snapshot providers, render-html)
- Update Decisions 4/5/6 to reflect current implementation
- Fix Decision 1 cleanup line (sessions persist, no TTL)
- Add gate flag resubmission gap to backlog
- Add provenance data collection to backlog

* Fix zombie listener, folder reuse, and button state on refresh

- Approve/exit paths now use resolveAndCycle() so the decision loop
  stays alive for future resubmissions (fixes agent hang after approve)
- Folder annotate sessions get a match key so re-running the same
  folder reuses the existing session instead of creating duplicates
- All three servers track lastDecision and include it in the initial
  API response so frontends restore correct button state on refresh
- Session-revision listeners accept snapshots and guard annotation
  clearing behind a content-change check to prevent data loss on
  WebSocket reconnect
- Annotate updateContent is always provided (not just for file-based)
  so folder sessions can publish reactivation events

* Fix snapshot state wipe, editor annotations, remote share, HTML draft key

- Session-revision handlers only reset awaiting/feedback/submitted state
  when content actually changed, preventing WebSocket snapshots from
  wiping lastDecision-restored state on tab refresh
- Add feedbackSent to annotate action bar disabled state so buttons
  hide after folder/annotate-last feedback
- Add clearAll() to editor annotation handler; call it in plan and
  review handleUpdateContent so VS Code annotations don't survive
  across revisions
- Regenerate remoteShare URL in all three session reuse paths (plan,
  annotate, review) so remote users get current share links
- Fix raw-HTML annotate draft key to hash rawHtml instead of empty
  markdown, preventing cross-session draft collisions
- Update overview.md: remove all stale 10-minute TTL references

* Fix session-revision handler: distinguish snapshots from live events

State resets (feedbackSent, awaitingResubmission, submitted) now fire
on content change OR live events, but not on snapshots with unchanged
content. This fixes two competing requirements:
- Tab refresh: snapshot has same content, skip state reset (preserves
  lastDecision-restored state)
- Folder reactivation: live event has same content, reset state
  (re-enables buttons after feedbackSent)

* Fix dead import, HTML→markdown switching, standalone awaiting overlay

- Remove unused PlannotatorSession type import from session-factory
- Always assign rawHtml in annotate updateContent so clearing works
  when a file switches from --render-html to plain markdown
- Wire awaitingResubmission into CompletionOverlay for standalone and
  legacy tab mode so deny shows a waiting screen instead of nothing

* Fix stale docs: decision cycle, annotate match keys, persistence scope

- overview.md: approve/exit now also cycle (not final), annotate match
  keys include project scope …
backnotprop added a commit that referenced this pull request May 27, 2026
* Add long-running Plannotator daemon runtime

Single daemon process per machine manages session lifecycle, serves
browser UIs at /s/<sessionId>, and exposes control APIs. CLI commands
auto-start the daemon and create sessions via HTTP. Includes session
store, auth tokens, lock files, event broadcasting, and goal-setup
daemon integration.

* Add daemon debug shell and simulator (#738)

* Add long-running Plannotator daemon runtime

Single daemon process per machine manages session lifecycle, serves
browser UIs at /s/<sessionId>, and exposes control APIs. CLI commands
auto-start the daemon and create sessions via HTTP. Includes session
store, auth tokens, lock files, event broadcasting, and goal-setup
daemon integration.

* Add daemon debug shell and simulator

Debug frontend (apps/debug-frontend) served by the daemon as the session
browser shell. Agent simulator TUI (apps/debug-tui) exercises all agent
protocols with concurrent session support. Includes reactive session
dashboard, event log, prominent session action buttons, and goal-setup
scenarios.

* Add daemon WebSocket event hub (#744)

* Add long-running Plannotator daemon runtime

Single daemon process per machine manages session lifecycle, serves
browser UIs at /s/<sessionId>, and exposes control APIs. CLI commands
auto-start the daemon and create sessions via HTTP. Includes session
store, auth tokens, lock files, event broadcasting, and goal-setup
daemon integration.

* Add daemon debug shell and simulator

Debug frontend (apps/debug-frontend) served by the daemon as the session
browser shell. Agent simulator TUI (apps/debug-tui) exercises all agent
protocols with concurrent session support. Includes reactive session
dashboard, event log, prominent session action buttons, and goal-setup
scenarios.

* Add daemon WebSocket event hub

Replace persistent SSE streams with a single WebSocket connection per
frontend instance. Daemon multiplexes session-scoped events (annotations,
agent jobs, lifecycle) through /daemon/ws with subscribe/unsubscribe
messaging. Includes session actions over WebSocket, reconnect/resync,
auth enforcement, and polling fallback.

* Restore goal-setup integration to WebSocket layer

The goal-setup daemon integration from layers 734/738 was lost during
re-squash cascades. This commit restores all missing pieces:

- PluginGoalSetupRequest type and goal-setup action in plugin protocol
- goal-setup case in daemon session factory
- setup-goal CLI subcommand routed through runDaemonSessionRequest
- goal-setup-submit/goal-setup-exit debug frontend actions
- TUI scenarios for interview and facts with fixture bundles
- TUI completion for goal-setup sessions
- Fix getRepoInfo to use session cwd in createGoalSetupSession
- Standardize naming: "goal-setup" everywhere (was "setup-goal" in
  daemon protocol and debug frontend)

* Add daemon subpath exports to server package

The source shim resolves workspace imports through package exports.
Without these entries, `bun apps/hook/server/index.ts` fails to find
daemon modules even though the files exist on disk.

* Complete goal-setup protocol typing and daemon test coverage

- Add PluginGoalSetupResult to PluginActionResult union
- Add goal-setup to PLANNOTATOR_PLUGIN_FEATURES
- Use typed result in CLI instead of cast
- Add session factory tests: interview submit and facts exit

* Fix goal-setup bundle path resolution and plugin feature list

- Resolve relative bundle paths against getInvocationCwd() so
  hook/wrapper invocations find the file in the project directory
- Remove goal-setup from PLANNOTATOR_PLUGIN_FEATURES since it is
  a direct CLI command invoked by agent skills, not a plugin action
  dispatched through the plugin protocol

* Fix hub-client: reconnect after protocol-level error frames

The onerror and onclose socket handlers both called scheduleReconnect(),
but the handleMessage error branch tore down the socket without
scheduling a reconnect — leaving the client permanently dead.

* Add production frontend with initial view (#753)

* Add production frontend app with daemon project registry

Daemon:
- Project registry persisted to ~/.plannotator/projects.json
- Auto-registers projects from session cwd on creation
- GET/POST/DELETE /daemon/projects endpoints
- cwd exposed on DaemonSessionSummary
- project-registry feature flag + 9 unit tests

Frontend (apps/frontend/):
- TanStack Router, Zustand+Immer, Tailwind v4, oxlint/oxfmt
- Daemon API client with project + session creation methods
- WebSocket hub client with event stream + polling fallback
- Landing page: project selector table + Code Review/Archive actions
- Offcanvas sidebar: sessions grouped by mode (matching prototype)
- Add project dialog
- DiffKit theme + prototype shell pattern (bg-muted → card → content)
- 13 shadcn primitives vendored in components/ui/
- Single-file HTML build (viteSingleFile)
- Dev script: daemon + Vite with auto-proxy (scripts/dev-frontend.ts)

* Remove unused shadcn components and hooks

badge, card, dialog, dropdown-menu, label — not imported by any app code.
use-project-actions, use-sessions-by-project — orphaned after sidebar
and landing page rewrites.

* Rename diffkit theme to neutral

* Add active sessions list to landing page

* Embed code review surface in frontend app (#755)

* Copy review-editor and editor as new embeddable packages

packages/plannotator-code-review — copy of packages/review-editor
packages/plannotator-plan-review — copy of packages/editor

Unmodified copies to start. These will be refactored to strip
standalone providers (ThemeProvider, TooltipProvider, Toaster)
and accept session-scoped API context from the frontend shell.
The original packages remain untouched for the legacy single-file
HTML flow.

* Add useSessionFetch hook and SessionProvider

React context that scopes fetch calls to a daemon session.
When inside a SessionProvider, fetch("/api/diff") rewrites to
fetch("/s/:sessionId/api/diff"). Without a provider, returns
the global fetch unchanged.

* Migrate shared hooks to useSessionFetch

Add const fetch = useSessionFetch() to 10 hooks in packages/ui/hooks/.
The shadowed fetch variable routes /api/ calls through the session
context when a SessionProvider is present, and falls back to global
fetch when not.

configStore.ts uses apiFetch (import-based) since it's a class method.

* Migrate code review package to useSessionFetch

Add const fetch = useSessionFetch() to all 11 files in
packages/plannotator-code-review/ that call fetch("/api/...").
The shadowed fetch variable routes calls through the session
context. No fetch call sites were modified — only the function
that provides the fetch was changed.

* Export ReviewAppEmbedded without standalone providers

ReviewApp accepts __embedded prop to skip ThemeProvider,
TooltipProvider, and Toaster (shell provides these). Uses h-full
instead of h-screen when embedded. ReviewAppEmbedded is a named
export that passes the prop. Default export unchanged.

Shell Layout gains TooltipProvider for code review tooltips.

* Mount code review surface in frontend session route

When session.mode === "review", the /s/:sessionId route wraps
ReviewAppEmbedded in a SessionProvider and renders the full
code review UI. Other modes keep the placeholder.

- Added @plannotator/code-review as frontend dependency
- Created App.d.ts type declaration for the code review package
- Added plannotator-ui.d.ts for SessionProvider and ThemeProvider types
- Added PNG module declaration for asset imports
- Fixed settings.ts satisfies type for strict-mode compatibility
- Vite alias resolves to package source for bundling
- TypeScript uses .d.ts for type checking (avoids strict-checking loose package source)

* Fix session surface integration issues

- Add @custom-variant dark to code-review CSS for .light class toggle
- Remove forced theme cookies from main.tsx (use defaultColorTheme prop)
- Clean up document.title on review unmount (restore previous)
- Clean up CSS custom properties on review unmount
- Define __APP_VERSION__ from root package.json in vite config
- Narrow Vite proxy to /s/:id/api/ only (page loads stay with Vite SPA)
- Skip auto-registering temp directory projects in session factory
- Add max-height scroll to project table for overflow
- Add headerLeft prop to ReviewAppEmbedded for global sidebar trigger
- Fix header padding when sidebar trigger is present

* Resolve ~ to home directory in addProject

* Fix duplicate Tailwind build causing style conflicts

The code review's index.css had its own @import "tailwindcss" with
separate @source and @theme directives, producing a second Tailwind
build that competed with the frontend's styles.css. This caused
buttons, dialogs, and other components to render with wrong styles.

Fix: remove Tailwind, theme import, and @source from the code
review's index.css (keep only dockview + custom CSS). Add @source
directives to the frontend's styles.css to scan the code review
package and shared UI components. One Tailwind build, one theme,
no conflicts.

* Clean up CSS: remove duplications, fix keyframe collision

- Rename code review's @keyframes fade-in to cr-fade-in to avoid
  collision with the frontend's fade-in (different animation)
- Remove redundant panel scrollbar rules from styles.css (global
  scrollbar rules already cover all elements)
- Add comment noting intentional scrollbar override of theme.css
- Single Tailwind build, single theme import, zero duplications

* Make sidebar logo link to homepage

* Match sidebar trigger hover style to code review buttons

* Fix sidebar session labels and badge overlap

- Strip machine-generated prefixes from session labels (plugin-review-,
  claude-code-, etc.) to show just the project/PR name
- Add pr-7 padding to menu buttons so truncation ellipsis doesn't
  overlap with the status badge dot
- Full label still visible on hover via tooltip

* Fix formatting

* Fix test suite: restore globalThis.fetch after useSessionFetch tests

The useSessionFetch test replaced globalThis.fetch with a mock in
beforeEach but never restored it. Other test files running in the
same process (daemon runtime tests) got the mock instead of real
fetch, causing JSON parse failures on "ok" responses.

Added afterEach to restore the original fetch. All 1,463 tests pass.

* Add React Activity keep-alive for session surfaces

Sessions now stay alive when the user navigates away. Instead of
unmounting and remounting on each navigation, visited sessions are
hidden via React's <Activity mode="hidden"> and restored instantly
when the user returns.

- AppStore tracks visitedSessions (keyed by session ID) and activeSessionId
- Layout renders all visited sessions in <Activity> wrappers — only
  the active one is visible, the rest are hidden but preserved
- Session route registers its bootstrap data with the store and
  renders nothing — Layout owns the rendering
- Landing page deactivates the current session when navigated to
- SessionSurface component extracted to handle mode-based rendering

Effects clean up when hidden (WebSocket subs, timers stop) and
restart when visible. DOM, React state, scroll position, annotations,
dock layout all survive navigation.

* Fix: show error state when session load fails during active session

* Fix: deactivate session from Layout instead of inside hidden Activity

* Dispatch resize event when session becomes visible to fix Pierre diffs

* Replace Activity with visibility:hidden for session keep-alive

Activity uses display:none which breaks Pierre diffs' virtualizer —
it measures the container at zero height and renders no content.
When made visible again, the virtualizer doesn't recalculate.

Switch to visibility:hidden + position:absolute which preserves
element dimensions. Pierre diffs keeps its measurements, Dockview
keeps its layout. The tradeoff is effects don't pause for hidden
sessions, but that's preferable to broken diffs.

* Fix: derive landing page visibility from route match synchronously

* Set router pendingMs to 0 to eliminate navigation delay

* Auto-restore code review drafts silently, remove restore dialog

Drafts are keyed by diff content hash on the server. Same diff = same
draft. When a draft exists on mount, it's now restored automatically
with a subtle toast notification instead of a blocking dialog.

- useCodeAnnotationDraft takes an onRestore callback instead of
  returning draftBanner/restoreDraft/dismissDraft
- ConfirmDialog for draft restore removed from App.tsx
- Toast shows "Restored N annotations" on auto-restore

* Fix flash of unstyled sidebar on page load

* Style Toaster with theme tokens

* Add rounded top-left corner to embedded code review

* Move rounded corner to Layout session container and clip overflow

* Fix curved border: move border from sidebar to session container

* Only show curved border when sidebar is open

* Fix: use useSidebar hook for conditional curved border

* Fix project registry: key by cwd, defer registration until session succeeds

- registerProject now finds existing entries by cwd (not name), so two
  repos with the same name get separate entries
- removeProject takes cwd instead of name for unambiguous deletion
- Server DELETE /daemon/projects now accepts JSON body with cwd
- Session factory defers registerProject until after session creation
  succeeds, avoiding phantom entries from failed requests
- Hub-client: add missing scheduleReconnect after protocol error frames

* Embed plan review surface and fix cross-surface issues (#758)

* Migrate missed shared UI components to useSessionFetch

8 component/hook files in packages/ui/ still had bare fetch('/api/...')
calls that weren't caught during the code review migration:

- Settings.tsx, InlineMarkdown.tsx, AttachmentsButton.tsx, ExportModal.tsx
- settings/HooksTab.tsx, goal-setup/GoalSetupSurface.tsx
- plan-diff/PlanDiffViewer.tsx, hooks/useLinkedDoc.ts

Also set window.__PLANNOTATOR_API_BASE__ in SessionProvider so
non-hook consumers (apiPath for <img src> in ImageThumbnail) get
session-scoped paths.

* Migrate plan review App.tsx to useSessionFetch + title cleanup

* Auto-restore plan review drafts silently, remove restore dialog

Rewrite useAnnotationDraft with onRestore callback pattern (same as
useCodeAnnotationDraft). Legacy tuple format preserved. Toast on
restore. ConfirmDialog removed from plan review App.tsx.

* Export PlanAppEmbedded without standalone providers

Strip ThemeProvider, TooltipProvider, Toaster when __embedded.
h-full instead of h-screen. headerLeft prop passed through to
AppHeader for sidebar trigger. App.d.ts type declaration added.

* Strip Tailwind from plan review CSS, add @source to frontend

* Remove dead toast animation classes that conflicted with tailwindcss-animate

* Add visibility guards to keyboard handlers on both surfaces

When keep-alive hides a surface with visibility:hidden, its keyboard
listeners on window/document stay active. Without guards, Mod+Enter
on the visible code review would also fire the hidden plan review's
submit handler.

Both App.tsx files now check getComputedStyle(rootRef).visibility
at the start of every keyboard handler. If hidden, return early.

* Wire plan review surface into frontend app

All session modes now render production surfaces:
- review → ReviewAppEmbedded
- plan, annotate, archive, goal-setup → PlanAppEmbedded

Added @plannotator/plan-review dependency, Vite aliases, and styles.
SessionSurface simplified — review gets code review, everything else
gets plan review (which determines its mode from /api/plan response).

* Fix: pass session fetch to submitGoalSetup helper

* Replace full-screen completion overlay with inline banner in embedded mode

When running inside the frontend app (__embedded), the CompletionOverlay
blocked the entire viewport including the sidebar. Now:
- Embedded surfaces show a CompletionBanner (colored bar below the header)
- Action buttons hide after submission (plan review hides via AppHeader
  submitted prop, code review hides via !submitted guard)
- Standalone mode keeps the original full-screen overlay with auto-close
- No window.close() fires in embedded mode since useAutoClose lives
  inside CompletionOverlay which is skipped

* Serve production frontend from daemon, debug shell via env var only

The daemon now serves the production frontend HTML (apps/frontend/) at
session URLs. The debug frontend is only loaded when PLANNOTATOR_DEBUG_SHELL=1,
read from disk at runtime — never bundled in the compiled binary.

* Session lifecycle, worktree projects, and directory picker (#759)

* Add frontend visibility and focus reporting to daemon WebSocket

The daemon now tracks per-connection client state: tab visibility and
active session ID. The frontend reports these via a new `client-state`
WebSocket message type on connect, visibility change, and route
navigation. The event hub exposes `getFrontendState()` which returns
whether any frontend is connected, any tab is visible, and which
sessions are actively being viewed.

This is the foundation for smart session opening — the daemon will use
this state to decide between opening a browser and sending an in-app
notification.

* Move browser opening from CLI to daemon with smart presentation

The daemon now decides how to present new sessions based on frontend
connection state. If a frontend tab is connected and visible, it sends
a notification event (no new tab). If no frontend is connected or the
tab is backgrounded, it opens a browser.

- Add presentSession() to daemon runtime with decision matrix
- Add legacyTabMode config: always opens browser when enabled
- Remove handleServerReady/handleReviewServerReady/handleAnnotateServerReady
  calls from CLI hook — the daemon handles it
- Add browserAction field to POST /daemon/sessions response
- CLI sessions --open command kept as-is (explicit user action)

* Add session notification toasts and keep completed sessions in sidebar

Phase 3: When the daemon notifies instead of opening a browser, it
publishes a session-notify event. The frontend shows an auto-dismissing
toast (8s) with mode, project, and an Open button. Toasts are gated on
document.visibilityState — queued when tab is backgrounded, flushed on
return.

Phase 4: Completed sessions no longer disappear from the sidebar.
The terminal-status splice in event-store was removed — sessions now
update in-place with their new status. Only explicit session-removed
events cause removal.

* Collapse sidebar on direct session links, open on landing page

SidebarProvider defaultOpen is now based on the initial route: collapsed
when loading /s/:id directly, open when loading /. Users can still
toggle the sidebar manually after the initial render.

* Add disk-backed session snapshots for completed session persistence

When a session completes, the daemon writes a content snapshot to
~/.plannotator/sessions/<id>.json before disposing the handler.
Snapshots capture the plan markdown, diff data, or annotation content —
everything the frontend needs to render the session read-only.

The daemon server serves snapshot content when a request hits a disposed
or missing session. This means completed sessions survive page refresh
and daemon restart. Snapshots are capped at 5MB to avoid oversized
review diffs.

Each session type provides a snapshot callback in the factory that
closes over its content at creation time.

* Wire legacy tab mode through server config to surface overlays

When legacyTabMode is set in config.json, the daemon always opens a
browser (already wired in Phase 2), and both surfaces render the
full-screen CompletionOverlay with auto-close instead of the inline
CompletionBanner — even in embedded mode. This preserves the old
tab-per-session + auto-close experience for users who prefer it.

The legacyTabMode flag flows through getServerConfig() → /api/plan
and /api/diff responses → surface state.

* Document legacyTabMode config setting in AGENTS.md

* Load session snapshots from disk on daemon startup

Completed sessions from previous daemon runs now appear in the sidebar
immediately. On startup, the daemon reads all snapshots from
~/.plannotator/sessions/ and creates completed records in the store.
These records have no handlers but serve content via the snapshot
fallback in the server.

* Add worktree-aware project hierarchy to landing page

Projects that are git worktrees auto-detect their parent repo and nest
underneath it. The landing page shows projects as collapsible tree nodes
— expanding a project fetches its worktrees via git worktree list and
shows them with branch names.

- DaemonProjectEntry gains optional parentCwd and branch fields
- addProject detects worktrees via git rev-parse --git-common-dir and
  auto-registers the parent repo
- New GET /daemon/projects/worktrees?cwd= endpoint lists worktrees
- Frontend ProjectTable refactored to collapsible tree with worktree
  children, selection passes cwd to session creation
- Session labels include branch name when created from a worktree cwd

* Fix parent project registration dedup and add branch to all session labels

- Parent auto-registration now adds directly to the flat array instead
  of calling registerProject, avoiding name-based dedup that could
  overwrite unrelated projects with the same derived name
- All session modes (annotate, archive, goal-setup) now include the
  branch name in their labels, matching plan and review

* Fix blank page when adding a worktree project

When adding a directory that is a worktree, the daemon auto-creates
the parent project. But the store only added the returned entry (the
worktree child), leaving the parent missing from the frontend state.
Since the worktree has parentCwd set, the topLevel filter found zero
entries and nothing rendered.

Fix: when the added entry has parentCwd, re-fetch the full project
list so the auto-created parent is included.

* Filter temp directory worktrees from project listing

* Sort worktrees by last activity (index mtime > commit time > dir mtime)

Each worktree gets a lastActive timestamp derived from:
1. Git index file mtime (updates on add, checkout, stash — reflects
   active work even without commits)
2. Last commit timestamp (fallback if index unavailable)
3. Directory mtime (fallback for brand new worktrees)

All three signals are cross-platform (fs.statSync + git log).
Worktrees are sorted most-recently-active first.

* Fix toast: skip for frontend-initiated sessions, clean label, fix colors

- Don't call presentSession for origin "plannotator-frontend" — the
  frontend already navigates to the session it just created
- Strip internal prefixes from session label in toast description,
  suppress description when it matches the project name
- Style toast action button with theme primary colors
- Widen project selector to max-w-2xl
- Remove opacity-50 from worktree icons

* Replace manual project input with searchable directory picker

The Add Project dialog is now a searchable directory browser inspired
by OpenCode's project picker:

- Type a path (~/work/, /Users/...) and see child directories listed
- Arrow keys to navigate, Enter to select, Tab to navigate into a dir
- Recent projects shown at top for quick re-selection
- ~ expansion handled server-side
- Hidden directories (.git, .cache, etc.) filtered out
- 150ms debounced directory listing for responsive typeahead

New daemon endpoint: GET /daemon/fs/list?path= returns child
directories for any path with ~ expansion.

* Only show worktree chevron when worktrees exist, add Worktrees label

- Fetch worktrees eagerly on mount instead of on expand, so the chevron
  only appears when there are actual worktrees to show
- Projects without worktrees get a plain spacer instead of the chevron
- Add a "Worktrees" section label above the expanded list

* Fix: add missing useEffect import in LandingPage

* Fix project row layout: chevron to right, remove branch icons, align folders

- The whole project row is now one selectable button with folder icon
  consistently at the left
- Worktree expand chevron moved to the right end, only visible on hover
  area — doesn't block the selectable feel
- Removed all GitBranch icons from worktree entries — just indentation
  and the branch/worktree name
- Projects without worktrees have no chevron at all, no spacer needed

* Add ASCII art Plannotator banner to landing page

* Increase ASCII banner opacity to 70%

* Remove redundant Plannotator label from landing page nav

* Make Add Project buttons more visible

* Design audit: fix color contrast, remove opacity abuse, fix a11y

Applied Emil's design engineering principles:

- Interactive rows use text-foreground by default, not text-muted-foreground.
  Muted text is only for metadata (paths, timestamps, section labels).
  Items should look clickable at rest, not disabled.
- Replaced all opacity-60 on secondary text with text-muted-foreground
  (semantic token instead of raw opacity)
- Borders use border-border (full opacity) not border-border/40 — borders
  should be visible enough to serve their structural purpose
- Removed transition-colors (was a transition: all risk) — hover states
  are instant by design for frequently-used UI
- Changed expand <span role="button"> to proper <button> with aria-label
- Added select-none to ASCII banner (decorative element)
- Used proper ellipsis character (…) instead of ...
- Selected state uses bg-primary/10 instead of bg-primary/8 for clearer
  feedback

* Design audit: fix directory picker dialog contrast and accessibility

Applied Emil's design engineering principles to AddProjectDialog:

- Interactive rows use text-foreground by default, not text-muted-foreground
- Removed all /50 and /60 opacity modifiers on borders and text —
  borders use border-border, metadata uses text-muted-foreground
- Input uses text-base (16px) to prevent iOS zoom on focus, with
  sm:text-[13px] for desktop
- Fixed nested <button> inside <button> (invalid HTML) — directory
  rows now use a <div> wrapper with two sibling buttons
- Navigate-into chevron is always visible (was opacity-0 hover-only,
  violating "never rely on hover for core functionality")
- Added aria-labels to close button and navigate buttons
- Removed transition-colors from interactive rows (speed over delight)
- Used proper ellipsis character (…) in placeholder and loading text
- Removed unused displayName prop from DirectoryRow

* Add goal files for session lifecycle and worktree projects

* Unified settings, performance optimizations, and Zustand review store (#766)

* Add shadcn Dialog and Tabs primitives to frontend

Dialog wraps @radix-ui/react-dialog with DiffKit prototype styling:
bg-black/55 backdrop, rounded-2xl card, entrance animation with
reduced-motion support. Sized at max-w-4xl for the settings dialog.

Tabs wraps @radix-ui/react-tabs with vertical orientation support.
Tab triggers use text-foreground by default (per design audit).

Also backlogged AddProjectDialog migration to use these primitives.

* Add unified settings dialog with vertical tabs layout

The frontend app now has a single app-level settings dialog accessible
via Cmd+, (Ctrl+, on Windows/Linux) or the sidebar Settings button.
It uses a wide Radix Dialog (896px) with vertical tabs grouped into
four sections: General, Plan Review, Code Review, and Integrations.

Tab content components are imported from the shared UI package:
- ThemeTab, KeyboardShortcuts, HooksTab (already separate files)
- GitTab, ReviewDisplayTab, CommentsTab (newly exported from Settings.tsx)
- SegmentedControl, ToggleSwitch (extracted to settings/shared.tsx)

The existing per-surface Settings modals remain for standalone mode.

* Wire per-surface gear icon to unified settings dialog when embedded

Both PlanAppEmbedded and ReviewAppEmbedded now accept an onOpenSettings
callback. When provided (embedded mode), the gear icon opens the
app-level unified settings dialog instead of the per-surface modal.
Standalone mode is unchanged — no callback means the built-in modal
opens as before.

* Complete settings dialog: all 4 sections, 13 tabs, AI tab

Dialog now has all four sections from the plan:
- General: General (placeholder), Theme, Shortcuts
- Plan Review: Display (placeholder), Saving (placeholder), Labels
  (placeholder), Hooks
- Code Review: Git, Display, Comments, AI
- Integrations: Files (placeholder), Obsidian (placeholder)

7 tabs have full content (Theme, Shortcuts, Hooks, Git, Review Display,
Comments, AI). 6 tabs show placeholder text indicating what settings
will go there — these need the inline content extracted from the
monolithic Settings.tsx in a follow-up.

Reduced motion is already handled globally via theme.css and
tailwindcss-animate.

* Extract settings tabs: General, PlanGeneral, PlanDisplay, Saving

Extracted four tab components from the Settings.tsx monolith into
self-contained files in packages/ui/components/settings/:

- GeneralTab: identity + auto-close (shared across all modes)
- PlanGeneralTab: permission mode (Claude Code) + agent switching
  (OpenCode) — plan-specific, moved out of General
- PlanDisplayTab: TOC, sticky actions, plan width with preview
- SavingTab: plan save toggle/path, default notes app, integration
  quick-nav buttons with onNavigateTab callback

Dialog now has 16 tabs across 4 sections. 11 have full content,
5 remain as placeholders (Labels, Files, Obsidian, Bear, Octarine).

Tab grouping updated per design review:
- Permission Mode and Agent Switching moved from General to Plan Review
- Bear and Octarine added to Integrations section

* Complete all 16 settings tabs — zero placeholders remaining

Extracted remaining tab components from the Settings.tsx monolith:
- LabelsTab: quick annotation labels with emoji, color, AI tips, shortcuts
- FilesTab: file browser enable + directory list management
- ObsidianTab: vault detection, path/folder/filename config, auto-save,
  vault browser toggle
- BearTab: custom tags, tag position, auto-save
- OctarineTab: workspace, folder, auto-save

All 16 tabs across 4 sections now render full settings content:
- General (3): General, Theme, Shortcuts
- Plan Review (5): General, Display, Saving, Labels, Hooks
- Code Review (4): Git, Display, Comments, AI
- Integrations (4): Files, Obsidian, Bear, Octarine

Each extracted tab is self-contained — manages its own state reads/writes
via the existing utility functions and configStore.

* Fix critical settings dialog issues found in eight-agent audit

Critical fixes:
- AISettingsTab now receives required props (providers, selectedProviderId,
  onProviderChange). Fetches /api/ai/capabilities when dialog opens.
- PlanGeneralTab now receives origin from active session so Permission
  Mode and Agent Switching tabs actually render.

Functional fixes:
- Stale state on re-open: mountKey increments when dialog opens, forcing
  Radix Tabs to re-mount tab content so useState initializers re-read
  fresh cookie values.
- Cmd+, now toggles (close if open, open if closed) instead of only
  opening.

Remaining items deferred: Tater Mode toggle in PlanDisplayTab, ThemeTab
preview mode, diffTabSize in ReviewDisplayTab, content gaps in Obsidian/
Octarine/Saving helper text.

* Self-review fixes: stale AI provider, type safety, ObsidianTab fetch

- Re-read aiProviderId from cookies on each dialog open (was stale
  if changed via per-surface settings between opens)
- Remove `as any` cast on origin prop — PlanGeneralTab now accepts
  Origin | string | null
- ObsidianTab no longer depends on useSessionFetch (breaks outside
  SessionProvider). Uses fetchFn prop with globalThis.fetch default.
  Vault detection gracefully falls back to manual input if fetch fails.

* Fix critical issues from second eight-agent audit

1. AI capabilities fetch: route through active session API path
   (/s/:id/api/ai/capabilities) instead of broken root /api/ path.
   Skips fetch when no session is active — AI tab shows empty state.

2. Z-index: dialog overlay and content bumped from z-50 to z-[110],
   above CompletionOverlay's z-[100]. Settings dialog now renders
   on top of everything except toasts.

3. Keyboard shortcuts: unified dialog now shows BOTH plan and code
   review shortcuts with section labels, not just plan mode.

* Full parity: every setting from original Settings.tsx now in extractions

Parity fixes:
- Tater Mode toggle added to PlanDisplayTab (with optional props)
- diffTabSize stepper added to ReviewDisplayTab (was only in popover)
- SavingTab: description text under Default Save Action select restored
- SavingTab: auto-reset effect when integration becomes unavailable
- ObsidianTab: filename format variables hint line restored
- ObsidianTab: filename separator helper text restored
- ObsidianTab: frontmatter preview block restored
- OctarineTab: workspace and folder helper text restored
- LabelsTab: tip editor onFocus cursor handler restored
- PlanGeneralTab: agent warning SVG icon restored
- PlanGeneralTab: stale agent "not found" disabled option restored

Resource cleanup:
- Hidden <Settings> no longer mounts in embedded mode — both plan
  review (skipBuiltInSettings prop) and code review (guard on
  externalOpenSettings) skip rendering when the unified dialog handles it

* Add daemon global settings API — settings work without active sessions

Five new daemon endpoints for settings that previously required a
session-scoped API path:

- GET/POST /daemon/config — read/write ~/.plannotator/config.json
- GET /daemon/git/user — detect git config user.name
- GET /daemon/vaults — detect Obsidian vaults
- GET /daemon/hooks/status — hook file status + PFM reminder

All are thin wrappers around existing functions in packages/shared/
with no session context needed.

Frontend routing: added globalFetchBase fallback to apiFetch. When
__PLANNOTATOR_API_BASE__ is unset (landing page, no session), config
writes route through /daemon/config. Session base still takes
precedence when set.

Settings dialog: fetches gitUser from /daemon/git/user on open,
initializes configStore from /daemon/config, passes daemon-routed
fetch to ObsidianTab and HooksTab so vault detection and hook status
work from the landing page.

* Fix ObsidianTab vault URL: accept both /daemon/vaults and /daemon/obsidian/vaults

* Fix theme preview mode and slow theme switching with keep-alive

Theme preview: wired onPreview to ThemeTab in unified dialog. Clicking
"Launch Preview Mode" hides the dialog and opens a bottom drawer with
a compact ThemeTab. Escape or Done button returns to the dialog.

Theme switching performance: hidden keep-alive surfaces now skip color
transitions entirely via [style*="visibility: hidden"] * { transition-
duration: 0s }. Only visible content animates, eliminating the lag from
thousands of elements transitioning simultaneously.

* Add performance scope documents: global keyboard registry + configStore Zustand migration

* Move performance scope docs to backlog directory

* Document performance findings: 15 identified issues ranked by impact

* Update performance findings: 22 issues across 4 tiers, prioritized for normal use

* Add 3 new findings from agent sweep: layout thrashing, context invalidation, getComputedStyle

* Add memory leak findings: draft maps, uncancelled rAF, WS subscription accumulation

* Final sweep: 39 total findings incl bundle size, static imports, backdrop-blur, monolith components

* Performance Phase 1: stop hidden sessions from degrading active session

Fix 1 — React.memo on SessionSurface: Layout re-renders (sidebar
toggle, dialog open, session switch) no longer cascade into every
mounted session's component tree. The bootstrap prop is reference-
stable so memo always bails out for hidden sessions.

Fix 3 — Scope document.querySelector to session container:
- StickyHeaderLane: new containerRef prop, queries within the session's
  root instead of the global document. Prevents hidden sessions from
  attaching ResizeObservers to the active session's elements.
- TableOfContents: scopes block-id query to scrollViewport instead of
  document. Prevents hidden session TOC clicks from scrolling the
  active session.
- PlanCleanDiffView: new containerRef prop for diff-block-index query.
  Prevents hidden session annotation selections from highlighting
  blocks in the active session.

Fix 4 — Gate document-level mutations on visibility:
- CSS custom properties (--diff-font-override etc.) now set on
  rootRef.current instead of document.documentElement. Each session
  scopes its own font/tab-size vars. No more global style recalc
  from hidden sessions writing to :root.
- document.title gated with isVisible() in both surfaces. Hidden
  sessions no longer overwrite the active session's title.

* Self-review fixes: PlanCleanDiffView self-scoping, font-size CSS selectors

- PlanCleanDiffView now has its own rootRef on its wrapper div, so the
  querySelector for diff-block-index elements scopes to its own tree
  without needing containerRef threaded from the parent
- CSS font-size-override selectors changed from :root[style*="..."]
  to .has-font-size-override class — the :root selectors broke when
  CSS vars moved from document.documentElement to rootRef.current
- Cleanup removes the class on effect teardown

* Fix tab size not applying to diffs and title not updating on session switch

Tab size: moved from CSS variable on rootRef (which didn't penetrate
Pierre's shadow DOM) to direct unsafeCSS injection via usePierreTheme,
matching how font-family and font-size already work.

Title: replaced static isVisible callback (empty deps, never re-triggered)
with useSessionVisible hook that uses MutationObserver to reactively
detect when Layout.tsx toggles the parent's visibility style.

* Use content-visibility: hidden on inactive sessions to skip layout/paint

The browser was doing full layout and style recalc on every hidden
session's DOM (60-120k nodes with 3+ sessions). content-visibility: hidden
tells the browser to skip all rendering work on inactive subtrees while
preserving DOM state, scroll positions, and React component state.

Also runs oxfmt on frontend files to fix pre-existing formatting drift.

* Scroll to annotation line when clicking sidebar comment in all-files view

Previously only scrolled to the file header. Now finds the
[data-annotation-id] element and scrolls to it directly, with a
retry loop for lazy-mounted diffs and file header fallback.

* Add Zustand review store with per-session scoping

Introduces a Zustand store for the code review surface, replacing
annotation useState calls with store state. Panels can now subscribe
to specific slices via selectors instead of re-rendering on every
unrelated state change through the ReviewStateContext god object.

Phase 1: annotations slice (hot path), diff-options slice, files slice.
DiffHunkPreview migrated as first panel consumer. ReviewStateContext
stays alive for unmigrated panels during the transition.

* Fix deleted annotations reappearing after refresh

When all annotations were deleted, the draft auto-save skipped the
save entirely, leaving the stale draft on the server. On refresh it
restored the deleted annotations. Now tracks whether a draft exists
on the server and issues a DELETE when annotations drop to zero.

* Stabilize callbacks via getState() and memo FileTreeNodeItem

Migrate files and activeFileIndex to store-owned state (remove sync
bridge). Stabilize 7 callbacks + keyboard handler by reading
pendingSelection, files, activeFileIndex, externalAnnotations,
selectedAnnotationId, and allAnnotations from storeApi.getState()
instead of closing over them. Callbacks now have stable references
that don't recreate on every hover or file switch.

Wrap FileTreeNodeItem in React.memo — 531 instances were re-rendering
29 times each (15,399 total, 1,202ms) due to parent cascades.

Baseline: 55% of render time (4,825ms) was full-tree cascades
triggered by ReviewApp. These changes eliminate the callback
instability that caused most of those cascades.

* Add React.memo to shared UI components and stabilize plan review props

Wrap BlockRenderer, InlineMarkdown, CodeFileLink, and TableBlock in
React.memo. These render hundreds of times per plan/review session
due to parent cascades — profiler showed 778 BlockRenderer renders
and 888 InlineMarkdown renders in a single session. Memo prevents
re-renders when the block content and callbacks haven't changed.

Stabilize plan review App.tsx props to Viewer: replace inline arrow
functions (onPlanDiffToggle) with useCallback, replace inline
linkedDocInfo object with useMemo. These created new references on
every render, defeating any memo on child components.

* Fix backLabel reference-before-initialization in plan review

The linkedDocInfo useMemo was placed above the backLabel variable
it depends on, causing a crash on plan session load. Moved the
useMemo below backLabel's declaration.

* Unified frontend: Git Dashboard, PR listing, sidebar redesign, and settings cleanup (#765)

* Add PR listing, multi-select launch, stacked PR grouping, and project management to frontend landing page

- Fix worktree project registration: session factory now uses addProject() instead of registerProject() so worktrees nest under parent projects
- Fix directory typeahead: handle partial paths by splitting into parent dir + prefix filter
- Add daemon endpoint GET /daemon/projects/prs for listing PRs via GitHub/GitLab CLI
- Add PR list with stacked PR detection using headBranch/baseBranch chain matching
- Add multi-select for PRs and worktrees with parallel session launch via Promise.allSettled
- Restructure worktrees from badge pills to row layout matching PR list style
- Add tabbed expansion panel (PRs default, Worktrees) under each project
- Add shared formatSessionLabel() for clean display names across sidebar, landing page, and toasts
- Add right-click context menu to remove projects with session cancellation and history cleanup
- Add PullRequestIcon with state-based coloring (open/merged/closed)

* Clean up settings dialog: proper header, version info, compact display tab layout

- Replace absolute-positioned close button with dedicated header bar to prevent toggle overlap
- Remove settings cog icon, add version number and send feedback link
- Rewrite Code Review Display tab with grouped sections (Typography, Layout, Options)
- Replace font size slider with +/- stepper matching tab size pattern
- Make segmented controls inline with labels for compact layout
- Add live preview showing both font family and size together
- Fix Theme tab spacing: add divider and gap between Mode and Theme sections
- Move Line Backgrounds toggle to end of Options (sub-setting at bottom)

* Git dashboard, settings cleanup, sidebar redesign, and PR data pipeline

- Add PRDetailedListItem type and fetchPRDetailedList for dashboard-specific PR data
- Add daemon endpoint GET /daemon/projects/prs/detailed with 30s cache
- Add git-dashboard-store (Zustand) with parallel multi-project fetch and dedup
- Build Git Dashboard with PRRow, PRGroup, MetricCards components
- Dashboard groups PRs into Open/Draft/Recently Merged with scroll-to navigation
- Clicking a PR row launches a review session via createReviewSession
- Add CSS translateX slide transition between landing page and dashboard
- Clean up settings dialog: proper header bar, version info, send feedback link
- Rewrite Code Review Display tab with grouped sections and compact inline controls
- Fix Theme tab spacing between Mode and Theme sections
- Redesign sidebar: sprite mascot header, Instrument Sans brand font, version display
- Remove session status dots/checkmarks, move mode icons to group headers
- Compact sidebar session rows (text-xs, h-7) with alignment spacers
- Embed sidebar trigger in landing page card instead of dedicated nav bar
- Add right-click context menu for project removal with session/history cleanup
- Fix directory typeahead prefix filtering for partial path input
- Fix worktree project registration via addProject() instead of registerProject()
- Add multi-select session launch with parallel Promise.allSettled
- Add stacked PR grouping via headBranch/baseBranch chain detection
- Add shared formatSessionLabel() for clean display across sidebar/landing/toasts
- Deduplicate PRs across projects sharing the same remote

* Add hover-triggered peek sidebar when sidebar is closed

- Extract AppSidebarContent for reuse between real sidebar and peek
- SidebarPeek renders a floating panel on left-edge hover (80vh, centered)
- Backdrop overlay fades in at bg-black/30 behind the panel
- Panel slides in/out at 150ms, backdrop at 200ms
- Uses bg-sidebar token for consistent background with real sidebar
- Remove session tooltip hovers
- Only active when sidebar is collapsed

* Remove unused asset files (banners, mascot, sprite backups, index.html)

* Address PR review findings: security fix, dedup, lazy loading, error surfacing

- Fix path traversal: sanitize project name before use in rmSync path
- Switch project deletion from name-based to cwd-based identification
- Deduplicate archive launches by cwd when multiple selections share a project
- Gate dashboard PR fetch on visibility (only fetch when dashboard slide is active)
- Gate worktree fetch on expanded state (don't shell out until user expands)
- Track project count in dashboard store for cache invalidation on add/remove
- Surface auth and CLI errors in dashboard instead of showing misleading empty state
- Consolidate duplicate PR ref resolution into single handler for both endpoints
- Delete unused carousel.tsx and remove motion dependency
- Move @radix-ui/react-context-menu from devDependencies to dependencies

* Tighten path sanitization: reject dot-only names in history cleanup

* Fix review findings: timer leak, stale tabs, blank dashboard, clear on remove, path guard

- Fix SidebarPeek timer leak: clear existing timeout before setting new one in hide()
- PR tabs now refetch after 30s instead of being permanently cached
- Dashboard isEmpty derived from visible groups, not raw array (fixes blank state for closed-only repos)
- Dashboard clears stale PRs when all projects are removed
- Defense-in-depth: verify resolved rmSync path is inside history root before deleting
- Fix handleRemove missing project.cwd in useCallback dependency array

* Move tater mode to configStore and fix formatting from L10 merge

Tater mode was useState inside plan review App.tsx — unreachable from
the unified settings dialog. Now lives in the global configStore as a
cookie-backed setting. PlanDisplayTab reads it directly via
useConfigValue, no props needed from AppSettingsDialog.

Also runs oxfmt on files from the L10 merge (git dashboard, sidebar,
settings dialog).

* Address PR review findings: stale closures, dead code, missing deps

- Fix stale closure in applyPRResponse: read currentPath before setFiles
- Deduplicate allAnnotations: App.tsx useMemo now calls selectAllAnnotations
- Remove dead fields from files slice (viewedFiles, stagedFiles, reviewBase,
  activeDiffBase and their setters — never called, data still flows through
  ReviewStateContext)
- Wrap default ReviewApp export in ReviewStoreProvider
- Add missing daemon endpoints to AGENTS.md
- Add dependency array to SavingTab useEffect (was firing every render)
- Add activeSessionId to AppSettingsDialog AI capabilities fetch deps

* Fix fetchDiffSwitch stale closure + unstable deps, correct AGENTS.md allowlist

- Read currentPath from getState() before setFiles in preserveFile
  branch (same pattern as applyPRResponse fix)
- Remove files and activeFileIndex from fetchDiffSwitch dep array —
  last unstable callback, now reads from getState() consistently
- Fix AGENTS.md: POST /daemon/config allows conventionalComments and
  conventionalLabels, not legacyTabMode and verifyAttestation

* Fix L10 bugs: stale selections, PR error clearing, dashboard cache

- Clean up selections when projects are removed — prevents stale
  selections from enabling launch buttons for deleted projects
- Clear PR error on successful fetch — errors no longer stick after
  the user fixes their CLI setup
- Dashboard cache invalidates on project identity (cwd set), not just
  count — swapping projects within the same count now triggers refresh

* Add legacy tab mode toggle to settings dialog

Adds "Open sessions in new tabs" toggle to General settings. When
enabled, every session opens in a separate browser tab with the
full-screen completion overlay and auto-close, like the classic
Plannotator experience.

The daemon already supports this via legacyTabMode in config.json —
this just adds the UI toggle and wires it through POST /daemon/config.

* Add remaining backlog items: GitLab detection, stack splitting, configStore migration, keyboard registry

* Session persistence: denied sessions stay alive for resubmission (#770)

* Add session persistence foundation: suspend, reactivate, awaiting-resubmission

Protocol: add "awaiting-resubmission" status (non-terminal), add
"session-revision" event family, bump protocol version to 2.

Session store: add suspend() (resolves waiters WITHOUT disposing
resources — session stays alive), reactivate() (transitions back
to active), and matchKey field on records.

Server: skip the 2-second deletion timer on result endpoint for
awaiting-resubmission sessions — they need to stay alive for the
agent to resubmit.

* Add cycle-based decision model and updateContent to plan server

Replace one-shot resolveDecision with a cycle system — each deny
resolves the current cycle and starts a new one. Approve resolves
the final cycle. Agent-originated sessions return awaitingResubmission
in the deny response.

Add updateContent(newPlan) method that updates plan content, saves
to version history, resets draft state, and publishes a
session-revision event via the WebSocket hub.

Add slug and getSnapshot to PlannotatorSession interface so the
factory can match resubmissions and serve correct snapshots.

Add session-revision to SessionEventFamily type.

* Add session matching, persistent decision loop, and CLI support

Session factory: add sessionRefs registry and findAwaitingSession()
matching by matchKey. Plan sessions compute matchKey as
plan:project:slug. On resubmission, existing awaiting session is
matched and reactivated instead of creating a new one.

Add registerPersistentDecision() — loops on waitForDecision(),
suspending on deny and completing on approve. Replaces one-shot
registerSessionDecision for plan sessions.

Fix unhandled promise rejection: add .catch() to disposed promise
in both registerSessionDecision and registerPersistentDecision.

CLI: accept "awaiting-resubmission" as valid non-error status so
denied sessions output feedback and exit 0 instead of failing.

* Add awaiting-resubmission UI state to plan review

Extend CompletionBanner with 'awaiting' variant — amber spinner with
"Feedback sent — waiting for agent to revise..." message and optional
cancel button.

Plan review deny handler now checks response for awaitingResubmission
flag. When true, shows the awaiting banner instead of the completion
overlay.

Subscribe to session-revision events via daemon WebSocket. When the
agent resubmits and the session reactivates, the event carries the
new plan content. The UI updates: markdown refreshes, previousPlan
updates for diff, all annotations clear, awaiting state resets.

* Document session persistence: AGENTS.md and backlog updates

Add session persistence and resubmission section to AGENTS.md
covering the awaiting-resubmission status, matchKey matching, and
session-revision event family.

Update backlog: mark #3 (live plan updates) and #4 (session
persistence after completion) as done.

* Self-review fixes: operator precedence bug and sessionRefs cleanup

Fix operator precedence in registerPersistentDecision catch handler —
was evaluating as (A && B) || C instead of A && (B || C).

Extend findAwaitingSession to prune completed/expired/failed/cancelled
entries from sessionRefs map, preventing slow memory growth over
daemon lifetime.

* Wire all three session types for persistence

Extract createDecisionScope helper to eliminate duplication between
registerSessionDecision and registerPersistentDecision. Define
SessionDecisionResult type for explicit contracts.

Annotate server: cycle-based decisions, updateContent for file-based
modes, awaitingResubmission in /api/feedback response.

Review server: cycle-based decisions, updateContent(rawPatch, gitRef),
awaitingResubmission for non-approved feedback.

Factory: generalize sessionRefs to PersistableSession interface.
Add matchKey computation and matching for annotate (annotate:filepath)
and review (review:project:branch or review:prUrl). Wire both with
registerPersistentDecision. URL and annotate-last modes keep one-shot
behavior (no persistent source to refresh).

* Add awaiting-resubmission frontend state for annotate and code review

Annotate: handleAnnotateFeedback now checks response for
awaitingResubmission flag, same pattern as plan deny handler.
Session-revision subscription already handles both plan and annotate
since they share App.tsx.

Code review: handleSendFeedback checks awaitingResubmission response.
Add session-revision event subscription that refreshes diff data,
clears annotations, and resets awaiting state. CompletionBanner shows
awaiting variant for all three surfaces.

* Quality fixes: extract updateContent, document findAwaitingSession

Move plan server's inline updateContent closure to a named function
handleUpdateContent for readability. Document findAwaitingSession's
side effect of pruning terminal sessions during search.

* Extract shared decision cycle helper, consistent updateContent naming

Add createDecisionCycle<T>() and resolveAndCycle() to session-handler.ts.
All three servers (plan, annotate, review) now use the shared helper
instead of copy-pasting the cycle setup, resolve, and startNewCycle
pattern. Deny handlers collapse to a single resolveAndCycle() call.

Extract updateContent to named handleUpdateContent functions in all
three servers for consistency — inline closures in return blocks
replaced with named functions defined above the return.

* Fix 5 review findings: snapshot provider, origin check, exit handling, empty content, TTL

1. Register no-op snapshot provider for session-revision in all three
   servers — prevents WebSocket disconnect when frontend subscribes.

2. Exclude plannotator-frontend from agent origins in resolveAndCycle
   — dashboard-created sessions complete on deny instead of hanging.

3. Complete session on exit: true in registerPersistentDecision —
   Exit button now properly terminates instead of suspending.

4. Check revision.plan !== undefined instead of truthiness — empty
   diffs and empty annotate content no longer ignored.

5. Store ttlMs on session record and restore it on reactivate() —
   reactivated sessions expire normally if abandoned.

* Add session persistence design docs and decisions

Captures the overview of what session persistence does, the technical
architecture, and active design decisions from PR #770 triage — notably
removing persistence from code review and redesigning the awaiting state.

* Remove redundant HTML overview from tracking

* Add version history and diff support to annotate sessions

Reuses the plan review's version infrastructure for file-based annotate
sessions. Revisions now save to history, the session-revision event
carries previousPlan and versionInfo, and the frontend's diff badge,
diff view, and version browser activate automatically. Also updates
decisions.md with product facts and revised design decisions.

* Long-lived code review sessions with idle status

Replace the awaiting-resubmission persistence model for code review
with a new "idle" session status. After sending feedback, the session
stays alive and interactive — the user can annotate and send again.
Agent reviews from the same directory attach to the idle session
instead of creating a new one. The frontend shows a calm "Feedback
sent" banner instead of a spinner.

* Hide submit buttons while idle, no auto-dismiss

After feedback is sent and the session is idle (no agent listening),
the Send Feedback and Approve buttons disappear. They reappear when
the agent reactivates the session via session-revision. Keyboard
shortcuts are also blocked while idle.

* Update decisions.md with implemented code review lifecycle

Document the actual idle flow, resolve open questions, and mark
Decision 1 and 3 as implemented.

* Fix 4 review findings: idle TTL, late waiter, self-refresh, temp cleanup

1. Idle sessions with no ttlMs now get a fallback TTL instead of
   living forever (uses AWAITING_RESUBMISSION_TTL_MS as fallback)
2. waitForResult returns immediately for idle sessions with results
   instead of hanging forever on late agent checks
3. Review handleUpdateContent re-runs the diff with current user
   settings instead of accepting a pre-computed patch, and clears
   currentError. Preserves user's diff type and base branch choice.
4. Temp worktree cleanup on the review session reuse path

* Fix PR mode review reuse: pass fetched patch instead of local diff

For PR reviews, the diff comes from the GitHub/GitLab API, not from
local git. handleUpdateContent now accepts an optional pre-computed
patch for PR mode, falling back to self-refresh for local reviews.

* Fix plan diff base tracking and linked-doc annotation leak

1. usePlanDiff now updates the diff base on every revision, not just
   the first. Previously the diff always compared against v1 after
   multiple deny/resubmit cycles.
2. Clear linked-doc annotation cache on session revision so stale
   annotations from linked files don't persist into the next plan
   version.

* Exclude folder annotate from persistence, fix review loop survival

1. Folder-mode annotate sessions no longer participate in session
   matching or resubmission — they have no single document to track.
   Prevents empty content being pushed on resubmission.
2. Review decision loop uses promise identity tracking instead of
   idle() return value to detect cycle exhaustion. Survives double-
   submissions while still exiting cleanly for non-agent origins.

* Scope annotate matchKey by project to prevent cross-project collisions

The "document" filePath fallback created a global collision bucket
for fileless annotate sessions. Now scoped as annotate:project:path.

* Update decisions.md with cross-cutting facts and current open items

Add cross-cutting requirements from review triage: external annotation
flushing, waitForResult consistency, plan action disabling, snapshot
provider, and architectural gaps (HTML pipeline, PR metadata). Replace
outdated bug table with current open items reflecting what's fixed,
what's deferred, and what's accepted.

* Fix 5 cross-cutting issues: external annotations, waitForResult, plan actions, snapshots, docs

1. Clear external annotations on revision for all three servers
   (plan, annotate, review) — stale lint/agent comments no longer
   persist with wrong line numbers after content refresh.
2. waitForResult short-circuits for awaiting-resubmission sessions
   with results, matching the existing idle behavior.
3. Plan/annotate actions disabled during awaitingResubmission —
   keyboard shortcuts and header buttons gated.
4. session-revision snapshot providers return current content instead
   of null — late WebSocket subscribers get the latest state.
5. Comment on registerReviewDecision explaining intentional idle
   lifecycle (reviews stay alive, cleanup via TTL).

* Collapse duplicate decision loops into shared registerDecisionLoop

The persistent and review decision handlers shared ~80% of their structure.
Extract a parameterized registerDecisionLoop that takes an onResult callback
and activeStatuses set. Also drop an unnecessary cast and extract a named
boolean in waitForResult for readability.

* Fix 3 review findings: protocol test, legacy overlay, smart viewed-files retention

1. Update daemon-protocol test to expect session-revision event family
2. Wire feedbackSent to CompletionOverlay so legacy tab mode shows the
   full-screen close experience after sending review feedback
3. Add retainUnchangedViewedFiles() — on diff revision, only un-hide files
   whose patch actually changed; unchanged files stay hidden
4. Document viewed-files and legacy-mode facts in decisions.md

* Fix CompletionOverlay feedback-sent visual to match CompletionBanner

Both components now show success colors and checkmark icon for
feedback-sent state. Previously the overlay used accent/chat-bubble
while the banner used success/check — inconsistent for the same action.

* Code quality: exit cycle, util location, docs, decision cycle comment

1. Review /api/exit uses direct resolve instead of resolveAndCycle —
   no dangling cycle left for the decision loop to block on
2. Move retainUnchangedViewedFiles from types.ts to utils/diffFiles.ts
3. Document createDecisionCycle promise identity invariant
4. Update AGENTS.md: correct annotate match key format, add idle status
   lifecycle for code review, add version history endpoints to annotate
   server table
5. Add session lifetime and timeout facts to decisions.md

* Sessions never die: remove TTL, persistent all annotate types, fix 8 divergences

1. Remove AWAITING_RESUBMISSION_TTL_MS — suspend(), idle(), reactivate()
   no longer set expiresAt. Non-terminal sessions live until daemon restart.
2. registerPersistentDecision never calls store.complete() — approve and
   exit suspend the session like deny does. The agent still gets the result
   via resolveWaiters.
3. All annotate types (folder, last, URL) use registerPersistentDecision
   instead of one-shot registerSessionDecision.
4. Annotate /api/feedback returns feedbackSent instead of awaitingResubmission
   for non-revisable sessions (folder, last).
5. Plan review frontend handles feedbackSent state with feedback-sent banner.
6. Annotate handleUpdateContent accepts optional rawHtml for --render-html
   revision support.
7. Review /api/feedback ties feedbackDelivered to resolveAndCycle return
   value — dashboard sessions no longer get stuck.
8. Update decisions.md and AGENTS.md with sessions-never-die principle.

* Self-review: pass rawHtml through annotate reuse path, fix interface and comment

The factory's annotate reuse path called updateContent(input.markdown)
without forwarding input.rawHtml. Updated to pass both. Also updated
the AnnotateSession interface to match the new signature and fixed
a stale comment about TTL expiry.

* Clean up decisions.md: mark fixed items, update stale decisions, add backlog

- Mark 5 open items as Fixed (external annotations, actions disabled,
  waitForResult, snapshot providers, render-html)
- Update Decisions 4/5/6 to reflect current implementation
- Fix Decision 1 cleanup line (sessions persist, no TTL)
- Add gate flag resubmission gap to backlog
- Add provenance data collection to backlog

* Fix zombie listener, folder reuse, and button state on refresh

- Approve/exit paths now use resolveAndCycle() so the decision loop
  stays alive for future resubmissions (fixes agent hang after approve)
- Folder annotate sessions get a match key so re-running the same
  folder reuses the existing session instead of creating duplicates
- All three servers track lastDecision and include it in the initial
  API response so frontends restore correct button state on refresh
- Session-revision listeners accept snapshots and guard annotation
  clearing behind a content-change check to prevent data loss on
  WebSocket reconnect
- Annotate updateContent is always provided (not just for file-based)
  so folder sessions can publish reactivation events

* Fix snapshot state wipe, editor annotations, remote share, HTML draft key

- Session-revision handlers only reset awaiting/feedback/submitted state
  when content actually changed, preventing WebSocket snapshots from
  wiping lastDecision-restored state on tab refresh
- Add feedbackSent to annotate action bar disabled state so buttons
  hide after folder/annotate-last feedback
- Add clearAll() to editor annotation handler; call it in plan and
  review handleUpdateContent so VS Code annotations don't survive
  across revisions
- Regenerate remoteShare URL in all three session reuse paths (plan,
  annotate, review) so remote users get current share links
- Fix raw-HTML annotate draft key to hash rawHtml instead of empty
  markdown, preventing cross-session draft collisions
- Update overview.md: remove all stale 10-minute TTL references

* Fix session-revision handler: distinguish snapshots from live events

State resets (feedbackSent, awaitingResubmission, submitted) now fire
on content change OR live events, but not on snapshots with unchanged
content. This fixes two competing requirements:
- Tab refresh: snapshot has same content, skip state reset (preserves
  lastDecision-restored state)
- Folder reactivation: live event has same content, reset state
  (re-enables buttons after feedbackSent)

* Fix dead import, HTML→markdown switching, standalone awaiting overlay

- Remove unused PlannotatorSession type import from session-factory
- Always assign rawHtml in annotate updateCon…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant