aboutsummaryrefslogtreecommitdiffstats
path: root/renderer/src/components
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-03-04 21:21:22 -0800
committerbndw <ben@bdw.to>2026-03-04 21:21:22 -0800
commitb6405dd6a4ba65fc5dc6746db7be7be7d0bb29f3 (patch)
tree7d04268e9adfe9a6a83029556ef0dd5f72a63d42 /renderer/src/components
parentead65fd7d50ead785f437cc895c74146bd232702 (diff)
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
Diffstat (limited to 'renderer/src/components')
-rw-r--r--renderer/src/components/ChatPane.tsx4
-rw-r--r--renderer/src/components/Header.tsx172
-rw-r--r--renderer/src/components/Sidebar.tsx231
3 files changed, 254 insertions, 153 deletions
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({
45 style={{ width: collapsed ? 28 : chatWidth }} 45 style={{ width: collapsed ? 28 : chatWidth }}
46 > 46 >
47 <div className="chat-header"> 47 <div className="chat-header">
48 {!collapsed && <span>Chat</span>}
49 <button className="chat-collapse-btn" onClick={onToggleCollapse}> 48 <button className="chat-collapse-btn" onClick={onToggleCollapse}>
50 {collapsed ? "⟨" : "⟩"} 49 {collapsed ? "β—€" : "β–·"}
51 </button> 50 </button>
51 {!collapsed && <span>Chat</span>}
52 </div> 52 </div>
53 53
54 {!collapsed && ( 54 {!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 @@
1import React, { useState, useEffect } from "react"; 1import React, { useState, useEffect } from "react";
2import type { Project, Session, Phase } from "../types"; 2import type { Session, Phase } from "../types";
3import { formatSessionLabel } from "../utils/timeFormat";
4 3
5const api = window.api; 4const api = window.api;
6 5
7type Theme = "dark" | "light"; 6type Theme = "dark" | "light";
8 7
9interface HeaderProps { 8interface HeaderProps {
10 projects: Project[];
11 sessions: Session[];
12 selectedProject: Project | null;
13 selectedSession: Session | null; 9 selectedSession: Session | null;
14 onSelectProject: (project: Project | null) => void;
15 onSelectSession: (session: Session | null) => void;
16 onCreateProject: () => void;
17 onCreateSession: () => void;
18 onDeleteProject?: (id: string) => void;
19 onDeleteSession?: (id: string) => void;
20 onRenameSession?: (id: string, name: string) => void;
21 theme: Theme; 10 theme: Theme;
22 onToggleTheme: () => void; 11 onToggleTheme: () => void;
23 gitBranch: string | null; 12 gitBranch: string | null;
24 onOpenSettings: () => void; 13 onOpenSettings: () => void;
14 viewPhase: Phase;
15 onViewPhase: (phase: Phase) => void;
25} 16}
26 17
27const phaseLabels: Record<Phase, string> = { 18const phaseLabels: Record<Phase, string> = {
@@ -33,69 +24,18 @@ const phaseLabels: Record<Phase, string> = {
33const phases: Phase[] = ["research", "plan", "implement"]; 24const phases: Phase[] = ["research", "plan", "implement"];
34 25
35export function Header({ 26export function Header({
36 projects,
37 sessions,
38 selectedProject,
39 selectedSession, 27 selectedSession,
40 onSelectProject,
41 onSelectSession,
42 onCreateProject,
43 onCreateSession,
44 onDeleteProject,
45 onDeleteSession,
46 onRenameSession,
47 theme, 28 theme,
48 onToggleTheme, 29 onToggleTheme,
49 gitBranch, 30 gitBranch,
50 onOpenSettings, 31 onOpenSettings,
32 viewPhase,
33 onViewPhase,
51}: HeaderProps) { 34}: HeaderProps) {
52 const handleDeleteProject = () => {
53 if (!selectedProject || !onDeleteProject) return;
54 if (confirm(`Delete project "${selectedProject.name}"? This cannot be undone.`)) {
55 onDeleteProject(selectedProject.id);
56 }
57 };
58
59 const handleDeleteSession = () => {
60 if (!selectedSession || !onDeleteSession) return;
61 if (confirm(`Delete session "${selectedSession.name}"? This cannot be undone.`)) {
62 onDeleteSession(selectedSession.id);
63 }
64 };
65
66 const [isRenamingSession, setIsRenamingSession] = useState(false);
67 const [renameValue, setRenameValue] = useState("");
68 // Guard against double-commit (onKeyDown Enter β†’ unmount β†’ onBlur)
69 const renameCommitted = React.useRef(false);
70
71 const startRename = () => {
72 if (!selectedSession) return;
73 renameCommitted.current = false;
74 setRenameValue(selectedSession.name);
75 setIsRenamingSession(true);
76 };
77
78 const commitRename = () => {
79 if (renameCommitted.current) return;
80 renameCommitted.current = true;
81 if (selectedSession && onRenameSession && renameValue.trim()) {
82 onRenameSession(selectedSession.id, renameValue.trim());
83 }
84 setIsRenamingSession(false);
85 };
86
87 const cancelRename = () => {
88 renameCommitted.current = true; // prevent blur from committing after cancel
89 setIsRenamingSession(false);
90 };
91
92 // ── Maximize ───────────────────────────────────────────────── 35 // ── Maximize ─────────────────────────────────────────────────
93 const [isMaximized, setIsMaximized] = useState(false); 36 const [isMaximized, setIsMaximized] = useState(false);
94 37
95 useEffect(() => { 38 useEffect(() => {
96 // Returns the unsubscribe function; React cleanup calls it on unmount.
97 // On macOS, clicking the native green traffic light also fires this,
98 // keeping the glyph accurate when native controls are used.
99 return api.onWindowMaximized(setIsMaximized); 39 return api.onWindowMaximized(setIsMaximized);
100 }, []); 40 }, []);
101 41
@@ -112,89 +52,7 @@ export function Header({
112 return ( 52 return (
113 <header className="header"> 53 <header className="header">
114 <div className="header-left"> 54 <div className="header-left">
115 {/* ── Wordmark ── */}
116 <span className="app-wordmark">Claude Flow</span> 55 <span className="app-wordmark">Claude Flow</span>
117
118 <select
119 value={selectedProject?.id || ""}
120 onChange={(e) => {
121 const project = projects.find((p) => p.id === e.target.value);
122 onSelectProject(project || null);
123 onSelectSession(null);
124 }}
125 >
126 <option value="">Select Project...</option>
127 {projects.map((p) => (
128 <option key={p.id} value={p.id}>
129 {p.name}
130 </option>
131 ))}
132 </select>
133 <button onClick={onCreateProject}>+ Project</button>
134 {selectedProject && onDeleteProject && (
135 <button
136 onClick={handleDeleteProject}
137 className="btn-delete"
138 title="Delete project"
139 >
140 πŸ—‘οΈ
141 </button>
142 )}
143
144 {selectedProject && (
145 <>
146 {isRenamingSession ? (
147 <input
148 autoFocus
149 value={renameValue}
150 onChange={(e) => setRenameValue(e.target.value)}
151 onKeyDown={(e) => {
152 if (e.key === "Enter") commitRename();
153 if (e.key === "Escape") cancelRename();
154 }}
155 onBlur={commitRename}
156 className="session-rename-input"
157 />
158 ) : (
159 <select
160 value={selectedSession?.id || ""}
161 onChange={(e) => {
162 const session = sessions.find((s) => s.id === e.target.value);
163 onSelectSession(session || null);
164 }}
165 >
166 <option value="">Select Session...</option>
167 {sessions.map((s) => (
168 <option key={s.id} value={s.id}>
169 {formatSessionLabel(s.name, s.updated_at)}
170 </option>
171 ))}
172 </select>
173 )}
174 <button onClick={onCreateSession}>+ Session</button>
175 {selectedSession &&
176 onRenameSession &&
177 !isRenamingSession &&
178 selectedSession.phase !== "implement" && (
179 <button
180 onClick={startRename}
181 className="btn-rename"
182 title="Rename session"
183 >
184 ✏️
185 </button>
186 )}
187 {selectedSession && onDeleteSession && (
188 <button
189 onClick={handleDeleteSession}
190 className="btn-delete"
191 title="Delete session"
192 >
193 πŸ—‘οΈ
194 </button>
195 )}
196 </>
197 )}
198 </div> 56 </div>
199 57
200 <div className="header-right"> 58 <div className="header-right">
@@ -205,15 +63,27 @@ export function Header({
205 const currentIndex = phases.indexOf(selectedSession.phase); 63 const currentIndex = phases.indexOf(selectedSession.phase);
206 const isComplete = phaseIndex < currentIndex; 64 const isComplete = phaseIndex < currentIndex;
207 const isActive = phase === selectedSession.phase; 65 const isActive = phase === selectedSession.phase;
66 const isReachable = phaseIndex <= currentIndex;
67 const isViewing = phase === viewPhase && !isActive;
208 68
209 return ( 69 return isReachable ? (
210 <span 70 <button
211 key={phase} 71 key={phase}
212 className={`phase-step ${isActive ? "active" : ""} ${ 72 className={`phase-step ${isActive ? "active" : ""} ${
213 isComplete ? "complete" : "" 73 isComplete ? "complete" : ""
214 }`} 74 } ${isViewing ? "viewing" : ""}`}
75 onClick={() => onViewPhase(phase)}
76 title={
77 isActive
78 ? `Viewing ${phaseLabels[phase]} (current)`
79 : `View ${phaseLabels[phase]} artifact`
80 }
215 > 81 >
216 {phaseLabels[phase]} 82 {phaseLabels[phase]}
83 </button>
84 ) : (
85 <span key={phase} className="phase-step">
86 {phaseLabels[phase]}
217 </span> 87 </span>
218 ); 88 );
219 })} 89 })}
@@ -244,7 +114,7 @@ export function Header({
244 onClick={() => api.toggleMaximize()} 114 onClick={() => api.toggleMaximize()}
245 title={isMaximized ? "Restore window" : "Maximize window"} 115 title={isMaximized ? "Restore window" : "Maximize window"}
246 > 116 >
247 {isMaximized ? '⊑' : 'β–‘'} 117 {isMaximized ? "⊑" : "β–‘"}
248 </button> 118 </button>
249 119
250 {/* ── Settings button ── */} 120 {/* ── 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 @@
1import React, { useState, useRef } from "react";
2import type { Project, Session } from "../types";
3import { formatRelativeTime } from "../utils/timeFormat";
4
5interface SidebarProps {
6 projects: Project[];
7 sessions: Session[];
8 selectedProject: Project | null;
9 selectedSession: Session | null;
10 onSelectProject: (project: Project) => void;
11 onSelectSession: (session: Session | null) => void;
12 onCreateProject: () => void;
13 onCreateSession: (projectId: string) => void;
14 onDeleteProject: (id: string) => void;
15 onDeleteSession: (id: string) => void;
16 onRenameSession: (id: string, name: string) => void;
17 loadingBySession: Record<string, boolean>;
18 width: number;
19 collapsed: boolean;
20 onCollapsedChange: (collapsed: boolean) => void;
21}
22
23export function Sidebar({
24 projects,
25 sessions,
26 selectedProject,
27 selectedSession,
28 onSelectProject,
29 onSelectSession,
30 onCreateProject,
31 onCreateSession,
32 onDeleteProject,
33 onDeleteSession,
34 onRenameSession,
35 loadingBySession,
36 width,
37 collapsed,
38 onCollapsedChange,
39}: SidebarProps) {
40 const [renamingSessionId, setRenamingSessionId] = useState<string | null>(null);
41 const [renameValue, setRenameValue] = useState("");
42 // Guard against double-commit (onKeyDown Enter β†’ unmount β†’ onBlur)
43 const renameCommitted = useRef(false);
44
45 const startRename = (session: Session) => {
46 renameCommitted.current = false;
47 setRenameValue(session.name);
48 setRenamingSessionId(session.id);
49 };
50
51 const commitRename = (sessionId: string) => {
52 if (renameCommitted.current) return;
53 renameCommitted.current = true;
54 const trimmed = renameValue.trim();
55 if (trimmed) onRenameSession(sessionId, trimmed);
56 setRenamingSessionId(null);
57 };
58
59 const cancelRename = () => {
60 renameCommitted.current = true;
61 setRenamingSessionId(null);
62 };
63
64 const handleDeleteProject = (project: Project) => {
65 if (confirm(`Delete project "${project.name}"? This cannot be undone.`)) {
66 onDeleteProject(project.id);
67 }
68 };
69
70 const handleDeleteSession = (session: Session) => {
71 if (confirm(`Delete session "${session.name}"? This cannot be undone.`)) {
72 onDeleteSession(session.id);
73 }
74 };
75
76 if (collapsed) {
77 return (
78 <div className="sidebar collapsed">
79 <button
80 className="sidebar-collapse-btn"
81 onClick={() => onCollapsedChange(false)}
82 title="Expand sidebar"
83 >
84 β–Ά
85 </button>
86 </div>
87 );
88 }
89
90 return (
91 <div className="sidebar" style={{ width }}>
92 {/* ── Header ── */}
93 <div className="sidebar-header">
94 <span className="sidebar-title">Projects</span>
95 <div className="sidebar-header-actions">
96 <button
97 className="sidebar-action-btn"
98 onClick={onCreateProject}
99 title="Add project"
100 >
101 +
102 </button>
103 <button
104 className="sidebar-collapse-btn"
105 onClick={() => onCollapsedChange(true)}
106 title="Collapse sidebar"
107 >
108 ◁
109 </button>
110 </div>
111 </div>
112
113 {/* ── Tree ── */}
114 <div className="sidebar-tree">
115 {projects.length === 0 && (
116 <div className="sidebar-empty">No projects yet</div>
117 )}
118
119 {projects.map((project) => {
120 const projectSessions = sessions.filter(
121 (s) => s.project_id === project.id
122 );
123 const isSelectedProject = selectedProject?.id === project.id;
124
125 return (
126 <React.Fragment key={project.id}>
127 {/* Project row */}
128 <div
129 className={`project-item${isSelectedProject ? " selected" : ""}`}
130 >
131 <span
132 className="project-name"
133 onClick={() => {
134 onSelectProject(project);
135 onSelectSession(null);
136 }}
137 title={project.path}
138 >
139 {project.name}
140 </span>
141 <div className="item-controls">
142 <button
143 className="item-btn"
144 onClick={() => onCreateSession(project.id)}
145 title="New session"
146 >
147 +
148 </button>
149 <button
150 className="item-btn item-btn-danger"
151 onClick={() => handleDeleteProject(project)}
152 title="Delete project"
153 >
154 Γ—
155 </button>
156 </div>
157 </div>
158
159 {/* Empty sessions hint (only under selected project) */}
160 {projectSessions.length === 0 && isSelectedProject && (
161 <div className="sidebar-empty session-empty">
162 No sessions yet
163 </div>
164 )}
165
166 {/* Session rows */}
167 {projectSessions.map((session) => {
168 const isSelected = selectedSession?.id === session.id;
169 const isLoading = loadingBySession[session.id] ?? false;
170 const isRenaming = renamingSessionId === session.id;
171
172 return (
173 <div
174 key={session.id}
175 className={`session-item${isSelected ? " selected" : ""}`}
176 >
177 {isRenaming ? (
178 <input
179 className="session-rename-input"
180 autoFocus
181 value={renameValue}
182 onChange={(e) => setRenameValue(e.target.value)}
183 onKeyDown={(e) => {
184 if (e.key === "Enter") commitRename(session.id);
185 if (e.key === "Escape") cancelRename();
186 }}
187 onBlur={() => commitRename(session.id)}
188 />
189 ) : (
190 <>
191 <span
192 className="session-name"
193 onClick={() => onSelectSession(session)}
194 title={`${session.name} Β· ${session.phase} Β· ${formatRelativeTime(session.updated_at)}`}
195 >
196 {session.name}
197 {isLoading && (
198 <span
199 className="session-activity-dot"
200 title="Thinking…"
201 />
202 )}
203 </span>
204 <div className="item-controls">
205 <button
206 className="item-btn"
207 onClick={() => startRename(session)}
208 title="Rename session"
209 >
210 ✏️
211 </button>
212 <button
213 className="item-btn item-btn-danger"
214 onClick={() => handleDeleteSession(session)}
215 title="Delete session"
216 >
217 Γ—
218 </button>
219 </div>
220 </>
221 )}
222 </div>
223 );
224 })}
225 </React.Fragment>
226 );
227 })}
228 </div>
229 </div>
230 );
231}