From b6405dd6a4ba65fc5dc6746db7be7be7d0bb29f3 Mon Sep 17 00:00:00 2001 From: bndw Date: Wed, 4 Mar 2026 21:21:22 -0800 Subject: feat: replace header dropdowns with collapsible sidebar tree - Add Sidebar.tsx: project/session tree with inline rename, collapse/resize - App.tsx: load all sessions at startup, sync selectedProject on session click - Header.tsx: strip project/session UI, keep only right-side controls - globals.css: add .main-layout, sidebar, item, and activity-dot styles - Chat pane: move toggle button to left, use triangle icons matching sidebar --- CLAUDE.md | 85 +++++++++ renderer/src/App.tsx | 347 +++++++++++++++++++++++------------ renderer/src/components/ChatPane.tsx | 4 +- renderer/src/components/Header.tsx | 172 +++-------------- renderer/src/components/Sidebar.tsx | 231 +++++++++++++++++++++++ renderer/src/styles/globals.css | 337 ++++++++++++++++++++++++++++++++-- 6 files changed, 884 insertions(+), 292 deletions(-) create mode 100644 renderer/src/components/Sidebar.tsx diff --git a/CLAUDE.md b/CLAUDE.md index d2e968a..5d25656 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,8 @@ renderer/src/ # React UI ### Phase System Phases: `research | plan | implement`. Each defined in `src/main/claude/phases.ts` with its own `systemPrompt`, `tools[]`, `permissionMode`, and `initialMessage`. Phase progression is one-way; triggered by user clicking "Submit". +**Important**: All new sessions currently start at `research` phase by default (hardcoded in `sessions.createSession()`). If building phase selection UI, modify `createSession()` in both `/db/sessions.ts` and the IPC handler at `/ipc/handlers.ts` to accept an optional `phase` parameter. + ### Artifact Storage Session artifacts (`research.md`, `plan.md`) stored inside the target project at `.claude-flow/sessions/{sessionId}/`. This keeps them within the SDK's allowed write boundary (project `cwd`). Add `.claude-flow/` to `.gitignore` to exclude from version control. @@ -54,6 +56,77 @@ Schema migrations: `db/index.ts::getDb()` calls `initSchema()` which uses `CREAT `allowedTools: string[]` in the SDK maps to Claude Code's `--allowedTools` CLI flag and supports patterns like `'Bash(git *)'` to auto-allow only specific Bash command forms. +## UI Architecture + +### Current Layout +``` +┌─────────────────────────────────────────────────────┐ +│ Header: Project selector, Session selector, Controls │ +├─────────────────────────────────────────────────────┤ +│ Main Content (flex row) │ +│ ├─ Document Pane (flex 1) │ +│ │ └─ Markdown editor/viewer │ +│ └─ Chat Pane (resizable, 380px default) │ +│ └─ Chat messages + input │ +├─────────────────────────────────────────────────────┤ +│ Action Bar: Review/Submit buttons, tokens, settings │ +└─────────────────────────────────────────────────────┘ +``` + +### Key Components +- **App.tsx**: Root container, manages projects/sessions/messages state, subscription to Claude messages +- **Header.tsx**: Project & session selection (dropdowns + buttons), phase indicator, theme toggle +- **DocumentPane.tsx**: CodeMirror markdown editor in edit mode, react-markdown renderer in view mode +- **ChatPane.tsx**: Message history, input field, collapsible (stored width in localStorage) +- **ActionBar.tsx**: Review button (research/plan phases), Submit button, token usage bar, permission mode toggle + +### State Management +App.tsx owns all state. Key state variables: +- `selectedProject` / `selectedSession` — current context +- `loadingBySession[sessionId]` — per-session loading flag (tracks thinking state) +- `activityBySession[sessionId]` — per-session activity status text (e.g., "Using Bash (5s)") +- `viewPhase` — which artifact to display (research/plan, defaults to current phase) +- `chatWidth` / `chatCollapsed` — layout preferences (persisted to localStorage) +- `theme` — "dark" or "light" (persisted to localStorage) + +### Per-Session Activity Tracking +App subscribes to `api.onClaudeMessage()` which broadcasts all Claude SDK messages to all sessions. App updates `loadingBySession` and `activityBySession` dictionaries to track which sessions are currently processing. This allows switching between projects/sessions without losing the thinking indicator state. + +### UI Patterns & Conventions + +#### Modal/Overlay Pattern +Full-page overlays (e.g., SettingsPage) use this pattern: +```tsx +
+
+ {/* Header with close button */} +
+
+ {/* Content */} +
+
+``` + +When building new modal UI: +- Use `.settings-overlay` (or similar) class for backdrop + positioning +- Include a close button with `className="settings-close"` +- Keep header style consistent with app header height/styling +- For simple modals (not full-page), consider a centered dialog box instead + +#### Form Pattern +Settings sections use input/select fields. Standard patterns: +- Label + input field pairs +- Button groups for related actions +- Consistent spacing via CSS grid/flex +- Validation feedback via inline text or error states + +#### List/Tree Pattern +Sidebar demonstrates tree structure for hierarchical data: +- Parent items with click handlers and action buttons +- Nested items with indent/visual hierarchy +- Inline edit mode for renaming (Rename modal not needed) +- Context awareness (expanded/collapsed states) + ## Important Notes - `ANTHROPIC_API_KEY` env var must be set before launching @@ -61,3 +134,15 @@ Schema migrations: `db/index.ts::getDb()` calls `initSchema()` which uses `CREAT - `bypassPermissions` mode is a user-controlled toggle in implement phase only - Token usage (from `SDKResultMessage.usage`) is displayed in the ActionBar - No git library in dependencies — use Node.js `child_process` (built-in) for git operations +- Session rename auto-triggers when research phase completes if session name is default "Session N" format (extracts first sentence from research.md) + +## Extensibility Notes for UI Features + +When adding new UI features that require user input: + +1. **Modal dialogs**: Follow the SettingsPage pattern (full-page overlay with header/body) +2. **Inline editing**: Use sidebar pattern (inline input that commits on blur/Enter) +3. **Phase selection**: Phase column in DB already exists and accepts any value—no schema changes needed to support starting at different phases +4. **Settings additions**: Add to `SettingsPage.tsx` with a new section and corresponding settings UI file in `/components/settings/` +5. **IPC endpoints**: Register in `/src/main/ipc/handlers.ts` and expose in `/src/main/preload.ts` +6. **State management**: Keep state in `App.tsx` for global UI state; component local state for transient UI state (e.g., modal visibility, form input) diff --git a/renderer/src/App.tsx b/renderer/src/App.tsx index b2cd168..719faac 100644 --- a/renderer/src/App.tsx +++ b/renderer/src/App.tsx @@ -1,5 +1,6 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Header } from "./components/Header"; +import { Sidebar } from "./components/Sidebar"; import { DocumentPane } from "./components/DocumentPane"; import { ChatPane } from "./components/ChatPane"; import { ActionBar } from "./components/ActionBar"; @@ -51,8 +52,14 @@ export function App() { const [messages, setMessages] = useState([]); const [documentContent, setDocumentContent] = useState(""); const [originalContent, setOriginalContent] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [activityStatus, setActivityStatus] = useState(null); + // Per-session loading/activity state so switching sessions doesn't inherit + // another session's "Thinking" indicator. + const [loadingBySession, setLoadingBySession] = useState>({}); + const [activityBySession, setActivityBySession] = useState>({}); + + const isLoading = selectedSession ? (loadingBySession[selectedSession.id] ?? false) : false; + const activityStatus = selectedSession ? (activityBySession[selectedSession.id] ?? null) : null; + const [tokenUsage, setTokenUsage] = useState({ inputTokens: 0, outputTokens: 0, @@ -65,12 +72,42 @@ export function App() { () => (localStorage.getItem("cf-theme") as Theme) ?? "dark" ); + // Which phase's artifact is currently displayed in the DocumentPane. + // Defaults to the session's current phase; can be overridden to view + // a historical artifact (e.g. research.md while in implement phase). + const [viewPhase, setViewPhase] = useState( + (selectedSession?.phase ?? "research") as Phase + ); + + // Reset viewPhase whenever the active session or its phase changes. + useEffect(() => { + setViewPhase((selectedSession?.phase ?? "research") as Phase); + }, [selectedSession?.id, selectedSession?.phase]); + + const isViewingHistorical = + !!selectedSession && viewPhase !== (selectedSession.phase as Phase); + + // Refs so the global claude:message handler always sees the latest values + // without needing to re-subscribe when the selected session changes. + const selectedSessionRef = useRef(selectedSession); + const selectedProjectRef = useRef(selectedProject); + const viewPhaseRef = useRef(viewPhase); + useEffect(() => { selectedSessionRef.current = selectedSession; }, [selectedSession]); + useEffect(() => { selectedProjectRef.current = selectedProject; }, [selectedProject]); + useEffect(() => { viewPhaseRef.current = viewPhase; }, [viewPhase]); + const [chatWidth, setChatWidth] = useState( () => Number(localStorage.getItem("cf-chat-width")) || 380 ); const [chatCollapsed, setChatCollapsed] = useState( () => localStorage.getItem("cf-chat-collapsed") === "true" ); + const [sidebarWidth, setSidebarWidth] = useState( + () => Number(localStorage.getItem("cf-sidebar-width")) || 260 + ); + const [sidebarCollapsed, setSidebarCollapsed] = useState( + () => localStorage.getItem("cf-sidebar-collapsed") === "true" + ); // Keep document.documentElement in sync and persist to localStorage useEffect(() => { @@ -86,11 +123,20 @@ export function App() { localStorage.setItem("cf-chat-collapsed", String(chatCollapsed)); }, [chatCollapsed]); + useEffect(() => { + localStorage.setItem("cf-sidebar-width", String(sidebarWidth)); + }, [sidebarWidth]); + + useEffect(() => { + localStorage.setItem("cf-sidebar-collapsed", String(sidebarCollapsed)); + }, [sidebarCollapsed]); + const handleCancel = () => { if (!selectedSession) return; - api.interruptSession(selectedSession.id); - setIsLoading(false); - setActivityStatus(null); + const id = selectedSession.id; + api.interruptSession(id); + setLoadingBySession((prev) => ({ ...prev, [id]: false })); + setActivityBySession((prev) => ({ ...prev, [id]: null })); }; const handleToggleTheme = () => @@ -113,6 +159,23 @@ export function App() { document.addEventListener("mouseup", onUp); }; + const handleSidebarResizeMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + const startX = e.clientX; + const startWidth = sidebarWidth; + const onMove = (ev: MouseEvent) => { + const delta = ev.clientX - startX; // drag right = wider sidebar + const next = Math.max(180, Math.min(400, startWidth + delta)); + setSidebarWidth(next); + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; + const hasChanges = documentContent !== originalContent; // Clear error after 5 seconds @@ -128,9 +191,10 @@ export function App() { const handleKeyDown = (e: KeyboardEvent) => { // Escape to interrupt if (e.key === "Escape" && isLoading && selectedSession) { - api.interruptSession(selectedSession.id); - setIsLoading(false); - setActivityStatus(null); + const id = selectedSession.id; + api.interruptSession(id); + setLoadingBySession((prev) => ({ ...prev, [id]: false })); + setActivityBySession((prev) => ({ ...prev, [id]: null })); } // Cmd/Ctrl + Enter to submit if ( @@ -148,87 +212,93 @@ export function App() { return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedSession, isLoading]); - // Load projects and initial model setting on mount + // Load projects, all sessions, and initial model setting on mount. + // Sessions for every project are fetched in parallel so the sidebar tree + // can display all projects and their sessions immediately. useEffect(() => { - api.listProjects().then(setProjects); - // Seed the model badge from the DB so it shows before any query fires. - // system:init will overwrite this with the SDK-resolved name once a query runs. - api.getSettings(["model"]).then((s) => { + (async () => { + const loadedProjects = await api.listProjects(); + setProjects(loadedProjects); + const arrays = await Promise.all( + loadedProjects.map((p) => api.listSessions(p.id)) + ); + setSessions(arrays.flat()); + // Seed the model badge from the DB so it shows before any query fires. + // system:init will overwrite this with the SDK-resolved name once a query runs. + const s = await api.getSettings(["model"]); if (s["model"]) setActiveModel(s["model"]); - }); + })(); }, []); - // Load sessions when project changes + // Load messages when session changes (not affected by viewPhase). useEffect(() => { - if (selectedProject) { - api.listSessions(selectedProject.id).then(setSessions); + if (selectedSession) { + api.listMessages(selectedSession.id).then(setMessages); } else { - setSessions([]); + setMessages([]); } - }, [selectedProject]); + }, [selectedSession?.id]); - // Load messages and artifact when session changes + // Load the viewed artifact whenever the session or the viewed phase changes. useEffect(() => { if (selectedSession && selectedProject) { - // Load messages - api.listMessages(selectedSession.id).then(setMessages); - - // Load session-specific artifact (from ~/.claude-flow/) - const filename = - selectedSession.phase === "research" ? "research.md" : "plan.md"; + const filename = viewPhase === "research" ? "research.md" : "plan.md"; api.readSessionArtifact(selectedProject.id, selectedSession.id, filename).then((content) => { const text = content || ""; setDocumentContent(text); setOriginalContent(text); }); } else { - setMessages([]); setDocumentContent(""); setOriginalContent(""); } - }, [selectedSession?.id, selectedSession?.phase, selectedProject]); + }, [selectedSession?.id, selectedProject, viewPhase]); - // Subscribe to Claude messages + // Subscribe to Claude messages once globally. + // Loading/activity state is updated for ANY session (via per-session maps). + // Content state (messages, artifacts) is only updated for the currently + // selected session, using refs to avoid re-subscribing on every session switch. useEffect(() => { const unsubscribe = api.onClaudeMessage((sessionId, msg) => { - if (sessionId !== selectedSession?.id) return; - // ── Live activity indicator ────────────────────────────────────── - // Primary: tool_progress fires periodically while a tool is running if (msg.type === "tool_progress") { const elapsed = Math.round(msg.elapsed_time_seconds); - setActivityStatus( - elapsed > 0 - ? `Using ${msg.tool_name} (${elapsed}s)` - : `Using ${msg.tool_name}…` - ); + setActivityBySession((prev) => ({ + ...prev, + [sessionId]: + elapsed > 0 + ? `Using ${msg.tool_name} (${elapsed}s)` + : `Using ${msg.tool_name}…`, + })); } - // Secondary: task_progress carries last_tool_name for sub-agent tasks if ( msg.type === "system" && msg.subtype === "task_progress" && (msg as { last_tool_name?: string }).last_tool_name ) { - setActivityStatus( - `Using ${(msg as { last_tool_name: string }).last_tool_name}…` - ); + setActivityBySession((prev) => ({ + ...prev, + [sessionId]: `Using ${(msg as { last_tool_name: string }).last_tool_name}…`, + })); } // ── Model resolved by SDK ──────────────────────────────────────── - // SDKSystemMessage (subtype "init") contains the actual model in use. if (msg.type === "system" && msg.subtype === "init") { setActiveModel((msg as { model?: string }).model ?? null); } // ── Result (success or error) ──────────────────────────────────── - // Always clear loading state on any result subtype so error results - // don't leave the UI stuck in the loading/thinking state. + // Clear loading state for the session that completed, regardless of + // which session is currently selected. if (msg.type === "result") { - setIsLoading(false); - setActivityStatus(null); + setLoadingBySession((prev) => ({ ...prev, [sessionId]: false })); + setActivityBySession((prev) => ({ ...prev, [sessionId]: null })); - if (msg.subtype === "success") { + // Content updates only matter for the currently visible session. + const currentSession = selectedSessionRef.current; + const currentProject = selectedProjectRef.current; + if (sessionId === currentSession?.id && msg.subtype === "success") { if (msg.usage) { setTokenUsage({ inputTokens: msg.usage.input_tokens, @@ -236,20 +306,24 @@ export function App() { cacheHits: msg.usage.cache_read_input_tokens, }); } - // Reload artifact after Claude updates it - if (selectedProject && selectedSession) { + // Only reload the artifact if the user is viewing the current phase's + // document. If they're browsing a historical artifact, leave it alone; + // the effect tied to viewPhase will reload when they navigate back. + if ( + currentProject && + currentSession && + viewPhaseRef.current === currentSession.phase + ) { const filename = - selectedSession.phase === "research" ? "research.md" : "plan.md"; - // Capture for the async closure - const sessionAtTrigger = selectedSession; + currentSession.phase === "research" ? "research.md" : "plan.md"; + const sessionAtTrigger = currentSession; api - .readSessionArtifact(selectedProject.id, sessionAtTrigger.id, filename) + .readSessionArtifact(currentProject.id, sessionAtTrigger.id, filename) .then((content) => { const text = content || ""; setDocumentContent(text); setOriginalContent(text); - // Auto-name: only during research, only while name is still the default if ( sessionAtTrigger.phase === "research" && /^Session \d+$/.test(sessionAtTrigger.name) && @@ -266,7 +340,8 @@ export function App() { } // ── Assistant message ──────────────────────────────────────────── - if (msg.type === "assistant") { + // Only update the chat pane for the currently visible session. + if (msg.type === "assistant" && sessionId === selectedSessionRef.current?.id) { const content = ( msg.message.content as Array<{ type: string; text?: string }> ) @@ -278,10 +353,8 @@ export function App() { setMessages((prev) => { const last = prev[prev.length - 1]; if (last?.role === "assistant") { - // Update existing assistant message return [...prev.slice(0, -1), { ...last, content }]; } - // Add new assistant message return [ ...prev, { @@ -298,74 +371,74 @@ export function App() { }); return unsubscribe; - }, [selectedSession?.id, selectedSession?.phase, selectedProject]); + }, []); // Subscribe once — uses refs for current session/project const handleSendMessage = async (message: string) => { if (!selectedSession) return; - setIsLoading(true); + const id = selectedSession.id; + setLoadingBySession((prev) => ({ ...prev, [id]: true })); setError(null); setMessages((prev) => [ ...prev, { id: crypto.randomUUID(), - session_id: selectedSession.id, + session_id: id, role: "user", content: message, created_at: Date.now() / 1000, }, ]); try { - await api.sendMessage(selectedSession.id, message); + await api.sendMessage(id, message); } catch (err) { setError(err instanceof Error ? err.message : "Failed to send message"); - setIsLoading(false); + setLoadingBySession((prev) => ({ ...prev, [id]: false })); } }; const handleReview = async () => { - if (!selectedSession || !selectedProject) return; + if (!selectedSession || !selectedProject || isViewingHistorical) return; + const id = selectedSession.id; setError(null); try { - // Save user edits first (session-specific, stored in ~/.claude-flow/) const filename = selectedSession.phase === "research" ? "research.md" : "plan.md"; - await api.writeSessionArtifact(selectedProject.id, selectedSession.id, filename, documentContent); + await api.writeSessionArtifact(selectedProject.id, id, filename, documentContent); setOriginalContent(documentContent); - setIsLoading(true); - await api.triggerReview(selectedSession.id); + setLoadingBySession((prev) => ({ ...prev, [id]: true })); + await api.triggerReview(id); } catch (err) { setError(err instanceof Error ? err.message : "Review failed"); - setIsLoading(false); + setLoadingBySession((prev) => ({ ...prev, [id]: false })); } }; const handleSubmit = async () => { - if (!selectedSession || !selectedProject) return; + if (!selectedSession || !selectedProject || isViewingHistorical) return; + const id = selectedSession.id; setError(null); try { - // Save any pending edits (session-specific, stored in ~/.claude-flow/) const filename = selectedSession.phase === "research" ? "research.md" : "plan.md"; - await api.writeSessionArtifact(selectedProject.id, selectedSession.id, filename, documentContent); + await api.writeSessionArtifact(selectedProject.id, id, filename, documentContent); - const advanced = await api.advancePhase(selectedSession.id); + const advanced = await api.advancePhase(id); if (advanced) { setSelectedSession({ ...selectedSession, phase: advanced.phase, git_branch: advanced.git_branch, }); - // Trigger initial message for next phase - setIsLoading(true); + setLoadingBySession((prev) => ({ ...prev, [id]: true })); const initialMsg = advanced.phase === "plan" ? "Create a detailed implementation plan based on the research." : "Begin implementing the plan."; - await api.sendMessage(selectedSession.id, initialMsg); + await api.sendMessage(id, initialMsg); } } catch (err) { setError(err instanceof Error ? err.message : "Submit failed"); - setIsLoading(false); + setLoadingBySession((prev) => ({ ...prev, [id]: false })); } }; @@ -378,26 +451,28 @@ export function App() { setSelectedProject(project); }; - const handleCreateSession = async () => { - if (!selectedProject) return; - const name = `Session ${sessions.length + 1}`; - const session = await api.createSession(selectedProject.id, name); + const handleCreateSession = async (projectId: string) => { + const project = projects.find((p) => p.id === projectId); + if (!project) return; + const projectSessions = sessions.filter((s) => s.project_id === projectId); + const name = `Session ${projectSessions.length + 1}`; + const session = await api.createSession(projectId, name); setSessions((prev) => [session, ...prev]); + setSelectedProject(project); setSelectedSession(session); setMessages([]); setDocumentContent(""); setOriginalContent(""); - // Note: Each session has its own artifact directory, no need to clear }; const handleDeleteProject = async (id: string) => { try { await api.deleteProject(id); setProjects((prev) => prev.filter((p) => p.id !== id)); + setSessions((prev) => prev.filter((s) => s.project_id !== id)); if (selectedProject?.id === id) { setSelectedProject(null); setSelectedSession(null); - setSessions([]); setMessages([]); setDocumentContent(""); setOriginalContent(""); @@ -436,56 +511,88 @@ export function App() { return (
setShowSettings(true)} + viewPhase={viewPhase} + onViewPhase={setViewPhase} /> -
- + { + setSelectedProject(p); + setSelectedSession(null); + }} + onSelectSession={(session) => { + if (session) { + const project = projects.find((p) => p.id === session.project_id); + if (project) setSelectedProject(project); + } + setSelectedSession(session); + }} + onCreateProject={handleCreateProject} + onCreateSession={handleCreateSession} + onDeleteProject={handleDeleteProject} + onDeleteSession={handleDeleteSession} + onRenameSession={handleRenameSession} + loadingBySession={loadingBySession} + width={sidebarWidth} + collapsed={sidebarCollapsed} + onCollapsedChange={setSidebarCollapsed} /> - {!chatCollapsed && ( + {!sidebarCollapsed && (
)} - setChatCollapsed((c) => !c)} - activityStatus={activityStatus} - onCancel={handleCancel} - /> +
+ + + {!chatCollapsed && ( +
+ )} + + setChatCollapsed((c) => !c)} + activityStatus={activityStatus} + onCancel={handleCancel} + /> +
{error && ( diff --git a/renderer/src/components/ChatPane.tsx b/renderer/src/components/ChatPane.tsx index c2cdfbb..4136d16 100644 --- a/renderer/src/components/ChatPane.tsx +++ b/renderer/src/components/ChatPane.tsx @@ -45,10 +45,10 @@ export function ChatPane({ style={{ width: collapsed ? 28 : chatWidth }} >
- {!collapsed && Chat} + {!collapsed && Chat}
{!collapsed && ( diff --git a/renderer/src/components/Header.tsx b/renderer/src/components/Header.tsx index dc88a73..4e193e8 100644 --- a/renderer/src/components/Header.tsx +++ b/renderer/src/components/Header.tsx @@ -1,27 +1,18 @@ import React, { useState, useEffect } from "react"; -import type { Project, Session, Phase } from "../types"; -import { formatSessionLabel } from "../utils/timeFormat"; +import type { Session, Phase } from "../types"; const api = window.api; type Theme = "dark" | "light"; interface HeaderProps { - projects: Project[]; - sessions: Session[]; - selectedProject: Project | null; selectedSession: Session | null; - onSelectProject: (project: Project | null) => void; - onSelectSession: (session: Session | null) => void; - onCreateProject: () => void; - onCreateSession: () => void; - onDeleteProject?: (id: string) => void; - onDeleteSession?: (id: string) => void; - onRenameSession?: (id: string, name: string) => void; theme: Theme; onToggleTheme: () => void; gitBranch: string | null; onOpenSettings: () => void; + viewPhase: Phase; + onViewPhase: (phase: Phase) => void; } const phaseLabels: Record = { @@ -33,69 +24,18 @@ const phaseLabels: Record = { const phases: Phase[] = ["research", "plan", "implement"]; export function Header({ - projects, - sessions, - selectedProject, selectedSession, - onSelectProject, - onSelectSession, - onCreateProject, - onCreateSession, - onDeleteProject, - onDeleteSession, - onRenameSession, theme, onToggleTheme, gitBranch, onOpenSettings, + viewPhase, + onViewPhase, }: HeaderProps) { - const handleDeleteProject = () => { - if (!selectedProject || !onDeleteProject) return; - if (confirm(`Delete project "${selectedProject.name}"? This cannot be undone.`)) { - onDeleteProject(selectedProject.id); - } - }; - - const handleDeleteSession = () => { - if (!selectedSession || !onDeleteSession) return; - if (confirm(`Delete session "${selectedSession.name}"? This cannot be undone.`)) { - onDeleteSession(selectedSession.id); - } - }; - - const [isRenamingSession, setIsRenamingSession] = useState(false); - const [renameValue, setRenameValue] = useState(""); - // Guard against double-commit (onKeyDown Enter → unmount → onBlur) - const renameCommitted = React.useRef(false); - - const startRename = () => { - if (!selectedSession) return; - renameCommitted.current = false; - setRenameValue(selectedSession.name); - setIsRenamingSession(true); - }; - - const commitRename = () => { - if (renameCommitted.current) return; - renameCommitted.current = true; - if (selectedSession && onRenameSession && renameValue.trim()) { - onRenameSession(selectedSession.id, renameValue.trim()); - } - setIsRenamingSession(false); - }; - - const cancelRename = () => { - renameCommitted.current = true; // prevent blur from committing after cancel - setIsRenamingSession(false); - }; - // ── Maximize ───────────────────────────────────────────────── const [isMaximized, setIsMaximized] = useState(false); useEffect(() => { - // Returns the unsubscribe function; React cleanup calls it on unmount. - // On macOS, clicking the native green traffic light also fires this, - // keeping the glyph accurate when native controls are used. return api.onWindowMaximized(setIsMaximized); }, []); @@ -112,89 +52,7 @@ export function Header({ return (
- {/* ── Wordmark ── */} Claude Flow - - - - {selectedProject && onDeleteProject && ( - - )} - - {selectedProject && ( - <> - {isRenamingSession ? ( - setRenameValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") commitRename(); - if (e.key === "Escape") cancelRename(); - }} - onBlur={commitRename} - className="session-rename-input" - /> - ) : ( - - )} - - {selectedSession && - onRenameSession && - !isRenamingSession && - selectedSession.phase !== "implement" && ( - - )} - {selectedSession && onDeleteSession && ( - - )} - - )}
@@ -205,15 +63,27 @@ export function Header({ const currentIndex = phases.indexOf(selectedSession.phase); const isComplete = phaseIndex < currentIndex; const isActive = phase === selectedSession.phase; + const isReachable = phaseIndex <= currentIndex; + const isViewing = phase === viewPhase && !isActive; - return ( - onViewPhase(phase)} + title={ + isActive + ? `Viewing ${phaseLabels[phase]} (current)` + : `View ${phaseLabels[phase]} artifact` + } > {phaseLabels[phase]} + + ) : ( + + {phaseLabels[phase]} ); })} @@ -244,7 +114,7 @@ export function Header({ onClick={() => api.toggleMaximize()} title={isMaximized ? "Restore window" : "Maximize window"} > - {isMaximized ? '⊡' : '□'} + {isMaximized ? "⊡" : "□"} {/* ── Settings button ── */} diff --git a/renderer/src/components/Sidebar.tsx b/renderer/src/components/Sidebar.tsx new file mode 100644 index 0000000..567e731 --- /dev/null +++ b/renderer/src/components/Sidebar.tsx @@ -0,0 +1,231 @@ +import React, { useState, useRef } from "react"; +import type { Project, Session } from "../types"; +import { formatRelativeTime } from "../utils/timeFormat"; + +interface SidebarProps { + projects: Project[]; + sessions: Session[]; + selectedProject: Project | null; + selectedSession: Session | null; + onSelectProject: (project: Project) => void; + onSelectSession: (session: Session | null) => void; + onCreateProject: () => void; + onCreateSession: (projectId: string) => void; + onDeleteProject: (id: string) => void; + onDeleteSession: (id: string) => void; + onRenameSession: (id: string, name: string) => void; + loadingBySession: Record; + width: number; + collapsed: boolean; + onCollapsedChange: (collapsed: boolean) => void; +} + +export function Sidebar({ + projects, + sessions, + selectedProject, + selectedSession, + onSelectProject, + onSelectSession, + onCreateProject, + onCreateSession, + onDeleteProject, + onDeleteSession, + onRenameSession, + loadingBySession, + width, + collapsed, + onCollapsedChange, +}: SidebarProps) { + const [renamingSessionId, setRenamingSessionId] = useState(null); + const [renameValue, setRenameValue] = useState(""); + // Guard against double-commit (onKeyDown Enter → unmount → onBlur) + const renameCommitted = useRef(false); + + const startRename = (session: Session) => { + renameCommitted.current = false; + setRenameValue(session.name); + setRenamingSessionId(session.id); + }; + + const commitRename = (sessionId: string) => { + if (renameCommitted.current) return; + renameCommitted.current = true; + const trimmed = renameValue.trim(); + if (trimmed) onRenameSession(sessionId, trimmed); + setRenamingSessionId(null); + }; + + const cancelRename = () => { + renameCommitted.current = true; + setRenamingSessionId(null); + }; + + const handleDeleteProject = (project: Project) => { + if (confirm(`Delete project "${project.name}"? This cannot be undone.`)) { + onDeleteProject(project.id); + } + }; + + const handleDeleteSession = (session: Session) => { + if (confirm(`Delete session "${session.name}"? This cannot be undone.`)) { + onDeleteSession(session.id); + } + }; + + if (collapsed) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* ── Header ── */} +
+ Projects +
+ + +
+
+ + {/* ── Tree ── */} +
+ {projects.length === 0 && ( +
No projects yet
+ )} + + {projects.map((project) => { + const projectSessions = sessions.filter( + (s) => s.project_id === project.id + ); + const isSelectedProject = selectedProject?.id === project.id; + + return ( + + {/* Project row */} +
+ { + onSelectProject(project); + onSelectSession(null); + }} + title={project.path} + > + {project.name} + +
+ + +
+
+ + {/* Empty sessions hint (only under selected project) */} + {projectSessions.length === 0 && isSelectedProject && ( +
+ No sessions yet +
+ )} + + {/* Session rows */} + {projectSessions.map((session) => { + const isSelected = selectedSession?.id === session.id; + const isLoading = loadingBySession[session.id] ?? false; + const isRenaming = renamingSessionId === session.id; + + return ( +
+ {isRenaming ? ( + setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commitRename(session.id); + if (e.key === "Escape") cancelRename(); + }} + onBlur={() => commitRename(session.id)} + /> + ) : ( + <> + onSelectSession(session)} + title={`${session.name} · ${session.phase} · ${formatRelativeTime(session.updated_at)}`} + > + {session.name} + {isLoading && ( + + )} + +
+ + +
+ + )} +
+ ); + })} +
+ ); + })} +
+
+ ); +} diff --git a/renderer/src/styles/globals.css b/renderer/src/styles/globals.css index 20275ae..c463432 100644 --- a/renderer/src/styles/globals.css +++ b/renderer/src/styles/globals.css @@ -122,23 +122,6 @@ body { background: var(--bg-tertiary); } -.session-rename-input { - padding: 5px 10px; - background: var(--bg-tertiary); - border: 1px solid var(--accent); - border-radius: 2px; - color: var(--text-primary); - font-size: 12px; - font-family: inherit; - min-width: 140px; - outline: none; - box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2); -} - -html[data-theme="light"] .session-rename-input { - box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2); -} - /* Theme toggle */ .theme-toggle { font-size: 11px; @@ -167,6 +150,17 @@ html[data-theme="light"] .session-rename-input { border-radius: 2px; background: var(--bg-tertiary); color: var(--text-secondary); + border: none; + font-family: inherit; +} + +/* Reachable phases rendered as