diff options
| -rw-r--r-- | renderer/src/App.tsx | 12 | ||||
| -rw-r--r-- | renderer/src/components/Header.tsx | 41 | ||||
| -rw-r--r-- | renderer/src/styles/globals.css | 6 | ||||
| -rw-r--r-- | src/main/git.ts | 86 | ||||
| -rw-r--r-- | src/main/ipc/handlers.ts | 30 | ||||
| -rw-r--r-- | src/main/preload.ts | 2 |
6 files changed, 128 insertions, 49 deletions
diff --git a/renderer/src/App.tsx b/renderer/src/App.tsx index ecbb5b2..74b1f91 100644 --- a/renderer/src/App.tsx +++ b/renderer/src/App.tsx | |||
| @@ -263,13 +263,17 @@ export function App() { | |||
| 263 | selectedSession.phase === "research" ? "research.md" : "plan.md"; | 263 | selectedSession.phase === "research" ? "research.md" : "plan.md"; |
| 264 | await api.writeSessionArtifact(selectedProject.id, selectedSession.id, filename, documentContent); | 264 | await api.writeSessionArtifact(selectedProject.id, selectedSession.id, filename, documentContent); |
| 265 | 265 | ||
| 266 | const nextPhase = await api.advancePhase(selectedSession.id); | 266 | const advanced = await api.advancePhase(selectedSession.id); |
| 267 | if (nextPhase) { | 267 | if (advanced) { |
| 268 | setSelectedSession({ ...selectedSession, phase: nextPhase }); | 268 | setSelectedSession({ |
| 269 | ...selectedSession, | ||
| 270 | phase: advanced.phase, | ||
| 271 | git_branch: advanced.git_branch, | ||
| 272 | }); | ||
| 269 | // Trigger initial message for next phase | 273 | // Trigger initial message for next phase |
| 270 | setIsLoading(true); | 274 | setIsLoading(true); |
| 271 | const initialMsg = | 275 | const initialMsg = |
| 272 | nextPhase === "plan" | 276 | advanced.phase === "plan" |
| 273 | ? "Create a detailed implementation plan based on the research." | 277 | ? "Create a detailed implementation plan based on the research." |
| 274 | : "Begin implementing the plan."; | 278 | : "Begin implementing the plan."; |
| 275 | await api.sendMessage(selectedSession.id, initialMsg); | 279 | await api.sendMessage(selectedSession.id, initialMsg); |
diff --git a/renderer/src/components/Header.tsx b/renderer/src/components/Header.tsx index e56264f..fc0289d 100644 --- a/renderer/src/components/Header.tsx +++ b/renderer/src/components/Header.tsx | |||
| @@ -152,21 +152,23 @@ export function Header({ | |||
| 152 | {sessions.map((s) => ( | 152 | {sessions.map((s) => ( |
| 153 | <option key={s.id} value={s.id}> | 153 | <option key={s.id} value={s.id}> |
| 154 | {s.name} | 154 | {s.name} |
| 155 | {s.git_branch ? ` · ${s.git_branch}` : " · git unavailable"} | ||
| 156 | </option> | 155 | </option> |
| 157 | ))} | 156 | ))} |
| 158 | </select> | 157 | </select> |
| 159 | )} | 158 | )} |
| 160 | <button onClick={onCreateSession}>+ Session</button> | 159 | <button onClick={onCreateSession}>+ Session</button> |
| 161 | {selectedSession && onRenameSession && !isRenamingSession && ( | 160 | {selectedSession && |
| 162 | <button | 161 | onRenameSession && |
| 163 | onClick={startRename} | 162 | !isRenamingSession && |
| 164 | className="btn-rename" | 163 | selectedSession.phase !== "implement" && ( |
| 165 | title="Rename session" | 164 | <button |
| 166 | > | 165 | onClick={startRename} |
| 167 | ✏️ | 166 | className="btn-rename" |
| 168 | </button> | 167 | title="Rename session" |
| 169 | )} | 168 | > |
| 169 | ✏️ | ||
| 170 | </button> | ||
| 171 | )} | ||
| 170 | {selectedSession && onDeleteSession && ( | 172 | {selectedSession && onDeleteSession && ( |
| 171 | <button | 173 | <button |
| 172 | onClick={handleDeleteSession} | 174 | onClick={handleDeleteSession} |
| @@ -204,26 +206,15 @@ export function Header({ | |||
| 204 | )} | 206 | )} |
| 205 | 207 | ||
| 206 | {/* ── Branch badge ── */} | 208 | {/* ── Branch badge ── */} |
| 207 | {selectedSession && ( | 209 | {selectedSession && gitBranch && ( |
| 208 | <button | 210 | <button |
| 209 | className={[ | 211 | className={["branch-badge", copied ? "branch-copied" : ""] |
| 210 | "branch-badge", | ||
| 211 | gitBranch ? "" : "branch-unavailable", | ||
| 212 | copied ? "branch-copied" : "", | ||
| 213 | ] | ||
| 214 | .filter(Boolean) | 212 | .filter(Boolean) |
| 215 | .join(" ")} | 213 | .join(" ")} |
| 216 | onClick={handleCopyBranch} | 214 | onClick={handleCopyBranch} |
| 217 | disabled={!gitBranch} | 215 | title={copied ? "Copied!" : `Click to copy: ${gitBranch}`} |
| 218 | title={ | ||
| 219 | gitBranch | ||
| 220 | ? copied | ||
| 221 | ? "Copied!" | ||
| 222 | : `Click to copy: ${gitBranch}` | ||
| 223 | : "Git unavailable for this session" | ||
| 224 | } | ||
| 225 | > | 216 | > |
| 226 | ⎇ {gitBranch ?? "git unavailable"} | 217 | ⎇ {gitBranch} |
| 227 | </button> | 218 | </button> |
| 228 | )} | 219 | )} |
| 229 | 220 | ||
diff --git a/renderer/src/styles/globals.css b/renderer/src/styles/globals.css index 9d37742..ef0275e 100644 --- a/renderer/src/styles/globals.css +++ b/renderer/src/styles/globals.css | |||
| @@ -206,12 +206,6 @@ html[data-theme="light"] .session-rename-input { | |||
| 206 | color: white; | 206 | color: white; |
| 207 | } | 207 | } |
| 208 | 208 | ||
| 209 | .branch-badge.branch-unavailable { | ||
| 210 | border-style: dashed; | ||
| 211 | opacity: 0.5; | ||
| 212 | cursor: default; | ||
| 213 | } | ||
| 214 | |||
| 215 | /* ── Main Content ─────────────────────────────────────────────── */ | 209 | /* ── Main Content ─────────────────────────────────────────────── */ |
| 216 | .main-content { | 210 | .main-content { |
| 217 | flex: 1; | 211 | flex: 1; |
diff --git a/src/main/git.ts b/src/main/git.ts index 58dc860..20362a7 100644 --- a/src/main/git.ts +++ b/src/main/git.ts | |||
| @@ -146,6 +146,81 @@ function extractTaskName(checkboxLine: string): string { | |||
| 146 | } | 146 | } |
| 147 | 147 | ||
| 148 | // --------------------------------------------------------------------------- | 148 | // --------------------------------------------------------------------------- |
| 149 | // Commit subject builders | ||
| 150 | // --------------------------------------------------------------------------- | ||
| 151 | |||
| 152 | const LOCK_FILES = new Set([ | ||
| 153 | "package-lock.json", | ||
| 154 | "yarn.lock", | ||
| 155 | "pnpm-lock.yaml", | ||
| 156 | "bun.lockb", | ||
| 157 | "Cargo.lock", | ||
| 158 | "Gemfile.lock", | ||
| 159 | "poetry.lock", | ||
| 160 | ]); | ||
| 161 | |||
| 162 | /** | ||
| 163 | * Builds a `feat:` subject from completed task names. | ||
| 164 | * Keeps the total subject ≤ 72 chars. | ||
| 165 | * 1 task → "feat: {name}" | ||
| 166 | * N tasks → "feat: {name} (+N-1 more)" | ||
| 167 | */ | ||
| 168 | function buildTaskSubject(taskNames: string[]): string { | ||
| 169 | const prefix = "feat: "; | ||
| 170 | const MAX = 72; | ||
| 171 | |||
| 172 | if (taskNames.length === 1) { | ||
| 173 | const full = `${prefix}${taskNames[0]}`; | ||
| 174 | if (full.length <= MAX) return full; | ||
| 175 | return `${prefix}${taskNames[0].slice(0, MAX - prefix.length - 1)}\u2026`; | ||
| 176 | } | ||
| 177 | |||
| 178 | const suffix = ` (+${taskNames.length - 1} more)`; | ||
| 179 | const available = MAX - prefix.length - suffix.length; | ||
| 180 | const first = taskNames[0]; | ||
| 181 | const truncated = | ||
| 182 | first.length > available ? `${first.slice(0, available - 1)}\u2026` : first; | ||
| 183 | return `${prefix}${truncated}${suffix}`; | ||
| 184 | } | ||
| 185 | |||
| 186 | /** | ||
| 187 | * Returns the basenames of staged files, excluding lock files. | ||
| 188 | * Returns empty array on any failure. | ||
| 189 | */ | ||
| 190 | function getStagedFileNames(projectPath: string): string[] { | ||
| 191 | try { | ||
| 192 | const raw = execFileSync("git", ["diff", "--cached", "--name-only"], { | ||
| 193 | cwd: projectPath, | ||
| 194 | stdio: "pipe", | ||
| 195 | }) | ||
| 196 | .toString() | ||
| 197 | .trim(); | ||
| 198 | if (!raw) return []; | ||
| 199 | return raw | ||
| 200 | .split("\n") | ||
| 201 | .map((f) => path.basename(f.trim())) | ||
| 202 | .filter((f) => f && !LOCK_FILES.has(f)); | ||
| 203 | } catch { | ||
| 204 | return []; | ||
| 205 | } | ||
| 206 | } | ||
| 207 | |||
| 208 | /** | ||
| 209 | * Builds a `chore:` subject from the staged file list when no tasks completed. | ||
| 210 | * 1 file → "chore: Update {file}" | ||
| 211 | * 2 files → "chore: Update {file1}, {file2}" | ||
| 212 | * 3+ files → "chore: Update {file1} (+N more files)" | ||
| 213 | * 0 files → "chore: Implement progress" | ||
| 214 | */ | ||
| 215 | function buildFileSubject(projectPath: string): string { | ||
| 216 | const files = getStagedFileNames(projectPath); | ||
| 217 | if (files.length === 0) return "chore: Implement progress"; | ||
| 218 | if (files.length === 1) return `chore: Update ${files[0]}`; | ||
| 219 | if (files.length === 2) return `chore: Update ${files[0]}, ${files[1]}`; | ||
| 220 | return `chore: Update ${files[0]} (+${files.length - 1} more files)`; | ||
| 221 | } | ||
| 222 | |||
| 223 | // --------------------------------------------------------------------------- | ||
| 149 | // Auto-commit | 224 | // Auto-commit |
| 150 | // --------------------------------------------------------------------------- | 225 | // --------------------------------------------------------------------------- |
| 151 | 226 | ||
| @@ -184,13 +259,12 @@ export function autoCommitTurn( | |||
| 184 | 259 | ||
| 185 | let commitMsg: string; | 260 | let commitMsg: string; |
| 186 | if (newlyCompleted.length > 0) { | 261 | if (newlyCompleted.length > 0) { |
| 187 | const count = newlyCompleted.length; | 262 | const taskNames = newlyCompleted.map(extractTaskName); |
| 188 | const taskLines = newlyCompleted | 263 | const subject = buildTaskSubject(taskNames); |
| 189 | .map((l) => `- ✅ ${extractTaskName(l)}`) | 264 | const body = taskNames.map((t) => `- \u2705 ${t}`).join("\n"); |
| 190 | .join("\n"); | 265 | commitMsg = `${subject}\n\n${body}`; |
| 191 | commitMsg = `feat: Complete ${count} task${count > 1 ? "s" : ""}\n\n${taskLines}`; | ||
| 192 | } else { | 266 | } else { |
| 193 | commitMsg = "chore: Implement progress (no tasks completed this turn)"; | 267 | commitMsg = buildFileSubject(projectPath); |
| 194 | } | 268 | } |
| 195 | 269 | ||
| 196 | execFileSync("git", ["commit", "-m", commitMsg], { cwd: projectPath }); | 270 | execFileSync("git", ["commit", "-m", commitMsg], { cwd: projectPath }); |
diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index f0a5b82..774eb63 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts | |||
| @@ -2,7 +2,7 @@ import { ipcMain, dialog, type BrowserWindow } from "electron"; | |||
| 2 | import * as projects from "../db/projects"; | 2 | import * as projects from "../db/projects"; |
| 3 | import * as sessions from "../db/sessions"; | 3 | import * as sessions from "../db/sessions"; |
| 4 | import * as claude from "../claude"; | 4 | import * as claude from "../claude"; |
| 5 | import { createSessionBranch } from "../git"; | 5 | import { createSessionBranch, ensureGitIgnore } from "../git"; |
| 6 | import type { UserPermissionMode } from "../claude/phases"; | 6 | import type { UserPermissionMode } from "../claude/phases"; |
| 7 | 7 | ||
| 8 | export function registerIpcHandlers(mainWindow: BrowserWindow): void { | 8 | export function registerIpcHandlers(mainWindow: BrowserWindow): void { |
| @@ -26,12 +26,11 @@ export function registerIpcHandlers(mainWindow: BrowserWindow): void { | |||
| 26 | 26 | ||
| 27 | const session = sessions.createSession(projectId, name); | 27 | const session = sessions.createSession(projectId, name); |
| 28 | 28 | ||
| 29 | const branchName = createSessionBranch(project.path, session.name, session.id); | 29 | // Ensure .claude-flow/ is gitignored from day one. |
| 30 | if (branchName) { | 30 | // Branch creation is deferred until the session advances to implement. |
| 31 | sessions.updateSession(session.id, { git_branch: branchName }); | 31 | try { ensureGitIgnore(project.path); } catch { /* non-fatal */ } |
| 32 | } | ||
| 33 | 32 | ||
| 34 | return { ...session, git_branch: branchName ?? null }; | 33 | return session; |
| 35 | }); | 34 | }); |
| 36 | 35 | ||
| 37 | ipcMain.handle("sessions:delete", (_, id: string) => { | 36 | ipcMain.handle("sessions:delete", (_, id: string) => { |
| @@ -103,7 +102,24 @@ export function registerIpcHandlers(mainWindow: BrowserWindow): void { | |||
| 103 | ipcMain.handle("workflow:advance", (_, sessionId: string) => { | 102 | ipcMain.handle("workflow:advance", (_, sessionId: string) => { |
| 104 | const session = sessions.getSession(sessionId); | 103 | const session = sessions.getSession(sessionId); |
| 105 | if (!session) throw new Error("Session not found"); | 104 | if (!session) throw new Error("Session not found"); |
| 106 | return claude.advancePhase(session); | 105 | |
| 106 | const nextPhase = claude.advancePhase(session); | ||
| 107 | if (!nextPhase) return null; | ||
| 108 | |||
| 109 | let git_branch = session.git_branch; | ||
| 110 | |||
| 111 | if (nextPhase === "implement") { | ||
| 112 | const project = projects.getProject(session.project_id); | ||
| 113 | if (project) { | ||
| 114 | const branchName = createSessionBranch(project.path, session.name, session.id); | ||
| 115 | if (branchName) { | ||
| 116 | sessions.updateSession(sessionId, { git_branch: branchName }); | ||
| 117 | git_branch = branchName; | ||
| 118 | } | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | return { phase: nextPhase, git_branch }; | ||
| 107 | }); | 123 | }); |
| 108 | 124 | ||
| 109 | ipcMain.handle( | 125 | ipcMain.handle( |
diff --git a/src/main/preload.ts b/src/main/preload.ts index f377639..299a1b5 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts | |||
| @@ -26,7 +26,7 @@ export interface ClaudeFlowAPI { | |||
| 26 | 26 | ||
| 27 | // Workflow | 27 | // Workflow |
| 28 | triggerReview: (sessionId: string) => Promise<void>; | 28 | triggerReview: (sessionId: string) => Promise<void>; |
| 29 | advancePhase: (sessionId: string) => Promise<Phase | null>; | 29 | advancePhase: (sessionId: string) => Promise<{ phase: Phase; git_branch: string | null } | null>; |
| 30 | setPermissionMode: ( | 30 | setPermissionMode: ( |
| 31 | sessionId: string, | 31 | sessionId: string, |
| 32 | mode: UserPermissionMode | 32 | mode: UserPermissionMode |
