diff --git a/backend/chat_library.py b/backend/chat_library.py index e583e9a..3c6bbe4 100644 --- a/backend/chat_library.py +++ b/backend/chat_library.py @@ -62,6 +62,55 @@ def _format_timestamp(value: Any) -> str: return str(value) +def _format_timestamp_iso(value: Any) -> str | None: + """R8a: the Chat Library redesign groups rows by date (Today/Yesterday/ + Previous 7 Days/...), which needs a real, parseable instant - the + display label from _format_timestamp above is deliberately locale/human + formatted and not meant to be parsed back. None (not a sentinel string) + on anything unparseable/empty, so the frontend can cleanly bucket those + rows as "Unknown" rather than crash on a bad date.""" + if not value: + return None + try: + return datetime.strptime(str(value), "%Y-%m-%d %H:%M:%S").isoformat() + except ValueError: + return None + + +_PREVIEW_MAX_CHARS = 140 + + +def _extract_preview_and_message_count(chat_data: dict[str, Any]) -> tuple[str, int]: + """R8a: a one-line snippet (the last chat message) + total message count + for the redesigned Chat Library list. Deliberately computed HERE, at + save time, from the SAME chat_data dict already about to be + json.dumps'd - not parsed back out of `data` at list-read time in + get_all_chats, which would mean loading every row's full JSON blob + (images can be embedded as base64 bytes inside it - see + backend/canvas.py's _process_content_for_serialization) just to render + a list of titles. This is effectively free: no extra I/O, no extra + parsing, just a pass over a dict already in memory. + + raw_content is a plain string for a text-only message, or a list of + content-part dicts (`_serialize_chat_node`) for a multimodal one - only + the "text" parts of the latter contribute to the preview, matching what + a user actually reads as the message.""" + chat_nodes = [ + node for node in chat_data.get("nodes", []) + if isinstance(node, dict) and node.get("node_type") == "chat" + ] + last_content = chat_nodes[-1].get("raw_content") if chat_nodes else None + if isinstance(last_content, list): + text = " ".join( + str(part.get("text", "")) for part in last_content + if isinstance(part, dict) and part.get("type") == "text" + ) + else: + text = str(last_content or "") + preview = " ".join(text.split())[:_PREVIEW_MAX_CHARS] + return preview, len(chat_nodes) + + def _connect(db_path: Path) -> sqlite3.Connection: db_path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(db_path, timeout=30) @@ -85,6 +134,18 @@ def _ensure_chats_table(conn: sqlite3.Connection) -> None: ) """ ) + # R8a: the redesigned Chat Library list needs a preview snippet + message + # count per row (see _extract_preview_and_message_count) - mirrors the + # notes/pins tables' own PRAGMA table_info + ALTER TABLE migration idiom + # exactly, so a chats.db written before this change gains the columns in + # place rather than needing a destructive rebuild. Defaults keep + # pre-migration rows valid (empty preview, zero count) until their next + # save recomputes both for real. + columns = [info[1] for info in conn.execute("PRAGMA table_info(chats)").fetchall()] + if "preview" not in columns: + conn.execute("ALTER TABLE chats ADD COLUMN preview TEXT DEFAULT ''") + if "message_count" not in columns: + conn.execute("ALTER TABLE chats ADD COLUMN message_count INTEGER DEFAULT 0") def _ensure_notes_table(conn: sqlite3.Connection) -> None: @@ -149,7 +210,8 @@ def get_all_chats(db_path: Path) -> list[dict[str, Any]]: with contextlib.closing(_connect(db_path)) as conn, conn: _ensure_chats_table(conn) rows = conn.execute( - "SELECT id, title, created_at, updated_at FROM chats ORDER BY updated_at DESC" + "SELECT id, title, created_at, updated_at, preview, message_count " + "FROM chats ORDER BY updated_at DESC" ).fetchall() return [ { @@ -157,6 +219,10 @@ def get_all_chats(db_path: Path) -> list[dict[str, Any]]: "title": str(row[1]), "createdLabel": _format_timestamp(row[2]), "updatedLabel": _format_timestamp(row[3]), + "createdAtIso": _format_timestamp_iso(row[2]), + "updatedAtIso": _format_timestamp_iso(row[3]), + "preview": str(row[4] or ""), + "messageCount": int(row[5] or 0), } for row in rows ] @@ -265,6 +331,7 @@ def save_chat_atomically_row( caller (mirrors _prepare_chat_payload's own pop, done once at the boundary rather than inside this function).""" chat_data_json = json.dumps(chat_data) + preview, message_count = _extract_preview_and_message_count(chat_data) with contextlib.closing(_connect(db_path)) as conn: _ensure_chats_table(conn) _ensure_notes_table(conn) @@ -272,14 +339,16 @@ def save_chat_atomically_row( with conn: if chat_id: conn.execute( - "UPDATE chats SET title = ?, data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", - (title, chat_data_json, chat_id), + "UPDATE chats SET title = ?, data = ?, preview = ?, message_count = ?, " + "updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (title, chat_data_json, preview, message_count, chat_id), ) resolved_chat_id = chat_id else: cursor = conn.execute( - "INSERT INTO chats (title, data, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", - (title, chat_data_json), + "INSERT INTO chats (title, data, preview, message_count, updated_at) " + "VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)", + (title, chat_data_json, preview, message_count), ) resolved_chat_id = cursor.lastrowid diff --git a/backend/tests/test_chat_library.py b/backend/tests/test_chat_library.py index 2ed2bd4..90c4fb9 100644 --- a/backend/tests/test_chat_library.py +++ b/backend/tests/test_chat_library.py @@ -17,8 +17,10 @@ from backend.chat_library import ( AUTOSAVE_OWNER, USER_OWNER, + _extract_preview_and_message_count, _fallback_title, _format_timestamp, + _format_timestamp_iso, _resolve_seed_message, chat_library_payload, delete_chat, @@ -78,8 +80,16 @@ def test_get_all_chats_reads_real_rows(db_path): ids = {row["id"] for row in rows} assert ids == {first_id, second_id} for row in rows: - assert set(row) == {"id", "title", "createdLabel", "updatedLabel"} + assert set(row) == { + "id", "title", "createdLabel", "updatedLabel", + "createdAtIso", "updatedAtIso", "preview", "messageCount", + } assert row["updatedLabel"] == "Jan 02, 2026 11:30 AM" + assert row["updatedAtIso"] == "2026-01-02T11:30:00" + # _insert_chat writes the OLD (pre-R8a) column set directly - the + # ALTER TABLE migration must still leave these rows valid. + assert row["preview"] == "" + assert row["messageCount"] == 0 def test_format_timestamp_matches_legacy_display_format(): @@ -89,6 +99,55 @@ def test_format_timestamp_matches_legacy_display_format(): assert _format_timestamp("not-a-timestamp") == "not-a-timestamp" +def test_format_timestamp_iso_returns_a_real_parseable_instant(): + assert _format_timestamp_iso("2026-01-02 11:30:00") == "2026-01-02T11:30:00" + assert _format_timestamp_iso("") is None + assert _format_timestamp_iso(None) is None + assert _format_timestamp_iso("not-a-timestamp") is None + + +def test_extract_preview_uses_the_last_chat_nodes_text(): + chat_data = { + "nodes": [ + {"node_type": "chat", "raw_content": "first message", "is_user": True}, + {"node_type": "code", "code": "x = 1"}, + {"node_type": "chat", "raw_content": " the real last message ", "is_user": False}, + ], + } + preview, count = _extract_preview_and_message_count(chat_data) + assert preview == "the real last message" + assert count == 2 + + +def test_extract_preview_handles_multimodal_content_parts(): + chat_data = { + "nodes": [ + { + "node_type": "chat", + "raw_content": [ + {"type": "text", "text": "look at this"}, + {"type": "image_bytes", "data": "not-real-image-data"}, + ], + "is_user": True, + }, + ], + } + preview, count = _extract_preview_and_message_count(chat_data) + assert preview == "look at this" + assert count == 1 + + +def test_extract_preview_truncates_long_text(): + chat_data = {"nodes": [{"node_type": "chat", "raw_content": "a" * 500, "is_user": True}]} + preview, _ = _extract_preview_and_message_count(chat_data) + assert len(preview) == 140 + + +def test_extract_preview_is_empty_for_no_chat_nodes(): + assert _extract_preview_and_message_count({"nodes": []}) == ("", 0) + assert _extract_preview_and_message_count({}) == ("", 0) + + def test_rename_chat_persists_and_updates_timestamp(db_path): chat_id = _insert_chat(db_path, "Original") rename_chat(db_path, chat_id, "Renamed") @@ -347,6 +406,17 @@ def test_save_chat_atomically_row_inserts_when_chat_id_is_none(db_path): assert row == {"title": "New Title", "data": {"nodes": []}} +def test_save_chat_atomically_row_persists_a_real_preview_and_message_count(db_path): + chat_data = { + "nodes": [{"node_type": "chat", "raw_content": "hello there world", "is_user": True}], + } + chat_id = save_chat_atomically_row(db_path, None, "T", chat_data, [], []) + + row = next(r for r in get_all_chats(db_path) if r["id"] == chat_id) + assert row["preview"] == "hello there world" + assert row["messageCount"] == 1 + + def test_save_chat_atomically_row_updates_the_same_row_when_chat_id_given(db_path): first_id = save_chat_atomically_row(db_path, None, "First", {"nodes": [1]}, [], []) second_id = save_chat_atomically_row(db_path, first_id, "First", {"nodes": [1, 2]}, [], []) diff --git a/contracts/graphlink_app_chat_library_payload.py b/contracts/graphlink_app_chat_library_payload.py index 06efc1a..fc8d5b9 100644 --- a/contracts/graphlink_app_chat_library_payload.py +++ b/contracts/graphlink_app_chat_library_payload.py @@ -1,12 +1,16 @@ -"""The SPA chat-library topic's wire contract (Qt-removal plan R2.5e). +"""The SPA chat-library topic's wire contract (Qt-removal plan R2.5e, R8a +library redesign). -Field-for-field the same shape as graphlink_chat_library_payload.py's -ChatLibraryStatePayload (id/title/createdLabel/updatedLabel rows, plus a -`notice` field for a recoverable DB-read error), registered as a distinct -codegen artifact so the SPA's validator is generated from this independent -Qt-free source rather than importing anything Qt-coupled. No loadChat/ -newChat/search-query field: search is client-only, and load/new-chat are -deferred to R6 (see backend/chat_library.py's module docstring). +Started field-for-field identical to graphlink_chat_library_payload.py's +(now-deleted, Qt-era) ChatLibraryStatePayload; the Qt app is fully gone as +of R7.6b, so there is no longer any legacy shape to mirror. R8a adds what +the redesigned list needs to show real per-row content instead of just a +title and two timestamps: createdAtIso/updatedAtIso (real parseable +instants, for date-bucketed grouping - createdLabel/updatedLabel stay as +human display strings, not meant to be parsed back), preview (a one-line +snippet of the last message, computed once at save time - see +backend/chat_library.py's _extract_preview_and_message_count), and +messageCount. """ from __future__ import annotations @@ -20,6 +24,10 @@ class AppChatLibraryRowPayload: title: str createdLabel: str updatedLabel: str + createdAtIso: str | None + updatedAtIso: str | None + preview: str + messageCount: int @dataclass diff --git a/web_ui/src/app/chrome/ChatLibraryDialog.test.tsx b/web_ui/src/app/chrome/ChatLibraryDialog.test.tsx index 93ac807..b58b446 100644 --- a/web_ui/src/app/chrome/ChatLibraryDialog.test.tsx +++ b/web_ui/src/app/chrome/ChatLibraryDialog.test.tsx @@ -1,17 +1,45 @@ -import { act, render, screen } from "@testing-library/react"; +import { act, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ChatLibraryDialog } from "./ChatLibraryDialog"; import { OverlayProvider, useOverlays } from "../overlays/overlays"; import type { WsTransport } from "../../lib/ws/transport"; +function row(overrides: Record) { + return { + id: 1, + title: "Untitled", + createdLabel: "Jan 01, 2026", + updatedLabel: "Jan 01, 2026", + createdAtIso: "2026-01-01T08:00:00", + updatedAtIso: "2026-01-01T08:00:00", + preview: "", + messageCount: 0, + ...overrides, + }; +} + const snapshot = { schemaVersion: 1, minCompatibleSchemaVersion: 1, revision: 1, rows: [ - { id: 1, title: "First Chat", createdLabel: "Jan 01, 2026", updatedLabel: "Jan 02, 2026" }, - { id: 2, title: "Second Chat", createdLabel: "Jan 03, 2026", updatedLabel: "Jan 04, 2026" }, + row({ + id: 1, + title: "First Chat", + preview: "hello there", + messageCount: 2, + createdAtIso: "2026-01-15T08:00:00", + updatedAtIso: "2026-01-15T08:00:00", + }), + row({ + id: 2, + title: "Second Chat", + preview: "another conversation", + messageCount: 5, + createdAtIso: "2026-01-14T08:00:00", + updatedAtIso: "2026-01-14T08:00:00", + }), ], notice: null, }; @@ -59,44 +87,173 @@ function setup() { return { user, ...fake }; } +// "Now" is pinned so date-bucketing is deterministic; only Date is faked so +// userEvent's own internal async scheduling is unaffected. +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-01-15T12:00:00")); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + describe("ChatLibraryDialog", () => { - it("disables Load Chat when there are no saved chats at all", async () => { + it("shows a real empty state (not a small message inside an otherwise-normal list) when nothing has been saved", async () => { const { user, push } = setup(); act(() => push({ ...snapshot, rows: [] })); await user.click(screen.getByText("open library")); - expect(screen.getByText("Load Chat")).toBeDisabled(); + expect(screen.getByText("No saved chats yet")).toBeInTheDocument(); + expect(screen.queryByLabelText("Search chats")).toBeNull(); }); - it("Load Chat is enabled once a row is selectable, dispatches loadChat with the right id, and closes the dialog", async () => { + it("clicking a row's body loads THAT chat and closes the dialog - no separate select-then-Load step", async () => { const { user, intents } = setup(); await user.click(screen.getByText("open library")); - // The first row is the default effective selection. - const loadButton = screen.getByText("Load Chat"); - expect(loadButton).not.toBeDisabled(); + await user.click(screen.getByRole("button", { name: /Open chat "Second Chat"/ })); - await user.click(loadButton); - expect(intents).toContainEqual(["app-chat-library", "loadChat", [1]]); - // Dialog closes immediately on Load Chat, matching legacy's own behavior. + expect(intents).toContainEqual(["app-chat-library", "loadChat", [2]]); expect(screen.queryByRole("dialog")).toBeNull(); }); - it("loadChat targets whichever row is explicitly selected, not always the first", async () => { + it("New Chat dispatches the newChat intent and closes the dialog", async () => { const { user, intents } = setup(); await user.click(screen.getByText("open library")); - await user.click(screen.getByText("Second Chat")); - await user.click(screen.getByText("Load Chat")); - expect(intents).toContainEqual(["app-chat-library", "loadChat", [2]]); + await user.click(screen.getByRole("button", { name: "New Chat" })); + expect(intents).toContainEqual(["app-chat-library", "newChat", []]); + expect(screen.queryByRole("dialog")).toBeNull(); }); - it("New Chat dispatches the newChat intent and closes the dialog", async () => { + it("groups rows under the correct date headers, most recent group first", async () => { + const { user, push } = setup(); + act(() => + push({ + ...snapshot, + rows: [ + row({ id: 1, title: "Today Chat", updatedAtIso: "2026-01-15T08:00:00", createdAtIso: "2026-01-15T08:00:00" }), + row({ id: 2, title: "Yesterday Chat", updatedAtIso: "2026-01-14T08:00:00", createdAtIso: "2026-01-14T08:00:00" }), + row({ id: 3, title: "Week Chat", updatedAtIso: "2026-01-09T08:00:00", createdAtIso: "2026-01-09T08:00:00" }), + row({ id: 4, title: "Month Chat", updatedAtIso: "2026-01-01T08:00:00", createdAtIso: "2026-01-01T08:00:00" }), + row({ id: 5, title: "Ancient Chat", updatedAtIso: "2025-11-01T08:00:00", createdAtIso: "2025-11-01T08:00:00" }), + row({ id: 6, title: "Undated Chat", updatedAtIso: null, createdAtIso: null }), + ], + }), + ); + await user.click(screen.getByText("open library")); + + const headers = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent); + expect(headers).toEqual(["Today", "Yesterday", "Previous 7 Days", "Previous 30 Days", "Older"]); + + const olderSection = screen.getByRole("region", { name: "Older" }); + // Undated rows sort AFTER dated Older rows, never guessed into a + // recent bucket just because they have no timestamp. + const olderTitles = within(olderSection) + .getAllByRole("button", { name: /^Open chat/ }) + .map((b) => b.textContent); + expect(olderTitles[0]).toContain("Ancient Chat"); + expect(olderTitles[1]).toContain("Undated Chat"); + }); + + it("an empty preview renders a placeholder instead of a blank line", async () => { + const { user, push } = setup(); + act(() => push({ ...snapshot, rows: [row({ id: 9, title: "Blank Chat", preview: "" })] })); + await user.click(screen.getByText("open library")); + + expect(screen.getByText("No messages yet")).toBeInTheDocument(); + }); + + it("search filters live against title and preview text", async () => { + const { user } = setup(); + await user.click(screen.getByText("open library")); + + await user.type(screen.getByLabelText("Search chats"), "another"); + + expect(screen.queryByText("First Chat")).toBeNull(); + expect(screen.getByText("Second Chat")).toBeInTheDocument(); + }); + + it("shows a distinct 'no matches' state (not the empty-library state) for a search with no results, and Clear search restores the list", async () => { + const { user } = setup(); + await user.click(screen.getByText("open library")); + + await user.type(screen.getByLabelText("Search chats"), "nothing matches this"); + expect(screen.getByText('No chats match "nothing matches this".')).toBeInTheDocument(); + expect(screen.queryByText("No saved chats yet")).toBeNull(); + + await user.click(screen.getByText("Clear search")); + expect(screen.getByText("First Chat")).toBeInTheDocument(); + }); + + it("renaming a row: pencil opens an inline input, Enter commits renameChat with the trimmed title", async () => { const { user, intents } = setup(); await user.click(screen.getByText("open library")); - await user.click(screen.getByText("New Chat")); - expect(intents).toContainEqual(["app-chat-library", "newChat", []]); + await user.click(screen.getByLabelText('Rename "First Chat"')); + const input = screen.getByLabelText('Rename "First Chat"') as HTMLInputElement; + expect(input.value).toBe("First Chat"); + + await user.clear(input); + await user.type(input, " Renamed Title {Enter}"); + + expect(intents).toContainEqual(["app-chat-library", "renameChat", [1, "Renamed Title"]]); + // The dialog stays open for a rename (unlike load/new). + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("renaming a row: the Cancel (x) button reverts without dispatching renameChat", async () => { + const { user, intents } = setup(); + await user.click(screen.getByText("open library")); + + await user.click(screen.getByLabelText('Rename "First Chat"')); + await user.click(screen.getByLabelText("Cancel rename")); + + expect(intents.filter((i) => i[1] === "renameChat")).toEqual([]); + expect(screen.getByText("First Chat")).toBeInTheDocument(); + }); + + it("Escape while renaming closes the whole dialog (the app's global Escape-to-close policy) without committing a rename", async () => { + const { user, intents } = setup(); + await user.click(screen.getByText("open library")); + + await user.click(screen.getByLabelText('Rename "First Chat"')); + await user.keyboard("{Escape}"); + + expect(intents.filter((i) => i[1] === "renameChat")).toEqual([]); expect(screen.queryByRole("dialog")).toBeNull(); }); + + it("deleting a row: trash opens an inline scoped confirm, confirming dispatches deleteChat for THAT row only", async () => { + const { user, intents } = setup(); + await user.click(screen.getByText("open library")); + + await user.click(screen.getByLabelText('Delete "First Chat"')); + expect(screen.getByText("Delete?")).toBeInTheDocument(); + // The other row is untouched - no confirm state leaked onto it. + expect(screen.getByLabelText('Delete "Second Chat"')).toBeInTheDocument(); + + await user.click(screen.getByLabelText('Confirm delete "First Chat"')); + expect(intents).toContainEqual(["app-chat-library", "deleteChat", [1]]); + }); + + it("deleting a row: Cancel dismisses the confirm without dispatching deleteChat", async () => { + const { user, intents } = setup(); + await user.click(screen.getByText("open library")); + + await user.click(screen.getByLabelText('Delete "First Chat"')); + await user.click(screen.getByLabelText("Cancel delete")); + + expect(intents.filter((i) => i[1] === "deleteChat")).toEqual([]); + expect(screen.getByLabelText('Delete "First Chat"')).toBeInTheDocument(); + }); + + it("a DB-read notice still renders when present", async () => { + const { user, push } = setup(); + act(() => push({ ...snapshot, notice: "Could not load saved chats: disk error" })); + await user.click(screen.getByText("open library")); + + expect(screen.getByText("Could not load saved chats: disk error")).toBeInTheDocument(); + }); }); diff --git a/web_ui/src/app/chrome/ChatLibraryDialog.tsx b/web_ui/src/app/chrome/ChatLibraryDialog.tsx index 00a54ca..5dab5e6 100644 --- a/web_ui/src/app/chrome/ChatLibraryDialog.tsx +++ b/web_ui/src/app/chrome/ChatLibraryDialog.tsx @@ -1,17 +1,26 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { WsTransport } from "../../lib/ws/transport"; import { TOPIC_VALIDATORS } from "../../lib/api-contract/topics"; -import type { AppChatLibraryState } from "../../lib/bridge-core/generated/app-chat-library-state"; +import type { AppChatLibraryRow, AppChatLibraryState } from "../../lib/bridge-core/generated/app-chat-library-state"; import { Dialog, useOverlays } from "../overlays/overlays"; /** - * The chat library dialog (Qt-removal plan R2.5e + R6.4 + R6.5) - chat- - * library island's SPA successor. List/search/rename/delete/load/new are - * all real (backend/chat_library.py reads/writes the same - * ~/.graphlink/chats.db the legacy app uses; R6.4 wired Load Chat to a real - * backend/session_load.py restore, R6.5 wired New Chat to a real - * clear_for_load + current_chat_id reset - Save itself lives on the app - * bar, not this dialog, see AppBar.tsx's own comment). + * The chat library dialog (Qt-removal plan R2.5e + R6.4 + R6.5, R8a full + * redesign). List/search/rename/delete/load/new are all real (backend/ + * chat_library.py reads/writes the same ~/.graphlink/chats.db the legacy + * app uses). + * + * R8a replaced the old select-a-row-then-click-a-shared-toolbar-button + * model (which is what made this "the worst UI element" - a permanently + * boxed flat list, no content preview, four buttons that mutated based on + * "whichever row is currently selected") with a real chat-history list: + * date-grouped rows (Today/Yesterday/Previous 7 Days/Previous 30 Days/ + * Older), a one-line preview of each chat's last message, and per-row + * rename/delete that act on THAT row, never a shared selection. Clicking a + * row's body IS Load Chat now - there is no separate select step. Reuses + * the composer island's exact "flat/borderless until interaction, one + * filled accent element" language rather than inventing a new visual + * system - see styles.css's own comment on the .library-* rules. */ const initialState: AppChatLibraryState = { @@ -22,15 +31,114 @@ const initialState: AppChatLibraryState = { notice: null, }; +type BucketKey = "Today" | "Yesterday" | "Previous 7 Days" | "Previous 30 Days" | "Older"; +const BUCKET_ORDER: BucketKey[] = ["Today", "Yesterday", "Previous 7 Days", "Previous 30 Days", "Older"]; +const DAY_MS = 24 * 60 * 60 * 1000; + +function startOfLocalDay(date: Date): number { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); +} + +function effectiveIso(row: AppChatLibraryRow): string | null { + return row.updatedAtIso ?? row.createdAtIso ?? null; +} + +// Rows with no usable timestamp (an old pre-migration row, or a corrupt +// one) always land in Older, sorted after every dated Older row - never +// guessed into Today/Yesterday just because they're undated. +function bucketFor(row: AppChatLibraryRow, todayStart: number): BucketKey { + const iso = effectiveIso(row); + if (!iso) return "Older"; + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) return "Older"; + const diffDays = Math.round((todayStart - startOfLocalDay(parsed)) / DAY_MS); + if (diffDays <= 0) return "Today"; + if (diffDays === 1) return "Yesterday"; + if (diffDays <= 7) return "Previous 7 Days"; + if (diffDays <= 30) return "Previous 30 Days"; + return "Older"; +} + +function sortWithinBucket(rows: AppChatLibraryRow[]): AppChatLibraryRow[] { + return [...rows].sort((a, b) => { + const aIso = effectiveIso(a); + const bIso = effectiveIso(b); + if (aIso && bIso) return aIso < bIso ? 1 : aIso > bIso ? -1 : 0; + if (aIso) return -1; + if (bIso) return 1; + return b.id - a.id; + }); +} + +function groupRows(rows: AppChatLibraryRow[]): Array<{ key: BucketKey; rows: AppChatLibraryRow[] }> { + const todayStart = startOfLocalDay(new Date()); + const buckets = new Map(); + for (const row of rows) { + const key = bucketFor(row, todayStart); + const bucket = buckets.get(key); + if (bucket) bucket.push(row); + else buckets.set(key, [row]); + } + return BUCKET_ORDER.filter((key) => buckets.has(key)).map((key) => ({ + key, + rows: sortWithinBucket(buckets.get(key) as AppChatLibraryRow[]), + })); +} + +function Icon({ name }: { name: "search" | "pencil" | "trash" | "check" | "x" | "chat" }) { + switch (name) { + case "search": + return ( + + ); + case "pencil": + return ( + + ); + case "trash": + return ( + + ); + case "check": + return ( + + ); + case "x": + return ( + + ); + case "chat": + return ( + + ); + } +} + export function ChatLibraryDialog({ transport }: { transport: WsTransport }) { const overlays = useOverlays(); const [state, setState] = useState(initialState); const [query, setQuery] = useState(""); - const [selectedId, setSelectedId] = useState(null); const [renamingId, setRenamingId] = useState(null); const [renameDraft, setRenameDraft] = useState(""); const [confirmingDeleteId, setConfirmingDeleteId] = useState(null); const renameRef = useRef(null); + const deleteCancelRef = useRef(null); + const lastTriggerRef = useRef(null); useEffect(() => { return transport.subscribe("app-chat-library", (payload) => { @@ -47,10 +155,13 @@ export function ChatLibraryDialog({ transport }: { transport: WsTransport }) { } }, [renamingId]); - // A republish after delete/rename can drop the row a pending confirm/ - // rename targeted - reset-during-render on a revision change, same - // pattern as CommandPalette's wasOpen tracking, so neither can point at - // a gone row. + useEffect(() => { + if (confirmingDeleteId !== null) deleteCancelRef.current?.focus(); + }, [confirmingDeleteId]); + + // A republish after delete/rename elsewhere can drop the row a pending + // confirm/rename targeted - reset-during-render on a revision change, + // same pattern CommandPalette uses for its own wasOpen tracking. const [seenRevision, setSeenRevision] = useState(state.revision); if (seenRevision !== state.revision) { setSeenRevision(state.revision); @@ -61,23 +172,25 @@ export function ChatLibraryDialog({ transport }: { transport: WsTransport }) { const filtered = useMemo(() => { const term = query.trim().toLowerCase(); if (!term) return state.rows; - return state.rows.filter((row) => - `${row.title} ${row.updatedLabel} ${row.createdLabel}`.toLowerCase().includes(term), - ); + return state.rows.filter((row) => `${row.title} ${row.preview}`.toLowerCase().includes(term)); }, [query, state.rows]); - const effectiveSelected = filtered.find((row) => row.id === selectedId) ?? filtered[0] ?? null; + const groups = useMemo(() => groupRows(filtered), [filtered]); - function selectRow(id: number) { - setSelectedId(id); - setConfirmingDeleteId(null); - setRenamingId(null); + function loadChat(id: number) { + transport.intent("app-chat-library", "loadChat", [id]); + overlays.close(); + } + + function newChat() { + transport.intent("app-chat-library", "newChat", []); + overlays.close(); } - function startRename() { - if (!effectiveSelected) return; - setRenamingId(effectiveSelected.id); - setRenameDraft(effectiveSelected.title); + function startRename(row: AppChatLibraryRow, trigger: HTMLButtonElement) { + lastTriggerRef.current = trigger; + setRenamingId(row.id); + setRenameDraft(row.title); setConfirmingDeleteId(null); } @@ -86,37 +199,12 @@ export function ChatLibraryDialog({ transport }: { transport: WsTransport }) { if (renamingId === null || !title) return; transport.intent("app-chat-library", "renameChat", [renamingId, title]); setRenamingId(null); + lastTriggerRef.current?.focus(); } - function startDelete() { - if (!effectiveSelected) return; - setConfirmingDeleteId(effectiveSelected.id); + function cancelRename() { setRenamingId(null); - } - - function confirmDelete() { - if (confirmingDeleteId === null) return; - transport.intent("app-chat-library", "deleteChat", [confirmingDeleteId]); - setConfirmingDeleteId(null); - } - - function loadChat() { - if (!effectiveSelected) return; - // Fire-and-forget, same posture as rename/delete above - the backend's - // own loadChat intent (backend/chat_library.py) reports success/failure - // via a real "notification" publish, not a return value here. Closing - // the dialog immediately (rather than waiting on that notification) - // matches legacy's own ChatLibraryDialog, which closed itself the - // moment Load Chat was clicked. - transport.intent("app-chat-library", "loadChat", [effectiveSelected.id]); - overlays.close(); - } - - function newChat() { - // R6.5: clears the live canvas and drops current_chat_id server-side - - // same fire-and-forget, close-immediately posture as loadChat above. - transport.intent("app-chat-library", "newChat", []); - overlays.close(); + lastTriggerRef.current?.focus(); } function onRenameKeyDown(event: React.KeyboardEvent) { @@ -125,96 +213,57 @@ export function ChatLibraryDialog({ transport }: { transport: WsTransport }) { commitRename(); } else if (event.key === "Escape") { event.preventDefault(); - setRenamingId(null); + cancelRename(); } } + function startDelete(row: AppChatLibraryRow, trigger: HTMLButtonElement) { + lastTriggerRef.current = trigger; + setConfirmingDeleteId(row.id); + setRenamingId(null); + } + + function confirmDelete() { + if (confirmingDeleteId === null) return; + transport.intent("app-chat-library", "deleteChat", [confirmingDeleteId]); + setConfirmingDeleteId(null); + } + + function cancelDelete() { + setConfirmingDeleteId(null); + lastTriggerRef.current?.focus(); + } + const total = state.rows.length; - const statusText = - total === 0 - ? "No saved chats yet." - : filtered.length !== total - ? `Showing ${filtered.length} of ${total} saved ${total === 1 ? "chat" : "chats"}.` - : `${total} saved ${total === 1 ? "chat" : "chats"}.`; + const resultsAnnouncement = + total === 0 ? "" : filtered.length === 0 ? "No chats match" : `${filtered.length} results`; return (
- setQuery(event.target.value)} - placeholder="Search chats..." - aria-label="Search chats" - autoComplete="off" - spellCheck={false} - /> - -
- {renamingId !== null ? ( - <> +
+ {total > 0 && ( +
+ setRenameDraft(event.target.value)} - onKeyDown={onRenameKeyDown} - aria-label="New chat title" + value={query} + onChange={(event) => setQuery(event.target.value)} + placeholder="Search chats..." + aria-label="Search chats" autoComplete="off" spellCheck={false} /> - - - - ) : confirmingDeleteId !== null ? ( - <> - - Delete this chat? This cannot be undone. - - - - - ) : ( - <> - - - - - +
)} + +
+ +
+ {resultsAnnouncement}
{state.notice && ( @@ -223,27 +272,148 @@ export function ChatLibraryDialog({ transport }: { transport: WsTransport }) {

)} -
    - {total > 0 && filtered.length === 0 &&
  • No chats match your search.
  • } - {filtered.map((row) => ( -
  • selectRow(row.id)} - > -

    {row.title}

    -

    - Updated {row.updatedLabel} · Created {row.createdLabel} -

    -
  • - ))} -
- -

- {statusText} -

+ {total === 0 ? ( +
+ +

No saved chats yet

+

Start a new chat to begin building your library.

+ +
+ ) : filtered.length === 0 ? ( +
+

No chats match "{query}".

+ +
+ ) : ( +
+ {groups.map((group) => ( +
+

{group.key}

+
    + {group.rows.map((row) => { + const isRenaming = renamingId === row.id; + const isConfirmingDelete = confirmingDeleteId === row.id; + const messageWord = row.messageCount === 1 ? "message" : "messages"; + return ( +
  • + {isRenaming ? ( +
    + setRenameDraft(event.target.value)} + onKeyDown={onRenameKeyDown} + aria-label={`Rename "${row.title}"`} + autoComplete="off" + spellCheck={false} + /> +

    Enter to save · Esc to cancel

    +
    + ) : ( + + )} + + {!isRenaming && !isConfirmingDelete && row.messageCount > 0 && ( + {row.messageCount} + )} + + {isRenaming ? ( +
    + + +
    + ) : isConfirmingDelete ? ( +
    { + if (event.key === "Escape") { + event.preventDefault(); + cancelDelete(); + } + }} + > + Delete? + + +
    + ) : ( +
    + + +
    + )} +
  • + ); + })} +
+
+ ))} +
+ )}
); diff --git a/web_ui/src/app/styles.css b/web_ui/src/app/styles.css index 348748e..610ba2c 100644 --- a/web_ui/src/app/styles.css +++ b/web_ui/src/app/styles.css @@ -2445,15 +2445,32 @@ body, font-size: 12px; } -/* -- Chat library dialog (R2.5e) -------------------------------------------- */ +/* -- Chat library dialog (R2.5e, R8a full redesign) ------------------------- + Was a permanently-boxed flat list with a shared toolbar that mutated + based on "whichever row is selected" - no content preview, no date + grouping, click-to-select-then-click-Load as a two-step action. R8a + replaces all of that with a real chat-history list: date-grouped rows + (Today/Yesterday/Previous 7 Days/Previous 30 Days/Older), a one-line + preview of each chat's last message, and per-row rename/delete that + act on THAT row alone. Deliberately reuses the composer island's exact + visual language (flat/borderless controls, var(--gl-neutral-button-hover) + as the one interaction fill, a single filled-pill accent element) rather + than inventing a competing style - see Composer.tsx-adjacent rules above + for the same idiom. Action icons stay always-visible (never opacity- + gated on hover) - a hover-only reveal reintroduces the "rename/delete + controls go missing" regression real chat-history UIs have shipped and + been criticized for. */ .library-dialog { - min-width: 480px; - max-width: min(560px, calc(100vw - 64px)); - height: min(620px, calc(100vh - 96px)); + /* Pinned, not a min/max range - the row count changes on every search + keystroke, so the dialog's own frame must never visibly resize with + it (same fix already applied to .settings-dialog). */ + width: min(680px, calc(100vw - 64px)); + height: min(680px, calc(100vh - 96px)); } .library-dialog .overlay-dialog-body { + padding: 0; flex: 1; min-height: 0; overflow: hidden; @@ -2464,15 +2481,45 @@ body, height: 100%; display: flex; flex-direction: column; - gap: 12px; color: var(--gl-surface-text); } +/* -- header: search + New Chat, persistent, never selection-gated -- */ + +.library-header { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 14px 20px; + border-bottom: 1px solid var(--gl-surface-border); +} + +.library-search-wrap { + position: relative; + flex: 1; + min-width: 0; +} + +.library-search-wrap .icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + width: 14px; + height: 14px; + fill: none; + stroke: var(--gl-surface-text-muted); + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; + pointer-events: none; +} + .library-search-input { box-sizing: border-box; - flex-shrink: 0; width: 100%; - padding: 10px 12px; + padding: 9px 12px 9px 32px; font-size: 13px; font-family: inherit; color: var(--gl-surface-text); @@ -2482,131 +2529,310 @@ body, outline: none; } -.library-toolbar { +.library-search-input:focus { + border-color: var(--gl-surface-text-muted); +} + +/* The one bold accent affordance in this dialog - same idiom as the + composer's filled circular send button, widened to a labeled pill + since "New Chat" needs a label, not just a glyph. */ +.library-new-chat-button { flex-shrink: 0; display: flex; align-items: center; - gap: 8px; -} - -.library-button { - padding: 8px 12px; - font-size: 11px; + gap: 6px; + padding: 8px 14px; + font-size: 12px; font-weight: 600; font-family: inherit; - color: var(--gl-surface-text); + color: var(--gl-surface-window); + background-color: var(--gl-surface-text-bright, var(--gl-surface-text-muted)); + border: none; + border-radius: 999px; + cursor: pointer; + transition: opacity 100ms ease; +} + +.library-new-chat-button:hover { + opacity: 0.88; +} + +.library-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.library-notice { + flex-shrink: 0; + margin: 10px 20px 0; + padding: 8px 10px; + font-size: 12px; + color: var(--gl-semantic-status-warning, currentColor); background-color: var(--gl-neutral-button-hover); border: 1px solid var(--gl-surface-border); border-radius: 8px; - cursor: pointer; } -.library-button:hover:not(:disabled) { - filter: brightness(1.15); -} +/* -- empty / no-match states -- */ -.library-button:disabled { - opacity: 0.45; - cursor: default; +.library-empty-state { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 32px 24px; + text-align: center; + color: var(--gl-surface-text-muted); } -.library-button-primary { - color: var(--gl-semantic-status-success, currentColor); - border-color: var(--gl-semantic-status-success, currentColor); +.library-empty-state .icon { + width: 32px; + height: 32px; + fill: none; + stroke: var(--gl-surface-text-muted); + stroke-width: 1.4; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.7; } -.library-button-danger { - color: var(--gl-semantic-status-error, currentColor); - border-color: var(--gl-semantic-status-error, currentColor); +.library-empty-state-title { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--gl-surface-text); } -.library-rename-input { - box-sizing: border-box; - flex: 1; - min-width: 0; - padding: 8px 10px; +.library-empty-state-sub { + margin: 0; font-size: 12px; - font-family: inherit; - color: var(--gl-surface-text); - background-color: var(--gl-surface-inset, var(--gl-surface-window)); - border: 1px solid var(--gl-surface-border); - border-radius: 8px; - outline: none; + max-width: 280px; } -.library-confirm-text { +.library-search-empty { flex: 1; - min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 28px 16px; + text-align: center; font-size: 12px; - color: var(--gl-semantic-status-warning, currentColor); + color: var(--gl-surface-text-muted); } -.library-notice { - flex-shrink: 0; +.library-search-empty p { margin: 0; - padding: 8px 10px; +} + +.library-search-empty-clear { + display: inline-block; + margin-top: 6px; + padding: 2px 4px; + background: none; + border: none; font-size: 12px; - color: var(--gl-semantic-status-warning, currentColor); - background-color: var(--gl-neutral-button-hover); - border: 1px solid var(--gl-surface-border); - border-radius: 8px; + font-family: inherit; + color: var(--gl-surface-text-muted); + text-decoration: underline; + cursor: pointer; } -.library-list { +/* -- scrolling, date-grouped list -- */ + +.library-groups { flex: 1; min-height: 0; - margin: 0; - padding: 0; - list-style: none; overflow-y: auto; - display: flex; - flex-direction: column; - gap: 6px; + padding: 4px 12px 12px; } -.library-empty { - padding: 12px 14px; - font-size: 12px; +.library-group:first-child .library-group-header { + padding-top: 6px; +} + +.library-group-header { + position: sticky; + top: 0; + z-index: 1; + margin: 0; + padding: 12px 8px 6px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; color: var(--gl-surface-text-muted); + background-color: var(--gl-surface-node-body); } +.library-group-rows { + margin: 0 0 4px; + padding: 0; + list-style: none; +} + +/* Row: flat/borderless at rest - matches the composer's control idiom + literally, no border/background until interaction. Elevation stays on + the dialog panel itself (the shared Dialog wrapper's own shadow), the + same way the composer dock - not its individual buttons - is the one + shadowed surface in that pattern. */ .library-row { - box-sizing: border-box; - padding: 12px 14px; - border: 1px solid var(--gl-surface-border); + position: relative; + display: flex; + align-items: center; + gap: 8px; border-radius: 8px; - background-color: var(--gl-neutral-button-hover); - cursor: pointer; } -.library-row:hover { - filter: brightness(1.15); +.library-row:hover, +.library-row:focus-within { + background-color: var(--gl-neutral-button-hover); } -.library-row.selected { - border-color: var(--gl-semantic-status-success, currentColor); +.library-row-primary { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 8px 4px 8px 10px; + text-align: left; + font-family: inherit; + color: inherit; + background: transparent; + border: none; + cursor: pointer; } .library-row-title { - margin: 0 0 4px 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-size: 13px; font-weight: 600; } -.library-row-meta { - margin: 0; - font-size: 11px; +.library-row-preview { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; color: var(--gl-surface-text-muted); } -.library-status { +.library-row-preview.placeholder, +.library-row-preview.hint { + font-style: italic; +} + +.library-row-count { flex-shrink: 0; - margin: 0; - padding-left: 2px; - font-size: 11px; + min-width: 20px; + text-align: right; + font-size: 10.5px; color: var(--gl-surface-text-muted); } +/* Rename: an in-place input filling the exact slot the title occupied */ +.library-row-rename-input { + box-sizing: border-box; + width: 100%; + padding: 2px 6px; + margin: -2px -6px; + font: inherit; + font-size: 13px; + font-weight: 600; + color: var(--gl-surface-text); + background-color: var(--gl-surface-inset, var(--gl-surface-window)); + border: 1px solid var(--gl-surface-text-muted); + border-radius: 6px; + outline: none; +} + +/* Actions: ALWAYS rendered and legible - never opacity/pointer-events + gated on hover (see this section's own header comment for why). */ +.library-row-actions { + flex-shrink: 0; + display: flex; + gap: 2px; + padding-right: 6px; +} + +.library-icon-button { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + color: var(--gl-neutral-button-icon); + background-color: transparent; + border: none; + border-radius: 6px; + cursor: pointer; +} + +.library-icon-button:hover:enabled { + background-color: var(--gl-neutral-button-hover); +} + +.library-icon-button:disabled { + opacity: 0.45; + cursor: default; +} + +.library-icon-button .icon { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; +} + +.library-icon-button-delete:hover { + color: var(--gl-semantic-status-error, currentColor); +} + +/* Inline delete confirm - occupies the same trailing slot, no popover: + this list scrolls, and a floating popover near the dialog's top/bottom + edge is fragile to position correctly, so the confirm swaps in place. */ +.library-row-confirm { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 4px; + padding-right: 6px; +} + +.library-row-confirm-label { + font-size: 10.5px; + color: var(--gl-semantic-status-warning, currentColor); +} + +.library-icon-button-confirm-delete { + color: var(--gl-semantic-status-error, currentColor); +} + +.library-icon-button-confirm-delete:hover { + background-color: var(--gl-semantic-status-error, currentColor); + color: var(--gl-surface-window); +} + /* -- R3.25/R3.26 conversation node ------------------------------------------ */ .conversation-node { diff --git a/web_ui/src/lib/bridge-core/generated/app-chat-library-state.schema.json b/web_ui/src/lib/bridge-core/generated/app-chat-library-state.schema.json index 8eb600c..d5b579c 100644 --- a/web_ui/src/lib/bridge-core/generated/app-chat-library-state.schema.json +++ b/web_ui/src/lib/bridge-core/generated/app-chat-library-state.schema.json @@ -15,15 +15,27 @@ "items": { "additionalProperties": false, "properties": { + "createdAtIso": { + "type": "string" + }, "createdLabel": { "type": "string" }, "id": { "type": "integer" }, + "messageCount": { + "type": "integer" + }, + "preview": { + "type": "string" + }, "title": { "type": "string" }, + "updatedAtIso": { + "type": "string" + }, "updatedLabel": { "type": "string" } @@ -32,7 +44,9 @@ "id", "title", "createdLabel", - "updatedLabel" + "updatedLabel", + "preview", + "messageCount" ], "type": "object" }, diff --git a/web_ui/src/lib/bridge-core/generated/app-chat-library-state.ts b/web_ui/src/lib/bridge-core/generated/app-chat-library-state.ts index 60e53f7..c51384b 100644 --- a/web_ui/src/lib/bridge-core/generated/app-chat-library-state.ts +++ b/web_ui/src/lib/bridge-core/generated/app-chat-library-state.ts @@ -7,6 +7,10 @@ export interface AppChatLibraryRow { title: string; createdLabel: string; updatedLabel: string; + createdAtIso?: string | null; + updatedAtIso?: string | null; + preview: string; + messageCount: number; } export interface AppChatLibraryState { @@ -54,6 +58,24 @@ function checkAppChatLibraryRow(value: unknown, path: string, errors: string[]): if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.updatedLabel: missing required field`); else { if (typeof fieldValue !== "string") errors.push(`${path}.updatedLabel` + ": expected string"); } } + { + const fieldValue = value["createdAtIso"]; + if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.createdAtIso` + ": expected string"); } + } + { + const fieldValue = value["updatedAtIso"]; + if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.updatedAtIso` + ": expected string"); } + } + { + const fieldValue = value["preview"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.preview: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.preview` + ": expected string"); } + } + { + const fieldValue = value["messageCount"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.messageCount: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.messageCount` + ": expected number"); } + } } function checkAppChatLibraryState(value: unknown, path: string, errors: string[]): void {