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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 22:29:58 +01:00
1 parent df1924b0a1
commit fca1c41087
22 files changed
+821 -32

No files matched your search

+100
View File
@@ -377,3 +377,103 @@ mod tests {
assert!(!valid_ref("a b"));
}
}
// --- worktrees per session -------------------------------------------------------
fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str {
wt[key].as_str().unwrap_or("")
}
/// Create, inspect, merge or remove the session's isolated worktree.
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
let spec = crate::sandbox::spec_for(app, Some(&user.id));
let cfg = crate::gitcreds::global_config(app, user);
let s = session.clone();
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, worktree_inner(s, action))).await?;
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
session.cwd = cwd.to_string();
}
if res.get("worktree").is_some() {
session.worktree = res["worktree"].clone().as_object().map(|_| res["worktree"].clone());
}
Ok(res)
}
async fn worktree_inner(session: crate::types::Session, action: &str) -> Result<Value, ApiError> {
let bad = |m: &str| ApiError(StatusCode::BAD_REQUEST, m.to_string());
match action {
"create" => {
if session.worktree.is_some() {
return Err(bad("this session already has a worktree"));
}
let cwd = PathBuf::from(&session.cwd);
let repo = repo_root(&cwd).ok_or_else(|| bad("the session folder is not in a git repository"))?;
let sub = cwd.strip_prefix(&repo).map(|p| p.to_path_buf()).unwrap_or_default();
let short: String = session.id.chars().rev().take(8).collect::<String>().chars().rev().collect();
let branch = format!("codecraft/{short}");
let path = repo.join(".codecraft").join("worktrees").join(&short);
let base = git_ok(&repo, &["rev-parse", "--abbrev-ref", "HEAD"], 30).await?.trim().to_string();
if base == "HEAD" {
return Err(bad("the repository is in detached HEAD state – check out a branch first"));
}
// Keep the worktrees out of the repository's status (local only, not committed).
let common = git_ok(&repo, &["rev-parse", "--git-common-dir"], 30).await?.trim().to_string();
let common = if Path::new(&common).is_absolute() { PathBuf::from(common) } else { repo.join(common) };
let exclude = common.join("info").join("exclude");
let current = std::fs::read_to_string(&exclude).unwrap_or_default();
if !current.lines().any(|l| l.trim() == ".codecraft/") {
let _ = std::fs::create_dir_all(exclude.parent().unwrap());
let _ = std::fs::write(&exclude, format!("{current}{}.codecraft/\n", if current.is_empty() || current.ends_with('\n') { "" } else { "\n" }));
}
std::fs::create_dir_all(path.parent().unwrap()).map_err(|e| bad(&e.to_string()))?;
let p = path.display().to_string();
git_ok(&repo, &["worktree", "add", "-b", &branch, &p, "HEAD"], 120).await?;
let wt = json!({"repo": repo, "path": path, "branch": branch, "base": base, "sub": sub});
let cwd = if sub.as_os_str().is_empty() { path.clone() } else { path.join(&sub) };
Ok(json!({"ok": true, "worktree": wt, "cwd": cwd}))
}
"status" => {
let wt = session.worktree.clone().ok_or_else(|| bad("no worktree"))?;
let path = PathBuf::from(wt_str(&wt, "path"));
let dirty = git_ok(&path, &["status", "--porcelain"], 60).await?.lines().count();
let range = format!("{}..{}", wt_str(&wt, "base"), wt_str(&wt, "branch"));
let commits = git_ok(&path, &["log", "--oneline", &range], 60).await.unwrap_or_default();
let stat = git_ok(&path, &["diff", "--shortstat", wt_str(&wt, "base")], 60).await.unwrap_or_default();
Ok(json!({"worktree": wt, "dirty_files": dirty, "commits": commits.lines().collect::<Vec<_>>(), "diffstat": stat.trim()}))
}
"merge" => {
let wt = session.worktree.clone().ok_or_else(|| bad("no worktree"))?;
let path = PathBuf::from(wt_str(&wt, "path"));
let repo = PathBuf::from(wt_str(&wt, "repo"));
let (branch, base) = (wt_str(&wt, "branch"), wt_str(&wt, "base"));
// Uncommitted work in the worktree is committed first.
if !git_ok(&path, &["status", "--porcelain"], 60).await?.trim().is_empty() {
git_ok(&path, &["add", "-A"], 120).await?;
let msg = format!("CodeCraft: {}", if session.title.is_empty() { "session changes" } else { session.title.as_str() });
git_ok(&path, &["commit", "-q", "-m", &msg], 120).await?;
}
let current = git_ok(&repo, &["rev-parse", "--abbrev-ref", "HEAD"], 30).await?.trim().to_string();
if current != base {
return Err(bad(&format!("the main working copy is on '{current}', not on '{base}' – check out '{base}' first")));
}
let msg = format!("Merge {branch}{}", if session.title.is_empty() { String::new() } else { format!(": {}", session.title) });
let o = git(&repo, &["merge", "--no-ff", "-m", &msg, branch], 300).await?;
if !o.ok {
let _ = git(&repo, &["merge", "--abort"], 60).await;
return Err(ApiError(StatusCode::CONFLICT, format!("merge failed (aborted, nothing changed): {}", if o.stderr.trim().is_empty() { o.stdout.trim() } else { o.stderr.trim() })));
}
Ok(json!({"ok": true, "merged": branch, "into": base, "output": o.stdout.trim()}))
}
"remove" => {
let wt = session.worktree.clone().ok_or_else(|| bad("no worktree"))?;
let repo = PathBuf::from(wt_str(&wt, "repo"));
let p = wt_str(&wt, "path").to_string();
git_ok(&repo, &["worktree", "remove", "--force", &p], 120).await?;
let _ = git(&repo, &["branch", "-D", wt_str(&wt, "branch")], 60).await;
let sub = wt_str(&wt, "sub");
let cwd = if sub.is_empty() { repo.clone() } else { repo.join(sub) };
Ok(json!({"ok": true, "worktree": Value::Null, "cwd": if cwd.is_dir() { cwd } else { repo }}))
}
_ => Err(bad("unknown worktree action")),
}
}