Skip to content
Open
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
4 changes: 3 additions & 1 deletion studio/backend/routes/chat_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class ChatThread(BaseModel):
projectId: Optional[str] = None
archived: bool = False
createdAt: int
updatedAt: Optional[int] = None
openaiCodeExecContainerId: Optional[str] = None
anthropicCodeExecContainerId: Optional[str] = None
forkedFromThreadId: Optional[str] = None
Expand All @@ -70,6 +71,7 @@ class ChatThreadPatch(BaseModel):
projectId: Optional[str] = None
archived: Optional[bool] = None
createdAt: Optional[int] = None
updatedAt: Optional[int] = None
openaiCodeExecContainerId: Optional[str] = None
anthropicCodeExecContainerId: Optional[str] = None

Expand Down Expand Up @@ -251,7 +253,7 @@ async def patch_thread(
current_subject: str = Depends(get_current_subject),
):
patch = payload.model_dump(exclude_unset = True)
for field in ("title", "modelType", "modelId", "archived", "createdAt"):
for field in ("title", "modelType", "modelId", "archived", "createdAt", "updatedAt"):
if field in patch and patch[field] is None:
raise HTTPException(status_code = 400, detail = f"{field} cannot be null")
if patch.get("projectId") and get_chat_project(patch["projectId"]) is None:
Expand Down
47 changes: 44 additions & 3 deletions studio/backend/storage/studio_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
project_id TEXT,
archived INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER,
openai_code_exec_container_id TEXT,
anthropic_code_exec_container_id TEXT,
forked_from_thread_id TEXT,
Expand All @@ -245,6 +246,19 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
conn.execute("ALTER TABLE chat_threads ADD COLUMN forked_from_thread_id TEXT")
if "forked_from_message_id" not in chat_thread_cols:
conn.execute("ALTER TABLE chat_threads ADD COLUMN forked_from_message_id TEXT")
if "updated_at" not in chat_thread_cols:
conn.execute("ALTER TABLE chat_threads ADD COLUMN updated_at INTEGER")
conn.execute(
"""
UPDATE chat_threads SET updated_at = COALESCE(
(
SELECT MAX(m.created_at) FROM chat_messages m
WHERE m.thread_id = chat_threads.id
),
created_at
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_messages (
Expand Down Expand Up @@ -972,6 +986,9 @@ def _chat_thread_from_row(row: sqlite3.Row) -> dict:
"projectId": data.get("project_id") or None,
"archived": bool(data["archived"]),
"createdAt": data["created_at"],
"updatedAt": data.get("updated_at")
if data.get("updated_at") is not None
else data["created_at"],
"openaiCodeExecContainerId": data.get("openai_code_exec_container_id"),
"anthropicCodeExecContainerId": data.get("anthropic_code_exec_container_id"),
"forkedFromThreadId": data.get("forked_from_thread_id"),
Expand Down Expand Up @@ -1019,8 +1036,8 @@ def upsert_chat_thread(thread: dict) -> dict:
conn.execute(
"""
INSERT INTO chat_threads
(id, title, model_type, model_id, pair_id, project_id, archived, created_at, openai_code_exec_container_id, anthropic_code_exec_container_id, forked_from_thread_id, forked_from_message_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, title, model_type, model_id, pair_id, project_id, archived, created_at, updated_at, openai_code_exec_container_id, anthropic_code_exec_container_id, forked_from_thread_id, forked_from_message_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
model_type = excluded.model_type,
Expand All @@ -1029,6 +1046,7 @@ def upsert_chat_thread(thread: dict) -> dict:
project_id = excluded.project_id,
archived = excluded.archived,
created_at = excluded.created_at,
updated_at = COALESCE(excluded.updated_at, chat_threads.updated_at),
openai_code_exec_container_id = excluded.openai_code_exec_container_id,
anthropic_code_exec_container_id = excluded.anthropic_code_exec_container_id,
forked_from_thread_id = excluded.forked_from_thread_id,
Expand All @@ -1043,6 +1061,7 @@ def upsert_chat_thread(thread: dict) -> dict:
thread.get("projectId"),
1 if thread.get("archived") else 0,
int(thread["createdAt"]),
int(thread["updatedAt"]) if thread.get("updatedAt") is not None else None,
thread.get("openaiCodeExecContainerId"),
thread.get("anthropicCodeExecContainerId"),
thread.get("forkedFromThreadId"),
Expand All @@ -1064,6 +1083,7 @@ def update_chat_thread(id: str, patch: dict) -> Optional[dict]:
"projectId": ("project_id", patch.get("projectId")),
"archived": ("archived", 1 if patch.get("archived") else 0),
"createdAt": ("created_at", patch.get("createdAt")),
"updatedAt": ("updated_at", patch.get("updatedAt")),
"openaiCodeExecContainerId": (
"openai_code_exec_container_id",
patch.get("openaiCodeExecContainerId"),
Expand Down Expand Up @@ -1135,7 +1155,8 @@ def list_chat_threads(
conn = get_connection()
try:
rows = conn.execute(
f"SELECT * FROM chat_threads {where} ORDER BY created_at DESC",
f"SELECT * FROM chat_threads {where} "
"ORDER BY COALESCE(updated_at, created_at) DESC, created_at DESC",
values,
).fetchall()
return [_chat_thread_from_row(row) for row in rows]
Expand Down Expand Up @@ -1374,6 +1395,19 @@ def _raise_if_chat_message_thread_conflicts(
)


def _bump_chat_thread_updated_at(
conn: sqlite3.Connection, thread_id: str, message_created_at: int
) -> None:
conn.execute(
"""
UPDATE chat_threads
SET updated_at = MAX(COALESCE(updated_at, created_at), ?)
WHERE id = ?
""",
(message_created_at, thread_id),
)


def upsert_chat_message(message: dict) -> dict:
conn = get_connection()
try:
Expand Down Expand Up @@ -1412,6 +1446,9 @@ def upsert_chat_message(message: dict) -> dict:
int(message["createdAt"]),
),
)
_bump_chat_thread_updated_at(
conn, message["threadId"], int(message["createdAt"])
)
conn.commit()
return message
except Exception:
Expand Down Expand Up @@ -1464,6 +1501,10 @@ def sync_chat_messages(
for m in messages
],
)
if messages:
_bump_chat_thread_updated_at(
conn, thread_id, max(int(m["createdAt"]) for m in messages)
)
conn.commit()
return list_chat_messages(thread_id)
except ChatMessageConflictError:
Expand Down
114 changes: 114 additions & 0 deletions studio/backend/tests/test_chat_history_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
import os
import platform
import shutil
import sqlite3
import threading
import uuid
from pathlib import Path

import pytest

from storage import studio_db
from utils.paths import studio_db_path


def _reset_studio_db(
Expand Down Expand Up @@ -108,6 +110,118 @@ def test_sync_chat_messages_upserts_without_pruning(tmp_path, monkeypatch):
assert by_id["msg-2"]["content"] == [{"type": "text", "text": "updated text"}]


def test_chat_thread_updated_at_bumps_on_message_writes(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
thread = studio_db.upsert_chat_thread(_thread())
assert thread["updatedAt"] == thread["createdAt"]

studio_db.upsert_chat_message(_message("msg-1", 1_700_000_000_500, "hi"))
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_000_500

studio_db.upsert_chat_message(_message("msg-0", 1_600_000_000_000, "old"))
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_000_500

studio_db.sync_chat_messages(
"thread-1",
[_message("msg-2", 1_700_000_001_000, "newer")],
)
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_001_000


def test_chat_thread_updated_at_survives_thread_resave(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_thread(_thread())
studio_db.upsert_chat_message(_message("msg-1", 1_700_000_000_500, "hi"))

studio_db.upsert_chat_thread(_thread())
assert studio_db.get_chat_thread("thread-1")["updatedAt"] == 1_700_000_000_500


def test_list_chat_threads_orders_by_last_activity(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
older = _thread("thread-old")
older["createdAt"] = 1_700_000_000_000
newer = _thread("thread-new")
newer["createdAt"] = 1_700_000_100_000
studio_db.upsert_chat_thread(older)
studio_db.upsert_chat_thread(newer)
assert [t["id"] for t in studio_db.list_chat_threads()] == [
"thread-new",
"thread-old",
]

studio_db.upsert_chat_message(
_message("msg-1", 1_700_000_200_000, "hi", thread_id = "thread-old")
)
assert [t["id"] for t in studio_db.list_chat_threads()] == [
"thread-old",
"thread-new",
]


def test_chat_threads_updated_at_migration_backfills_from_messages(
tmp_path, monkeypatch
):
_reset_studio_db(tmp_path, monkeypatch)
db_path = studio_db_path()
db_path.parent.mkdir(parents = True, exist_ok = True)
conn = sqlite3.connect(str(db_path))
try:
conn.execute(
"""
CREATE TABLE chat_threads (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
model_type TEXT NOT NULL,
model_id TEXT,
pair_id TEXT,
archived INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE chat_messages (
id TEXT NOT NULL PRIMARY KEY,
thread_id TEXT NOT NULL,
parent_id TEXT,
role TEXT NOT NULL,
content_json TEXT NOT NULL,
attachments_json TEXT,
metadata_json TEXT,
created_at INTEGER NOT NULL
)
"""
)
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, created_at) VALUES (?, ?, ?, ?)",
("thread-with-msgs", "Old", "base", 1_700_000_000_000),
)
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, created_at) VALUES (?, ?, ?, ?)",
("thread-empty", "Empty", "base", 1_700_000_050_000),
)
conn.executemany(
"INSERT INTO chat_messages (id, thread_id, role, content_json, created_at) VALUES (?, ?, ?, ?, ?)",
[
("m1", "thread-with-msgs", "user", "[]", 1_700_000_001_000),
("m2", "thread-with-msgs", "assistant", "[]", 1_700_000_002_000),
],
)
conn.commit()
finally:
conn.close()

assert (
studio_db.get_chat_thread("thread-with-msgs")["updatedAt"]
== 1_700_000_002_000
)
assert (
studio_db.get_chat_thread("thread-empty")["updatedAt"] == 1_700_000_050_000
)


def test_chat_projects_delete_cascades_threads_and_messages(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
project = studio_db.upsert_chat_project(_project())
Expand Down
9 changes: 8 additions & 1 deletion studio/frontend/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ export function AppSidebar() {
const activeProjectId = isChatRoute
? ((search.project as string | undefined) ?? null)
: null;
const { items: allChatItems } = useChatSidebarItems({
const { items: allChatItems, loaded: chatItemsLoaded } = useChatSidebarItems({
enabled: !isStudioRoute,
requireMessages: false,
});
Expand Down Expand Up @@ -1310,6 +1310,13 @@ export function AppSidebar() {
renderChatSidebarItem(item, "recent"),
)}
</SidebarMenu>
{chatItemsLoaded &&
recentChatItems.length === 0 &&
pinnedChatItems.length === 0 && (
<p className="px-3 py-2 text-xs text-muted-foreground">
{t("shell.navigation.noChatsYet")}
</p>
)}
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
Expand Down
26 changes: 19 additions & 7 deletions studio/frontend/src/features/chat/hooks/use-chat-sidebar-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,21 @@ export interface SidebarItem {
id: string;
title: string;
createdAt: number;
updatedAt: number;
isFork?: boolean;
projectId?: string | null;
}

function lastActivityAt(thread: ThreadRecord): number {
return thread.updatedAt ?? thread.createdAt;
}

export function groupThreads(
threads: ThreadRecord[],
archived = false,
): SidebarItem[] {
const items: SidebarItem[] = [];
const seenPairs = new Set<string>();
const pairItems = new Map<string, SidebarItem>();

for (const t of threads) {
// Coerce archived to a boolean before comparing. Legacy threads (from the
Expand All @@ -48,30 +53,35 @@ export function groupThreads(
continue;
}
if (t.pairId) {
if (seenPairs.has(t.pairId)) {
const existing = pairItems.get(t.pairId);
if (existing) {
existing.updatedAt = Math.max(existing.updatedAt, lastActivityAt(t));
continue;
}
seenPairs.add(t.pairId);
items.push({
const item: SidebarItem = {
type: "compare",
id: t.pairId,
title: t.title,
createdAt: t.createdAt,
updatedAt: lastActivityAt(t),
projectId: t.projectId ?? null,
});
};
pairItems.set(t.pairId, item);
items.push(item);
} else if (!t.pairId) {
items.push({
type: "single",
id: t.id,
title: t.title,
createdAt: t.createdAt,
updatedAt: lastActivityAt(t),
isFork: Boolean(t.forkedFromThreadId),
projectId: t.projectId ?? null,
});
}
}

return items.sort((a, b) => b.createdAt - a.createdAt);
return items.sort((a, b) => b.updatedAt - a.updatedAt);
}

// Streaming fires CHAT_HISTORY_UPDATED_EVENT per chunk. Debounce so each quiet
Expand All @@ -84,6 +94,7 @@ export function useChatSidebarItems(options?: {
requireMessages?: boolean;
}) {
const [allThreads, setAllThreads] = useState<ThreadRecord[]>([]);
const [loaded, setLoaded] = useState(false);
const enabled = options?.enabled ?? true;
const requireMessages = options?.requireMessages ?? true;

Expand Down Expand Up @@ -111,6 +122,7 @@ export function useChatSidebarItems(options?: {
// were in flight, or if the effect was torn down.
if (cancelled || seq !== requestSeq) return;
setAllThreads(threads);
setLoaded(true);
} catch (error) {
if (isExpectedBackgroundChatStorageError(error)) {
return;
Expand Down Expand Up @@ -144,7 +156,7 @@ export function useChatSidebarItems(options?: {
const archivedItems = groupThreads(allThreads ?? [], true);
const canCompare = useChatRuntimeStore((s) => Boolean(s.params.checkpoint));

return { items, archivedItems, canCompare };
return { items, archivedItems, canCompare, loaded };
}

function cancelIfRunning(threadId: string): void {
Expand Down
Loading
Loading