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 --- renderer/src/App.tsx | 347 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 227 insertions(+), 120 deletions(-) (limited to 'renderer/src/App.tsx') 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 && ( -- cgit v1.2.3