diff --git a/app/api/chats/[id]/route.ts b/app/api/chats/[id]/route.ts index 5938fd8..265828a 100644 --- a/app/api/chats/[id]/route.ts +++ b/app/api/chats/[id]/route.ts @@ -1,4 +1,4 @@ -import { getChat, isTurnRunning } from "@/src/server"; +import { getChat, isTurnRunning, stores } from "@/src/server"; export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -8,3 +8,23 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str // (chat-handler write-ahead + registry — see src/server/turn-liveness.ts). return Response.json({ ...chat, turnInFlight: isTurnRunning(id) }); } + +/** + * Rename (2026-09-17): the header's click-to-edit title commits here. + * Validation is a single rule — a non-empty string after trim (the store's + * "Untitled session" floor is never reached from this route). updatedAt is + * deliberately NOT bumped: a rename must not reorder the sidebar (same + * rule as the settings PUT). + */ +export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + if (!getChat(id)) return Response.json({ error: "Not found" }, { status: 404 }); + const body = (await req.json().catch(() => null)) as { title?: unknown } | null; + if (typeof body?.title !== "string" || body.title.trim() === "") { + return Response.json({ error: "title must be a non-empty string" }, { status: 400 }); + } + const title = body.title.trim(); + const record = stores.chatStore.renameChat(id, title); + return Response.json({ title: record.title }); +} + diff --git a/components/chat.tsx b/components/chat.tsx index 625ccd6..2594701 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -153,6 +153,52 @@ export function Chat({ // the orphaned turn finishes — poll for that, don't resurrect. const [turnInFlight, setTurnInFlight] = useState(initiallyInFlight ?? false); + // Session title: click-to-rename (owner, 2026-09-17). Clicking the header + // title opens edit mode with everything selected — typing replaces, arrows + // move inside. Enter or a click away commits (PUT /api/chats/:id); Escape + // discards. The server never bumps updatedAt on a rename (sidebar order + // must not churn), and the sidebar patch is instant via the window event — + // its 2.5s poll is only a backstop. + const [titleText, setTitleText] = useState(title); + const [renaming, setRenaming] = useState(false); + const [titleDraft, setTitleDraft] = useState(""); + const titleInputRef = useRef(null); + + const commitRename = useCallback( + async (next: string) => { + setRenaming(false); + const t = next.trim(); + if (t === "" || t === titleText) return; // empty commit = discard + try { + const res = await fetch(`/api/chats/${chatId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: t }), + }); + if (!res.ok) throw new Error(`rename failed: ${res.status}`); + setTitleText(t); + window.dispatchEvent( + new CustomEvent("lh:chat-renamed", { detail: { id: chatId, title: t } }), + ); + } catch (err) { + console.error("rename failed:", err); // keep the old title + } + }, + [chatId, titleText], + ); + + // Select-all when edit mode opens: the click means "replace", so the + // whole title highlights on entry (owner ask, 2026-09-17). + useEffect(() => { + if (renaming) { + const el = titleInputRef.current; + if (el) { + el.focus(); + el.select(); + } + } + }, [renaming]); + // Mount continuation: if the stored transcript ends with a fulfilled tool // (a job result landed while no client was open), resume the lesson — the // SDK's sendAutomaticallyWhen only fires inside addToolOutput, so reloads @@ -593,7 +639,33 @@ export function Chat({ band's bottom edge as one line across the app (owner, round 6). */}
-
{title}
+ {renaming ? ( + setTitleDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void commitRename(titleDraft); + else if (e.key === "Escape") setRenaming(false); + }} + onBlur={() => void commitRename(titleDraft)} + spellCheck={false} + aria-label="Session title" + className="min-w-0 flex-1 bg-transparent font-title text-3xl tracking-tight caret-current outline-none" + /> + ) : ( + + )} {/* The plan chip (owner, plan-placement round): session-level structure beside the phase chip — access to the full-DAG popup at every width; the marginalia is the wide-screen glance view. */} diff --git a/components/graph-coverage.tsx b/components/graph-coverage.tsx index 1571458..4e1b030 100644 --- a/components/graph-coverage.tsx +++ b/components/graph-coverage.tsx @@ -1,6 +1,7 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePopoverDismiss } from "@/components/popover-dismiss"; import { ListChecksIcon, Loader2Icon } from "lucide-react"; import { formatTokens } from "@/components/usage-format"; @@ -34,6 +35,10 @@ interface CoverageChat { export function GraphCoverageControl({ refreshKey = 0 }: { refreshKey?: number }) { const [open, setOpen] = useState(false); + // Click anywhere outside (or Escape) closes the panel (2026-09-17) — + // the root wraps button + panel, so the toggle itself keeps working. + const rootRef = useRef(null); + usePopoverDismiss(rootRef, open, () => setOpen(false)); const [chats, setChats] = useState([]); const [loaded, setLoaded] = useState(false); const [extracting, setExtracting] = useState([]); // click-pending ids (optimistic until the poll says running) @@ -83,7 +88,7 @@ export function GraphCoverageControl({ refreshKey = 0 }: { refreshKey?: number } const pending = (chat: CoverageChat) => chat.running || extracting.includes(chat.chatId); return ( -
+
+ + )} + {/* Extraction coverage — between search and import (2026-09-17); + always present, even when the graph is EMPTY — an empty graph + with unextracted sessions is exactly when it matters (extract + → the graph gains nodes). The panel re-reads when background + runs settle (refreshTick). */} + + {!isEmpty && ( + <> {/* Import notes — its own container. */}
{/* Rendering settings — own component; the gear toggles it. The - gear sits at the far right (owner, round 3) so its panel - drops at the very right edge, over less of the graph. */} + gear sits at the far right (owner, round 3) so its panel + drops at the very right edge, over less of the graph. */} )} - {/* Extraction coverage — own container, always present; the - panel re-reads when background runs settle (refreshTick). */} -
{/* Background pipeline status: what is currently running. */} diff --git a/components/popover-dismiss.ts b/components/popover-dismiss.ts new file mode 100644 index 0000000..105a5aa --- /dev/null +++ b/components/popover-dismiss.ts @@ -0,0 +1,32 @@ +import { useEffect, type RefObject } from "react"; + +/** + * Close-on-dismissal for floating panels (graph chrome, 2026-09-17): + * any pointerdown landing OUTSIDE the control's root closes it; Escape + * closes it too. The root contains both the toggle button and the panel, + * so clicks on the control's own button stay "inside" — its toggle keeps + * working — while a click on a sibling chrome button counts as outside: + * pointerdown closes the open panel first, then the sibling's click opens + * its own. Two dropdowns can therefore never be open at once. + */ +export function usePopoverDismiss( + rootRef: RefObject, + open: boolean, + close: () => void, +): void { + useEffect(() => { + if (!open) return; + const onPointerDown = (e: PointerEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) close(); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open, close, rootRef]); +} diff --git a/components/sidebar.tsx b/components/sidebar.tsx index e1b387c..d138dd0 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -215,6 +215,18 @@ function SessionsSidebar() { } }, []); + /* Instant rename propagation (2026-09-17): the chat header's rename + commits dispatch lh:chat-renamed; patch the snapshot in place instead + of waiting for the 2.5s poll. */ + useEffect(() => { + const onRenamed = (e: Event) => { + const { id, title } = (e as CustomEvent<{ id: string; title: string }>).detail; + setChats((prev) => (prev ? prev.map((c) => (c.id === id ? { ...c, title } : c)) : prev)); + }; + window.addEventListener("lh:chat-renamed", onRenamed); + return () => window.removeEventListener("lh:chat-renamed", onRenamed); + }, []); + useEffect(() => { if (!open) return; void refresh(); diff --git a/src/core/ports.ts b/src/core/ports.ts index aa9e3d7..4dee803 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -54,6 +54,10 @@ export interface ChatStore { /** Persist the per-chat model/thinking settings (the model control). * Replace-whole: the picker sends the complete settings object. */ saveSettings(id: string, settings: ChatSettings): void; + /** Rename a session (the header's click-to-edit title, 2026-09-17). + * Like saveSettings: a rename is NOT conversation activity — updatedAt + * stays put, so sidebar order never churns on a rename. */ + renameChat(id: string, title: string): ChatRecord; } // ─── GraphStore — the append-only evidence ledgers (data/graph) ────────────── diff --git a/src/store/chats.ts b/src/store/chats.ts index 639f770..663e38c 100644 --- a/src/store/chats.ts +++ b/src/store/chats.ts @@ -116,6 +116,19 @@ function saveSettings(id: string, settings: ChatSettings): void { fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8"); } +/** Rename a session (the header's click-to-edit title, 2026-09-17). Like + * saveSettings, a rename is NOT conversation activity: updatedAt stays put + * so sidebar order never churns. Same fallback as createChat — the empty + * string becomes "Untitled session" (the API gate rejects it first; this + * is the honest floor for any other caller). */ +function renameChat(id: string, title: string): ChatRecord { + const record = getChat(id); + if (!record) throw new Error(`Chat not found: ${id}`); + record.title = title.trim() || "Untitled session"; + fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8"); + return record; +} + /** Persist the session plan tree (plan tools call this in execute). */ function saveTree(id: string, tree: PlanTree): void { const record = getChat(id); @@ -133,6 +146,7 @@ export const chatStore: ChatStore = { saveMessages, saveTree, saveSettings, + renameChat, appendTurnUsage, appendExtractionUsage, }; diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts index e4085fc..774f56d 100644 --- a/tests/chat-flow.test.ts +++ b/tests/chat-flow.test.ts @@ -528,4 +528,32 @@ describe("mid-turn navigation (turn liveness + write-ahead)", () => { assert.equal(ok.status, 200); await drain(ok); }); -}); \ No newline at end of file +}); + +describe("renameChat (the header's click-to-edit title)", () => { + it("persists the title and does NOT bump updatedAt — sidebar order must not churn", () => { + const chat = chatStore.createChat("old title"); + createdChats.push(chat.id); + const before = chatStore.getChat(chat.id)!.updatedAt; + const renamed = chatStore.renameChat(chat.id, " new title "); + assert.equal(renamed.title, "new title", "trimmed on the way in"); + assert.equal(chatStore.getChat(chat.id)!.title, "new title"); + assert.equal( + chatStore.getChat(chat.id)!.updatedAt, + before, + "a rename is not conversation activity", + ); + assert.equal( + chatStore.listChats().find((c) => c.id === chat.id)!.title, + "new title", + "the sidebar list view sees it", + ); + }); + + it("falls back to Untitled session on an all-whitespace title (the honest floor)", () => { + const chat = chatStore.createChat("keep me"); + createdChats.push(chat.id); + const renamed = chatStore.renameChat(chat.id, " "); + assert.equal(renamed.title, "Untitled session"); + }); +});