From 283013c09d4855529e846951a1e090f0f16030a8 Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 28 Feb 2026 19:34:02 -0800 Subject: feat: auto session naming --- renderer/src/App.tsx | 70 +++++++++++++++++++++++++++++--- renderer/src/components/Header.tsx | 81 +++++++++++++++++++++++++++++++------- renderer/src/styles/globals.css | 28 +++++++++++++ src/main/index.ts | 4 +- src/main/ipc/handlers.ts | 6 ++- 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"; const api = window.api; +/** + * Derive a short session name (≤30 chars) from research.md content. + * Looks for the first non-empty body line under ## Overview. + * Falls back to the first non-heading line in the document. + * Returns null if nothing usable is found. + */ +function extractSessionName(content: string): string | null { + const truncate = (s: string) => + s.length > 30 ? s.slice(0, 28) + "\u2026" : s; + + // Primary: first sentence of ## Overview body + const overviewMatch = content.match(/##\s+Overview\s*\n([\s\S]*?)(?=\n##|\n---)/); + if (overviewMatch) { + const firstLine = overviewMatch[1] + .split("\n") + .map((l) => l.trim()) + .find((l) => l.length > 0 && !l.startsWith("#")); + if (firstLine) { + const firstSentence = firstLine.split(/[.?!]/)[0].trim(); + if (firstSentence.length > 0) return truncate(firstSentence); + } + } + + // Fallback: first non-heading line anywhere in the document + const firstLine = content + .split("\n") + .map((l) => l.trim()) + .find((l) => l.length > 0 && !l.startsWith("#")); + if (!firstLine) return null; + return truncate(firstLine); +} + type Theme = "dark" | "light"; export function App() { @@ -125,11 +157,27 @@ export function App() { if (selectedProject && selectedSession) { const filename = selectedSession.phase === "research" ? "research.md" : "plan.md"; - api.readSessionArtifact(selectedProject.id, selectedSession.id, filename).then((content) => { - const text = content || ""; - setDocumentContent(text); - setOriginalContent(text); - }); + // Capture for the async closure + const sessionAtTrigger = selectedSession; + api + .readSessionArtifact(selectedProject.id, sessionAtTrigger.id, filename) + .then((content) => { + const text = content || ""; + setDocumentContent(text); + setOriginalContent(text); + + // Auto-name: only during research, only while name is still the default + if ( + sessionAtTrigger.phase === "research" && + /^Session \d+$/.test(sessionAtTrigger.name) && + text.length > 0 + ) { + const derived = extractSessionName(text); + if (derived) { + handleRenameSession(sessionAtTrigger.id, derived); + } + } + }); } } @@ -285,6 +333,17 @@ export function App() { } }; + const handleRenameSession = async (id: string, name: string) => { + await api.renameSession(id, name); + setSessions((prev) => + prev.map((s) => (s.id === id ? { ...s, name } : s)) + ); + // Use functional updater to avoid stale-closure issues + setSelectedSession((prev) => + prev?.id === id ? { ...prev, name } : prev + ); + }; + return (
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 @@ -import React from "react"; +import React, { useState } from "react"; import type { Project, Session, Phase } from "../types"; type Theme = "dark" | "light"; @@ -14,6 +14,7 @@ interface HeaderProps { onCreateSession: () => void; onDeleteProject?: (id: string) => void; onDeleteSession?: (id: string) => void; + onRenameSession?: (id: string, name: string) => void; theme: Theme; onToggleTheme: () => void; } @@ -37,6 +38,7 @@ export function Header({ onCreateSession, onDeleteProject, onDeleteSession, + onRenameSession, theme, onToggleTheme, }: HeaderProps) { @@ -54,6 +56,32 @@ export function Header({ } }; + const [isRenamingSession, setIsRenamingSession] = useState(false); + const [renameValue, setRenameValue] = useState(""); + // Guard against double-commit (onKeyDown Enter → unmount → onBlur) + const renameCommitted = React.useRef(false); + + const startRename = () => { + if (!selectedSession) return; + renameCommitted.current = false; + setRenameValue(selectedSession.name); + setIsRenamingSession(true); + }; + + const commitRename = () => { + if (renameCommitted.current) return; + renameCommitted.current = true; + if (selectedSession && onRenameSession && renameValue.trim()) { + onRenameSession(selectedSession.id, renameValue.trim()); + } + setIsRenamingSession(false); + }; + + const cancelRename = () => { + renameCommitted.current = true; // prevent blur from committing after cancel + setIsRenamingSession(false); + }; + return (
@@ -88,21 +116,44 @@ export function Header({ {selectedProject && ( <> - + {isRenamingSession ? ( + setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commitRename(); + if (e.key === "Escape") cancelRename(); + }} + onBlur={commitRename} + className="session-rename-input" + /> + ) : ( + + )} + {selectedSession && onRenameSession && !isRenamingSession && ( + + )} {selectedSession && onDeleteSession && (