Add worktrees, per-hunk review, effort per message, regenerate, voice input
- Isolated git worktree per session (create, status, merge, discard) - Permission card: accept file-edit hunks individually; agent learns what was rejected - Reasoning effort per message; regenerate the last answer with another model/effort - Voice input via the Web Speech API - Folder-up button in the file browser; ssh, rsync and mc in the image Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
df1924b0a1
commit
fca1c41087
22 files changed
+821
-32
No files matched your search
+105
-1
@@ -74,6 +74,8 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/sessions/search", get(search_sessions))
|
||||
.route("/sessions/{id}/fork", post(fork_session))
|
||||
.route("/sessions/{id}/title", post(suggest_title))
|
||||
.route("/sessions/{id}/regenerate", post(regenerate))
|
||||
.route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action))
|
||||
.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))
|
||||
@@ -409,6 +411,7 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
last_usage: None,
|
||||
clean_room: body.clean_room,
|
||||
phase: None,
|
||||
worktree: None,
|
||||
};
|
||||
let s = app.save_session(s)?;
|
||||
Ok(Json(json!(s)))
|
||||
@@ -433,6 +436,88 @@ async fn search_sessions(Extension(user): Extension<User>, State(app): State<App
|
||||
Ok(Json(json!(hits)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RegenerateBody {
|
||||
/// An assistant message of the turn to redo.
|
||||
message_id: String,
|
||||
model: Option<String>,
|
||||
effort: Option<String>,
|
||||
}
|
||||
|
||||
/// Redo a turn: drop everything after the user's prompt, restore files changed
|
||||
/// since, and run again (optionally with another model / effort).
|
||||
async fn regenerate(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<RegenerateBody>) -> ApiResult {
|
||||
let mut s = writable(&app, &user, &id)?;
|
||||
if app.is_busy(&id) {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||
}
|
||||
let msgs = app.store.messages(&id)?;
|
||||
let idx = msgs.iter().position(|m| m.id == b.message_id).ok_or_else(|| not_found("message"))?;
|
||||
// The prompt of this turn: the last user message with real content before it.
|
||||
let prompt_idx = msgs[..idx]
|
||||
.iter()
|
||||
.rposition(|m| matches!(m.role, crate::types::Role::User) && m.parts.iter().any(|p| matches!(p, Part::Text { .. } | Part::Image { .. })))
|
||||
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no prompt before this message".into()))?;
|
||||
let first_dropped = msgs.get(prompt_idx + 1).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "nothing to regenerate".into()))?;
|
||||
if let Some(m) = b.model.filter(|m| !m.is_empty()) {
|
||||
app.registry().resolve(&m).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
s.model = m;
|
||||
}
|
||||
// Files the dropped steps changed go back to their state before.
|
||||
let since = first_dropped.created_at;
|
||||
let roots = app.user_roots(s.owner_id.as_deref());
|
||||
for (path, content) in app.store.checkpoints_since(&id, since)? {
|
||||
let p = PathBuf::from(&path);
|
||||
if !roots.iter().any(|r| p.starts_with(r)) {
|
||||
continue;
|
||||
}
|
||||
match content {
|
||||
Some(bytes) => {
|
||||
let _ = std::fs::write(&p, bytes);
|
||||
}
|
||||
None => {
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
app.store.delete_checkpoints_since(&id, since)?;
|
||||
app.store.truncate_from(&id, &first_dropped.id)?;
|
||||
s.last_usage = None;
|
||||
app.save_session(s)?;
|
||||
set_turn_effort(&app, &id, b.effort.as_deref())?;
|
||||
runner::submit(app.clone(), id.clone(), vec![], "user").map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WorktreeBody {
|
||||
action: String,
|
||||
}
|
||||
|
||||
async fn worktree_status(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let mut s = readable(&app, &user, &id)?;
|
||||
if s.worktree.is_none() {
|
||||
return Ok(Json(json!({"worktree": null})));
|
||||
}
|
||||
Ok(Json(crate::git::worktree(&app, &user, &mut s, "status").await?))
|
||||
}
|
||||
|
||||
async fn worktree_action(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<WorktreeBody>) -> ApiResult {
|
||||
let mut s = writable(&app, &user, &id)?;
|
||||
if app.is_busy(&id) && b.action != "status" {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "the session is busy – wait until the agent is done".into()));
|
||||
}
|
||||
let res = crate::git::worktree(&app, &user, &mut s, &b.action).await?;
|
||||
if b.action == "remove" {
|
||||
s.worktree = None;
|
||||
}
|
||||
if b.action != "status" {
|
||||
app.save_session(s)?;
|
||||
app.audit(Some(&user), &format!("worktree.{}", b.action), &id, "");
|
||||
}
|
||||
Ok(Json(res))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct TitleBody {
|
||||
model: Option<String>,
|
||||
@@ -472,6 +557,7 @@ async fn fork_session(Extension(user): Extension<User>, State(app): State<AppSta
|
||||
updated_at: now,
|
||||
last_usage: None,
|
||||
phase: None,
|
||||
worktree: None,
|
||||
..src.clone()
|
||||
};
|
||||
let fork = app.save_session(fork)?;
|
||||
@@ -569,6 +655,21 @@ struct PromptBody {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
images: Vec<ImageData>,
|
||||
/// Reasoning effort for this turn (low, medium, high, xhigh, max).
|
||||
effort: Option<String>,
|
||||
}
|
||||
|
||||
const EFFORTS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
fn set_turn_effort(app: &App, id: &str, effort: Option<&str>) -> Result<(), ApiError> {
|
||||
match effort.filter(|e| !e.is_empty()) {
|
||||
Some(e) if !EFFORTS.contains(&e) => Err(ApiError(StatusCode::BAD_REQUEST, format!("invalid effort '{e}'"))),
|
||||
Some(e) if !app.is_busy(id) => {
|
||||
app.with_runtime(id, |r| r.effort = Some(e.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
|
||||
@@ -600,6 +701,7 @@ async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, P
|
||||
}
|
||||
}
|
||||
}
|
||||
set_turn_effort(&app, &id, body.effort.as_deref())?;
|
||||
let mut parts = vec![Part::Text { text: prompt_text }];
|
||||
for image in body.images {
|
||||
parts.push(Part::Image { image });
|
||||
@@ -775,12 +877,14 @@ fn check_pending(app: &App, user: &User, id: &str) -> Result<(), ApiError> {
|
||||
struct PermissionBody {
|
||||
reply: PermissionReply,
|
||||
message: Option<String>,
|
||||
/// Accepted diff hunks of a file edit (indices); missing = all.
|
||||
hunks: Option<Vec<usize>>,
|
||||
}
|
||||
|
||||
async fn reply_permission(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PermissionBody>) -> ApiResult {
|
||||
check_pending(&app, &user, &id)?;
|
||||
let what = app.pending_title(&id).unwrap_or_default();
|
||||
app.reply_permission(&id, body.reply, body.message)?;
|
||||
app.reply_permission(&id, body.reply, body.message, body.hunks)?;
|
||||
app.audit(Some(&user), &format!("permission.{}", serde_json::to_value(body.reply).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_default()), what, "");
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user