From b6405dd6a4ba65fc5dc6746db7be7be7d0bb29f3 Mon Sep 17 00:00:00 2001
From: bndw <ben@bdw.to>
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<Message[]>([]);
   const [documentContent, setDocumentContent] = useState<string>("");
   const [originalContent, setOriginalContent] = useState<string>("");
-  const [isLoading, setIsLoading] = useState(false);
-  const [activityStatus, setActivityStatus] = useState<string | null>(null);
+  // Per-session loading/activity state so switching sessions doesn't inherit
+  // another session's "Thinking" indicator.
+  const [loadingBySession, setLoadingBySession] = useState<Record<string, boolean>>({});
+  const [activityBySession, setActivityBySession] = useState<Record<string, string | null>>({});
+
+  const isLoading = selectedSession ? (loadingBySession[selectedSession.id] ?? false) : false;
+  const activityStatus = selectedSession ? (activityBySession[selectedSession.id] ?? null) : null;
+
   const [tokenUsage, setTokenUsage] = useState<TokenUsage>({
     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<Phase>(
+    (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>(
     () => Number(localStorage.getItem("cf-chat-width")) || 380
   );
   const [chatCollapsed, setChatCollapsed] = useState<boolean>(
     () => localStorage.getItem("cf-chat-collapsed") === "true"
   );
+  const [sidebarWidth, setSidebarWidth] = useState<number>(
+    () => Number(localStorage.getItem("cf-sidebar-width")) || 260
+  );
+  const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(
+    () => 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 (
     <div className="app">
       <Header
-        projects={projects}
-        sessions={sessions}
-        selectedProject={selectedProject}
         selectedSession={selectedSession}
-        onSelectProject={setSelectedProject}
-        onSelectSession={setSelectedSession}
-        onCreateProject={handleCreateProject}
-        onCreateSession={handleCreateSession}
-        onDeleteProject={handleDeleteProject}
-        onDeleteSession={handleDeleteSession}
-        onRenameSession={handleRenameSession}
         theme={theme}
         onToggleTheme={handleToggleTheme}
         gitBranch={selectedSession?.git_branch ?? null}
         onOpenSettings={() => setShowSettings(true)}
+        viewPhase={viewPhase}
+        onViewPhase={setViewPhase}
       />
 
-      <div className="main-content">
-        <DocumentPane
-          content={documentContent}
-          onChange={setDocumentContent}
-          phase={selectedSession?.phase || "research"}
-          disabled={!selectedSession || selectedSession.phase === "implement"}
-          showOnboarding={!selectedProject}
-          theme={theme}
+      <div className="main-layout">
+        <Sidebar
+          projects={projects}
+          sessions={sessions}
+          selectedProject={selectedProject}
+          selectedSession={selectedSession}
+          onSelectProject={(p) => {
+            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 && (
           <div
-            className="chat-resize-handle"
-            onMouseDown={handleResizeMouseDown}
+            className="sidebar-resize-handle"
+            onMouseDown={handleSidebarResizeMouseDown}
           />
         )}
 
-        <ChatPane
-          messages={messages}
-          onSend={handleSendMessage}
-          isLoading={isLoading}
-          disabled={!selectedSession}
-          placeholder={
-            selectedSession
-              ? `Chat with Claude (${selectedSession.phase})...`
-              : "Select a session to start"
-          }
-          collapsed={chatCollapsed}
-          chatWidth={chatWidth}
-          onToggleCollapse={() => setChatCollapsed((c) => !c)}
-          activityStatus={activityStatus}
-          onCancel={handleCancel}
-        />
+        <div className="main-content">
+          <DocumentPane
+            content={documentContent}
+            onChange={setDocumentContent}
+            phase={viewPhase}
+            disabled={
+              !selectedSession ||
+              isViewingHistorical ||
+              selectedSession.phase === "implement"
+            }
+            showOnboarding={!selectedProject}
+            theme={theme}
+          />
+
+          {!chatCollapsed && (
+            <div
+              className="chat-resize-handle"
+              onMouseDown={handleResizeMouseDown}
+            />
+          )}
+
+          <ChatPane
+            messages={messages}
+            onSend={handleSendMessage}
+            isLoading={isLoading}
+            disabled={!selectedSession}
+            placeholder={
+              selectedSession
+                ? `Chat with Claude (${selectedSession.phase})...`
+                : "Select a session to start"
+            }
+            collapsed={chatCollapsed}
+            chatWidth={chatWidth}
+            onToggleCollapse={() => setChatCollapsed((c) => !c)}
+            activityStatus={activityStatus}
+            onCancel={handleCancel}
+          />
+        </div>
       </div>
 
       {error && (
-- 
cgit v1.2.3

