diff --git a/Dockerfile b/Dockerfile index ec6a030..64aa0f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential pkg-config libssl-dev cmake zlib1g-dev binutils-multiarch \ chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \ openscad xvfb xauth \ + openssh-client rsync mc \ && rm -rf /var/lib/apt/lists/* # Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be # installed by agents: rustup component add rust-analyzer, go install …). diff --git a/server/src/api.rs b/server/src/api.rs index 34aa2ce..0bd587a 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -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, State(app): State, State(app): State, + effort: Option, +} + +/// 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, State(app): State, Path(id): Path, Json(b): Json) -> 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, State(app): State, Path(id): Path) -> 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, State(app): State, Path(id): Path, Json(b): Json) -> 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, @@ -472,6 +557,7 @@ async fn fork_session(Extension(user): Extension, State(app): State, + /// Reasoning effort for this turn (low, medium, high, xhigh, max). + effort: Option, +} + +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, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { @@ -600,6 +701,7 @@ async fn prompt(Extension(user): Extension, State(app): State, 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, + /// Accepted diff hunks of a file edit (indices); missing = all. + hunks: Option>, } async fn reply_permission(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> 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}))) } diff --git a/server/src/app.rs b/server/src/app.rs index afd7477..d2d8033 100644 --- a/server/src/app.rs +++ b/server/src/app.rs @@ -25,7 +25,7 @@ pub enum PermissionReply { struct PendingPermission { event: Event, - tx: oneshot::Sender<(PermissionReply, Option)>, + tx: oneshot::Sender<(PermissionReply, Option, Option>)>, } struct PendingQuestion { @@ -41,6 +41,8 @@ pub struct Runtime { pub approved: HashSet, /// The model request currently running (for clients that connect mid-step). pub step: Option, + /// Reasoning effort chosen for the current turn (overrides the model's default). + pub effort: Option, } pub struct App { @@ -436,7 +438,7 @@ impl App { input: Value, diff: Option, cancel: CancellationToken, - ) -> (PermissionReply, Option) { + ) -> (PermissionReply, Option, Option>) { let id = crate::types::new_id(); let event = Event::PermissionRequest { id: id.clone(), @@ -451,17 +453,18 @@ impl App { self.permissions.lock().unwrap().insert(id.clone(), PendingPermission { event: event.clone(), tx }); self.emit(event); let res = tokio::select! { - r = rx => r.unwrap_or((PermissionReply::Reject, None)), - _ = cancel.cancelled() => (PermissionReply::Reject, Some("aborted".into())), + r = rx => r.unwrap_or((PermissionReply::Reject, None, None)), + _ = cancel.cancelled() => (PermissionReply::Reject, Some("aborted".into()), None), }; self.permissions.lock().unwrap().remove(&id); self.emit(Event::PermissionResolved { id, session_id: session_id.to_string() }); res } - pub fn reply_permission(&self, id: &str, reply: PermissionReply, message: Option) -> Result<()> { + /// `hunks`: for file edits, the indices of the diff hunks the user accepted (None = all). + pub fn reply_permission(&self, id: &str, reply: PermissionReply, message: Option, hunks: Option>) -> Result<()> { let p = self.permissions.lock().unwrap().remove(id).ok_or_else(|| anyhow!("permission not pending"))?; - let _ = p.tx.send((reply, message)); + let _ = p.tx.send((reply, message, hunks)); Ok(()) } } diff --git a/server/src/git.rs b/server/src/git.rs index 0087e86..65c7043 100644 --- a/server/src/git.rs +++ b/server/src/git.rs @@ -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, user: &User, session: &mut crate::types::Session, action: &str) -> Result { + 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 { + 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::().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::>(), "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")), + } +} diff --git a/server/src/jobs.rs b/server/src/jobs.rs index fd049a0..631cc02 100644 --- a/server/src/jobs.rs +++ b/server/src/jobs.rs @@ -153,6 +153,7 @@ pub async fn run(app: &Arc, job: &Job, trigger: &str) -> Result { last_usage: None, clean_room: None, phase: None, + worktree: None, }; let session = app.save_session(session)?; app.store.job_started(&job.id, &session.id, now)?; diff --git a/server/src/runner.rs b/server/src/runner.rs index 16dd185..ced8f8b 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -90,10 +90,13 @@ pub fn submit(app: Arc, session_id: String, parts: Vec, source: &str) 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 }); - maybe_generate_title(app.clone(), &session_id, &text_only); + // Empty parts: continue from the existing history (regenerate). + if !parts.is_empty() { + 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 }); + maybe_generate_title(app.clone(), &session_id, &text_only); + } run_session(app.clone(), &session_id, cancel.clone(), 0).await } .await; @@ -105,6 +108,7 @@ pub fn submit(app: Arc, session_id: String, parts: Vec, source: &str) // stopped the agent: then they stay queued until sent explicitly. let leftover = app.with_runtime(&session_id, |r| { r.cancel = None; + r.effort = None; if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) } }); app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false }); @@ -271,7 +275,10 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo agent.prompt = prompts::craft_prompt(clean_room); } let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone()); - let (provider, model) = app.registry().resolve(&model_spec)?; + let (provider, mut model) = app.registry().resolve(&model_spec)?; + if let Some(effort) = app.with_runtime(session_id, |r| r.effort.clone()) { + model.effort = Some(effort); + } // Auto-compaction when the last request used most of the context window. if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) { @@ -464,6 +471,8 @@ async fn execute_call( }; let name = name.as_str(); + // Hunks of a file edit the user accepted in the review (None = whole change). + let mut partial: Option> = None; // Permission check. Approvals are tracked on the top-level session. let root = app.root_session_id(&session.id); let cfg = app.config(); @@ -476,7 +485,7 @@ async fn execute_call( } Decision::Ask => { let diff = tool.preview(&input, &ctx).await; - let (reply, message) = app + let (reply, message, hunks) = app .request_permission(&session.id, name, tool.title(&input), input.clone(), diff, base.cancel.clone()) .await; match reply { @@ -499,6 +508,12 @@ async fn execute_call( }), PermissionReply::Once => {} } + if let Some(sel) = hunks { + if sel.is_empty() { + return tool_result(call_id, call_name, "The user rejected all parts of this change.".into(), true, None); + } + partial = Some(sel); + } } Decision::Allow => {} } @@ -528,7 +543,9 @@ async fn execute_call( } let input_path = input["path"].as_str().map(String::from); let input_for_phase = if base.agent == "craft" { input.clone() } else { Value::Null }; - let res = if name == "task" { + let res = if let (Some(sel), true) = (&partial, matches!(name, "write" | "edit")) { + apply_partial(&ctx, name, &input, sel).await + } else if name == "task" { // Pass depth through the agent name channel: subagents run with depth+1. run_task(app, &ctx, input, depth).await } else { @@ -669,6 +686,7 @@ fn create_child(app: &Arc, ctx: &ToolCtx, agent_name: &str, description: &s last_usage: None, clean_room: parent.clean_room, phase: None, + worktree: None, }; let child = app.save_session(child)?; let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]); @@ -997,6 +1015,30 @@ pub async fn suggest_title(app: Arc, session_id: &str, model_spec: Option Result { + let path = ctx.resolve(tools::arg_str(input, "path")?)?; + let old = tokio::fs::read_to_string(&path).await.unwrap_or_default(); + let proposed = if name == "write" { tools::arg_str(input, "content")?.to_string() } else { tools::fs::apply_edit(&old, input)? }; + let (result, total) = tools::fs::apply_hunks(&old, &proposed, selected); + let accepted = selected.iter().filter(|i| **i < total).count(); + if let Some(dir) = path.parent() { + tokio::fs::create_dir_all(dir).await?; + } + tokio::fs::write(&path, &result).await?; + ctx.app.mark_read(&ctx.session_id, &path); + let rel = path.strip_prefix(&ctx.cwd).map(|p| p.display().to_string()).unwrap_or_else(|_| path.display().to_string()); + let applied = tools::fs::unified_diff(&rel, &old, &result); + let rejected = tools::fs::unified_diff(&rel, &result, &proposed); + Ok(tools::ToolOutput::text(format!( + "The user reviewed your change to {rel} and applied only {accepted} of {total} hunks. \ + The file now contains the accepted parts; the rejected parts were NOT applied – do not re-apply them unless the user asks.\n\n\ + Rejected (what is still missing compared to your proposal):\n{}", + clip(&rejected, 8000) + )) + .with_meta(serde_json::json!({"diff": applied, "path": path, "partial": {"accepted": accepted, "total": total}}))) +} + const PHASES: &[&str] = &["study", "specify", "build", "check", "ready"]; /// Craft phase implied by a tool call (fallback when the agent does not report it). diff --git a/server/src/tools/fs.rs b/server/src/tools/fs.rs index 93cb4ff..a16e408 100644 --- a/server/src/tools/fs.rs +++ b/server/src/tools/fs.rs @@ -22,6 +22,27 @@ fn image_type(path: &Path) -> Option<&'static str> { } } +/// Apply only the selected hunks (indices of the 3-line-context groups, as in +/// `unified_diff`) of the change old -> new. Returns (result, total hunks). +pub fn apply_hunks(old: &str, new: &str, selected: &[usize]) -> (String, usize) { + let diff = similar::TextDiff::from_lines(old, new); + let groups = diff.grouped_ops(3); + let total = groups.len(); + let mut out = String::new(); + for op in diff.ops() { + let in_selected = op.tag() != similar::DiffTag::Equal && groups.iter().enumerate().any(|(i, g)| selected.contains(&i) && g.contains(op)); + let lines: Vec<&str> = if in_selected { + diff.new_slices()[op.new_range()].to_vec() + } else { + diff.old_slices()[op.old_range()].to_vec() + }; + for l in lines { + out.push_str(l); + } + } + (out, total) +} + pub fn unified_diff(path: &str, old: &str, new: &str) -> String { similar::TextDiff::from_lines(old, new) .unified_diff() @@ -235,7 +256,7 @@ impl Tool for Edit { } } -fn apply_edit(old: &str, input: &Value) -> Result { +pub fn apply_edit(old: &str, input: &Value) -> Result { let from = arg_str(input, "old_string")?; let to = arg_str(input, "new_string")?; if from.is_empty() { @@ -484,3 +505,19 @@ mod tests { assert!(apply_edit("xyz", &input).is_err()); } } + +#[cfg(test)] +mod hunk_tests { + #[test] + fn partial_apply() { + let old: String = (1..=30).map(|i| format!("line {i}\n")).collect(); + let new = old.replace("line 2\n", "line TWO\n").replace("line 25\n", "line 25\nextra\n"); + let (only_first, total) = super::apply_hunks(&old, &new, &[0]); + assert_eq!(total, 2); + assert!(only_first.contains("line TWO") && !only_first.contains("extra")); + let (only_second, _) = super::apply_hunks(&old, &new, &[1]); + assert!(!only_second.contains("line TWO") && only_second.contains("extra")); + assert_eq!(super::apply_hunks(&old, &new, &[0, 1]).0, new); + assert_eq!(super::apply_hunks(&old, &new, &[]).0, old); + } +} diff --git a/server/src/types.rs b/server/src/types.rs index e2f0b29..a81cf45 100644 --- a/server/src/types.rs +++ b/server/src/types.rs @@ -130,6 +130,9 @@ pub struct Session { /// Craft workflow phase reported by the agent (study, specify, build, check, ready). #[serde(default, skip_serializing_if = "Option::is_none")] pub phase: Option, + /// Isolated git worktree of this session: {repo, path, branch, base}. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option, } impl Session { diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 1c0b54b..7d94078 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -12,6 +12,8 @@ import { useReasoningMode } from "../prefs"; import { Composer } from "./Composer"; import { ShareDialog } from "./ShareDialog"; import { RenameDialog } from "./RenameDialog"; +import { RegenerateButton } from "./Regenerate"; +import { WorktreeChip } from "./WorktreeChip"; import { QueueList } from "./QueueList"; import { BudgetChip } from "./Budget"; import { PermissionCard, QuestionCard } from "./Interaction"; @@ -215,14 +217,14 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () } }; - const send = async (raw: string, images: ImageData[]) => { + const send = async (raw: string, images: ImageData[], effort?: string) => { const txt = raw.trim(); if (txt === "/new") { setText(""); return navigate("/new"); } try { - await post(`/sessions/${sessionId}/prompt`, { text: txt, images }); + await post(`/sessions/${sessionId}/prompt`, { text: txt, images, effort: effort || undefined }); setText(""); follow(true); } catch (e: any) { @@ -230,6 +232,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () } }; + const lastAssistantIdx = messages.reduce((acc, m, i) => (m.role === "assistant" ? i : acc), -1); const [rewinding, setRewinding] = useState(); const rewind = (m: Message) => setRewinding(m); const rewindDone = async () => { @@ -296,6 +299,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () {fmtCost(stats.total.cost, stats.currency)} )} + {isAdmin && } {session.permission_mode === "bypass" && {t("permissionMode.bypass")}} @@ -323,16 +327,33 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () )}
- - + +
+ {crumbs.map((c, i) => { const path = i === 0 ? c : `${crumbs[0]}/${crumbs.slice(1, i + 1).join("/")}`; return ( diff --git a/web/src/components/Icon.tsx b/web/src/components/Icon.tsx index 860544c..b85e5df 100644 --- a/web/src/components/Icon.tsx +++ b/web/src/components/Icon.tsx @@ -193,6 +193,12 @@ const P: Record = { ), dot: , plus: , + mic: ( + <> + + + + ), clock: ( <> diff --git a/web/src/components/Interaction.tsx b/web/src/components/Interaction.tsx index fdb8472..fab73b8 100644 --- a/web/src/components/Interaction.tsx +++ b/web/src/components/Interaction.tsx @@ -5,7 +5,7 @@ import { useT } from "../i18n"; import { navigate } from "../router"; import { useStore } from "../store"; import type { PermissionRequest, QuestionRequest } from "../types"; -import { Diff } from "./Diff"; +import { Diff, HunkDiff, splitHunks } from "./Diff"; function SubagentHint({ sessionId, currentId }: { sessionId: string; currentId: string }) { const t = useT(); @@ -25,11 +25,17 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur const t = useT(); const [feedback, setFeedback] = useState(""); const [busy, setBusy] = useState(false); + // File edits with several hunks can be accepted piece by piece. + const hunkCount = req.diff && (req.tool === "edit" || req.tool === "write") ? splitHunks(req.diff).hunks.length : 0; + const [selected, setSelected] = useState(() => Array(hunkCount).fill(true)); + const chosen = selected.filter(Boolean).length; + const partial = hunkCount > 1 && chosen < hunkCount; const reply = async (r: "once" | "always" | "reject") => { setBusy(true); try { - await post(`/permissions/${req.id}`, { reply: r, message: r === "reject" ? feedback : undefined }); + const hunks = r === "once" && partial ? selected.flatMap((on, i) => (on ? [i] : [])) : undefined; + await post(`/permissions/${req.id}`, { reply: r, message: r === "reject" || partial ? feedback || undefined : undefined, hunks }); } finally { setBusy(false); } @@ -49,7 +55,12 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
{req.title}
{command &&
$ {command}
} - {req.diff ? ( + {req.diff && hunkCount > 1 ? ( + <> +
{t("permission.hunksHint", { n: hunkCount })}
+ setSelected((s) => s.map((v, j) => (j === i ? !v : v)))} /> + + ) : req.diff ? ( ) : ( !command && ( @@ -69,10 +80,10 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur onKeyDown={(e) => e.key === "Enter" && reply("reject")} />
- -
)} {folderMode === "existing" && } + {folderMode !== "new" && ( + + )} {error &&
{error}
}
+ {open && ( +
+
{t("regen.hint")}
+
+ + + +
+
+ )} +
+ ); +} diff --git a/web/src/components/Voice.tsx b/web/src/components/Voice.tsx new file mode 100644 index 0000000..1df20b4 --- /dev/null +++ b/web/src/components/Voice.tsx @@ -0,0 +1,68 @@ +import { useEffect, useRef, useState } from "react"; +import { getLang, useT } from "../i18n"; +import { toast } from "../dialogs"; +import { Icon } from "./Icon"; + +/* Web Speech API (Chrome/Edge/Safari; Firefox has none). */ +type Recognition = { + lang: string; + continuous: boolean; + interimResults: boolean; + start(): void; + stop(): void; + onresult: ((e: any) => void) | null; + onend: (() => void) | null; + onerror: ((e: any) => void) | null; +}; +const Impl: (new () => Recognition) | undefined = (window as any).SpeechRecognition ?? (window as any).webkitSpeechRecognition; + +export const voiceSupported = () => !!Impl; + +const LOCALES: Record = { de: "de-DE", en: "en-US" }; + +/** Microphone button: dictated text is appended to the message. */ +export function VoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) { + const t = useT(); + const [listening, setListening] = useState(false); + const rec = useRef(null); + + useEffect(() => () => rec.current?.stop(), []); + if (!Impl) return null; + + const toggle = () => { + if (listening) { + rec.current?.stop(); + return; + } + const r = new Impl!(); + r.lang = LOCALES[getLang()] ?? navigator.language; + r.continuous = true; + r.interimResults = true; + r.onresult = (e: any) => { + let interim = ""; + for (let i = e.resultIndex; i < e.results.length; i++) { + const res = e.results[i]; + if (res.isFinal) onText(res[0].transcript.trim()); + else interim += res[0].transcript; + } + onInterim(interim); + }; + r.onerror = (e: any) => { + if (e.error === "not-allowed" || e.error === "service-not-allowed") toast(t("voice.denied")); + else if (e.error !== "no-speech" && e.error !== "aborted") toast(`${t("voice.error")}: ${e.error}`); + }; + r.onend = () => { + setListening(false); + onInterim(""); + }; + rec.current = r; + r.start(); + setListening(true); + }; + + return ( + + ); +} diff --git a/web/src/components/WorktreeChip.tsx b/web/src/components/WorktreeChip.tsx new file mode 100644 index 0000000..9a82c8d --- /dev/null +++ b/web/src/components/WorktreeChip.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { get, post } from "../api"; +import { useT } from "../i18n"; +import { confirmDialog, toast } from "../dialogs"; +import { Icon } from "./Icon"; +import { Spinner } from "./Activity"; +import type { Session } from "../types"; + +interface WtStatus { + worktree: { repo: string; path: string; branch: string; base: string } | null; + dirty_files?: number; + commits?: string[]; + diffstat?: string; +} + +/** Chip + panel for a session working in an isolated git worktree. */ +export function WorktreeChip({ session, busy }: { session: Session; busy: boolean }) { + const t = useT(); + const wt = session.worktree as WtStatus["worktree"] | undefined; + const [open, setOpen] = useState(false); + const [status, setStatus] = useState(); + const [working, setWorking] = useState(); + + useEffect(() => { + if (!open) return; + get(`/sessions/${session.id}/worktree`) + .then(setStatus) + .catch((e) => toast(e.message)); + }, [open, session.id, busy]); + + if (!wt) return null; + + const act = async (action: "merge" | "remove") => { + const msg = action === "merge" ? t("worktree.mergeConfirm", { branch: wt.branch, base: wt.base }) : t("worktree.removeConfirm", { branch: wt.branch }); + if (!(await confirmDialog({ message: msg, danger: action === "remove" }))) return; + setWorking(action); + try { + await post(`/sessions/${session.id}/worktree`, { action }); + toast(t(action === "merge" ? "worktree.merged" : "worktree.removed", { base: wt.base }), "success"); + if (action === "merge") setStatus(await get(`/sessions/${session.id}/worktree`)); + else setOpen(false); + } catch (e: any) { + toast(e.message); + } finally { + setWorking(undefined); + } + }; + + return ( + + + {open && ( +
+
{t("worktree.hint", { base: wt.base })}
+ {!status ? ( + + ) : ( + <> +
+ {t("worktree.dirty", { n: status.dirty_files ?? 0 })} + {t("worktree.commits", { n: status.commits?.length ?? 0 })} + {status.diffstat && {status.diffstat}} +
+ {!!status.commits?.length && ( +
{status.commits.slice(0, 8).join("\n")}
+ )} + + )} +
+ + +
+ {busy &&
{t("worktree.busy")}
} +
+ )} +
+ ); +} diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index bcf02cc..5b29170 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -335,6 +335,41 @@ export const de: Messages = { "jobs.status.done": "fertig", "jobs.status.timeout": "Zeitlimit", "jobs.status.error": "Fehler", + "chat.more": "Mehr", + "worktree.title": "Isolierter Git-Worktree", + "worktree.hint": "Der Agent arbeitet auf einem eigenen Branch; deine Arbeitskopie auf „{base}“ bleibt unberührt, bis du mergst.", + "worktree.dirty": "{n} nicht committete Dateien", + "worktree.commits": "{n} Commits", + "worktree.merge": "In {base} mergen", + "worktree.remove": "Verwerfen", + "worktree.mergeConfirm": "Offene Änderungen committen und {branch} in {base} mergen?", + "worktree.removeConfirm": "Worktree und Branch {branch} verwerfen? Nicht gemergte Änderungen gehen verloren.", + "worktree.merged": "In {base} gemergt", + "worktree.removed": "Worktree verworfen", + "worktree.created": "Die Session arbeitet jetzt in einem eigenen Worktree", + "worktree.create": "Isolierter Worktree", + "worktree.busy": "Warte, bis der Agent fertig ist.", + "worktree.isolate": "In einem isolierten Git-Worktree arbeiten", + "worktree.isolateHint": "eigener Branch, am Ende mergen", + "worktree.creating": "Worktree wird angelegt…", + "files.up": "Einen Ordner nach oben", + "permission.hunksHint": "{n} Änderungen – entferne den Haken bei denen, die du nicht willst.", + "permission.applySelected": "Auswahl übernehmen ({k}/{n})", + "effort.title": "Denkaufwand für diese Nachricht", + "effort.auto": "Aufwand: Modell-Standard", + "effort.autoShort": "Aufwand: auto", + "effort.low": "Aufwand: niedrig", + "effort.medium": "Aufwand: mittel", + "effort.high": "Aufwand: hoch", + "effort.max": "Aufwand: maximal", + "regen.button": "Neu generieren", + "regen.title": "Diese Antwort neu erzeugen", + "regen.hint": "Entfernt diese Antwort (und stellt dabei geänderte Dateien wieder her) und führt deine letzte Nachricht erneut aus – mit dem hier gewählten Modell und Aufwand.", + "regen.go": "Neu generieren", + "voice.start": "Diktieren (Spracherkennung deines Browsers)", + "voice.stop": "Diktat beenden", + "voice.denied": "Mikrofonzugriff verweigert – in den Browser-Einstellungen erlauben.", + "voice.error": "Spracherkennung fehlgeschlagen", "new.title": "Neue Session", "new.directory": "Projektverzeichnis", "new.up": "Hoch", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index abafa88..6965846 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -334,6 +334,41 @@ export const en = { "jobs.status.done": "done", "jobs.status.timeout": "timed out", "jobs.status.error": "error", + "chat.more": "More", + "worktree.title": "Isolated git worktree", + "worktree.hint": "The agent works on its own branch; your working copy on “{base}” stays untouched until you merge.", + "worktree.dirty": "{n} uncommitted files", + "worktree.commits": "{n} commits", + "worktree.merge": "Merge into {base}", + "worktree.remove": "Discard", + "worktree.mergeConfirm": "Commit open changes and merge {branch} into {base}?", + "worktree.removeConfirm": "Discard the worktree and branch {branch}? Unmerged changes are lost.", + "worktree.merged": "Merged into {base}", + "worktree.removed": "Worktree discarded", + "worktree.created": "The session now works in its own worktree", + "worktree.create": "Isolated worktree", + "worktree.busy": "Wait until the agent is done.", + "worktree.isolate": "Work in an isolated git worktree", + "worktree.isolateHint": "own branch, merge when done", + "worktree.creating": "Creating worktree…", + "files.up": "One folder up", + "permission.hunksHint": "{n} changes – untick the ones you don't want.", + "permission.applySelected": "Apply selected ({k}/{n})", + "effort.title": "Thinking effort for this message", + "effort.auto": "Effort: model default", + "effort.autoShort": "Effort: auto", + "effort.low": "Effort: low", + "effort.medium": "Effort: medium", + "effort.high": "Effort: high", + "effort.max": "Effort: max", + "regen.button": "Regenerate", + "regen.title": "Redo this answer", + "regen.hint": "Removes this answer (and restores files it changed), then runs your last message again – with the model and effort chosen here.", + "regen.go": "Regenerate", + "voice.start": "Dictate (speech recognition of your browser)", + "voice.stop": "Stop dictation", + "voice.denied": "Microphone access was denied – allow it in the browser settings.", + "voice.error": "Speech recognition failed", "new.title": "New session", "new.directory": "Project directory", "new.up": "Up", diff --git a/web/src/styles.css b/web/src/styles.css index d92b211..1d60995 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1555,6 +1555,18 @@ a.button.small { font-size: 12.5px; color: var(--muted); word-break: break-all; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 2px; +} +.crumbs-up { + margin-right: 6px; + padding: 3px 8px; +} +.crumbs-up .icon { + width: 18px; + height: 18px; } .entries { flex: 1; @@ -2942,3 +2954,89 @@ button.danger-solid { .chip.ok { color: var(--green); } + +/* --- worktree ------------------------------------------------------------------- */ +.wt-wrap { + position: relative; +} +.wt-chip { + border: 1px solid var(--accent); + color: var(--accent); + cursor: pointer; +} +.wt-panel { + width: 340px; + padding: 12px; + gap: 6px; +} +.wt-stats { + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: 13px; +} +.wt-log { + font-size: 12px; + background: var(--bg0); + padding: 6px 8px; + border-radius: 6px; + max-height: 140px; + overflow: auto; + margin: 0; +} +.menu-pop-body a .icon { + margin-right: 6px; +} +.wiz-check-row { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +/* --- per-hunk review ----------------------------------------------------------- */ +.hunk-block { + border-top: 1px dashed var(--bg3); +} +.hunk-block.off > div { + opacity: 0.35; + text-decoration: line-through; +} +.hunk-head { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + color: var(--blue); + background: var(--bg1); + padding: 2px 6px; +} + +/* --- regenerate / effort --------------------------------------------------------- */ +.regen { + margin-top: 6px; +} +.regen > button { + opacity: 0.7; +} +.regen > button:hover { + opacity: 1; +} +.regen-panel { + margin-top: 6px; + padding: 10px; + border: 1px solid var(--bg3); + border-radius: var(--radius); + background: var(--bg1); + display: flex; + flex-direction: column; + gap: 8px; +} +.effort-select { + max-width: 150px; +} + +.voice-btn.on { + color: var(--red); + animation: glow 1.2s ease-in-out infinite; +} diff --git a/web/src/types.ts b/web/src/types.ts index 578f4f5..18f7f52 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -60,6 +60,8 @@ export interface Session { clean_room?: boolean | null; /** Craft workflow phase reported by the agent. */ phase?: string; + /** Isolated git worktree of the session. */ + worktree?: { repo: string; path: string; branch: string; base: string; sub?: string } | null; /** Listed because it was shared with the current user. */ shared?: boolean; }