aboutsummaryrefslogtreecommitdiffstats
path: root/renderer/src/components/Sidebar.tsx
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/Sidebar.tsx
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/Sidebar.tsx')
-rw-r--r--renderer/src/components/Sidebar.tsx231
1 files changed, 231 insertions, 0 deletions
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}