From 34104c2dcc879df26f1af7cdce496f9bb2eed1b9 Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Tue, 28 Jul 2026 14:35:19 -0400 Subject: [PATCH] R8a: full redesign of the Chat Library dialog The library was a permanently-boxed flat list with a shared toolbar that mutated based on "whichever row is selected" (New Chat / Load Chat / Rename / Delete swapping into an inline title-editor or a delete-confirm warning) - no content preview, no date grouping, click-to-select-then- click-Load as a two-step action. Researched how chat-history/workspace- save lists are handled elsewhere before redesigning: date-bucketed grouping (Today/Yesterday/Previous 7 Days/Previous 30 Days/Older), one clear primary action per row, at most two scoped secondary actions, and a genuine content snapshot rather than title-only rows are all now standard expectations this dialog didn't meet. Backend: chats now carry a one-line preview (the last chat message) and a message count, computed once at save time from the same in-memory chat_data dict already being serialized - not parsed back out of the stored JSON blob on every list read, which would mean loading every row's full data (images can be embedded as base64 inside it) just to render a list of titles. Real ISO timestamps are also exposed for date-bucketing; the existing human-formatted labels are unchanged. A migration adds the two new columns to existing chats.db files in place. Frontend: rows are flat/borderless at rest (the same "flat until hover, one accent element" language the composer island just established) and group under sticky date headers. Clicking a row's body loads that chat directly - no separate select step. Rename and delete are scoped to their own row (an inline title-swap for rename, an inline confirm for delete, never a shared toolbar) and stay always visible rather than hover-gated, since a hover-only reveal reintroduces the "rename/delete controls went missing" regression comparable software has shipped and been criticized for. New Chat is a persistent header action, independent of any row. Verified against the real, populated ~/.graphlink/chats.db: the schema migration applies cleanly to existing rows, all five date groups render with real data, and every legacy (pre-migration) row falls back to a "No messages yet" placeholder rather than a blank line. --- backend/chat_library.py | 79 ++- backend/tests/test_chat_library.py | 72 ++- .../graphlink_app_chat_library_payload.py | 24 +- .../src/app/chrome/ChatLibraryDialog.test.tsx | 197 +++++++- web_ui/src/app/chrome/ChatLibraryDialog.tsx | 470 ++++++++++++------ web_ui/src/app/styles.css | 378 +++++++++++--- .../app-chat-library-state.schema.json | 16 +- .../generated/app-chat-library-state.ts | 22 + 8 files changed, 997 insertions(+), 261 deletions(-) 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 {