From 16ed550bf0c8d7f706f9ba155cd7825bbc15edf0 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 18 May 2026 16:56:50 -0700 Subject: [PATCH 1/5] Add production frontend app with daemon project registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daemon: - Project registry persisted to ~/.plannotator/projects.json - Auto-registers projects from session cwd on creation - GET/POST/DELETE /daemon/projects endpoints - cwd exposed on DaemonSessionSummary - project-registry feature flag + 9 unit tests Frontend (apps/frontend/): - TanStack Router, Zustand+Immer, Tailwind v4, oxlint/oxfmt - Daemon API client with project + session creation methods - WebSocket hub client with event stream + polling fallback - Landing page: project selector table + Code Review/Archive actions - Offcanvas sidebar: sessions grouped by mode (matching prototype) - Add project dialog - DiffKit theme + prototype shell pattern (bg-muted → card → content) - 13 shadcn primitives vendored in components/ui/ - Single-file HTML build (viteSingleFile) - Dev script: daemon + Vite with auto-proxy (scripts/dev-frontend.ts) --- apps/frontend/.oxfmtignore | 2 + apps/frontend/README.md | 34 + apps/frontend/index.html | 12 + apps/frontend/package.json | 57 ++ .../scripts/verify-single-file-build.ts | 56 ++ apps/frontend/src/app/Layout.tsx | 36 + apps/frontend/src/app/router.tsx | 23 + .../components/landing/AddProjectDialog.tsx | 137 ++++ .../src/components/landing/LandingPage.tsx | 164 ++++ .../src/components/sidebar/AppSidebar.tsx | 146 ++++ apps/frontend/src/components/ui/badge.tsx | 39 + apps/frontend/src/components/ui/button.tsx | 77 ++ apps/frontend/src/components/ui/card.tsx | 58 ++ apps/frontend/src/components/ui/dialog.tsx | 215 ++++++ .../src/components/ui/dropdown-menu.tsx | 248 ++++++ apps/frontend/src/components/ui/input.tsx | 21 + apps/frontend/src/components/ui/label.tsx | 21 + apps/frontend/src/components/ui/separator.tsx | 28 + apps/frontend/src/components/ui/sheet.tsx | 130 ++++ apps/frontend/src/components/ui/sidebar.tsx | 713 ++++++++++++++++++ apps/frontend/src/components/ui/skeleton.tsx | 13 + apps/frontend/src/components/ui/tooltip.tsx | 56 ++ apps/frontend/src/daemon/api/client.ts | 474 ++++++++++++ apps/frontend/src/daemon/api/errors.ts | 45 ++ apps/frontend/src/daemon/contracts.ts | 63 ++ .../frontend/src/daemon/events/event-store.ts | 107 +++ .../src/daemon/events/event-stream.ts | 166 ++++ apps/frontend/src/daemon/events/hub-client.ts | 317 ++++++++ .../src/daemon/events/use-daemon-events.ts | 22 + .../src/hooks/projects/use-project-actions.ts | 47 ++ .../hooks/sessions/use-sessions-by-project.ts | 16 + apps/frontend/src/lib/utils.ts | 6 + apps/frontend/src/main.tsx | 25 + apps/frontend/src/routeTree.gen.ts | 77 ++ apps/frontend/src/routes/__root.tsx | 7 + apps/frontend/src/routes/index.tsx | 12 + apps/frontend/src/routes/s.$sessionId.tsx | 67 ++ apps/frontend/src/shared/session-meta.ts | 29 + apps/frontend/src/stores/app-store.ts | 38 + apps/frontend/src/stores/project-store.ts | 94 +++ apps/frontend/src/styles.css | 290 +++++++ apps/frontend/tsconfig.json | 24 + apps/frontend/vite.config.ts | 79 ++ apps/frontend/vitest.browser.config.ts | 24 + apps/frontend/vitest.config.ts | 19 + bun.lock | 116 ++- goals/initial-view/facts.md | 67 ++ goals/initial-view/goal.md | 20 + goals/initial-view/plan.md | 176 +++++ package.json | 1 + .../server/daemon/project-registry.test.ts | 81 ++ packages/server/daemon/project-registry.ts | 93 +++ packages/server/daemon/server.ts | 40 + packages/server/daemon/session-factory.ts | 7 + packages/server/daemon/session-store.ts | 4 + packages/shared/daemon-protocol.ts | 13 + packages/ui/theme.css | 1 + packages/ui/themes/diffkit.css | 59 ++ packages/ui/utils/themeRegistry.ts | 10 + scripts/dev-frontend.ts | 39 + 60 files changed, 5060 insertions(+), 1 deletion(-) create mode 100644 apps/frontend/.oxfmtignore create mode 100644 apps/frontend/README.md create mode 100644 apps/frontend/index.html create mode 100644 apps/frontend/package.json create mode 100644 apps/frontend/scripts/verify-single-file-build.ts create mode 100644 apps/frontend/src/app/Layout.tsx create mode 100644 apps/frontend/src/app/router.tsx create mode 100644 apps/frontend/src/components/landing/AddProjectDialog.tsx create mode 100644 apps/frontend/src/components/landing/LandingPage.tsx create mode 100644 apps/frontend/src/components/sidebar/AppSidebar.tsx create mode 100644 apps/frontend/src/components/ui/badge.tsx create mode 100644 apps/frontend/src/components/ui/button.tsx create mode 100644 apps/frontend/src/components/ui/card.tsx create mode 100644 apps/frontend/src/components/ui/dialog.tsx create mode 100644 apps/frontend/src/components/ui/dropdown-menu.tsx create mode 100644 apps/frontend/src/components/ui/input.tsx create mode 100644 apps/frontend/src/components/ui/label.tsx create mode 100644 apps/frontend/src/components/ui/separator.tsx create mode 100644 apps/frontend/src/components/ui/sheet.tsx create mode 100644 apps/frontend/src/components/ui/sidebar.tsx create mode 100644 apps/frontend/src/components/ui/skeleton.tsx create mode 100644 apps/frontend/src/components/ui/tooltip.tsx create mode 100644 apps/frontend/src/daemon/api/client.ts create mode 100644 apps/frontend/src/daemon/api/errors.ts create mode 100644 apps/frontend/src/daemon/contracts.ts create mode 100644 apps/frontend/src/daemon/events/event-store.ts create mode 100644 apps/frontend/src/daemon/events/event-stream.ts create mode 100644 apps/frontend/src/daemon/events/hub-client.ts create mode 100644 apps/frontend/src/daemon/events/use-daemon-events.ts create mode 100644 apps/frontend/src/hooks/projects/use-project-actions.ts create mode 100644 apps/frontend/src/hooks/sessions/use-sessions-by-project.ts create mode 100644 apps/frontend/src/lib/utils.ts create mode 100644 apps/frontend/src/main.tsx create mode 100644 apps/frontend/src/routeTree.gen.ts create mode 100644 apps/frontend/src/routes/__root.tsx create mode 100644 apps/frontend/src/routes/index.tsx create mode 100644 apps/frontend/src/routes/s.$sessionId.tsx create mode 100644 apps/frontend/src/shared/session-meta.ts create mode 100644 apps/frontend/src/stores/app-store.ts create mode 100644 apps/frontend/src/stores/project-store.ts create mode 100644 apps/frontend/src/styles.css create mode 100644 apps/frontend/tsconfig.json create mode 100644 apps/frontend/vite.config.ts create mode 100644 apps/frontend/vitest.browser.config.ts create mode 100644 apps/frontend/vitest.config.ts create mode 100644 goals/initial-view/facts.md create mode 100644 goals/initial-view/goal.md create mode 100644 goals/initial-view/plan.md create mode 100644 packages/server/daemon/project-registry.test.ts create mode 100644 packages/server/daemon/project-registry.ts create mode 100644 packages/ui/themes/diffkit.css create mode 100644 scripts/dev-frontend.ts diff --git a/apps/frontend/.oxfmtignore b/apps/frontend/.oxfmtignore new file mode 100644 index 000000000..928e4ae2f --- /dev/null +++ b/apps/frontend/.oxfmtignore @@ -0,0 +1,2 @@ +dist +src/routeTree.gen.ts diff --git a/apps/frontend/README.md b/apps/frontend/README.md new file mode 100644 index 000000000..70e66e7ca --- /dev/null +++ b/apps/frontend/README.md @@ -0,0 +1,34 @@ +# @plannotator/debug-frontend + +Debug/development harness UI for the Plannotator daemon runtime. **Not production code** — this is a +testbed for exercising daemon sessions, verifying event streams, and testing session lifecycle actions. + +## Shape + +- `src/routes` is only TanStack Router wiring. +- `src/daemon` owns the typed daemon API client and contracts. +- `src/sessions` owns session ids, session state, the dashboard, and mode dispatch. +- `src/plan`, `src/review`, `src/annotate`, `src/archive`, and `src/setup-goal` own product views. +- `src/testing` owns contract fixtures and browser helpers. + +The shell talks to session APIs through `/s/:sessionId/api`, never root `/api`. + +The build is intentionally single-file HTML for daemon serving. Separate static asset +routes are deferred until the full UI migration needs code splitting or cacheable chunks. + +## Commands + +```bash +bun run --cwd apps/debug-frontend dev +bun run --cwd apps/debug-frontend build +bun run --cwd apps/debug-frontend check +bun run --cwd apps/debug-frontend test:browser +``` + +Or from the repo root: + +```bash +bun run dev:debug-frontend +bun run build:debug-frontend +bun run check:debug-frontend +``` diff --git a/apps/frontend/index.html b/apps/frontend/index.html new file mode 100644 index 000000000..a975c0f7a --- /dev/null +++ b/apps/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Plannotator + + +
+ + + diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 000000000..07e5eec54 --- /dev/null +++ b/apps/frontend/package.json @@ -0,0 +1,57 @@ +{ + "name": "@plannotator/frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && bun run scripts/verify-single-file-build.ts", + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "oxlint .", + "lint:fix": "oxlint . --fix", + "fmt": "oxfmt --ignore-path .oxfmtignore --write .", + "fmt:check": "oxfmt --ignore-path .oxfmtignore --check .", + "test": "vitest run --passWithNoTests", + "check": "bun run typecheck && bun run lint && bun run fmt:check && bun run test" + }, + "dependencies": { + "@fontsource-variable/geist-mono": "^5.2.7", + "@fontsource-variable/inter": "^5.2.8", + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "@radix-ui/react-collapsible": "^1.1.12", + "@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-tooltip": "^1.2.8", + "@tanstack/react-router": "^1.141.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "immer": "^10.2.0", + "lucide-react": "^1.14.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.18", + "@tanstack/router-plugin": "^1.141.0", + "@types/node": "^22.14.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "oxfmt": "^0.17.0", + "oxlint": "^1.31.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.8.2", + "vite": "^6.2.0", + "vite-plugin-singlefile": "^2.0.3", + "vitest": "^4.0.16" + } +} diff --git a/apps/frontend/scripts/verify-single-file-build.ts b/apps/frontend/scripts/verify-single-file-build.ts new file mode 100644 index 000000000..137408b26 --- /dev/null +++ b/apps/frontend/scripts/verify-single-file-build.ts @@ -0,0 +1,56 @@ +import { existsSync, readdirSync, readFileSync } from "fs"; +import { join, relative } from "path"; + +const distDir = join(import.meta.dirname, "..", "dist"); +const indexPath = join(distDir, "index.html"); + +function listFiles(dir: string): string[] { + if (!existsSync(dir)) return []; + + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const entryPath = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...listFiles(entryPath)); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + return files; +} + +if (!existsSync(indexPath)) { + throw new Error("Expected apps/debug-frontend/dist/index.html to exist after build."); +} + +const html = readFileSync(indexPath, "utf-8"); + +const outputFiles = listFiles(distDir) + .map((file) => relative(distDir, file)) + .sort(); +const extraFiles = outputFiles.filter((file) => file !== "index.html"); + +if (extraFiles.length > 0) { + throw new Error( + `Frontend daemon shell build must be single-file; found outputs: ${extraFiles.join(", ")}`, + ); +} + +const htmlWithoutInlineCode = html + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/style>/gi, ""); + +const externalScriptPattern = /]*\bsrc=["'][^"']+["']/i; +const externalLinkPatterns = [ + /]*\brel=["'](?:stylesheet|modulepreload|preload)["'][^>]*\bhref=["'][^"']+["']/i, + /]*\bhref=["'][^"']+["'][^>]*\brel=["'](?:stylesheet|modulepreload|preload)["']/i, +]; + +if ( + externalScriptPattern.test(html) || + externalLinkPatterns.some((pattern) => pattern.test(htmlWithoutInlineCode)) +) { + throw new Error("Frontend daemon shell build must inline scripts and styles."); +} + +console.log("Verified single-file frontend shell build."); diff --git a/apps/frontend/src/app/Layout.tsx b/apps/frontend/src/app/Layout.tsx new file mode 100644 index 000000000..6a6876c19 --- /dev/null +++ b/apps/frontend/src/app/Layout.tsx @@ -0,0 +1,36 @@ +import { useCallback, useEffect } from "react"; +import { Outlet } from "@tanstack/react-router"; +import { Toaster } from "sonner"; +import { SidebarProvider } from "@/components/ui/sidebar"; +import { AppSidebar } from "../components/sidebar/AppSidebar"; +import { AddProjectDialog } from "../components/landing/AddProjectDialog"; +import { useDaemonEvents } from "../daemon/events/use-daemon-events"; +import { projectStore } from "../stores/project-store"; +import { useAppStore } from "../stores/app-store"; + +export function Layout() { + const addProjectOpen = useAppStore((s) => s.addProjectOpen); + const setAddProjectOpen = useAppStore((s) => s.setAddProjectOpen); + + useDaemonEvents(); + + useEffect(() => { + void projectStore.getState().fetchProjects(); + }, []); + + const openAddProject = useCallback(() => setAddProjectOpen(true), [setAddProjectOpen]); + + return ( + + +
+ +
+ + +
+ ); +} diff --git a/apps/frontend/src/app/router.tsx b/apps/frontend/src/app/router.tsx new file mode 100644 index 000000000..63b8e47d0 --- /dev/null +++ b/apps/frontend/src/app/router.tsx @@ -0,0 +1,23 @@ +import { createRouter } from "@tanstack/react-router"; +import { createDaemonApiClient, type DaemonApiClient } from "../daemon/api/client"; +import { routeTree } from "../routeTree.gen"; + +export interface AppRouterContext { + daemonClient: DaemonApiClient; +} + +export function createAppRouter( + context: AppRouterContext = { daemonClient: createDaemonApiClient() }, +) { + return createRouter({ + routeTree, + context, + defaultPreload: "intent", + }); +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } +} diff --git a/apps/frontend/src/components/landing/AddProjectDialog.tsx b/apps/frontend/src/components/landing/AddProjectDialog.tsx new file mode 100644 index 000000000..0b5339fd7 --- /dev/null +++ b/apps/frontend/src/components/landing/AddProjectDialog.tsx @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useProjectStore } from "../../stores/project-store"; + +interface AddProjectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AddProjectDialog({ open, onOpenChange }: AddProjectDialogProps) { + const [cwd, setCwd] = useState(""); + const [name, setName] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const addProject = useProjectStore((s) => s.addProject); + const inputRef = useRef(null); + + useEffect(() => { + if (open) { + setCwd(""); + setName(""); + setError(undefined); + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [open]); + + useEffect(() => { + if (!open) return; + const handle = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + 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) { + onOpenChange(false); + } else { + setError("Failed to add project. Check the path exists."); + } + }, + [cwd, name, addProject, onOpenChange], + ); + + if (!open) return null; + + return ( +
onOpenChange(false)} + > +
e.stopPropagation()} + > +
+

Add project

+ +
+

+ Register a project directory to launch sessions from. +

+ +
+
+
+ + setCwd(e.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-[13px] outline-none placeholder:text-muted-foreground/50 focus:border-ring focus:ring-[3px] focus:ring-ring/50" + /> +
+
+ + setName(e.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-[13px] outline-none placeholder:text-muted-foreground/50 focus:border-ring focus:ring-[3px] focus:ring-ring/50" + /> +
+ {error &&

{error}

} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/landing/LandingPage.tsx b/apps/frontend/src/components/landing/LandingPage.tsx new file mode 100644 index 000000000..a1667d652 --- /dev/null +++ b/apps/frontend/src/components/landing/LandingPage.tsx @@ -0,0 +1,164 @@ +import { useCallback, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { Code2, Archive, Folder, FolderPlus } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { useProjectStore } from "../../stores/project-store"; +import { daemonApiClient } from "../../daemon/api/client"; +import type { ProjectEntry } from "../../daemon/contracts"; + +interface LandingPageProps { + onAddProject: () => void; +} + +export function LandingPage({ onAddProject }: LandingPageProps) { + const projects = useProjectStore((s) => s.projects); + const [selected, setSelected] = useState(null); + const [loading, setLoading] = useState(null); + const navigate = useNavigate(); + + const selectedProject = projects.find((p) => p.name === selected); + + const handleAction = useCallback( + async (action: "review" | "archive") => { + if (!selectedProject) return; + setLoading(action); + const result = + action === "review" + ? await daemonApiClient.createReviewSession(selectedProject.cwd) + : await daemonApiClient.createArchiveSession(selectedProject.cwd); + 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 }); + } + }, + [selectedProject, navigate], + ); + + return ( +
+ + +
+
+
+
+ {projects.length === 0 ? ( + + ) : ( + <> + + Select project + + + + +
+ + Launch + +
+
+ + +
+ + )} +
+
+
+
+
+ ); +} + +function ProjectTable({ + projects, + selected, + onSelect, +}: { + projects: ProjectEntry[]; + selected: string | null; + onSelect: (name: string) => void; +}) { + return ( +
+ {projects.map((project, i) => ( + + ))} +
+ ); +} + +function EmptyState({ onAddProject }: { onAddProject: () => void }) { + return ( +
+

No projects yet

+

+ Projects appear automatically when an agent creates a session, or you can add one manually. +

+ +
+ ); +} diff --git a/apps/frontend/src/components/sidebar/AppSidebar.tsx b/apps/frontend/src/components/sidebar/AppSidebar.tsx new file mode 100644 index 000000000..86858b0ad --- /dev/null +++ b/apps/frontend/src/components/sidebar/AppSidebar.tsx @@ -0,0 +1,146 @@ +import { useCallback, useMemo } from "react"; +import { Link, useMatchRoute } from "@tanstack/react-router"; +import { Check, FolderPlus, Moon, Sun } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + 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"; + +const MODE_ORDER = ["plan", "review", "annotate", "goal-setup", "archive"]; + +interface AppSidebarProps { + onAddProject: () => void; +} + +export function AppSidebar({ onAddProject }: AppSidebarProps) { + const sessions = useDaemonEventStore((s) => s.sessions); + const { resolvedMode, setMode } = useTheme(); + const matchRoute = useMatchRoute(); + + const grouped = useMemo(() => { + const map = new Map(); + for (const s of sessions) { + const list = map.get(s.mode) ?? []; + list.push(s); + map.set(s.mode, list); + } + return map; + }, [sessions]); + + const toggleTheme = useCallback(() => { + setMode(resolvedMode === "dark" ? "light" : "dark"); + }, [resolvedMode, setMode]); + + return ( + + + + + +
+ P +
+
+ Plannotator + + {sessions.length} session{sessions.length !== 1 ? "s" : ""} + +
+
+
+
+
+ + + {MODE_ORDER.map((mode) => { + const modeSessions = grouped.get(mode); + if (!modeSessions?.length) return null; + const meta = getSessionModeMeta(mode); + + return ( + + {meta.label}s + + + {modeSessions.map((session) => { + const Icon = meta.icon; + const isActive = !!matchRoute({ + to: "/s/$sessionId", + params: { sessionId: session.id }, + }); + const isTerminal = + session.status === "completed" || session.status === "cancelled"; + + return ( + + + + + + {session.label} + + + + {session.status === "active" && ( + + + + )} + {isTerminal && ( + + + + )} + + ); + })} + + + + ); + })} + + + + + + + + Add project + + + + + {resolvedMode === "dark" ? : } + Toggle theme + + + + +
+ ); +} diff --git a/apps/frontend/src/components/ui/badge.tsx b/apps/frontend/src/components/ui/badge.tsx new file mode 100644 index 000000000..80612e7da --- /dev/null +++ b/apps/frontend/src/components/ui/badge.tsx @@ -0,0 +1,39 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-3 py-1 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-auto", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", + outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/apps/frontend/src/components/ui/button.tsx b/apps/frontend/src/components/ui/button.tsx new file mode 100644 index 000000000..acf8c3d9e --- /dev/null +++ b/apps/frontend/src/components/ui/button.tsx @@ -0,0 +1,77 @@ +import { Slot, Slottable } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-[13px] font-medium transition-[color,background-color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", + outline: + "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + xxs: "h-6 rounded-md gap-1.5 px-2.5", + xs: "h-7 rounded-md gap-1.5 px-2.5", + sm: "h-8 rounded-md gap-1.5 px-3", + lg: "h-10 rounded-md px-6", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +type ButtonIcon = React.ReactNode; + +function Button({ + children, + className, + variant, + size, + asChild = false, + iconLeft, + iconRight, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + iconLeft?: ButtonIcon; + iconRight?: ButtonIcon; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + {iconLeft ? ( + + ) : null} + {children} + {iconRight ? ( + + ) : null} + + ); +} + +export { Button, buttonVariants }; diff --git a/apps/frontend/src/components/ui/card.tsx b/apps/frontend/src/components/ui/card.tsx new file mode 100644 index 000000000..9fef829ba --- /dev/null +++ b/apps/frontend/src/components/ui/card.tsx @@ -0,0 +1,58 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/apps/frontend/src/components/ui/dialog.tsx b/apps/frontend/src/components/ui/dialog.tsx new file mode 100644 index 000000000..01133637e --- /dev/null +++ b/apps/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { X as XIcon } from "lucide-react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import type * as React from "react"; +import { useSyncExternalStore } from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; + +import { cn } from "@/lib/utils"; + +// --------------------------------------------------------------------------- +// Mobile detection (shared across all dialog instances) +// --------------------------------------------------------------------------- + +const MD_QUERY = "(min-width: 768px)"; +const subscribe = (cb: () => void) => { + const mql = window.matchMedia(MD_QUERY); + mql.addEventListener("change", cb); + return () => mql.removeEventListener("change", cb); +}; +const getSnapshot = () => window.matchMedia(MD_QUERY).matches; +const getServerSnapshot = () => true; + +function useIsDesktop() { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +// --------------------------------------------------------------------------- +// Dialog — renders as Vaul Drawer on mobile, Radix Dialog on desktop +// --------------------------------------------------------------------------- + +function Dialog({ ...props }: React.ComponentProps) { + const isDesktop = useIsDesktop(); + + if (!isDesktop) { + return ( + + {props.children} + + ); + } + + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + const isDesktop = useIsDesktop(); + + if (!isDesktop) { + return ; + } + + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + const isDesktop = useIsDesktop(); + + if (!isDesktop) { + return ; + } + + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + const isDesktop = useIsDesktop(); + + if (!isDesktop) { + return ( + + + +
+
{children}
+ + + ); + } + + return ( + + + + {children} + + + Close + + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + const isDesktop = useIsDesktop(); + + if (!isDesktop) { + return ( + + ); + } + + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + const isDesktop = useIsDesktop(); + + if (!isDesktop) { + return ( + + ); + } + + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/apps/frontend/src/components/ui/dropdown-menu.tsx b/apps/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..4fcfa5fb8 --- /dev/null +++ b/apps/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { + Check as CheckIcon, + ChevronRight as ChevronRightIcon, + Circle as CircleIcon, +} from "lucide-react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ + className, + keys, + children, + ...props +}: React.ComponentProps<"span"> & { + keys?: string[]; +}) { + return ( + + {keys + ? keys.map((key, i) => ( + + {i > 0 && then} + {key} + + )) + : children} + + ); +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/apps/frontend/src/components/ui/input.tsx b/apps/frontend/src/components/ui/input.tsx new file mode 100644 index 000000000..13fc29e98 --- /dev/null +++ b/apps/frontend/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/frontend/src/components/ui/label.tsx b/apps/frontend/src/components/ui/label.tsx new file mode 100644 index 000000000..aa7fe645a --- /dev/null +++ b/apps/frontend/src/components/ui/label.tsx @@ -0,0 +1,21 @@ +"use client"; + +import * as LabelPrimitive from "@radix-ui/react-label"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Label({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/apps/frontend/src/components/ui/separator.tsx b/apps/frontend/src/components/ui/separator.tsx new file mode 100644 index 000000000..856296e91 --- /dev/null +++ b/apps/frontend/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/apps/frontend/src/components/ui/sheet.tsx b/apps/frontend/src/components/ui/sheet.tsx new file mode 100644 index 000000000..4873123f8 --- /dev/null +++ b/apps/frontend/src/components/ui/sheet.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { X } from "lucide-react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Sheet({ ...props }: React.ComponentProps) { + return ; +} + +function SheetTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function SheetClose({ ...props }: React.ComponentProps) { + return ; +} + +function SheetPortal({ ...props }: React.ComponentProps) { + return ; +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left"; +}) { + return ( + + + + {children} + + + Close + + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/apps/frontend/src/components/ui/sidebar.tsx b/apps/frontend/src/components/ui/sidebar.tsx new file mode 100644 index 000000000..2893d59c2 --- /dev/null +++ b/apps/frontend/src/components/ui/sidebar.tsx @@ -0,0 +1,713 @@ +"use client"; + +import { PanelLeft } from "lucide-react"; +import { Slot } from "@radix-ui/react-slot"; +import type { VariantProps } from "class-variance-authority"; +import { cva } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +const SIDEBAR_STORAGE_KEY = "sidebar_state"; +const SIDEBAR_WIDTH = "244px"; // 16rem +const SIDEBAR_WIDTH_MOBILE = "260px"; // 18rem +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +const MOBILE_BREAKPOINT = 1024; + +function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined); + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +} + +type SidebarContext = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(() => { + if (typeof window === "undefined") { + return defaultOpen; + } + + const storedOpenState = window.localStorage.getItem(SIDEBAR_STORAGE_KEY); + return storedOpenState === null ? defaultOpen : storedOpenState === "true"; + }); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(openState)); + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, toggleSidebar], + ); + + return ( + + +
+ {children} +
+
+
+ ); +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === "none") { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ); + } + + return ( +
+
+ +
+ ); +} + +function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar(); + + return ( + +
+ {projects.length > 0 && ( +
+ + Select project + + + -
- - Launch - -
-
- +
+ + Launch + +
+ + +
+
+
+ )} + + {sessions.length > 0 && ( +
+ + Active sessions + + +
+ )} + + {projects.length === 0 && ( -
- + )} +
)}
@@ -144,6 +175,33 @@ function ProjectTable({ ); } +function SessionList({ sessions }: { sessions: SessionSummary[] }) { + return ( +
+ {sessions.map((session, i) => { + const meta = getSessionModeMeta(session.mode); + const Icon = meta.icon; + return ( + 0 && "border-t border-border/40", + "text-muted-foreground hover:bg-surface-1/50 hover:text-foreground", + )} + > + + {session.label} + {meta.label} + + ); + })} +
+ ); +} + function EmptyState({ onAddProject }: { onAddProject: () => void }) { return (
From 646f7b26db893e98a7a27e630e51d3547f69d4e7 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 26 May 2026 17:26:52 -0700 Subject: [PATCH 5/5] Embed code review surface in frontend app (#755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Copy review-editor and editor as new embeddable packages packages/plannotator-code-review — copy of packages/review-editor packages/plannotator-plan-review — copy of packages/editor Unmodified copies to start. These will be refactored to strip standalone providers (ThemeProvider, TooltipProvider, Toaster) and accept session-scoped API context from the frontend shell. The original packages remain untouched for the legacy single-file HTML flow. * Add useSessionFetch hook and SessionProvider React context that scopes fetch calls to a daemon session. When inside a SessionProvider, fetch("/api/diff") rewrites to fetch("/s/:sessionId/api/diff"). Without a provider, returns the global fetch unchanged. * Migrate shared hooks to useSessionFetch Add const fetch = useSessionFetch() to 10 hooks in packages/ui/hooks/. The shadowed fetch variable routes /api/ calls through the session context when a SessionProvider is present, and falls back to global fetch when not. configStore.ts uses apiFetch (import-based) since it's a class method. * Migrate code review package to useSessionFetch Add const fetch = useSessionFetch() to all 11 files in packages/plannotator-code-review/ that call fetch("/api/..."). The shadowed fetch variable routes calls through the session context. No fetch call sites were modified — only the function that provides the fetch was changed. * Export ReviewAppEmbedded without standalone providers ReviewApp accepts __embedded prop to skip ThemeProvider, TooltipProvider, and Toaster (shell provides these). Uses h-full instead of h-screen when embedded. ReviewAppEmbedded is a named export that passes the prop. Default export unchanged. Shell Layout gains TooltipProvider for code review tooltips. * Mount code review surface in frontend session route When session.mode === "review", the /s/:sessionId route wraps ReviewAppEmbedded in a SessionProvider and renders the full code review UI. Other modes keep the placeholder. - Added @plannotator/code-review as frontend dependency - Created App.d.ts type declaration for the code review package - Added plannotator-ui.d.ts for SessionProvider and ThemeProvider types - Added PNG module declaration for asset imports - Fixed settings.ts satisfies type for strict-mode compatibility - Vite alias resolves to package source for bundling - TypeScript uses .d.ts for type checking (avoids strict-checking loose package source) * Fix session surface integration issues - Add @custom-variant dark to code-review CSS for .light class toggle - Remove forced theme cookies from main.tsx (use defaultColorTheme prop) - Clean up document.title on review unmount (restore previous) - Clean up CSS custom properties on review unmount - Define __APP_VERSION__ from root package.json in vite config - Narrow Vite proxy to /s/:id/api/ only (page loads stay with Vite SPA) - Skip auto-registering temp directory projects in session factory - Add max-height scroll to project table for overflow - Add headerLeft prop to ReviewAppEmbedded for global sidebar trigger - Fix header padding when sidebar trigger is present * Resolve ~ to home directory in addProject * Fix duplicate Tailwind build causing style conflicts The code review's index.css had its own @import "tailwindcss" with separate @source and @theme directives, producing a second Tailwind build that competed with the frontend's styles.css. This caused buttons, dialogs, and other components to render with wrong styles. Fix: remove Tailwind, theme import, and @source from the code review's index.css (keep only dockview + custom CSS). Add @source directives to the frontend's styles.css to scan the code review package and shared UI components. One Tailwind build, one theme, no conflicts. * Clean up CSS: remove duplications, fix keyframe collision - Rename code review's @keyframes fade-in to cr-fade-in to avoid collision with the frontend's fade-in (different animation) - Remove redundant panel scrollbar rules from styles.css (global scrollbar rules already cover all elements) - Add comment noting intentional scrollbar override of theme.css - Single Tailwind build, single theme import, zero duplications * Make sidebar logo link to homepage * Match sidebar trigger hover style to code review buttons * Fix sidebar session labels and badge overlap - Strip machine-generated prefixes from session labels (plugin-review-, claude-code-, etc.) to show just the project/PR name - Add pr-7 padding to menu buttons so truncation ellipsis doesn't overlap with the status badge dot - Full label still visible on hover via tooltip * Fix formatting * Fix test suite: restore globalThis.fetch after useSessionFetch tests The useSessionFetch test replaced globalThis.fetch with a mock in beforeEach but never restored it. Other test files running in the same process (daemon runtime tests) got the mock instead of real fetch, causing JSON parse failures on "ok" responses. Added afterEach to restore the original fetch. All 1,463 tests pass. * Add React Activity keep-alive for session surfaces Sessions now stay alive when the user navigates away. Instead of unmounting and remounting on each navigation, visited sessions are hidden via React's and restored instantly when the user returns. - AppStore tracks visitedSessions (keyed by session ID) and activeSessionId - Layout renders all visited sessions in wrappers — only the active one is visible, the rest are hidden but preserved - Session route registers its bootstrap data with the store and renders nothing — Layout owns the rendering - Landing page deactivates the current session when navigated to - SessionSurface component extracted to handle mode-based rendering Effects clean up when hidden (WebSocket subs, timers stop) and restart when visible. DOM, React state, scroll position, annotations, dock layout all survive navigation. * Fix: show error state when session load fails during active session * Fix: deactivate session from Layout instead of inside hidden Activity * Dispatch resize event when session becomes visible to fix Pierre diffs * Replace Activity with visibility:hidden for session keep-alive Activity uses display:none which breaks Pierre diffs' virtualizer — it measures the container at zero height and renders no content. When made visible again, the virtualizer doesn't recalculate. Switch to visibility:hidden + position:absolute which preserves element dimensions. Pierre diffs keeps its measurements, Dockview keeps its layout. The tradeoff is effects don't pause for hidden sessions, but that's preferable to broken diffs. * Fix: derive landing page visibility from route match synchronously * Set router pendingMs to 0 to eliminate navigation delay * Auto-restore code review drafts silently, remove restore dialog Drafts are keyed by diff content hash on the server. Same diff = same draft. When a draft exists on mount, it's now restored automatically with a subtle toast notification instead of a blocking dialog. - useCodeAnnotationDraft takes an onRestore callback instead of returning draftBanner/restoreDraft/dismissDraft - ConfirmDialog for draft restore removed from App.tsx - Toast shows "Restored N annotations" on auto-restore * Fix flash of unstyled sidebar on page load * Style Toaster with theme tokens * Add rounded top-left corner to embedded code review * Move rounded corner to Layout session container and clip overflow * Fix curved border: move border from sidebar to session container * Only show curved border when sidebar is open * Fix: use useSidebar hook for conditional curved border * Fix project registry: key by cwd, defer registration until session succeeds - registerProject now finds existing entries by cwd (not name), so two repos with the same name get separate entries - removeProject takes cwd instead of name for unambiguous deletion - Server DELETE /daemon/projects now accepts JSON body with cwd - Session factory defers registerProject until after session creation succeeds, avoiding phantom entries from failed requests - Hub-client: add missing scheduleReconnect after protocol error frames * Embed plan review surface and fix cross-surface issues (#758) * Migrate missed shared UI components to useSessionFetch 8 component/hook files in packages/ui/ still had bare fetch('/api/...') calls that weren't caught during the code review migration: - Settings.tsx, InlineMarkdown.tsx, AttachmentsButton.tsx, ExportModal.tsx - settings/HooksTab.tsx, goal-setup/GoalSetupSurface.tsx - plan-diff/PlanDiffViewer.tsx, hooks/useLinkedDoc.ts Also set window.__PLANNOTATOR_API_BASE__ in SessionProvider so non-hook consumers (apiPath for in ImageThumbnail) get session-scoped paths. * Migrate plan review App.tsx to useSessionFetch + title cleanup * Auto-restore plan review drafts silently, remove restore dialog Rewrite useAnnotationDraft with onRestore callback pattern (same as useCodeAnnotationDraft). Legacy tuple format preserved. Toast on restore. ConfirmDialog removed from plan review App.tsx. * Export PlanAppEmbedded without standalone providers Strip ThemeProvider, TooltipProvider, Toaster when __embedded. h-full instead of h-screen. headerLeft prop passed through to AppHeader for sidebar trigger. App.d.ts type declaration added. * Strip Tailwind from plan review CSS, add @source to frontend * Remove dead toast animation classes that conflicted with tailwindcss-animate * Add visibility guards to keyboard handlers on both surfaces When keep-alive hides a surface with visibility:hidden, its keyboard listeners on window/document stay active. Without guards, Mod+Enter on the visible code review would also fire the hidden plan review's submit handler. Both App.tsx files now check getComputedStyle(rootRef).visibility at the start of every keyboard handler. If hidden, return early. * Wire plan review surface into frontend app All session modes now render production surfaces: - review → ReviewAppEmbedded - plan, annotate, archive, goal-setup → PlanAppEmbedded Added @plannotator/plan-review dependency, Vite aliases, and styles. SessionSurface simplified — review gets code review, everything else gets plan review (which determines its mode from /api/plan response). * Fix: pass session fetch to submitGoalSetup helper * Replace full-screen completion overlay with inline banner in embedded mode When running inside the frontend app (__embedded), the CompletionOverlay blocked the entire viewport including the sidebar. Now: - Embedded surfaces show a CompletionBanner (colored bar below the header) - Action buttons hide after submission (plan review hides via AppHeader submitted prop, code review hides via !submitted guard) - Standalone mode keeps the original full-screen overlay with auto-close - No window.close() fires in embedded mode since useAutoClose lives inside CompletionOverlay which is skipped * Serve production frontend from daemon, debug shell via env var only The daemon now serves the production frontend HTML (apps/frontend/) at session URLs. The debug frontend is only loaded when PLANNOTATOR_DEBUG_SHELL=1, read from disk at runtime — never bundled in the compiled binary. * Session lifecycle, worktree projects, and directory picker (#759) * Add frontend visibility and focus reporting to daemon WebSocket The daemon now tracks per-connection client state: tab visibility and active session ID. The frontend reports these via a new `client-state` WebSocket message type on connect, visibility change, and route navigation. The event hub exposes `getFrontendState()` which returns whether any frontend is connected, any tab is visible, and which sessions are actively being viewed. This is the foundation for smart session opening — the daemon will use this state to decide between opening a browser and sending an in-app notification. * Move browser opening from CLI to daemon with smart presentation The daemon now decides how to present new sessions based on frontend connection state. If a frontend tab is connected and visible, it sends a notification event (no new tab). If no frontend is connected or the tab is backgrounded, it opens a browser. - Add presentSession() to daemon runtime with decision matrix - Add legacyTabMode config: always opens browser when enabled - Remove handleServerReady/handleReviewServerReady/handleAnnotateServerReady calls from CLI hook — the daemon handles it - Add browserAction field to POST /daemon/sessions response - CLI sessions --open command kept as-is (explicit user action) * Add session notification toasts and keep completed sessions in sidebar Phase 3: When the daemon notifies instead of opening a browser, it publishes a session-notify event. The frontend shows an auto-dismissing toast (8s) with mode, project, and an Open button. Toasts are gated on document.visibilityState — queued when tab is backgrounded, flushed on return. Phase 4: Completed sessions no longer disappear from the sidebar. The terminal-status splice in event-store was removed — sessions now update in-place with their new status. Only explicit session-removed events cause removal. * Collapse sidebar on direct session links, open on landing page SidebarProvider defaultOpen is now based on the initial route: collapsed when loading /s/:id directly, open when loading /. Users can still toggle the sidebar manually after the initial render. * Add disk-backed session snapshots for completed session persistence When a session completes, the daemon writes a content snapshot to ~/.plannotator/sessions/.json before disposing the handler. Snapshots capture the plan markdown, diff data, or annotation content — everything the frontend needs to render the session read-only. The daemon server serves snapshot content when a request hits a disposed or missing session. This means completed sessions survive page refresh and daemon restart. Snapshots are capped at 5MB to avoid oversized review diffs. Each session type provides a snapshot callback in the factory that closes over its content at creation time. * Wire legacy tab mode through server config to surface overlays When legacyTabMode is set in config.json, the daemon always opens a browser (already wired in Phase 2), and both surfaces render the full-screen CompletionOverlay with auto-close instead of the inline CompletionBanner — even in embedded mode. This preserves the old tab-per-session + auto-close experience for users who prefer it. The legacyTabMode flag flows through getServerConfig() → /api/plan and /api/diff responses → surface state. * Document legacyTabMode config setting in AGENTS.md * Load session snapshots from disk on daemon startup Completed sessions from previous daemon runs now appear in the sidebar immediately. On startup, the daemon reads all snapshots from ~/.plannotator/sessions/ and creates completed records in the store. These records have no handlers but serve content via the snapshot fallback in the server. * Add worktree-aware project hierarchy to landing page Projects that are git worktrees auto-detect their parent repo and nest underneath it. The landing page shows projects as collapsible tree nodes — expanding a project fetches its worktrees via git worktree list and shows them with branch names. - DaemonProjectEntry gains optional parentCwd and branch fields - addProject detects worktrees via git rev-parse --git-common-dir and auto-registers the parent repo - New GET /daemon/projects/worktrees?cwd= endpoint lists worktrees - Frontend ProjectTable refactored to collapsible tree with worktree children, selection passes cwd to session creation - Session labels include branch name when created from a worktree cwd * Fix parent project registration dedup and add branch to all session labels - Parent auto-registration now adds directly to the flat array instead of calling registerProject, avoiding name-based dedup that could overwrite unrelated projects with the same derived name - All session modes (annotate, archive, goal-setup) now include the branch name in their labels, matching plan and review * Fix blank page when adding a worktree project When adding a directory that is a worktree, the daemon auto-creates the parent project. But the store only added the returned entry (the worktree child), leaving the parent missing from the frontend state. Since the worktree has parentCwd set, the topLevel filter found zero entries and nothing rendered. Fix: when the added entry has parentCwd, re-fetch the full project list so the auto-created parent is included. * Filter temp directory worktrees from project listing * Sort worktrees by last activity (index mtime > commit time > dir mtime) Each worktree gets a lastActive timestamp derived from: 1. Git index file mtime (updates on add, checkout, stash — reflects active work even without commits) 2. Last commit timestamp (fallback if index unavailable) 3. Directory mtime (fallback for brand new worktrees) All three signals are cross-platform (fs.statSync + git log). Worktrees are sorted most-recently-active first. * Fix toast: skip for frontend-initiated sessions, clean label, fix colors - Don't call presentSession for origin "plannotator-frontend" — the frontend already navigates to the session it just created - Strip internal prefixes from session label in toast description, suppress description when it matches the project name - Style toast action button with theme primary colors - Widen project selector to max-w-2xl - Remove opacity-50 from worktree icons * Replace manual project input with searchable directory picker The Add Project dialog is now a searchable directory browser inspired by OpenCode's project picker: - Type a path (~/work/, /Users/...) and see child directories listed - Arrow keys to navigate, Enter to select, Tab to navigate into a dir - Recent projects shown at top for quick re-selection - ~ expansion handled server-side - Hidden directories (.git, .cache, etc.) filtered out - 150ms debounced directory listing for responsive typeahead New daemon endpoint: GET /daemon/fs/list?path= returns child directories for any path with ~ expansion. * Only show worktree chevron when worktrees exist, add Worktrees label - Fetch worktrees eagerly on mount instead of on expand, so the chevron only appears when there are actual worktrees to show - Projects without worktrees get a plain spacer instead of the chevron - Add a "Worktrees" section label above the expanded list * Fix: add missing useEffect import in LandingPage * Fix project row layout: chevron to right, remove branch icons, align folders - The whole project row is now one selectable button with folder icon consistently at the left - Worktree expand chevron moved to the right end, only visible on hover area — doesn't block the selectable feel - Removed all GitBranch icons from worktree entries — just indentation and the branch/worktree name - Projects without worktrees have no chevron at all, no spacer needed * Add ASCII art Plannotator banner to landing page * Increase ASCII banner opacity to 70% * Remove redundant Plannotator label from landing page nav * Make Add Project buttons more visible * Design audit: fix color contrast, remove opacity abuse, fix a11y Applied Emil's design engineering principles: - Interactive rows use text-foreground by default, not text-muted-foreground. Muted text is only for metadata (paths, timestamps, section labels). Items should look clickable at rest, not disabled. - Replaced all opacity-60 on secondary text with text-muted-foreground (semantic token instead of raw opacity) - Borders use border-border (full opacity) not border-border/40 — borders should be visible enough to serve their structural purpose - Removed transition-colors (was a transition: all risk) — hover states are instant by design for frequently-used UI - Changed expand to proper
-

- Register a project directory to launch sessions from. -

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

{error}

} -
+ )} -
- - +
+ {recentProjects.length > 0 && dirs.length > 0 && ( + + Directories + + )} + {dirs.map((dir, i) => { + const idx = recentProjects.length + i; + return ( + handleSelect(dir.path)} + onNavigate={() => handleNavigate(dir.path)} + onHover={() => setActiveIndex(idx)} + /> + ); + })} + {!loading && dirs.length === 0 && recentProjects.length === 0 && ( +
+ No directories found +
+ )}
- +
+ +
+ + select + + + Tab navigate into + + + Esc close + +
); } + +function ProjectRow({ + project, + active, + index, + onSelect, + onHover, +}: { + project: ProjectEntry; + active: boolean; + index: number; + onSelect: () => void; + onHover: () => void; +}) { + return ( + + ); +} + +function DirectoryRow({ + dir, + active, + index, + onSelect, + onNavigate, + onHover, +}: { + dir: DirectoryEntry; + active: boolean; + index: number; + onSelect: () => void; + onNavigate: () => void; + onHover: () => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/apps/frontend/src/components/landing/LandingPage.tsx b/apps/frontend/src/components/landing/LandingPage.tsx index 89ffcbed6..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 (
- - -
-
-
-
- {projects.length === 0 && sessions.length === 0 ? ( - - ) : ( -
- {projects.length > 0 && ( -
- - Select project - - - - -
- - Launch - -
- - +
+
+
+ +
+
+
+
+
+ + + {projects.length === 0 && sessions.length === 0 ? ( + + ) : ( +
+ {projects.length > 0 && ( +
+
+ + Select project + + +
+ + +
+ + Launch + +
+ + + +
+
-
-
- )} + )} - {sessions.length > 0 && ( -
- - Active sessions - - -
- )} + {sessions.length > 0 && ( +
+
+ Active sessions +
+ +
+ )} - {projects.length === 0 && ( - + {projects.length === 0 && ( + + )} +
)}
- )} +
+
+
+ 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 ( + + +
+
+ + +
+ + {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 ( + + ); +} + +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 ( +
+ + {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 && ( + <> + {" · "} + + + )} +
+ ); + } + + return ( +
+ {stacks.map((stack) => ( + + ))} + {loose.map((pr) => ( + + ))} + {!showAll && hiddenCount > 0 && ( + + )} +
+ ); +} + +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) => ( ))}
@@ -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 ( +
+
+
+ +
+ + {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 ( + + ); +} + +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 ( + + ); +} 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 new file mode 100644 index 000000000..1aac30548 --- /dev/null +++ b/apps/frontend/src/components/sessions/SessionSurface.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { SessionProvider } from "@plannotator/ui/hooks/useSessionFetch"; +import { ReviewAppEmbedded } from "@plannotator/code-review"; +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 const SessionSurface = React.memo(function SessionSurface({ + bootstrap, +}: SessionSurfaceProps) { + const { session } = bootstrap; + + if (session.mode === "review") { + return ( + + + + ); + } + + 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 + +
+ + +
+ + 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} + + ))} + +
+
+ +
+
+ +
+
+ {/* 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 + + +
+
+ +
+
+
, + document.body, + )} + + ); +} diff --git a/apps/frontend/src/components/sidebar/AppSidebar.tsx b/apps/frontend/src/components/sidebar/AppSidebar.tsx index 86858b0ad..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,22 +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"]; -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(); @@ -46,38 +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 }, @@ -87,34 +97,19 @@ export function AppSidebar({ onAddProject }: AppSidebarProps) { return ( - + - + - {session.label} + {formatSessionLabel(session.label, session.mode)} - {session.status === "active" && ( - - - - )} - {isTerminal && ( - - - - )} ); })} @@ -128,9 +123,12 @@ export function AppSidebar({ onAddProject }: AppSidebarProps) { - - - Add project + appStore.getState().setSettingsOpen(true)} + tooltip="Settings" + > + + Settings @@ -141,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/main.tsx b/apps/frontend/src/main.tsx index f3ff62a5c..6e67ff792 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -5,9 +5,6 @@ import { ThemeProvider } from "@plannotator/ui/components/ThemeProvider"; import { createAppRouter } from "./app/router"; import "./styles.css"; -document.cookie = "plannotator-color-theme=neutral; path=/; max-age=31536000; SameSite=Lax"; -document.cookie = "plannotator-theme=dark; path=/; max-age=31536000; SameSite=Lax"; - const rootElement = document.getElementById("root"); if (!rootElement) { diff --git a/apps/frontend/src/routes/s.$sessionId.tsx b/apps/frontend/src/routes/s.$sessionId.tsx index 85b960262..9345bf8a3 100644 --- a/apps/frontend/src/routes/s.$sessionId.tsx +++ b/apps/frontend/src/routes/s.$sessionId.tsx @@ -1,8 +1,9 @@ +import { useEffect } from "react"; import { createFileRoute } from "@tanstack/react-router"; import { SidebarTrigger } from "@/components/ui/sidebar"; import type { SessionBootstrap } from "../daemon/contracts"; import type { DaemonApiResult } from "../daemon/api/errors"; -import { getSessionModeMeta } from "../shared/session-meta"; +import { appStore } from "../stores/app-store"; const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{2,127}$/; @@ -20,6 +21,15 @@ export const Route = createFileRoute("/s/$sessionId")({ function SessionRoute() { const result: DaemonApiResult = Route.useLoaderData(); + const { sessionId } = Route.useParams(); + + useEffect(() => { + if (result.ok) { + appStore.getState().activateSession(sessionId, result.data); + } else { + appStore.getState().deactivateSession(); + } + }, [sessionId, result]); if (!result.ok) { return ( @@ -36,32 +46,6 @@ function SessionRoute() { ); } - const { session } = result.data; - const meta = getSessionModeMeta(session.mode); - const Icon = meta.icon; - - return ( -
- - -
-
-
-
-

- {meta.label} surface · {session.project} · {session.id} -

-
-
-
-
-
- ); + // The actual surface is rendered by Layout via Activity — this route just registers the session + return null; } 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 a0f44f579..8a612af72 100644 --- a/apps/frontend/src/stores/app-store.ts +++ b/apps/frontend/src/stores/app-store.ts @@ -1,20 +1,35 @@ import { createStore } from "zustand/vanilla"; import { useStore } from "zustand"; import { immer } from "zustand/middleware/immer"; +import type { SessionBootstrap } from "../daemon/contracts"; + +export interface VisitedSession { + sessionId: string; + bootstrap: SessionBootstrap; +} -// TODO: expand as we add global preferences, active project context, etc. 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; } export type AppStore = AppState & AppActions; const initialState: AppState = { addProjectOpen: false, + settingsOpen: false, + activeSessionId: null, + visitedSessions: {}, }; export function createAppStore(initial: Partial = {}) { @@ -27,6 +42,32 @@ export function createAppStore(initial: Partial = {}) { state.addProjectOpen = open; }); }, + setSettingsOpen(open) { + set((state) => { + state.settingsOpen = open; + }); + }, + activateSession(sessionId, bootstrap) { + set((state) => { + state.activeSessionId = sessionId; + if (!state.visitedSessions[sessionId]) { + state.visitedSessions[sessionId] = { sessionId, bootstrap }; + } + }); + }, + deactivateSession() { + set((state) => { + state.activeSessionId = null; + }); + }, + removeSession(sessionId) { + set((state) => { + delete state.visitedSessions[sessionId]; + if (state.activeSessionId === sessionId) { + state.activeSessionId = null; + } + }); + }, })), ); } 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 e08c89925..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"; @@ -6,6 +7,10 @@ @plugin "tailwindcss-animate"; @source "../src/**/*.{ts,tsx}"; +@source "../../../packages/plannotator-code-review/**/*.{ts,tsx}"; +@source "../../../packages/plannotator-plan-review/**/*.{ts,tsx}"; +@source "../../../packages/ui/components/**/*.{ts,tsx}"; +@source "../../../packages/ui/hooks/**/*.{ts,tsx}"; /* * Theme bridge — Plannotator uses .theme-{name} (dark default) + .light modifier. @@ -123,7 +128,7 @@ } } -/* Custom scrollbar — thumb only, no track, overlays content */ +/* Custom scrollbar — overrides theme.css with rounder thumbs and ring hover color */ * { scrollbar-width: thin; scrollbar-color: var(--border) transparent; @@ -244,14 +249,6 @@ body[style*="user-select"] [data-sidebar-panel] { } } -/* Thin scrollbars — panels and diff scroll areas */ -aside, -[data-annotation-panel], -.diff-scroll { - scrollbar-color: oklch(0.5 0 0 / 0.12) transparent; - scrollbar-width: thin; -} - /* Tab bar — no visible scrollbar */ [role="tablist"] { scrollbar-width: none; diff --git a/apps/frontend/src/types/plannotator-ui.d.ts b/apps/frontend/src/types/plannotator-ui.d.ts new file mode 100644 index 000000000..0c1134e5e --- /dev/null +++ b/apps/frontend/src/types/plannotator-ui.d.ts @@ -0,0 +1,42 @@ +declare module "@plannotator/ui/hooks/useSessionFetch" { + import type { ReactNode } from "react"; + type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise; + export function SessionProvider(props: { sessionId: string; children: ReactNode }): ReactNode; + export function useSessionFetch(): FetchFn; +} + +declare module "@plannotator/ui/components/ThemeProvider" { + import type { ReactNode } from "react"; + export type Mode = "dark" | "light" | "system"; + interface ThemeInfo { + id: string; + name: string; + builtIn: boolean; + modeSupport: "both" | "dark-only" | "light-only"; + } + interface ThemeProviderState { + theme: Mode; + setTheme: (mode: Mode) => void; + mode: Mode; + setMode: (mode: Mode) => void; + resolvedMode: "dark" | "light"; + colorTheme: string; + setColorTheme: (theme: string) => void; + availableThemes: ThemeInfo[]; + } + export function ThemeProvider(props: { + children: ReactNode; + defaultTheme?: Mode; + defaultColorTheme?: string; + storageKey?: string; + colorThemeStorageKey?: string; + }): ReactNode; + export function useTheme(): ThemeProviderState; +} + +declare module "*.png" { + const src: string; + export default src; +} + +declare const __APP_VERSION__: string; diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index 07549d8b7..80627d93a 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -16,8 +16,6 @@ "baseUrl": ".", "paths": { "@/*": ["src/*"], - "@plannotator/shared/*": ["../../packages/shared/*"], - "@plannotator/ui/*": ["../../packages/ui/*"], }, }, "include": ["src", "scripts", "vite.config.ts", "vitest.config.ts", "vitest.browser.config.ts"], diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index b00ac07ae..8ae982b3a 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -40,7 +40,7 @@ export default defineConfig(({ command }) => { ws: true, headers: { Authorization: `Bearer ${daemon.authToken}` }, }, - "/s/": { + "^/s/[^/]+/api": { target: daemon.baseUrl, }, } @@ -60,10 +60,31 @@ export default defineConfig(({ command }) => { resolve: { alias: { "@": path.resolve(__dirname, "src"), + "@plannotator/code-review/styles": path.resolve( + __dirname, + "../../packages/plannotator-code-review/index.css", + ), + "@plannotator/code-review": path.resolve( + __dirname, + "../../packages/plannotator-code-review", + ), + "@plannotator/plan-review/styles": path.resolve( + __dirname, + "../../packages/plannotator-plan-review/index.css", + ), + "@plannotator/plan-review": path.resolve( + __dirname, + "../../packages/plannotator-plan-review", + ), "@plannotator/shared": path.resolve(__dirname, "../../packages/shared"), "@plannotator/ui": path.resolve(__dirname, "../../packages/ui"), }, }, + define: { + __APP_VERSION__: JSON.stringify( + JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../package.json"), "utf-8")).version, + ), + }, build: { target: "esnext", assetsInlineLimit: 100000000, diff --git a/apps/hook/server/daemon-shell-html.ts b/apps/hook/server/daemon-shell-html.ts index 0c0c4530f..7fc0e4083 100644 --- a/apps/hook/server/daemon-shell-html.ts +++ b/apps/hook/server/daemon-shell-html.ts @@ -1,7 +1,15 @@ -// TODO: Replace debug-frontend with production frontend (layer 5 in stack). -// Keep the daemon shell import separate from legacy mode HTML so direct -// non-daemon commands do not require apps/debug-frontend/dist unless the daemon starts. +// Production frontend is statically imported — bundled into the compiled binary. // @ts-ignore - Bun import attribute for text -import shellHtml from "../../debug-frontend/dist/index.html" with { type: "text" }; +import productionHtml from "../../frontend/dist/index.html" with { type: "text" }; -export const daemonShellHtmlContent = shellHtml as unknown as string; +// Debug frontend is read from disk at runtime when PLANNOTATOR_DEBUG_SHELL=1. +// Never bundled in production. Only works in dev when debug-frontend is built. +export async function loadDaemonShellHtml(): Promise { + if (process.env.PLANNOTATOR_DEBUG_SHELL === "1") { + try { + const debugPath = new URL("../../debug-frontend/dist/index.html", import.meta.url).pathname; + return await Bun.file(debugPath).text(); + } catch {} + } + return productionHtml as unknown as string; +} diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 55f917661..adf400f6a 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 { @@ -126,7 +117,6 @@ let planHtmlContentPromise: Promise | undefined; let reviewHtmlContentPromise: Promise | undefined; let daemonShellHtmlContentPromise: Promise | undefined; let htmlAssetsPromise: Promise | undefined; -let daemonShellHtmlPromise: Promise | undefined; function getHtmlAssets() { htmlAssetsPromise ??= import("./html-assets"); @@ -144,8 +134,7 @@ function getReviewHtmlContent(): Promise { } function getDaemonShellHtmlContent(): Promise { - daemonShellHtmlPromise ??= import("./daemon-shell-html"); - daemonShellHtmlContentPromise ??= daemonShellHtmlPromise.then((mod) => mod.daemonShellHtmlContent); + daemonShellHtmlContentPromise ??= import("./daemon-shell-html").then((mod) => mod.loadDaemonShellHtml()); return daemonShellHtmlContentPromise; } @@ -654,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; @@ -693,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) { @@ -710,22 +684,12 @@ 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(); fail(completed.error.code, completed.error.message); } - if (completed.session.status !== "completed") { + if (completed.session.status !== "completed" && completed.session.status !== "awaiting-resubmission" && completed.session.status !== "idle") { fail( completed.session.status, completed.session.error ?? `Plannotator session ${completed.session.id} ended with status ${completed.session.status}.`, diff --git a/bun.lock b/bun.lock index b760313d2..c4c565693 100644 --- a/bun.lock +++ b/bun.lock @@ -73,15 +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", @@ -259,6 +264,40 @@ "tailwindcss": "^4.1.18", }, }, + "packages/plannotator-code-review": { + "name": "@plannotator/code-review", + "version": "0.0.1", + "dependencies": { + "@pierre/diffs": "^1.1.12", + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.15", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-tooltip": "^1.2.8", + "highlight.js": "^11.11.1", + "motion": "^12.38.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + "zustand": "^5.0.13", + }, + }, + "packages/plannotator-plan-review": { + "name": "@plannotator/plan-review", + "version": "0.0.1", + "dependencies": { + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "sonner": "^2.0.7", + "tailwindcss": "^4.1.18", + }, + }, "packages/review-editor": { "name": "@plannotator/review-editor", "version": "0.0.1", @@ -613,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=="], @@ -849,6 +890,8 @@ "@plannotator/ai": ["@plannotator/ai@workspace:packages/ai"], + "@plannotator/code-review": ["@plannotator/code-review@workspace:packages/plannotator-code-review"], + "@plannotator/debug-frontend": ["@plannotator/debug-frontend@workspace:apps/debug-frontend"], "@plannotator/debug-tui": ["@plannotator/debug-tui@workspace:apps/debug-tui"], @@ -867,6 +910,8 @@ "@plannotator/pi-extension": ["@plannotator/pi-extension@workspace:apps/pi-extension"], + "@plannotator/plan-review": ["@plannotator/plan-review@workspace:packages/plannotator-plan-review"], + "@plannotator/portal": ["@plannotator/portal@workspace:apps/portal"], "@plannotator/review": ["@plannotator/review@workspace:apps/review"], @@ -953,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..b16442f91 --- /dev/null +++ b/goals/frontend-session-lifecycle/backlog.md @@ -0,0 +1,167 @@ +# 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~~ DONE + +Implemented in `feat/session-persistence`. Sessions enter `awaiting-resubmission` status on deny. Agent resubmission is matched by `plan:project:slug` and the session reactivates in place. Frontend receives `session-revision` WebSocket event with updated content. + +--- + +## ~~4. Session persistence after completion~~ DONE + +Implemented in `feat/session-persistence`. Denied sessions stay alive (handler not disposed) in `awaiting-resubmission` state with no expiry. Sessions persist until daemon restart. + +**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 `