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:
+73
-1
@@ -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<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
|
||||
// (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). */}
|
||||
<header className="flex h-15 items-center gap-3 border-b border-border px-3">
|
||||
<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
|
||||
structure beside the phase chip — access to the full-DAG popup
|
||||
at every width; the marginalia is the wide-screen glance view. */}
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
usePopoverDismiss(rootRef, open, () => setOpen(false));
|
||||
const [chats, setChats] = useState<CoverageChat[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
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);
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
title="Extraction coverage — what the knowledge graph has covered"
|
||||
|
||||
+27
-12
@@ -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. */}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user