diff options
| -rw-r--r-- | renderer/src/App.tsx | 70 | ||||
| -rw-r--r-- | renderer/src/components/Header.tsx | 81 | ||||
| -rw-r--r-- | renderer/src/styles/globals.css | 28 | ||||
| -rw-r--r-- | src/main/index.ts | 4 | ||||
| -rw-r--r-- | src/main/ipc/handlers.ts | 6 | ||||
| -rw-r--r-- | src/main/preload.ts | 2 |
6 files changed, 169 insertions, 22 deletions
diff --git a/renderer/src/App.tsx b/renderer/src/App.tsx index f7ba41d..19f6284 100644 --- a/renderer/src/App.tsx +++ b/renderer/src/App.tsx | |||
| @@ -8,6 +8,38 @@ import "./styles/globals.css"; | |||
| 8 | 8 | ||
| 9 | const api = window.api; | 9 | const api = window.api; |
| 10 | 10 | ||
| 11 | /** | ||
| 12 | * Derive a short session name (≤30 chars) from research.md content. | ||
| 13 | * Looks for the first non-empty body line under ## Overview. | ||
| 14 | * Falls back to the first non-heading line in the document. | ||
| 15 | * Returns null if nothing usable is found. | ||
| 16 | */ | ||
| 17 | function extractSessionName(content: string): string | null { | ||
| 18 | const truncate = (s: string) => | ||
| 19 | s.length > 30 ? s.slice(0, 28) + "\u2026" : s; | ||
| 20 | |||
| 21 | // Primary: first sentence of ## Overview body | ||
| 22 | const overviewMatch = content.match(/##\s+Overview\s*\n([\s\S]*?)(?=\n##|\n---)/); | ||
| 23 | if (overviewMatch) { | ||
| 24 | const firstLine = overviewMatch[1] | ||
| 25 | .split("\n") | ||
| 26 | .map((l) => l.trim()) | ||
| 27 | .find((l) => l.length > 0 && !l.startsWith("#")); | ||
| 28 | if (firstLine) { | ||
| 29 | const firstSentence = firstLine.split(/[.?!]/)[0].trim(); | ||
| 30 | if (firstSentence.length > 0) return truncate(firstSentence); | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | // Fallback: first non-heading line anywhere in the document | ||
| 35 | const firstLine = content | ||
| 36 | .split("\n") | ||
| 37 | .map((l) => l.trim()) | ||
| 38 | .find((l) => l.length > 0 && !l.startsWith("#")); | ||
| 39 | if (!firstLine) return null; | ||
| 40 | return truncate(firstLine); | ||
| 41 | } | ||
| 42 | |||
| 11 | type Theme = "dark" | "light"; | 43 | type Theme = "dark" | "light"; |
| 12 | 44 | ||
| 13 | export function App() { | 45 | export function App() { |
| @@ -125,11 +157,27 @@ export function App() { | |||
| 125 | if (selectedProject && selectedSession) { | 157 | if (selectedProject && selectedSession) { |
| 126 | const filename = | 158 | const filename = |
| 127 | selectedSession.phase === "research" ? "research.md" : "plan.md"; | 159 | selectedSession.phase === "research" ? "research.md" : "plan.md"; |
| 128 | api.readSessionArtifact(selectedProject.id, selectedSession.id, filename).then((content) => { | 160 | // Capture for the async closure |
| 129 | const text = content || ""; | 161 | const sessionAtTrigger = selectedSession; |
| 130 | setDocumentContent(text); | 162 | api |
| 131 | setOriginalContent(text); | 163 | .readSessionArtifact(selectedProject.id, sessionAtTrigger.id, filename) |
| 132 | }); | 164 | .then((content) => { |
| 165 | const text = content || ""; | ||
| 166 | setDocumentContent(text); | ||
| 167 | setOriginalContent(text); | ||
| 168 | |||
| 169 | // Auto-name: only during research, only while name is still the default | ||
| 170 | if ( | ||
| 171 | sessionAtTrigger.phase === "research" && | ||
| 172 | /^Session \d+$/.test(sessionAtTrigger.name) && | ||
| 173 | text.length > 0 | ||
| 174 | ) { | ||
| 175 | const derived = extractSessionName(text); | ||
| 176 | if (derived) { | ||
| 177 | handleRenameSession(sessionAtTrigger.id, derived); | ||
| 178 | } | ||
| 179 | } | ||
| 180 | }); | ||
| 133 | } | 181 | } |
| 134 | } | 182 | } |
| 135 | 183 | ||
| @@ -285,6 +333,17 @@ export function App() { | |||
| 285 | } | 333 | } |
| 286 | }; | 334 | }; |
| 287 | 335 | ||
| 336 | const handleRenameSession = async (id: string, name: string) => { | ||
| 337 | await api.renameSession(id, name); | ||
| 338 | setSessions((prev) => | ||
| 339 | prev.map((s) => (s.id === id ? { ...s, name } : s)) | ||
| 340 | ); | ||
| 341 | // Use functional updater to avoid stale-closure issues | ||
| 342 | setSelectedSession((prev) => | ||
| 343 | prev?.id === id ? { ...prev, name } : prev | ||
| 344 | ); | ||
| 345 | }; | ||
| 346 | |||
| 288 | return ( | 347 | return ( |
| 289 | <div className="app"> | 348 | <div className="app"> |
| 290 | <Header | 349 | <Header |
| @@ -298,6 +357,7 @@ export function App() { | |||
| 298 | onCreateSession={handleCreateSession} | 357 | onCreateSession={handleCreateSession} |
| 299 | onDeleteProject={handleDeleteProject} | 358 | onDeleteProject={handleDeleteProject} |
| 300 | onDeleteSession={handleDeleteSession} | 359 | onDeleteSession={handleDeleteSession} |
| 360 | onRenameSession={handleRenameSession} | ||
| 301 | theme={theme} | 361 | theme={theme} |
| 302 | onToggleTheme={handleToggleTheme} | 362 | onToggleTheme={handleToggleTheme} |
| 303 | /> | 363 | /> |
diff --git a/renderer/src/components/Header.tsx b/renderer/src/components/Header.tsx index b4faa6e..a435519 100644 --- a/renderer/src/components/Header.tsx +++ b/renderer/src/components/Header.tsx | |||
| @@ -1,4 +1,4 @@ | |||
| 1 | import React from "react"; | 1 | import React, { useState } from "react"; |
| 2 | import type { Project, Session, Phase } from "../types"; | 2 | import type { Project, Session, Phase } from "../types"; |
| 3 | 3 | ||
| 4 | type Theme = "dark" | "light"; | 4 | type Theme = "dark" | "light"; |
| @@ -14,6 +14,7 @@ interface HeaderProps { | |||
| 14 | onCreateSession: () => void; | 14 | onCreateSession: () => void; |
| 15 | onDeleteProject?: (id: string) => void; | 15 | onDeleteProject?: (id: string) => void; |
| 16 | onDeleteSession?: (id: string) => void; | 16 | onDeleteSession?: (id: string) => void; |
| 17 | onRenameSession?: (id: string, name: string) => void; | ||
| 17 | theme: Theme; | 18 | theme: Theme; |
| 18 | onToggleTheme: () => void; | 19 | onToggleTheme: () => void; |
| 19 | } | 20 | } |
| @@ -37,6 +38,7 @@ export function Header({ | |||
| 37 | onCreateSession, | 38 | onCreateSession, |
| 38 | onDeleteProject, | 39 | onDeleteProject, |
| 39 | onDeleteSession, | 40 | onDeleteSession, |
| 41 | onRenameSession, | ||
| 40 | theme, | 42 | theme, |
| 41 | onToggleTheme, | 43 | onToggleTheme, |
| 42 | }: HeaderProps) { | 44 | }: HeaderProps) { |
| @@ -54,6 +56,32 @@ export function Header({ | |||
| 54 | } | 56 | } |
| 55 | }; | 57 | }; |
| 56 | 58 | ||
| 59 | const [isRenamingSession, setIsRenamingSession] = useState(false); | ||
| 60 | const [renameValue, setRenameValue] = useState(""); | ||
| 61 | // Guard against double-commit (onKeyDown Enter → unmount → onBlur) | ||
| 62 | const renameCommitted = React.useRef(false); | ||
| 63 | |||
| 64 | const startRename = () => { | ||
| 65 | if (!selectedSession) return; | ||
| 66 | renameCommitted.current = false; | ||
| 67 | setRenameValue(selectedSession.name); | ||
| 68 | setIsRenamingSession(true); | ||
| 69 | }; | ||
| 70 | |||
| 71 | const commitRename = () => { | ||
| 72 | if (renameCommitted.current) return; | ||
| 73 | renameCommitted.current = true; | ||
| 74 | if (selectedSession && onRenameSession && renameValue.trim()) { | ||
| 75 | onRenameSession(selectedSession.id, renameValue.trim()); | ||
| 76 | } | ||
| 77 | setIsRenamingSession(false); | ||
| 78 | }; | ||
| 79 | |||
| 80 | const cancelRename = () => { | ||
| 81 | renameCommitted.current = true; // prevent blur from committing after cancel | ||
| 82 | setIsRenamingSession(false); | ||
| 83 | }; | ||
| 84 | |||
| 57 | return ( | 85 | return ( |
| 58 | <header className="header"> | 86 | <header className="header"> |
| 59 | <div className="header-left"> | 87 | <div className="header-left"> |
| @@ -88,21 +116,44 @@ export function Header({ | |||
| 88 | 116 | ||
| 89 | {selectedProject && ( | 117 | {selectedProject && ( |
| 90 | <> | 118 | <> |
| 91 | <select | 119 | {isRenamingSession ? ( |
| 92 | value={selectedSession?.id || ""} | 120 | <input |
| 93 | onChange={(e) => { | 121 | autoFocus |
| 94 | const session = sessions.find((s) => s.id === e.target.value); | 122 | value={renameValue} |
| 95 | onSelectSession(session || null); | 123 | onChange={(e) => setRenameValue(e.target.value)} |
| 96 | }} | 124 | onKeyDown={(e) => { |
| 97 | > | 125 | if (e.key === "Enter") commitRename(); |
| 98 | <option value="">Select Session...</option> | 126 | if (e.key === "Escape") cancelRename(); |
| 99 | {sessions.map((s) => ( | 127 | }} |
| 100 | <option key={s.id} value={s.id}> | 128 | onBlur={commitRename} |
| 101 | {s.name} | 129 | className="session-rename-input" |
| 102 | </option> | 130 | /> |
| 103 | ))} | 131 | ) : ( |
| 104 | </select> | 132 | <select |
| 133 | value={selectedSession?.id || ""} | ||
| 134 | onChange={(e) => { | ||
| 135 | const session = sessions.find((s) => s.id === e.target.value); | ||
| 136 | onSelectSession(session || null); | ||
| 137 | }} | ||
| 138 | > | ||
| 139 | <option value="">Select Session...</option> | ||
| 140 | {sessions.map((s) => ( | ||
| 141 | <option key={s.id} value={s.id}> | ||
| 142 | {s.name} | ||
| 143 | </option> | ||
| 144 | ))} | ||
| 145 | </select> | ||
| 146 | )} | ||
| 105 | <button onClick={onCreateSession}>+ Session</button> | 147 | <button onClick={onCreateSession}>+ Session</button> |
| 148 | {selectedSession && onRenameSession && !isRenamingSession && ( | ||
| 149 | <button | ||
| 150 | onClick={startRename} | ||
| 151 | className="btn-rename" | ||
| 152 | title="Rename session" | ||
| 153 | > | ||
| 154 | ✏️ | ||
| 155 | </button> | ||
| 156 | )} | ||
| 106 | {selectedSession && onDeleteSession && ( | 157 | {selectedSession && onDeleteSession && ( |
| 107 | <button | 158 | <button |
| 108 | onClick={handleDeleteSession} | 159 | onClick={handleDeleteSession} |
diff --git a/renderer/src/styles/globals.css b/renderer/src/styles/globals.css index 97b7bb8..f141538 100644 --- a/renderer/src/styles/globals.css +++ b/renderer/src/styles/globals.css | |||
| @@ -111,6 +111,34 @@ body { | |||
| 111 | border-color: var(--danger); | 111 | border-color: var(--danger); |
| 112 | } | 112 | } |
| 113 | 113 | ||
| 114 | .header button.btn-rename { | ||
| 115 | background: transparent; | ||
| 116 | border: 1px solid var(--border); | ||
| 117 | padding: 5px 8px; | ||
| 118 | font-size: 13px; | ||
| 119 | } | ||
| 120 | |||
| 121 | .header button.btn-rename:hover { | ||
| 122 | background: var(--bg-tertiary); | ||
| 123 | } | ||
| 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 | |||
| 114 | /* Theme toggle */ | 142 | /* Theme toggle */ |
| 115 | .theme-toggle { | 143 | .theme-toggle { |
| 116 | font-size: 11px; | 144 | font-size: 11px; |
diff --git a/src/main/index.ts b/src/main/index.ts index f0b23f7..a7bed00 100644 --- a/src/main/index.ts +++ b/src/main/index.ts | |||
| @@ -1,4 +1,4 @@ | |||
| 1 | import { app, BrowserWindow } from "electron"; | 1 | import { app, BrowserWindow, Menu } from "electron"; |
| 2 | import path from "node:path"; | 2 | import path from "node:path"; |
| 3 | import { getDb, closeDb } from "./db"; | 3 | import { getDb, closeDb } from "./db"; |
| 4 | import { registerIpcHandlers } from "./ipc/handlers"; | 4 | import { registerIpcHandlers } from "./ipc/handlers"; |
| @@ -41,6 +41,8 @@ function createWindow() { | |||
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | app.whenReady().then(() => { | 43 | app.whenReady().then(() => { |
| 44 | Menu.setApplicationMenu(null); | ||
| 45 | |||
| 44 | // Initialize database | 46 | // Initialize database |
| 45 | getDb(); | 47 | getDb(); |
| 46 | 48 | ||
diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index d9beaf0..145c6e2 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts | |||
| @@ -31,7 +31,11 @@ export function registerIpcHandlers(mainWindow: BrowserWindow): void { | |||
| 31 | } | 31 | } |
| 32 | sessions.deleteSession(id); | 32 | sessions.deleteSession(id); |
| 33 | }); | 33 | }); |
| 34 | 34 | ||
| 35 | ipcMain.handle("sessions:rename", (_, id: string, name: string) => { | ||
| 36 | sessions.updateSession(id, { name }); | ||
| 37 | }); | ||
| 38 | |||
| 35 | ipcMain.handle("sessions:get", (_, id: string) => sessions.getSession(id)); | 39 | ipcMain.handle("sessions:get", (_, id: string) => sessions.getSession(id)); |
| 36 | 40 | ||
| 37 | // Messages | 41 | // Messages |
diff --git a/src/main/preload.ts b/src/main/preload.ts index 2c228dd..f377639 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts | |||
| @@ -15,6 +15,7 @@ export interface ClaudeFlowAPI { | |||
| 15 | createSession: (projectId: string, name: string) => Promise<Session>; | 15 | createSession: (projectId: string, name: string) => Promise<Session>; |
| 16 | deleteSession: (id: string) => Promise<void>; | 16 | deleteSession: (id: string) => Promise<void>; |
| 17 | getSession: (id: string) => Promise<Session | undefined>; | 17 | getSession: (id: string) => Promise<Session | undefined>; |
| 18 | renameSession: (id: string, name: string) => Promise<void>; | ||
| 18 | 19 | ||
| 19 | // Messages | 20 | // Messages |
| 20 | listMessages: (sessionId: string) => Promise<Message[]>; | 21 | listMessages: (sessionId: string) => Promise<Message[]>; |
| @@ -70,6 +71,7 @@ const api: ClaudeFlowAPI = { | |||
| 70 | ipcRenderer.invoke("sessions:create", projectId, name), | 71 | ipcRenderer.invoke("sessions:create", projectId, name), |
| 71 | deleteSession: (id) => ipcRenderer.invoke("sessions:delete", id), | 72 | deleteSession: (id) => ipcRenderer.invoke("sessions:delete", id), |
| 72 | getSession: (id) => ipcRenderer.invoke("sessions:get", id), | 73 | getSession: (id) => ipcRenderer.invoke("sessions:get", id), |
| 74 | renameSession: (id, name) => ipcRenderer.invoke("sessions:rename", id, name), | ||
| 73 | 75 | ||
| 74 | // Messages | 76 | // Messages |
| 75 | listMessages: (sessionId) => ipcRenderer.invoke("messages:list", sessionId), | 77 | listMessages: (sessionId) => ipcRenderer.invoke("messages:list", sessionId), |
