aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md85
-rw-r--r--renderer/src/App.tsx347
-rw-r--r--renderer/src/components/ChatPane.tsx4
-rw-r--r--renderer/src/components/Header.tsx172
-rw-r--r--renderer/src/components/Sidebar.tsx231
-rw-r--r--renderer/src/styles/globals.css337
6 files changed, 884 insertions, 292 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index d2e968a..5d25656 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -33,6 +33,8 @@ renderer/src/ # React UI
33### Phase System 33### Phase System
34Phases: `research | plan | implement`. Each defined in `src/main/claude/phases.ts` with its own `systemPrompt`, `tools[]`, `permissionMode`, and `initialMessage`. Phase progression is one-way; triggered by user clicking "Submit". 34Phases: `research | plan | implement`. Each defined in `src/main/claude/phases.ts` with its own `systemPrompt`, `tools[]`, `permissionMode`, and `initialMessage`. Phase progression is one-way; triggered by user clicking "Submit".
35 35
36**Important**: All new sessions currently start at `research` phase by default (hardcoded in `sessions.createSession()`). If building phase selection UI, modify `createSession()` in both `/db/sessions.ts` and the IPC handler at `/ipc/handlers.ts` to accept an optional `phase` parameter.
37
36### Artifact Storage 38### Artifact Storage
37Session artifacts (`research.md`, `plan.md`) stored inside the target project at `.claude-flow/sessions/{sessionId}/`. This keeps them within the SDK's allowed write boundary (project `cwd`). Add `.claude-flow/` to `.gitignore` to exclude from version control. 39Session artifacts (`research.md`, `plan.md`) stored inside the target project at `.claude-flow/sessions/{sessionId}/`. This keeps them within the SDK's allowed write boundary (project `cwd`). Add `.claude-flow/` to `.gitignore` to exclude from version control.
38 40
@@ -54,6 +56,77 @@ Schema migrations: `db/index.ts::getDb()` calls `initSchema()` which uses `CREAT
54 56
55`allowedTools: string[]` in the SDK maps to Claude Code's `--allowedTools` CLI flag and supports patterns like `'Bash(git *)'` to auto-allow only specific Bash command forms. 57`allowedTools: string[]` in the SDK maps to Claude Code's `--allowedTools` CLI flag and supports patterns like `'Bash(git *)'` to auto-allow only specific Bash command forms.
56 58
59## UI Architecture
60
61### Current Layout
62```
63┌─────────────────────────────────────────────────────┐
64│ Header: Project selector, Session selector, Controls │
65├─────────────────────────────────────────────────────┤
66│ Main Content (flex row) │
67│ ├─ Document Pane (flex 1) │
68│ │ └─ Markdown editor/viewer │
69│ └─ Chat Pane (resizable, 380px default) │
70│ └─ Chat messages + input │
71├─────────────────────────────────────────────────────┤
72│ Action Bar: Review/Submit buttons, tokens, settings │
73└─────────────────────────────────────────────────────┘
74```
75
76### Key Components
77- **App.tsx**: Root container, manages projects/sessions/messages state, subscription to Claude messages
78- **Header.tsx**: Project & session selection (dropdowns + buttons), phase indicator, theme toggle
79- **DocumentPane.tsx**: CodeMirror markdown editor in edit mode, react-markdown renderer in view mode
80- **ChatPane.tsx**: Message history, input field, collapsible (stored width in localStorage)
81- **ActionBar.tsx**: Review button (research/plan phases), Submit button, token usage bar, permission mode toggle
82
83### State Management
84App.tsx owns all state. Key state variables:
85- `selectedProject` / `selectedSession` — current context
86- `loadingBySession[sessionId]` — per-session loading flag (tracks thinking state)
87- `activityBySession[sessionId]` — per-session activity status text (e.g., "Using Bash (5s)")
88- `viewPhase` — which artifact to display (research/plan, defaults to current phase)
89- `chatWidth` / `chatCollapsed` — layout preferences (persisted to localStorage)
90- `theme` — "dark" or "light" (persisted to localStorage)
91
92### Per-Session Activity Tracking
93App subscribes to `api.onClaudeMessage()` which broadcasts all Claude SDK messages to all sessions. App updates `loadingBySession` and `activityBySession` dictionaries to track which sessions are currently processing. This allows switching between projects/sessions without losing the thinking indicator state.
94
95### UI Patterns & Conventions
96
97#### Modal/Overlay Pattern
98Full-page overlays (e.g., SettingsPage) use this pattern:
99```tsx
100<div className="settings-overlay">
101 <div className="settings-header">
102 {/* Header with close button */}
103 </div>
104 <div className="settings-body">
105 {/* Content */}
106 </div>
107</div>
108```
109
110When building new modal UI:
111- Use `.settings-overlay` (or similar) class for backdrop + positioning
112- Include a close button with `className="settings-close"`
113- Keep header style consistent with app header height/styling
114- For simple modals (not full-page), consider a centered dialog box instead
115
116#### Form Pattern
117Settings sections use input/select fields. Standard patterns:
118- Label + input field pairs
119- Button groups for related actions
120- Consistent spacing via CSS grid/flex
121- Validation feedback via inline text or error states
122
123#### List/Tree Pattern
124Sidebar demonstrates tree structure for hierarchical data:
125- Parent items with click handlers and action buttons
126- Nested items with indent/visual hierarchy
127- Inline edit mode for renaming (Rename modal not needed)
128- Context awareness (expanded/collapsed states)
129
57## Important Notes 130## Important Notes
58 131
59- `ANTHROPIC_API_KEY` env var must be set before launching 132- `ANTHROPIC_API_KEY` env var must be set before launching
@@ -61,3 +134,15 @@ Schema migrations: `db/index.ts::getDb()` calls `initSchema()` which uses `CREAT
61- `bypassPermissions` mode is a user-controlled toggle in implement phase only 134- `bypassPermissions` mode is a user-controlled toggle in implement phase only
62- Token usage (from `SDKResultMessage.usage`) is displayed in the ActionBar 135- Token usage (from `SDKResultMessage.usage`) is displayed in the ActionBar
63- No git library in dependencies — use Node.js `child_process` (built-in) for git operations 136- No git library in dependencies — use Node.js `child_process` (built-in) for git operations
137- Session rename auto-triggers when research phase completes if session name is default "Session N" format (extracts first sentence from research.md)
138
139## Extensibility Notes for UI Features
140
141When adding new UI features that require user input:
142
1431. **Modal dialogs**: Follow the SettingsPage pattern (full-page overlay with header/body)
1442. **Inline editing**: Use sidebar pattern (inline input that commits on blur/Enter)
1453. **Phase selection**: Phase column in DB already exists and accepts any value—no schema changes needed to support starting at different phases
1464. **Settings additions**: Add to `SettingsPage.tsx` with a new section and corresponding settings UI file in `/components/settings/`
1475. **IPC endpoints**: Register in `/src/main/ipc/handlers.ts` and expose in `/src/main/preload.ts`
1486. **State management**: Keep state in `App.tsx` for global UI state; component local state for transient UI state (e.g., modal visibility, form input)
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 @@
1import React, { useState, useEffect } from "react"; 1import React, { useState, useEffect, useRef } from "react";
2import { Header } from "./components/Header"; 2import { Header } from "./components/Header";
3import { Sidebar } from "./components/Sidebar";
3import { DocumentPane } from "./components/DocumentPane"; 4import { DocumentPane } from "./components/DocumentPane";
4import { ChatPane } from "./components/ChatPane"; 5import { ChatPane } from "./components/ChatPane";
5import { ActionBar } from "./components/ActionBar"; 6import { ActionBar } from "./components/ActionBar";
@@ -51,8 +52,14 @@ export function App() {
51 const [messages, setMessages] = useState<Message[]>([]); 52 const [messages, setMessages] = useState<Message[]>([]);
52 const [documentContent, setDocumentContent] = useState<string>(""); 53 const [documentContent, setDocumentContent] = useState<string>("");
53 const [originalContent, setOriginalContent] = useState<string>(""); 54 const [originalContent, setOriginalContent] = useState<string>("");
54 const [isLoading, setIsLoading] = useState(false); 55 // Per-session loading/activity state so switching sessions doesn't inherit
55 const [activityStatus, setActivityStatus] = useState<string | null>(null); 56 // another session's "Thinking" indicator.
57 const [loadingBySession, setLoadingBySession] = useState<Record<string, boolean>>({});
58 const [activityBySession, setActivityBySession] = useState<Record<string, string | null>>({});
59
60 const isLoading = selectedSession ? (loadingBySession[selectedSession.id] ?? false) : false;
61 const activityStatus = selectedSession ? (activityBySession[selectedSession.id] ?? null) : null;
62
56 const [tokenUsage, setTokenUsage] = useState<TokenUsage>({ 63 const [tokenUsage, setTokenUsage] = useState<TokenUsage>({
57 inputTokens: 0, 64 inputTokens: 0,
58 outputTokens: 0, 65 outputTokens: 0,
@@ -65,12 +72,42 @@ export function App() {
65 () => (localStorage.getItem("cf-theme") as Theme) ?? "dark" 72 () => (localStorage.getItem("cf-theme") as Theme) ?? "dark"
66 ); 73 );
67 74
75 // Which phase's artifact is currently displayed in the DocumentPane.
76 // Defaults to the session's current phase; can be overridden to view
77 // a historical artifact (e.g. research.md while in implement phase).
78 const [viewPhase, setViewPhase] = useState<Phase>(
79 (selectedSession?.phase ?? "research") as Phase
80 );
81
82 // Reset viewPhase whenever the active session or its phase changes.
83 useEffect(() => {
84 setViewPhase((selectedSession?.phase ?? "research") as Phase);
85 }, [selectedSession?.id, selectedSession?.phase]);
86
87 const isViewingHistorical =
88 !!selectedSession && viewPhase !== (selectedSession.phase as Phase);
89
90 // Refs so the global claude:message handler always sees the latest values
91 // without needing to re-subscribe when the selected session changes.
92 const selectedSessionRef = useRef(selectedSession);
93 const selectedProjectRef = useRef(selectedProject);
94 const viewPhaseRef = useRef(viewPhase);
95 useEffect(() => { selectedSessionRef.current = selectedSession; }, [selectedSession]);
96 useEffect(() => { selectedProjectRef.current = selectedProject; }, [selectedProject]);
97 useEffect(() => { viewPhaseRef.current = viewPhase; }, [viewPhase]);
98
68 const [chatWidth, setChatWidth] = useState<number>( 99 const [chatWidth, setChatWidth] = useState<number>(
69 () => Number(localStorage.getItem("cf-chat-width")) || 380 100 () => Number(localStorage.getItem("cf-chat-width")) || 380
70 ); 101 );
71 const [chatCollapsed, setChatCollapsed] = useState<boolean>( 102 const [chatCollapsed, setChatCollapsed] = useState<boolean>(
72 () => localStorage.getItem("cf-chat-collapsed") === "true" 103 () => localStorage.getItem("cf-chat-collapsed") === "true"
73 ); 104 );
105 const [sidebarWidth, setSidebarWidth] = useState<number>(
106 () => Number(localStorage.getItem("cf-sidebar-width")) || 260
107 );
108 const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(
109 () => localStorage.getItem("cf-sidebar-collapsed") === "true"
110 );
74 111
75 // Keep document.documentElement in sync and persist to localStorage 112 // Keep document.documentElement in sync and persist to localStorage
76 useEffect(() => { 113 useEffect(() => {
@@ -86,11 +123,20 @@ export function App() {
86 localStorage.setItem("cf-chat-collapsed", String(chatCollapsed)); 123 localStorage.setItem("cf-chat-collapsed", String(chatCollapsed));
87 }, [chatCollapsed]); 124 }, [chatCollapsed]);
88 125
126 useEffect(() => {
127 localStorage.setItem("cf-sidebar-width", String(sidebarWidth));
128 }, [sidebarWidth]);
129
130 useEffect(() => {
131 localStorage.setItem("cf-sidebar-collapsed", String(sidebarCollapsed));
132 }, [sidebarCollapsed]);
133
89 const handleCancel = () => { 134 const handleCancel = () => {
90 if (!selectedSession) return; 135 if (!selectedSession) return;
91 api.interruptSession(selectedSession.id); 136 const id = selectedSession.id;
92 setIsLoading(false); 137 api.interruptSession(id);
93 setActivityStatus(null); 138 setLoadingBySession((prev) => ({ ...prev, [id]: false }));
139 setActivityBySession((prev) => ({ ...prev, [id]: null }));
94 }; 140 };
95 141
96 const handleToggleTheme = () => 142 const handleToggleTheme = () =>
@@ -113,6 +159,23 @@ export function App() {
113 document.addEventListener("mouseup", onUp); 159 document.addEventListener("mouseup", onUp);
114 }; 160 };
115 161
162 const handleSidebarResizeMouseDown = (e: React.MouseEvent) => {
163 e.preventDefault();
164 const startX = e.clientX;
165 const startWidth = sidebarWidth;
166 const onMove = (ev: MouseEvent) => {
167 const delta = ev.clientX - startX; // drag right = wider sidebar
168 const next = Math.max(180, Math.min(400, startWidth + delta));
169 setSidebarWidth(next);
170 };
171 const onUp = () => {
172 document.removeEventListener("mousemove", onMove);
173 document.removeEventListener("mouseup", onUp);
174 };
175 document.addEventListener("mousemove", onMove);
176 document.addEventListener("mouseup", onUp);
177 };
178
116 const hasChanges = documentContent !== originalContent; 179 const hasChanges = documentContent !== originalContent;
117 180
118 // Clear error after 5 seconds 181 // Clear error after 5 seconds
@@ -128,9 +191,10 @@ export function App() {
128 const handleKeyDown = (e: KeyboardEvent) => { 191 const handleKeyDown = (e: KeyboardEvent) => {
129 // Escape to interrupt 192 // Escape to interrupt
130 if (e.key === "Escape" && isLoading && selectedSession) { 193 if (e.key === "Escape" && isLoading && selectedSession) {
131 api.interruptSession(selectedSession.id); 194 const id = selectedSession.id;
132 setIsLoading(false); 195 api.interruptSession(id);
133 setActivityStatus(null); 196 setLoadingBySession((prev) => ({ ...prev, [id]: false }));
197 setActivityBySession((prev) => ({ ...prev, [id]: null }));
134 } 198 }
135 // Cmd/Ctrl + Enter to submit 199 // Cmd/Ctrl + Enter to submit
136 if ( 200 if (
@@ -148,87 +212,93 @@ export function App() {
148 return () => window.removeEventListener("keydown", handleKeyDown); 212 return () => window.removeEventListener("keydown", handleKeyDown);
149 }, [selectedSession, isLoading]); 213 }, [selectedSession, isLoading]);
150 214
151 // Load projects and initial model setting on mount 215 // Load projects, all sessions, and initial model setting on mount.
216 // Sessions for every project are fetched in parallel so the sidebar tree
217 // can display all projects and their sessions immediately.
152 useEffect(() => { 218 useEffect(() => {
153 api.listProjects().then(setProjects); 219 (async () => {
154 // Seed the model badge from the DB so it shows before any query fires. 220 const loadedProjects = await api.listProjects();
155 // system:init will overwrite this with the SDK-resolved name once a query runs. 221 setProjects(loadedProjects);
156 api.getSettings(["model"]).then((s) => { 222 const arrays = await Promise.all(
223 loadedProjects.map((p) => api.listSessions(p.id))
224 );
225 setSessions(arrays.flat());
226 // Seed the model badge from the DB so it shows before any query fires.
227 // system:init will overwrite this with the SDK-resolved name once a query runs.
228 const s = await api.getSettings(["model"]);
157 if (s["model"]) setActiveModel(s["model"]); 229 if (s["model"]) setActiveModel(s["model"]);
158 }); 230 })();
159 }, []); 231 }, []);
160 232
161 // Load sessions when project changes 233 // Load messages when session changes (not affected by viewPhase).
162 useEffect(() => { 234 useEffect(() => {
163 if (selectedProject) { 235 if (selectedSession) {
164 api.listSessions(selectedProject.id).then(setSessions); 236 api.listMessages(selectedSession.id).then(setMessages);
165 } else { 237 } else {
166 setSessions([]); 238 setMessages([]);
167 } 239 }
168 }, [selectedProject]); 240 }, [selectedSession?.id]);
169 241
170 // Load messages and artifact when session changes 242 // Load the viewed artifact whenever the session or the viewed phase changes.
171 useEffect(() => { 243 useEffect(() => {
172 if (selectedSession && selectedProject) { 244 if (selectedSession && selectedProject) {
173 // Load messages 245 const filename = viewPhase === "research" ? "research.md" : "plan.md";
174 api.listMessages(selectedSession.id).then(setMessages);
175
176 // Load session-specific artifact (from ~/.claude-flow/)
177 const filename =
178 selectedSession.phase === "research" ? "research.md" : "plan.md";
179 api.readSessionArtifact(selectedProject.id, selectedSession.id, filename).then((content) => { 246 api.readSessionArtifact(selectedProject.id, selectedSession.id, filename).then((content) => {
180 const text = content || ""; 247 const text = content || "";
181 setDocumentContent(text); 248 setDocumentContent(text);
182 setOriginalContent(text); 249 setOriginalContent(text);
183 }); 250 });
184 } else { 251 } else {
185 setMessages([]);
186 setDocumentContent(""); 252 setDocumentContent("");
187 setOriginalContent(""); 253 setOriginalContent("");
188 } 254 }
189 }, [selectedSession?.id, selectedSession?.phase, selectedProject]); 255 }, [selectedSession?.id, selectedProject, viewPhase]);
190 256
191 // Subscribe to Claude messages 257 // Subscribe to Claude messages once globally.
258 // Loading/activity state is updated for ANY session (via per-session maps).
259 // Content state (messages, artifacts) is only updated for the currently
260 // selected session, using refs to avoid re-subscribing on every session switch.
192 useEffect(() => { 261 useEffect(() => {
193 const unsubscribe = api.onClaudeMessage((sessionId, msg) => { 262 const unsubscribe = api.onClaudeMessage((sessionId, msg) => {
194 if (sessionId !== selectedSession?.id) return;
195
196 // ── Live activity indicator ────────────────────────────────────── 263 // ── Live activity indicator ──────────────────────────────────────
197 // Primary: tool_progress fires periodically while a tool is running
198 if (msg.type === "tool_progress") { 264 if (msg.type === "tool_progress") {
199 const elapsed = Math.round(msg.elapsed_time_seconds); 265 const elapsed = Math.round(msg.elapsed_time_seconds);
200 setActivityStatus( 266 setActivityBySession((prev) => ({
201 elapsed > 0 267 ...prev,
202 ? `Using ${msg.tool_name} (${elapsed}s)` 268 [sessionId]:
203 : `Using ${msg.tool_name}…` 269 elapsed > 0
204 ); 270 ? `Using ${msg.tool_name} (${elapsed}s)`
271 : `Using ${msg.tool_name}…`,
272 }));
205 } 273 }
206 274
207 // Secondary: task_progress carries last_tool_name for sub-agent tasks
208 if ( 275 if (
209 msg.type === "system" && 276 msg.type === "system" &&
210 msg.subtype === "task_progress" && 277 msg.subtype === "task_progress" &&
211 (msg as { last_tool_name?: string }).last_tool_name 278 (msg as { last_tool_name?: string }).last_tool_name
212 ) { 279 ) {
213 setActivityStatus( 280 setActivityBySession((prev) => ({
214 `Using ${(msg as { last_tool_name: string }).last_tool_name}…` 281 ...prev,
215 ); 282 [sessionId]: `Using ${(msg as { last_tool_name: string }).last_tool_name}…`,
283 }));
216 } 284 }
217 285
218 // ── Model resolved by SDK ──────────────────────────────────────── 286 // ── Model resolved by SDK ────────────────────────────────────────
219 // SDKSystemMessage (subtype "init") contains the actual model in use.
220 if (msg.type === "system" && msg.subtype === "init") { 287 if (msg.type === "system" && msg.subtype === "init") {
221 setActiveModel((msg as { model?: string }).model ?? null); 288 setActiveModel((msg as { model?: string }).model ?? null);
222 } 289 }
223 290
224 // ── Result (success or error) ──────────────────────────────────── 291 // ── Result (success or error) ────────────────────────────────────
225 // Always clear loading state on any result subtype so error results 292 // Clear loading state for the session that completed, regardless of
226 // don't leave the UI stuck in the loading/thinking state. 293 // which session is currently selected.
227 if (msg.type === "result") { 294 if (msg.type === "result") {
228 setIsLoading(false); 295 setLoadingBySession((prev) => ({ ...prev, [sessionId]: false }));
229 setActivityStatus(null); 296 setActivityBySession((prev) => ({ ...prev, [sessionId]: null }));
230 297
231 if (msg.subtype === "success") { 298 // Content updates only matter for the currently visible session.
299 const currentSession = selectedSessionRef.current;
300 const currentProject = selectedProjectRef.current;
301 if (sessionId === currentSession?.id && msg.subtype === "success") {
232 if (msg.usage) { 302 if (msg.usage) {
233 setTokenUsage({ 303 setTokenUsage({
234 inputTokens: msg.usage.input_tokens, 304 inputTokens: msg.usage.input_tokens,
@@ -236,20 +306,24 @@ export function App() {
236 cacheHits: msg.usage.cache_read_input_tokens, 306 cacheHits: msg.usage.cache_read_input_tokens,
237 }); 307 });
238 } 308 }
239 // Reload artifact after Claude updates it 309 // Only reload the artifact if the user is viewing the current phase's
240 if (selectedProject && selectedSession) { 310 // document. If they're browsing a historical artifact, leave it alone;
311 // the effect tied to viewPhase will reload when they navigate back.
312 if (
313 currentProject &&
314 currentSession &&
315 viewPhaseRef.current === currentSession.phase
316 ) {
241 const filename = 317 const filename =
242 selectedSession.phase === "research" ? "research.md" : "plan.md"; 318 currentSession.phase === "research" ? "research.md" : "plan.md";
243 // Capture for the async closure 319 const sessionAtTrigger = currentSession;
244 const sessionAtTrigger = selectedSession;
245 api 320 api
246 .readSessionArtifact(selectedProject.id, sessionAtTrigger.id, filename) 321 .readSessionArtifact(currentProject.id, sessionAtTrigger.id, filename)
247 .then((content) => { 322 .then((content) => {
248 const text = content || ""; 323 const text = content || "";
249 setDocumentContent(text); 324 setDocumentContent(text);
250 setOriginalContent(text); 325 setOriginalContent(text);
251 326
252 // Auto-name: only during research, only while name is still the default
253 if ( 327 if (
254 sessionAtTrigger.phase === "research" && 328 sessionAtTrigger.phase === "research" &&
255 /^Session \d+$/.test(sessionAtTrigger.name) && 329 /^Session \d+$/.test(sessionAtTrigger.name) &&
@@ -266,7 +340,8 @@ export function App() {
266 } 340 }
267 341
268 // ── Assistant message ──────────────────────────────────────────── 342 // ── Assistant message ────────────────────────────────────────────
269 if (msg.type === "assistant") { 343 // Only update the chat pane for the currently visible session.
344 if (msg.type === "assistant" && sessionId === selectedSessionRef.current?.id) {
270 const content = ( 345 const content = (
271 msg.message.content as Array<{ type: string; text?: string }> 346 msg.message.content as Array<{ type: string; text?: string }>
272 ) 347 )
@@ -278,10 +353,8 @@ export function App() {
278 setMessages((prev) => { 353 setMessages((prev) => {
279 const last = prev[prev.length - 1]; 354 const last = prev[prev.length - 1];
280 if (last?.role === "assistant") { 355 if (last?.role === "assistant") {
281 // Update existing assistant message
282 return [...prev.slice(0, -1), { ...last, content }]; 356 return [...prev.slice(0, -1), { ...last, content }];
283 } 357 }
284 // Add new assistant message
285 return [ 358 return [
286 ...prev, 359 ...prev,
287 { 360 {
@@ -298,74 +371,74 @@ export function App() {
298 }); 371 });
299 372
300 return unsubscribe; 373 return unsubscribe;
301 }, [selectedSession?.id, selectedSession?.phase, selectedProject]); 374 }, []); // Subscribe once — uses refs for current session/project
302 375
303 const handleSendMessage = async (message: string) => { 376 const handleSendMessage = async (message: string) => {
304 if (!selectedSession) return; 377 if (!selectedSession) return;
305 setIsLoading(true); 378 const id = selectedSession.id;
379 setLoadingBySession((prev) => ({ ...prev, [id]: true }));
306 setError(null); 380 setError(null);
307 setMessages((prev) => [ 381 setMessages((prev) => [
308 ...prev, 382 ...prev,
309 { 383 {
310 id: crypto.randomUUID(), 384 id: crypto.randomUUID(),
311 session_id: selectedSession.id, 385 session_id: id,
312 role: "user", 386 role: "user",
313 content: message, 387 content: message,
314 created_at: Date.now() / 1000, 388 created_at: Date.now() / 1000,
315 }, 389 },
316 ]); 390 ]);
317 try { 391 try {
318 await api.sendMessage(selectedSession.id, message); 392 await api.sendMessage(id, message);
319 } catch (err) { 393 } catch (err) {
320 setError(err instanceof Error ? err.message : "Failed to send message"); 394 setError(err instanceof Error ? err.message : "Failed to send message");
321 setIsLoading(false); 395 setLoadingBySession((prev) => ({ ...prev, [id]: false }));
322 } 396 }
323 }; 397 };
324 398
325 const handleReview = async () => { 399 const handleReview = async () => {
326 if (!selectedSession || !selectedProject) return; 400 if (!selectedSession || !selectedProject || isViewingHistorical) return;
401 const id = selectedSession.id;
327 setError(null); 402 setError(null);
328 try { 403 try {
329 // Save user edits first (session-specific, stored in ~/.claude-flow/)
330 const filename = 404 const filename =
331 selectedSession.phase === "research" ? "research.md" : "plan.md"; 405 selectedSession.phase === "research" ? "research.md" : "plan.md";
332 await api.writeSessionArtifact(selectedProject.id, selectedSession.id, filename, documentContent); 406 await api.writeSessionArtifact(selectedProject.id, id, filename, documentContent);
333 setOriginalContent(documentContent); 407 setOriginalContent(documentContent);
334 setIsLoading(true); 408 setLoadingBySession((prev) => ({ ...prev, [id]: true }));
335 await api.triggerReview(selectedSession.id); 409 await api.triggerReview(id);
336 } catch (err) { 410 } catch (err) {
337 setError(err instanceof Error ? err.message : "Review failed"); 411 setError(err instanceof Error ? err.message : "Review failed");
338 setIsLoading(false); 412 setLoadingBySession((prev) => ({ ...prev, [id]: false }));
339 } 413 }
340 }; 414 };
341 415
342 const handleSubmit = async () => { 416 const handleSubmit = async () => {
343 if (!selectedSession || !selectedProject) return; 417 if (!selectedSession || !selectedProject || isViewingHistorical) return;
418 const id = selectedSession.id;
344 setError(null); 419 setError(null);
345 try { 420 try {
346 // Save any pending edits (session-specific, stored in ~/.claude-flow/)
347 const filename = 421 const filename =
348 selectedSession.phase === "research" ? "research.md" : "plan.md"; 422 selectedSession.phase === "research" ? "research.md" : "plan.md";
349 await api.writeSessionArtifact(selectedProject.id, selectedSession.id, filename, documentContent); 423 await api.writeSessionArtifact(selectedProject.id, id, filename, documentContent);
350 424
351 const advanced = await api.advancePhase(selectedSession.id); 425 const advanced = await api.advancePhase(id);
352 if (advanced) { 426 if (advanced) {
353 setSelectedSession({ 427 setSelectedSession({
354 ...selectedSession, 428 ...selectedSession,
355 phase: advanced.phase, 429 phase: advanced.phase,
356 git_branch: advanced.git_branch, 430 git_branch: advanced.git_branch,
357 }); 431 });
358 // Trigger initial message for next phase 432 setLoadingBySession((prev) => ({ ...prev, [id]: true }));
359 setIsLoading(true);
360 const initialMsg = 433 const initialMsg =
361 advanced.phase === "plan" 434 advanced.phase === "plan"
362 ? "Create a detailed implementation plan based on the research." 435 ? "Create a detailed implementation plan based on the research."
363 : "Begin implementing the plan."; 436 : "Begin implementing the plan.";
364 await api.sendMessage(selectedSession.id, initialMsg); 437 await api.sendMessage(id, initialMsg);
365 } 438 }
366 } catch (err) { 439 } catch (err) {
367 setError(err instanceof Error ? err.message : "Submit failed"); 440 setError(err instanceof Error ? err.message : "Submit failed");
368 setIsLoading(false); 441 setLoadingBySession((prev) => ({ ...prev, [id]: false }));
369 } 442 }
370 }; 443 };
371 444
@@ -378,26 +451,28 @@ export function App() {
378 setSelectedProject(project); 451 setSelectedProject(project);
379 }; 452 };
380 453
381 const handleCreateSession = async () => { 454 const handleCreateSession = async (projectId: string) => {
382 if (!selectedProject) return; 455 const project = projects.find((p) => p.id === projectId);
383 const name = `Session ${sessions.length + 1}`; 456 if (!project) return;
384 const session = await api.createSession(selectedProject.id, name); 457 const projectSessions = sessions.filter((s) => s.project_id === projectId);
458 const name = `Session ${projectSessions.length + 1}`;
459 const session = await api.createSession(projectId, name);
385 setSessions((prev) => [session, ...prev]); 460 setSessions((prev) => [session, ...prev]);
461 setSelectedProject(project);
386 setSelectedSession(session); 462 setSelectedSession(session);
387 setMessages([]); 463 setMessages([]);
388 setDocumentContent(""); 464 setDocumentContent("");
389 setOriginalContent(""); 465 setOriginalContent("");
390 // Note: Each session has its own artifact directory, no need to clear
391 }; 466 };
392 467
393 const handleDeleteProject = async (id: string) => { 468 const handleDeleteProject = async (id: string) => {
394 try { 469 try {
395 await api.deleteProject(id); 470 await api.deleteProject(id);
396 setProjects((prev) => prev.filter((p) => p.id !== id)); 471 setProjects((prev) => prev.filter((p) => p.id !== id));
472 setSessions((prev) => prev.filter((s) => s.project_id !== id));
397 if (selectedProject?.id === id) { 473 if (selectedProject?.id === id) {
398 setSelectedProject(null); 474 setSelectedProject(null);
399 setSelectedSession(null); 475 setSelectedSession(null);
400 setSessions([]);
401 setMessages([]); 476 setMessages([]);
402 setDocumentContent(""); 477 setDocumentContent("");
403 setOriginalContent(""); 478 setOriginalContent("");
@@ -436,56 +511,88 @@ export function App() {
436 return ( 511 return (
437 <div className="app"> 512 <div className="app">
438 <Header 513 <Header
439 projects={projects}
440 sessions={sessions}
441 selectedProject={selectedProject}
442 selectedSession={selectedSession} 514 selectedSession={selectedSession}
443 onSelectProject={setSelectedProject}
444 onSelectSession={setSelectedSession}
445 onCreateProject={handleCreateProject}
446 onCreateSession={handleCreateSession}
447 onDeleteProject={handleDeleteProject}
448 onDeleteSession={handleDeleteSession}
449 onRenameSession={handleRenameSession}
450 theme={theme} 515 theme={theme}
451 onToggleTheme={handleToggleTheme} 516 onToggleTheme={handleToggleTheme}
452 gitBranch={selectedSession?.git_branch ?? null} 517 gitBranch={selectedSession?.git_branch ?? null}
453 onOpenSettings={() => setShowSettings(true)} 518 onOpenSettings={() => setShowSettings(true)}
519 viewPhase={viewPhase}
520 onViewPhase={setViewPhase}
454 /> 521 />
455 522
456 <div className="main-content"> 523 <div className="main-layout">
457 <DocumentPane 524 <Sidebar
458 content={documentContent} 525 projects={projects}
459 onChange={setDocumentContent} 526 sessions={sessions}
460 phase={selectedSession?.phase || "research"} 527 selectedProject={selectedProject}
461 disabled={!selectedSession || selectedSession.phase === "implement"} 528 selectedSession={selectedSession}
462 showOnboarding={!selectedProject} 529 onSelectProject={(p) => {
463 theme={theme} 530 setSelectedProject(p);
531 setSelectedSession(null);
532 }}
533 onSelectSession={(session) => {
534 if (session) {
535 const project = projects.find((p) => p.id === session.project_id);
536 if (project) setSelectedProject(project);
537 }
538 setSelectedSession(session);
539 }}
540 onCreateProject={handleCreateProject}
541 onCreateSession={handleCreateSession}
542 onDeleteProject={handleDeleteProject}
543 onDeleteSession={handleDeleteSession}
544 onRenameSession={handleRenameSession}
545 loadingBySession={loadingBySession}
546 width={sidebarWidth}
547 collapsed={sidebarCollapsed}
548 onCollapsedChange={setSidebarCollapsed}
464 /> 549 />
465 550
466 {!chatCollapsed && ( 551 {!sidebarCollapsed && (
467 <div 552 <div
468 className="chat-resize-handle" 553 className="sidebar-resize-handle"
469 onMouseDown={handleResizeMouseDown} 554 onMouseDown={handleSidebarResizeMouseDown}
470 /> 555 />
471 )} 556 )}
472 557
473 <ChatPane 558 <div className="main-content">
474 messages={messages} 559 <DocumentPane
475 onSend={handleSendMessage} 560 content={documentContent}
476 isLoading={isLoading} 561 onChange={setDocumentContent}
477 disabled={!selectedSession} 562 phase={viewPhase}
478 placeholder={ 563 disabled={
479 selectedSession 564 !selectedSession ||
480 ? `Chat with Claude (${selectedSession.phase})...` 565 isViewingHistorical ||
481 : "Select a session to start" 566 selectedSession.phase === "implement"
482 } 567 }
483 collapsed={chatCollapsed} 568 showOnboarding={!selectedProject}
484 chatWidth={chatWidth} 569 theme={theme}
485 onToggleCollapse={() => setChatCollapsed((c) => !c)} 570 />
486 activityStatus={activityStatus} 571
487 onCancel={handleCancel} 572 {!chatCollapsed && (
488 /> 573 <div
574 className="chat-resize-handle"
575 onMouseDown={handleResizeMouseDown}
576 />
577 )}
578
579 <ChatPane
580 messages={messages}
581 onSend={handleSendMessage}
582 isLoading={isLoading}
583 disabled={!selectedSession}
584 placeholder={
585 selectedSession
586 ? `Chat with Claude (${selectedSession.phase})...`
587 : "Select a session to start"
588 }
589 collapsed={chatCollapsed}
590 chatWidth={chatWidth}
591 onToggleCollapse={() => setChatCollapsed((c) => !c)}
592 activityStatus={activityStatus}
593 onCancel={handleCancel}
594 />
595 </div>
489 </div> 596 </div>
490 597
491 {error && ( 598 {error && (
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}
diff --git a/renderer/src/styles/globals.css b/renderer/src/styles/globals.css
index 20275ae..c463432 100644
--- a/renderer/src/styles/globals.css
+++ b/renderer/src/styles/globals.css
@@ -122,23 +122,6 @@ body {
122 background: var(--bg-tertiary); 122 background: var(--bg-tertiary);
123} 123}
124 124
125.session-rename-input {
126 padding: 5px 10px;
127 background: var(--bg-tertiary);
128 border: 1px solid var(--accent);
129 border-radius: 2px;
130 color: var(--text-primary);
131 font-size: 12px;
132 font-family: inherit;
133 min-width: 140px;
134 outline: none;
135 box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
136}
137
138html[data-theme="light"] .session-rename-input {
139 box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
140}
141
142/* Theme toggle */ 125/* Theme toggle */
143.theme-toggle { 126.theme-toggle {
144 font-size: 11px; 127 font-size: 11px;
@@ -167,6 +150,17 @@ html[data-theme="light"] .session-rename-input {
167 border-radius: 2px; 150 border-radius: 2px;
168 background: var(--bg-tertiary); 151 background: var(--bg-tertiary);
169 color: var(--text-secondary); 152 color: var(--text-secondary);
153 border: none;
154 font-family: inherit;
155}
156
157/* Reachable phases rendered as <button> — add pointer + hover */
158button.phase-step {
159 cursor: pointer;
160}
161
162button.phase-step:hover {
163 filter: brightness(1.15);
170} 164}
171 165
172.phase-step.active { 166.phase-step.active {
@@ -179,6 +173,12 @@ html[data-theme="light"] .session-rename-input {
179 color: white; 173 color: white;
180} 174}
181 175
176/* Viewing a historical artifact: outline on the viewed badge */
177.phase-step.viewing {
178 outline: 2px solid var(--accent);
179 outline-offset: -2px;
180}
181
182/* ── Branch Badge ────────────────────────────────────────────── */ 182/* ── Branch Badge ────────────────────────────────────────────── */
183.branch-badge { 183.branch-badge {
184 padding: 3px 10px; 184 padding: 3px 10px;
@@ -206,13 +206,277 @@ html[data-theme="light"] .session-rename-input {
206 color: white; 206 color: white;
207} 207}
208 208
209/* ── Main Content ─────────────────────────────────────────────── */ 209/* ── Main Layout (sidebar + content row) ─────────────────────── */
210.main-layout {
211 flex: 1;
212 display: flex;
213 overflow: hidden;
214}
215
216/* ── Main Content (document + chat row) ──────────────────────── */
210.main-content { 217.main-content {
211 flex: 1; 218 flex: 1;
212 display: flex; 219 display: flex;
213 overflow: hidden; 220 overflow: hidden;
214} 221}
215 222
223/* ── Sidebar ─────────────────────────────────────────────────── */
224.sidebar {
225 display: flex;
226 flex-direction: column;
227 background: var(--bg-secondary);
228 border-right: 1px solid var(--border);
229 flex-shrink: 0;
230 overflow: hidden;
231 min-width: 0;
232}
233
234.sidebar.collapsed {
235 width: 32px !important;
236 align-items: center;
237 padding-top: 8px;
238}
239
240.sidebar-header {
241 display: flex;
242 align-items: center;
243 justify-content: space-between;
244 padding: 8px 10px;
245 border-bottom: 1px solid var(--border);
246 flex-shrink: 0;
247}
248
249.sidebar-title {
250 font-size: 10px;
251 font-weight: 700;
252 letter-spacing: 0.12em;
253 text-transform: uppercase;
254 color: var(--text-secondary);
255 user-select: none;
256}
257
258.sidebar-header-actions {
259 display: flex;
260 gap: 4px;
261}
262
263.sidebar-action-btn,
264.sidebar-collapse-btn {
265 background: transparent;
266 border: none;
267 color: var(--text-secondary);
268 cursor: pointer;
269 font-size: 13px;
270 font-family: inherit;
271 padding: 2px 5px;
272 border-radius: 2px;
273 line-height: 1;
274 transition: background 0.1s, color 0.1s;
275}
276
277.sidebar-action-btn:hover,
278.sidebar-collapse-btn:hover {
279 background: var(--bg-tertiary);
280 color: var(--text-primary);
281}
282
283.sidebar-tree {
284 flex: 1;
285 overflow-y: auto;
286 padding: 4px 0;
287}
288
289/* ── Sidebar Resize Handle ───────────────────────────────────── */
290.sidebar-resize-handle {
291 width: 5px;
292 background: transparent;
293 cursor: col-resize;
294 flex-shrink: 0;
295 transition: background 0.15s;
296}
297
298.sidebar-resize-handle:hover {
299 background: var(--accent);
300}
301
302/* ── Project Items ───────────────────────────────────────────── */
303.project-item {
304 display: flex;
305 align-items: center;
306 justify-content: space-between;
307 padding: 5px 10px;
308 cursor: default;
309 user-select: none;
310 gap: 4px;
311}
312
313.project-item:hover {
314 background: var(--bg-tertiary);
315}
316
317.project-item.selected {
318 background: var(--bg-tertiary);
319}
320
321.project-name {
322 flex: 1;
323 font-size: 11px;
324 font-weight: 600;
325 letter-spacing: 0.04em;
326 text-transform: uppercase;
327 color: var(--text-primary);
328 cursor: pointer;
329 white-space: nowrap;
330 overflow: hidden;
331 text-overflow: ellipsis;
332 min-width: 0;
333}
334
335.project-item.selected .project-name {
336 color: var(--accent);
337}
338
339/* ── Session Items ───────────────────────────────────────────── */
340.session-item {
341 display: flex;
342 align-items: center;
343 padding: 4px 10px 4px 22px;
344 gap: 4px;
345 user-select: none;
346}
347
348.session-item:hover {
349 background: var(--bg-tertiary);
350}
351
352.session-item.selected {
353 background: var(--bg-tertiary);
354 border-left: 2px solid var(--accent);
355 padding-left: 20px;
356}
357
358.session-name {
359 flex: 1;
360 font-size: 12px;
361 color: var(--text-secondary);
362 cursor: pointer;
363 white-space: nowrap;
364 overflow: hidden;
365 text-overflow: ellipsis;
366 min-width: 0;
367 display: flex;
368 align-items: center;
369 gap: 5px;
370}
371
372.session-item.selected .session-name {
373 color: var(--text-primary);
374}
375
376/* ── Activity dot ────────────────────────────────────────────── */
377.session-activity-dot {
378 display: inline-block;
379 width: 6px;
380 height: 6px;
381 border-radius: 50%;
382 background: var(--accent);
383 flex-shrink: 0;
384 animation: activity-pulse 1.8s ease-in-out infinite;
385}
386
387@keyframes activity-pulse {
388 0%, 100% { opacity: 0.4; }
389 50% { opacity: 1; }
390}
391
392/* ── Shared item controls (shown on hover) ───────────────────── */
393.item-controls {
394 display: flex;
395 gap: 2px;
396 flex-shrink: 0;
397 opacity: 0;
398 transition: opacity 0.1s;
399}
400
401.project-item:hover .item-controls,
402.session-item:hover .item-controls {
403 opacity: 1;
404}
405
406.session-item.selected .item-controls {
407 opacity: 1;
408}
409
410.item-btn {
411 background: transparent;
412 border: none;
413 color: var(--text-secondary);
414 cursor: pointer;
415 font-size: 11px;
416 font-family: inherit;
417 padding: 1px 4px;
418 border-radius: 2px;
419 line-height: 1.4;
420 transition: background 0.1s, color 0.1s;
421}
422
423.item-btn:hover {
424 background: var(--bg-primary);
425 color: var(--text-primary);
426}
427
428.item-btn-danger:hover {
429 background: var(--danger);
430 color: white;
431}
432
433/* ── Sidebar empty states ────────────────────────────────────── */
434.sidebar-empty {
435 font-size: 11px;
436 color: var(--text-secondary);
437 font-style: italic;
438 padding: 10px 12px;
439 text-align: center;
440}
441
442.session-empty {
443 padding: 4px 10px 4px 22px;
444 text-align: left;
445}
446
447/* ── Sidebar scrollbar ───────────────────────────────────────── */
448.sidebar-tree::-webkit-scrollbar {
449 width: 4px;
450}
451
452.sidebar-tree::-webkit-scrollbar-track {
453 background: transparent;
454}
455
456.sidebar-tree::-webkit-scrollbar-thumb {
457 background: var(--bg-tertiary);
458 border-radius: 2px;
459}
460
461/* ── Session rename input (in sidebar) ───────────────────────── */
462.session-rename-input {
463 flex: 1;
464 padding: 2px 6px;
465 background: var(--bg-tertiary);
466 border: 1px solid var(--accent);
467 border-radius: 2px;
468 color: var(--text-primary);
469 font-size: 12px;
470 font-family: inherit;
471 min-width: 0;
472 outline: none;
473 box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
474}
475
476html[data-theme="light"] .session-rename-input {
477 box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
478}
479
216/* ── Document Pane ───────────────────────────────────────────── */ 480/* ── Document Pane ───────────────────────────────────────────── */
217.document-pane { 481.document-pane {
218 flex: 1; 482 flex: 1;
@@ -406,6 +670,11 @@ html[data-theme="light"] .session-rename-input {
406 text-transform: uppercase; 670 text-transform: uppercase;
407} 671}
408 672
673.badge-readonly {
674 background: var(--bg-tertiary);
675 color: var(--text-secondary);
676}
677
409/* ── Chat Pane ───────────────────────────────────────────────── */ 678/* ── Chat Pane ───────────────────────────────────────────────── */
410.chat-pane { 679.chat-pane {
411 display: flex; 680 display: flex;
@@ -432,7 +701,8 @@ html[data-theme="light"] .session-rename-input {
432/* ── Chat Header Strip ───────────────────────────────────────── */ 701/* ── Chat Header Strip ───────────────────────────────────────── */
433.chat-header { 702.chat-header {
434 display: flex; 703 display: flex;
435 justify-content: space-between; 704 justify-content: flex-start;
705 gap: 8px;
436 align-items: center; 706 align-items: center;
437 padding: 7px 12px; 707 padding: 7px 12px;
438 background: var(--bg-secondary); 708 background: var(--bg-secondary);
@@ -982,6 +1252,35 @@ html[data-theme="light"] .settings-textarea:focus {
982 border-color: var(--accent); 1252 border-color: var(--accent);
983} 1253}
984 1254
1255/* ── Phase model override rows ───────────────────────────────── */
1256.settings-phase-model-list {
1257 display: flex;
1258 flex-direction: column;
1259 gap: 8px;
1260 margin-top: 8px;
1261}
1262
1263.settings-phase-model-row {
1264 display: grid;
1265 grid-template-columns: 80px 1fr auto;
1266 align-items: center;
1267 gap: 8px;
1268}
1269
1270.settings-phase-model-label {
1271 font-size: 12px;
1272 font-weight: 600;
1273 color: var(--text-secondary);
1274 text-transform: uppercase;
1275 letter-spacing: 0.05em;
1276}
1277
1278.settings-phase-model-actions {
1279 display: flex;
1280 align-items: center;
1281 gap: 6px;
1282}
1283
985/* ── Settings Actions Row ────────────────────────────────────── */ 1284/* ── Settings Actions Row ────────────────────────────────────── */
986.settings-actions { 1285.settings-actions {
987 display: flex; 1286 display: flex;