From 06c5f4217e007a0a48409275ad3f7fabf33cb052 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 15:44:18 -0700 Subject: [PATCH 01/30] Add frontend visibility and focus reporting to daemon WebSocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/frontend/src/app/Layout.tsx | 6 +++- .../src/daemon/events/event-stream.ts | 25 +++++++++++++-- apps/frontend/src/daemon/events/hub-client.ts | 5 +++ .../src/daemon/events/use-daemon-events.ts | 19 +++++++++--- packages/server/daemon/event-hub.ts | 31 +++++++++++++++++++ packages/shared/daemon-protocol.ts | 10 ++++++ 6 files changed, 89 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/app/Layout.tsx b/apps/frontend/src/app/Layout.tsx index 9e45dd090..060658b3b 100644 --- a/apps/frontend/src/app/Layout.tsx +++ b/apps/frontend/src/app/Layout.tsx @@ -18,13 +18,17 @@ function LayoutContent() { const matchRoute = useMatchRoute(); const { open: sidebarOpen } = useSidebar(); - useDaemonEvents(); + const { reportActiveSession } = useDaemonEvents(); useEffect(() => { void projectStore.getState().fetchProjects(); }, []); const isOnSession = !!matchRoute({ to: "/s/$sessionId", fuzzy: true }); + + useEffect(() => { + reportActiveSession(isOnSession ? activeSessionId : null); + }, [reportActiveSession, isOnSession, activeSessionId]); const showLanding = !isOnSession; const openAddProject = useCallback(() => setAddProjectOpen(true), [setAddProjectOpen]); diff --git a/apps/frontend/src/daemon/events/event-stream.ts b/apps/frontend/src/daemon/events/event-stream.ts index a754b938e..30cecca14 100644 --- a/apps/frontend/src/daemon/events/event-stream.ts +++ b/apps/frontend/src/daemon/events/event-stream.ts @@ -23,6 +23,7 @@ export interface DaemonEventStreamOptions { export interface DaemonEventStreamController { stop(): void; + reportActiveSession(sessionId: string | null): void; } const DAEMON_EVENT_TYPES = [ @@ -79,6 +80,17 @@ export function connectDaemonEvents( }); }; + let currentActiveSessionId: string | null = null; + + const sendClientState = () => { + client.sendClientState(!document.hidden, currentActiveSessionId); + }; + + const handleVisibilityChange = () => { + if (!stopped) sendClientState(); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + const unsubscribe = client.subscribeDaemon( (message) => { if (stopped) return; @@ -88,7 +100,10 @@ export function connectDaemonEvents( (state) => { if (stopped) return; options.onState(state); - if (state === "open") stopPolling(); + if (state === "open") { + stopPolling(); + sendClientState(); + } if (state === "error" || state === "closed") startPolling(); }, (message) => { @@ -96,10 +111,16 @@ export function connectDaemonEvents( }, ); - return { stop }; + return { stop, reportActiveSession }; + + function reportActiveSession(sessionId: string | null): void { + currentActiveSessionId = sessionId; + sendClientState(); + } function stop() { stopped = true; + document.removeEventListener("visibilitychange", handleVisibilityChange); stopPolling(); unsubscribe(); } diff --git a/apps/frontend/src/daemon/events/hub-client.ts b/apps/frontend/src/daemon/events/hub-client.ts index 71bf964e3..c96983bab 100644 --- a/apps/frontend/src/daemon/events/hub-client.ts +++ b/apps/frontend/src/daemon/events/hub-client.ts @@ -233,6 +233,11 @@ export class DaemonHubClient { } } + sendClientState(visible: boolean, activeSessionId: string | null): void { + if (this.socket?.readyState !== OPEN) return; + this.send({ type: "client-state", visible, activeSessionId }); + } + private send(message: DaemonWebSocketClientMessage): void { if (this.socket?.readyState !== OPEN) { throw new DaemonHubOpenError("Daemon WebSocket is not open."); diff --git a/apps/frontend/src/daemon/events/use-daemon-events.ts b/apps/frontend/src/daemon/events/use-daemon-events.ts index 7af546cb5..dbf0ee1cb 100644 --- a/apps/frontend/src/daemon/events/use-daemon-events.ts +++ b/apps/frontend/src/daemon/events/use-daemon-events.ts @@ -1,12 +1,13 @@ -import { useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { daemonApiClient, type DaemonApiClient } from "../api/client"; -import { connectDaemonEvents } from "./event-stream"; +import { connectDaemonEvents, type DaemonEventStreamController } from "./event-stream"; import { useDaemonEventStore } from "./event-store"; -export function useDaemonEvents(client: DaemonApiClient = daemonApiClient, enabled = true): void { +export function useDaemonEvents(client: DaemonApiClient = daemonApiClient, enabled = true) { const applyEvent = useDaemonEventStore((state) => state.applyEvent); const setConnectionState = useDaemonEventStore((state) => state.setConnectionState); const setError = useDaemonEventStore((state) => state.setError); + const controllerRef = useRef(null); useEffect(() => { if (!enabled) return undefined; @@ -16,7 +17,17 @@ export function useDaemonEvents(client: DaemonApiClient = daemonApiClient, enabl onState: setConnectionState, onError: setError, }); + controllerRef.current = controller; - return () => controller.stop(); + return () => { + controller.stop(); + controllerRef.current = null; + }; }, [applyEvent, client, enabled, setConnectionState, setError]); + + const reportActiveSession = useCallback((sessionId: string | null) => { + controllerRef.current?.reportActiveSession(sessionId); + }, []); + + return { reportActiveSession }; } diff --git a/packages/server/daemon/event-hub.ts b/packages/server/daemon/event-hub.ts index ed9a23078..50079d344 100644 --- a/packages/server/daemon/event-hub.ts +++ b/packages/server/daemon/event-hub.ts @@ -34,6 +34,14 @@ interface ConnectionState { pendingSubscriptions: Map; heartbeat: ReturnType; daemonAuthenticated: boolean; + tabVisible: boolean; + activeSessionId: string | null; +} + +export interface FrontendState { + connected: boolean; + anyVisible: boolean; + allActiveSessionIds: Set; } function scopeKey(scope: DaemonWebSocketScope): string { @@ -97,6 +105,19 @@ export class DaemonEventHub { this.publish({ family, sessionId }, event); } + getFrontendState(): FrontendState { + let connected = false; + let anyVisible = false; + const allActiveSessionIds = new Set(); + for (const conn of this.connections.values()) { + if (!conn.daemonAuthenticated) continue; + connected = true; + if (conn.tabVisible) anyVisible = true; + if (conn.activeSessionId) allActiveSessionIds.add(conn.activeSessionId); + } + return { connected, anyVisible, allActiveSessionIds }; + } + closeAll(): void { for (const socket of Array.from(this.connections.keys())) { try { @@ -119,6 +140,8 @@ export class DaemonEventHub { pendingSubscriptions: new Map(), heartbeat, daemonAuthenticated: socket.data?.daemonAuthenticated === true, + tabVisible: true, + activeSessionId: null, }); } @@ -142,6 +165,14 @@ export class DaemonEventHub { case "ping": this.send(socket, { type: "pong", requestId: message.requestId, at: nowIso() }); return; + case "client-state": { + const connection = this.connections.get(socket); + if (connection) { + connection.tabVisible = message.visible; + connection.activeSessionId = message.activeSessionId; + } + return; + } } } diff --git a/packages/shared/daemon-protocol.ts b/packages/shared/daemon-protocol.ts index 2a9010596..2eb00139b 100644 --- a/packages/shared/daemon-protocol.ts +++ b/packages/shared/daemon-protocol.ts @@ -256,6 +256,11 @@ export type DaemonWebSocketClientMessage = | { type: "ping"; requestId?: string; + } + | { + type: "client-state"; + visible: boolean; + activeSessionId: string | null; }; export type DaemonWebSocketServerMessage = @@ -370,6 +375,11 @@ export function parseDaemonWebSocketClientMessage( }; } if (value.type === "ping") return { type: "ping", ...(requestId && { requestId }) }; + if (value.type === "client-state") { + if (typeof value.visible !== "boolean") return null; + const activeSessionId = isString(value.activeSessionId) ? value.activeSessionId : null; + return { type: "client-state", visible: value.visible, activeSessionId }; + } return null; } From cb747407dfe91de2ba8567826736531edc880ac3 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 15:46:56 -0700 Subject: [PATCH 02/30] Move browser opening from CLI to daemon with smart presentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/hook/server/index.ts | 36 +------------------------------ packages/server/daemon/runtime.ts | 19 ++++++++++++++-- packages/server/daemon/server.ts | 15 ++++++++++++- packages/shared/config.ts | 6 ++++++ 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index c6aec82ee..b189d1202 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -52,15 +52,6 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { - handleServerReady, -} from "@plannotator/server"; -import { - handleReviewServerReady, -} from "@plannotator/server/review"; -import { - handleAnnotateServerReady, -} from "@plannotator/server/annotate"; import { loadConfig, resolveUseJina } from "@plannotator/shared/config"; import { parseReviewArgs } from "@plannotator/shared/review-args"; import { @@ -652,19 +643,6 @@ function registerDaemonSessionInterruptCleanup( }; } -async function withProcessCwd(cwd: string | undefined, fn: () => Promise): Promise { - if (!cwd) return fn(); - const original = process.cwd(); - const target = path.resolve(cwd); - if (target === original) return fn(); - process.chdir(target); - try { - return await fn(); - } finally { - process.chdir(original); - } -} - async function runDaemonSessionRequest(request: PluginRequest, options: { pluginError?: boolean } = {}): Promise<{ result: PluginActionResult; session: PluginSessionInfo; @@ -691,12 +669,10 @@ async function runDaemonSessionRequest(request: PluginRequest, options: { plugin }); const sessionUrl = new URL(created.session.url); - const sessionPort = Number(sessionUrl.port); - const browserSessionUrl = createDaemonBrowserAuthUrl(daemon.state, sessionUrl.pathname); const session: PluginSessionInfo = { mode: created.session.mode, url: created.session.url, - port: sessionPort, + port: Number(sessionUrl.port), isRemote: daemon.state.isRemote, }; if (created.session.remoteShare) { @@ -708,16 +684,6 @@ async function runDaemonSessionRequest(request: PluginRequest, options: { plugin emitPluginSessionReady(session); } - await withProcessCwd(request.cwd, async () => { - if (request.action === "review") { - await handleReviewServerReady(browserSessionUrl, daemon.state.isRemote, sessionPort); - } else if (request.action === "annotate" || request.action === "annotate-last") { - await handleAnnotateServerReady(browserSessionUrl, daemon.state.isRemote, sessionPort); - } else { - await handleServerReady(browserSessionUrl, daemon.state.isRemote, sessionPort); - } - }); - const completed = await daemon.waitForResult(created.session.id); if (completed.ok !== true) { await cancelCreatedSession(); diff --git a/packages/server/daemon/runtime.ts b/packages/server/daemon/runtime.ts index 0944403a6..0662c4c81 100644 --- a/packages/server/daemon/runtime.ts +++ b/packages/server/daemon/runtime.ts @@ -1,8 +1,11 @@ import { getServerHostname, getServerPort, isRemoteSession } from "../remote"; -import { acquireDaemonLock, createDaemonState, removeDaemonState, writeDaemonState, type DaemonLock, type DaemonState, type DaemonStateOptions } from "./state"; +import { openBrowser } from "../browser"; +import { loadConfig } from "@plannotator/shared/config"; +import { acquireDaemonLock, createDaemonState, createDaemonBrowserAuthUrl, removeDaemonState, writeDaemonState, type DaemonLock, type DaemonState, type DaemonStateOptions } from "./state"; import { DaemonSessionStore, type DaemonSessionRecord } from "./session-store"; -import { createDaemonFetchHandler, type DaemonFetchContext, type DaemonFetchHandler } from "./server"; +import { createDaemonFetchHandler, type DaemonFetchContext, type DaemonFetchHandler, type SessionBrowserAction } from "./server"; import type { DaemonCreateSessionRequest } from "@plannotator/shared/daemon-protocol"; +import type { DaemonEventHub } from "./event-hub"; export interface StartDaemonRuntimeOptions extends DaemonStateOptions { shellHtmlContent: string; @@ -78,11 +81,23 @@ export async function startDaemonRuntime(options: StartDaemonRuntimeOptions): Pr binaryVersion: options.binaryVersion, requestedPort, }); + async function presentSession(record: DaemonSessionRecord, eventHub: DaemonEventHub): Promise { + const config = loadConfig(); + const frontendState = eventHub.getFrontendState(); + if (!config.legacyTabMode && frontendState.connected && frontendState.anyVisible) { + return "notified"; + } + const url = createDaemonBrowserAuthUrl(state, new URL(record.url).pathname); + await openBrowser(url, { isRemote }); + return "opened"; + } + handler = createDaemonFetchHandler({ state, store, shellHtmlContent: options.shellHtmlContent, createSession: options.createSession, + presentSession, onShutdown: async () => { await runtime?.stop(); await options.onShutdown?.(); diff --git a/packages/server/daemon/server.ts b/packages/server/daemon/server.ts index a3cb6fa39..04350a36d 100644 --- a/packages/server/daemon/server.ts +++ b/packages/server/daemon/server.ts @@ -20,6 +20,8 @@ import { addProject, listProjects, removeProject } from "./project-registry"; const RESULT_DELETE_GRACE_MS = 2_000; const DAEMON_AUTH_COOKIE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; +export type SessionBrowserAction = "opened" | "notified"; + export interface DaemonServerOptions { state: DaemonState; shellHtmlContent: string; @@ -28,6 +30,10 @@ export interface DaemonServerOptions { request: DaemonCreateSessionRequest, context: DaemonFetchContext, ) => DaemonSessionRecord | Promise; + presentSession?: ( + record: DaemonSessionRecord, + eventHub: DaemonEventHub, + ) => Promise; onShutdown?: () => void | Promise; } @@ -385,7 +391,14 @@ export function createDaemonFetchHandler(options: DaemonServerOptions): DaemonFe try { requestContext?.disableIdleTimeout?.(); const record = await options.createSession(body, context); - return json({ ok: true, session: store.summary(record, { includeRemoteShare: true }) }, { status: 201 }); + const browserAction = options.presentSession + ? await options.presentSession(record, eventHub).catch((): SessionBrowserAction => "opened") + : undefined; + return json({ + ok: true, + session: store.summary(record, { includeRemoteShare: true }), + ...(browserAction && { browserAction }), + }, { status: 201 }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to create session."; eventHub.publishDaemonEvent({ diff --git a/packages/shared/config.ts b/packages/shared/config.ts index d9e32865f..721e3e1f9 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -124,6 +124,12 @@ export interface PlannotatorConfig { * Read by the `improve-context` PreToolUse handler. Default: false. */ pfmReminder?: boolean; + /** + * When true, the daemon always opens a new browser tab per session and the + * frontend uses the full-screen CompletionOverlay with auto-close instead of + * the inline CompletionBanner. Default: false (smart single-app mode). + */ + legacyTabMode?: boolean; } const CONFIG_DIR = join(homedir(), ".plannotator"); From 8edabb4f6eff6f6e254c876ec281b860c071df62 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 15:49:06 -0700 Subject: [PATCH 03/30] Add session notification toasts and keep completed sessions in sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/frontend/src/daemon/contracts.ts | 2 +- .../frontend/src/daemon/events/event-store.ts | 2 +- .../src/daemon/events/event-stream.ts | 22 ++++++++++++-- .../src/daemon/events/use-daemon-events.ts | 29 ++++++++++++++++++- packages/server/daemon/runtime.ts | 5 ++++ packages/shared/daemon-protocol.ts | 6 ++++ 6 files changed, 61 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/daemon/contracts.ts b/apps/frontend/src/daemon/contracts.ts index 2e68488c6..98168f8bc 100644 --- a/apps/frontend/src/daemon/contracts.ts +++ b/apps/frontend/src/daemon/contracts.ts @@ -56,7 +56,7 @@ export type DaemonLifecycleEvent = | Extract | Extract | (Omit< - Extract, + Extract, "session" > & { session: SessionSummary; diff --git a/apps/frontend/src/daemon/events/event-store.ts b/apps/frontend/src/daemon/events/event-store.ts index 4cec8cffd..9201b4183 100644 --- a/apps/frontend/src/daemon/events/event-store.ts +++ b/apps/frontend/src/daemon/events/event-store.ts @@ -59,7 +59,7 @@ export function applyDaemonEvent(state: DaemonEventState, event: DaemonLifecycle } const existingIndex = state.sessions.findIndex((session) => session.id === event.session.id); - if (event.type === "session-removed" || TERMINAL_STATUSES.has(event.session.status)) { + if (event.type === "session-removed") { if (existingIndex >= 0) state.sessions.splice(existingIndex, 1); return; } diff --git a/apps/frontend/src/daemon/events/event-stream.ts b/apps/frontend/src/daemon/events/event-stream.ts index 30cecca14..a6f77ea5f 100644 --- a/apps/frontend/src/daemon/events/event-stream.ts +++ b/apps/frontend/src/daemon/events/event-stream.ts @@ -17,6 +17,7 @@ export interface DaemonEventStreamOptions { onEvent(event: DaemonLifecycleEvent): void; onState(state: DaemonHubConnectionState | "polling"): void; onError(message: string): void; + onSessionNotify?(session: { id: string; mode: string; project: string; label: string }): void; webSocketFactory?: WebSocketFactory; fallbackPollMs?: number; } @@ -32,6 +33,7 @@ const DAEMON_EVENT_TYPES = [ "session-created", "session-updated", "session-removed", + "session-notify", "daemon-error", "debug-log", ] as const; @@ -81,13 +83,20 @@ export function connectDaemonEvents( }; let currentActiveSessionId: string | null = null; + const pendingNotifications: { id: string; mode: string; project: string; label: string }[] = []; const sendClientState = () => { client.sendClientState(!document.hidden, currentActiveSessionId); }; const handleVisibilityChange = () => { - if (!stopped) sendClientState(); + if (stopped) return; + sendClientState(); + if (!document.hidden && pendingNotifications.length > 0 && options.onSessionNotify) { + for (const n of pendingNotifications.splice(0)) { + options.onSessionNotify(n); + } + } }; document.addEventListener("visibilitychange", handleVisibilityChange); @@ -95,7 +104,16 @@ export function connectDaemonEvents( (message) => { if (stopped) return; const event = messageToDaemonEvent(message); - if (event) options.onEvent(event); + if (!event) return; + if (event.type === "session-notify" && "session" in event && options.onSessionNotify) { + const s = event.session; + if (!document.hidden) { + options.onSessionNotify({ id: s.id, mode: s.mode, project: s.project, label: s.label }); + } else { + pendingNotifications.push({ id: s.id, mode: s.mode, project: s.project, label: s.label }); + } + } + options.onEvent(event); }, (state) => { if (stopped) return; diff --git a/apps/frontend/src/daemon/events/use-daemon-events.ts b/apps/frontend/src/daemon/events/use-daemon-events.ts index dbf0ee1cb..ebcb76c56 100644 --- a/apps/frontend/src/daemon/events/use-daemon-events.ts +++ b/apps/frontend/src/daemon/events/use-daemon-events.ts @@ -1,13 +1,39 @@ import { useCallback, useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { useRouter } from "@tanstack/react-router"; import { daemonApiClient, type DaemonApiClient } from "../api/client"; import { connectDaemonEvents, type DaemonEventStreamController } from "./event-stream"; import { useDaemonEventStore } from "./event-store"; +const MODE_LABELS: Record = { + plan: "Plan Review", + review: "Code Review", + annotate: "Annotate", + archive: "Archive", + "goal-setup": "Goal Setup", +}; + export function useDaemonEvents(client: DaemonApiClient = daemonApiClient, enabled = true) { const applyEvent = useDaemonEventStore((state) => state.applyEvent); const setConnectionState = useDaemonEventStore((state) => state.setConnectionState); const setError = useDaemonEventStore((state) => state.setError); const controllerRef = useRef(null); + const router = useRouter(); + + const handleSessionNotify = useCallback( + (session: { id: string; mode: string; project: string; label: string }) => { + const modeLabel = MODE_LABELS[session.mode] ?? session.mode; + toast(`${modeLabel} — ${session.project}`, { + description: session.label, + duration: 8000, + action: { + label: "Open", + onClick: () => router.navigate({ to: "/s/$sessionId", params: { sessionId: session.id } }), + }, + }); + }, + [router], + ); useEffect(() => { if (!enabled) return undefined; @@ -16,6 +42,7 @@ export function useDaemonEvents(client: DaemonApiClient = daemonApiClient, enabl onEvent: applyEvent, onState: setConnectionState, onError: setError, + onSessionNotify: handleSessionNotify, }); controllerRef.current = controller; @@ -23,7 +50,7 @@ export function useDaemonEvents(client: DaemonApiClient = daemonApiClient, enabl controller.stop(); controllerRef.current = null; }; - }, [applyEvent, client, enabled, setConnectionState, setError]); + }, [applyEvent, client, enabled, handleSessionNotify, setConnectionState, setError]); const reportActiveSession = useCallback((sessionId: string | null) => { controllerRef.current?.reportActiveSession(sessionId); diff --git a/packages/server/daemon/runtime.ts b/packages/server/daemon/runtime.ts index 0662c4c81..213046549 100644 --- a/packages/server/daemon/runtime.ts +++ b/packages/server/daemon/runtime.ts @@ -85,6 +85,11 @@ export async function startDaemonRuntime(options: StartDaemonRuntimeOptions): Pr const config = loadConfig(); const frontendState = eventHub.getFrontendState(); if (!config.legacyTabMode && frontendState.connected && frontendState.anyVisible) { + eventHub.publishDaemonEvent({ + type: "session-notify", + at: new Date().toISOString(), + session: store.summary(record), + }); return "notified"; } const url = createDaemonBrowserAuthUrl(state, new URL(record.url).pathname); diff --git a/packages/shared/daemon-protocol.ts b/packages/shared/daemon-protocol.ts index 2eb00139b..561b634cb 100644 --- a/packages/shared/daemon-protocol.ts +++ b/packages/shared/daemon-protocol.ts @@ -170,6 +170,7 @@ export type DaemonEventType = | "session-created" | "session-updated" | "session-removed" + | "session-notify" | "daemon-error" | "debug-log"; @@ -190,6 +191,11 @@ export type DaemonEvent = at: string; session: DaemonSessionSummary; } + | { + type: "session-notify"; + at: string; + session: DaemonSessionSummary; + } | { type: "daemon-error"; at: string; From 8a73455bd170c19284d468035a0caa4aee1e226a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 15:49:40 -0700 Subject: [PATCH 04/30] 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. --- apps/frontend/src/app/Layout.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/app/Layout.tsx b/apps/frontend/src/app/Layout.tsx index 060658b3b..e0ba8d480 100644 --- a/apps/frontend/src/app/Layout.tsx +++ b/apps/frontend/src/app/Layout.tsx @@ -76,10 +76,13 @@ function LayoutContent() { } export function Layout() { + const matchRoute = useMatchRoute(); + const initiallyOnSession = !!matchRoute({ to: "/s/$sessionId", fuzzy: true }); + return ( From 42f284818692a7c00acf1fddd1001ae5a436b503 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 15:53:59 -0700 Subject: [PATCH 05/30] Add disk-backed session snapshots for completed session persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a session completes, the daemon writes a content snapshot to ~/.plannotator/sessions/.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. --- packages/server/daemon/server.ts | 44 ++++++++++++++- packages/server/daemon/session-factory.ts | 10 ++++ packages/server/daemon/session-store.ts | 67 +++++++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/packages/server/daemon/server.ts b/packages/server/daemon/server.ts index 04350a36d..7e4ab8ae7 100644 --- a/packages/server/daemon/server.ts +++ b/packages/server/daemon/server.ts @@ -6,6 +6,8 @@ import { type DaemonEndpoint, type DaemonEvent, type DaemonSessionBootstrapResponse, + type DaemonSessionStatus, + type DaemonSessionSummary, type DaemonStatus, type DaemonWebSocketClientMessage, } from "@plannotator/shared/daemon-protocol"; @@ -16,6 +18,7 @@ import { DaemonEventHub } from "./event-hub"; import type { SessionEventFamily, SessionRequestContext, SessionSnapshotProvider } from "../session-handler"; import { handleFavicon } from "../shared-handlers"; import { addProject, listProjects, removeProject } from "./project-registry"; +import { readSnapshot } from "./session-store"; const RESULT_DELETE_GRACE_MS = 2_000; const DAEMON_AUTH_COOKIE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; @@ -508,9 +511,41 @@ export function createDaemonFetchHandler(options: DaemonServerOptions): DaemonFe const browserSession = sessionFromPath(url.pathname); if (browserSession) { - const record = store.get(browserSession.id); + let record = store.get(browserSession.id); const sessionApiPath = `/s/${browserSession.id}/api`; if (!record) { + const snapshot = readSnapshot(browserSession.id); + if (snapshot && isPageRequest(req)) { + return html(sessionShellHtml(options.shellHtmlContent, browserSession.id)); + } + if (snapshot && req.method === "GET") { + if (url.pathname === `${sessionApiPath}/session`) { + const summary: DaemonSessionSummary = { + id: snapshot.sessionId, + mode: snapshot.mode, + status: snapshot.status as DaemonSessionStatus, + url: `${endpoint.baseUrl}/s/${snapshot.sessionId}`, + project: snapshot.meta.project, + label: snapshot.meta.label, + ...(snapshot.meta.origin && { origin: snapshot.meta.origin }), + ...(snapshot.meta.cwd && { cwd: snapshot.meta.cwd }), + createdAt: snapshot.capturedAt, + updatedAt: snapshot.capturedAt, + }; + const bootstrap: DaemonSessionBootstrapResponse = { + ok: true, + session: summary, + apiBase: sessionApiPath, + capabilities: getDaemonCapabilities(), + supportedSessionViews: [...PLANNOTATOR_DAEMON_SESSION_VIEWS], + }; + return json(bootstrap); + } + const apiPath = url.pathname.slice(sessionApiPath.length); + if (apiPath === "/plan" || apiPath === "/diff") { + return json({ ...snapshot.content as object, _snapshot: true, _status: snapshot.status, _result: snapshot.result }); + } + } if (url.pathname === `${sessionApiPath}/session` && req.method === "GET") { return json(createDaemonErrorResponse("session-not-found", `Session not found: ${browserSession.id}`), { status: 404 }); } @@ -534,6 +569,13 @@ export function createDaemonFetchHandler(options: DaemonServerOptions): DaemonFe } if (url.pathname === sessionApiPath || url.pathname.startsWith(`${sessionApiPath}/`)) { if (!record.handleRequest) { + const snapshot = readSnapshot(browserSession.id); + if (snapshot && req.method === "GET") { + const apiPath = url.pathname.slice(sessionApiPath.length); + if (apiPath === "/plan" || apiPath === "/diff") { + return json({ ...snapshot.content as object, _snapshot: true, _status: snapshot.status, _result: snapshot.result }); + } + } return new Response("Session has no API handler", { status: 404 }); } const scopedUrl = stripSessionApiPath(url, browserSession.id); diff --git a/packages/server/daemon/session-factory.ts b/packages/server/daemon/session-factory.ts index f56adcbdb..eadbdbe16 100644 --- a/packages/server/daemon/session-factory.ts +++ b/packages/server/daemon/session-factory.ts @@ -536,6 +536,7 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) handleRequest: session.handleRequest, dispose: registerSessionDecision(context, id, () => session.waitForDecision(), () => session.dispose()), remoteShare, + snapshot: () => ({ plan, origin: request.origin }), }); return record; } @@ -606,6 +607,7 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) mode: input.mode, })), remoteShare, + snapshot: () => ({ plan: input.markdown, filePath: input.filePath, mode: input.mode, sourceInfo: input.sourceInfo }), }); return record; } @@ -658,6 +660,13 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) handleRequest: session.handleRequest, dispose: registerSessionDecision(context, id, () => session.waitForDecision(), () => session.dispose()), remoteShare, + snapshot: () => ({ + rawPatch: input.rawPatch, + gitRef: input.gitRef, + origin: request.origin, + diffType: input.gitContext ? (input.diffType ?? "unstaged") : undefined, + gitContext: input.gitContext ? { currentBranch: input.gitContext.currentBranch, base: input.base } : undefined, + }), }); return record; } @@ -682,6 +691,7 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) htmlContent: session.htmlContent, handleRequest: session.handleRequest, dispose: registerSessionDecision(context, id, () => session.waitForDecision(), () => session.dispose()), + snapshot: () => ({ stage: bundle.stage, goalSlug: request.goalSlug }), }); return record; } diff --git a/packages/server/daemon/session-store.ts b/packages/server/daemon/session-store.ts index 4eab84d47..50b1c90f7 100644 --- a/packages/server/daemon/session-store.ts +++ b/packages/server/daemon/session-store.ts @@ -1,3 +1,6 @@ +import { mkdirSync, writeFileSync, readdirSync, readFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; import type { DaemonRemoteShareNotice, DaemonSessionEvent, @@ -26,6 +29,7 @@ export interface DaemonSessionRecord { handleRequest?: SessionRequestHandler; dispose?: () => void | Promise; disposed?: boolean; + snapshot?: () => unknown; } export interface CreateDaemonSessionInput { @@ -43,6 +47,68 @@ export interface CreateDaemonSessionInput { dispose?: () => void | Promise; result?: TResult; remoteShare?: DaemonRemoteShareNotice; + snapshot?: () => unknown; +} + +export interface SessionSnapshot { + version: 1; + mode: DaemonSessionMode; + sessionId: string; + status: string; + result: unknown; + capturedAt: string; + meta: { project: string; origin?: string; cwd?: string; label: string }; + content: unknown; +} + +const SNAPSHOT_DIR = join(homedir(), ".plannotator", "sessions"); +const MAX_SNAPSHOT_BYTES = 5 * 1024 * 1024; + +function writeSnapshot(record: DaemonSessionRecord): void { + if (!record.snapshot) return; + try { + const content = record.snapshot(); + const snapshot: SessionSnapshot = { + version: 1, + mode: record.mode, + sessionId: record.id, + status: record.status, + result: record.result, + capturedAt: new Date().toISOString(), + meta: { project: record.project, origin: record.origin, cwd: record.cwd, label: record.label }, + content, + }; + const json = JSON.stringify(snapshot); + if (json.length > MAX_SNAPSHOT_BYTES) return; + mkdirSync(SNAPSHOT_DIR, { recursive: true }); + writeFileSync(join(SNAPSHOT_DIR, `${record.id}.json`), json, "utf-8"); + } catch {} +} + +export function readSnapshot(sessionId: string): SessionSnapshot | null { + try { + const raw = readFileSync(join(SNAPSHOT_DIR, `${sessionId}.json`), "utf-8"); + const parsed = JSON.parse(raw); + if (parsed?.version === 1 && parsed?.sessionId === sessionId) return parsed as SessionSnapshot; + } catch {} + return null; +} + +export function listSnapshots(): SessionSnapshot[] { + try { + const files = readdirSync(SNAPSHOT_DIR).filter((f) => f.endsWith(".json")); + const snapshots: SessionSnapshot[] = []; + for (const file of files) { + try { + const raw = readFileSync(join(SNAPSHOT_DIR, file), "utf-8"); + const parsed = JSON.parse(raw); + if (parsed?.version === 1) snapshots.push(parsed as SessionSnapshot); + } catch {} + } + return snapshots; + } catch { + return []; + } } export interface DaemonSessionStoreOptions { @@ -166,6 +232,7 @@ export class DaemonSessionStore { record.expiresAt = iso(now + TERMINAL_SESSION_TTL_MS); this.resolveWaiters(record); this.emit("session-updated", record); + writeSnapshot(record); void this.disposeResources(record); this.releaseRoutingPayloads(record); return record; From 41fbd5da42348dd2675fe0891b0ab3d0a641ec0f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 15:56:28 -0700 Subject: [PATCH 06/30] Wire legacy tab mode through server config to surface overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/plannotator-code-review/App.tsx | 10 +++++----- packages/plannotator-plan-review/App.tsx | 11 ++++++----- packages/shared/config.ts | 2 ++ 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/plannotator-code-review/App.tsx b/packages/plannotator-code-review/App.tsx index 32a66d15a..323e7e352 100644 --- a/packages/plannotator-code-review/App.tsx +++ b/packages/plannotator-code-review/App.tsx @@ -196,6 +196,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode } const [origin, setOrigin] = useState(null); const [gitUser, setGitUser] = useState(); const [isWSL, setIsWSL] = useState(false); + const [legacyTabMode, setLegacyTabMode] = useState(false); const [diffType, setDiffType] = useState('uncommitted'); const [gitContext, setGitContext] = useState(null); // Two bases: @@ -789,10 +790,9 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode } isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string }; }) => { - // Initialize config store with server-provided values (config file > cookie > default) configStore.init(data.serverConfig); - // gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable setGitUser(data.serverConfig?.gitUser); + if ((data.serverConfig as { legacyTabMode?: boolean } | undefined)?.legacyTabMode) setLegacyTabMode(true); const apiFiles = parseDiffToFiles(data.rawPatch); setDiffData({ files: apiFiles, @@ -2140,7 +2140,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode } {/* Embedded completion banner — inline, non-blocking */} - {__embedded && } + {__embedded && !legacyTabMode && } {/* Main content */}
@@ -2445,8 +2445,8 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode } /> )} - {/* Standalone completion overlay — full screen with auto-close */} - {!__embedded && ( + {/* Full-screen overlay: standalone mode, or legacy tab mode even when embedded */} + {(!__embedded || legacyTabMode) && ( = ({ const [origin, setOrigin] = useState(null); const [gitUser, setGitUser] = useState(); const [isWSL, setIsWSL] = useState(false); + const [legacyTabMode, setLegacyTabMode] = useState(false); const [globalAttachments, setGlobalAttachments] = useState([]); const [annotateMode, setAnnotateMode] = useState(false); const [gate, setGate] = useState(false); @@ -745,8 +746,8 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }> = ({ .then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive' | 'goal-setup'; goalSetup?: GoalSetupBundle; filePath?: string; sourceInfo?: string; sourceConverted?: boolean; gate?: boolean; renderAs?: 'html' | 'markdown'; rawHtml?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string; host?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string } }) => { // Initialize config store with server-provided values (config file > cookie > default) configStore.init(data.serverConfig); - // gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable setGitUser(data.serverConfig?.gitUser); + if ((data.serverConfig as { legacyTabMode?: boolean } | undefined)?.legacyTabMode) setLegacyTabMode(true); if (data.mode === 'goal-setup' && data.goalSetup) { setGoalSetupBundle(data.goalSetup); setMarkdown(''); @@ -1776,8 +1777,8 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }> = ({ octarineConfigured={isOctarineConfigured()} /> - {/* Embedded completion banner — inline, non-blocking */} - {__embedded && } + {/* Embedded completion banner — inline, non-blocking (skipped in legacy tab mode) */} + {__embedded && !legacyTabMode && } {/* Linked document error banner */} {linkedDocHook.error && ( @@ -2215,8 +2216,8 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }> = ({ /> )} - {/* Standalone completion overlay — full screen with auto-close */} - {!__embedded && ( + {/* Full-screen overlay: standalone mode, or legacy tab mode even when embedded */} + {(!__embedded || legacyTabMode) && ( Date: Tue, 19 May 2026 15:57:12 -0700 Subject: [PATCH 07/30] Document legacyTabMode config setting in AGENTS.md --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index d49167ae4..462c1d985 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,6 +136,7 @@ claude --plugin-dir ./apps/hook **Config-only settings (`~/.plannotator/config.json`)**: Some settings have no env-var equivalent and are toggled by editing the config file directly: - `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`). +- `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. **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. From 894c5dea6ffed2564754671bb836e995b107399d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 16:03:48 -0700 Subject: [PATCH 08/30] 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. --- packages/server/daemon/runtime.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/server/daemon/runtime.ts b/packages/server/daemon/runtime.ts index 213046549..841ff693e 100644 --- a/packages/server/daemon/runtime.ts +++ b/packages/server/daemon/runtime.ts @@ -2,7 +2,7 @@ import { getServerHostname, getServerPort, isRemoteSession } from "../remote"; import { openBrowser } from "../browser"; import { loadConfig } from "@plannotator/shared/config"; import { acquireDaemonLock, createDaemonState, createDaemonBrowserAuthUrl, removeDaemonState, writeDaemonState, type DaemonLock, type DaemonState, type DaemonStateOptions } from "./state"; -import { DaemonSessionStore, type DaemonSessionRecord } from "./session-store"; +import { DaemonSessionStore, listSnapshots, type DaemonSessionRecord } from "./session-store"; import { createDaemonFetchHandler, type DaemonFetchContext, type DaemonFetchHandler, type SessionBrowserAction } from "./server"; import type { DaemonCreateSessionRequest } from "@plannotator/shared/daemon-protocol"; import type { DaemonEventHub } from "./event-hub"; @@ -40,6 +40,7 @@ export async function startDaemonRuntime(options: StartDaemonRuntimeOptions): Pr let lock: DaemonLock | undefined = lockResult.lock; const store = new DaemonSessionStore(); + const isRemote = isRemoteSession(); const hostname = options.hostname ?? getServerHostname(); const requestedPort = options.port ?? getServerPort(); @@ -81,6 +82,21 @@ export async function startDaemonRuntime(options: StartDaemonRuntimeOptions): Pr binaryVersion: options.binaryVersion, requestedPort, }); + + for (const snapshot of listSnapshots()) { + if (store.get(snapshot.sessionId)) continue; + store.create({ + id: snapshot.sessionId, + mode: snapshot.mode, + url: `${state.baseUrl}/s/${snapshot.sessionId}`, + project: snapshot.meta.project, + cwd: snapshot.meta.cwd, + label: snapshot.meta.label, + origin: snapshot.meta.origin, + result: snapshot.result, + }); + } + async function presentSession(record: DaemonSessionRecord, eventHub: DaemonEventHub): Promise { const config = loadConfig(); const frontendState = eventHub.getFrontendState(); From 8059bd2c2654d4f3bff8a319728f7523bd732214 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 16:38:56 -0700 Subject: [PATCH 09/30] Add worktree-aware project hierarchy to landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/landing/LandingPage.tsx | 183 +++++++++++++++--- apps/frontend/src/daemon/api/client.ts | 14 ++ apps/frontend/src/daemon/contracts.ts | 11 ++ goals/worktree-projects/facts.md | 47 +++++ goals/worktree-projects/goal.md | 19 ++ goals/worktree-projects/plan.md | 22 +++ packages/server/daemon/project-registry.ts | 62 +++++- packages/server/daemon/server.ts | 29 +++ packages/server/daemon/session-factory.ts | 10 +- packages/shared/daemon-protocol.ts | 2 + 10 files changed, 369 insertions(+), 30 deletions(-) create mode 100644 goals/worktree-projects/facts.md create mode 100644 goals/worktree-projects/goal.md create mode 100644 goals/worktree-projects/plan.md diff --git a/apps/frontend/src/components/landing/LandingPage.tsx b/apps/frontend/src/components/landing/LandingPage.tsx index 26d6c87e0..df4bcff1d 100644 --- a/apps/frontend/src/components/landing/LandingPage.tsx +++ b/apps/frontend/src/components/landing/LandingPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; -import { Code2, Archive, Folder, FolderPlus } from "lucide-react"; +import { Code2, Archive, Folder, FolderPlus, GitBranch, ChevronRight, ChevronDown } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { SidebarTrigger } from "@/components/ui/sidebar"; @@ -8,29 +8,32 @@ import { useProjectStore } from "../../stores/project-store"; import { useDaemonEventStore } from "../../daemon/events/event-store"; import { daemonApiClient } from "../../daemon/api/client"; import { getSessionModeMeta } from "../../shared/session-meta"; -import type { ProjectEntry, SessionSummary } from "../../daemon/contracts"; +import type { ProjectEntry, SessionSummary, WorktreeEntry } from "../../daemon/contracts"; interface LandingPageProps { onAddProject: () => void; } +interface Selection { + cwd: string; + label: string; +} + export function LandingPage({ onAddProject }: LandingPageProps) { const projects = useProjectStore((s) => s.projects); const sessions = useDaemonEventStore((s) => s.sessions); - const [selected, setSelected] = useState(null); + const [selected, setSelected] = useState(null); const [loading, setLoading] = useState(null); const navigate = useNavigate(); - const selectedProject = projects.find((p) => p.name === selected); - const handleAction = useCallback( async (action: "review" | "archive") => { - if (!selectedProject) return; + if (!selected) return; setLoading(action); const result = action === "review" - ? await daemonApiClient.createReviewSession(selectedProject.cwd) - : await daemonApiClient.createArchiveSession(selectedProject.cwd); + ? await daemonApiClient.createReviewSession(selected.cwd) + : await daemonApiClient.createArchiveSession(selected.cwd); setLoading(null); if (result.ok) { void navigate({ to: "/s/$sessionId", params: { sessionId: result.data.session.id } }); @@ -38,7 +41,7 @@ export function LandingPage({ onAddProject }: LandingPageProps) { toast.error(`Failed to start ${action}`, { description: result.error.message }); } }, - [selectedProject, navigate], + [selected, navigate], ); return ( @@ -63,7 +66,7 @@ export function LandingPage({ onAddProject }: LandingPageProps) { + )} - ))} -
+ + + {expanded && ( + <> + {children.map((child) => ( + + ))} + {worktrees.map((wt) => { + if (children.some((c) => c.cwd === wt.path)) return null; + return ( + + ); + })} + {loadingWorktrees && ( +
+ Loading worktrees... +
+ )} + + )} + ); } diff --git a/apps/frontend/src/daemon/api/client.ts b/apps/frontend/src/daemon/api/client.ts index 8fa11f8b6..731649c7f 100644 --- a/apps/frontend/src/daemon/api/client.ts +++ b/apps/frontend/src/daemon/api/client.ts @@ -11,6 +11,7 @@ import type { SessionListResponse, SessionResponse, SessionSummary, + WorktreeListResponse, } from "../contracts"; import { DaemonHubActionError, @@ -65,6 +66,7 @@ export interface DaemonApiClient { name?: string, ): Promise>; removeProject(name: string): Promise>; + listWorktrees(cwd: string): Promise>; createReviewSession(cwd: string): Promise>; createArchiveSession(cwd: string): Promise>; } @@ -178,6 +180,10 @@ function isProjectResponse(value: unknown): value is { ok: true; project: Projec return hasOkTrue(value) && isProjectEntry((value as { project?: unknown }).project); } +function isWorktreeList(value: unknown): value is WorktreeListResponse { + return hasOkTrue(value) && Array.isArray((value as { worktrees?: unknown }).worktrees); +} + function isSessionBootstrap(value: unknown): value is SessionBootstrap { return ( isSessionResponse(value) && @@ -451,6 +457,14 @@ export function createDaemonApiClient(options: DaemonApiClientOptions = {}): Dae ); }, + listWorktrees(cwd) { + return requestJson( + fetchImpl, + joinUrl(options.baseUrl, `/daemon/projects/worktrees?cwd=${encodeURIComponent(cwd)}`), + isWorktreeList, + ); + }, + createReviewSession(cwd) { return requestJson( fetchImpl, diff --git a/apps/frontend/src/daemon/contracts.ts b/apps/frontend/src/daemon/contracts.ts index 98168f8bc..00490b871 100644 --- a/apps/frontend/src/daemon/contracts.ts +++ b/apps/frontend/src/daemon/contracts.ts @@ -46,6 +46,17 @@ export interface ProjectListResponse { projects: ProjectEntry[]; } +export interface WorktreeEntry { + path: string; + branch: string | null; + head: string; +} + +export interface WorktreeListResponse { + ok: true; + worktrees: WorktreeEntry[]; +} + export type SessionLifecycleStatus = DaemonSessionStatus; export type DaemonServerMessage = DaemonWebSocketServerMessage; diff --git a/goals/worktree-projects/facts.md b/goals/worktree-projects/facts.md new file mode 100644 index 000000000..b60c54633 --- /dev/null +++ b/goals/worktree-projects/facts.md @@ -0,0 +1,47 @@ +# Worktree-Aware Project Hierarchy — Facts + +## Auto-Detection + +- When a user adds a directory that is a git worktree, the daemon auto-detects the parent repo using `git rev-parse --git-common-dir`. +- The parent repo becomes a top-level project entry if it doesn't already exist. +- The added worktree directory nests under the parent project automatically. +- Adding a regular repo (not a worktree) works the same as today — it becomes a top-level project. + +## Data Model + +- `DaemonProjectEntry` gains an optional `parentCwd` field. Worktree entries have `parentCwd` set to the parent repo's cwd. Regular projects leave it unset. +- The on-disk format (`~/.plannotator/projects.json`) stays a flat array. The tree structure is resolved at query time, not stored. +- A new optional `branch` field on `DaemonProjectEntry` stores the worktree's checked-out branch name for display. + +## Worktree Listing + +- Expanding a project node in the UI triggers a `git worktree list` call via a daemon API endpoint. +- Worktree data is fetched on demand, not cached or polled. Each expand gets fresh data. +- The daemon returns worktrees as an array of `{ path, branch, head }` using the existing `WorktreeInfo` type from `packages/shared/review-core.ts`. + +## Landing Page UI + +- The project table on the landing page shows projects as collapsible tree nodes. +- Projects with worktrees display a chevron/expand control. +- Clicking the chevron expands the node and shows worktrees indented underneath, each with its branch name and path. +- Both parent projects and worktree entries are selectable for launching sessions (Code Review, Browse Archive). +- Selecting a worktree entry passes its `cwd` (the worktree path) to the session creation API. +- Projects without worktrees display the same as today — a flat row with no expand control. +- Collapsed by default. + +## Sidebar + +- The sidebar continues to show only sessions, not projects. No change to sidebar project display. +- Sessions created from a worktree cwd show the branch name in their sidebar label for context. + +## Actions + +- All session actions (Code Review, Browse Archive, Plan, Annotate) work identically on both parent projects and worktree entries. +- The only difference is the `cwd` passed to the daemon — the parent repo path or the worktree path. + +## Out of Scope + +- Worktree creation or deletion from the UI. Users manage worktrees via git CLI. +- Sidebar project hierarchy. Projects stay landing-page-only. +- Automatic worktree scanning in the background or on a timer. +- Worktree-specific session grouping in the sidebar (sessions group by mode, not by worktree). diff --git a/goals/worktree-projects/goal.md b/goals/worktree-projects/goal.md new file mode 100644 index 000000000..6d0153c08 --- /dev/null +++ b/goals/worktree-projects/goal.md @@ -0,0 +1,19 @@ +# Worktree-Aware Project Hierarchy + +Make the project list understand git worktree relationships. Directories that are worktrees auto-detect their parent repo and nest underneath it. Projects with worktrees show them as expandable branches. Users can launch sessions scoped to any worktree. + +## Shared Understanding + +See `facts.md` for the approved fact sheet. + +## Execution Plan + +See `plan.md`. + +## Done Condition + +- Adding a worktree directory auto-detects parent and creates hierarchy +- Expanding a project shows its worktrees with branch names +- Sessions can be launched from any worktree entry +- Session sidebar labels include branch name for worktree sessions +- Typecheck and tests pass diff --git a/goals/worktree-projects/plan.md b/goals/worktree-projects/plan.md new file mode 100644 index 000000000..e3380bc0a --- /dev/null +++ b/goals/worktree-projects/plan.md @@ -0,0 +1,22 @@ +# Worktree-Aware Project Hierarchy — Plan + +## Approach + +Extend the project registry data model with `parentCwd` and `branch` fields. When a directory is added, detect if it's a worktree and auto-discover the parent repo. Add a daemon endpoint to list worktrees for a project. Update the landing page to render projects as collapsible tree nodes with worktrees nested underneath. Add branch names to session labels for worktree-scoped sessions. + +## Steps + +1. Extend `DaemonProjectEntry` type with optional `parentCwd` and `branch` +2. Add worktree detection to `registerProject` / `addProject` +3. Add `GET /daemon/projects/worktrees?cwd=` endpoint +4. Add `listWorktrees` to frontend API client +5. Refactor `ProjectTable` to collapsible tree with worktree children +6. Add branch name to session labels for worktree cwds + +## Verification + +- Add a worktree directory → parent auto-detected, nests correctly +- Expand project → worktrees listed with branch names +- Select worktree → launch code review scoped to that path +- Session label shows branch name +- Typecheck + tests pass diff --git a/packages/server/daemon/project-registry.ts b/packages/server/daemon/project-registry.ts index ce2501799..ec4d72b3f 100644 --- a/packages/server/daemon/project-registry.ts +++ b/packages/server/daemon/project-registry.ts @@ -1,7 +1,8 @@ import type { DaemonProjectEntry } from "@plannotator/shared/daemon-protocol"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { execSync } from "child_process"; import { homedir } from "os"; -import { join } from "path"; +import { join, resolve, dirname } from "path"; export interface ProjectRegistryOptions { baseDir?: string; @@ -80,6 +81,32 @@ export function listProjects(options: ProjectRegistryOptions = {}): DaemonProjec return entries.sort((a, b) => b.lastSeen.localeCompare(a.lastSeen)); } +export interface WorktreeDetection { + isWorktree: boolean; + parentCwd?: string; + branch?: string; +} + +export function detectWorktree(cwd: string): WorktreeDetection { + try { + const toplevel = execSync("git rev-parse --show-toplevel", { cwd, encoding: "utf-8" }).trim(); + const commonDir = execSync("git rev-parse --git-common-dir", { cwd, encoding: "utf-8" }).trim(); + const resolvedCommon = resolve(cwd, commonDir); + const mainRepoGitDir = resolve(toplevel, ".git"); + + if (resolvedCommon !== mainRepoGitDir) { + const parentCwd = dirname(resolvedCommon); + let branch: string | undefined; + try { + branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd, encoding: "utf-8" }).trim(); + if (branch === "HEAD") branch = undefined; + } catch {} + return { isWorktree: true, parentCwd, branch }; + } + } catch {} + return { isWorktree: false }; +} + export function addProject( cwd: string, name: string | undefined, @@ -93,6 +120,39 @@ export function addProject( if (!existsSync(resolved)) { throw new Error(`Directory does not exist: ${resolved}`); } + + const wt = detectWorktree(resolved); const projectName = name ?? resolved.split("/").filter(Boolean).pop() ?? "unknown"; + + if (wt.isWorktree && wt.parentCwd) { + const parentName = wt.parentCwd.split("/").filter(Boolean).pop() ?? "unknown"; + const parentEntries = readProjectRegistry(options); + if (!parentEntries.some((e) => e.cwd === wt.parentCwd)) { + registerProject(parentName, wt.parentCwd, options); + } + + const entries = readProjectRegistry(options); + const now = new Date().toISOString(); + const existing = entries.find((e) => e.cwd === resolved); + if (existing) { + existing.name = projectName; + existing.lastSeen = now; + existing.parentCwd = wt.parentCwd; + existing.branch = wt.branch; + writeProjectRegistry(entries, options); + return existing; + } + const entry: DaemonProjectEntry = { + name: projectName, + cwd: resolved, + lastSeen: now, + parentCwd: wt.parentCwd, + branch: wt.branch, + }; + entries.push(entry); + writeProjectRegistry(entries, options); + return entry; + } + return registerProject(projectName, resolved, options); } diff --git a/packages/server/daemon/server.ts b/packages/server/daemon/server.ts index 7e4ab8ae7..cf3934158 100644 --- a/packages/server/daemon/server.ts +++ b/packages/server/daemon/server.ts @@ -509,6 +509,35 @@ export function createDaemonFetchHandler(options: DaemonServerOptions): DaemonFe return json({ ok: true }); } + if (url.pathname === "/daemon/projects/worktrees" && req.method === "GET") { + const cwd = url.searchParams.get("cwd"); + if (!cwd) { + return json(createDaemonErrorResponse("invalid-request", "Worktree listing requires a cwd query parameter."), { status: 400 }); + } + try { + const { execSync } = await import("child_process"); + const raw = execSync("git worktree list --porcelain", { cwd, encoding: "utf-8" }); + const worktrees: { path: string; branch: string | null; head: string }[] = []; + let current: { path?: string; branch?: string | null; head?: string } = {}; + for (const line of raw.split("\n")) { + if (line.startsWith("worktree ")) { + if (current.path) worktrees.push({ path: current.path, branch: current.branch ?? null, head: current.head ?? "" }); + current = { path: line.slice(9) }; + } else if (line.startsWith("HEAD ")) { + current.head = line.slice(5); + } else if (line.startsWith("branch ")) { + current.branch = line.slice(7).replace(/^refs\/heads\//, ""); + } else if (line === "detached") { + current.branch = null; + } + } + if (current.path) worktrees.push({ path: current.path, branch: current.branch ?? null, head: current.head ?? "" }); + return json({ ok: true, worktrees }); + } catch (err) { + return json({ ok: true, worktrees: [] }); + } + } + const browserSession = sessionFromPath(url.pathname); if (browserSession) { let record = store.get(browserSession.id); diff --git a/packages/server/daemon/session-factory.ts b/packages/server/daemon/session-factory.ts index eadbdbe16..e7a124a9e 100644 --- a/packages/server/daemon/session-factory.ts +++ b/packages/server/daemon/session-factory.ts @@ -489,6 +489,12 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) const request = createRequest.request; const cwd = getRequestCwd(request); const project = (await detectProjectName(cwd)) ?? "_unknown"; + let branch: string | undefined; + try { + const { execSync } = await import("child_process"); + const b = execSync("git rev-parse --abbrev-ref HEAD", { cwd, encoding: "utf-8" }).trim(); + if (b && b !== "HEAD") branch = b; + } catch {} try { const tmp = tmpdir(); if (!cwd.startsWith(tmp)) registerProject(project, cwd); @@ -530,7 +536,7 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) url, project, cwd, - label: `plugin-plan-${request.origin}-${project}`, + label: branch ? `plugin-plan-${request.origin}-${project}-${branch}` : `plugin-plan-${request.origin}-${project}`, origin: request.origin, ttlMs, handleRequest: session.handleRequest, @@ -654,7 +660,7 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) cwd, label: input.prMetadata ? `plugin-${getMRLabel(input.prMetadata).toLowerCase()}-review-${getDisplayRepo(input.prMetadata)}${getMRNumberLabel(input.prMetadata)}` - : `plugin-review-${request.origin}-${project}`, + : branch ? `plugin-review-${request.origin}-${project}-${branch}` : `plugin-review-${request.origin}-${project}`, origin: request.origin, ttlMs, handleRequest: session.handleRequest, diff --git a/packages/shared/daemon-protocol.ts b/packages/shared/daemon-protocol.ts index 561b634cb..d7caf7909 100644 --- a/packages/shared/daemon-protocol.ts +++ b/packages/shared/daemon-protocol.ts @@ -132,6 +132,8 @@ export interface DaemonProjectEntry { name: string; cwd: string; lastSeen: string; + parentCwd?: string; + branch?: string; } export interface DaemonProjectListResponse { From 095fcfdf9863f7f11b33f0863e4fb51e3a2e7f8c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 16:45:45 -0700 Subject: [PATCH 10/30] 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 --- packages/server/daemon/project-registry.ts | 4 +++- packages/server/daemon/session-factory.ts | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/server/daemon/project-registry.ts b/packages/server/daemon/project-registry.ts index ec4d72b3f..50e8483f8 100644 --- a/packages/server/daemon/project-registry.ts +++ b/packages/server/daemon/project-registry.ts @@ -128,7 +128,9 @@ export function addProject( const parentName = wt.parentCwd.split("/").filter(Boolean).pop() ?? "unknown"; const parentEntries = readProjectRegistry(options); if (!parentEntries.some((e) => e.cwd === wt.parentCwd)) { - registerProject(parentName, wt.parentCwd, options); + const now = new Date().toISOString(); + parentEntries.push({ name: parentName, cwd: wt.parentCwd, lastSeen: now }); + writeProjectRegistry(parentEntries, options); } const entries = readProjectRegistry(options); diff --git a/packages/server/daemon/session-factory.ts b/packages/server/daemon/session-factory.ts index e7a124a9e..2334964cb 100644 --- a/packages/server/daemon/session-factory.ts +++ b/packages/server/daemon/session-factory.ts @@ -565,7 +565,7 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) url, project, cwd, - label: `plugin-archive-${request.origin}-${project}`, + label: branch ? `plugin-archive-${request.origin}-${project}-${branch}` : `plugin-archive-${request.origin}-${project}`, origin: request.origin, ttlMs, handleRequest: session.handleRequest, @@ -602,8 +602,8 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) project, cwd, label: input.folderPath - ? `plugin-annotate-${request.origin}-${basename(input.folderPath)}` - : `plugin-annotate-${request.origin}-${input.mode === "annotate-last" ? "last" : basename(input.filePath)}`, + ? `plugin-annotate-${request.origin}-${basename(input.folderPath)}${branch ? `-${branch}` : ""}` + : `plugin-annotate-${request.origin}-${input.mode === "annotate-last" ? "last" : basename(input.filePath)}${branch ? `-${branch}` : ""}`, origin: request.origin, ttlMs, handleRequest: session.handleRequest, @@ -691,7 +691,7 @@ export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) url, project, cwd, - label: `goal-setup-${bundle.stage}-${request.goalSlug || project}`, + label: branch ? `goal-setup-${bundle.stage}-${request.goalSlug || project}-${branch}` : `goal-setup-${bundle.stage}-${request.goalSlug || project}`, origin: request.origin, ttlMs, htmlContent: session.htmlContent, From 9c510aa81b01ec8858d17486dc846bec177d169e Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 17:04:20 -0700 Subject: [PATCH 11/30] 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. --- apps/frontend/src/stores/project-store.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/stores/project-store.ts b/apps/frontend/src/stores/project-store.ts index 907ffb233..230f08c87 100644 --- a/apps/frontend/src/stores/project-store.ts +++ b/apps/frontend/src/stores/project-store.ts @@ -59,14 +59,23 @@ export function createProjectStore(initial: Partial = {}) { return undefined; } const entry = result.data.project; - set((state) => { - const idx = state.projects.findIndex((p) => p.name === entry.name); - if (idx >= 0) { - state.projects[idx] = entry; - } else { - state.projects.unshift(entry); + if (entry.parentCwd) { + const listResult = await client.listProjects(); + if (listResult.ok) { + set((state) => { + state.projects = listResult.data.projects; + }); } - }); + } else { + set((state) => { + const idx = state.projects.findIndex((p) => p.cwd === entry.cwd); + if (idx >= 0) { + state.projects[idx] = entry; + } else { + state.projects.unshift(entry); + } + }); + } return entry; }, From 4260c731acc83287e9b4a31eeb19e3c0c68341a7 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 18:08:15 -0700 Subject: [PATCH 12/30] Filter temp directory worktrees from project listing --- packages/server/daemon/server.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/server/daemon/server.ts b/packages/server/daemon/server.ts index cf3934158..6b5355221 100644 --- a/packages/server/daemon/server.ts +++ b/packages/server/daemon/server.ts @@ -532,7 +532,10 @@ export function createDaemonFetchHandler(options: DaemonServerOptions): DaemonFe } } if (current.path) worktrees.push({ path: current.path, branch: current.branch ?? null, head: current.head ?? "" }); - return json({ ok: true, worktrees }); + const { tmpdir } = await import("os"); + const tmp = tmpdir(); + const filtered = worktrees.filter((wt) => !wt.path.startsWith(tmp) && !wt.path.startsWith("/private" + tmp)); + return json({ ok: true, worktrees: filtered }); } catch (err) { return json({ ok: true, worktrees: [] }); } From 9e3da6d7203319d51658687cde28b803ee451593 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 18:17:46 -0700 Subject: [PATCH 13/30] Sort worktrees by last activity (index mtime > commit time > dir mtime) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/frontend/src/daemon/contracts.ts | 1 + packages/server/daemon/server.ts | 29 ++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/daemon/contracts.ts b/apps/frontend/src/daemon/contracts.ts index 00490b871..7fa8199d5 100644 --- a/apps/frontend/src/daemon/contracts.ts +++ b/apps/frontend/src/daemon/contracts.ts @@ -50,6 +50,7 @@ export interface WorktreeEntry { path: string; branch: string | null; head: string; + lastActive: number; } export interface WorktreeListResponse { diff --git a/packages/server/daemon/server.ts b/packages/server/daemon/server.ts index 6b5355221..ea289cac3 100644 --- a/packages/server/daemon/server.ts +++ b/packages/server/daemon/server.ts @@ -533,9 +533,36 @@ export function createDaemonFetchHandler(options: DaemonServerOptions): DaemonFe } if (current.path) worktrees.push({ path: current.path, branch: current.branch ?? null, head: current.head ?? "" }); const { tmpdir } = await import("os"); + const { statSync, existsSync } = await import("fs"); + const { join, resolve: resolvePath } = await import("path"); const tmp = tmpdir(); const filtered = worktrees.filter((wt) => !wt.path.startsWith(tmp) && !wt.path.startsWith("/private" + tmp)); - return json({ ok: true, worktrees: filtered }); + + const withActivity = filtered.map((wt) => { + let lastActive = 0; + try { + const gitDir = execSync("git rev-parse --git-dir", { cwd: wt.path, encoding: "utf-8" }).trim(); + const indexPath = join(resolvePath(wt.path, gitDir), "index"); + if (existsSync(indexPath)) { + lastActive = statSync(indexPath).mtimeMs; + } + } catch {} + if (!lastActive) { + try { + const commitTime = execSync("git log -1 --format=%ct", { cwd: wt.path, encoding: "utf-8" }).trim(); + if (commitTime) lastActive = Number(commitTime) * 1000; + } catch {} + } + if (!lastActive) { + try { + lastActive = statSync(wt.path).mtimeMs; + } catch {} + } + return { ...wt, lastActive }; + }); + + withActivity.sort((a, b) => b.lastActive - a.lastActive); + return json({ ok: true, worktrees: withActivity }); } catch (err) { return json({ ok: true, worktrees: [] }); } From 3422418b181784fa127b7f61a43182eaac1b534c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 18:49:18 -0700 Subject: [PATCH 14/30] Fix toast: skip for frontend-initiated sessions, clean label, fix colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/frontend/src/app/Layout.tsx | 2 ++ apps/frontend/src/components/landing/LandingPage.tsx | 4 ++-- apps/frontend/src/daemon/events/use-daemon-events.ts | 6 +++++- packages/server/daemon/server.ts | 3 ++- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/app/Layout.tsx b/apps/frontend/src/app/Layout.tsx index e0ba8d480..ee5808a34 100644 --- a/apps/frontend/src/app/Layout.tsx +++ b/apps/frontend/src/app/Layout.tsx @@ -68,6 +68,8 @@ function LayoutContent() { "--normal-bg": "var(--card)", "--normal-border": "var(--border)", "--normal-text": "var(--foreground)", + "--normal-action-bg": "var(--primary)", + "--normal-action-text": "var(--primary-foreground)", } as React.CSSProperties, }} /> diff --git a/apps/frontend/src/components/landing/LandingPage.tsx b/apps/frontend/src/components/landing/LandingPage.tsx index df4bcff1d..2ce502aeb 100644 --- a/apps/frontend/src/components/landing/LandingPage.tsx +++ b/apps/frontend/src/components/landing/LandingPage.tsx @@ -54,7 +54,7 @@ export function LandingPage({ onAddProject }: LandingPageProps) {
-
+
{projects.length === 0 && sessions.length === 0 ? ( ) : ( @@ -287,7 +287,7 @@ function ProjectNode({ : "text-muted-foreground hover:bg-surface-1/50 hover:text-foreground", )} > - + {wt.branch ?? "detached"} {wt.path} diff --git a/apps/frontend/src/daemon/events/use-daemon-events.ts b/apps/frontend/src/daemon/events/use-daemon-events.ts index ebcb76c56..b2f8791f7 100644 --- a/apps/frontend/src/daemon/events/use-daemon-events.ts +++ b/apps/frontend/src/daemon/events/use-daemon-events.ts @@ -23,8 +23,12 @@ export function useDaemonEvents(client: DaemonApiClient = daemonApiClient, enabl const handleSessionNotify = useCallback( (session: { id: string; mode: string; project: string; label: string }) => { const modeLabel = MODE_LABELS[session.mode] ?? session.mode; + const cleanLabel = session.label + .replace(/^plugin-(plan|review|annotate|archive)-/, "") + .replace(/^(claude-code|opencode|pi|plannotator-frontend|codex|copilot-cli|gemini-cli)-/, "") + .replace(/^goal-setup-(interview|facts)-/, ""); toast(`${modeLabel} — ${session.project}`, { - description: session.label, + description: cleanLabel !== session.project ? cleanLabel : undefined, duration: 8000, action: { label: "Open", diff --git a/packages/server/daemon/server.ts b/packages/server/daemon/server.ts index ea289cac3..e65e16d8d 100644 --- a/packages/server/daemon/server.ts +++ b/packages/server/daemon/server.ts @@ -394,7 +394,8 @@ export function createDaemonFetchHandler(options: DaemonServerOptions): DaemonFe try { requestContext?.disableIdleTimeout?.(); const record = await options.createSession(body, context); - const browserAction = options.presentSession + const isFrontendInitiated = record.origin === "plannotator-frontend"; + const browserAction = options.presentSession && !isFrontendInitiated ? await options.presentSession(record, eventHub).catch((): SessionBrowserAction => "opened") : undefined; return json({ From f402ecdc12c93fc608a362f280e5abea9cc772fc Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 19:16:32 -0700 Subject: [PATCH 15/30] 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. --- .../components/landing/AddProjectDialog.tsx | 342 +++++++++++++----- apps/frontend/src/daemon/api/client.ts | 14 + apps/frontend/src/daemon/contracts.ts | 11 + packages/server/daemon/server.ts | 22 ++ 4 files changed, 299 insertions(+), 90 deletions(-) diff --git a/apps/frontend/src/components/landing/AddProjectDialog.tsx b/apps/frontend/src/components/landing/AddProjectDialog.tsx index 0b5339fd7..51c034eed 100644 --- a/apps/frontend/src/components/landing/AddProjectDialog.tsx +++ b/apps/frontend/src/components/landing/AddProjectDialog.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { X } from "lucide-react"; +import { Folder, ChevronRight, X, CornerDownLeft } from "lucide-react"; import { cn } from "@/lib/utils"; import { useProjectStore } from "../../stores/project-store"; +import { daemonApiClient } from "../../daemon/api/client"; +import type { DirectoryEntry, ProjectEntry } from "../../daemon/contracts"; interface AddProjectDialogProps { open: boolean; @@ -9,129 +11,289 @@ interface AddProjectDialogProps { } export function AddProjectDialog({ open, onOpenChange }: AddProjectDialogProps) { - const [cwd, setCwd] = useState(""); - const [name, setName] = useState(""); + const [query, setQuery] = useState("~"); + const [resolvedPath, setResolvedPath] = useState(""); + const [dirs, setDirs] = useState([]); const [loading, setLoading] = useState(false); - const [error, setError] = useState(); + const [adding, setAdding] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const projects = useProjectStore((s) => s.projects); const addProject = useProjectStore((s) => s.addProject); const inputRef = useRef(null); + const listRef = useRef(null); - useEffect(() => { - if (open) { - setCwd(""); - setName(""); - setError(undefined); - requestAnimationFrame(() => inputRef.current?.focus()); + const recentProjects = projects.slice(0, 5); + + const fetchDirs = useCallback(async (path: string) => { + setLoading(true); + const result = await daemonApiClient.listDirectories(path); + if (result.ok) { + setResolvedPath(result.data.path); + setDirs(result.data.dirs); + } else { + setDirs([]); } - }, [open]); + setLoading(false); + setActiveIndex(0); + }, []); + + useEffect(() => { + if (!open) return; + setQuery("~"); + setDirs([]); + setResolvedPath(""); + setActiveIndex(0); + fetchDirs("~"); + requestAnimationFrame(() => inputRef.current?.focus()); + }, [open, fetchDirs]); useEffect(() => { if (!open) return; - const handle = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.stopPropagation(); + const timer = setTimeout(() => { + if (query.trim()) fetchDirs(query.trim()); + }, 150); + return () => clearTimeout(timer); + }, [query, open, fetchDirs]); + + const handleSelect = useCallback( + async (path: string) => { + setAdding(true); + const result = await addProject(path); + setAdding(false); + if (result) { onOpenChange(false); } - }; - window.addEventListener("keydown", handle); - return () => window.removeEventListener("keydown", handle); - }, [open, onOpenChange]); - - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - if (!cwd.trim()) return; - setLoading(true); - setError(undefined); - const result = await addProject(cwd.trim(), name.trim() || undefined); - setLoading(false); - if (result) { + }, + [addProject, onOpenChange], + ); + + const handleNavigate = useCallback( + (path: string) => { + const display = path.replace(resolvedPath === "/" ? "" : resolvedPath, resolvedPath === "/" ? "/" : ""); + setQuery(resolvedPath === "/" ? path : path.replace(/.*\//, resolvedPath + "/")); + setQuery(path); + fetchDirs(path); + }, + [resolvedPath, fetchDirs], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const totalItems = recentProjects.length + dirs.length; + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((prev) => (prev + 1) % totalItems); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((prev) => (prev - 1 + totalItems) % totalItems); + } else if (e.key === "Tab" && !e.shiftKey && dirs.length > 0) { + e.preventDefault(); + const dirIndex = activeIndex - recentProjects.length; + if (dirIndex >= 0 && dirIndex < dirs.length) { + handleNavigate(dirs[dirIndex].path); + } else if (dirs.length > 0) { + handleNavigate(dirs[0].path); + } + } else if (e.key === "Enter") { + e.preventDefault(); + if (activeIndex < recentProjects.length) { + handleSelect(recentProjects[activeIndex].cwd); + } else { + const dirIndex = activeIndex - recentProjects.length; + if (dirIndex >= 0 && dirIndex < dirs.length) { + handleSelect(dirs[dirIndex].path); + } else if (resolvedPath) { + handleSelect(resolvedPath); + } + } + } else if (e.key === "Escape") { onOpenChange(false); - } else { - setError("Failed to add project. Check the path exists."); } }, - [cwd, name, addProject, onOpenChange], + [activeIndex, dirs, recentProjects, resolvedPath, handleNavigate, handleSelect, onOpenChange], ); + useEffect(() => { + const el = listRef.current?.querySelector(`[data-index="${activeIndex}"]`); + el?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + if (!open) return null; + const shortenPath = (p: string) => + resolvedPath && p.startsWith(resolvedPath) + ? p.slice(resolvedPath.length + 1) || p + : p; + return (
onOpenChange(false)} >
e.stopPropagation()} > -
-

Add project

+
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="~/work/project or search..." + autoComplete="off" + spellCheck={false} + className="flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/50" + /> + {adding && Adding...}
-

- Register a project directory to launch sessions from. -

- -
-
-
- - setCwd(e.target.value)} - className="h-9 w-full rounded-md border border-input bg-background px-3 text-[13px] outline-none placeholder:text-muted-foreground/50 focus:border-ring focus:ring-[3px] focus:ring-ring/50" - /> -
-
- - setName(e.target.value)} - className="h-9 w-full rounded-md border border-input bg-background px-3 text-[13px] outline-none placeholder:text-muted-foreground/50 focus:border-ring focus:ring-[3px] focus:ring-ring/50" - /> + +
+ {recentProjects.length > 0 && ( +
+ + Recent + + {recentProjects.map((project, i) => ( + handleSelect(project.cwd)} + onHover={() => setActiveIndex(i)} + /> + ))}
- {error &&

{error}

} -
+ )} -
- - +
+ {recentProjects.length > 0 && dirs.length > 0 && ( + + Directories + + )} + {dirs.map((dir, i) => { + const idx = recentProjects.length + i; + return ( + handleSelect(dir.path)} + onNavigate={() => handleNavigate(dir.path)} + onHover={() => setActiveIndex(idx)} + /> + ); + })} + {!loading && dirs.length === 0 && recentProjects.length === 0 && ( +
+ No directories found +
+ )}
- +
+ +
+ + select + + + Tab navigate into + + + Esc close + +
); } + +function ProjectRow({ + project, + active, + index, + onSelect, + onHover, +}: { + project: ProjectEntry; + active: boolean; + index: number; + onSelect: () => void; + onHover: () => void; +}) { + return ( + + ); +} + +function DirectoryRow({ + dir, + displayName, + active, + index, + onSelect, + onNavigate, + onHover, +}: { + dir: DirectoryEntry; + displayName: string; + active: boolean; + index: number; + onSelect: () => void; + onNavigate: () => void; + onHover: () => void; +}) { + return ( + + + ); +} diff --git a/apps/frontend/src/daemon/api/client.ts b/apps/frontend/src/daemon/api/client.ts index 731649c7f..09d66bcdf 100644 --- a/apps/frontend/src/daemon/api/client.ts +++ b/apps/frontend/src/daemon/api/client.ts @@ -12,6 +12,7 @@ import type { SessionResponse, SessionSummary, WorktreeListResponse, + DirectoryListResponse, } from "../contracts"; import { DaemonHubActionError, @@ -67,6 +68,7 @@ export interface DaemonApiClient { ): Promise>; removeProject(name: string): Promise>; listWorktrees(cwd: string): Promise>; + listDirectories(path?: string): Promise>; createReviewSession(cwd: string): Promise>; createArchiveSession(cwd: string): Promise>; } @@ -184,6 +186,10 @@ function isWorktreeList(value: unknown): value is WorktreeListResponse { return hasOkTrue(value) && Array.isArray((value as { worktrees?: unknown }).worktrees); } +function isDirectoryList(value: unknown): value is DirectoryListResponse { + return hasOkTrue(value) && Array.isArray((value as { dirs?: unknown }).dirs); +} + function isSessionBootstrap(value: unknown): value is SessionBootstrap { return ( isSessionResponse(value) && @@ -457,6 +463,14 @@ export function createDaemonApiClient(options: DaemonApiClientOptions = {}): Dae ); }, + listDirectories(path = "~") { + return requestJson( + fetchImpl, + joinUrl(options.baseUrl, `/daemon/fs/list?path=${encodeURIComponent(path)}`), + isDirectoryList, + ); + }, + listWorktrees(cwd) { return requestJson( fetchImpl, diff --git a/apps/frontend/src/daemon/contracts.ts b/apps/frontend/src/daemon/contracts.ts index 7fa8199d5..c2e07534a 100644 --- a/apps/frontend/src/daemon/contracts.ts +++ b/apps/frontend/src/daemon/contracts.ts @@ -58,6 +58,17 @@ export interface WorktreeListResponse { worktrees: WorktreeEntry[]; } +export interface DirectoryEntry { + name: string; + path: string; +} + +export interface DirectoryListResponse { + ok: true; + path: string; + dirs: DirectoryEntry[]; +} + export type SessionLifecycleStatus = DaemonSessionStatus; export type DaemonServerMessage = DaemonWebSocketServerMessage; diff --git a/packages/server/daemon/server.ts b/packages/server/daemon/server.ts index e65e16d8d..bfc2f65bb 100644 --- a/packages/server/daemon/server.ts +++ b/packages/server/daemon/server.ts @@ -471,6 +471,28 @@ export function createDaemonFetchHandler(options: DaemonServerOptions): DaemonFe return json({ ok: true, shuttingDown: true }); } + if (url.pathname === "/daemon/fs/list" && req.method === "GET") { + const rawPath = url.searchParams.get("path") ?? "~"; + try { + const { readdirSync, statSync } = await import("fs"); + const { homedir } = await import("os"); + const { join, resolve: resolvePath } = await import("path"); + const resolved = rawPath === "~" || rawPath === "~/" + ? homedir() + : rawPath.startsWith("~/") + ? join(homedir(), rawPath.slice(2)) + : resolvePath(rawPath); + const entries = readdirSync(resolved, { withFileTypes: true }); + const dirs = entries + .filter((e) => e.isDirectory() && !e.name.startsWith(".")) + .map((e) => ({ name: e.name, path: join(resolved, e.name) })) + .sort((a, b) => a.name.localeCompare(b.name)); + return json({ ok: true, path: resolved, dirs }); + } catch { + return json({ ok: true, path: rawPath, dirs: [] }); + } + } + if (url.pathname === "/daemon/projects" && req.method === "GET") { return json({ ok: true, projects: listProjects() }); } From 942ddd02d6743141fa587535eaf67a373d9f7390 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 19:19:14 -0700 Subject: [PATCH 16/30] 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 --- .../src/components/landing/LandingPage.tsx | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/apps/frontend/src/components/landing/LandingPage.tsx b/apps/frontend/src/components/landing/LandingPage.tsx index 2ce502aeb..dea136604 100644 --- a/apps/frontend/src/components/landing/LandingPage.tsx +++ b/apps/frontend/src/components/landing/LandingPage.tsx @@ -192,24 +192,20 @@ function ProjectNode({ }) { const [expanded, setExpanded] = useState(false); const [worktrees, setWorktrees] = useState([]); - const [loadingWorktrees, setLoadingWorktrees] = useState(false); + const [worktreesFetched, setWorktreesFetched] = useState(false); const hasChildren = children.length > 0; - const handleToggle = useCallback(async () => { - if (expanded) { - setExpanded(false); - return; - } - setExpanded(true); - if (worktrees.length === 0 && !loadingWorktrees) { - setLoadingWorktrees(true); - const result = await daemonApiClient.listWorktrees(project.cwd); + useEffect(() => { + if (worktreesFetched) return; + setWorktreesFetched(true); + daemonApiClient.listWorktrees(project.cwd).then((result) => { if (result.ok) { setWorktrees(result.data.worktrees.filter((wt) => wt.path !== project.cwd)); } - setLoadingWorktrees(false); - } - }, [expanded, worktrees.length, loadingWorktrees, project.cwd]); + }); + }, [project.cwd, worktreesFetched]); + + const hasWorktrees = hasChildren || worktrees.length > 0; const isSelected = selectedCwd === project.cwd; @@ -224,10 +220,10 @@ function ProjectNode({ : "text-muted-foreground hover:bg-surface-1/50 hover:text-foreground", )} > - {(hasChildren || true) && ( + {hasWorktrees ? ( + ) : ( +
)} - ) : ( -
+ {expanded ? : } + )} - -
+ {expanded && ( <> @@ -269,7 +259,6 @@ function ProjectNode({ : "text-muted-foreground hover:bg-surface-1/50 hover:text-foreground", )} > - {child.branch ?? child.name} {child.cwd} @@ -288,7 +277,6 @@ function ProjectNode({ : "text-muted-foreground hover:bg-surface-1/50 hover:text-foreground", )} > - {wt.branch ?? "detached"} {wt.path} From 17b508cbddcaf6f962336f73d851d27814494a24 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 20:02:43 -0700 Subject: [PATCH 19/30] Add ASCII art Plannotator banner to landing page --- apps/frontend/src/components/landing/LandingPage.tsx | 5 +++++ apps/frontend/src/components/landing/ascii-banner.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 apps/frontend/src/components/landing/ascii-banner.ts diff --git a/apps/frontend/src/components/landing/LandingPage.tsx b/apps/frontend/src/components/landing/LandingPage.tsx index 2e5fc4f50..76bcfdc36 100644 --- a/apps/frontend/src/components/landing/LandingPage.tsx +++ b/apps/frontend/src/components/landing/LandingPage.tsx @@ -3,6 +3,7 @@ import { Link, useNavigate } from "@tanstack/react-router"; import { Code2, Archive, Folder, FolderPlus, ChevronRight, ChevronDown } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; +import { ASCII_BANNER } from "./ascii-banner"; import { SidebarTrigger } from "@/components/ui/sidebar"; import { useProjectStore } from "../../stores/project-store"; import { useDaemonEventStore } from "../../daemon/events/event-store"; @@ -55,6 +56,10 @@ export function LandingPage({ onAddProject }: LandingPageProps) {
+ + {projects.length === 0 && sessions.length === 0 ? ( ) : ( diff --git a/apps/frontend/src/components/landing/ascii-banner.ts b/apps/frontend/src/components/landing/ascii-banner.ts new file mode 100644 index 000000000..90adff3b1 --- /dev/null +++ b/apps/frontend/src/components/landing/ascii-banner.ts @@ -0,0 +1 @@ +export const ASCII_BANNER = " _ __ ,---. .-._ .-._ _,.---._ ,--.--------. ,---. ,--.--------. _,.---._ \n .-`.' ,`. _.-. .--.' \\ /==/ \\ .-._/==/ \\ .-._ ,-.' , - `. /==/, - , -\\.--.' \\ /==/, - , -\\,-.' , - `. .-.,.---. \n /==/, - \\.-,.'| \\==\\-/\\ \\ |==|, \\/ /, /==|, \\/ /, /==/_, , - \\\\==\\.-. - ,-./\\==\\-/\\ \\\\==\\.-. - ,-./==/_, , - \\ /==/ ` \\ \n|==| _ .=. |==|, | /==/-|_\\ | |==|- \\| ||==|- \\| |==| .=. |`--`\\==\\- \\ /==/-|_\\ |`--`\\==\\- \\ |==| .=. |==|-, .=., | \n|==| , '=',|==|- | \\==\\, - \\ |==| , | -||==| , | -|==|_ : ;=: - | \\==\\_ \\ \\==\\, - \\ \\==\\_ \\|==|_ : ;=: - |==| '=' / \n|==|- '..'|==|, | /==/ - ,| |==| - _ ||==| - _ |==| , '=' | |==|- | /==/ - ,| |==|- ||==| , '=' |==|- , .' \n|==|, | |==|- `-._/==/- /\\ - \\|==| /\\ , ||==| /\\ , |\\==\\ - ,_ / |==|, | /==/- /\\ - \\ |==|, | \\==\\ - ,_ /|==|_ . ,'. \n/==/ - | /==/ - , ,|==\\ _.\\=\\.-'/==/, | |- |/==/, | |- | '.='. - .' /==/ -/ \\==\\ _.\\=\\.-' /==/ -/ '.='. - .' /==/ /\\ , ) \n`--`---' `--`-----' `--` `--`./ `--``--`./ `--` `--`--'' `--`--` `--` `--`--` `--`--'' `--`-`--`--' "; From 0da3d43e31c83245fef9ff58c14c57b7ce5ecccf Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 20:04:18 -0700 Subject: [PATCH 20/30] Increase ASCII banner opacity to 70% --- apps/frontend/src/components/landing/LandingPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/components/landing/LandingPage.tsx b/apps/frontend/src/components/landing/LandingPage.tsx index 76bcfdc36..001165a85 100644 --- a/apps/frontend/src/components/landing/LandingPage.tsx +++ b/apps/frontend/src/components/landing/LandingPage.tsx @@ -56,7 +56,7 @@ export function LandingPage({ onAddProject }: LandingPageProps) {
-