Long chats stay fast; search in a chat; media overview

Performance (measured with 5,900 messages: open 9 s -> 0.7 s, typing 440 ms -> 25 ms)
- GET /sessions/{id}?limit=N loads the end of a chat (from the last summary
  on, at most N); /sessions/{id}/messages?before|after|around loads more
  (scrolling up stops at a summary unless asked). Without parameters the
  full history as before (terminal client).
- Chat view: memoised message list (typing, streaming and status changes no
  longer re-render the history), finished turns fold into "N steps · M files"
  with the answer visible, tool card bodies and summaries are built only when
  opened, scroll-to-bottom only when content changes.

Search: /sessions/{id}/search over the whole chat; a hit loads its part
(around) and marks the message, "back to the latest messages" returns.

Media: /sessions/{id}/media lists images, videos and audio of a chat and its
subagents (generated images, tool output, files written, media in answers,
attachments); ⋯ -> Media shows them with filters, a large view and a jump to
the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-11 07:32:18 +01:00
1 parent 6e9eda28b5
commit 3b0764450a
14 files changed
+1278 -99

No files matched your search

+170 -23
View File
@@ -1,6 +1,6 @@
//! SQLite persistence for sessions and messages.
use crate::types::{ImageData, Message, Part, Session, now_ms};
use crate::types::{ImageData, Message, Part, Role, Session, now_ms};
use serde::Serialize;
use serde_json::{Value, json};
use anyhow::Result;
@@ -244,28 +244,7 @@ impl Store {
let (sdata, mdata) = row?;
let session: Session = serde_json::from_str(&sdata)?;
// Snippet from the visible text of the matching message (not JSON keys).
let mut snippet = String::new();
if let Some(m) = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()) {
for p in &m.parts {
let text = match p {
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
Part::ToolResult { content, .. } => content.as_str(),
_ => continue,
};
if let Some(i) = text.to_lowercase().find(&needle) {
let mut a = i.saturating_sub(60);
while !text.is_char_boundary(a) {
a -= 1;
}
let mut b = (i + needle.len() + 100).min(text.len());
while !text.is_char_boundary(b) {
b += 1;
}
snippet = format!("{}{}{}", if a > 0 { "…" } else { "" }, text[a..b].replace('\n', " "), if b < text.len() { "…" } else { "" });
break;
}
}
}
let snippet = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()).and_then(|m| snippet_in(&m, &needle)).unwrap_or_default();
if snippet.is_empty() && !session.title.to_lowercase().contains(&needle) {
continue; // matched only JSON structure (keys, ids)
}
@@ -274,6 +253,121 @@ impl Store {
Ok(out)
}
/// A part of a session's messages for the chat view (long chats are not loaded at
/// once). Returns the messages (oldest first) and whether older / newer ones exist.
pub fn messages_window(&self, session_id: &str, w: &MessageWindow) -> Result<(Vec<Message>, bool, bool)> {
let conn = self.conn.lock().unwrap();
let seq_of = |id: &str| -> Result<i64> {
conn.query_row("SELECT seq FROM messages WHERE session_id = ?1 AND id = ?2", params![session_id, id], |r| r.get(0))
.optional()?
.ok_or_else(|| anyhow::anyhow!("message {id} not found"))
};
let (sql, a, limit): (&str, i64, i64) = match w {
// From the last summary (compaction) on, at most `limit`.
MessageWindow::Tail { limit } => {
let from: i64 = conn
.query_row(
"SELECT seq FROM messages WHERE session_id = ?1 AND data LIKE '%\"type\":\"summary\"%' ORDER BY seq DESC LIMIT 1",
[session_id],
|r| r.get(0),
)
.optional()?
.unwrap_or(i64::MIN);
("SELECT data FROM messages WHERE session_id = ?1 AND seq >= ?2 ORDER BY seq DESC LIMIT ?3", from, *limit)
}
// Scrolling up stops at a summary (compaction); the history before it on request.
MessageWindow::Before { id, limit, stop_at_summary } => {
let before = seq_of(id)?;
let summary: Option<i64> = if *stop_at_summary {
conn.query_row(
"SELECT seq FROM messages WHERE session_id = ?1 AND seq < ?2 AND data LIKE '%\"type\":\"summary\"%' ORDER BY seq DESC LIMIT 1",
params![session_id, before],
|r| r.get(0),
)
.optional()?
} else {
None
};
match summary {
Some(from) => {
let mut stmt = conn.prepare("SELECT seq, data FROM messages WHERE session_id = ?1 AND seq >= ?2 AND seq < ?3 ORDER BY seq DESC LIMIT ?4")?;
let mut rows: Vec<(i64, Message)> = Vec::new();
for r in stmt.query_map(params![session_id, from, before, limit], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
let (seq, data) = r?;
rows.push((seq, serde_json::from_str(&data)?));
}
drop(stmt);
return Self::finish_window(&conn, session_id, rows, false);
}
None => ("SELECT data FROM messages WHERE session_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3", before, *limit),
}
}
MessageWindow::After { id, limit } => ("SELECT data FROM messages WHERE session_id = ?1 AND seq > ?2 ORDER BY seq ASC LIMIT ?3", seq_of(id)?, *limit),
// The message and about half the limit before it.
MessageWindow::Around { id, limit } => {
let seq = seq_of(id)?;
let before: i64 = conn.query_row(
"SELECT COALESCE(MIN(seq), ?2) FROM (SELECT seq FROM messages WHERE session_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3)",
params![session_id, seq, limit / 2],
|r| r.get(0),
)?;
("SELECT data FROM messages WHERE session_id = ?1 AND seq >= ?2 ORDER BY seq ASC LIMIT ?3", before, *limit)
}
};
let mut rows: Vec<(i64, Message)> = Vec::new();
let mut stmt = conn.prepare(&sql.replace("SELECT data", "SELECT seq, data"))?;
for r in stmt.query_map(params![session_id, a, limit], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
let (seq, data) = r?;
rows.push((seq, serde_json::from_str(&data)?));
}
drop(stmt);
Self::finish_window(&conn, session_id, rows, matches!(w, MessageWindow::Tail { .. }))
}
/// Sort a window and tell whether older / newer messages exist around it.
fn finish_window(conn: &Connection, session_id: &str, mut rows: Vec<(i64, Message)>, tail: bool) -> Result<(Vec<Message>, bool, bool)> {
rows.sort_by_key(|(seq, _)| *seq);
let exists = |q: &str, seq: i64| -> Result<bool> { Ok(conn.prepare(q)?.exists(params![session_id, seq])?) };
let older = match rows.first() {
Some((first, _)) => exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq < ?2", *first)?,
None => tail && exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq > ?2", i64::MIN)?,
};
let newer = match rows.last() {
Some((last, _)) => exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq > ?2", *last)?,
None => false,
};
Ok((rows.into_iter().map(|(_, m)| m).collect(), older, newer))
}
/// Messages of one session containing `q` (case-insensitive), newest first:
/// (message id, role, created_at, snippet).
pub fn search_messages(&self, session_id: &str, q: &str, limit: usize) -> Result<Vec<(String, Role, i64, String)>> {
let needle = q.to_lowercase();
let like = format!("%{}%", q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"));
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT data FROM messages WHERE session_id = ?1 AND data LIKE ?2 ESCAPE '\\' ORDER BY seq DESC")?;
let mut out = Vec::new();
for r in stmt.query_map(params![session_id, like], |r| r.get::<_, String>(0))? {
let Ok(m) = serde_json::from_str::<Message>(&r?) else { continue };
// Only visible text counts (not JSON keys or ids).
if let Some(snippet) = snippet_in(&m, &needle) {
out.push((m.id.clone(), m.role, m.created_at, snippet));
if out.len() >= limit {
break;
}
}
}
Ok(out)
}
/// Ids of the subagent sessions started by a session.
pub fn child_sessions(&self, id: &str) -> Result<Vec<String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id FROM sessions WHERE parent_id = ?1")?;
let rows = stmt.query_map([id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
Ok(rows)
}
pub fn delete_session(&self, id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
// Child sessions (subagents) go with their parent.
@@ -1045,6 +1139,17 @@ impl Store {
}
/// Latest images; `user = None` lists everybody's (admins).
/// Generated images of these sessions.
pub fn images_of_sessions(&self, sessions: &[String]) -> Result<Vec<ImageRecord>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(&format!("SELECT {IMAGE_COLS} FROM images WHERE session_id = ?1"))?;
let mut out = Vec::new();
for s in sessions {
out.extend(stmt.query_map([s], image_row)?.collect::<rusqlite::Result<Vec<_>>>()?);
}
Ok(out)
}
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(&format!(
@@ -1392,3 +1497,45 @@ pub struct PushSub {
pub p256dh: String,
pub auth: String,
}
/// Which messages of a session the chat view loads (see Store::messages_window).
pub enum MessageWindow {
Tail { limit: i64 },
Before { id: String, limit: i64, stop_at_summary: bool },
After { id: String, limit: i64 },
Around { id: String, limit: i64 },
}
/// Text around the first occurrence of `needle` (lowercase) in the visible text of a
/// message: its text, reasoning, summaries, tool calls and tool output.
fn snippet_in(m: &Message, needle: &str) -> Option<String> {
for p in &m.parts {
let owned;
let text = match p {
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
Part::ToolResult { content, .. } => content.as_str(),
Part::ToolCall { input, .. } => {
owned = input.to_string();
owned.as_str()
}
_ => continue,
};
let lower = text.to_lowercase();
// Lowercasing can change byte lengths (rare): then cut the lowercase text.
let (src, i) = match lower.find(needle) {
Some(i) if lower.len() == text.len() => (text, i),
Some(i) => (lower.as_str(), i),
None => continue,
};
let mut a = i.saturating_sub(60);
while !src.is_char_boundary(a) {
a -= 1;
}
let mut b = (i + needle.len() + 100).min(src.len());
while !src.is_char_boundary(b) {
b += 1;
}
return Some(format!("{}{}{}", if a > 0 { "…" } else { "" }, src[a..b].replace('\n', " "), if b < src.len() { "…" } else { "" }));
}
None
}