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 }> }) {
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 });
}