Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 74 additions & 5 deletions backend/chat_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -149,14 +210,19 @@ 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 [
{
"id": int(row[0]),
"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
]
Expand Down Expand Up @@ -265,21 +331,24 @@ 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)
_ensure_pins_table(conn)
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

Expand Down
72 changes: 71 additions & 1 deletion backend/tests/test_chat_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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():
Expand All @@ -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")
Expand Down Expand Up @@ -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]}, [], [])
Expand Down
24 changes: 16 additions & 8 deletions contracts/graphlink_app_chat_library_payload.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,6 +24,10 @@ class AppChatLibraryRowPayload:
title: str
createdLabel: str
updatedLabel: str
createdAtIso: str | None
updatedAtIso: str | None
preview: str
messageCount: int


@dataclass
Expand Down
Loading
Loading