From 9d59db29ffbae1a64fdb639666ee929f60f0b00b Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 08:15:09 -0700 Subject: [PATCH 01/12] 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. --- packages/ui/components/AttachmentsButton.tsx | 2 ++ packages/ui/components/ExportModal.tsx | 2 ++ packages/ui/components/InlineMarkdown.tsx | 2 ++ packages/ui/components/Settings.tsx | 2 ++ .../ui/components/goal-setup/GoalSetupSurface.tsx | 3 +++ packages/ui/components/plan-diff/PlanDiffViewer.tsx | 2 ++ packages/ui/components/settings/HooksTab.tsx | 2 ++ packages/ui/hooks/useLinkedDoc.ts | 2 ++ packages/ui/hooks/useSessionFetch.tsx | 11 ++++++++++- 9 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/ui/components/AttachmentsButton.tsx b/packages/ui/components/AttachmentsButton.tsx index 3d0f1876f..af7edb517 100644 --- a/packages/ui/components/AttachmentsButton.tsx +++ b/packages/ui/components/AttachmentsButton.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; +import { useSessionFetch } from '../hooks/useSessionFetch'; import { ImageThumbnail, getImageSrc } from './ImageThumbnail'; import { ImageAnnotator } from './ImageAnnotator'; import type { ImageAttachment } from '../types'; @@ -58,6 +59,7 @@ export const AttachmentsButton: React.FC = ({ variant = 'toolbar', hideLabel = false, }) => { + const fetch = useSessionFetch(); const [isOpen, setIsOpen] = useState(false); const [manualPath, setManualPath] = useState(''); const [uploading, setUploading] = useState(false); diff --git a/packages/ui/components/ExportModal.tsx b/packages/ui/components/ExportModal.tsx index 251001f00..d66081837 100644 --- a/packages/ui/components/ExportModal.tsx +++ b/packages/ui/components/ExportModal.tsx @@ -7,6 +7,7 @@ */ import React, { useState, useEffect } from 'react'; +import { useSessionFetch } from '../hooks/useSessionFetch'; import { getObsidianSettings, getEffectiveVaultPath } from '../utils/obsidian'; import { getBearSettings } from '../utils/bear'; import { getOctarineSettings } from '../utils/octarine'; @@ -57,6 +58,7 @@ export const ExportModal: React.FC = ({ isApiMode = false, initialTab, }) => { + const fetch = useSessionFetch(); const defaultTab = initialTab || (sharingEnabled ? 'share' : 'annotations'); const [activeTab, setActiveTab] = useState(defaultTab); const [copied, setCopied] = useState<'short' | 'full' | 'annotations' | false>(false); diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index 1871a86c7..546f7fb64 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { createPortal } from "react-dom"; import hljs from "highlight.js"; +import { useSessionFetch } from '../hooks/useSessionFetch'; import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/shared/code-file"; import { transformPlainText } from "../utils/inlineTransforms"; import { getImageSrc } from "./ImageThumbnail"; @@ -120,6 +121,7 @@ const CodeFileLink: React.FC<{ onOpenCodeFile: (path: string) => void; baseDir?: string; }> = ({ candidate, display, onOpenCodeFile, baseDir }) => { + const fetch = useSessionFetch(); const validation = useCodePathValidation(); const gate = gateCodePath(candidate, validation); const [pickerOpen, setPickerOpen] = useState(false); diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index e2982315b..1bd87de4c 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useMemo } from 'react'; +import { useSessionFetch } from '../hooks/useSessionFetch'; import { createPortal } from 'react-dom'; import type { Origin } from '@plannotator/shared/agents'; import type { DiffLineBgIntensity } from '@plannotator/shared/config'; @@ -600,6 +601,7 @@ const CommentsTab: React.FC = () => { }; export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { + const fetch = useSessionFetch(); const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); diff --git a/packages/ui/components/goal-setup/GoalSetupSurface.tsx b/packages/ui/components/goal-setup/GoalSetupSurface.tsx index fb16a0a81..f367cc427 100644 --- a/packages/ui/components/goal-setup/GoalSetupSurface.tsx +++ b/packages/ui/components/goal-setup/GoalSetupSurface.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSessionFetch } from '../../hooks/useSessionFetch'; import { Check, ChevronDown, @@ -132,6 +133,7 @@ const InterviewSurface = React.forwardRef void; onActionStateChange?: (state: GoalSetupActionState) => void; }>(({ bundle, onSubmitted, onActionStateChange }, ref) => { + const fetch = useSessionFetch(); const [answers, setAnswers] = useState>(() => Object.fromEntries( bundle.questions.map((question) => [ @@ -814,6 +816,7 @@ const FactsSurface = React.forwardRef void; onActionStateChange?: (state: GoalSetupActionState) => void; }>(({ bundle, onSubmitted, onActionStateChange }, ref) => { + const fetch = useSessionFetch(); // Product choice: facts stay visible after acceptance so later review passes keep context. // `showAccepted` is legacy model state and does not hide rows in this surface. const [facts, setFacts] = useState(() => diff --git a/packages/ui/components/plan-diff/PlanDiffViewer.tsx b/packages/ui/components/plan-diff/PlanDiffViewer.tsx index 2830e8439..f46f98b79 100644 --- a/packages/ui/components/plan-diff/PlanDiffViewer.tsx +++ b/packages/ui/components/plan-diff/PlanDiffViewer.tsx @@ -7,6 +7,7 @@ */ import React, { useState } from "react"; +import { useSessionFetch } from '../../hooks/useSessionFetch'; import type { PlanDiffBlock, PlanDiffStats } from "../../utils/planDiffEngine"; import type { Annotation, EditorMode } from "../../types"; import { @@ -52,6 +53,7 @@ export const PlanDiffViewer: React.FC = ({ selectedAnnotationId, mode, }) => { + const fetch = useSessionFetch(); const [vscodeDiffLoading, setVscodeDiffLoading] = useState(false); const [vscodeDiffError, setVscodeDiffError] = useState(null); diff --git a/packages/ui/components/settings/HooksTab.tsx b/packages/ui/components/settings/HooksTab.tsx index 683ba3a59..266cdedec 100644 --- a/packages/ui/components/settings/HooksTab.tsx +++ b/packages/ui/components/settings/HooksTab.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { FAVICON_SVG } from '@plannotator/shared/favicon'; +import { useSessionFetch } from '../../hooks/useSessionFetch'; interface HooksStatus { pfmReminder: { enabled: boolean }; @@ -49,6 +50,7 @@ const CopyPathButton: React.FC<{ filePath: string }> = ({ filePath }) => { }; export const HooksTab: React.FC = () => { + const fetch = useSessionFetch(); const [status, setStatus] = useState(null); const [pfmEnabled, setPfmEnabled] = useState(false); const [hookExpanded, setHookExpanded] = useState(false); diff --git a/packages/ui/hooks/useLinkedDoc.ts b/packages/ui/hooks/useLinkedDoc.ts index c7d3a3d41..9993f35b8 100644 --- a/packages/ui/hooks/useLinkedDoc.ts +++ b/packages/ui/hooks/useLinkedDoc.ts @@ -7,6 +7,7 @@ */ import { useState, useCallback, useRef } from "react"; +import { useSessionFetch } from './useSessionFetch'; import type { Annotation, ImageAttachment } from "../types"; import type { ViewerHandle } from "../components/Viewer"; import type { SidebarTab } from "./useSidebar"; @@ -68,6 +69,7 @@ export interface UseLinkedDocReturn { const HIGHLIGHT_REAPPLY_DELAY = 100; export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn { + const fetch = useSessionFetch(); const { markdown, annotations, diff --git a/packages/ui/hooks/useSessionFetch.tsx b/packages/ui/hooks/useSessionFetch.tsx index 2bf80235c..8abb70798 100644 --- a/packages/ui/hooks/useSessionFetch.tsx +++ b/packages/ui/hooks/useSessionFetch.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useCallback, type ReactNode } from 'react'; +import { createContext, useContext, useCallback, useEffect, type ReactNode } from 'react'; type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise; @@ -29,6 +29,15 @@ export function SessionProvider({ [sessionId], ); + // Also set the window global so non-hook consumers (apiPath, ) work + useEffect(() => { + const prev = window.__PLANNOTATOR_API_BASE__; + window.__PLANNOTATOR_API_BASE__ = `/s/${sessionId}/api`; + return () => { + window.__PLANNOTATOR_API_BASE__ = prev; + }; + }, [sessionId]); + return ( {children} From a22dffa72fa88606233956c82d369fe38d324360 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 08:16:21 -0700 Subject: [PATCH 02/12] Migrate plan review App.tsx to useSessionFetch + title cleanup --- packages/plannotator-plan-review/App.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/plannotator-plan-review/App.tsx b/packages/plannotator-plan-review/App.tsx index c8e12d2e1..708ef0b8c 100644 --- a/packages/plannotator-plan-review/App.tsx +++ b/packages/plannotator-plan-review/App.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; +import { useSessionFetch } from '@plannotator/ui/hooks/useSessionFetch'; import { toast, Toaster } from 'sonner'; import { type Origin, getAgentName } from '@plannotator/shared/agents'; import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, exportCodeFileAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry } from '@plannotator/ui/utils/parser'; @@ -97,6 +98,7 @@ type NoteAutoSaveResults = { }; const App: React.FC = () => { + const fetch = useSessionFetch(); const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT); const [annotations, setAnnotations] = useState([]); const [codeAnnotations, setCodeAnnotations] = useState([]); @@ -175,7 +177,9 @@ const App: React.FC = () => { const goalSetupMode = goalSetupBundle !== null; useEffect(() => { + const prev = document.title; document.title = repoInfo ? `${repoInfo.display} · Plannotator` : "Plannotator"; + return () => { document.title = prev; }; }, [repoInfo]); const [initialExportTab, setInitialExportTab] = useState<'share' | 'annotations' | 'notes'>(); From 3236a4465a46ad018f0595d34996d88dc1aa7c83 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 08:18:05 -0700 Subject: [PATCH 03/12] 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. --- packages/plannotator-plan-review/App.tsx | 39 +++++-------- packages/ui/hooks/useAnnotationDraft.ts | 70 +++++------------------- 2 files changed, 27 insertions(+), 82 deletions(-) diff --git a/packages/plannotator-plan-review/App.tsx b/packages/plannotator-plan-review/App.tsx index 708ef0b8c..4d5b02b49 100644 --- a/packages/plannotator-plan-review/App.tsx +++ b/packages/plannotator-plan-review/App.tsx @@ -664,29 +664,28 @@ const App: React.FC = () => { return () => ro.disconnect(); }, [isLoading, isSharedSession]); - // Auto-save annotation drafts - const { draftBanner, restoreDraft, dismissDraft } = useAnnotationDraft({ + // Auto-save and auto-restore annotation drafts + useAnnotationDraft({ annotations: allAnnotations, codeAnnotations, globalAttachments, isApiMode: isApiMode && !goalSetupMode, isSharedSession, submitted: !!submitted, + onRestore: useCallback((restored, restoredCode, restoredGlobal) => { + if (restored.length > 0 || restoredCode.length > 0 || restoredGlobal.length > 0) { + setAnnotations(restored); + setCodeAnnotations(restoredCode); + if (restoredGlobal.length > 0) setGlobalAttachments(restoredGlobal); + const totalCount = restored.length + restoredCode.length + restoredGlobal.length; + toast(`Restored ${totalCount} annotation${totalCount !== 1 ? 's' : ''}`); + setTimeout(() => { + viewerRef.current?.applySharedAnnotations(restored.filter(a => !a.diffContext)); + }, 100); + } + }, []), }); - const handleRestoreDraft = React.useCallback(() => { - const { annotations: restored, codeAnnotations: restoredCode, globalAttachments: restoredGlobal } = restoreDraft(); - if (restored.length > 0 || restoredCode.length > 0 || restoredGlobal.length > 0) { - setAnnotations(restored); - setCodeAnnotations(restoredCode); - if (restoredGlobal.length > 0) setGlobalAttachments(restoredGlobal); - // Apply highlights to DOM after a tick - setTimeout(() => { - viewerRef.current?.applySharedAnnotations(restored.filter(a => !a.diffContext)); - }, 100); - } - }, [restoreDraft]); - // Fetch available agents for OpenCode (for validation on approve) const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin); @@ -1830,16 +1829,6 @@ const App: React.FC = () => { data-print-region="document" onViewportReady={handleViewportReady} > -
{/* Sticky header lane — ghost bar that pins the toolstrip + badges at top: 12px once the user scrolls. Invisible at top diff --git a/packages/ui/hooks/useAnnotationDraft.ts b/packages/ui/hooks/useAnnotationDraft.ts index 347cd4346..84ed551d8 100644 --- a/packages/ui/hooks/useAnnotationDraft.ts +++ b/packages/ui/hooks/useAnnotationDraft.ts @@ -1,14 +1,14 @@ /** - * Auto-save annotation drafts to the server. + * Auto-save and auto-restore annotation drafts. * * Stores full Annotation[] objects directly (preserving all fields - * including `source`, `id`, offsets, and meta). On mount, checks for - * an existing draft and exposes banner state for the UI to offer restoration. + * including `source`, `id`, offsets, and meta). On mount, if a draft + * exists, it is restored silently via the onRestore callback. * * Backward compatible: loads old tuple-serialized drafts via fromShareable(). */ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import type { Annotation, CodeAnnotation, ImageAttachment } from '../types'; import { fromShareable, parseShareableImages } from '../utils/sharing'; import type { ShareableAnnotation } from '../utils/sharing'; @@ -36,17 +36,6 @@ function isLegacyDraft(data: unknown): data is LegacyDraftData { return !!data && typeof data === 'object' && 'a' in data && Array.isArray((data as LegacyDraftData).a); } -function formatTimeAgo(ts: number): string { - const seconds = Math.floor((Date.now() - ts) / 1000); - if (seconds < 60) return 'just now'; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`; - const days = Math.floor(hours / 24); - return `${days} day${days !== 1 ? 's' : ''} ago`; -} - interface UseAnnotationDraftOptions { annotations: Annotation[]; codeAnnotations?: CodeAnnotation[]; @@ -54,12 +43,7 @@ interface UseAnnotationDraftOptions { isApiMode: boolean; isSharedSession: boolean; submitted: boolean; -} - -interface UseAnnotationDraftResult { - draftBanner: { count: number; timeAgo: string } | null; - restoreDraft: () => { annotations: Annotation[]; codeAnnotations: CodeAnnotation[]; globalAttachments: ImageAttachment[] }; - dismissDraft: () => void; + onRestore: (annotations: Annotation[], codeAnnotations: CodeAnnotation[], globalAttachments: ImageAttachment[]) => void; } export function useAnnotationDraft({ @@ -69,16 +53,16 @@ export function useAnnotationDraft({ isApiMode, isSharedSession, submitted, -}: UseAnnotationDraftOptions): UseAnnotationDraftResult { + onRestore, +}: UseAnnotationDraftOptions): void { const fetch = useSessionFetch(); - const [draftBanner, setDraftBanner] = useState<{ count: number; timeAgo: string } | null>(null); - const draftDataRef = useRef<{ annotations: Annotation[]; codeAnnotations: CodeAnnotation[]; globalAttachments: ImageAttachment[] } | null>(null); const timerRef = useRef | null>(null); const hasMountedRef = useRef(false); + const restoredRef = useRef(false); - // Load draft on mount + // Load and auto-restore draft on mount useEffect(() => { - if (!isApiMode || isSharedSession) return; + if (!isApiMode || isSharedSession || restoredRef.current) return; fetch('/api/draft') .then(res => { @@ -96,11 +80,9 @@ export function useAnnotationDraft({ let restoredGlobal: ImageAttachment[]; if (isLegacyDraft(data)) { - // Old tuple format — deserialize via fromShareable restoredAnnotations = data.a.length > 0 ? fromShareable(data.a, data.d) : []; restoredGlobal = data.g ? (parseShareableImages(data.g as Parameters[0]) ?? []) : []; } else if (Array.isArray(data.annotations)) { - // New direct-object format restoredAnnotations = data.annotations; restoredCodeAnnotations = Array.isArray(data.codeAnnotations) ? data.codeAnnotations : []; restoredGlobal = Array.isArray(data.globalAttachments) ? data.globalAttachments : []; @@ -115,11 +97,8 @@ export function useAnnotationDraft({ const totalCount = restoredAnnotations.length + restoredCodeAnnotations.length + restoredGlobal.length; if (totalCount > 0) { - draftDataRef.current = { annotations: restoredAnnotations, codeAnnotations: restoredCodeAnnotations, globalAttachments: restoredGlobal }; - setDraftBanner({ - count: totalCount, - timeAgo: formatTimeAgo(data.ts || 0), - }); + restoredRef.current = true; + onRestore(restoredAnnotations, restoredCodeAnnotations, restoredGlobal); } hasMountedRef.current = true; }) @@ -148,34 +127,11 @@ export function useAnnotationDraft({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), - }).catch(() => { - // Silent failure — draft is best-effort - }); + }).catch(() => {}); }, DEBOUNCE_MS); return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, [annotations, codeAnnotations, globalAttachments, isApiMode, isSharedSession, submitted]); - - const restoreDraft = useCallback(() => { - const data = draftDataRef.current; - setDraftBanner(null); - draftDataRef.current = null; - - if (!data) return { annotations: [], codeAnnotations: [], globalAttachments: [] }; - - return data; - }, []); - - const dismissDraft = useCallback(() => { - setDraftBanner(null); - draftDataRef.current = null; - - fetch('/api/draft', { method: 'DELETE' }).catch(() => { - // Silent failure - }); - }, []); - - return { draftBanner, restoreDraft, dismissDraft }; } From 36bb43ec8ff10b915d37a468d7facec6446513d5 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 08:21:48 -0700 Subject: [PATCH 04/12] 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. --- packages/plannotator-plan-review/App.d.ts | 4 ++ packages/plannotator-plan-review/App.tsx | 65 +++++++++++-------- .../components/AppHeader.tsx | 8 ++- packages/plannotator-plan-review/package.json | 5 +- 4 files changed, 54 insertions(+), 28 deletions(-) create mode 100644 packages/plannotator-plan-review/App.d.ts diff --git a/packages/plannotator-plan-review/App.d.ts b/packages/plannotator-plan-review/App.d.ts new file mode 100644 index 000000000..6126722e8 --- /dev/null +++ b/packages/plannotator-plan-review/App.d.ts @@ -0,0 +1,4 @@ +import type { FC, ReactNode } from "react"; +export declare const PlanAppEmbedded: FC<{ headerLeft?: ReactNode }>; +declare const App: FC; +export default App; diff --git a/packages/plannotator-plan-review/App.tsx b/packages/plannotator-plan-review/App.tsx index 4d5b02b49..44b7a32dc 100644 --- a/packages/plannotator-plan-review/App.tsx +++ b/packages/plannotator-plan-review/App.tsx @@ -97,7 +97,7 @@ type NoteAutoSaveResults = { octarine?: boolean; }; -const App: React.FC = () => { +const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }> = ({ __embedded, headerLeft }) => { const fetch = useSessionFetch(); const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT); const [annotations, setAnnotations] = useState([]); @@ -1674,18 +1674,17 @@ const App: React.FC = () => { if (isLoading && !isSharedSession) { - return ( - -
- + const skeleton = ( +
); + if (__embedded) return skeleton; + return {skeleton}; } - return ( - - -
+ const innerContent = ( +
{ variant="warning" /> - + {!__embedded && ( + + )} {/* Completion overlay - shown after approve/deny */} { }} />
+ ); + + if (__embedded) return innerContent; + + return ( + + + {innerContent} ); }; export default App; + +export function PlanAppEmbedded({ headerLeft }: { headerLeft?: React.ReactNode }) { + return ; +} diff --git a/packages/plannotator-plan-review/components/AppHeader.tsx b/packages/plannotator-plan-review/components/AppHeader.tsx index 45a5ba477..7d3e9839e 100644 --- a/packages/plannotator-plan-review/components/AppHeader.tsx +++ b/packages/plannotator-plan-review/components/AppHeader.tsx @@ -9,6 +9,8 @@ import type { CallbackConfig } from '@plannotator/ui/utils/callback'; import type { UIPreferences } from '@plannotator/ui/utils/uiPreferences'; interface AppHeaderProps { + // Slot for external content (e.g., shell sidebar trigger) + headerLeft?: React.ReactNode; // Mode flags (stable after mount) isApiMode: boolean; annotateMode: boolean; @@ -78,6 +80,7 @@ interface AppHeaderProps { } export const AppHeader = React.memo(({ + headerLeft, isApiMode, annotateMode, archiveMode, @@ -136,7 +139,10 @@ export const AppHeader = React.memo(({ }) => { return (
- +
+ {headerLeft} + +
{/* Bot callback buttons — only shown when ?cb=&ct= params are present */} diff --git a/packages/plannotator-plan-review/package.json b/packages/plannotator-plan-review/package.json index f43c165c9..cd9560e4b 100644 --- a/packages/plannotator-plan-review/package.json +++ b/packages/plannotator-plan-review/package.json @@ -3,7 +3,10 @@ "version": "0.0.1", "type": "module", "exports": { - ".": "./App.tsx", + ".": { + "types": "./App.d.ts", + "default": "./App.tsx" + }, "./styles": "./index.css", "./shortcuts": "./shortcuts.ts" }, From 18ce393ce0b0b3d95973e38a7c662dccb60caf7b Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 08:22:32 -0700 Subject: [PATCH 05/12] Strip Tailwind from plan review CSS, add @source to frontend --- apps/frontend/src/styles.css | 1 + packages/plannotator-plan-review/index.css | 10 ---------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css index 102b33c4d..5855da4bc 100644 --- a/apps/frontend/src/styles.css +++ b/apps/frontend/src/styles.css @@ -7,6 +7,7 @@ @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}"; diff --git a/packages/plannotator-plan-review/index.css b/packages/plannotator-plan-review/index.css index fc236c044..4df06a804 100644 --- a/packages/plannotator-plan-review/index.css +++ b/packages/plannotator-plan-review/index.css @@ -1,13 +1,3 @@ -@import "tailwindcss"; - -/* Tell Tailwind where to scan for classes */ -@source "../ui/components/**/*.tsx"; -@source "../ui/hooks/**/*.ts"; -@source "./*.tsx"; -@source "./components/**/*.tsx"; - -@import "../ui/theme.css"; - /* Code blocks */ pre code.hljs { display: block; From fcfaac2885f122df7320266c0cede70ba25cc167 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 08:23:32 -0700 Subject: [PATCH 06/12] Remove dead toast animation classes that conflicted with tailwindcss-animate --- packages/plannotator-code-review/index.css | 24 ---------------------- 1 file changed, 24 deletions(-) diff --git a/packages/plannotator-code-review/index.css b/packages/plannotator-code-review/index.css index 1c57735b5..8e8c62b90 100644 --- a/packages/plannotator-code-review/index.css +++ b/packages/plannotator-code-review/index.css @@ -731,30 +731,6 @@ diffs-container { background: oklch(from var(--warning) l c h / 0.3); } -/* Toast animations */ -@keyframes cr-fade-in { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes cr-slide-in-from-bottom-2 { - from { transform: translateY(0.5rem) translateX(-50%); } - to { transform: translateY(0) translateX(-50%); } -} - -.animate-in { - animation-duration: 200ms; - animation-timing-function: ease-out; - animation-fill-mode: both; -} - -.fade-in { - animation-name: cr-fade-in; -} - -.slide-in-from-bottom-2 { - animation-name: cr-slide-in-from-bottom-2; -} /* AI Chat — toolbar divider, bubbles, line references, streaming cursor */ From 2af4b2d3c1a57c00c972bb676c9fc202158161a9 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 08:27:57 -0700 Subject: [PATCH 07/12] 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. --- packages/plannotator-code-review/App.tsx | 12 +++++++++++- packages/plannotator-plan-review/App.tsx | 11 ++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/plannotator-code-review/App.tsx b/packages/plannotator-code-review/App.tsx index ba6e6c4ed..9f3d4a406 100644 --- a/packages/plannotator-code-review/App.tsx +++ b/packages/plannotator-code-review/App.tsx @@ -135,6 +135,11 @@ function getFileTabTitle(filePath: string): string { const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }> = ({ __embedded, headerLeft }) => { const fetch = useSessionFetch(); const { resolvedMode } = useTheme(); + const rootRef = useRef(null); + const isVisible = useCallback(() => { + if (!rootRef.current) return true; + return getComputedStyle(rootRef.current).visibility !== 'hidden'; + }, []); const [diffData, setDiffData] = useState(null); const [files, setFiles] = useState([]); const [activeFileIndex, setActiveFileIndex] = useState(0); @@ -635,6 +640,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode } useEffect(() => { if (!import.meta.env.DEV) return; const handler = (e: KeyboardEvent) => { + if (!isVisible()) return; if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'T' || e.key === 't')) { e.preventDefault(); setTourDialogJobId(prev => (prev === DEMO_TOUR_ID ? null : DEMO_TOUR_ID)); @@ -701,6 +707,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode } // Global keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + if (!isVisible()) return; // Cmd/Ctrl+F to focus file search when diff files are available. if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f' && !isTypingTarget(e.target)) { if (hasSearchableFiles) { @@ -1074,6 +1081,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode } useEffect(() => { const handler = (e: KeyboardEvent) => { + if (!isVisible()) return; if (e.metaKey || e.ctrlKey || e.shiftKey || isTypingTarget(e.target)) return; if (!isDiffPanelActive) return; const filePath = files[activeFileIndex]?.path; @@ -1676,6 +1684,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode } const DOUBLE_TAP_WINDOW = 300; const handleKeyDown = (e: KeyboardEvent) => { + if (!isVisible()) return; if (e.key !== 'Alt' || e.repeat) return; const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; @@ -1708,6 +1717,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode } // Cmd/Ctrl+Enter keyboard shortcut to approve or send feedback useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + if (!isVisible()) return; if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return; // If the platform post dialog is open, Cmd+Enter submits it @@ -1772,7 +1782,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode } {isSwitchingPRScope && } -
+
{/* Header */}
diff --git a/packages/plannotator-plan-review/App.tsx b/packages/plannotator-plan-review/App.tsx index 44b7a32dc..6c9be2b0b 100644 --- a/packages/plannotator-plan-review/App.tsx +++ b/packages/plannotator-plan-review/App.tsx @@ -99,6 +99,11 @@ type NoteAutoSaveResults = { const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }> = ({ __embedded, headerLeft }) => { const fetch = useSessionFetch(); + const rootRef = useRef(null); + const isVisible = useCallback(() => { + if (!rootRef.current) return true; + return getComputedStyle(rootRef.current).visibility !== 'hidden'; + }, []); const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT); const [annotations, setAnnotations] = useState([]); const [codeAnnotations, setCodeAnnotations] = useState([]); @@ -307,6 +312,7 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }> = ({ useEffect(() => { if (!isPlanDiffActive) return; const handleKeyDown = (e: KeyboardEvent) => { + if (!isVisible()) return; if (e.key === 'Escape') { setIsPlanDiffActive(false); } @@ -1131,6 +1137,7 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }> = ({ // Global keyboard shortcuts (Cmd/Ctrl+Enter to submit) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + if (!isVisible()) return; // Only handle Cmd/Ctrl+Enter if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return; @@ -1509,6 +1516,7 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }> = ({ // Cmd/Ctrl+S keyboard shortcut — save to default notes app useEffect(() => { const handleSaveShortcut = (e: KeyboardEvent) => { + if (!isVisible()) return; if (e.key !== 's' || !(e.metaKey || e.ctrlKey)) return; const tag = (e.target as HTMLElement)?.tagName; @@ -1551,6 +1559,7 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }> = ({ // Cmd/Ctrl+P keyboard shortcut — print plan useEffect(() => { const handlePrintShortcut = (e: KeyboardEvent) => { + if (!isVisible()) return; if (e.key !== 'p' || !(e.metaKey || e.ctrlKey)) return; const tag = (e.target as HTMLElement)?.tagName; @@ -1682,7 +1691,7 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }> = ({ } const innerContent = ( -
+
Date: Tue, 19 May 2026 08:30:09 -0700 Subject: [PATCH 08/12] Wire plan review surface into frontend app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/frontend/package.json | 1 + .../components/sessions/SessionSurface.tsx | 42 +++++-------------- apps/frontend/vite.config.ts | 8 ++++ bun.lock | 1 + 4 files changed, 21 insertions(+), 31 deletions(-) diff --git a/apps/frontend/package.json b/apps/frontend/package.json index e1d9c3e2f..44b5405ff 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -18,6 +18,7 @@ "@fontsource-variable/geist-mono": "^5.2.7", "@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", diff --git a/apps/frontend/src/components/sessions/SessionSurface.tsx b/apps/frontend/src/components/sessions/SessionSurface.tsx index 7fad36969..bb5e8bf90 100644 --- a/apps/frontend/src/components/sessions/SessionSurface.tsx +++ b/apps/frontend/src/components/sessions/SessionSurface.tsx @@ -1,9 +1,14 @@ 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 { getSessionModeMeta } from "../../shared/session-meta"; + +const sidebarTrigger = ( + +); interface SessionSurfaceProps { bootstrap: SessionBootstrap; @@ -15,40 +20,15 @@ export function SessionSurface({ bootstrap }: SessionSurfaceProps) { if (session.mode === "review") { return ( - - } - /> + ); } - const meta = getSessionModeMeta(session.mode); - const Icon = meta.icon; - + // plan, annotate, archive, goal-setup — all handled by the plan review component return ( -
- - -
-
-
-
-

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

-
-
-
-
-
+ + + ); } diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index fd025df1d..8ae982b3a 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -68,6 +68,14 @@ export default defineConfig(({ command }) => { __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"), }, diff --git a/bun.lock b/bun.lock index 084097620..5b8de3646 100644 --- a/bun.lock +++ b/bun.lock @@ -75,6 +75,7 @@ "@fontsource-variable/geist-mono": "^5.2.7", "@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", From b0d3534bed6310f7d75437d2205c5dfb3db217b5 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 09:23:04 -0700 Subject: [PATCH 09/12] Fix: pass session fetch to submitGoalSetup helper --- packages/ui/components/goal-setup/GoalSetupSurface.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/components/goal-setup/GoalSetupSurface.tsx b/packages/ui/components/goal-setup/GoalSetupSurface.tsx index f367cc427..835858578 100644 --- a/packages/ui/components/goal-setup/GoalSetupSurface.tsx +++ b/packages/ui/components/goal-setup/GoalSetupSurface.tsx @@ -48,8 +48,8 @@ function cx(...classes: Array): string { return classes.filter(Boolean).join(' '); } -async function submitGoalSetup(payload: unknown): Promise { - const response = await fetch('/api/goal-setup/submit', { +async function submitGoalSetup(payload: unknown, fetchFn: (input: string, init?: RequestInit) => Promise = fetch): Promise { + const response = await fetchFn('/api/goal-setup/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), @@ -255,7 +255,7 @@ const InterviewSurface = React.forwardRef Date: Tue, 19 May 2026 10:29:44 -0700 Subject: [PATCH 10/12] 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 --- packages/plannotator-code-review/App.tsx | 51 +++++++------- packages/plannotator-plan-review/App.tsx | 66 +++++++++++-------- .../components/AppHeader.tsx | 6 +- packages/ui/components/CompletionBanner.tsx | 37 +++++++++++ 4 files changed, 107 insertions(+), 53 deletions(-) create mode 100644 packages/ui/components/CompletionBanner.tsx diff --git a/packages/plannotator-code-review/App.tsx b/packages/plannotator-code-review/App.tsx index 9f3d4a406..32a66d15a 100644 --- a/packages/plannotator-code-review/App.tsx +++ b/packages/plannotator-code-review/App.tsx @@ -9,6 +9,7 @@ import { AgentReviewActions } from './components/AgentReviewActions'; import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner'; import { storage } from '@plannotator/ui/utils/storage'; import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; +import { CompletionBanner } from '@plannotator/ui/components/CompletionBanner'; import { GitHubIcon } from '@plannotator/ui/components/GitHubIcon'; import { GitLabIcon } from '@plannotator/ui/components/GitLabIcon'; import { RepoIcon } from '@plannotator/ui/components/RepoIcon'; @@ -1778,6 +1779,21 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode } return {skeleton}; } + const completionTitle = !submitted ? '' : + submitted === 'approved' ? 'Changes Approved' + : submitted === 'exited' ? 'Session Closed' + : 'Feedback Sent'; + const completionSubtitle = !submitted ? '' : + submitted === 'exited' + ? 'Review session closed without feedback.' + : platformMode + ? submitted === 'approved' + ? `Your approval was submitted to ${platformLabel}.` + : `Your feedback was submitted to ${platformLabel}.` + : submitted === 'approved' + ? `${getAgentName(origin)} will proceed with the changes.` + : `${getAgentName(origin)} will address your review feedback.`; + const innerContent = ( @@ -1894,7 +1910,7 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }
- {origin ? ( + {origin && !submitted ? ( <> {/* Destination dropdown (PR mode only) */} {prMetadata && ( @@ -2123,6 +2139,9 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }
+ {/* Embedded completion banner — inline, non-blocking */} + {__embedded && } + {/* Main content */}
{shouldShowFileTree && isFileTreeOpen && ( @@ -2426,27 +2445,15 @@ const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode } /> )} - {/* Completion overlay - shown after approve/feedback/exit */} - + {/* Standalone completion overlay — full screen with auto-close */} + {!__embedded && ( + + )} {/* Update notification */} diff --git a/packages/plannotator-plan-review/App.tsx b/packages/plannotator-plan-review/App.tsx index 6c9be2b0b..8bb6af93a 100644 --- a/packages/plannotator-plan-review/App.tsx +++ b/packages/plannotator-plan-review/App.tsx @@ -23,6 +23,7 @@ import { useActiveSection } from '@plannotator/ui/hooks/useActiveSection'; import { storage } from '@plannotator/ui/utils/storage'; import { configStore } from '@plannotator/ui/config'; import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; +import { CompletionBanner } from '@plannotator/ui/components/CompletionBanner'; import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner'; import { getObsidianSettings, getEffectiveVaultPath, isObsidianConfigured, CUSTOM_PATH_SENTINEL } from '@plannotator/ui/utils/obsidian'; import { getBearSettings } from '@plannotator/ui/utils/bear'; @@ -1690,6 +1691,29 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }> = ({ return {skeleton}; } + const completionTitle = !submitted ? '' : + archive.archiveMode ? 'Archive Closed' + : submitted === 'exited' ? 'Session Closed' + : goalSetupMode ? 'Answers Submitted' + : submitted === 'approved' + ? (annotateMode ? 'Approved' : 'Plan Approved') + : annotateMode ? 'Annotations Sent' + : 'Feedback Sent'; + const completionSubtitle = !submitted ? '' : + submitted === 'exited' + ? 'Annotation session closed without feedback.' + : archive.archiveMode + ? 'You can reopen with plannotator archive.' + : goalSetupMode + ? `${agentName} will use your answers to continue.` + : submitted === 'approved' + ? (annotateMode + ? `${agentName} will proceed.` + : `${agentName} will proceed with the implementation.`) + : annotateMode + ? `${agentName} will address your annotations on the ${annotateSource === 'message' ? 'message' : annotateSource === 'folder' ? 'files' : 'file'}.` + : `${agentName} will revise the plan based on your annotations.`; + const innerContent = (
= ({ gate={gate} isSharedSession={isSharedSession} origin={origin} + submitted={!!submitted} isSubmitting={isSubmitting} isExiting={isExiting} isPanelOpen={isPanelOpen} @@ -1751,6 +1776,9 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }> = ({ octarineConfigured={isOctarineConfigured()} /> + {/* Embedded completion banner — inline, non-blocking */} + {__embedded && } + {/* Linked document error banner */} {linkedDocHook.error && (
@@ -2187,35 +2215,15 @@ const App: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode }> = ({ /> )} - {/* Completion overlay - shown after approve/deny */} - + {/* Standalone completion overlay — full screen with auto-close */} + {!__embedded && ( + + )} {/* Update notification */} diff --git a/packages/plannotator-plan-review/components/AppHeader.tsx b/packages/plannotator-plan-review/components/AppHeader.tsx index 7d3e9839e..ef159c7b0 100644 --- a/packages/plannotator-plan-review/components/AppHeader.tsx +++ b/packages/plannotator-plan-review/components/AppHeader.tsx @@ -24,6 +24,7 @@ interface AppHeaderProps { origin: Origin | null; // Dynamic state + submitted: boolean; isSubmitting: boolean; isExiting: boolean; isPanelOpen: boolean; @@ -91,6 +92,7 @@ export const AppHeader = React.memo(({ gate, isSharedSession, origin, + submitted, isSubmitting, isExiting, isPanelOpen, @@ -186,7 +188,7 @@ export const AppHeader = React.memo(({ )} - {isApiMode && !linkedDocIsActive && goalSetupMode && ( + {isApiMode && !submitted && !linkedDocIsActive && goalSetupMode && ( <> (({ )} - {isApiMode && (!linkedDocIsActive || annotateMode) && !archiveMode && !goalSetupMode && ( + {isApiMode && !submitted && (!linkedDocIsActive || annotateMode) && !archiveMode && !goalSetupMode && ( <> {annotateMode ? ( <> diff --git a/packages/ui/components/CompletionBanner.tsx b/packages/ui/components/CompletionBanner.tsx new file mode 100644 index 000000000..6f9c68b95 --- /dev/null +++ b/packages/ui/components/CompletionBanner.tsx @@ -0,0 +1,37 @@ +interface CompletionBannerProps { + submitted: 'approved' | 'denied' | 'feedback' | 'exited' | null | false; + title: string; + subtitle: string; +} + +export function CompletionBanner({ submitted, title, subtitle }: CompletionBannerProps) { + if (!submitted) return null; + + const isApproved = submitted === 'approved'; + + return ( +
+ + {isApproved ? ( + + ) : ( + + )} + +
+ {title} + {subtitle} +
+
+ ); +} From 99d1aec65e14490b1be3828197d08842dc2d3666 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 19 May 2026 13:01:55 -0700 Subject: [PATCH 11/12] Serve production frontend from daemon, debug shell via env var only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/hook/server/daemon-shell-html.ts | 18 +++++++++++++----- apps/hook/server/index.ts | 4 +--- 2 files changed, 14 insertions(+), 8 deletions(-) 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..c6aec82ee 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -126,7 +126,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 +143,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; } From fc0bf3a95e50d79ddfa01c3511dd3c425e6ff2e1 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 26 May 2026 17:21:13 -0700 Subject: [PATCH 12/12] Session lifecycle, worktree projects, and directory picker (#759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 26d6c87e0..1ca7ba6f0 100644 --- a/apps/frontend/src/components/landing/LandingPage.tsx +++ b/apps/frontend/src/components/landing/LandingPage.tsx @@ -1,141 +1,268 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; -import { Code2, Archive, Folder, FolderPlus } from "lucide-react"; +import { + Code2, + Archive, + Folder, + FolderPlus, + ChevronRight, + ChevronDown, + Trash2, +} from "lucide-react"; +import * as ContextMenu from "@radix-ui/react-context-menu"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { PullRequestIcon } from "@plannotator/ui/components/PullRequestIcon"; +import { ASCII_BANNER } from "./ascii-banner"; import { SidebarTrigger } from "@/components/ui/sidebar"; -import { useProjectStore } from "../../stores/project-store"; +import { useProjectStore, projectStore } from "../../stores/project-store"; +import { GitDashboard } from "./git-dashboard/GitDashboard"; import { useDaemonEventStore } from "../../daemon/events/event-store"; import { daemonApiClient } from "../../daemon/api/client"; -import { getSessionModeMeta } from "../../shared/session-meta"; -import type { ProjectEntry, SessionSummary } from "../../daemon/contracts"; +import { getSessionModeMeta, formatSessionLabel } from "../../shared/session-meta"; +import type { + ProjectEntry, + PRListItem, + SessionSummary, + WorktreeEntry, +} from "../../daemon/contracts"; interface LandingPageProps { onAddProject: () => void; } +interface Selection { + key: string; + cwd: string; + label: string; + prUrl?: string; +} + +function selectionKey(sel: Omit): string { + return sel.prUrl ?? sel.cwd; +} + export function LandingPage({ onAddProject }: LandingPageProps) { const projects = useProjectStore((s) => s.projects); const sessions = useDaemonEventStore((s) => s.sessions); - const [selected, setSelected] = useState(null); + const [selections, setSelections] = useState>(new Map()); + useEffect(() => { + const cwds = new Set(projects.map((p) => p.cwd)); + setSelections((prev) => { + const next = new Map(); + for (const [k, sel] of prev) { + if (cwds.has(sel.cwd)) next.set(k, sel); + } + return next.size === prev.size ? prev : next; + }); + }, [projects]); const [loading, setLoading] = useState(null); + const [viewIndex, setViewIndex] = useState(() => + typeof window !== "undefined" && window.location.hash === "#dashboard" ? 1 : 0, + ); const navigate = useNavigate(); - const selectedProject = projects.find((p) => p.name === selected); + const toggleSelection = useCallback((sel: Omit) => { + setSelections((prev) => { + const key = selectionKey(sel); + const next = new Map(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.set(key, { ...sel, key }); + } + return next; + }); + }, []); + + const selectionCount = selections.size; const handleAction = useCallback( async (action: "review" | "archive") => { - if (!selectedProject) return; + if (selectionCount === 0) return; setLoading(action); - const result = - action === "review" - ? await daemonApiClient.createReviewSession(selectedProject.cwd) - : await daemonApiClient.createArchiveSession(selectedProject.cwd); + let items = [...selections.values()]; + if (action === "archive") { + const seen = new Set(); + items = items.filter((sel) => { + if (seen.has(sel.cwd)) return false; + seen.add(sel.cwd); + return true; + }); + } + + const results = await Promise.allSettled( + items.map(async (sel) => { + const result = + action === "review" + ? await daemonApiClient.createReviewSession(sel.cwd, sel.prUrl) + : await daemonApiClient.createArchiveSession(sel.cwd); + return { sel, result }; + }), + ); setLoading(null); - if (result.ok) { - void navigate({ to: "/s/$sessionId", params: { sessionId: result.data.session.id } }); - } else { - toast.error(`Failed to start ${action}`, { description: result.error.message }); + + let firstSessionId: string | null = null; + let successCount = 0; + const failures: { label: string; message: string }[] = []; + + for (const outcome of results) { + if (outcome.status === "fulfilled" && outcome.value.result.ok) { + successCount++; + if (!firstSessionId) firstSessionId = outcome.value.result.data.session.id; + } else { + const label = outcome.status === "fulfilled" ? outcome.value.sel.label : "Unknown"; + const message = + outcome.status === "fulfilled" && !outcome.value.result.ok + ? outcome.value.result.error.message + : outcome.status === "rejected" + ? String(outcome.reason) + : "Unknown error"; + failures.push({ label, message }); + } + } + + if (firstSessionId) { + setSelections(new Map()); + void navigate({ to: "/s/$sessionId", params: { sessionId: firstSessionId } }); + if (successCount > 1) { + toast.success(`Launched ${successCount} sessions`); + } + } + + for (const fail of failures) { + toast.error(fail.label, { description: fail.message }); } }, - [selectedProject, navigate], + [selections, selectionCount, navigate], ); return (
- - -
-
-
-
- {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 index bb5e8bf90..1aac30548 100644 --- a/apps/frontend/src/components/sessions/SessionSurface.tsx +++ b/apps/frontend/src/components/sessions/SessionSurface.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { SidebarTrigger } from "@/components/ui/sidebar"; import { SessionProvider } from "@plannotator/ui/hooks/useSessionFetch"; import { ReviewAppEmbedded } from "@plannotator/code-review"; @@ -5,30 +6,34 @@ import { PlanAppEmbedded } from "@plannotator/plan-review"; import "@plannotator/code-review/styles"; import "@plannotator/plan-review/styles"; import type { SessionBootstrap } from "../../daemon/contracts"; +import { appStore } from "../../stores/app-store"; const sidebarTrigger = ( ); +const openSettings = () => appStore.getState().setSettingsOpen(true); + interface SessionSurfaceProps { bootstrap: SessionBootstrap; } -export function SessionSurface({ bootstrap }: SessionSurfaceProps) { +export const SessionSurface = React.memo(function SessionSurface({ + bootstrap, +}: SessionSurfaceProps) { const { session } = bootstrap; if (session.mode === "review") { return ( - + ); } - // plan, annotate, archive, goal-setup — all handled by the plan review component return ( - + ); -} +}); diff --git a/apps/frontend/src/components/settings/AppSettingsDialog.tsx b/apps/frontend/src/components/settings/AppSettingsDialog.tsx new file mode 100644 index 000000000..857ee73c7 --- /dev/null +++ b/apps/frontend/src/components/settings/AppSettingsDialog.tsx @@ -0,0 +1,353 @@ +import { useState, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { useAppStore } from "../../stores/app-store"; +import { GeneralTab } from "@plannotator/ui/components/settings/GeneralTab"; +import { PlanGeneralTab } from "@plannotator/ui/components/settings/PlanGeneralTab"; +import { PlanDisplayTab } from "@plannotator/ui/components/settings/PlanDisplayTab"; +import { SavingTab } from "@plannotator/ui/components/settings/SavingTab"; +import { LabelsTab } from "@plannotator/ui/components/settings/LabelsTab"; +import { FilesTab } from "@plannotator/ui/components/settings/FilesTab"; +import { ObsidianTab } from "@plannotator/ui/components/settings/ObsidianTab"; +import { BearTab } from "@plannotator/ui/components/settings/BearTab"; +import { OctarineTab } from "@plannotator/ui/components/settings/OctarineTab"; +import { GitTab, ReviewDisplayTab, CommentsTab } from "@plannotator/ui/components/Settings"; +import { ThemeTab } from "@plannotator/ui/components/ThemeTab"; +import { KeyboardShortcuts } from "@plannotator/ui/components/KeyboardShortcuts"; +import { AISettingsTab } from "@plannotator/ui/components/AISettingsTab"; +import { HooksTab } from "@plannotator/ui/components/settings/HooksTab"; +import { getAIProviderSettings, saveAIProviderSettings } from "@plannotator/ui/utils/aiProvider"; +import { configStore } from "@plannotator/ui/config"; + +interface TabDef { + id: string; + label: string; +} + +const GENERAL_TABS: TabDef[] = [ + { id: "general", label: "General" }, + { id: "theme", label: "Theme" }, + { id: "shortcuts", label: "Shortcuts" }, +]; + +const PLAN_TABS: TabDef[] = [ + { id: "plan-general", label: "General" }, + { id: "plan-display", label: "Display" }, + { id: "plan-saving", label: "Saving" }, + { id: "plan-labels", label: "Labels" }, + { id: "plan-hooks", label: "Hooks" }, +]; + +const REVIEW_TABS: TabDef[] = [ + { id: "review-git", label: "Git" }, + { id: "review-display", label: "Display" }, + { id: "review-comments", label: "Comments" }, + { id: "review-ai", label: "AI" }, +]; + +const INTEGRATION_TABS: TabDef[] = [ + { id: "int-files", label: "Files" }, + { id: "int-obsidian", label: "Obsidian" }, + { id: "int-bear", label: "Bear" }, + { id: "int-octarine", label: "Octarine" }, +]; + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function AppSettingsDialog() { + const open = useAppStore((s) => s.settingsOpen); + const setOpen = useAppStore((s) => s.setSettingsOpen); + const [activeTab, setActiveTab] = useState("general"); + const [themePreview, setThemePreview] = useState(false); + + useEffect(() => { + if (!themePreview) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setThemePreview(false); + setOpen(true); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [themePreview, setOpen]); + + // Force re-mount of tab content when dialog opens to ensure fresh state + const [mountKey, setMountKey] = useState(0); + useEffect(() => { + if (open) setMountKey((k) => k + 1); + }, [open]); + + // Detect origin from the active session (if any) + const activeSessionId = useAppStore((s) => s.activeSessionId); + const visitedSessions = useAppStore((s) => s.visitedSessions); + const activeOrigin = activeSessionId + ? ((visitedSessions[activeSessionId]?.bootstrap.session.origin as string | undefined) ?? null) + : null; + + // Fetch git user and config from daemon on open + const [gitUser, setGitUser] = useState(); + const [legacyTabMode, setLegacyTabMode] = useState(false); + + useEffect(() => { + if (!open) return; + fetch("/daemon/git/user") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.gitUser) setGitUser(data.gitUser); + }) + .catch(() => {}); + fetch("/daemon/config") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.config) { + configStore.init(data.config); + setLegacyTabMode(!!data.config.legacyTabMode); + } + }) + .catch(() => {}); + }, [open]); + + // Daemon-routed fetch for tabs that need server calls without session context + const daemonFetch = useCallback((input: string, init?: RequestInit) => { + const path = + typeof input === "string" && input.startsWith("/api/") ? `/daemon${input.slice(4)}` : input; + return fetch(path, init); + }, []); + + // AI provider state — fetched once when dialog opens + const [aiProviders, setAiProviders] = useState< + Array<{ id: string; name: string; capabilities: Record }> + >([]); + const [aiProviderId, setAiProviderId] = useState( + () => getAIProviderSettings().providerId, + ); + + // Re-read AI provider on each open (could have changed via per-surface settings) + useEffect(() => { + if (open) setAiProviderId(getAIProviderSettings().providerId); + }, [open]); + + useEffect(() => { + if (!open) return; + const apiBase = activeSessionId ? `/s/${activeSessionId}/api` : null; + if (!apiBase) return; + fetch(`${apiBase}/ai/capabilities`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.providers) setAiProviders(data.providers); + }) + .catch(() => {}); + }, [open, activeSessionId]); + + const handleAiProviderChange = useCallback((providerId: string | null) => { + setAiProviderId(providerId); + const current = getAIProviderSettings(); + saveAIProviderSettings({ ...current, providerId }); + }, []); + + return ( + <> + + + Settings + +
+ + +
+ + 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 984fded21..ec923f135 100644 --- a/apps/frontend/src/components/sidebar/AppSidebar.tsx +++ b/apps/frontend/src/components/sidebar/AppSidebar.tsx @@ -1,6 +1,8 @@ import { useCallback, useMemo } from "react"; import { Link, useMatchRoute } from "@tanstack/react-router"; -import { Check, FolderPlus, Moon, Sun } from "lucide-react"; +import { Moon, Settings, Sun } from "lucide-react"; +import { TaterSpriteSidebar } from "./TaterSpriteSidebar"; +import { appStore } from "../../stores/app-store"; import { cn } from "@/lib/utils"; import { Sidebar, @@ -11,29 +13,17 @@ import { SidebarGroupLabel, SidebarHeader, SidebarMenu, - SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; import { useTheme } from "@plannotator/ui/components/ThemeProvider"; import { useDaemonEventStore } from "../../daemon/events/event-store"; import type { SessionSummary } from "../../daemon/contracts"; -import { getSessionModeMeta } from "../../shared/session-meta"; +import { getSessionModeMeta, formatSessionLabel } from "../../shared/session-meta"; const MODE_ORDER = ["plan", "review", "annotate", "goal-setup", "archive"]; -function formatSessionLabel(label: string): string { - return label - .replace(/^plugin-(plan|review|annotate|archive)-/, "") - .replace(/^(claude-code|opencode|pi|plannotator-frontend)-/, "") - .replace(/^goal-setup-(interview|facts)-/, ""); -} - -interface AppSidebarProps { - onAddProject: () => void; -} - -export function AppSidebar({ onAddProject }: AppSidebarProps) { +export function AppSidebarContent() { const sessions = useDaemonEventStore((s) => s.sessions); const { resolvedMode, setMode } = useTheme(); const matchRoute = useMatchRoute(); @@ -53,40 +43,51 @@ export function AppSidebar({ onAddProject }: AppSidebarProps) { }, [resolvedMode, setMode]); return ( - + <> - - - - -
- P -
-
- Plannotator - - {sessions.length} session{sessions.length !== 1 ? "s" : ""} - -
- -
-
-
+ + +
+ + Plannotator + + + v{__APP_VERSION__} ·{" "} + e.stopPropagation()} + > + Send feedback + + +
+
- + {MODE_ORDER.map((mode) => { const modeSessions = grouped.get(mode); if (!modeSessions?.length) return null; const meta = getSessionModeMeta(mode); + const Icon = meta.icon; return ( - {meta.label}s + + + {meta.label}s + {modeSessions.map((session) => { - const Icon = meta.icon; const isActive = !!matchRoute({ to: "/s/$sessionId", params: { sessionId: session.id }, @@ -96,39 +97,19 @@ export function AppSidebar({ onAddProject }: AppSidebarProps) { return ( - + - + - {formatSessionLabel(session.label)} + {formatSessionLabel(session.label, session.mode)} - {session.status === "active" && ( - - - - )} - {isTerminal && ( - - - - )} ); })} @@ -142,9 +123,12 @@ export function AppSidebar({ onAddProject }: AppSidebarProps) { - - - Add project + appStore.getState().setSettingsOpen(true)} + tooltip="Settings" + > + + Settings @@ -155,6 +139,14 @@ export function AppSidebar({ onAddProject }: AppSidebarProps) { + + ); +} + +export function AppSidebar() { + return ( + + ); } diff --git a/apps/frontend/src/components/sidebar/SidebarPeek.tsx b/apps/frontend/src/components/sidebar/SidebarPeek.tsx new file mode 100644 index 000000000..705d25dc5 --- /dev/null +++ b/apps/frontend/src/components/sidebar/SidebarPeek.tsx @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSidebar } from "@/components/ui/sidebar"; +import { AppSidebarContent } from "./AppSidebar"; + +export function SidebarPeek() { + const { open } = useSidebar(); + const [visible, setVisible] = useState(false); + const [backdropMounted, setBackdropMounted] = useState(false); + const [backdropVisible, setBackdropVisible] = useState(false); + const hideTimeout = useRef | null>(null); + + const show = useCallback(() => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + hideTimeout.current = null; + } + setVisible(true); + }, []); + + const hide = useCallback(() => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + } + hideTimeout.current = setTimeout(() => setVisible(false), 150); + }, []); + + useEffect(() => { + if (visible) { + setBackdropMounted(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => setBackdropVisible(true)); + }); + } else { + setBackdropVisible(false); + const timer = setTimeout(() => setBackdropMounted(false), 200); + return () => clearTimeout(timer); + } + }, [visible]); + + if (open) return null; + + return ( + <> + {/* Hover strip — invisible hit area on left edge */} +
+ {/* Backdrop overlay */} + {backdropMounted && ( +
+ )} + {/* Floating sidebar panel */} +
+
+ +
+
+ + ); +} diff --git a/apps/frontend/src/components/sidebar/TaterSpriteSidebar.tsx b/apps/frontend/src/components/sidebar/TaterSpriteSidebar.tsx new file mode 100644 index 000000000..2da10b85c --- /dev/null +++ b/apps/frontend/src/components/sidebar/TaterSpriteSidebar.tsx @@ -0,0 +1,32 @@ +import spriteSheet from "../../assets/sprite_package_sidebar/sprite.png"; + +const NATIVE_W = 117; +const NATIVE_H = 96; +const FRAMES = 24; +const DISPLAY_H = 40; +const SCALE = DISPLAY_H / NATIVE_H; +const DISPLAY_W = NATIVE_W * SCALE; +const TOTAL_WIDTH = NATIVE_W * FRAMES * SCALE; + +export function TaterSpriteSidebar() { + return ( +
+ +
+ ); +} diff --git a/apps/frontend/src/components/ui/dialog.tsx b/apps/frontend/src/components/ui/dialog.tsx new file mode 100644 index 000000000..1a98f2f22 --- /dev/null +++ b/apps/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,106 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + hideClose?: boolean; + } +>(({ className, children, hideClose, ...props }, ref) => ( + + + + {children} + {!hideClose && ( + + + Close + + )} + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +function DialogHeader({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +const DialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +}; diff --git a/apps/frontend/src/components/ui/sidebar.tsx b/apps/frontend/src/components/ui/sidebar.tsx index 2893d59c2..21ea19364 100644 --- a/apps/frontend/src/components/ui/sidebar.tsx +++ b/apps/frontend/src/components/ui/sidebar.tsx @@ -274,7 +274,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps} - className={cn("h-7 w-7", className)} + className={cn("h-7 w-7 hover:bg-muted hover:text-foreground", className)} onClick={(event) => { onClick?.(event); toggleSidebar(); @@ -406,7 +406,7 @@ function SidebarGroupLabel({ data-slot="sidebar-group-label" data-sidebar="group-label" className={cn( - "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", className, )} diff --git a/apps/frontend/src/components/ui/tabs.tsx b/apps/frontend/src/components/ui/tabs.tsx new file mode 100644 index 000000000..323007d42 --- /dev/null +++ b/apps/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/frontend/src/daemon/api/client.ts b/apps/frontend/src/daemon/api/client.ts index 8fa11f8b6..0e2583de1 100644 --- a/apps/frontend/src/daemon/api/client.ts +++ b/apps/frontend/src/daemon/api/client.ts @@ -11,6 +11,10 @@ import type { SessionListResponse, SessionResponse, SessionSummary, + WorktreeListResponse, + DirectoryListResponse, + PRListResponse, + PRDetailedListResponse, } from "../contracts"; import { DaemonHubActionError, @@ -64,8 +68,12 @@ export interface DaemonApiClient { cwd: string, name?: string, ): Promise>; - removeProject(name: string): Promise>; - createReviewSession(cwd: string): Promise>; + removeProject(cwd: string, clean?: boolean): Promise>; + listWorktrees(cwd: string): Promise>; + listDirectories(path?: string): Promise>; + listPRs(cwd: string): Promise>; + listDetailedPRs(cwd: string): Promise>; + createReviewSession(cwd: string, prUrl?: string): Promise>; createArchiveSession(cwd: string): Promise>; } @@ -178,6 +186,22 @@ function isProjectResponse(value: unknown): value is { ok: true; project: Projec return hasOkTrue(value) && isProjectEntry((value as { project?: unknown }).project); } +function isWorktreeList(value: unknown): value is WorktreeListResponse { + return hasOkTrue(value) && Array.isArray((value as { worktrees?: unknown }).worktrees); +} + +function isDirectoryList(value: unknown): value is DirectoryListResponse { + return hasOkTrue(value) && Array.isArray((value as { dirs?: unknown }).dirs); +} + +function isPRList(value: unknown): value is PRListResponse { + return hasOkTrue(value) && Array.isArray((value as { prs?: unknown }).prs); +} + +function isPRDetailedList(value: unknown): value is PRDetailedListResponse { + return hasOkTrue(value) && Array.isArray((value as { prs?: unknown }).prs); +} + function isSessionBootstrap(value: unknown): value is SessionBootstrap { return ( isSessionResponse(value) && @@ -442,21 +466,62 @@ export function createDaemonApiClient(options: DaemonApiClientOptions = {}): Dae ); }, - removeProject(name) { + removeProject(cwd, clean) { + const params = new URLSearchParams({ cwd }); + if (clean) params.set("clean", "1"); return requestJson( fetchImpl, - joinUrl(options.baseUrl, `/daemon/projects/${encodeURIComponent(name)}`), + joinUrl(options.baseUrl, `/daemon/projects?${params}`), isDeleteSessionResponse, { method: "DELETE" }, ); }, - createReviewSession(cwd) { + listDirectories(path = "~") { + return requestJson( + fetchImpl, + joinUrl(options.baseUrl, `/daemon/fs/list?path=${encodeURIComponent(path)}`), + isDirectoryList, + ); + }, + + listWorktrees(cwd) { + return requestJson( + fetchImpl, + joinUrl(options.baseUrl, `/daemon/projects/worktrees?cwd=${encodeURIComponent(cwd)}`), + isWorktreeList, + ); + }, + + listPRs(cwd) { + return requestJson( + fetchImpl, + joinUrl(options.baseUrl, `/daemon/projects/prs?cwd=${encodeURIComponent(cwd)}`), + isPRList, + ); + }, + + listDetailedPRs(cwd) { + return requestJson( + fetchImpl, + joinUrl(options.baseUrl, `/daemon/projects/prs/detailed?cwd=${encodeURIComponent(cwd)}`), + isPRDetailedList, + ); + }, + + createReviewSession(cwd, prUrl) { return requestJson( fetchImpl, joinUrl(options.baseUrl, "/daemon/sessions"), isSessionResponse, - jsonPost({ request: { action: "review", origin: "plannotator-frontend", cwd } }), + jsonPost({ + request: { + action: "review", + origin: "plannotator-frontend", + cwd, + ...(prUrl && { prUrl }), + }, + }), ); }, diff --git a/apps/frontend/src/daemon/contracts.ts b/apps/frontend/src/daemon/contracts.ts index 2e68488c6..0dea63f8b 100644 --- a/apps/frontend/src/daemon/contracts.ts +++ b/apps/frontend/src/daemon/contracts.ts @@ -46,6 +46,66 @@ export interface ProjectListResponse { projects: ProjectEntry[]; } +export interface WorktreeEntry { + path: string; + branch: string | null; + head: string; + lastActive: number; +} + +export interface WorktreeListResponse { + ok: true; + worktrees: WorktreeEntry[]; +} + +export interface DirectoryEntry { + name: string; + path: string; +} + +export interface DirectoryListResponse { + ok: true; + path: string; + dirs: DirectoryEntry[]; +} + +export interface PRListItem { + id: string; + number: number; + title: string; + author: string; + url: string; + baseBranch: string; + headBranch: string; + state: "open" | "closed" | "merged"; +} + +export interface PRDetailedListItem extends PRListItem { + additions: number; + deletions: number; + commentCount: number; + updatedAt: string; + isDraft: boolean; + reviewDecision: string; +} + +export interface PRDetailedListResponse { + ok: true; + prs: PRDetailedListItem[]; + platform: "github" | "gitlab" | null; + error?: "no-remote" | "no-cli" | "auth-failed"; + message?: string; +} + +export interface PRListResponse { + ok: true; + prs: PRListItem[]; + platform: "github" | "gitlab" | null; + defaultBranch?: string; + error?: "no-remote" | "no-cli" | "auth-failed"; + message?: string; +} + export type SessionLifecycleStatus = DaemonSessionStatus; export type DaemonServerMessage = DaemonWebSocketServerMessage; @@ -56,7 +116,10 @@ export type DaemonLifecycleEvent = | Extract | Extract | (Omit< - Extract, + Extract< + DaemonEvent, + { type: "session-created" | "session-updated" | "session-removed" | "session-notify" } + >, "session" > & { session: SessionSummary; diff --git a/apps/frontend/src/daemon/events/event-store.ts b/apps/frontend/src/daemon/events/event-store.ts index 4cec8cffd..9201b4183 100644 --- a/apps/frontend/src/daemon/events/event-store.ts +++ b/apps/frontend/src/daemon/events/event-store.ts @@ -59,7 +59,7 @@ export function applyDaemonEvent(state: DaemonEventState, event: DaemonLifecycle } const existingIndex = state.sessions.findIndex((session) => session.id === event.session.id); - if (event.type === "session-removed" || TERMINAL_STATUSES.has(event.session.status)) { + if (event.type === "session-removed") { if (existingIndex >= 0) state.sessions.splice(existingIndex, 1); return; } diff --git a/apps/frontend/src/daemon/events/event-stream.ts b/apps/frontend/src/daemon/events/event-stream.ts index a754b938e..a6f77ea5f 100644 --- a/apps/frontend/src/daemon/events/event-stream.ts +++ b/apps/frontend/src/daemon/events/event-stream.ts @@ -17,12 +17,14 @@ export interface DaemonEventStreamOptions { onEvent(event: DaemonLifecycleEvent): void; onState(state: DaemonHubConnectionState | "polling"): void; onError(message: string): void; + onSessionNotify?(session: { id: string; mode: string; project: string; label: string }): void; webSocketFactory?: WebSocketFactory; fallbackPollMs?: number; } export interface DaemonEventStreamController { stop(): void; + reportActiveSession(sessionId: string | null): void; } const DAEMON_EVENT_TYPES = [ @@ -31,6 +33,7 @@ const DAEMON_EVENT_TYPES = [ "session-created", "session-updated", "session-removed", + "session-notify", "daemon-error", "debug-log", ] as const; @@ -79,16 +82,46 @@ export function connectDaemonEvents( }); }; + let currentActiveSessionId: string | null = null; + const pendingNotifications: { id: string; mode: string; project: string; label: string }[] = []; + + const sendClientState = () => { + client.sendClientState(!document.hidden, currentActiveSessionId); + }; + + const handleVisibilityChange = () => { + if (stopped) return; + sendClientState(); + if (!document.hidden && pendingNotifications.length > 0 && options.onSessionNotify) { + for (const n of pendingNotifications.splice(0)) { + options.onSessionNotify(n); + } + } + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + const unsubscribe = client.subscribeDaemon( (message) => { if (stopped) return; const event = messageToDaemonEvent(message); - if (event) options.onEvent(event); + if (!event) return; + if (event.type === "session-notify" && "session" in event && options.onSessionNotify) { + const s = event.session; + if (!document.hidden) { + options.onSessionNotify({ id: s.id, mode: s.mode, project: s.project, label: s.label }); + } else { + pendingNotifications.push({ id: s.id, mode: s.mode, project: s.project, label: s.label }); + } + } + options.onEvent(event); }, (state) => { if (stopped) return; options.onState(state); - if (state === "open") stopPolling(); + if (state === "open") { + stopPolling(); + sendClientState(); + } if (state === "error" || state === "closed") startPolling(); }, (message) => { @@ -96,10 +129,16 @@ export function connectDaemonEvents( }, ); - return { stop }; + return { stop, reportActiveSession }; + + function reportActiveSession(sessionId: string | null): void { + currentActiveSessionId = sessionId; + sendClientState(); + } function stop() { stopped = true; + document.removeEventListener("visibilitychange", handleVisibilityChange); stopPolling(); unsubscribe(); } diff --git a/apps/frontend/src/daemon/events/hub-client.ts b/apps/frontend/src/daemon/events/hub-client.ts index 71bf964e3..c96983bab 100644 --- a/apps/frontend/src/daemon/events/hub-client.ts +++ b/apps/frontend/src/daemon/events/hub-client.ts @@ -233,6 +233,11 @@ export class DaemonHubClient { } } + sendClientState(visible: boolean, activeSessionId: string | null): void { + if (this.socket?.readyState !== OPEN) return; + this.send({ type: "client-state", visible, activeSessionId }); + } + private send(message: DaemonWebSocketClientMessage): void { if (this.socket?.readyState !== OPEN) { throw new DaemonHubOpenError("Daemon WebSocket is not open."); diff --git a/apps/frontend/src/daemon/events/use-daemon-events.ts b/apps/frontend/src/daemon/events/use-daemon-events.ts index 7af546cb5..9a8524c01 100644 --- a/apps/frontend/src/daemon/events/use-daemon-events.ts +++ b/apps/frontend/src/daemon/events/use-daemon-events.ts @@ -1,12 +1,34 @@ -import { useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { useRouter } from "@tanstack/react-router"; import { daemonApiClient, type DaemonApiClient } from "../api/client"; -import { connectDaemonEvents } from "./event-stream"; +import { connectDaemonEvents, type DaemonEventStreamController } from "./event-stream"; import { useDaemonEventStore } from "./event-store"; +import { getSessionModeMeta, formatSessionLabel } from "../../shared/session-meta"; -export function useDaemonEvents(client: DaemonApiClient = daemonApiClient, enabled = true): void { +export function useDaemonEvents(client: DaemonApiClient = daemonApiClient, enabled = true) { const applyEvent = useDaemonEventStore((state) => state.applyEvent); const setConnectionState = useDaemonEventStore((state) => state.setConnectionState); const setError = useDaemonEventStore((state) => state.setError); + const controllerRef = useRef(null); + const router = useRouter(); + + const handleSessionNotify = useCallback( + (session: { id: string; mode: string; project: string; label: string }) => { + const meta = getSessionModeMeta(session.mode); + const displayLabel = formatSessionLabel(session.label, session.mode); + toast(`${meta.label} — ${session.project}`, { + description: displayLabel !== session.project ? displayLabel : undefined, + duration: 8000, + action: { + label: "Open", + onClick: () => + router.navigate({ to: "/s/$sessionId", params: { sessionId: session.id } }), + }, + }); + }, + [router], + ); useEffect(() => { if (!enabled) return undefined; @@ -15,8 +37,19 @@ export function useDaemonEvents(client: DaemonApiClient = daemonApiClient, enabl onEvent: applyEvent, onState: setConnectionState, onError: setError, + onSessionNotify: handleSessionNotify, }); + controllerRef.current = controller; + + return () => { + controller.stop(); + controllerRef.current = null; + }; + }, [applyEvent, client, enabled, handleSessionNotify, setConnectionState, setError]); + + const reportActiveSession = useCallback((sessionId: string | null) => { + controllerRef.current?.reportActiveSession(sessionId); + }, []); - return () => controller.stop(); - }, [applyEvent, client, enabled, setConnectionState, setError]); + return { reportActiveSession }; } diff --git a/apps/frontend/src/shared/session-meta.ts b/apps/frontend/src/shared/session-meta.ts index fa7b22aae..54f9ee447 100644 --- a/apps/frontend/src/shared/session-meta.ts +++ b/apps/frontend/src/shared/session-meta.ts @@ -27,3 +27,63 @@ const FALLBACK: SessionModeMeta = { icon: ListChecks, label: "Session" }; export function getSessionModeMeta(mode: SessionMode): SessionModeMeta { return MODE_META[mode] ?? FALLBACK; } + +const ORIGINS = /^(claude-code|opencode|pi|plannotator-frontend|codex|copilot-cli|gemini-cli)-/; + +export function formatSessionLabel(label: string, mode: SessionMode): string { + // PR/MR review: "plugin-pr-review-owner/repo#123" → "PR #123" + const prMatch = label.match(/^plugin-(?:pr|mr)-review-.+?(#\d+|!\d+)$/); + if (prMatch) return `${label.includes("-mr-") ? "MR" : "PR"} ${prMatch[1]}`; + + // Local review: "plugin-review-{origin}-{project}-{branch}" → "project (branch)" + if (mode === "review") { + const stripped = label.replace(/^plugin-review-/, "").replace(ORIGINS, ""); + const lastDash = stripped.lastIndexOf("-"); + if (lastDash > 0) { + return `${stripped.slice(0, lastDash)} (${stripped.slice(lastDash + 1)})`; + } + return stripped; + } + + // Plan: "plugin-plan-{origin}-{project}-{branch}" → "project (branch)" + if (mode === "plan") { + const stripped = label.replace(/^plugin-plan-/, "").replace(ORIGINS, ""); + const lastDash = stripped.lastIndexOf("-"); + if (lastDash > 0) { + return `${stripped.slice(0, lastDash)} (${stripped.slice(lastDash + 1)})`; + } + return stripped; + } + + // Annotate: "plugin-annotate-{origin}-{file}-{branch}" → "file (branch)" + if (mode === "annotate") { + const stripped = label.replace(/^plugin-annotate-/, "").replace(ORIGINS, ""); + const lastDash = stripped.lastIndexOf("-"); + if (lastDash > 0) { + return `${stripped.slice(0, lastDash)} (${stripped.slice(lastDash + 1)})`; + } + return stripped; + } + + // Archive: "plugin-archive-{origin}-{project}-{branch}" → "project (branch)" + if (mode === "archive") { + const stripped = label.replace(/^plugin-archive-/, "").replace(ORIGINS, ""); + const lastDash = stripped.lastIndexOf("-"); + if (lastDash > 0) { + return `${stripped.slice(0, lastDash)} (${stripped.slice(lastDash + 1)})`; + } + return stripped; + } + + // Goal setup: "goal-setup-{stage}-{slug}-{branch}" → "slug (branch)" + if (mode === "goal-setup") { + const stripped = label.replace(/^goal-setup-(interview|facts)-/, ""); + const lastDash = stripped.lastIndexOf("-"); + if (lastDash > 0) { + return `${stripped.slice(0, lastDash)} (${stripped.slice(lastDash + 1)})`; + } + return stripped; + } + + return label; +} diff --git a/apps/frontend/src/stores/app-store.ts b/apps/frontend/src/stores/app-store.ts index b05138ee5..8a612af72 100644 --- a/apps/frontend/src/stores/app-store.ts +++ b/apps/frontend/src/stores/app-store.ts @@ -10,12 +10,14 @@ export interface VisitedSession { export interface AppState { addProjectOpen: boolean; + settingsOpen: boolean; activeSessionId: string | null; visitedSessions: Record; } export interface AppActions { setAddProjectOpen(open: boolean): void; + setSettingsOpen(open: boolean): void; activateSession(sessionId: string, bootstrap: SessionBootstrap): void; deactivateSession(): void; removeSession(sessionId: string): void; @@ -25,6 +27,7 @@ export type AppStore = AppState & AppActions; const initialState: AppState = { addProjectOpen: false, + settingsOpen: false, activeSessionId: null, visitedSessions: {}, }; @@ -39,6 +42,11 @@ export function createAppStore(initial: Partial = {}) { state.addProjectOpen = open; }); }, + setSettingsOpen(open) { + set((state) => { + state.settingsOpen = open; + }); + }, activateSession(sessionId, bootstrap) { set((state) => { state.activeSessionId = sessionId; diff --git a/apps/frontend/src/stores/git-dashboard-store.ts b/apps/frontend/src/stores/git-dashboard-store.ts new file mode 100644 index 000000000..a2d5e4c83 --- /dev/null +++ b/apps/frontend/src/stores/git-dashboard-store.ts @@ -0,0 +1,128 @@ +import { createStore } from "zustand/vanilla"; +import { useStore } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import type { PRDetailedListItem } from "../daemon/contracts"; +import type { DaemonApiClient } from "../daemon/api/client"; +import { daemonApiClient } from "../daemon/api/client"; + +export interface GitDashboardPR extends PRDetailedListItem { + projectCwd: string; + projectName: string; + repoSlug: string; +} + +export interface GitDashboardState { + prs: GitDashboardPR[]; + loading: boolean; + error?: string; + lastFetchedAt: number | null; + lastProjectKey: string; +} + +export interface GitDashboardActions { + fetchAllPRs( + projects: Array<{ cwd: string; name: string; parentCwd?: string }>, + client?: DaemonApiClient, + ): Promise; + clear(): void; +} + +export type GitDashboardStore = GitDashboardState & GitDashboardActions; + +function extractRepoSlug(url: string): string { + const gh = url.match(/github\.com\/([^/]+\/[^/]+)/); + if (gh) return gh[1]; + const gl = url.match(/gitlab\.[^/]+\/(.+?)\/-\//); + if (gl) return gl[1]; + return ""; +} + +const initialState: GitDashboardState = { + prs: [], + loading: false, + lastFetchedAt: null, + lastProjectKey: "", +}; + +export const gitDashboardStore = createStore()( + immer((set) => ({ + ...initialState, + + async fetchAllPRs(projects, client = daemonApiClient) { + const topLevel = projects.filter((p) => !p.parentCwd); + if (topLevel.length === 0) return; + + set((state) => { + state.loading = true; + state.error = undefined; + }); + + const results = await Promise.allSettled( + topLevel.map(async (project) => { + const result = await client.listDetailedPRs(project.cwd); + return { project, result }; + }), + ); + + const allPRs: GitDashboardPR[] = []; + const errors: string[] = []; + + for (const outcome of results) { + if (outcome.status === "rejected") continue; + const { project, result } = outcome.value; + if (!result.ok) continue; + if (result.data.error) { + const e = result.data.error; + if (e === "no-cli") errors.push(`${project.name}: GitHub/GitLab CLI not installed`); + else if (e === "auth-failed") errors.push(`${project.name}: CLI not authenticated`); + continue; + } + for (const pr of result.data.prs) { + allPRs.push({ + ...pr, + projectCwd: project.cwd, + projectName: project.name, + repoSlug: extractRepoSlug(pr.url), + }); + } + } + + const seen = new Set(); + const deduplicated = allPRs.filter((pr) => { + if (seen.has(pr.url)) return false; + seen.add(pr.url); + return true; + }); + + deduplicated.sort((a, b) => { + if (a.updatedAt && b.updatedAt) return b.updatedAt.localeCompare(a.updatedAt); + return b.number - a.number; + }); + + set((state) => { + state.prs = deduplicated; + state.loading = false; + state.lastFetchedAt = Date.now(); + state.lastProjectKey = topLevel + .map((p) => p.cwd) + .sort() + .join("|"); + if (deduplicated.length === 0 && errors.length > 0) { + state.error = errors.join(". "); + } + }); + }, + + clear() { + set((state) => { + state.prs = []; + state.lastFetchedAt = null; + state.error = undefined; + }); + }, + })), +); + +export function useGitDashboardStore(selector: (state: GitDashboardStore) => T): T { + return useStore(gitDashboardStore, selector); +} diff --git a/apps/frontend/src/stores/project-store.ts b/apps/frontend/src/stores/project-store.ts index 907ffb233..460b25406 100644 --- a/apps/frontend/src/stores/project-store.ts +++ b/apps/frontend/src/stores/project-store.ts @@ -18,7 +18,7 @@ export interface ProjectStoreActions { name?: string, client?: DaemonApiClient, ): Promise; - removeProject(name: string, client?: DaemonApiClient): Promise; + removeProject(cwd: string, clean?: boolean, client?: DaemonApiClient): Promise; } export type ProjectStore = ProjectStoreState & ProjectStoreActions; @@ -59,19 +59,28 @@ export function createProjectStore(initial: Partial = {}) { return undefined; } const entry = result.data.project; - set((state) => { - const idx = state.projects.findIndex((p) => p.name === entry.name); - if (idx >= 0) { - state.projects[idx] = entry; - } else { - state.projects.unshift(entry); + if (entry.parentCwd) { + const listResult = await client.listProjects(); + if (listResult.ok) { + set((state) => { + state.projects = listResult.data.projects; + }); } - }); + } else { + set((state) => { + const idx = state.projects.findIndex((p) => p.cwd === entry.cwd); + if (idx >= 0) { + state.projects[idx] = entry; + } else { + state.projects.unshift(entry); + } + }); + } return entry; }, - async removeProject(name, client = daemonApiClient) { - const result = await client.removeProject(name); + async removeProject(cwd, clean, client = daemonApiClient) { + const result = await client.removeProject(cwd, clean); if (!result.ok) { set((state) => { state.error = result.error.message; @@ -79,7 +88,7 @@ export function createProjectStore(initial: Partial = {}) { return false; } set((state) => { - state.projects = state.projects.filter((p) => p.name !== name); + state.projects = state.projects.filter((p) => p.cwd !== cwd && p.parentCwd !== cwd); }); return true; }, diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css index 5855da4bc..7d52b050a 100644 --- a/apps/frontend/src/styles.css +++ b/apps/frontend/src/styles.css @@ -1,4 +1,5 @@ @import "@fontsource-variable/inter"; +@import "@fontsource-variable/instrument-sans"; @import "@fontsource-variable/geist-mono"; @import "@plannotator/ui/theme.css"; @import "tailwindcss"; diff --git a/apps/frontend/src/types/plannotator-ui.d.ts b/apps/frontend/src/types/plannotator-ui.d.ts index 672be485d..0c1134e5e 100644 --- a/apps/frontend/src/types/plannotator-ui.d.ts +++ b/apps/frontend/src/types/plannotator-ui.d.ts @@ -38,3 +38,5 @@ declare module "*.png" { const src: string; export default src; } + +declare const __APP_VERSION__: string; diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index c6aec82ee..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 { @@ -652,19 +643,6 @@ function registerDaemonSessionInterruptCleanup( }; } -async function withProcessCwd(cwd: string | undefined, fn: () => Promise): Promise { - if (!cwd) return fn(); - const original = process.cwd(); - const target = path.resolve(cwd); - if (target === original) return fn(); - process.chdir(target); - try { - return await fn(); - } finally { - process.chdir(original); - } -} - async function runDaemonSessionRequest(request: PluginRequest, options: { pluginError?: boolean } = {}): Promise<{ result: PluginActionResult; session: PluginSessionInfo; @@ -691,12 +669,10 @@ async function runDaemonSessionRequest(request: PluginRequest, options: { plugin }); const sessionUrl = new URL(created.session.url); - const sessionPort = Number(sessionUrl.port); - const browserSessionUrl = createDaemonBrowserAuthUrl(daemon.state, sessionUrl.pathname); const session: PluginSessionInfo = { mode: created.session.mode, url: created.session.url, - port: sessionPort, + port: Number(sessionUrl.port), isRemote: daemon.state.isRemote, }; if (created.session.remoteShare) { @@ -708,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 5b8de3646..c4c565693 100644 --- a/bun.lock +++ b/bun.lock @@ -73,17 +73,20 @@ "version": "0.0.1", "dependencies": { "@fontsource-variable/geist-mono": "^5.2.7", + "@fontsource-variable/instrument-sans": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", "@plannotator/code-review": "workspace:*", "@plannotator/plan-review": "workspace:*", "@plannotator/shared": "workspace:*", "@plannotator/ui": "workspace:*", "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-router": "^1.141.0", "class-variance-authority": "^0.7.1", @@ -280,6 +283,7 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "tailwindcss": "^4.1.18", + "zustand": "^5.0.13", }, }, "packages/plannotator-plan-review": { @@ -648,6 +652,8 @@ "@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="], + "@fontsource-variable/instrument-sans": ["@fontsource-variable/instrument-sans@5.2.8", "", {}, "sha512-mTCaukbdIjjoipj2E3Q5XoZM3ZxJWdzyHevf/LG/0PHlfF9Q85pxOM7B7A9MerFyxmRzz5kVlumgIvgDSG4CPg=="], + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], "@google/genai": ["@google/genai@1.42.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-+3nlMTcrQufbQ8IumGkOphxD5Pd5kKyJOzLcnY0/1IuE8upJk5aLmoexZ2BJhBp1zAjRJMEB4a2CJwKI9e2EYw=="], @@ -992,6 +998,8 @@ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], diff --git a/goals/frontend-session-lifecycle/backlog.md b/goals/frontend-session-lifecycle/backlog.md new file mode 100644 index 000000000..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 `