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:
1 parent
ec1b9b4925
commit
013695722a
17 files changed
+911
-41
No files matched your search
@@ -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)?;
|
||||
|
||||
Reference in new issue
Block a user