diff --git a/AGENTS.md b/AGENTS.md index d49167ae4..46baf6ae1 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. @@ -234,6 +235,16 @@ The daemon is the single long-running Bun server used by normal plan/review/anno | `/daemon/sessions/:id/cancel` | POST | Cancel a session and dispose its resources | | `/daemon/sessions/:id` | DELETE | Delete a session record | | `/daemon/shutdown` | POST | Ask the daemon to stop | +| `/daemon/config` | GET | Read global config (`~/.plannotator/config.json`) | +| `/daemon/config` | POST | Write global config keys (allowlisted: `displayName`, `pfmReminder`, `legacyTabMode`, `diffOptions`, `conventionalComments`, `conventionalLabels`) | +| `/daemon/git/user` | GET | Return git user name from `git config user.name` | +| `/daemon/vaults` | GET | Detect available Obsidian vaults | +| `/daemon/obsidian/vaults` | GET | Alias for `/daemon/vaults` | +| `/daemon/hooks/status` | GET | Return PFM reminder and improvement hook status | +| `/daemon/projects` | DELETE | Remove a project by `?cwd=` (optional `?clean=1` to cancel active sessions) | +| `/daemon/projects/prs` | GET | List open PRs for a project (`?cwd=`) | +| `/daemon/projects/prs/detailed` | GET | List PRs with review metadata for dashboard (`?cwd=`) | +| `/daemon/fs/list` | GET | List directory contents (`?path=`) | | `/daemon/ws` | WebSocket | Multiplex daemon lifecycle events, session-scoped external annotation events, agent job events, and correlated session actions | | `/s/:id` | GET | Serve the browser HTML for a session | | `/s/:id/api/...` | Any | Route browser API requests to that session's plan/review/annotate handler | diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 44b5405ff..061a8540a 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -16,17 +16,20 @@ }, "dependencies": { "@fontsource-variable/geist-mono": "^5.2.7", + "@fontsource-variable/instrument-sans": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", "@plannotator/code-review": "workspace:*", "@plannotator/plan-review": "workspace:*", "@plannotator/shared": "workspace:*", "@plannotator/ui": "workspace:*", "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-router": "^1.141.0", "class-variance-authority": "^0.7.1", diff --git a/apps/frontend/src/app/Layout.tsx b/apps/frontend/src/app/Layout.tsx index 9e45dd090..071f13d0d 100644 --- a/apps/frontend/src/app/Layout.tsx +++ b/apps/frontend/src/app/Layout.tsx @@ -4,9 +4,15 @@ import { Toaster } from "sonner"; import { SidebarProvider, useSidebar } from "@/components/ui/sidebar"; import { TooltipProvider } from "@/components/ui/tooltip"; import { AppSidebar } from "../components/sidebar/AppSidebar"; +import { SidebarPeek } from "../components/sidebar/SidebarPeek"; import { AddProjectDialog } from "../components/landing/AddProjectDialog"; +import { AppSettingsDialog } from "../components/settings/AppSettingsDialog"; import { SessionSurface } from "../components/sessions/SessionSurface"; +import { appStore } from "../stores/app-store"; +import { setGlobalFetchBase } from "@plannotator/ui/utils/api"; import { useDaemonEvents } from "../daemon/events/use-daemon-events"; + +setGlobalFetchBase("/daemon"); import { projectStore } from "../stores/project-store"; import { useAppStore } from "../stores/app-store"; @@ -18,20 +24,35 @@ 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]); + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === ",") { + e.preventDefault(); + const current = appStore.getState().settingsOpen; + appStore.getState().setSettingsOpen(!current); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); return ( <> - + + - {Object.values(visitedSessions).map(({ sessionId, bootstrap }) => ( - - - - ))} + {Object.values(visitedSessions).map(({ sessionId, bootstrap }) => { + const isActive = sessionId === activeSessionId && isOnSession; + return ( + + + + ); + })} + @@ -72,10 +101,13 @@ function LayoutContent() { } export function Layout() { + const matchRoute = useMatchRoute(); + const initiallyOnSession = !!matchRoute({ to: "/s/$sessionId", fuzzy: true }); + return ( diff --git a/apps/frontend/src/assets/sprite_package_sidebar/sprite.png b/apps/frontend/src/assets/sprite_package_sidebar/sprite.png new file mode 100644 index 000000000..a209d60cc Binary files /dev/null and b/apps/frontend/src/assets/sprite_package_sidebar/sprite.png differ diff --git a/apps/frontend/src/components/landing/AddProjectDialog.tsx b/apps/frontend/src/components/landing/AddProjectDialog.tsx index 0b5339fd7..88fb4b00a 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,278 @@ 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) => { + setQuery(path); + fetchDirs(path); + }, + [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; 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-base outline-none placeholder:text-muted-foreground sm:text-[13px]" + /> + {adding && Adding…} onOpenChange(false)} - className="rounded-md p-1.5 text-muted-foreground/80 hover:bg-accent hover:text-foreground" + className="rounded-md p-1 text-muted-foreground hover:text-foreground" > - + - - Register a project directory to launch sessions from. - - - - - - - Directory path - - 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" - /> - - - - Name (optional) - - 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}} - + )} - - onOpenChange(false)} - className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-[13px] font-medium hover:bg-accent hover:text-accent-foreground" - > - Cancel - - - {loading ? "Adding..." : "Add project"} - + + {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 ( + + + {project.name} + + {project.cwd.replace(/^\/Users\/[^/]+/, "~")} + + + ); +} + +function DirectoryRow({ + dir, + active, + index, + onSelect, + onNavigate, + onHover, +}: { + dir: DirectoryEntry; + active: boolean; + index: number; + onSelect: () => void; + onNavigate: () => void; + onHover: () => void; +}) { + return ( + + + + {dir.name} + + + + + + ); +} diff --git a/apps/frontend/src/components/landing/LandingPage.tsx b/apps/frontend/src/components/landing/LandingPage.tsx index 26d6c87e0..1ca7ba6f0 100644 --- a/apps/frontend/src/components/landing/LandingPage.tsx +++ b/apps/frontend/src/components/landing/LandingPage.tsx @@ -1,141 +1,268 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; -import { Code2, Archive, Folder, FolderPlus } from "lucide-react"; +import { + Code2, + Archive, + Folder, + FolderPlus, + ChevronRight, + ChevronDown, + Trash2, +} from "lucide-react"; +import * as ContextMenu from "@radix-ui/react-context-menu"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { PullRequestIcon } from "@plannotator/ui/components/PullRequestIcon"; +import { ASCII_BANNER } from "./ascii-banner"; import { SidebarTrigger } from "@/components/ui/sidebar"; -import { useProjectStore } from "../../stores/project-store"; +import { useProjectStore, projectStore } from "../../stores/project-store"; +import { GitDashboard } from "./git-dashboard/GitDashboard"; 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 { getSessionModeMeta, formatSessionLabel } from "../../shared/session-meta"; +import type { + ProjectEntry, + PRListItem, + SessionSummary, + WorktreeEntry, +} from "../../daemon/contracts"; interface LandingPageProps { onAddProject: () => void; } +interface Selection { + key: string; + cwd: string; + label: string; + prUrl?: string; +} + +function selectionKey(sel: Omit): string { + return sel.prUrl ?? sel.cwd; +} + export function LandingPage({ onAddProject }: LandingPageProps) { const projects = useProjectStore((s) => s.projects); const sessions = useDaemonEventStore((s) => s.sessions); - const [selected, setSelected] = useState(null); + const [selections, setSelections] = useState>(new Map()); + useEffect(() => { + const cwds = new Set(projects.map((p) => p.cwd)); + setSelections((prev) => { + const next = new Map(); + for (const [k, sel] of prev) { + if (cwds.has(sel.cwd)) next.set(k, sel); + } + return next.size === prev.size ? prev : next; + }); + }, [projects]); const [loading, setLoading] = useState(null); + const [viewIndex, setViewIndex] = useState(() => + typeof window !== "undefined" && window.location.hash === "#dashboard" ? 1 : 0, + ); const navigate = useNavigate(); - const selectedProject = projects.find((p) => p.name === selected); + const toggleSelection = useCallback((sel: Omit) => { + setSelections((prev) => { + const key = selectionKey(sel); + const next = new Map(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.set(key, { ...sel, key }); + } + return next; + }); + }, []); + + const selectionCount = selections.size; const handleAction = useCallback( async (action: "review" | "archive") => { - if (!selectedProject) return; + if (selectionCount === 0) return; setLoading(action); - const result = - action === "review" - ? await daemonApiClient.createReviewSession(selectedProject.cwd) - : await daemonApiClient.createArchiveSession(selectedProject.cwd); + let items = [...selections.values()]; + if (action === "archive") { + const seen = new Set(); + items = items.filter((sel) => { + if (seen.has(sel.cwd)) return false; + seen.add(sel.cwd); + return true; + }); + } + + const results = await Promise.allSettled( + items.map(async (sel) => { + const result = + action === "review" + ? await daemonApiClient.createReviewSession(sel.cwd, sel.prUrl) + : await daemonApiClient.createArchiveSession(sel.cwd); + return { sel, result }; + }), + ); setLoading(null); - if (result.ok) { - void navigate({ to: "/s/$sessionId", params: { sessionId: result.data.session.id } }); - } else { - toast.error(`Failed to start ${action}`, { description: result.error.message }); + + let firstSessionId: string | null = null; + let successCount = 0; + const failures: { label: string; message: string }[] = []; + + for (const outcome of results) { + if (outcome.status === "fulfilled" && outcome.value.result.ok) { + successCount++; + if (!firstSessionId) firstSessionId = outcome.value.result.data.session.id; + } else { + const label = outcome.status === "fulfilled" ? outcome.value.sel.label : "Unknown"; + const message = + outcome.status === "fulfilled" && !outcome.value.result.ok + ? outcome.value.result.error.message + : outcome.status === "rejected" + ? String(outcome.reason) + : "Unknown error"; + failures.push({ label, message }); + } + } + + if (firstSessionId) { + setSelections(new Map()); + void navigate({ to: "/s/$sessionId", params: { sessionId: firstSessionId } }); + if (successCount > 1) { + toast.success(`Launched ${successCount} sessions`); + } + } + + for (const fail of failures) { + toast.error(fail.label, { description: fail.message }); } }, - [selectedProject, navigate], + [selections, selectionCount, navigate], ); return ( - - - Plannotator - - - - - - - {projects.length === 0 && sessions.length === 0 ? ( - - ) : ( - - {projects.length > 0 && ( - - - Select project - - - - - Add project - - - - - Launch - - - handleAction("review")} - className={cn( - "relative inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-background px-3 py-1.5 text-[12px] font-medium transition-colors", - "hover:bg-surface-1 active:scale-[0.97]", - "disabled:pointer-events-none disabled:opacity-40", - "before:absolute before:-inset-1 before:content-['']", - )} - > - - {loading === "review" ? "Starting..." : "Code Review"} - - handleAction("archive")} - className={cn( - "relative inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-background px-3 py-1.5 text-[12px] font-medium transition-colors", - "hover:bg-surface-1 active:scale-[0.97]", - "disabled:pointer-events-none disabled:opacity-40", - "before:absolute before:-inset-1 before:content-['']", - )} - > - - {loading === "archive" ? "Opening..." : "Browse Archive"} - + + + + + + + + + + + {ASCII_BANNER} + + + {projects.length === 0 && sessions.length === 0 ? ( + + ) : ( + + {projects.length > 0 && ( + + + + Select project + + + + Add project + + + + + + + Launch + + + handleAction("review")} + className={cn( + "inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-[12px] font-medium", + "hover:bg-surface-1 active:scale-[0.97]", + "disabled:pointer-events-none disabled:opacity-40", + )} + > + + {loading === "review" + ? "Starting…" + : selectionCount > 1 + ? `Code Review (${selectionCount})` + : "Code Review"} + + handleAction("archive")} + className={cn( + "inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-[12px] font-medium", + "hover:bg-surface-1 active:scale-[0.97]", + "disabled:pointer-events-none disabled:opacity-40", + )} + > + + {loading === "archive" ? "Opening…" : "Browse Archive"} + + setViewIndex(1)} + className="ml-auto text-[12px] text-muted-foreground hover:text-foreground" + > + Git Dashboard → + + + - - - )} + )} - {sessions.length > 0 && ( - - - Active sessions - - - - )} + {sessions.length > 0 && ( + + + Active sessions + + + + )} - {projects.length === 0 && ( - - - Add project to launch sessions - + {projects.length === 0 && ( + + + Add project to launch sessions + + )} + )} - )} + + + + setViewIndex(0)} /> - + @@ -144,31 +271,498 @@ export function LandingPage({ onAddProject }: LandingPageProps) { function ProjectTable({ projects, - selected, - onSelect, + selections, + onToggle, }: { projects: ProjectEntry[]; - selected: string | null; - onSelect: (name: string) => void; + selections: Map; + onToggle: (sel: Omit) => void; }) { + const topLevel = projects.filter((p) => !p.parentCwd); + const worktreeChildren = (parentCwd: string) => projects.filter((p) => p.parentCwd === parentCwd); + + return ( + + {topLevel.map((project, i) => { + const children = worktreeChildren(project.cwd); + return ( + + ); + })} + + ); +} + +function ProjectNode({ + project, + children, + isFirst, + selections, + onToggle, +}: { + project: ProjectEntry; + children: ProjectEntry[]; + isFirst: boolean; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + const [expanded, setExpanded] = useState(false); + const [worktrees, setWorktrees] = useState([]); + const [worktreesFetched, setWorktreesFetched] = useState(false); + const [prs, setPrs] = useState([]); + const [prPlatform, setPrPlatform] = useState(null); + const [prDefaultBranch, setPrDefaultBranch] = useState("main"); + const [prError, setPrError] = useState(null); + const [prsLoading, setPrsLoading] = useState(false); + const [prsFetchedAt, setPrsFetchedAt] = useState(0); + const hasChildren = children.length > 0; + + useEffect(() => { + if (!expanded || worktreesFetched) return; + setWorktreesFetched(true); + daemonApiClient.listWorktrees(project.cwd).then((result) => { + if (result.ok) { + setWorktrees(result.data.worktrees.filter((wt) => wt.path !== project.cwd)); + } + }); + }, [expanded, project.cwd, worktreesFetched]); + + useEffect(() => { + if (!expanded) return; + const stale = !prsFetchedAt || Date.now() - prsFetchedAt > 30_000; + if (!stale || prsLoading) return; + setPrsLoading(true); + daemonApiClient.listPRs(project.cwd).then((result) => { + if (result.ok) { + setPrs(result.data.prs); + setPrPlatform(result.data.platform); + if (result.data.defaultBranch) setPrDefaultBranch(result.data.defaultBranch); + setPrError(result.data.error ?? null); + } + setPrsLoading(false); + setPrsFetchedAt(Date.now()); + }); + }, [expanded, project.cwd, prsFetchedAt, prsLoading]); + + const hasWorktrees = hasChildren || worktrees.length > 0; + const isSelected = selections.has(project.cwd); + + const handleRemove = useCallback(async () => { + const ok = window.confirm( + `Remove "${project.name}"?\n\nThis will cancel active sessions and delete plan history for this project.`, + ); + if (!ok) return; + const removed = await projectStore.getState().removeProject(project.cwd, true); + if (removed) { + toast.success(`Removed ${project.name}`); + } else { + toast.error(`Failed to remove ${project.name}`); + } + }, [project.name, project.cwd]); + + return ( + + + + + onToggle({ cwd: project.cwd, label: project.name })} + className="flex flex-1 items-center gap-3 text-left" + > + + {project.name} + {project.branch && ( + {project.branch} + )} + + {project.cwd} + + + setExpanded((prev) => !prev)} + className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground" + > + {expanded ? : } + + + + {expanded && ( + + + + + PRs + + + Worktrees + + + + + + + + + + + )} + + + + + + + Remove project + + + + + ); +} + +interface PRStack { + prs: PRListItem[]; + label: string; +} + +function buildStacks( + prs: PRListItem[], + defaultBranch: string, +): { stacks: PRStack[]; loose: PRListItem[] } { + const byHead = new Map(); + for (const pr of prs) byHead.set(pr.headBranch, pr); + + const stacked = new Set(); + const chains: PRListItem[][] = []; + + for (const pr of prs) { + if (stacked.has(pr.id)) continue; + if (pr.baseBranch === defaultBranch) continue; + + const chain: PRListItem[] = []; + let current: PRListItem | undefined = pr; + while (current && !stacked.has(current.id)) { + chain.unshift(current); + stacked.add(current.id); + current = byHead.get(current.baseBranch); + } + if (chain.length > 1) { + chains.push(chain); + } else { + stacked.delete(pr.id); + } + } + + const stacks = chains.map((chain) => ({ + prs: chain, + label: `#${chain[0].number} → #${chain[chain.length - 1].number}`, + })); + const loose = prs.filter((pr) => !stacked.has(pr.id)); + return { stacks, loose }; +} + +function PRRow({ + pr, + projectCwd, + projectName, + selections, + onToggle, +}: { + pr: PRListItem; + projectCwd: string; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + return ( + + onToggle({ cwd: projectCwd, label: `${projectName} / #${pr.number}`, prUrl: pr.url }) + } + className={cn( + "flex items-center gap-2 rounded-md border px-2 py-1 text-left text-[11px]", + selections.has(pr.url) + ? "border-primary/40 bg-primary/10 text-foreground" + : "border-border bg-background text-foreground/80 hover:border-foreground/20 hover:text-foreground", + )} + > + + #{pr.number} + {pr.title} + @{pr.author} + + ); +} + +function StackIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function StackGroup({ + stack, + projectCwd, + projectName, + selections, + onToggle, +}: { + stack: PRStack; + projectCwd: string; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + const [expanded, setExpanded] = useState(false); + + return ( + + setExpanded((prev) => !prev)} + className="flex w-full items-center gap-2 px-2 py-1 text-left text-[11px] text-foreground/80 hover:text-foreground" + > + + {stack.label} + ({stack.prs.length} PRs) + + {expanded && ( + + {stack.prs.map((pr) => ( + + ))} + + )} + + ); +} + +function PRList({ + prs, + loading, + error, + platform, + defaultBranch, + projectCwd, + projectName, + selections, + onToggle, +}: { + prs: PRListItem[]; + loading: boolean; + error: string | null; + platform: string | null; + defaultBranch: string; + projectCwd: string; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + const [showAll, setShowAll] = useState(false); + const visible = useMemo( + () => (showAll ? prs : prs.filter((pr) => pr.state === "open")), + [prs, showAll], + ); + const hiddenCount = prs.length - visible.length; + const { stacks, loose } = useMemo( + () => buildStacks(visible, defaultBranch), + [visible, defaultBranch], + ); + + if (loading) { + return Loading PRs…; + } + if (error === "no-remote") { + return No git remote detected; + } + if (error === "no-cli") { + return ( + + {platform === "gitlab" ? "GitLab CLI (glab)" : "GitHub CLI (gh)"} not installed + + ); + } + if (error === "auth-failed") { + return ( + + {platform === "gitlab" ? "glab" : "gh"} not authenticated — run{" "} + + {platform === "gitlab" ? "glab" : "gh"} auth login + + + ); + } + if (platform === "gitlab" && prs.length === 0) { + return ( + GitLab MR listing coming soon + ); + } + if (visible.length === 0 && !showAll) { + return ( + + No open pull requests + {hiddenCount > 0 && ( + <> + {" · "} + setShowAll(true)} + className="underline hover:text-foreground" + > + show {hiddenCount} closed/merged + + > + )} + + ); + } + + return ( + + {stacks.map((stack) => ( + + ))} + {loose.map((pr) => ( + + ))} + {!showAll && hiddenCount > 0 && ( + setShowAll(true)} + className="py-0.5 text-left text-[10px] text-muted-foreground hover:text-foreground" + > + Show {hiddenCount} closed/merged + + )} + + ); +} + +function WorktreeList({ + children, + worktrees, + hasWorktrees, + projectName, + selections, + onToggle, +}: { + children: ProjectEntry[]; + worktrees: WorktreeEntry[]; + hasWorktrees: boolean; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + if (!hasWorktrees) { + return No worktrees; + } + + const allWorktrees: { path: string; branch: string }[] = []; + for (const child of children) { + allWorktrees.push({ path: child.cwd, branch: child.branch ?? child.name }); + } + for (const wt of worktrees) { + if (!children.some((c) => c.cwd === wt.path)) { + allWorktrees.push({ path: wt.path, branch: wt.branch ?? "detached" }); + } + } + return ( - - {projects.map((project, i) => ( + + {allWorktrees.map((wt) => ( onSelect(project.name)} + onClick={() => onToggle({ cwd: wt.path, label: `${projectName} / ${wt.branch}` })} className={cn( - "flex w-full items-center gap-3 px-3 py-2 text-left text-[13px] transition-colors", - i > 0 && "border-t border-border/40", - selected === project.name - ? "bg-primary/8 text-foreground" - : "text-muted-foreground hover:bg-surface-1/50 hover:text-foreground", + "flex items-center gap-2 rounded-md border px-2 py-1 text-left text-[11px]", + selections.has(wt.path) + ? "border-primary/40 bg-primary/10 text-foreground" + : "border-border bg-background text-foreground/80 hover:border-foreground/20 hover:text-foreground", )} > - - {project.name} - {project.cwd} + + {wt.branch} + + {wt.path} + ))} @@ -177,7 +771,7 @@ function ProjectTable({ function SessionList({ sessions }: { sessions: SessionSummary[] }) { return ( - + {sessions.map((session, i) => { const meta = getSessionModeMeta(session.mode); const Icon = meta.icon; @@ -187,14 +781,16 @@ function SessionList({ sessions }: { sessions: SessionSummary[] }) { to="/s/$sessionId" params={{ sessionId: session.id }} className={cn( - "flex w-full items-center gap-3 px-3 py-2 text-left text-[13px] transition-colors", - i > 0 && "border-t border-border/40", - "text-muted-foreground hover:bg-surface-1/50 hover:text-foreground", + "flex w-full items-center gap-3 px-3 py-2 text-left text-[13px]", + i > 0 && "border-t border-border", + "text-foreground hover:bg-surface-1", )} > - - {session.label} - {meta.label} + + + {formatSessionLabel(session.label, session.mode)} + + {meta.label} ); })} 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..4b096f41f --- /dev/null +++ b/apps/frontend/src/components/landing/ascii-banner.ts @@ -0,0 +1,2 @@ +export const ASCII_BANNER = + " _ __ ,---. .-._ .-._ _,.---._ ,--.--------. ,---. ,--.--------. _,.---._ \n .-`.' ,`. _.-. .--.' \\ /==/ \\ .-._/==/ \\ .-._ ,-.' , - `. /==/, - , -\\.--.' \\ /==/, - , -\\,-.' , - `. .-.,.---. \n /==/, - \\.-,.'| \\==\\-/\\ \\ |==|, \\/ /, /==|, \\/ /, /==/_, , - \\\\==\\.-. - ,-./\\==\\-/\\ \\\\==\\.-. - ,-./==/_, , - \\ /==/ ` \\ \n|==| _ .=. |==|, | /==/-|_\\ | |==|- \\| ||==|- \\| |==| .=. |`--`\\==\\- \\ /==/-|_\\ |`--`\\==\\- \\ |==| .=. |==|-, .=., | \n|==| , '=',|==|- | \\==\\, - \\ |==| , | -||==| , | -|==|_ : ;=: - | \\==\\_ \\ \\==\\, - \\ \\==\\_ \\|==|_ : ;=: - |==| '=' / \n|==|- '..'|==|, | /==/ - ,| |==| - _ ||==| - _ |==| , '=' | |==|- | /==/ - ,| |==|- ||==| , '=' |==|- , .' \n|==|, | |==|- `-._/==/- /\\ - \\|==| /\\ , ||==| /\\ , |\\==\\ - ,_ / |==|, | /==/- /\\ - \\ |==|, | \\==\\ - ,_ /|==|_ . ,'. \n/==/ - | /==/ - , ,|==\\ _.\\=\\.-'/==/, | |- |/==/, | |- | '.='. - .' /==/ -/ \\==\\ _.\\=\\.-' /==/ -/ '.='. - .' /==/ /\\ , ) \n`--`---' `--`-----' `--` `--`./ `--``--`./ `--` `--`--'' `--`--` `--` `--`--` `--`--'' `--`-`--`--' "; diff --git a/apps/frontend/src/components/landing/git-dashboard/GitDashboard.tsx b/apps/frontend/src/components/landing/git-dashboard/GitDashboard.tsx new file mode 100644 index 000000000..2720756cc --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/GitDashboard.tsx @@ -0,0 +1,122 @@ +import { useCallback, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { GitPullRequest, GitMerge, FileEdit } from "lucide-react"; +import { toast } from "sonner"; +import { daemonApiClient } from "../../../daemon/api/client"; +import type { GitDashboardPR } from "../../../stores/git-dashboard-store"; +import { useGitDashboard } from "./use-git-dashboard"; +import { MetricCards } from "./MetricCards"; +import { PRGroup } from "./PRGroup"; +import { PRRow } from "./PRRow"; + +interface GitDashboardProps { + active: boolean; + onBack: () => void; +} + +export function GitDashboard({ active, onBack }: GitDashboardProps) { + const { groups, metrics, loading, error, isEmpty } = useGitDashboard(active); + const [launchingId, setLaunchingId] = useState(null); + const navigate = useNavigate(); + + const handleSelect = useCallback( + async (pr: GitDashboardPR) => { + setLaunchingId(pr.url); + const result = await daemonApiClient.createReviewSession(pr.projectCwd, pr.url); + setLaunchingId(null); + if (result.ok) { + void navigate({ to: "/s/$sessionId", params: { sessionId: result.data.session.id } }); + } else { + toast.error("Failed to start review", { description: result.error.message }); + } + }, + [navigate], + ); + + return ( + + + + + ← Back + + + + {loading && isEmpty && ( + Loading PRs… + )} + + {!loading && isEmpty && ( + + {error ?? "No pull requests found across your projects"} + + )} + + {!isEmpty && ( + + + + {groups.open.length > 0 && ( + + {groups.open.map((pr) => ( + + ))} + + )} + {groups.draft.length > 0 && ( + + {groups.draft.map((pr) => ( + + ))} + + )} + {groups.merged.length > 0 && ( + + {groups.merged.map((pr) => ( + + ))} + + )} + + + + + )} + + + ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/MetricCards.tsx b/apps/frontend/src/components/landing/git-dashboard/MetricCards.tsx new file mode 100644 index 000000000..5e605e529 --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/MetricCards.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/lib/utils"; +import type { DashboardMetrics } from "./use-git-dashboard"; + +interface MetricCardProps { + label: string; + count: number; + active?: boolean; + onClick?: () => void; +} + +function MetricCard({ label, count, active, onClick }: MetricCardProps) { + return ( + + {label} + {count} + + ); +} + +function scrollToGroup(id: string) { + document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +export function MetricCards({ metrics }: { metrics: DashboardMetrics }) { + return ( + + Pull Requests + scrollToGroup("pr-group-open")} + /> + scrollToGroup("pr-group-draft")} + /> + scrollToGroup("pr-group-merged")} + /> + + ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/PRGroup.tsx b/apps/frontend/src/components/landing/git-dashboard/PRGroup.tsx new file mode 100644 index 000000000..42fb6928a --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/PRGroup.tsx @@ -0,0 +1,24 @@ +import type { LucideIcon } from "lucide-react"; + +interface PRGroupProps { + id?: string; + title: string; + icon: LucideIcon; + count: number; + children: React.ReactNode; +} + +export function PRGroup({ id, title, icon: Icon, count, children }: PRGroupProps) { + return ( + + + + {title} + + {count} + + + {children} + + ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/PRRow.tsx b/apps/frontend/src/components/landing/git-dashboard/PRRow.tsx new file mode 100644 index 000000000..2b04046ec --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/PRRow.tsx @@ -0,0 +1,93 @@ +import { cn } from "@/lib/utils"; +import { PullRequestIcon } from "@plannotator/ui/components/PullRequestIcon"; +import type { GitDashboardPR } from "../../../stores/git-dashboard-store"; +import { formatRelativeTime } from "./use-git-dashboard"; + +const STATUS_COLORS: Record = { + open: "text-green-500", + merged: "text-purple-500", + closed: "text-red-500", + draft: "text-muted-foreground/50", +}; + +const REVIEW_BADGES: Record = { + APPROVED: { label: "Approved", className: "bg-green-500/10 text-green-600 dark:text-green-400" }, + CHANGES_REQUESTED: { + label: "Changes requested", + className: "bg-red-500/10 text-red-600 dark:text-red-400", + }, + REVIEW_REQUIRED: { + label: "Review required", + className: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", + }, +}; + +interface PRRowProps { + pr: GitDashboardPR; + loading?: boolean; + onSelect: (pr: GitDashboardPR) => void; +} + +export function PRRow({ pr, loading, onSelect }: PRRowProps) { + const statusKey = pr.isDraft && pr.state === "open" ? "draft" : pr.state; + const reviewBadge = pr.reviewDecision ? (REVIEW_BADGES[pr.reviewDecision] ?? null) : null; + const repoName = pr.repoSlug.split("/")[1] ?? pr.repoSlug; + + return ( + onSelect(pr)} + disabled={loading} + className={cn( + "group flex w-full items-center gap-3 rounded-xl px-3.5 py-2.5 text-left transition-colors hover:bg-surface-1", + loading && "opacity-60", + )} + > + + + {pr.title} + + {repoName} #{pr.number} + {" · "}@{pr.author} + {pr.updatedAt && ( + <> + {" · "} + {formatRelativeTime(pr.updatedAt)} + > + )} + + + + {reviewBadge && ( + + {reviewBadge.label} + + )} + + +{pr.additions} + -{pr.deletions} + + {pr.commentCount > 0 && ( + + + + + {pr.commentCount} + + )} + + + ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/use-git-dashboard.ts b/apps/frontend/src/components/landing/git-dashboard/use-git-dashboard.ts new file mode 100644 index 000000000..aec89ee78 --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/use-git-dashboard.ts @@ -0,0 +1,92 @@ +import { useEffect, useMemo } from "react"; +import { useProjectStore } from "../../../stores/project-store"; +import { useGitDashboardStore, type GitDashboardPR } from "../../../stores/git-dashboard-store"; + +export interface PRGroups { + open: GitDashboardPR[]; + draft: GitDashboardPR[]; + merged: GitDashboardPR[]; +} + +export interface DashboardMetrics { + open: number; + draft: number; + merged: number; + total: number; +} + +function groupPRs(prs: GitDashboardPR[]): PRGroups { + const open: GitDashboardPR[] = []; + const draft: GitDashboardPR[] = []; + const merged: GitDashboardPR[] = []; + for (const pr of prs) { + if (pr.state === "open" && pr.isDraft) draft.push(pr); + else if (pr.state === "open") open.push(pr); + else if (pr.state === "merged") merged.push(pr); + } + return { open, draft, merged }; +} + +function computeMetrics(prs: GitDashboardPR[]): DashboardMetrics { + let open = 0; + let draft = 0; + let merged = 0; + for (const pr of prs) { + if (pr.state === "open" && pr.isDraft) draft++; + else if (pr.state === "open") open++; + else if (pr.state === "merged") merged++; + } + return { open, draft, merged, total: prs.length }; +} + +export function formatRelativeTime(iso: string): string { + if (!iso) return ""; + const diff = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return "now"; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d`; + const months = Math.floor(days / 30); + return `${months}mo`; +} + +const STALE_MS = 30_000; + +export function useGitDashboard(active = true) { + const projects = useProjectStore((s) => s.projects); + const prs = useGitDashboardStore((s) => s.prs); + const loading = useGitDashboardStore((s) => s.loading); + const error = useGitDashboardStore((s) => s.error); + const lastFetchedAt = useGitDashboardStore((s) => s.lastFetchedAt); + const lastProjectKey = useGitDashboardStore((s) => s.lastProjectKey); + const fetchAllPRs = useGitDashboardStore((s) => s.fetchAllPRs); + + const clear = useGitDashboardStore((s) => s.clear); + + useEffect(() => { + if (!active) return; + const topLevel = projects.filter((p) => !p.parentCwd); + if (topLevel.length === 0) { + if (prs.length > 0) clear(); + return; + } + const projectKey = topLevel + .map((p) => p.cwd) + .sort() + .join("|"); + const stale = + !lastFetchedAt || Date.now() - lastFetchedAt > STALE_MS || projectKey !== lastProjectKey; + if (stale && !loading) fetchAllPRs(projects); + }, [active, projects, prs.length, lastFetchedAt, lastProjectKey, loading, fetchAllPRs, clear]); + + const groups = useMemo(() => groupPRs(prs), [prs]); + const metrics = useMemo(() => computeMetrics(prs), [prs]); + + const isEmpty = + groups.open.length === 0 && groups.draft.length === 0 && groups.merged.length === 0; + + return { groups, metrics, loading, error, isEmpty }; +} diff --git a/apps/frontend/src/components/sessions/SessionSurface.tsx b/apps/frontend/src/components/sessions/SessionSurface.tsx index bb5e8bf90..1aac30548 100644 --- a/apps/frontend/src/components/sessions/SessionSurface.tsx +++ b/apps/frontend/src/components/sessions/SessionSurface.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { SidebarTrigger } from "@/components/ui/sidebar"; import { SessionProvider } from "@plannotator/ui/hooks/useSessionFetch"; import { ReviewAppEmbedded } from "@plannotator/code-review"; @@ -5,30 +6,34 @@ import { PlanAppEmbedded } from "@plannotator/plan-review"; import "@plannotator/code-review/styles"; import "@plannotator/plan-review/styles"; import type { SessionBootstrap } from "../../daemon/contracts"; +import { appStore } from "../../stores/app-store"; const sidebarTrigger = ( ); +const openSettings = () => appStore.getState().setSettingsOpen(true); + interface SessionSurfaceProps { bootstrap: SessionBootstrap; } -export function SessionSurface({ bootstrap }: SessionSurfaceProps) { +export const SessionSurface = React.memo(function SessionSurface({ + bootstrap, +}: SessionSurfaceProps) { const { session } = bootstrap; if (session.mode === "review") { return ( - + ); } - // plan, annotate, archive, goal-setup — all handled by the plan review component return ( - + ); -} +}); diff --git a/apps/frontend/src/components/settings/AppSettingsDialog.tsx b/apps/frontend/src/components/settings/AppSettingsDialog.tsx new file mode 100644 index 000000000..857ee73c7 --- /dev/null +++ b/apps/frontend/src/components/settings/AppSettingsDialog.tsx @@ -0,0 +1,353 @@ +import { useState, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { useAppStore } from "../../stores/app-store"; +import { GeneralTab } from "@plannotator/ui/components/settings/GeneralTab"; +import { PlanGeneralTab } from "@plannotator/ui/components/settings/PlanGeneralTab"; +import { PlanDisplayTab } from "@plannotator/ui/components/settings/PlanDisplayTab"; +import { SavingTab } from "@plannotator/ui/components/settings/SavingTab"; +import { LabelsTab } from "@plannotator/ui/components/settings/LabelsTab"; +import { FilesTab } from "@plannotator/ui/components/settings/FilesTab"; +import { ObsidianTab } from "@plannotator/ui/components/settings/ObsidianTab"; +import { BearTab } from "@plannotator/ui/components/settings/BearTab"; +import { OctarineTab } from "@plannotator/ui/components/settings/OctarineTab"; +import { GitTab, ReviewDisplayTab, CommentsTab } from "@plannotator/ui/components/Settings"; +import { ThemeTab } from "@plannotator/ui/components/ThemeTab"; +import { KeyboardShortcuts } from "@plannotator/ui/components/KeyboardShortcuts"; +import { AISettingsTab } from "@plannotator/ui/components/AISettingsTab"; +import { HooksTab } from "@plannotator/ui/components/settings/HooksTab"; +import { getAIProviderSettings, saveAIProviderSettings } from "@plannotator/ui/utils/aiProvider"; +import { configStore } from "@plannotator/ui/config"; + +interface TabDef { + id: string; + label: string; +} + +const GENERAL_TABS: TabDef[] = [ + { id: "general", label: "General" }, + { id: "theme", label: "Theme" }, + { id: "shortcuts", label: "Shortcuts" }, +]; + +const PLAN_TABS: TabDef[] = [ + { id: "plan-general", label: "General" }, + { id: "plan-display", label: "Display" }, + { id: "plan-saving", label: "Saving" }, + { id: "plan-labels", label: "Labels" }, + { id: "plan-hooks", label: "Hooks" }, +]; + +const REVIEW_TABS: TabDef[] = [ + { id: "review-git", label: "Git" }, + { id: "review-display", label: "Display" }, + { id: "review-comments", label: "Comments" }, + { id: "review-ai", label: "AI" }, +]; + +const INTEGRATION_TABS: TabDef[] = [ + { id: "int-files", label: "Files" }, + { id: "int-obsidian", label: "Obsidian" }, + { id: "int-bear", label: "Bear" }, + { id: "int-octarine", label: "Octarine" }, +]; + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function AppSettingsDialog() { + const open = useAppStore((s) => s.settingsOpen); + const setOpen = useAppStore((s) => s.setSettingsOpen); + const [activeTab, setActiveTab] = useState("general"); + const [themePreview, setThemePreview] = useState(false); + + useEffect(() => { + if (!themePreview) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setThemePreview(false); + setOpen(true); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [themePreview, setOpen]); + + // Force re-mount of tab content when dialog opens to ensure fresh state + const [mountKey, setMountKey] = useState(0); + useEffect(() => { + if (open) setMountKey((k) => k + 1); + }, [open]); + + // Detect origin from the active session (if any) + const activeSessionId = useAppStore((s) => s.activeSessionId); + const visitedSessions = useAppStore((s) => s.visitedSessions); + const activeOrigin = activeSessionId + ? ((visitedSessions[activeSessionId]?.bootstrap.session.origin as string | undefined) ?? null) + : null; + + // Fetch git user and config from daemon on open + const [gitUser, setGitUser] = useState(); + const [legacyTabMode, setLegacyTabMode] = useState(false); + + useEffect(() => { + if (!open) return; + fetch("/daemon/git/user") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.gitUser) setGitUser(data.gitUser); + }) + .catch(() => {}); + fetch("/daemon/config") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.config) { + configStore.init(data.config); + setLegacyTabMode(!!data.config.legacyTabMode); + } + }) + .catch(() => {}); + }, [open]); + + // Daemon-routed fetch for tabs that need server calls without session context + const daemonFetch = useCallback((input: string, init?: RequestInit) => { + const path = + typeof input === "string" && input.startsWith("/api/") ? `/daemon${input.slice(4)}` : input; + return fetch(path, init); + }, []); + + // AI provider state — fetched once when dialog opens + const [aiProviders, setAiProviders] = useState< + Array<{ id: string; name: string; capabilities: Record }> + >([]); + const [aiProviderId, setAiProviderId] = useState( + () => getAIProviderSettings().providerId, + ); + + // Re-read AI provider on each open (could have changed via per-surface settings) + useEffect(() => { + if (open) setAiProviderId(getAIProviderSettings().providerId); + }, [open]); + + useEffect(() => { + if (!open) return; + const apiBase = activeSessionId ? `/s/${activeSessionId}/api` : null; + if (!apiBase) return; + fetch(`${apiBase}/ai/capabilities`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.providers) setAiProviders(data.providers); + }) + .catch(() => {}); + }, [open, activeSessionId]); + + const handleAiProviderChange = useCallback((providerId: string | null) => { + setAiProviderId(providerId); + const current = getAIProviderSettings(); + saveAIProviderSettings({ ...current, providerId }); + }, []); + + return ( + <> + + + Settings + + + + Settings + + v{__APP_VERSION__} + · + + Send feedback + + + + + + + General + {GENERAL_TABS.map((tab) => ( + + {tab.label} + + ))} + + Plan Review + {PLAN_TABS.map((tab) => ( + + {tab.label} + + ))} + + Code Review + {REVIEW_TABS.map((tab) => ( + + {tab.label} + + ))} + + Integrations + {INTEGRATION_TABS.map((tab) => ( + + {tab.label} + + ))} + + + + + + + setOpen(false)} + className="rounded-md p-1.5 text-muted-foreground hover:text-foreground" + > + + + + + {/* General */} + + { + setLegacyTabMode(enabled); + fetch("/daemon/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ legacyTabMode: enabled }), + }).catch(() => {}); + }} + /> + + + { + setOpen(false); + setThemePreview(true); + }} + /> + + + + + + Plan Review + + + + + + Code Review + + + + + + + {/* Plan Review */} + + + + + + + + + + + + + + + + + {/* Code Review */} + + + + + + + + + + + + + + {/* Integrations */} + + + + + + + + + + + + + + + + + + + {themePreview && + createPortal( + + + + + + Theme Preview + + { + setThemePreview(false); + setOpen(true); + }} + className="px-3 py-1 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90" + > + Done + + + + + + + , + document.body, + )} + > + ); +} diff --git a/apps/frontend/src/components/sidebar/AppSidebar.tsx b/apps/frontend/src/components/sidebar/AppSidebar.tsx index 984fded21..ec923f135 100644 --- a/apps/frontend/src/components/sidebar/AppSidebar.tsx +++ b/apps/frontend/src/components/sidebar/AppSidebar.tsx @@ -1,6 +1,8 @@ import { useCallback, useMemo } from "react"; import { Link, useMatchRoute } from "@tanstack/react-router"; -import { Check, FolderPlus, Moon, Sun } from "lucide-react"; +import { Moon, Settings, Sun } from "lucide-react"; +import { TaterSpriteSidebar } from "./TaterSpriteSidebar"; +import { appStore } from "../../stores/app-store"; import { cn } from "@/lib/utils"; import { Sidebar, @@ -11,29 +13,17 @@ import { SidebarGroupLabel, SidebarHeader, SidebarMenu, - SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; import { useTheme } from "@plannotator/ui/components/ThemeProvider"; import { useDaemonEventStore } from "../../daemon/events/event-store"; import type { SessionSummary } from "../../daemon/contracts"; -import { getSessionModeMeta } from "../../shared/session-meta"; +import { getSessionModeMeta, formatSessionLabel } from "../../shared/session-meta"; const MODE_ORDER = ["plan", "review", "annotate", "goal-setup", "archive"]; -function formatSessionLabel(label: string): string { - return label - .replace(/^plugin-(plan|review|annotate|archive)-/, "") - .replace(/^(claude-code|opencode|pi|plannotator-frontend)-/, "") - .replace(/^goal-setup-(interview|facts)-/, ""); -} - -interface AppSidebarProps { - onAddProject: () => void; -} - -export function AppSidebar({ onAddProject }: AppSidebarProps) { +export function AppSidebarContent() { const sessions = useDaemonEventStore((s) => s.sessions); const { resolvedMode, setMode } = useTheme(); const matchRoute = useMatchRoute(); @@ -53,40 +43,51 @@ export function AppSidebar({ onAddProject }: AppSidebarProps) { }, [resolvedMode, setMode]); return ( - + <> - - - - - - P - - - Plannotator - - {sessions.length} session{sessions.length !== 1 ? "s" : ""} - - - - - - + + + + + Plannotator + + + v{__APP_VERSION__} ·{" "} + e.stopPropagation()} + > + Send feedback + + + + - + {MODE_ORDER.map((mode) => { const modeSessions = grouped.get(mode); if (!modeSessions?.length) return null; const meta = getSessionModeMeta(mode); + const Icon = meta.icon; return ( - {meta.label}s + + + {meta.label}s + {modeSessions.map((session) => { - const Icon = meta.icon; const isActive = !!matchRoute({ to: "/s/$sessionId", params: { sessionId: session.id }, @@ -96,39 +97,19 @@ export function AppSidebar({ onAddProject }: AppSidebarProps) { return ( - + - + - {formatSessionLabel(session.label)} + {formatSessionLabel(session.label, session.mode)} - {session.status === "active" && ( - - - - )} - {isTerminal && ( - - - - )} ); })} @@ -142,9 +123,12 @@ export function AppSidebar({ onAddProject }: AppSidebarProps) { - - - Add project + appStore.getState().setSettingsOpen(true)} + tooltip="Settings" + > + + Settings @@ -155,6 +139,14 @@ export function AppSidebar({ onAddProject }: AppSidebarProps) { + > + ); +} + +export function AppSidebar() { + return ( + + ); } diff --git a/apps/frontend/src/components/sidebar/SidebarPeek.tsx b/apps/frontend/src/components/sidebar/SidebarPeek.tsx new file mode 100644 index 000000000..705d25dc5 --- /dev/null +++ b/apps/frontend/src/components/sidebar/SidebarPeek.tsx @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSidebar } from "@/components/ui/sidebar"; +import { AppSidebarContent } from "./AppSidebar"; + +export function SidebarPeek() { + const { open } = useSidebar(); + const [visible, setVisible] = useState(false); + const [backdropMounted, setBackdropMounted] = useState(false); + const [backdropVisible, setBackdropVisible] = useState(false); + const hideTimeout = useRef | null>(null); + + const show = useCallback(() => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + hideTimeout.current = null; + } + setVisible(true); + }, []); + + const hide = useCallback(() => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + } + hideTimeout.current = setTimeout(() => setVisible(false), 150); + }, []); + + useEffect(() => { + if (visible) { + setBackdropMounted(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => setBackdropVisible(true)); + }); + } else { + setBackdropVisible(false); + const timer = setTimeout(() => setBackdropMounted(false), 200); + return () => clearTimeout(timer); + } + }, [visible]); + + if (open) return null; + + return ( + <> + {/* Hover strip — invisible hit area on left edge */} + + {/* Backdrop overlay */} + {backdropMounted && ( + + )} + {/* Floating sidebar panel */} + + + + + + > + ); +} diff --git a/apps/frontend/src/components/sidebar/TaterSpriteSidebar.tsx b/apps/frontend/src/components/sidebar/TaterSpriteSidebar.tsx new file mode 100644 index 000000000..2da10b85c --- /dev/null +++ b/apps/frontend/src/components/sidebar/TaterSpriteSidebar.tsx @@ -0,0 +1,32 @@ +import spriteSheet from "../../assets/sprite_package_sidebar/sprite.png"; + +const NATIVE_W = 117; +const NATIVE_H = 96; +const FRAMES = 24; +const DISPLAY_H = 40; +const SCALE = DISPLAY_H / NATIVE_H; +const DISPLAY_W = NATIVE_W * SCALE; +const TOTAL_WIDTH = NATIVE_W * FRAMES * SCALE; + +export function TaterSpriteSidebar() { + return ( + + + + ); +} diff --git a/apps/frontend/src/components/ui/dialog.tsx b/apps/frontend/src/components/ui/dialog.tsx new file mode 100644 index 000000000..1a98f2f22 --- /dev/null +++ b/apps/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,106 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + hideClose?: boolean; + } +>(({ className, children, hideClose, ...props }, ref) => ( + + + + {children} + {!hideClose && ( + + + Close + + )} + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +function DialogHeader({ className, ...props }: React.HTMLAttributes) { + return ( + + ); +} + +const DialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +}; diff --git a/apps/frontend/src/components/ui/sidebar.tsx b/apps/frontend/src/components/ui/sidebar.tsx index 2893d59c2..21ea19364 100644 --- a/apps/frontend/src/components/ui/sidebar.tsx +++ b/apps/frontend/src/components/ui/sidebar.tsx @@ -274,7 +274,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps} - className={cn("h-7 w-7", className)} + className={cn("h-7 w-7 hover:bg-muted hover:text-foreground", className)} onClick={(event) => { onClick?.(event); toggleSidebar(); @@ -406,7 +406,7 @@ function SidebarGroupLabel({ data-slot="sidebar-group-label" data-sidebar="group-label" className={cn( - "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", className, )} diff --git a/apps/frontend/src/components/ui/tabs.tsx b/apps/frontend/src/components/ui/tabs.tsx new file mode 100644 index 000000000..323007d42 --- /dev/null +++ b/apps/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/frontend/src/daemon/api/client.ts b/apps/frontend/src/daemon/api/client.ts index 8fa11f8b6..0e2583de1 100644 --- a/apps/frontend/src/daemon/api/client.ts +++ b/apps/frontend/src/daemon/api/client.ts @@ -11,6 +11,10 @@ import type { SessionListResponse, SessionResponse, SessionSummary, + WorktreeListResponse, + DirectoryListResponse, + PRListResponse, + PRDetailedListResponse, } from "../contracts"; import { DaemonHubActionError, @@ -64,8 +68,12 @@ export interface DaemonApiClient { cwd: string, name?: string, ): Promise>; - removeProject(name: string): Promise>; - createReviewSession(cwd: string): Promise>; + removeProject(cwd: string, clean?: boolean): Promise>; + listWorktrees(cwd: string): Promise>; + listDirectories(path?: string): Promise>; + listPRs(cwd: string): Promise>; + listDetailedPRs(cwd: string): Promise>; + createReviewSession(cwd: string, prUrl?: string): Promise>; createArchiveSession(cwd: string): Promise>; } @@ -178,6 +186,22 @@ 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 isDirectoryList(value: unknown): value is DirectoryListResponse { + return hasOkTrue(value) && Array.isArray((value as { dirs?: unknown }).dirs); +} + +function isPRList(value: unknown): value is PRListResponse { + return hasOkTrue(value) && Array.isArray((value as { prs?: unknown }).prs); +} + +function isPRDetailedList(value: unknown): value is PRDetailedListResponse { + return hasOkTrue(value) && Array.isArray((value as { prs?: unknown }).prs); +} + function isSessionBootstrap(value: unknown): value is SessionBootstrap { return ( isSessionResponse(value) && @@ -442,21 +466,62 @@ export function createDaemonApiClient(options: DaemonApiClientOptions = {}): Dae ); }, - removeProject(name) { + removeProject(cwd, clean) { + const params = new URLSearchParams({ cwd }); + if (clean) params.set("clean", "1"); return requestJson( fetchImpl, - joinUrl(options.baseUrl, `/daemon/projects/${encodeURIComponent(name)}`), + joinUrl(options.baseUrl, `/daemon/projects?${params}`), isDeleteSessionResponse, { method: "DELETE" }, ); }, - createReviewSession(cwd) { + listDirectories(path = "~") { + return requestJson( + fetchImpl, + joinUrl(options.baseUrl, `/daemon/fs/list?path=${encodeURIComponent(path)}`), + isDirectoryList, + ); + }, + + listWorktrees(cwd) { + return requestJson( + fetchImpl, + joinUrl(options.baseUrl, `/daemon/projects/worktrees?cwd=${encodeURIComponent(cwd)}`), + isWorktreeList, + ); + }, + + listPRs(cwd) { + return requestJson( + fetchImpl, + joinUrl(options.baseUrl, `/daemon/projects/prs?cwd=${encodeURIComponent(cwd)}`), + isPRList, + ); + }, + + listDetailedPRs(cwd) { + return requestJson( + fetchImpl, + joinUrl(options.baseUrl, `/daemon/projects/prs/detailed?cwd=${encodeURIComponent(cwd)}`), + isPRDetailedList, + ); + }, + + createReviewSession(cwd, prUrl) { return requestJson( fetchImpl, joinUrl(options.baseUrl, "/daemon/sessions"), isSessionResponse, - jsonPost({ request: { action: "review", origin: "plannotator-frontend", cwd } }), + jsonPost({ + request: { + action: "review", + origin: "plannotator-frontend", + cwd, + ...(prUrl && { prUrl }), + }, + }), ); }, diff --git a/apps/frontend/src/daemon/contracts.ts b/apps/frontend/src/daemon/contracts.ts index 2e68488c6..0dea63f8b 100644 --- a/apps/frontend/src/daemon/contracts.ts +++ b/apps/frontend/src/daemon/contracts.ts @@ -46,6 +46,66 @@ export interface ProjectListResponse { projects: ProjectEntry[]; } +export interface WorktreeEntry { + path: string; + branch: string | null; + head: string; + lastActive: number; +} + +export interface WorktreeListResponse { + ok: true; + worktrees: WorktreeEntry[]; +} + +export interface DirectoryEntry { + name: string; + path: string; +} + +export interface DirectoryListResponse { + ok: true; + path: string; + dirs: DirectoryEntry[]; +} + +export interface PRListItem { + id: string; + number: number; + title: string; + author: string; + url: string; + baseBranch: string; + headBranch: string; + state: "open" | "closed" | "merged"; +} + +export interface PRDetailedListItem extends PRListItem { + additions: number; + deletions: number; + commentCount: number; + updatedAt: string; + isDraft: boolean; + reviewDecision: string; +} + +export interface PRDetailedListResponse { + ok: true; + prs: PRDetailedListItem[]; + platform: "github" | "gitlab" | null; + error?: "no-remote" | "no-cli" | "auth-failed"; + message?: string; +} + +export interface PRListResponse { + ok: true; + prs: PRListItem[]; + platform: "github" | "gitlab" | null; + defaultBranch?: string; + error?: "no-remote" | "no-cli" | "auth-failed"; + message?: string; +} + export type SessionLifecycleStatus = DaemonSessionStatus; export type DaemonServerMessage = DaemonWebSocketServerMessage; @@ -56,7 +116,10 @@ export type DaemonLifecycleEvent = | Extract | Extract | (Omit< - Extract, + Extract< + DaemonEvent, + { type: "session-created" | "session-updated" | "session-removed" | "session-notify" } + >, "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 a754b938e..a6f77ea5f 100644 --- a/apps/frontend/src/daemon/events/event-stream.ts +++ b/apps/frontend/src/daemon/events/event-stream.ts @@ -17,12 +17,14 @@ 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; } export interface DaemonEventStreamController { stop(): void; + reportActiveSession(sessionId: string | null): void; } const DAEMON_EVENT_TYPES = [ @@ -31,6 +33,7 @@ const DAEMON_EVENT_TYPES = [ "session-created", "session-updated", "session-removed", + "session-notify", "daemon-error", "debug-log", ] as const; @@ -79,16 +82,46 @@ 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) return; + sendClientState(); + if (!document.hidden && pendingNotifications.length > 0 && options.onSessionNotify) { + for (const n of pendingNotifications.splice(0)) { + options.onSessionNotify(n); + } + } + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + const unsubscribe = client.subscribeDaemon( (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; options.onState(state); - if (state === "open") stopPolling(); + if (state === "open") { + stopPolling(); + sendClientState(); + } if (state === "error" || state === "closed") startPolling(); }, (message) => { @@ -96,10 +129,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..9a8524c01 100644 --- a/apps/frontend/src/daemon/events/use-daemon-events.ts +++ b/apps/frontend/src/daemon/events/use-daemon-events.ts @@ -1,12 +1,34 @@ -import { useEffect } from "react"; +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 } from "./event-stream"; +import { connectDaemonEvents, type DaemonEventStreamController } from "./event-stream"; import { useDaemonEventStore } from "./event-store"; +import { getSessionModeMeta, formatSessionLabel } from "../../shared/session-meta"; -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); + const router = useRouter(); + + const handleSessionNotify = useCallback( + (session: { id: string; mode: string; project: string; label: string }) => { + const meta = getSessionModeMeta(session.mode); + const displayLabel = formatSessionLabel(session.label, session.mode); + toast(`${meta.label} — ${session.project}`, { + description: displayLabel !== session.project ? displayLabel : undefined, + duration: 8000, + action: { + label: "Open", + onClick: () => + router.navigate({ to: "/s/$sessionId", params: { sessionId: session.id } }), + }, + }); + }, + [router], + ); useEffect(() => { if (!enabled) return undefined; @@ -15,8 +37,19 @@ export function useDaemonEvents(client: DaemonApiClient = daemonApiClient, enabl onEvent: applyEvent, onState: setConnectionState, onError: setError, + onSessionNotify: handleSessionNotify, }); + controllerRef.current = controller; + + return () => { + controller.stop(); + controllerRef.current = null; + }; + }, [applyEvent, client, enabled, handleSessionNotify, setConnectionState, setError]); + + const reportActiveSession = useCallback((sessionId: string | null) => { + controllerRef.current?.reportActiveSession(sessionId); + }, []); - return () => controller.stop(); - }, [applyEvent, client, enabled, setConnectionState, setError]); + return { reportActiveSession }; } diff --git a/apps/frontend/src/shared/session-meta.ts b/apps/frontend/src/shared/session-meta.ts index fa7b22aae..54f9ee447 100644 --- a/apps/frontend/src/shared/session-meta.ts +++ b/apps/frontend/src/shared/session-meta.ts @@ -27,3 +27,63 @@ const FALLBACK: SessionModeMeta = { icon: ListChecks, label: "Session" }; export function getSessionModeMeta(mode: SessionMode): SessionModeMeta { return MODE_META[mode] ?? FALLBACK; } + +const ORIGINS = /^(claude-code|opencode|pi|plannotator-frontend|codex|copilot-cli|gemini-cli)-/; + +export function formatSessionLabel(label: string, mode: SessionMode): string { + // PR/MR review: "plugin-pr-review-owner/repo#123" → "PR #123" + const prMatch = label.match(/^plugin-(?:pr|mr)-review-.+?(#\d+|!\d+)$/); + if (prMatch) return `${label.includes("-mr-") ? "MR" : "PR"} ${prMatch[1]}`; + + // Local review: "plugin-review-{origin}-{project}-{branch}" → "project (branch)" + if (mode === "review") { + const stripped = label.replace(/^plugin-review-/, "").replace(ORIGINS, ""); + const lastDash = stripped.lastIndexOf("-"); + if (lastDash > 0) { + return `${stripped.slice(0, lastDash)} (${stripped.slice(lastDash + 1)})`; + } + return stripped; + } + + // Plan: "plugin-plan-{origin}-{project}-{branch}" → "project (branch)" + if (mode === "plan") { + const stripped = label.replace(/^plugin-plan-/, "").replace(ORIGINS, ""); + const lastDash = stripped.lastIndexOf("-"); + if (lastDash > 0) { + return `${stripped.slice(0, lastDash)} (${stripped.slice(lastDash + 1)})`; + } + return stripped; + } + + // Annotate: "plugin-annotate-{origin}-{file}-{branch}" → "file (branch)" + if (mode === "annotate") { + const stripped = label.replace(/^plugin-annotate-/, "").replace(ORIGINS, ""); + const lastDash = stripped.lastIndexOf("-"); + if (lastDash > 0) { + return `${stripped.slice(0, lastDash)} (${stripped.slice(lastDash + 1)})`; + } + return stripped; + } + + // Archive: "plugin-archive-{origin}-{project}-{branch}" → "project (branch)" + if (mode === "archive") { + const stripped = label.replace(/^plugin-archive-/, "").replace(ORIGINS, ""); + const lastDash = stripped.lastIndexOf("-"); + if (lastDash > 0) { + return `${stripped.slice(0, lastDash)} (${stripped.slice(lastDash + 1)})`; + } + return stripped; + } + + // Goal setup: "goal-setup-{stage}-{slug}-{branch}" → "slug (branch)" + if (mode === "goal-setup") { + const stripped = label.replace(/^goal-setup-(interview|facts)-/, ""); + const lastDash = stripped.lastIndexOf("-"); + if (lastDash > 0) { + return `${stripped.slice(0, lastDash)} (${stripped.slice(lastDash + 1)})`; + } + return stripped; + } + + return label; +} diff --git a/apps/frontend/src/stores/app-store.ts b/apps/frontend/src/stores/app-store.ts index b05138ee5..8a612af72 100644 --- a/apps/frontend/src/stores/app-store.ts +++ b/apps/frontend/src/stores/app-store.ts @@ -10,12 +10,14 @@ export interface VisitedSession { export interface AppState { addProjectOpen: boolean; + settingsOpen: boolean; activeSessionId: string | null; visitedSessions: Record; } export interface AppActions { setAddProjectOpen(open: boolean): void; + setSettingsOpen(open: boolean): void; activateSession(sessionId: string, bootstrap: SessionBootstrap): void; deactivateSession(): void; removeSession(sessionId: string): void; @@ -25,6 +27,7 @@ export type AppStore = AppState & AppActions; const initialState: AppState = { addProjectOpen: false, + settingsOpen: false, activeSessionId: null, visitedSessions: {}, }; @@ -39,6 +42,11 @@ export function createAppStore(initial: Partial = {}) { state.addProjectOpen = open; }); }, + setSettingsOpen(open) { + set((state) => { + state.settingsOpen = open; + }); + }, activateSession(sessionId, bootstrap) { set((state) => { state.activeSessionId = sessionId; diff --git a/apps/frontend/src/stores/git-dashboard-store.ts b/apps/frontend/src/stores/git-dashboard-store.ts new file mode 100644 index 000000000..a2d5e4c83 --- /dev/null +++ b/apps/frontend/src/stores/git-dashboard-store.ts @@ -0,0 +1,128 @@ +import { createStore } from "zustand/vanilla"; +import { useStore } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import type { PRDetailedListItem } from "../daemon/contracts"; +import type { DaemonApiClient } from "../daemon/api/client"; +import { daemonApiClient } from "../daemon/api/client"; + +export interface GitDashboardPR extends PRDetailedListItem { + projectCwd: string; + projectName: string; + repoSlug: string; +} + +export interface GitDashboardState { + prs: GitDashboardPR[]; + loading: boolean; + error?: string; + lastFetchedAt: number | null; + lastProjectKey: string; +} + +export interface GitDashboardActions { + fetchAllPRs( + projects: Array<{ cwd: string; name: string; parentCwd?: string }>, + client?: DaemonApiClient, + ): Promise; + clear(): void; +} + +export type GitDashboardStore = GitDashboardState & GitDashboardActions; + +function extractRepoSlug(url: string): string { + const gh = url.match(/github\.com\/([^/]+\/[^/]+)/); + if (gh) return gh[1]; + const gl = url.match(/gitlab\.[^/]+\/(.+?)\/-\//); + if (gl) return gl[1]; + return ""; +} + +const initialState: GitDashboardState = { + prs: [], + loading: false, + lastFetchedAt: null, + lastProjectKey: "", +}; + +export const gitDashboardStore = createStore()( + immer((set) => ({ + ...initialState, + + async fetchAllPRs(projects, client = daemonApiClient) { + const topLevel = projects.filter((p) => !p.parentCwd); + if (topLevel.length === 0) return; + + set((state) => { + state.loading = true; + state.error = undefined; + }); + + const results = await Promise.allSettled( + topLevel.map(async (project) => { + const result = await client.listDetailedPRs(project.cwd); + return { project, result }; + }), + ); + + const allPRs: GitDashboardPR[] = []; + const errors: string[] = []; + + for (const outcome of results) { + if (outcome.status === "rejected") continue; + const { project, result } = outcome.value; + if (!result.ok) continue; + if (result.data.error) { + const e = result.data.error; + if (e === "no-cli") errors.push(`${project.name}: GitHub/GitLab CLI not installed`); + else if (e === "auth-failed") errors.push(`${project.name}: CLI not authenticated`); + continue; + } + for (const pr of result.data.prs) { + allPRs.push({ + ...pr, + projectCwd: project.cwd, + projectName: project.name, + repoSlug: extractRepoSlug(pr.url), + }); + } + } + + const seen = new Set(); + const deduplicated = allPRs.filter((pr) => { + if (seen.has(pr.url)) return false; + seen.add(pr.url); + return true; + }); + + deduplicated.sort((a, b) => { + if (a.updatedAt && b.updatedAt) return b.updatedAt.localeCompare(a.updatedAt); + return b.number - a.number; + }); + + set((state) => { + state.prs = deduplicated; + state.loading = false; + state.lastFetchedAt = Date.now(); + state.lastProjectKey = topLevel + .map((p) => p.cwd) + .sort() + .join("|"); + if (deduplicated.length === 0 && errors.length > 0) { + state.error = errors.join(". "); + } + }); + }, + + clear() { + set((state) => { + state.prs = []; + state.lastFetchedAt = null; + state.error = undefined; + }); + }, + })), +); + +export function useGitDashboardStore(selector: (state: GitDashboardStore) => T): T { + return useStore(gitDashboardStore, selector); +} diff --git a/apps/frontend/src/stores/project-store.ts b/apps/frontend/src/stores/project-store.ts index 907ffb233..460b25406 100644 --- a/apps/frontend/src/stores/project-store.ts +++ b/apps/frontend/src/stores/project-store.ts @@ -18,7 +18,7 @@ export interface ProjectStoreActions { name?: string, client?: DaemonApiClient, ): Promise; - removeProject(name: string, client?: DaemonApiClient): Promise; + removeProject(cwd: string, clean?: boolean, client?: DaemonApiClient): Promise; } export type ProjectStore = ProjectStoreState & ProjectStoreActions; @@ -59,19 +59,28 @@ 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; }, - async removeProject(name, client = daemonApiClient) { - const result = await client.removeProject(name); + async removeProject(cwd, clean, client = daemonApiClient) { + const result = await client.removeProject(cwd, clean); if (!result.ok) { set((state) => { state.error = result.error.message; @@ -79,7 +88,7 @@ export function createProjectStore(initial: Partial = {}) { return false; } set((state) => { - state.projects = state.projects.filter((p) => p.name !== name); + state.projects = state.projects.filter((p) => p.cwd !== cwd && p.parentCwd !== cwd); }); return true; }, diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css index 5855da4bc..7d52b050a 100644 --- a/apps/frontend/src/styles.css +++ b/apps/frontend/src/styles.css @@ -1,4 +1,5 @@ @import "@fontsource-variable/inter"; +@import "@fontsource-variable/instrument-sans"; @import "@fontsource-variable/geist-mono"; @import "@plannotator/ui/theme.css"; @import "tailwindcss"; diff --git a/apps/frontend/src/types/plannotator-ui.d.ts b/apps/frontend/src/types/plannotator-ui.d.ts index 672be485d..0c1134e5e 100644 --- a/apps/frontend/src/types/plannotator-ui.d.ts +++ b/apps/frontend/src/types/plannotator-ui.d.ts @@ -38,3 +38,5 @@ declare module "*.png" { const src: string; export default src; } + +declare const __APP_VERSION__: string; 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/bun.lock b/bun.lock index 5b8de3646..c4c565693 100644 --- a/bun.lock +++ b/bun.lock @@ -73,17 +73,20 @@ "version": "0.0.1", "dependencies": { "@fontsource-variable/geist-mono": "^5.2.7", + "@fontsource-variable/instrument-sans": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", "@plannotator/code-review": "workspace:*", "@plannotator/plan-review": "workspace:*", "@plannotator/shared": "workspace:*", "@plannotator/ui": "workspace:*", "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-router": "^1.141.0", "class-variance-authority": "^0.7.1", @@ -280,6 +283,7 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "tailwindcss": "^4.1.18", + "zustand": "^5.0.13", }, }, "packages/plannotator-plan-review": { @@ -648,6 +652,8 @@ "@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="], + "@fontsource-variable/instrument-sans": ["@fontsource-variable/instrument-sans@5.2.8", "", {}, "sha512-mTCaukbdIjjoipj2E3Q5XoZM3ZxJWdzyHevf/LG/0PHlfF9Q85pxOM7B7A9MerFyxmRzz5kVlumgIvgDSG4CPg=="], + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], "@google/genai": ["@google/genai@1.42.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-+3nlMTcrQufbQ8IumGkOphxD5Pd5kKyJOzLcnY0/1IuE8upJk5aLmoexZ2BJhBp1zAjRJMEB4a2CJwKI9e2EYw=="], @@ -992,6 +998,8 @@ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], diff --git a/goals/frontend-session-lifecycle/backlog.md b/goals/frontend-session-lifecycle/backlog.md new file mode 100644 index 000000000..3ba79516e --- /dev/null +++ b/goals/frontend-session-lifecycle/backlog.md @@ -0,0 +1,186 @@ +# Frontend Session Lifecycle — Backlog + +Tracked issues and feature requests for the daemon frontend app. + +--- + +## 1. ~~Completion overlay blocks the frontend~~ DONE + +Fixed in `7d2a626a`. Embedded surfaces now show a `CompletionBanner` (inline bar below the header) instead of the full-screen overlay. Action buttons hide after submission. Standalone mode unchanged. + +--- + +## 2. Tab mode config (open new tabs + auto-close) + +**Priority:** Low — may not diverge from default UX at all +**Size:** Small (once #6 is done) + +Some users will prefer each session opening in a new browser tab with auto-close after a decision. This is a config toggle, NOT a separate UI — the new frontend always renders. + +**Config:** `legacyTabMode: true` (or similar) in `~/.plannotator/config.json`. When set: +- CLI always calls `openBrowser()` for each session (no WebSocket navigate/notify) +- Auto-close behavior uses existing `plannotator-auto-close` cookie mechanism +- Same frontend app, same surfaces, just one-session-per-tab + +**Open question:** Once the core session lifecycle (#3, #4, #6) is designed, this might just be a single boolean that skips the "smart open" logic. Deferring until we see how much the UX actually diverges. + +--- + +## 3. Live plan updates across deny/replan cycles + +**Priority:** High — most-requested feature +**Size:** Large + +When the agent resubmits a plan after denial, the existing session should reactivate in-place rather than spawning a new session. + +**Desired behavior:** +- User denies plan, sends feedback, agent replans +- Agent calls ExitPlanMode again — daemon matches it to the existing session +- Session status flips from "completed" back to "active" +- Frontend receives a push notification via WebSocket +- Plan diff system shows what changed between versions +- The plan→deny→replan→approve cycle happens in one persistent session + +**Open questions:** +- How does the daemon match a new plan submission to an existing session? By project + plan slug? By a correlation ID from the agent? +- Does the session status reset to "active" on resubmission, or show "updated"? +- How does this interact with the version history system already in `~/.plannotator/history/`? + +--- + +## 4. Session persistence after completion + +**Priority:** High — current behavior is broken +**Size:** Medium, tied to #3 + +**Current bug:** When a session is approved/denied, the daemon disposes the session handler. The session disappears from the sidebar even though the route still resolves. API calls fail, so the plan content is gone. + +**Required behavior:** +- Completed sessions stay in the sidebar with a status badge (approved/denied) +- Session content remains viewable (read-only) after a decision +- Sessions do NOT disappear — they move to a "completed" visual state +- If the plan comes back (#3), the session reactivates from this state + +**Implementation options:** +- Cache the last plan content before disposal so completed sessions can serve read-only responses +- Or make sessions truly persistent (longer-term, tied to #3) + +--- + +## 5. ~~No browser opens on session creation~~ DONE + +Fixed in `99d1aec6`. The daemon now serves the production frontend HTML at `/s/:id`. The CLI's existing `openBrowser()` call opens the daemon URL, which renders the full app. No separate Vite server needed in production. + +--- + +## 6. Smart session opening (daemon-driven) + +**Priority:** High — core UX for the new app model +**Size:** Medium + +Move browser-opening logic from CLI to daemon. The daemon decides what to do based on frontend connection state. + +### Three states + +| Frontend state | Daemon action | +|---|---| +| No frontend connected | Call `openBrowser("/s/:id")` — new tab, bootstraps the app | +| Frontend connected, on landing page or idle | Send WebSocket navigate event — same tab switches to the session | +| Frontend connected, user is in an active session | Send WebSocket notify event — toast appears, user clicks when ready | + +### Notification rules + +- **Toast:** Auto-dismissing (5-10s) with a "Go to plan" button +- **Only show when tab is focused:** Check `document.visibilityState`. If tab is backgrounded, queue the notification and show on return to tab +- **Sidebar badge:** Always update, regardless of tab focus. User sees the count when they look + +### What needs building + +1. **Daemon tracks frontend connections** — WebSocket hub already knows subscribers. Add a `hasFrontendClient()` check. +2. **Frontend reports active session** — Send `{ type: "focus", sessionId }` on navigation changes. Daemon stores this. +3. **Browser opening moves to daemon** — `POST /daemon/sessions` response includes `{ browserAction: "opened" | "navigated" | "notified" }`. CLI removes its `openBrowser()` call. +4. **New WebSocket event types:** + - `session-navigate` → frontend does `router.navigate("/s/:id")` + - `session-notify` → frontend shows auto-dismissing toast with action button +5. **Visibility-gated toasts** — Frontend checks `document.hidden` before showing. Queues if backgrounded. + +### What we can't do + +- Focus an existing browser tab from the server (OS limitation) +- Prevent `open` command from creating a new tab (but we avoid this by not calling `open` when frontend is connected) +- Know if user is looking at the browser vs another app (but `document.visibilityState` covers tab-level focus) + +--- + +## Sidebar design (open question) + +The sidebar session hierarchy needs rethinking. Currently grouped by mode (plan, review, annotate). Might make more sense grouped by project. Completed sessions should be visually distinct but present — not removed. + +**Current issues:** +- Sessions disappear from sidebar after completion (broken) +- Mode-based grouping may get chaotic with many sessions +- No visual distinction between active and completed sessions + +**Needs design exploration before implementation.** Tied to #3 and #4. + +--- + +## Migrate AddProjectDialog to Radix Dialog primitive + +**Priority:** Low — cosmetic consistency +**Size:** Small + +The `AddProjectDialog` hand-rolls its own modal with `fixed inset-0 z-50`, manual backdrop click, and manual Escape handling. Once the shadcn Dialog component exists (created for the unified settings dialog), this should be migrated to use it. The search/typeahead content stays the same — just swap the outer modal wrapper. Eliminates having two different modal implementations in the app. + +--- + +## GitLab custom domain detection + +**Priority:** Medium +**Size:** Medium + +The daemon's PR listing endpoint (`packages/server/daemon/server.ts:671`) determines GitHub vs GitLab by checking `host.toLowerCase().includes("gitlab")`. Self-hosted GitLab instances on custom domains (e.g. `code.company.com`) are misidentified as GitHub, so `gh` is invoked instead of `glab`, and PR listing fails silently. + +Needs a more robust detection strategy — either try `glab auth status` first, examine the remote URL structure, or let the user configure platform per-project. + +--- + +## PR stack splitting is order-dependent + +**Priority:** Low +**Size:** Medium + +The `buildStacks` function in `LandingPage.tsx` walks PR chains by following `baseBranch` links. The algorithm processes PRs in API return order, which means if a middle PR is encountered before its descendants, the chain can be split incorrectly. Multi-PR stacks (3+) may display as loose PRs depending on timing. + +Fix: build chains from leaves upward (start with PRs whose head branch isn't anyone else's base), or use a proper topological sort. + +--- + +## GitLab detailed PRs returns empty + +**Priority:** Medium +**Size:** Medium + +`packages/shared/pr-provider.ts:129` returns an empty array for GitLab in `fetchPRDetailedList()`. The git dashboard shows "No pull requests found" for GitLab repos even when they have open MRs. The `glab mr list --json` command supports the same fields we need — someone just needs to implement `fetchGlMRDetailedList` following the GitHub pattern. + +--- + +## configStore Zustand migration + +**Priority:** Medium +**Size:** Medium + +The custom `configStore` in `packages/ui/config/configStore.ts` is a hand-rolled pub-sub singleton. It works but doesn't integrate with the Zustand stores used elsewhere in the frontend. Migrating it to Zustand would unify state management and enable selector-based subscriptions instead of the current broadcast-to-all-listeners pattern. + +Scoped in `goals/performance/backlog/configstore-zustand-migration.md`. + +--- + +## Global keyboard registry cleanup + +**Priority:** Medium +**Size:** Large + +10+ raw `window.addEventListener('keydown', ...)` handlers across both app surfaces bypass the keyboard shortcut registry that was built in PR #652. These should be consolidated into the registry for consistent handling, conflict detection, and the help modal. + +Scoped in `goals/performance/backlog/global-keyboard-registry.md`. diff --git a/goals/frontend-session-lifecycle/daemon-shell.md b/goals/frontend-session-lifecycle/daemon-shell.md new file mode 100644 index 000000000..1487ac8ed --- /dev/null +++ b/goals/frontend-session-lifecycle/daemon-shell.md @@ -0,0 +1,50 @@ +# Daemon Shell HTML — How It Works + +## Production (default) + +The daemon serves the production frontend (`apps/frontend/dist/index.html`) at all session URLs (`/s/:id`). This HTML is statically imported in `apps/hook/server/daemon-shell-html.ts` and bundled into the compiled binary. + +When the CLI creates a session, it opens the daemon's URL in the browser. The production frontend mounts, TanStack Router matches `/s/:id`, and the session surface renders. The daemon injects a `
- Register a project directory to launch sessions from. -
{error}
+ {ASCII_BANNER} +
+ {platform === "gitlab" ? "glab" : "gh"} auth login +
{pr.title}
+ {repoName} #{pr.number} + {" · "}@{pr.author} + {pr.updatedAt && ( + <> + {" · "} + {formatRelativeTime(pr.updatedAt)} + > + )} +