Wave 6 chunk A: sidebar context menu (Rename/Delete) + the two-option delete dialog
- 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.
This commit is contained in:
@@ -240,6 +240,19 @@ export function Chat({
|
|||||||
return () => window.removeEventListener("lh:kbd-rename-session", onRename);
|
return () => window.removeEventListener("lh:kbd-rename-session", onRename);
|
||||||
}, [titleText]);
|
}, [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
|
// 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 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
|
// stop — a server-side wake turn aborts nothing (the stop would be a
|
||||||
|
|||||||
@@ -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<ChatListItem, "id" | "title"> | null;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Success path — the sidebar owns navigation + list patching. */
|
||||||
|
onDeleted: (id: string, mode: ChatDeleteMode, graphRevision: number) => void;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState<ChatDeleteMode | null>(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(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
|
||||||
|
onClick={() => {
|
||||||
|
if (!busy) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Delete session"
|
||||||
|
data-testid="delete-session-dialog"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl"
|
||||||
|
>
|
||||||
|
<h2 className="font-title text-2xl tracking-tight">Delete “{chat.title}”?</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
This permanently removes the session — its messages, background jobs, and extraction records. Pick how
|
||||||
|
far it reaches:
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
|
{DELETE_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.mode}
|
||||||
|
type="button"
|
||||||
|
data-testid={option.testid}
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() => void run(option.mode)}
|
||||||
|
className="flex flex-col items-start rounded-lg border border-border/60 px-4 py-3 text-left transition-colors hover:bg-accent disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-foreground">{option.title}</span>
|
||||||
|
<span className="mt-1 text-xs text-muted-foreground">{option.body}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{failure && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-testid="delete-failure"
|
||||||
|
className="mt-3 rounded-sm border border-border/60 px-3 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<span className="text-foreground">{failure.error}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="delete-retry"
|
||||||
|
onClick={() => failure && void run(failure.mode)}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="ml-3 text-xs text-foreground underline-offset-2 hover:underline disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="delete-cancel"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{busy ? "Deleting…" : "Cancel"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -904,6 +904,21 @@ export function GraphView() {
|
|||||||
costsHadRunning.current = pipelineRuns.length > 0;
|
costsHadRunning.current = pipelineRuns.length > 0;
|
||||||
}, [pipelineRuns, refreshCosts]);
|
}, [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
|
// Layout (Option A): the positions ledger (written by the server's
|
||||||
// incremental settle after every merge) IS the layout — the renderer
|
// incremental settle after every merge) IS the layout — the renderer
|
||||||
// receives it as frozen data. The local one-shot forceAtlas2 pass runs
|
// receives it as frozen data. The local one-shot forceAtlas2 pass runs
|
||||||
|
|||||||
+188
-3
@@ -3,6 +3,9 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
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 { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react";
|
||||||
import { ActivitySection } from "@/components/activity-dock";
|
import { ActivitySection } from "@/components/activity-dock";
|
||||||
import { LhMark } from "@/components/lh-mark";
|
import { LhMark } from "@/components/lh-mark";
|
||||||
@@ -227,6 +230,7 @@ export function SessionRow({
|
|||||||
active,
|
active,
|
||||||
onOpen,
|
onOpen,
|
||||||
kbdSelected = false,
|
kbdSelected = false,
|
||||||
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
chat: ChatListItem;
|
chat: ChatListItem;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -234,12 +238,15 @@ export function SessionRow({
|
|||||||
/** Keyboard-cycle highlight (shortcut wave A: Alt+↑/↓ + Alt+Enter) —
|
/** Keyboard-cycle highlight (shortcut wave A: Alt+↑/↓ + Alt+Enter) —
|
||||||
* visually distinct from the active session, which stays filled. */
|
* visually distinct from the active session, which stays filled. */
|
||||||
kbdSelected?: boolean;
|
kbdSelected?: boolean;
|
||||||
|
/** Right-click opens the context menu (wave 6: Rename / Delete). */
|
||||||
|
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
data-testid={kbdSelected ? "session-row-kbd" : undefined}
|
onContextMenu={onContextMenu}
|
||||||
|
data-testid={kbdSelected ? "session-row-kbd" : "session-row"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative mb-0.5 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent",
|
"relative mb-0.5 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent",
|
||||||
active && "bg-accent",
|
active && "bg-accent",
|
||||||
@@ -260,6 +267,50 @@ export function SessionRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The inline-rename row (wave 6): same geometry as SessionRow, but the
|
||||||
|
* title line becomes a text input — select-all on entry (the click means
|
||||||
|
* "replace", the header rename's rule), Enter/blur commits, Esc discards.
|
||||||
|
* The meta line stays so the row never changes height mid-edit. */
|
||||||
|
export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; onCommit: (next: string) => void; onCancel: () => void }) {
|
||||||
|
const [draft, setDraft] = useState(chat.title);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = inputRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.focus();
|
||||||
|
el.select();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="session-rename-row"
|
||||||
|
className="relative mb-0.5 flex w-full items-center gap-2 rounded-lg bg-accent px-3 py-2"
|
||||||
|
>
|
||||||
|
<span aria-hidden className="w-3.5 shrink-0" />
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={draft}
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={() => onCommit(draft)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") e.currentTarget.blur();
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation(); // discard, not also the sidebar/chat chains
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Rename session"
|
||||||
|
data-testid="session-rename-input"
|
||||||
|
className="lh-input w-full min-w-0 truncate rounded-sm border border-border bg-background px-1.5 font-prose text-(length:--lh-prose-size) text-foreground outline-none"
|
||||||
|
/>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">{sessionMeta(chat)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SessionsSidebar() {
|
function SessionsSidebar() {
|
||||||
const { open, toggle } = useSidebar();
|
const { open, toggle } = useSidebar();
|
||||||
const [chats, setChats] = useState<ChatListItem[] | null>(null);
|
const [chats, setChats] = useState<ChatListItem[] | null>(null);
|
||||||
@@ -318,6 +369,86 @@ function SessionsSidebar() {
|
|||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
/* ── Wave 6 chunk A: context menu + inline rename + deletion ──
|
||||||
|
Right-click a session row → MenuShell with exactly two actions
|
||||||
|
(owner ruling): Rename (inline, below) and Delete (the two-option
|
||||||
|
dialog). The anchor is the row that was right-clicked — assigned
|
||||||
|
into a ref before the state flip so the shell measures it on its
|
||||||
|
first (hidden) pass. */
|
||||||
|
const [ctxChatId, setCtxChatId] = useState<string | null>(null);
|
||||||
|
const ctxAnchorRef = useRef<HTMLElement | null>(null);
|
||||||
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState<ChatListItem | null>(null);
|
||||||
|
|
||||||
|
const openContext = (e: React.MouseEvent<HTMLButtonElement>, chat: ChatListItem) => {
|
||||||
|
e.preventDefault(); // suppress the native menu
|
||||||
|
ctxAnchorRef.current = e.currentTarget;
|
||||||
|
setCtxChatId(chat.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The menu contract (agent-section's): any pointerdown outside the menu
|
||||||
|
// or its row closes it; Esc is CLAIMED so it never also aborts a turn
|
||||||
|
// or folds the sidebar. Right-clicking another row re-anchors: its
|
||||||
|
// pointerdown closes this menu, its contextmenu opens the next.
|
||||||
|
useEffect(() => {
|
||||||
|
if (ctxChatId === null) return;
|
||||||
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (target instanceof Element && target.closest("[data-lh-menu]")) return;
|
||||||
|
if (ctxAnchorRef.current?.contains(target)) return;
|
||||||
|
setCtxChatId(null);
|
||||||
|
};
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCtxChatId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [ctxChatId]);
|
||||||
|
|
||||||
|
// Inline rename (owner ruling, wave 6): the row swaps to a text input;
|
||||||
|
// commit rides the SAME plumbing as the header rename — PUT the title,
|
||||||
|
// then lh:chat-renamed patches every listener (this sidebar's own
|
||||||
|
// listener patches the snapshot instantly; an open chat header hears
|
||||||
|
// it too). Esc discards; empty commit discards (the header's rules).
|
||||||
|
const commitRename = useCallback(async (id: string, next: string) => {
|
||||||
|
setRenamingId(null);
|
||||||
|
const t = next.trim();
|
||||||
|
if (t === "") return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/chats/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: t }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`rename failed: ${res.status}`);
|
||||||
|
window.dispatchEvent(new CustomEvent("lh:chat-renamed", { detail: { id, title: t } }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("rename failed:", err); // keep the old title
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Delete success: patch the list instantly (the poll is the backstop),
|
||||||
|
// navigate home if the open session died (the chat view unmounts —
|
||||||
|
// drafts die with it, no orphan bubbles), and announce the graph
|
||||||
|
// revision so the graph view refetches (deletion is the one writer
|
||||||
|
// the status poll can't see — no run ever transitions some→none).
|
||||||
|
const onDeleted = useCallback(
|
||||||
|
(id: string, _mode: ChatDeleteMode, graphRevision: number) => {
|
||||||
|
setDeleting(null);
|
||||||
|
setChats((prev) => (prev ? prev.filter((c) => c.id !== id) : prev));
|
||||||
|
if (pathname === `/chat/${id}`) router.push("/");
|
||||||
|
window.dispatchEvent(new CustomEvent("lh:graph-revision", { detail: { revision: graphRevision } }));
|
||||||
|
},
|
||||||
|
[pathname, router],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFocusSearch = () => {
|
const onFocusSearch = () => {
|
||||||
if (!open) toggle();
|
if (!open) toggle();
|
||||||
@@ -463,7 +594,15 @@ function SessionsSidebar() {
|
|||||||
{query.trim() ? "No sessions match." : "No sessions yet."}
|
{query.trim() ? "No sessions match." : "No sessions yet."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
shown.map((c, i) => (
|
shown.map((c, i) =>
|
||||||
|
renamingId === c.id ? (
|
||||||
|
<RenameRow
|
||||||
|
key={c.id}
|
||||||
|
chat={c}
|
||||||
|
onCommit={(next) => void commitRename(c.id, next)}
|
||||||
|
onCancel={() => setRenamingId(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<SessionRow
|
<SessionRow
|
||||||
key={c.id}
|
key={c.id}
|
||||||
chat={c}
|
chat={c}
|
||||||
@@ -473,8 +612,10 @@ function SessionsSidebar() {
|
|||||||
setKbdIndex(null);
|
setKbdIndex(null);
|
||||||
router.push(`/chat/${c.id}`);
|
router.push(`/chat/${c.id}`);
|
||||||
}}
|
}}
|
||||||
|
onContextMenu={(e) => openContext(e, c)}
|
||||||
/>
|
/>
|
||||||
))
|
),
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -500,6 +641,50 @@ function SessionsSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<MenuShell
|
||||||
|
anchorRef={ctxAnchorRef}
|
||||||
|
testid="session-context-menu"
|
||||||
|
preferredSide="down"
|
||||||
|
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border bg-card p-2 text-xs shadow-xl"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="ctx-rename"
|
||||||
|
onClick={() => {
|
||||||
|
setRenamingId(ctxChatId);
|
||||||
|
setCtxChatId(null);
|
||||||
|
}}
|
||||||
|
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="ctx-delete"
|
||||||
|
onClick={() => {
|
||||||
|
const target = chats?.find((c) => c.id === ctxChatId);
|
||||||
|
if (target) setDeleting(target);
|
||||||
|
setCtxChatId(null);
|
||||||
|
}}
|
||||||
|
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
Delete…
|
||||||
|
</button>
|
||||||
|
</MenuShell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The two-option delete dialog (wave 6) — portaled to body
|
||||||
|
by the component itself, lives here for state locality. */}
|
||||||
|
<DeleteSessionDialog
|
||||||
|
chat={deleting}
|
||||||
|
onClose={() => setDeleting(null)}
|
||||||
|
onDeleted={onDeleted}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export * from "./fuzzy.ts";
|
|||||||
// the user-settings record's shape/sanitizer (pure) and its fully-resolved
|
// the user-settings record's shape/sanitizer (pure) and its fully-resolved
|
||||||
// view. Browser-safe — the Wave-4 panel imports these directly.
|
// view. Browser-safe — the Wave-4 panel imports these directly.
|
||||||
export * from "./settings.ts";
|
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 "./settings-resolution.ts";
|
||||||
export * from "./search.ts"; // search port (search wave, 2026-09-20)
|
export * from "./search.ts"; // search port (search wave, 2026-09-20)
|
||||||
// auth-keys.ts (auth-keys wave, 2026-09-19): the curated key catalog +
|
// auth-keys.ts (auth-keys wave, 2026-09-19): the curated key catalog +
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React from "react";
|
||||||
|
(globalThis as Record<string, unknown>).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(<SessionRow chat={chat} active={false} onOpen={() => {}} />);
|
||||||
|
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(
|
||||||
|
<RenameRow chat={chat} onCommit={() => {}} 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(
|
||||||
|
<DeleteSessionDialog chat={null} onClose={() => {}} 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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user