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:
@@ -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 }> }) {
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
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).
|
// (chat-handler write-ahead + registry — see src/server/turn-liveness.ts).
|
||||||
return Response.json({ ...chat, turnInFlight: isTurnRunning(id) });
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+73
-1
@@ -153,6 +153,52 @@ export function Chat({
|
|||||||
// the orphaned turn finishes — poll for that, don't resurrect.
|
// the orphaned turn finishes — poll for that, don't resurrect.
|
||||||
const [turnInFlight, setTurnInFlight] = useState(initiallyInFlight ?? false);
|
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
|
// Mount continuation: if the stored transcript ends with a fulfilled tool
|
||||||
// (a job result landed while no client was open), resume the lesson — the
|
// (a job result landed while no client was open), resume the lesson — the
|
||||||
// SDK's sendAutomaticallyWhen only fires inside addToolOutput, so reloads
|
// 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). */}
|
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">
|
<header className="flex h-15 items-center gap-3 border-b border-border px-3">
|
||||||
<SidebarToggle />
|
<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
|
{/* The plan chip (owner, plan-placement round): session-level
|
||||||
structure beside the phase chip — access to the full-DAG popup
|
structure beside the phase chip — access to the full-DAG popup
|
||||||
at every width; the marginalia is the wide-screen glance view. */}
|
at every width; the marginalia is the wide-screen glance view. */}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"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 { ListChecksIcon, Loader2Icon } from "lucide-react";
|
||||||
import { formatTokens } from "@/components/usage-format";
|
import { formatTokens } from "@/components/usage-format";
|
||||||
|
|
||||||
@@ -34,6 +35,10 @@ interface CoverageChat {
|
|||||||
|
|
||||||
export function GraphCoverageControl({ refreshKey = 0 }: { refreshKey?: number }) {
|
export function GraphCoverageControl({ refreshKey = 0 }: { refreshKey?: number }) {
|
||||||
const [open, setOpen] = useState(false);
|
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 [chats, setChats] = useState<CoverageChat[]>([]);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [extracting, setExtracting] = useState<string[]>([]); // click-pending ids (optimistic until the poll says running)
|
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);
|
const pending = (chat: CoverageChat) => chat.running || extracting.includes(chat.chatId);
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div ref={rootRef} className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Extraction coverage — what the knowledge graph has covered"
|
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 { incrementalLayout, type GraphNode, type GraphSnapshot } from "@/src/core";
|
||||||
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
|
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
|
||||||
import { GraphCoverageControl } from "./graph-coverage.tsx";
|
import { GraphCoverageControl } from "./graph-coverage.tsx";
|
||||||
|
import { usePopoverDismiss } from "@/components/popover-dismiss";
|
||||||
import {
|
import {
|
||||||
ARROW_LENGTH_LADDER,
|
ARROW_LENGTH_LADDER,
|
||||||
CHARGE_RANGE,
|
CHARGE_RANGE,
|
||||||
@@ -221,10 +222,14 @@ function GraphSettingsControl({
|
|||||||
changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void;
|
changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
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 regionTuning = settings.regions;
|
||||||
const physicsTuning = settings.physics;
|
const physicsTuning = settings.physics;
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div ref={rootRef} className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Rendering settings"
|
title="Rendering settings"
|
||||||
@@ -957,12 +962,15 @@ export function GraphView() {
|
|||||||
|
|
||||||
{/* Legend + filters: search box and per-state toggle rows. */}
|
{/* Legend + filters: search box and per-state toggle rows. */}
|
||||||
{/* Top-right controls — separate floating containers (owner,
|
{/* Top-right controls — separate floating containers (owner,
|
||||||
graph-view round 2): the search box, the settings gear (its own
|
graph-view round 2). Row order (2026-09-17): search,
|
||||||
collapsible component), the import button, and the coverage
|
coverage, import, gear — coverage sits between search and
|
||||||
control. Coverage renders even when the graph is EMPTY — an
|
import; the gear keeps the corner. items-start (not center)
|
||||||
empty graph with unextracted sessions is exactly when it
|
so the search container's no-match note can grow downward
|
||||||
matters (extract → the graph gains nodes). */}
|
without shifting its siblings — every button is 44px tall,
|
||||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
|
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 && (
|
{!isEmpty && (
|
||||||
<>
|
<>
|
||||||
{/* Search — its own container (icon + input + no-match note). */}
|
{/* Search — its own container (icon + input + no-match note). */}
|
||||||
@@ -983,6 +991,16 @@ export function GraphView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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. */}
|
{/* Import notes — its own container. */}
|
||||||
<div className="rounded-xl border bg-card/85 p-1.5 shadow-lg backdrop-blur">
|
<div className="rounded-xl border bg-card/85 p-1.5 shadow-lg backdrop-blur">
|
||||||
<input
|
<input
|
||||||
@@ -1007,14 +1025,11 @@ export function GraphView() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Rendering settings — own component; the gear toggles it. The
|
{/* Rendering settings — own component; the gear toggles it. The
|
||||||
gear sits at the far right (owner, round 3) so its panel
|
gear sits at the far right (owner, round 3) so its panel
|
||||||
drops at the very right edge, over less of the graph. */}
|
drops at the very right edge, over less of the graph. */}
|
||||||
<GraphSettingsControl settings={settings} changeSettings={changeSettings} />
|
<GraphSettingsControl settings={settings} changeSettings={changeSettings} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Extraction coverage — own container, always present; the
|
|
||||||
panel re-reads when background runs settle (refreshTick). */}
|
|
||||||
<GraphCoverageControl refreshKey={refreshTick} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Background pipeline status: what is currently running. */}
|
{/* 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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
void refresh();
|
void refresh();
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export interface ChatStore {
|
|||||||
/** Persist the per-chat model/thinking settings (the model control).
|
/** Persist the per-chat model/thinking settings (the model control).
|
||||||
* Replace-whole: the picker sends the complete settings object. */
|
* Replace-whole: the picker sends the complete settings object. */
|
||||||
saveSettings(id: string, settings: ChatSettings): void;
|
saveSettings(id: string, settings: ChatSettings): void;
|
||||||
|
/** Rename a session (the header's click-to-edit title, 2026-09-17).
|
||||||
|
* Like saveSettings: a rename is NOT conversation activity — updatedAt
|
||||||
|
* stays put, so sidebar order never churns on a rename. */
|
||||||
|
renameChat(id: string, title: string): ChatRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── GraphStore — the append-only evidence ledgers (data/graph) ──────────────
|
// ─── GraphStore — the append-only evidence ledgers (data/graph) ──────────────
|
||||||
|
|||||||
@@ -116,6 +116,19 @@ function saveSettings(id: string, settings: ChatSettings): void {
|
|||||||
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rename a session (the header's click-to-edit title, 2026-09-17). Like
|
||||||
|
* saveSettings, a rename is NOT conversation activity: updatedAt stays put
|
||||||
|
* so sidebar order never churns. Same fallback as createChat — the empty
|
||||||
|
* string becomes "Untitled session" (the API gate rejects it first; this
|
||||||
|
* is the honest floor for any other caller). */
|
||||||
|
function renameChat(id: string, title: string): ChatRecord {
|
||||||
|
const record = getChat(id);
|
||||||
|
if (!record) throw new Error(`Chat not found: ${id}`);
|
||||||
|
record.title = title.trim() || "Untitled session";
|
||||||
|
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
/** Persist the session plan tree (plan tools call this in execute). */
|
/** Persist the session plan tree (plan tools call this in execute). */
|
||||||
function saveTree(id: string, tree: PlanTree): void {
|
function saveTree(id: string, tree: PlanTree): void {
|
||||||
const record = getChat(id);
|
const record = getChat(id);
|
||||||
@@ -133,6 +146,7 @@ export const chatStore: ChatStore = {
|
|||||||
saveMessages,
|
saveMessages,
|
||||||
saveTree,
|
saveTree,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
|
renameChat,
|
||||||
appendTurnUsage,
|
appendTurnUsage,
|
||||||
appendExtractionUsage,
|
appendExtractionUsage,
|
||||||
};
|
};
|
||||||
|
|||||||
+29
-1
@@ -528,4 +528,32 @@ describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
|||||||
assert.equal(ok.status, 200);
|
assert.equal(ok.status, 200);
|
||||||
await drain(ok);
|
await drain(ok);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("renameChat (the header's click-to-edit title)", () => {
|
||||||
|
it("persists the title and does NOT bump updatedAt — sidebar order must not churn", () => {
|
||||||
|
const chat = chatStore.createChat("old title");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
const before = chatStore.getChat(chat.id)!.updatedAt;
|
||||||
|
const renamed = chatStore.renameChat(chat.id, " new title ");
|
||||||
|
assert.equal(renamed.title, "new title", "trimmed on the way in");
|
||||||
|
assert.equal(chatStore.getChat(chat.id)!.title, "new title");
|
||||||
|
assert.equal(
|
||||||
|
chatStore.getChat(chat.id)!.updatedAt,
|
||||||
|
before,
|
||||||
|
"a rename is not conversation activity",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
chatStore.listChats().find((c) => c.id === chat.id)!.title,
|
||||||
|
"new title",
|
||||||
|
"the sidebar list view sees it",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to Untitled session on an all-whitespace title (the honest floor)", () => {
|
||||||
|
const chat = chatStore.createChat("keep me");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
const renamed = chatStore.renameChat(chat.id, " ");
|
||||||
|
assert.equal(renamed.title, "Untitled session");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user