Add hooks, @file mentions, session search/fork/export, follow-scroll

- Hooks ([[hooks]]): pre_tool, post_tool, user_prompt, stop; sandboxed, exit 2 blocks
- @file mentions with autocomplete; mentioned files/folders are attached to the prompt
- Session full-text search in the sidebar, fork into a copy, export as Markdown/JSON
- Chat follows the newest message until the user scrolls up; jump-to-latest button
- SVG activity spark and thinking icon instead of Unicode/emoji glyphs
- File search ranks name matches first and returns relative paths

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 20:17:12 +01:00
1 parent ec1b9b4925
commit 013695722a
17 files changed
+911 -41

No files matched your search

+52 -1
View File
@@ -1,6 +1,6 @@
//! SQLite persistence for sessions and messages.
use crate::types::{Message, Session, now_ms};
use crate::types::{Message, Part, Session, now_ms};
use serde::Serialize;
use serde_json::{Value, json};
use anyhow::Result;
@@ -178,6 +178,57 @@ impl Store {
Ok(out)
}
/// Full-text search over titles and message texts of the sessions a user
/// can see (None = all). Returns (session, snippet) pairs, newest first.
pub fn search_sessions(&self, user: Option<&str>, q: &str) -> Result<Vec<(Session, 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 s.data,
(SELECT m.data FROM messages m WHERE m.session_id = s.id AND m.data LIKE ?2 ESCAPE '\\' ORDER BY m.seq DESC LIMIT 1)
FROM sessions s
WHERE s.parent_id IS NULL
AND (?1 IS NULL OR s.owner_id = ?1 OR s.id IN (SELECT session_id FROM session_shares WHERE user_id = ?1))
AND (s.data LIKE ?2 ESCAPE '\\' OR EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id AND m.data LIKE ?2 ESCAPE '\\'))
ORDER BY s.updated_at DESC LIMIT 50",
)?;
let rows = stmt.query_map(rusqlite::params![user, like], |r| Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?)))?;
let mut out = Vec::new();
for row in rows {
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;
}
}
}
if snippet.is_empty() && !session.title.to_lowercase().contains(&needle) {
continue; // matched only JSON structure (keys, ids)
}
out.push((session, snippet));
}
Ok(out)
}
pub fn delete_session(&self, id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
// Child sessions (subagents) go with their parent.