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)?;