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 | |
| 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')
| -rw-r--r-- | renderer/src/App.tsx | 347 | ||||
| -rw-r--r-- | renderer/src/components/ChatPane.tsx | 4 | ||||
| -rw-r--r-- | renderer/src/components/Header.tsx | 172 | ||||
| -rw-r--r-- | renderer/src/components/Sidebar.tsx | 231 | ||||
| -rw-r--r-- | renderer/src/styles/globals.css | 337 |
5 files changed, 799 insertions, 292 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 && ( |
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 @@ | |||
| 1 | import React, { useState, useEffect } from "react"; | 1 | import React, { useState, useEffect } from "react"; |
| 2 | import type { Project, Session, Phase } from "../types"; | 2 | import type { Session, Phase } from "../types"; |
| 3 | import { formatSessionLabel } from "../utils/timeFormat"; | ||
| 4 | 3 | ||
| 5 | const api = window.api; | 4 | const api = window.api; |
| 6 | 5 | ||
| 7 | type Theme = "dark" | "light"; | 6 | type Theme = "dark" | "light"; |
| 8 | 7 | ||
| 9 | interface HeaderProps { | 8 | interface 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 | ||
| 27 | const phaseLabels: Record<Phase, string> = { | 18 | const phaseLabels: Record<Phase, string> = { |
| @@ -33,69 +24,18 @@ const phaseLabels: Record<Phase, string> = { | |||
| 33 | const phases: Phase[] = ["research", "plan", "implement"]; | 24 | const phases: Phase[] = ["research", "plan", "implement"]; |
| 34 | 25 | ||
| 35 | export function Header({ | 26 | export 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 @@ | |||
| 1 | import React, { useState, useRef } from "react"; | ||
| 2 | import type { Project, Session } from "../types"; | ||
| 3 | import { formatRelativeTime } from "../utils/timeFormat"; | ||
| 4 | |||
| 5 | interface 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 | |||
| 23 | export 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 | |||
| 138 | html[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 */ | ||
| 158 | button.phase-step { | ||
| 159 | cursor: pointer; | ||
| 160 | } | ||
| 161 | |||
| 162 | button.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 | |||
| 476 | html[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; |
