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/components/ChatPane.tsx | 4 +- renderer/src/components/Header.tsx | 172 ++++---------------------- renderer/src/components/Sidebar.tsx | 231 +++++++++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+), 153 deletions(-) create mode 100644 renderer/src/components/Sidebar.tsx (limited to 'renderer/src/components') 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 && ( + + )} + +
+ + +
+ + )} +
+ ); + })} +
+ ); + })} +
+
+ ); +} -- cgit v1.2.3