diff options
| author | bndw <ben@bdw.to> | 2026-03-04 21:21:22 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-03-04 21:21:22 -0800 |
| commit | b6405dd6a4ba65fc5dc6746db7be7be7d0bb29f3 (patch) | |
| tree | 7d04268e9adfe9a6a83029556ef0dd5f72a63d42 /renderer/src/App.tsx | |
| parent | ead65fd7d50ead785f437cc895c74146bd232702 (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/App.tsx')
| -rw-r--r-- | renderer/src/App.tsx | 347 |
1 files changed, 227 insertions, 120 deletions
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 @@ | |||
| 1 | import React, { useState, useEffect } from "react"; | 1 | import React, { useState, useEffect, useRef } from "react"; |
| 2 | import { Header } from "./components/Header"; | 2 | import { Header } from "./components/Header"; |
| 3 | import { Sidebar } from "./components/Sidebar"; | ||
| 3 | import { DocumentPane } from "./components/DocumentPane"; | 4 | import { DocumentPane } from "./components/DocumentPane"; |
| 4 | import { ChatPane } from "./components/ChatPane"; | 5 | import { ChatPane } from "./components/ChatPane"; |
| 5 | import { ActionBar } from "./components/ActionBar"; | 6 | import { 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 && ( |
