From 283013c09d4855529e846951a1e090f0f16030a8 Mon Sep 17 00:00:00 2001
From: bndw <ben@bdw.to>
Date: Sat, 28 Feb 2026 19:34:02 -0800
Subject: feat: auto session naming

---
 renderer/src/App.tsx | 70 ++++++++++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 65 insertions(+), 5 deletions(-)

(limited to 'renderer/src/App.tsx')

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 (
     <div className="app">
       <Header
@@ -298,6 +357,7 @@ export function App() {
         onCreateSession={handleCreateSession}
         onDeleteProject={handleDeleteProject}
         onDeleteSession={handleDeleteSession}
+        onRenameSession={handleRenameSession}
         theme={theme}
         onToggleTheme={handleToggleTheme}
       />
-- 
cgit v1.2.3

