From 1c27694dbc570e65d8eb671515e43f8c018a0ceb Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 23 Sep 2026 12:45:12 +0300 Subject: [PATCH] Wave 6 chunk A: sidebar context menu (Rename/Delete) + the two-option delete dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Right-click any session row: MenuShell context menu, exactly two actions (owner ruling): Rename and Delete… — MenuShell discipline (placement, focus, arrow cycling, lh:menu-open announcements), agent-section's outside-click/Esc contract with [data-lh-menu] carve-outs. - Rename is INLINE in the row (owner ruling): select-all entry, Enter/blur commits through the existing plumbing — PUT /api/chats/:id then lh:chat-renamed. The open chat header now LISTENS for the event too, so renaming the active session from the sidebar updates the header in place (it only dispatched before). - Delete opens the two-option dialog (locked design, DELETE_OPTIONS pure seam for render pins): "Delete the session only" (preserve-graph — the graph keeps every contribution) vs "Also remove its graph traces" (delete-nodes — shared topics survive, only-this-session topics die). 409 turn_in_flight/jobs_running and 503 extraction_winding_down render the server's line verbatim with a same-mode Try-again. - Success: the sidebar patches its snapshot instantly, the active session navigates home (chat unmounts — drafts die with it), and lh:graph-revision announces the returned revision — the graph view refetches the snapshot AND costs (deletion is the one writer the status poll can't see). - ChatDeleteMode type re-exported through the core barrel (the shell's one needed seam from core/graph-deletion.ts). Gates: tsc clean, 909/909, lint 0, build ok. Live-verified on :3001: context menu open/Esc, inline rename (row + API + active header), both delete modes on scratch sessions, simulated 409 + retry-then-succeed, active-session navigation home, graph+costs refetch deltas on home. --- components/chat.tsx | 13 ++ components/delete-session-dialog.tsx | 184 +++++++++++++++++++++++ components/graph-view.tsx | 15 ++ components/sidebar.tsx | 211 +++++++++++++++++++++++++-- src/core/index.ts | 5 + tests/session-context.test.tsx | 68 +++++++++ 6 files changed, 483 insertions(+), 13 deletions(-) create mode 100644 components/delete-session-dialog.tsx create mode 100644 tests/session-context.test.tsx diff --git a/components/chat.tsx b/components/chat.tsx index b3fe6a7..5ecf155 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -240,6 +240,19 @@ export function Chat({ return () => window.removeEventListener("lh:kbd-rename-session", onRename); }, [titleText]); + // The OTHER rename surface (wave 6: the sidebar's inline rename): its + // commit dispatches the same lh:chat-renamed event this header's rename + // does — if it renamed THIS chat, patch the header title in place (the + // event is the single source of truth; the header is one more listener). + useEffect(() => { + const onRenamed = (e: Event) => { + const { id, title } = (e as CustomEvent<{ id: string; title: string }>).detail; + if (id === chatId) setTitleText((prev) => (title !== prev ? title : prev)); + }; + window.addEventListener("lh:chat-renamed", onRenamed); + return () => window.removeEventListener("lh:chat-renamed", onRenamed); + }, [chatId]); + // The Esc chain's last link: abort the turn. Honest scope (the composer's // Stop has the same rule): only a LOCAL stream has a client request to // stop — a server-side wake turn aborts nothing (the stop would be a diff --git a/components/delete-session-dialog.tsx b/components/delete-session-dialog.tsx new file mode 100644 index 0000000..cef8d6f --- /dev/null +++ b/components/delete-session-dialog.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import type { ChatDeleteMode, ChatListItem } from "@/src/core"; + +/** The locked two-option design (owner 2026-09-22/23) — plain-language + * consequences, the graph-touching option says so. Exported pure for + * render pins: the copy is design, not implementation. */ +export const DELETE_OPTIONS: { + mode: ChatDeleteMode; + testid: string; + title: string; + body: string; +}[] = [ + { + mode: "preserve-graph", + testid: "delete-option-preserve", + title: "Delete the session only", + body: "Everything this session added to the graph stays — topics, links, and notes it produced remain untouched.", + }, + { + mode: "delete-nodes", + testid: "delete-option-nodes", + title: "Also remove its graph traces", + body: "This session’s topics, links, and events leave the graph too. Topics other sessions or imported notes also cover survive; topics only this session created are removed entirely.", + }, +]; + +/** + * Session deletion (wave 6 chunk A) — THE destructive-confirmation surface, + * portaled z-50 like the setup dialog. Two options per the locked design, + * consequences in plain language; the one that touches the graph says so: + * + * preserve-graph the session record, its jobs, and its extraction + * bookkeeping die; the graph keeps every contribution. + * delete-nodes the session's ledger traces go too — shared topics + * survive (other sessions / imported notes still + * reference them), topics only this session created + * are removed entirely. + * + * Retryable failures render honestly with a Try-again: 409 turn_in_flight / + * jobs_running ("wait for running work") and 503 extraction_winding_down + * ("in a moment") both clear on their own — the server's error line is + * already written for the user; we show it verbatim. + */ +export function DeleteSessionDialog({ + chat, + onClose, + onDeleted, +}: { + /** The session up for deletion; null = closed. */ + chat: Pick | null; + onClose: () => void; + /** Success path — the sidebar owns navigation + list patching. */ + onDeleted: (id: string, mode: ChatDeleteMode, graphRevision: number) => void; +}) { + const [busy, setBusy] = useState(null); + // The failure carries the mode it came from — Try-again re-runs THAT + // mode (busy is back to null by then, so it can't be read off busy). + const [failure, setFailure] = useState<{ code: string; error: string; mode: ChatDeleteMode } | null>(null); + + // Fresh state per session: closing resets, opening a new chat starts clean. + useEffect(() => { + if (!chat) { + setBusy(null); + setFailure(null); + } + }, [chat]); + + // Esc closes (claimed — a menu/dialog Esc must not also abort a turn). + useEffect(() => { + if (!chat) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [chat, onClose]); + + const run = async (mode: ChatDeleteMode) => { + if (!chat || busy) return; + setBusy(mode); + setFailure(null); + try { + const res = await fetch(`/api/chats/${chat.id}?mode=${mode}`, { method: "DELETE" }); + const body = (await res.json()) as { + ok?: boolean; + error?: string; + code?: string; + graphRevision?: number; + }; + if (!res.ok) { + // 409 (turn/jobs running) and 503 (extraction winding down) are + // both retryable — keep the dialog open, show the server's line. + setFailure({ + code: body.code ?? "error", + error: body.error ?? `Delete failed (${res.status})`, + mode, + }); + setBusy(null); + return; + } + onDeleted(chat.id, mode, body.graphRevision ?? 0); + } catch { + setFailure({ code: "network", error: "Delete failed — network error. Try again.", mode }); + setBusy(null); + } + }; + + if (!chat) return null; + + return createPortal( +
{ + if (!busy) onClose(); + }} + > +
e.stopPropagation()} + className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl" + > +

Delete “{chat.title}”?

+

+ This permanently removes the session — its messages, background jobs, and extraction records. Pick how + far it reaches: +

+
+ {DELETE_OPTIONS.map((option) => ( + + ))} +
+ {failure && ( +
+ {failure.error} + +
+ )} +
+ +
+
+
, + document.body, + ); +} \ No newline at end of file diff --git a/components/graph-view.tsx b/components/graph-view.tsx index ccf18e2..ed57497 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -904,6 +904,21 @@ export function GraphView() { costsHadRunning.current = pipelineRuns.length > 0; }, [pipelineRuns, refreshCosts]); + /* Deletion refresh (wave 6 chunk A): the DELETE /api/chats/:id response + carries the new graphRevision, and the sidebar (which owns the delete + flow) announces it as lh:graph-revision. The status poll above can't + see a deletion — no pipeline run ever transitions some→none for it — + so this event is the one seam. Refetch the snapshot and the costs on + every announcement: deletions are rare, a redundant fetch is free. */ + useEffect(() => { + const onRevision = () => { + setRefreshTick((t) => t + 1); + refreshCosts(); + }; + window.addEventListener("lh:graph-revision", onRevision); + return () => window.removeEventListener("lh:graph-revision", onRevision); + }, [refreshCosts]); + // Layout (Option A): the positions ledger (written by the server's // incremental settle after every merge) IS the layout — the renderer // receives it as frozen data. The local one-shot forceAtlas2 pass runs diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 31cf1af..f968498 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -3,6 +3,9 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; +import { MenuShell } from "@/components/menu-shell"; +import { DeleteSessionDialog } from "@/components/delete-session-dialog"; +import type { ChatDeleteMode } from "@/src/core"; import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react"; import { ActivitySection } from "@/components/activity-dock"; import { LhMark } from "@/components/lh-mark"; @@ -227,6 +230,7 @@ export function SessionRow({ active, onOpen, kbdSelected = false, + onContextMenu, }: { chat: ChatListItem; active: boolean; @@ -234,12 +238,15 @@ export function SessionRow({ /** Keyboard-cycle highlight (shortcut wave A: Alt+↑/↓ + Alt+Enter) — * visually distinct from the active session, which stays filled. */ kbdSelected?: boolean; + /** Right-click opens the context menu (wave 6: Rename / Delete). */ + onContextMenu?: (e: React.MouseEvent) => void; }) { return ( + + {/* The context menu (wave 6): exactly two actions (owner + ruling — no more). MenuShell owns placement/focus/arrow + cycling; the dismissal effect above owns the contract. */} + {ctxChatId !== null && ( + + + + + )} + + {/* The two-option delete dialog (wave 6) — portaled to body + by the component itself, lives here for state locality. */} + setDeleting(null)} + onDeleted={onDeleted} + /> ); } diff --git a/src/core/index.ts b/src/core/index.ts index 589f559..03a100e 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -40,6 +40,11 @@ export * from "./fuzzy.ts"; // the user-settings record's shape/sanitizer (pure) and its fully-resolved // view. Browser-safe — the Wave-4 panel imports these directly. export * from "./settings.ts"; +// graph-deletion.ts (wave 6): the whole module is PURE but storage-port +// driven — the deletion RUNS server-side only. The shell needs exactly one +// thing from it: the ChatDeleteMode union (the delete dialog's two-option +// design + the DELETE query param), hence this type-only re-export. +export type { ChatDeleteMode, ChatDeletionStats } from "./graph-deletion.ts"; export * from "./settings-resolution.ts"; export * from "./search.ts"; // search port (search wave, 2026-09-20) // auth-keys.ts (auth-keys wave, 2026-09-19): the curated key catalog + diff --git a/tests/session-context.test.tsx b/tests/session-context.test.tsx new file mode 100644 index 0000000..bdc379e --- /dev/null +++ b/tests/session-context.test.tsx @@ -0,0 +1,68 @@ +import React from "react"; +(globalThis as Record).React = React; + +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { RenameRow, SessionRow } from "../components/sidebar.tsx"; +import { DELETE_OPTIONS, DeleteSessionDialog } from "../components/delete-session-dialog.tsx"; +import type { ChatListItem } from "../src/core/index.ts"; + +/** + * Wave 6 chunk A — the deletion + context-menu seams on the sidebar: + * SessionRow's right-click seam (always carries the row testid now), the + * inline RenameRow (same geometry, select-all entry), and the two-option + * delete dialog's plain-language copy (the locked design: both options + * name their reach, the graph-touching one says so). + */ + +const chat: ChatListItem = { + id: "c1", + title: "Wave mechanics", + updatedAt: new Date().toISOString(), + busy: false, +} as unknown as ChatListItem; + +describe("SessionRow — the right-click seam", () => { + it("rows carry the session-row testid (context menu anchors to it)", () => { + const html = renderToStaticMarkup( {}} />); + assert.ok(html.includes('data-testid="session-row"')); + }); +}); + +describe("RenameRow — the inline rename surface", () => { + it("renders the draft input with the current title, keeping the meta line", () => { + const html = renderToStaticMarkup( + {}} onCancel={() => {}} />, + ); + assert.ok(html.includes('data-testid="session-rename-input"')); + // controlled draft starts from the current title + assert.ok(html.includes(`value="${chat.title}"`)); + // the meta line rides along (row never changes height mid-edit) + assert.ok(html.includes("session-rename-row")); + }); +}); + +describe("DeleteSessionDialog — the locked two-option copy (portals no-op in SSR; pin the pure seam)", () => { + it("closed (chat=null) renders nothing", () => { + const html = renderToStaticMarkup( + {}} onDeleted={() => {}} />, + ); + assert.equal(html, ""); + }); + + it("exactly two options, both modes covered, consequences in plain language", () => { + assert.equal(DELETE_OPTIONS.length, 2); + const modes = DELETE_OPTIONS.map((o) => o.mode).sort(); + assert.deepEqual(modes, ["delete-nodes", "preserve-graph"]); + }); + + it("the graph-touching option says so; the session-only option promises the graph stays", () => { + const preserve = DELETE_OPTIONS.find((o) => o.mode === "preserve-graph"); + const nodes = DELETE_OPTIONS.find((o) => o.mode === "delete-nodes"); + assert.ok(preserve?.body.includes("graph stays")); + assert.ok(nodes?.body.includes("leave the graph")); + // shared-topics survival is stated on the destructive option + assert.ok(nodes?.body.includes("survive")); + }); +}); \ No newline at end of file