Skip to content

Commit 646f7b2

Browse files
authored
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
1 parent 7ed752f commit 646f7b2

237 files changed

Lines changed: 29834 additions & 839 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ claude --plugin-dir ./apps/hook
136136
**Config-only settings (`~/.plannotator/config.json`)**: Some settings have no env-var equivalent and are toggled by editing the config file directly:
137137

138138
- `pfmReminder` (`true` / `false`, default `false`) — when enabled, a Plannotator Flavored Markdown reminder is injected at plan-time describing the renderer's extensions (code-file links, callouts, tables, diagrams, task lists, hex swatches, wiki-links). Lets the planning agent enrich plans with PFM features without having to discover them. Composes cleanly with the compound-skill improvement hook. Supported across all three runtimes: Claude Code (`improve-context` PreToolUse hook in `apps/hook/server/index.ts`), OpenCode (`experimental.chat.system.transform` in `apps/opencode-plugin/index.ts`), and Pi (`before_agent_start` in `apps/pi-extension/index.ts`).
139+
- `legacyTabMode` (`true` / `false`, default `false`) — when enabled, the daemon opens a new browser tab for every session regardless of whether a frontend is already connected. Sessions use the full-screen `CompletionOverlay` with auto-close instead of the inline `CompletionBanner`. Preserves the pre-frontend tab-per-session behavior for users who prefer it.
139140

140141
**Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected when `PLANNOTATOR_REMOTE` is unset. Set `PLANNOTATOR_REMOTE=1` / `true` to force remote mode or `0` / `false` to force local mode.
141142

@@ -234,11 +235,33 @@ The daemon is the single long-running Bun server used by normal plan/review/anno
234235
| `/daemon/sessions/:id/cancel` | POST | Cancel a session and dispose its resources |
235236
| `/daemon/sessions/:id` | DELETE | Delete a session record |
236237
| `/daemon/shutdown` | POST | Ask the daemon to stop |
237-
| `/daemon/ws` | WebSocket | Multiplex daemon lifecycle events, session-scoped external annotation events, agent job events, and correlated session actions |
238+
| `/daemon/config` | GET | Read global config (`~/.plannotator/config.json`) |
239+
| `/daemon/config` | POST | Write global config keys (allowlisted: `displayName`, `pfmReminder`, `legacyTabMode`, `diffOptions`, `conventionalComments`, `conventionalLabels`) |
240+
| `/daemon/git/user` | GET | Return git user name from `git config user.name` |
241+
| `/daemon/vaults` | GET | Detect available Obsidian vaults |
242+
| `/daemon/obsidian/vaults` | GET | Alias for `/daemon/vaults` |
243+
| `/daemon/hooks/status` | GET | Return PFM reminder and improvement hook status |
244+
| `/daemon/projects` | DELETE | Remove a project by `?cwd=` (optional `?clean=1` to cancel active sessions) |
245+
| `/daemon/projects/prs` | GET | List open PRs for a project (`?cwd=`) |
246+
| `/daemon/projects/prs/detailed` | GET | List PRs with review metadata for dashboard (`?cwd=`) |
247+
| `/daemon/fs/list` | GET | List directory contents (`?path=`) |
248+
| `/daemon/ws` | WebSocket | Multiplex daemon lifecycle events, session-scoped external annotation events, agent job events, session revision events, and correlated session actions |
238249
| `/s/:id` | GET | Serve the browser HTML for a session |
239250
| `/s/:id/api/...` | Any | Route browser API requests to that session's plan/review/annotate handler |
240251

241-
Runtime live updates for daemon lifecycle events, external annotations, and agent jobs are delivered through `/daemon/ws`. Session-scoped updates subscribe by `{ family, sessionId }`. HTTP endpoints below remain for snapshots, mutations, uploads, and large payloads. AI query token streaming remains on `/api/ai/query`.
252+
Runtime live updates for daemon lifecycle events, external annotations, agent jobs, and session revisions are delivered through `/daemon/ws`. Session-scoped updates subscribe by `{ family, sessionId }`. HTTP endpoints below remain for snapshots, mutations, uploads, and large payloads. AI query token streaming remains on `/api/ai/query`.
253+
254+
### Session Persistence and Resubmission
255+
256+
When a user denies a plan (or sends feedback on a review/annotation), the session enters `awaiting-resubmission` status instead of completing. The session's HTTP handler stays alive. When the agent replans and submits again via `POST /daemon/sessions`, the daemon matches the new submission to the existing session by a match key (`plan:project:slug` for plans, `review:project:branch` for reviews, `annotate:project:filePath` for single-file annotations). The session reactivates in place — the frontend receives a `session-revision` event via WebSocket with the updated content.
257+
258+
**Sessions never die.** No session type calls `store.complete()` from its decision handler. All sessions survive feedback, approve, and exit — the HTTP handler stays alive and the tab keeps working. `registerPersistentDecision` always calls `store.suspend()`. `registerReviewDecision` always calls `store.idle()`. Non-terminal sessions have no expiry timer.
259+
260+
**Session statuses (plan/annotate):** `active``awaiting-resubmission` (on any decision) → `active` (on resubmit) → `awaiting-resubmission` ... repeating.
261+
262+
**Session statuses (code review):** `active``idle` (on any decision) → `active` (on agent resubmit) → `idle` ... repeating.
263+
264+
**Event families:** `daemon`, `external-annotations`, `agent-jobs`, `session-revision`.
242265

243266
### Plan Server (`packages/server/index.ts`)
244267

@@ -309,7 +332,9 @@ Runtime live updates for daemon lifecycle events, external annotations, and agen
309332

310333
| Endpoint | Method | Purpose |
311334
| --------------------- | ------ | ------------------------------------------ |
312-
| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo?, gate, renderAs?, rawHtml? }` |
335+
| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo?, gate, renderAs?, rawHtml?, previousPlan, versionInfo }` |
336+
| `/api/plan/version` | GET | Fetch specific version (`?v=N`) — single-file annotate only |
337+
| `/api/plan/versions` | GET | List all versions — single-file annotate only |
313338
| `/api/feedback` | POST | Submit annotations (body: feedback, annotations) |
314339
| `/api/approve` | POST | Approve without feedback (review-gate UX, `--gate`) |
315340
| `/api/exit` | POST | Close session without feedback |

apps/debug-frontend/src/daemon/events/hub-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ export class DaemonHubClient {
221221
this.socket = undefined;
222222
this.daemonSubscribed = false;
223223
socket?.close();
224+
this.scheduleReconnect();
224225
return;
225226
}
226227
if (

apps/frontend/index.html

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
<!doctype html>
2-
<html lang="en">
2+
<html lang="en" class="theme-neutral">
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>Plannotator</title>
7+
<script>
8+
// Apply saved theme before React mounts to prevent flash of unstyled content.
9+
// ThemeProvider will take over once mounted.
10+
try {
11+
const ct = document.cookie.match(/(?:^|; )plannotator-color-theme=([^;]*)/);
12+
const theme = ct ? decodeURIComponent(ct[1]) : 'neutral';
13+
const mt = document.cookie.match(/(?:^|; )plannotator-theme=([^;]*)/);
14+
const mode = mt ? decodeURIComponent(mt[1]) : 'dark';
15+
document.documentElement.className = 'theme-' + theme + (mode === 'light' ? ' light' : '');
16+
} catch(e) {}
17+
</script>
718
</head>
819
<body>
920
<div id="root"></div>

apps/frontend/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,20 @@
1616
},
1717
"dependencies": {
1818
"@fontsource-variable/geist-mono": "^5.2.7",
19+
"@fontsource-variable/instrument-sans": "^5.2.8",
1920
"@fontsource-variable/inter": "^5.2.8",
21+
"@plannotator/code-review": "workspace:*",
22+
"@plannotator/plan-review": "workspace:*",
2023
"@plannotator/shared": "workspace:*",
2124
"@plannotator/ui": "workspace:*",
2225
"@radix-ui/react-collapsible": "^1.1.12",
26+
"@radix-ui/react-context-menu": "^2.2.16",
2327
"@radix-ui/react-dialog": "^1.1.15",
2428
"@radix-ui/react-dropdown-menu": "^2.1.16",
2529
"@radix-ui/react-label": "^2.1.8",
2630
"@radix-ui/react-separator": "^1.1.8",
2731
"@radix-ui/react-slot": "^1.2.4",
32+
"@radix-ui/react-tabs": "^1.1.13",
2833
"@radix-ui/react-tooltip": "^1.2.8",
2934
"@tanstack/react-router": "^1.141.0",
3035
"class-variance-authority": "^0.7.1",

apps/frontend/src/app/Layout.tsx

Lines changed: 95 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,117 @@
11
import { useCallback, useEffect } from "react";
2-
import { Outlet } from "@tanstack/react-router";
2+
import { Outlet, useMatchRoute } from "@tanstack/react-router";
33
import { Toaster } from "sonner";
4-
import { SidebarProvider } from "@/components/ui/sidebar";
4+
import { SidebarProvider, useSidebar } from "@/components/ui/sidebar";
5+
import { TooltipProvider } from "@/components/ui/tooltip";
56
import { AppSidebar } from "../components/sidebar/AppSidebar";
7+
import { SidebarPeek } from "../components/sidebar/SidebarPeek";
68
import { AddProjectDialog } from "../components/landing/AddProjectDialog";
9+
import { AppSettingsDialog } from "../components/settings/AppSettingsDialog";
10+
import { SessionSurface } from "../components/sessions/SessionSurface";
11+
import { appStore } from "../stores/app-store";
12+
import { setGlobalFetchBase } from "@plannotator/ui/utils/api";
713
import { useDaemonEvents } from "../daemon/events/use-daemon-events";
14+
15+
setGlobalFetchBase("/daemon");
816
import { projectStore } from "../stores/project-store";
917
import { useAppStore } from "../stores/app-store";
1018

11-
export function Layout() {
19+
function LayoutContent() {
1220
const addProjectOpen = useAppStore((s) => s.addProjectOpen);
1321
const setAddProjectOpen = useAppStore((s) => s.setAddProjectOpen);
22+
const activeSessionId = useAppStore((s) => s.activeSessionId);
23+
const visitedSessions = useAppStore((s) => s.visitedSessions);
24+
const matchRoute = useMatchRoute();
25+
const { open: sidebarOpen } = useSidebar();
1426

15-
useDaemonEvents();
27+
const { reportActiveSession } = useDaemonEvents();
1628

1729
useEffect(() => {
1830
void projectStore.getState().fetchProjects();
1931
}, []);
2032

21-
const openAddProject = useCallback(() => setAddProjectOpen(true), [setAddProjectOpen]);
33+
const isOnSession = !!matchRoute({ to: "/s/$sessionId", fuzzy: true });
34+
35+
useEffect(() => {
36+
reportActiveSession(isOnSession ? activeSessionId : null);
37+
}, [reportActiveSession, isOnSession, activeSessionId]);
38+
const showLanding = !isOnSession;
39+
40+
useEffect(() => {
41+
const handler = (e: KeyboardEvent) => {
42+
if ((e.metaKey || e.ctrlKey) && e.key === ",") {
43+
e.preventDefault();
44+
const current = appStore.getState().settingsOpen;
45+
appStore.getState().setSettingsOpen(!current);
46+
}
47+
};
48+
window.addEventListener("keydown", handler);
49+
return () => window.removeEventListener("keydown", handler);
50+
}, []);
2251

2352
return (
24-
<SidebarProvider
25-
defaultOpen={false}
26-
style={{ "--sidebar-width": "16rem" } as React.CSSProperties}
27-
>
28-
<AppSidebar onAddProject={openAddProject} />
29-
<main className="flex-1 overflow-hidden">
30-
<Outlet />
53+
<>
54+
<AppSidebar />
55+
<SidebarPeek />
56+
<main className="relative flex-1 overflow-hidden">
57+
<div
58+
className="absolute inset-0"
59+
style={{
60+
visibility: showLanding ? "visible" : "hidden",
61+
zIndex: showLanding ? 1 : 0,
62+
}}
63+
>
64+
<Outlet />
65+
</div>
66+
67+
{Object.values(visitedSessions).map(({ sessionId, bootstrap }) => {
68+
const isActive = sessionId === activeSessionId && isOnSession;
69+
return (
70+
<div
71+
key={sessionId}
72+
className={`absolute inset-0 overflow-hidden ${sidebarOpen ? "rounded-tl-xl border-l border-border/50" : ""}`}
73+
style={{
74+
visibility: isActive ? "visible" : "hidden",
75+
contentVisibility: isActive ? "visible" : "hidden",
76+
containIntrinsicSize: isActive ? undefined : "auto 100vh",
77+
zIndex: isActive ? 1 : 0,
78+
}}
79+
>
80+
<SessionSurface bootstrap={bootstrap} />
81+
</div>
82+
);
83+
})}
3184
</main>
3285
<AddProjectDialog open={addProjectOpen} onOpenChange={setAddProjectOpen} />
33-
<Toaster position="bottom-right" />
34-
</SidebarProvider>
86+
<AppSettingsDialog />
87+
<Toaster
88+
position="bottom-right"
89+
toastOptions={{
90+
style: {
91+
"--normal-bg": "var(--card)",
92+
"--normal-border": "var(--border)",
93+
"--normal-text": "var(--foreground)",
94+
"--normal-action-bg": "var(--primary)",
95+
"--normal-action-text": "var(--primary-foreground)",
96+
} as React.CSSProperties,
97+
}}
98+
/>
99+
</>
100+
);
101+
}
102+
103+
export function Layout() {
104+
const matchRoute = useMatchRoute();
105+
const initiallyOnSession = !!matchRoute({ to: "/s/$sessionId", fuzzy: true });
106+
107+
return (
108+
<TooltipProvider delayDuration={200} skipDelayDuration={100}>
109+
<SidebarProvider
110+
defaultOpen={!initiallyOnSession}
111+
style={{ "--sidebar-width": "16rem" } as React.CSSProperties}
112+
>
113+
<LayoutContent />
114+
</SidebarProvider>
115+
</TooltipProvider>
35116
);
36117
}

apps/frontend/src/app/router.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export function createAppRouter(
1313
routeTree,
1414
context,
1515
defaultPreload: "intent",
16+
defaultPendingMs: 0,
17+
defaultPendingMinMs: 0,
1618
});
1719
}
1820

415 KB
Loading

0 commit comments

Comments
 (0)