feat: session rename + graph chrome polish (popovers dismiss, no-shift no-match, coverage reseat)

Rename (owner ask): clicking the header title opens edit mode with the
whole title selected — typing replaces, arrows move inside. Enter or a
click away commits (PUT /api/chats/:id); Escape and an empty commit
discard. renameChat joins the ChatStore port; like saveSettings it is
NOT conversation activity — updatedAt stays put so sidebar order never
churns on a rename. The sidebar patch is instant: the header dispatches
lh:chat-renamed and SessionsSidebar folds it into its snapshot (the 2.5s
poll is only a backstop). Sidebar right-click rename is DELIBERATELY
deferred: it needs a real context-menu system, which gets its own pass
later (owner decision, 2026-09-17).

Graph chrome:
- The extraction-coverage button moves between search and import; the
  gear keeps the far-right corner. Coverage still renders on an EMPTY
  graph (that is exactly when it matters).
- The no-match search note no longer shifts its siblings: the chrome
  row is items-start (every button is 44px tall, so top alignment pins
  the row while the search container grows downward).
- Popovers dismiss properly: pointerdown anywhere outside the control's
  root (button + panel) closes it, and so does Escape. Two panels can
  never be open at once — the sibling chrome buttons are outside the open
  root, so pointerdown closes the first before the click opens the
  second. New shared hook components/popover-dismiss.ts, wired into
  both the settings gear and the coverage control.

Gates: typecheck, eslint, 498/498 (+2 rename store tests), build.
Live-verified: select-all/focus/Enter/Escape/empty/blur round-trip,
updatedAt untouched, sidebar instant patch; chrome order + frozen
positions under a no-match query; popover close via canvas, search
input, Esc, mutual exclusion, toggle intact.
This commit is contained in:
amosblomqvist
2026-09-17 14:28:20 +03:00
parent 5c380de669
commit 769a3da186
9 changed files with 219 additions and 17 deletions
+21 -1
View File
@@ -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 }> }) { export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params; 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). // (chat-handler write-ahead + registry — see src/server/turn-liveness.ts).
return Response.json({ ...chat, turnInFlight: isTurnRunning(id) }); 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 });
}
+73 -1
View File
@@ -153,6 +153,52 @@ export function Chat({
// the orphaned turn finishes — poll for that, don't resurrect. // the orphaned turn finishes — poll for that, don't resurrect.
const [turnInFlight, setTurnInFlight] = useState(initiallyInFlight ?? false); 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<HTMLInputElement | null>(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 // Mount continuation: if the stored transcript ends with a fulfilled tool
// (a job result landed while no client was open), resume the lesson — the // (a job result landed while no client was open), resume the lesson — the
// SDK's sendAutomaticallyWhen only fires inside addToolOutput, so reloads // 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). */} band's bottom edge as one line across the app (owner, round 6). */}
<header className="flex h-15 items-center gap-3 border-b border-border px-3"> <header className="flex h-15 items-center gap-3 border-b border-border px-3">
<SidebarToggle /> <SidebarToggle />
<div className="min-w-0 flex-1 truncate font-title text-3xl tracking-tight">{title}</div> {renaming ? (
<input
ref={titleInputRef}
value={titleDraft}
onChange={(e) => 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"
/>
) : (
<button
type="button"
title="Rename session"
onClick={() => {
setTitleDraft(titleText);
setRenaming(true);
}}
className="min-w-0 flex-1 cursor-text truncate text-left font-title text-3xl tracking-tight"
>
{titleText}
</button>
)}
{/* The plan chip (owner, plan-placement round): session-level {/* The plan chip (owner, plan-placement round): session-level
structure beside the phase chip — access to the full-DAG popup structure beside the phase chip — access to the full-DAG popup
at every width; the marginalia is the wide-screen glance view. */} at every width; the marginalia is the wide-screen glance view. */}
+7 -2
View File
@@ -1,6 +1,7 @@
"use client"; "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 { ListChecksIcon, Loader2Icon } from "lucide-react";
import { formatTokens } from "@/components/usage-format"; import { formatTokens } from "@/components/usage-format";
@@ -34,6 +35,10 @@ interface CoverageChat {
export function GraphCoverageControl({ refreshKey = 0 }: { refreshKey?: number }) { export function GraphCoverageControl({ refreshKey = 0 }: { refreshKey?: number }) {
const [open, setOpen] = useState(false); 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<HTMLDivElement>(null);
usePopoverDismiss(rootRef, open, () => setOpen(false));
const [chats, setChats] = useState<CoverageChat[]>([]); const [chats, setChats] = useState<CoverageChat[]>([]);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [extracting, setExtracting] = useState<string[]>([]); // click-pending ids (optimistic until the poll says running) const [extracting, setExtracting] = useState<string[]>([]); // 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); const pending = (chat: CoverageChat) => chat.running || extracting.includes(chat.chatId);
return ( return (
<div className="relative"> <div ref={rootRef} className="relative">
<button <button
type="button" type="button"
title="Extraction coverage — what the knowledge graph has covered" title="Extraction coverage — what the knowledge graph has covered"
+27 -12
View File
@@ -67,6 +67,7 @@ import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, Waypoi
import { incrementalLayout, type GraphNode, type GraphSnapshot } from "@/src/core"; import { incrementalLayout, type GraphNode, type GraphSnapshot } from "@/src/core";
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts"; import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
import { GraphCoverageControl } from "./graph-coverage.tsx"; import { GraphCoverageControl } from "./graph-coverage.tsx";
import { usePopoverDismiss } from "@/components/popover-dismiss";
import { import {
ARROW_LENGTH_LADDER, ARROW_LENGTH_LADDER,
CHARGE_RANGE, CHARGE_RANGE,
@@ -221,10 +222,14 @@ function GraphSettingsControl({
changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void; changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void;
}) { }) {
const [open, setOpen] = useState(false); 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<HTMLDivElement>(null);
usePopoverDismiss(rootRef, open, () => setOpen(false));
const regionTuning = settings.regions; const regionTuning = settings.regions;
const physicsTuning = settings.physics; const physicsTuning = settings.physics;
return ( return (
<div className="relative"> <div ref={rootRef} className="relative">
<button <button
type="button" type="button"
title="Rendering settings" title="Rendering settings"
@@ -957,12 +962,15 @@ export function GraphView() {
{/* Legend + filters: search box and per-state toggle rows. */} {/* Legend + filters: search box and per-state toggle rows. */}
{/* Top-right controls — separate floating containers (owner, {/* Top-right controls — separate floating containers (owner,
graph-view round 2): the search box, the settings gear (its own graph-view round 2). Row order (2026-09-17): search,
collapsible component), the import button, and the coverage coverage, import, gear — coverage sits between search and
control. Coverage renders even when the graph is EMPTY — an import; the gear keeps the corner. items-start (not center)
empty graph with unextracted sessions is exactly when it so the search container's no-match note can grow downward
matters (extract → the graph gains nodes). */} without shifting its siblings — every button is 44px tall,
<div className="absolute right-3 top-3 z-10 flex items-center gap-2"> top alignment pins the row. Coverage renders even when the
graph is EMPTY — an empty graph with unextracted sessions is
exactly when it matters (extract → the graph gains nodes). */}
<div className="absolute right-3 top-3 z-10 flex items-start gap-2">
{!isEmpty && ( {!isEmpty && (
<> <>
{/* Search — its own container (icon + input + no-match note). */} {/* Search — its own container (icon + input + no-match note). */}
@@ -983,6 +991,16 @@ export function GraphView() {
</div> </div>
)} )}
</div> </div>
</>
)}
{/* 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). */}
<GraphCoverageControl refreshKey={refreshTick} />
{!isEmpty && (
<>
{/* Import notes — its own container. */} {/* Import notes — its own container. */}
<div className="rounded-xl border bg-card/85 p-1.5 shadow-lg backdrop-blur"> <div className="rounded-xl border bg-card/85 p-1.5 shadow-lg backdrop-blur">
<input <input
@@ -1007,14 +1025,11 @@ export function GraphView() {
</button> </button>
</div> </div>
{/* Rendering settings — own component; the gear toggles it. The {/* Rendering settings — own component; the gear toggles it. The
gear sits at the far right (owner, round 3) so its panel gear sits at the far right (owner, round 3) so its panel
drops at the very right edge, over less of the graph. */} drops at the very right edge, over less of the graph. */}
<GraphSettingsControl settings={settings} changeSettings={changeSettings} /> <GraphSettingsControl settings={settings} changeSettings={changeSettings} />
</> </>
)} )}
{/* Extraction coverage — own container, always present; the
panel re-reads when background runs settle (refreshTick). */}
<GraphCoverageControl refreshKey={refreshTick} />
</div> </div>
{/* Background pipeline status: what is currently running. */} {/* Background pipeline status: what is currently running. */}
+32
View File
@@ -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<HTMLElement | null>,
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]);
}
+12
View File
@@ -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(() => { useEffect(() => {
if (!open) return; if (!open) return;
void refresh(); void refresh();
+4
View File
@@ -54,6 +54,10 @@ export interface ChatStore {
/** Persist the per-chat model/thinking settings (the model control). /** Persist the per-chat model/thinking settings (the model control).
* Replace-whole: the picker sends the complete settings object. */ * Replace-whole: the picker sends the complete settings object. */
saveSettings(id: string, settings: ChatSettings): void; 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) ────────────── // ─── GraphStore — the append-only evidence ledgers (data/graph) ──────────────
+14
View File
@@ -116,6 +116,19 @@ function saveSettings(id: string, settings: ChatSettings): void {
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8"); 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). */ /** Persist the session plan tree (plan tools call this in execute). */
function saveTree(id: string, tree: PlanTree): void { function saveTree(id: string, tree: PlanTree): void {
const record = getChat(id); const record = getChat(id);
@@ -133,6 +146,7 @@ export const chatStore: ChatStore = {
saveMessages, saveMessages,
saveTree, saveTree,
saveSettings, saveSettings,
renameChat,
appendTurnUsage, appendTurnUsage,
appendExtractionUsage, appendExtractionUsage,
}; };
+29 -1
View File
@@ -528,4 +528,32 @@ describe("mid-turn navigation (turn liveness + write-ahead)", () => {
assert.equal(ok.status, 200); assert.equal(ok.status, 200);
await drain(ok); await drain(ok);
}); });
}); });
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");
});
});