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

+86
View File
@@ -70,6 +70,9 @@ pub fn router(app: AppState) -> Router {
.route("/agents", get(agents))
.route("/commands", get(commands))
.route("/sessions", get(list_sessions).post(create_session))
.route("/sessions/search", get(search_sessions))
.route("/sessions/{id}/fork", post(fork_session))
.route("/sessions/{id}/export", get(export_session))
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
.route("/sessions/{id}/prompt", post(prompt))
.route("/sessions/{id}/abort", post(abort))
@@ -367,6 +370,89 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
Ok(Json(json!(s)))
}
#[derive(Deserialize)]
struct SearchQ {
q: String,
}
async fn search_sessions(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<SearchQ>) -> ApiResult {
if q.q.trim().len() < 2 {
return Ok(Json(json!([])));
}
let filter = if user.is_admin() { None } else { Some(user.id.as_str()) };
let hits: Vec<Value> = app
.store
.search_sessions(filter, q.q.trim())?
.into_iter()
.map(|(s, snippet)| json!({"id": s.id, "title": s.title, "cwd": s.cwd, "agent": s.agent, "updated_at": s.updated_at, "snippet": snippet}))
.collect();
Ok(Json(json!(hits)))
}
#[derive(Deserialize, Default)]
struct ForkBody {
/// Last message to keep (inclusive); all messages when missing.
message_id: Option<String>,
}
/// Copy a session (up to a message) into a new one to try another path.
async fn fork_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, body: Option<Json<ForkBody>>) -> ApiResult {
let src = writable(&app, &user, &id)?;
let body = body.map(|b| b.0).unwrap_or_default();
let messages = app.store.messages(&id)?;
let end = match &body.message_id {
Some(mid) => messages.iter().position(|m| &m.id == mid).ok_or_else(|| not_found("message"))? + 1,
None => messages.len(),
};
let now = now_ms();
let title = if src.title.is_empty() { "Fork".to_string() } else { format!("{} (fork)", src.title) };
let fork = Session {
id: new_id(),
title,
parent_id: None,
owner_id: Some(user.id.clone()),
created_at: now,
updated_at: now,
last_usage: None,
..src.clone()
};
let fork = app.save_session(fork)?;
for m in &messages[..end] {
let mut m = m.clone();
m.id = new_id();
app.store.add_message(&fork.id, &m)?;
}
app.audit(Some(&user), "session.fork", &format!("{id} -> {}", fork.id), "");
Ok(Json(json!(fork)))
}
#[derive(Deserialize)]
struct ExportQ {
#[serde(default)]
format: String,
}
/// Download a session as Markdown (default) or JSON.
async fn export_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ExportQ>) -> Result<axum::response::Response, ApiError> {
let s = readable(&app, &user, &id)?;
let messages = app.store.messages(&id)?;
let base: String = s.title.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect::<String>().trim_matches('_').to_string();
let base = if base.is_empty() { format!("session-{}", &id[..8.min(id.len())]) } else { base };
let (body, mime, ext) = if q.format == "json" {
(serde_json::to_string_pretty(&json!({"session": s, "messages": messages}))?, "application/json", "json")
} else {
(crate::export::markdown(&s, &messages), "text/markdown; charset=utf-8", "md")
};
Ok((
[
(axum::http::header::CONTENT_TYPE, mime.to_string()),
(axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{base}.{ext}\"")),
],
body,
)
.into_response())
}
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = readable(&app, &user, &id)?;
let messages = app.store.messages(&id)?;
+25
View File
@@ -24,6 +24,31 @@ pub struct Config {
pub sandbox: SandboxConfig,
pub search: SearchConfig,
pub lsp: LspConfig,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<HookConfig>,
}
/// A shell command run on an agent event (see hooks.rs).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HookConfig {
/// pre_tool, post_tool, user_prompt or stop.
pub event: String,
/// Tool name pattern for tool events ("*", "bash", "edit|write", "mcp__*").
#[serde(default = "star")]
pub matcher: String,
pub command: String,
#[serde(default = "sixty")]
pub timeout_secs: u64,
#[serde(default = "yes")]
pub enabled: bool,
}
fn star() -> String {
"*".into()
}
fn sixty() -> u64 {
60
}
/// Language servers for diagnostics after edits.
+83
View File
@@ -0,0 +1,83 @@
//! Session export as Markdown.
use crate::types::{Message, Part, Role, Session};
fn fence(s: &str) -> String {
// A fence longer than any backtick run inside the content.
let mut longest = 0;
let mut run = 0;
for c in s.chars() {
if c == '`' {
run += 1;
longest = longest.max(run);
} else {
run = 0;
}
}
"`".repeat((longest + 1).max(3))
}
fn clip(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}\n… ({} more bytes)", &s[..end], s.len() - end)
}
fn time(ms: i64) -> String {
chrono::DateTime::from_timestamp_millis(ms).map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string()).unwrap_or_default()
}
pub fn markdown(s: &Session, messages: &[Message]) -> String {
let title = if s.title.is_empty() { "Untitled session" } else { s.title.as_str() };
let mut out = format!(
"# {title}\n\n- Directory: `{}`\n- Model: `{}`\n- Mode: {}\n- Created: {}\n- Exported from CodeCraft\n\n---\n\n",
s.cwd,
s.model,
s.agent,
time(s.created_at)
);
for m in messages {
let is_tool_results = m.parts.iter().all(|p| matches!(p, Part::ToolResult { .. }));
if !is_tool_results {
let who = match m.role {
Role::User => "User".to_string(),
_ => format!("Assistant{}", m.model.as_deref().map(|x| format!(" ({x})")).unwrap_or_default()),
};
out.push_str(&format!("## {who} · {}\n\n", time(m.created_at)));
}
for p in &m.parts {
match p {
Part::Text { text } => out.push_str(&format!("{}\n\n", text.trim())),
Part::Summary { text } => out.push_str(&format!("<details><summary>Summary of the earlier conversation</summary>\n\n{}\n\n</details>\n\n", text.trim())),
Part::Reasoning { text, .. } if !text.trim().is_empty() => {
out.push_str(&format!("<details><summary>Thinking</summary>\n\n{}\n\n</details>\n\n", text.trim()))
}
Part::Reasoning { .. } => {}
Part::Image { image } => out.push_str(&format!("*[image: {}]*\n\n", image.media_type)),
Part::ToolCall { name, input, .. } => {
let body = match name.as_str() {
"bash" => input["command"].as_str().unwrap_or("").to_string(),
_ => serde_json::to_string_pretty(input).unwrap_or_default(),
};
let body = clip(&body, 4000);
let f = fence(&body);
out.push_str(&format!("**Tool `{name}`**\n\n{f}{}\n{body}\n{f}\n\n", if name == "bash" { "sh" } else { "json" }));
}
Part::ToolResult { name, content, is_error, .. } => {
let body = clip(content.trim(), 4000);
let f = fence(&body);
out.push_str(&format!(
"<details><summary>{} of <code>{name}</code></summary>\n\n{f}\n{body}\n{f}\n\n</details>\n\n",
if *is_error { "Error" } else { "Result" }
));
}
}
}
}
out
}
+19 -7
View File
@@ -399,20 +399,32 @@ pub async fn search(Extension(user): Extension<User>, State(app): State<AppState
return Ok(Json(json!([])));
}
let hits = tokio::task::spawn_blocking(move || {
let mut out = Vec::new();
// Matches on the name or the relative path; name prefix matches first.
let mut found: Vec<(u8, usize, Value)> = Vec::new();
for e in ignore::WalkBuilder::new(&root).hidden(true).build().flatten() {
if e.depth() == 0 {
continue;
}
let name = e.file_name().to_string_lossy().to_lowercase();
if name.contains(&needle) {
out.push(json!({"path": e.path(), "is_dir": e.file_type().is_some_and(|t| t.is_dir())}));
if out.len() >= 200 {
break;
}
let rel = e.path().strip_prefix(&root).unwrap_or(e.path()).to_string_lossy().to_string();
let rel_l = rel.to_lowercase();
let rank = if name.starts_with(&needle) {
0
} else if name.contains(&needle) {
1
} else if rel_l.contains(&needle) {
2
} else {
continue;
};
let is_dir = e.file_type().is_some_and(|t| t.is_dir());
found.push((rank, rel.len(), json!({"path": e.path(), "rel": rel, "is_dir": is_dir})));
if found.len() >= 2000 {
break;
}
}
out
found.sort_by_key(|(r, l, _)| (*r, *l));
found.into_iter().take(200).map(|(_, _, v)| v).collect::<Vec<_>>()
})
.await?;
Ok(Json(json!(hits)))
+135
View File
@@ -0,0 +1,135 @@
//! Hooks: shell commands run on agent events (configured by admins).
//!
//! Each hook gets the event as JSON on stdin and runs in the session owner's
//! sandbox in the session directory.
//! - exit 0: success; stdout is added as context (user_prompt, post_tool)
//! - exit 2: block; stderr (or stdout) is the reason
//! (pre_tool: the call is rejected, user_prompt: the prompt is dropped,
//! stop: the agent continues with the reason as new instruction)
//! - other exit codes: logged and ignored
use crate::app::App;
use crate::sandbox::SandboxSpec;
use serde_json::Value;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
const MAX_OUTPUT: usize = 10_000;
#[derive(Default, Debug)]
pub struct Outcome {
/// Reason when a hook blocked (exit code 2).
pub blocked: Option<String>,
/// Collected stdout of successful hooks.
pub context: String,
}
fn matches(pattern: &str, tool: &str) -> bool {
if pattern.is_empty() || pattern == "*" {
return true;
}
pattern.split('|').any(|p| {
let p = p.trim();
globset::Glob::new(p).map(|g| g.compile_matcher().is_match(tool)).unwrap_or(p == tool)
})
}
fn clip(s: &str) -> String {
let s = s.trim();
if s.len() <= MAX_OUTPUT {
return s.to_string();
}
let mut end = MAX_OUTPUT;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &s[..end])
}
/// Run all hooks of `event` (for tool events: matching `tool`).
pub async fn run(app: &App, event: &str, tool: Option<&str>, payload: Value, cwd: &Path, sandbox: Option<&SandboxSpec>) -> Outcome {
let hooks: Vec<_> = app
.config()
.hooks
.iter()
.filter(|h| h.enabled && h.event == event && tool.is_none_or(|t| matches(&h.matcher, t)))
.cloned()
.collect();
let mut out = Outcome::default();
if hooks.is_empty() {
return out;
}
let mut payload = payload;
payload["event"] = Value::String(event.into());
let input = serde_json::to_vec(&payload).unwrap_or_default();
for h in hooks {
let script = format!(
"export CODECRAFT_HOOK_EVENT={event} CODECRAFT_HOOK_TOOL='{}'\n{}",
tool.unwrap_or("").replace('\'', ""),
h.command
);
let mut cmd = match sandbox {
Some(spec) => {
if !crate::sandbox::available() {
tracing::warn!("hook skipped: sandbox unavailable");
continue;
}
crate::sandbox::command(spec, cwd, "bash", &["-c", &script])
}
None => {
let mut c = tokio::process::Command::new("bash");
c.arg("-c").arg(&script).current_dir(cwd).env("CODECRAFT", "1");
c
}
};
cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true);
let Ok(mut child) = cmd.spawn() else {
tracing::warn!("hook could not be started: {}", h.command);
continue;
};
if let Some(mut stdin) = child.stdin.take() {
let data = input.clone();
tokio::spawn(async move {
let _ = stdin.write_all(&data).await;
});
}
let res = tokio::time::timeout(Duration::from_secs(h.timeout_secs.max(1)), child.wait_with_output()).await;
let Ok(Ok(o)) = res else {
tracing::warn!("hook timed out or failed: {}", h.command);
continue;
};
let stdout = String::from_utf8_lossy(&o.stdout);
let stderr = String::from_utf8_lossy(&o.stderr);
match o.status.code() {
Some(0) => {
if !stdout.trim().is_empty() {
out.context.push_str(&clip(&stdout));
out.context.push('\n');
}
}
Some(2) => {
let reason = if stderr.trim().is_empty() { stdout.to_string() } else { stderr.to_string() };
out.blocked = Some(if reason.trim().is_empty() { format!("blocked by hook `{}`", h.command) } else { clip(&reason) });
return out;
}
code => tracing::warn!("hook `{}` exited with {code:?}: {}", h.command, stderr.trim()),
}
}
out
}
#[cfg(test)]
mod tests {
use super::matches;
#[test]
fn matcher() {
assert!(matches("*", "bash"));
assert!(matches("", "edit"));
assert!(matches("edit|write", "write"));
assert!(!matches("edit|write", "bash"));
assert!(matches("mcp__*", "mcp__github__create_issue"));
}
}
+3
View File
@@ -6,10 +6,13 @@ mod auth;
mod budget;
mod config;
mod docs;
mod export;
mod files;
mod git;
mod hooks;
mod images;
mod lsp;
mod mentions;
mod mcp;
mod permission;
mod prompts;
+83
View File
@@ -0,0 +1,83 @@
//! `@path` mentions in user prompts: the mentioned files (or folder listings)
//! are attached to the prompt, read with the regular read/list tools.
use crate::app::App;
use crate::tools::{Tool, ToolCtx};
use crate::types::Part;
use serde_json::json;
use std::path::PathBuf;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
const MAX_MENTIONS: usize = 10;
const MAX_CHARS: usize = 60_000;
/// `@token`s at the start of the text or after whitespace (not e-mail addresses).
pub fn find(text: &str) -> Vec<String> {
let re = regex::Regex::new(r"(?:^|\s)@([\w./~+\-]+)").unwrap();
let mut out: Vec<String> = Vec::new();
for c in re.captures_iter(text) {
let token = c[1].trim_end_matches(['.', ',', ';', ':', '!', '?', ')']).to_string();
if !token.is_empty() && !out.contains(&token) {
out.push(token);
}
}
out
}
/// Parts to append to a user prompt for its mentions.
pub async fn expand(app: &Arc<App>, session_id: &str, text: &str) -> Vec<Part> {
let tokens = find(text);
if tokens.is_empty() {
return vec![];
}
let Ok(session) = app.session(session_id) else { return vec![] };
let cwd = PathBuf::from(&session.cwd);
let ctx = ToolCtx {
app: app.clone(),
session_id: session_id.to_string(),
call_id: String::new(),
cwd: cwd.clone(),
cancel: CancellationToken::new(),
agent: session.agent.clone(),
roots: app.user_roots(session.owner_id.as_deref()),
sandbox: crate::sandbox::spec_for(app, session.owner_id.as_deref()),
clean_room: session.clean_room(app.config().craft.clean_room),
};
let mut parts = Vec::new();
for token in tokens.into_iter().take(MAX_MENTIONS) {
let Ok(path) = ctx.resolve_read(&token) else { continue };
if !path.exists() {
continue;
}
let res = if path.is_dir() {
crate::tools::fs::List.run(json!({"path": token}), &ctx).await
} else {
crate::tools::fs::Read.run(json!({"path": token}), &ctx).await
};
let Ok(out) = res else { continue };
let mut content = out.content;
if content.len() > MAX_CHARS {
let mut end = MAX_CHARS;
while !content.is_char_boundary(end) {
end -= 1;
}
content.truncate(end);
content.push_str("\n[… truncated – read the rest with the read tool]");
}
let kind = if path.is_dir() { "folder" } else { "file" };
parts.push(Part::Text { text: format!("<mentioned-{kind} path=\"{token}\">\n{content}\n</mentioned-{kind}>") });
parts.extend(out.images.into_iter().map(|image| Part::Image { image }));
}
parts
}
#[cfg(test)]
mod tests {
#[test]
fn finds_mentions() {
assert_eq!(super::find("look at @src/main.rs, and @README.md."), vec!["src/main.rs", "README.md"]);
assert!(super::find("mail me at a@b.de").is_empty());
assert_eq!(super::find("@docs/ please"), vec!["docs/"]);
}
}
+56 -1
View File
@@ -11,7 +11,7 @@ use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms};
use anyhow::{Result, anyhow, bail};
use futures::future::join_all;
use serde_json::Value;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::mpsc;
@@ -70,9 +70,26 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
.collect::<Vec<_>>()
.join("\n");
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true });
let source = source.to_string();
tokio::spawn(async move {
let res = async {
let mut parts = parts;
if source == "user" {
let session = app.session(&session_id)?;
let sandbox = crate::sandbox::spec_for(&app, session.owner_id.as_deref());
let payload = serde_json::json!({"session_id": session_id, "cwd": session.cwd, "prompt": text_only});
let o = crate::hooks::run(&app, "user_prompt", None, payload, Path::new(&session.cwd), sandbox.as_ref()).await;
if let Some(reason) = o.blocked {
anyhow::bail!("Prompt blocked by a hook: {}", reason.trim());
}
if !o.context.trim().is_empty() {
parts.push(Part::Text { text: format!("<hook-context event=\"user_prompt\">\n{}\n</hook-context>", o.context.trim()) });
}
}
if source == "user" {
parts.extend(crate::mentions::expand(&app, &session_id, &text_only).await);
}
let msg = Message::new(Role::User, parts);
app.store.add_message(&session_id, &msg)?;
app.emit(Event::MessageAdded { session_id: session_id.clone(), message: msg });
@@ -236,6 +253,7 @@ fn run_session_boxed(
/// Run the loop for a session until the model stops. Returns the final text.
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
let mut final_text = String::new();
let mut stop_hooks = 0;
for _ in 0..MAX_STEPS {
if cancel.is_cancelled() {
break;
@@ -303,6 +321,16 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
}
break;
}
if calls.is_empty() && depth == 0 && stop_hooks < 3 && matches!(step.stop_reason.as_str(), "stop" | "end_turn" | "") {
let payload = serde_json::json!({"session_id": session_id, "cwd": session.cwd, "last_message": final_text});
let sandbox = crate::sandbox::spec_for(&app, session.owner_id.as_deref());
let o = crate::hooks::run(&app, "stop", None, payload, &cwd, sandbox.as_ref()).await;
if let Some(reason) = o.blocked {
stop_hooks += 1;
save_user_parts(&app, session_id, vec![Part::Text { text: format!("<hook-feedback event=\"stop\">\n{}\n</hook-feedback>", reason.trim()) }])?;
continue;
}
}
if calls.is_empty() {
match step.stop_reason.as_str() {
"max_tokens" | "length" => app.emit(Event::Error {
@@ -453,6 +481,20 @@ async fn execute_call(
Decision::Allow => {}
}
// Hooks see the tool input (cloned only when hooks are configured).
let hook_input = if app.config().hooks.is_empty() { Value::Null } else { input.clone() };
let hook_payload = |extra: Option<(&str, Value)>| {
let mut v = serde_json::json!({"session_id": session.id, "cwd": session.cwd, "tool": name, "input": hook_input});
if let Some((k, x)) = extra {
v[k] = x;
}
v
};
let pre = crate::hooks::run(app, "pre_tool", Some(name), hook_payload(None), &ctx.cwd, ctx.sandbox.as_ref()).await;
if let Some(reason) = pre.blocked {
return tool_result(call_id, call_name, format!("Blocked by a hook: {}", reason.trim()), true, None);
}
// Checkpoint: remember files before the agent changes them (rewind can restore them).
if tool.is_edit() {
if let Some(path) = input["path"].as_str().and_then(|p| ctx.resolve(p).ok()) {
@@ -482,6 +524,19 @@ async fn execute_call(
}
other => other,
};
let res = match res {
Ok(mut out) => {
let result = serde_json::json!({"content": out.content.chars().take(20_000).collect::<String>(), "is_error": out.is_error});
let post = crate::hooks::run(app, "post_tool", Some(name), hook_payload(Some(("result", result))), &ctx.cwd, ctx.sandbox.as_ref()).await;
if let Some(reason) = post.blocked {
out.content.push_str(&format!("\n\n<hook-feedback event=\"post_tool\">\n{}\n</hook-feedback>", reason.trim()));
} else if !post.context.trim().is_empty() {
out.content.push_str(&format!("\n\n<hook-context event=\"post_tool\">\n{}\n</hook-context>", post.context.trim()));
}
Ok(out)
}
e => e,
};
match res {
Ok(out) => Part::ToolResult {
call_id: call_id.into(),
+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.