diff options
Diffstat (limited to 'renderer/src/components')
| -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 |
3 files changed, 254 insertions, 153 deletions
diff --git a/renderer/src/components/ChatPane.tsx b/renderer/src/components/ChatPane.tsx index c2cdfbb..4136d16 100644 --- a/renderer/src/components/ChatPane.tsx +++ b/renderer/src/components/ChatPane.tsx | |||
| @@ -45,10 +45,10 @@ export function ChatPane({ | |||
| 45 | style={{ width: collapsed ? 28 : chatWidth }} | 45 | style={{ width: collapsed ? 28 : chatWidth }} |
| 46 | > | 46 | > |
| 47 | <div className="chat-header"> | 47 | <div className="chat-header"> |
| 48 | {!collapsed && <span>Chat</span>} | ||
| 49 | <button className="chat-collapse-btn" onClick={onToggleCollapse}> | 48 | <button className="chat-collapse-btn" onClick={onToggleCollapse}> |
| 50 | {collapsed ? "β¨" : "β©"} | 49 | {collapsed ? "β" : "β·"} |
| 51 | </button> | 50 | </button> |
| 51 | {!collapsed && <span>Chat</span>} | ||
| 52 | </div> | 52 | </div> |
| 53 | 53 | ||
| 54 | {!collapsed && ( | 54 | {!collapsed && ( |
diff --git a/renderer/src/components/Header.tsx b/renderer/src/components/Header.tsx index dc88a73..4e193e8 100644 --- a/renderer/src/components/Header.tsx +++ b/renderer/src/components/Header.tsx | |||
| @@ -1,27 +1,18 @@ | |||
| 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 | } | ||
