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
+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 { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
import { GraphCoverageControl } from "./graph-coverage.tsx";
import { usePopoverDismiss } from "@/components/popover-dismiss";
import {
ARROW_LENGTH_LADDER,
CHARGE_RANGE,
@@ -221,10 +222,14 @@ function GraphSettingsControl({
changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void;
}) {
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 physicsTuning = settings.physics;
return (
<div className="relative">
<div ref={rootRef} className="relative">
<button
type="button"
title="Rendering settings"
@@ -957,12 +962,15 @@ export function GraphView() {
{/* Legend + filters: search box and per-state toggle rows. */}
{/* Top-right controls — separate floating containers (owner,
graph-view round 2): the search box, the settings gear (its own
collapsible component), the import button, and the coverage
control. 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-center gap-2">
graph-view round 2). Row order (2026-09-17): search,
coverage, import, gear — coverage sits between search and
import; the gear keeps the corner. items-start (not center)
so the search container's no-match note can grow downward
without shifting its siblings — every button is 44px tall,
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 && (
<>
{/* Search — its own container (icon + input + no-match note). */}
@@ -983,6 +991,16 @@ export function GraphView() {
</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. */}
<div className="rounded-xl border bg-card/85 p-1.5 shadow-lg backdrop-blur">
<input
@@ -1007,14 +1025,11 @@ export function GraphView() {
</button>
</div>
{/* 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. */}
<GraphSettingsControl settings={settings} changeSettings={changeSettings} />
</>
)}
{/* Extraction coverage — own container, always present; the
panel re-reads when background runs settle (refreshTick). */}
<GraphCoverageControl refreshKey={refreshTick} />
</div>
{/* Background pipeline status: what is currently running. */}