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
+817
-28
No files matched your search
@@ -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 …).
|
||||
|
||||
+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})))
|
||||
}
|
||||
|
||||
+9
-6
@@ -25,7 +25,7 @@ pub enum PermissionReply {
|
||||
|
||||
struct PendingPermission {
|
||||
event: Event,
|
||||
tx: oneshot::Sender<(PermissionReply, Option<String>)>,
|
||||
tx: oneshot::Sender<(PermissionReply, Option<String>, Option<Vec<usize>>)>,
|
||||
}
|
||||
|
||||
struct PendingQuestion {
|
||||
@@ -41,6 +41,8 @@ pub struct Runtime {
|
||||
pub approved: HashSet<String>,
|
||||
/// The model request currently running (for clients that connect mid-step).
|
||||
pub step: Option<Value>,
|
||||
/// Reasoning effort chosen for the current turn (overrides the model's default).
|
||||
pub effort: Option<String>,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
@@ -436,7 +438,7 @@ impl App {
|
||||
input: Value,
|
||||
diff: Option<String>,
|
||||
cancel: CancellationToken,
|
||||
) -> (PermissionReply, Option<String>) {
|
||||
) -> (PermissionReply, Option<String>, Option<Vec<usize>>) {
|
||||
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<String>) -> 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<String>, hunks: Option<Vec<usize>>) -> 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(())
|
||||
}
|
||||
}
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,7 @@ pub async fn run(app: &Arc<App>, job: &Job, trigger: &str) -> Result<String> {
|
||||
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)?;
|
||||
|
||||
+45
-3
@@ -90,10 +90,13 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
|
||||
if source == "user" {
|
||||
parts.extend(crate::mentions::expand(&app, &session_id, &text_only).await);
|
||||
}
|
||||
// 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<App>, session_id: String, parts: Vec<Part>, 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<App>, 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<Vec<usize>> = 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<App>, 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<App>, session_id: &str, model_spec: Option<S
|
||||
Ok(title.chars().take(80).collect())
|
||||
}
|
||||
|
||||
/// Write only the hunks of a proposed write/edit that the user accepted.
|
||||
async fn apply_partial(ctx: &ToolCtx, name: &str, input: &Value, selected: &[usize]) -> Result<tools::ToolOutput> {
|
||||
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).
|
||||
|
||||
+38
-1
@@ -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<String> {
|
||||
pub fn apply_edit(old: &str, input: &Value) -> Result<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// Isolated git worktree of this session: {repo, path, branch, base}.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worktree: Option<Value>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
|
||||
@@ -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<Message>();
|
||||
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)}
|
||||
</button>
|
||||
)}
|
||||
<WorktreeChip session={session} busy={busy} />
|
||||
<ShellsChip sessionId={sessionId} busy={busy} />
|
||||
{isAdmin && <BudgetChip />}
|
||||
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
|
||||
@@ -323,16 +327,33 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
</button>
|
||||
)}
|
||||
<details className="menu-pop">
|
||||
<summary className="small ghost" title={t("chat.export")}>
|
||||
<Icon name="download" />
|
||||
<summary className="small ghost" title={t("chat.more")} aria-label={t("chat.more")}>
|
||||
<Icon name="more" />
|
||||
</summary>
|
||||
<div className="menu-pop-body">
|
||||
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
|
||||
{t("chat.exportMd")}
|
||||
<Icon name="download" /> {t("chat.exportMd")}
|
||||
</a>
|
||||
<a href={`/api/sessions/${sessionId}/export?format=json`} download>
|
||||
{t("chat.exportJson")}
|
||||
<Icon name="download" /> {t("chat.exportJson")}
|
||||
</a>
|
||||
{!session.parent_id && !session.worktree && (
|
||||
<a
|
||||
href="#"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
|
||||
try {
|
||||
await post(`/sessions/${sessionId}/worktree`, { action: "create" });
|
||||
toast(t("worktree.created"), "success");
|
||||
} catch (err: any) {
|
||||
toast(err.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="fork" /> {t("worktree.create")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
|
||||
@@ -366,11 +387,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
||||
<div className="chat">
|
||||
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
|
||||
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
|
||||
{messages.map((m) => {
|
||||
{messages.map((m, idx) => {
|
||||
if (m.role === "assistant") {
|
||||
const lastAnswer = idx === lastAssistantIdx && !busy && !readOnly && !session.parent_id;
|
||||
return (
|
||||
<div key={m.id} className="msg">
|
||||
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={session.cwd} />
|
||||
{lastAnswer && <RegenerateButton sessionId={sessionId} messageId={m.id} model={session.model} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { Icon } from "./Icon";
|
||||
import { formatSize } from "./FileFigure";
|
||||
import { uploadFile } from "../api";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAgents, useFetch } from "../hooks";
|
||||
import { useAgents, useFetch, useModels } from "../hooks";
|
||||
import { useT, type Key } from "../i18n";
|
||||
import type { ImageData, Session } from "../types";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { EffortSelect } from "./Regenerate";
|
||||
import { VoiceButton } from "./Voice";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
|
||||
interface Suggestion {
|
||||
@@ -49,7 +51,7 @@ export function Composer({
|
||||
busy: boolean;
|
||||
text: string;
|
||||
setText: (s: string) => void;
|
||||
onSend: (text: string, images: ImageData[]) => void;
|
||||
onSend: (text: string, images: ImageData[], effort?: string) => void;
|
||||
onAbort: () => void;
|
||||
onUpdate: (patch: Partial<Session>) => void;
|
||||
}) {
|
||||
@@ -60,6 +62,12 @@ export function Composer({
|
||||
const [files, setFiles] = useState<{ name: string; path: string; size: number }[]>([]);
|
||||
const [uploading, setUploading] = useState(0);
|
||||
const [active, setActive] = useState(0);
|
||||
// Reasoning effort for the next message only ("" = model default).
|
||||
const [effort, setEffort] = useState("");
|
||||
const [interim, setInterim] = useState("");
|
||||
const models = useModels();
|
||||
const [prov, ...mid] = session.model.split("/");
|
||||
const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning;
|
||||
// @file mentions: token before the caret and matching files.
|
||||
const [mention, setMention] = useState<{ start: number; end: number; q: string }>();
|
||||
const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]);
|
||||
@@ -130,7 +138,8 @@ export function Composer({
|
||||
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
|
||||
// Files are referenced by path; the agent reads them with its tools.
|
||||
const refs = files.length ? `\n\n${t("composer.attachedFiles")}\n${files.map((f) => `- ${f.path} (${formatSize(f.size)})`).join("\n")}` : "";
|
||||
onSend(`${text}${refs}`, images);
|
||||
onSend(`${text}${refs}`, images, effort);
|
||||
setEffort("");
|
||||
setImages([]);
|
||||
setFiles([]);
|
||||
};
|
||||
@@ -258,7 +267,7 @@ export function Composer({
|
||||
ref={ref}
|
||||
rows={1}
|
||||
value={text}
|
||||
placeholder={busy ? t("composer.placeholderBusy") : t("composer.placeholder")}
|
||||
placeholder={interim ? `${interim} …` : busy ? t("composer.placeholderBusy") : t("composer.placeholder")}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
setActive(0);
|
||||
@@ -312,6 +321,8 @@ export function Composer({
|
||||
<option value="bypass">{t("permissionMode.bypass")}</option>
|
||||
</select>
|
||||
<ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} />
|
||||
{reasoning && <EffortSelect value={effort} onChange={setEffort} compact />}
|
||||
<VoiceButton onText={(d) => setText(text ? `${text.replace(/\s+$/, "")} ${d}` : d)} onInterim={setInterim} />
|
||||
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
|
||||
<Icon name="clip" />
|
||||
<input type="file" multiple hidden onChange={(e) => e.target.files && (addFiles(e.target.files), (e.target.value = ""))} />
|
||||
|
||||
@@ -17,3 +17,43 @@ export function Diff({ diff }: { diff: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Split a unified diff into its file header and hunks (at "@@" lines). */
|
||||
export function splitHunks(diff: string): { header: string[]; hunks: string[][] } {
|
||||
const lines = diff.replace(/\n+$/, "").split("\n");
|
||||
const header: string[] = [];
|
||||
const hunks: string[][] = [];
|
||||
for (const l of lines) {
|
||||
if (l.startsWith("@@")) hunks.push([l]);
|
||||
else if (hunks.length) hunks[hunks.length - 1].push(l);
|
||||
else header.push(l);
|
||||
}
|
||||
return { header, hunks };
|
||||
}
|
||||
|
||||
/** Diff with a checkbox per hunk (review a change piece by piece). */
|
||||
export function HunkDiff({ diff, selected, onToggle }: { diff: string; selected: boolean[]; onToggle: (i: number) => void }) {
|
||||
const { header, hunks } = splitHunks(diff);
|
||||
return (
|
||||
<div className="diff hunk-diff">
|
||||
{header.map((l, i) => (
|
||||
<div key={`h${i}`} className="meta">
|
||||
{l}
|
||||
</div>
|
||||
))}
|
||||
{hunks.map((h, i) => (
|
||||
<div key={i} className={`hunk-block ${selected[i] ? "" : "off"}`}>
|
||||
<label className="hunk-head">
|
||||
<input type="checkbox" checked={!!selected[i]} onChange={() => onToggle(i)} />
|
||||
<span>{h[0]}</span>
|
||||
</label>
|
||||
{h.slice(1).map((l, j) => (
|
||||
<div key={j} className={l.startsWith("+") ? "add" : l.startsWith("-") ? "del" : ""}>
|
||||
{l || " "}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -268,6 +268,15 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="crumbs">
|
||||
<button
|
||||
className="small ghost crumbs-up"
|
||||
disabled={!listing?.parent}
|
||||
onClick={() => listing?.parent && void confirmDiscard().then((ok): unknown => ok && load(listing.parent!))}
|
||||
title={t("files.up")}
|
||||
aria-label={t("files.up")}
|
||||
>
|
||||
<Icon name="folder-up" />
|
||||
</button>
|
||||
{crumbs.map((c, i) => {
|
||||
const path = i === 0 ? c : `${crumbs[0]}/${crumbs.slice(1, i + 1).join("/")}`;
|
||||
return (
|
||||
|
||||
@@ -193,6 +193,12 @@ const P: Record<string, ReactNode> = {
|
||||
),
|
||||
dot: <circle cx="12" cy="12" r="4.5" className="fill" />,
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
mic: (
|
||||
<>
|
||||
<rect x="9" y="3" width="6" height="11" rx="3" />
|
||||
<path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21M9 21h6" />
|
||||
</>
|
||||
),
|
||||
clock: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
|
||||
@@ -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<boolean[]>(() => 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
|
||||
<SubagentHint sessionId={req.session_id} currentId={currentId} />
|
||||
<div style={{ margin: "6px 0" }}>{req.title}</div>
|
||||
{command && <pre className="mono" style={{ whiteSpace: "pre-wrap", margin: "6px 0" }}>$ {command}</pre>}
|
||||
{req.diff ? (
|
||||
{req.diff && hunkCount > 1 ? (
|
||||
<>
|
||||
<div className="hint">{t("permission.hunksHint", { n: hunkCount })}</div>
|
||||
<HunkDiff diff={req.diff} selected={selected} onToggle={(i) => setSelected((s) => s.map((v, j) => (j === i ? !v : v)))} />
|
||||
</>
|
||||
) : req.diff ? (
|
||||
<Diff diff={req.diff} />
|
||||
) : (
|
||||
!command && (
|
||||
@@ -69,10 +80,10 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
|
||||
onKeyDown={(e) => e.key === "Enter" && reply("reject")}
|
||||
/>
|
||||
<div className="actions">
|
||||
<button className="primary" disabled={busy} onClick={() => reply("once")} autoFocus>
|
||||
{t("permission.allowOnce")}
|
||||
<button className="primary" disabled={busy || (hunkCount > 1 && chosen === 0)} onClick={() => reply("once")} autoFocus>
|
||||
{partial ? t("permission.applySelected", { k: chosen, n: hunkCount }) : t("permission.allowOnce")}
|
||||
</button>
|
||||
<button disabled={busy} onClick={() => reply("always")}>
|
||||
<button disabled={busy || partial} onClick={() => reply("always")}>
|
||||
{alwaysLabel}
|
||||
</button>
|
||||
<button className="danger" disabled={busy} onClick={() => reply("reject")}>
|
||||
|
||||
@@ -169,6 +169,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
// Folder: a new one (name) inside a root, or an existing one (picked).
|
||||
const [folderMode, setFolderMode] = useState<"new" | "existing" | "git">("new");
|
||||
const [repoUrl, setRepoUrl] = useState("");
|
||||
const [isolated, setIsolated] = useState(false);
|
||||
// Original software (zip, tar.gz, installer …) for the recreate workflow, uploaded in chunks.
|
||||
const [originals, setOriginals] = useState<File[]>([]);
|
||||
const [progress, setProgress] = useState<Record<string, number>>({});
|
||||
@@ -323,6 +324,10 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
agent: task === "cad" ? "build" : task,
|
||||
clean_room: isCraft ? !deep : undefined,
|
||||
});
|
||||
if (isolated && folderMode !== "new") {
|
||||
setCreating(t("worktree.creating"));
|
||||
await post(`/sessions/${s.id}/worktree`, { action: "create" });
|
||||
}
|
||||
const msgs = prompts(uploaded).filter((m) => m.trim());
|
||||
for (const [i, m] of msgs.entries()) await post(`/sessions/${s.id}/prompt`, { text: m, images: i === 0 ? images : [] });
|
||||
onCreated(s.id);
|
||||
@@ -547,6 +552,15 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
</div>
|
||||
)}
|
||||
{folderMode === "existing" && <FolderPicker path={picked} onPick={setPicked} />}
|
||||
{folderMode !== "new" && (
|
||||
<label className="wiz-check-row">
|
||||
<input type="checkbox" checked={isolated} onChange={(e) => setIsolated(e.target.checked)} />
|
||||
<span>
|
||||
<b>{t("worktree.isolate")}</b>
|
||||
<span className="hint"> – {t("worktree.isolateHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
<div className="wiz-start-row">
|
||||
<button className="primary wiz-start" disabled={!canStart} onClick={create}>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from "react";
|
||||
import { post } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import { toast } from "../dialogs";
|
||||
import { Icon } from "./Icon";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
|
||||
export const EFFORTS = ["low", "medium", "high", "max"] as const;
|
||||
|
||||
/** Effort choice for one turn ("" = the model's default). */
|
||||
export function EffortSelect({ value, onChange, compact }: { value: string; onChange: (v: string) => void; compact?: boolean }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<select className={compact ? "effort-select" : ""} value={value} onChange={(e) => onChange(e.target.value)} title={t("effort.title")} aria-label={t("effort.title")}>
|
||||
<option value="">{compact ? t("effort.autoShort") : t("effort.auto")}</option>
|
||||
{EFFORTS.map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{t(`effort.${e}` as any)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
/** Redo the last turn, optionally with another model and effort. */
|
||||
export function RegenerateButton({ sessionId, messageId, model }: { sessionId: string; messageId: string; model: string }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [m, setM] = useState(model);
|
||||
const [effort, setEffort] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const go = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await post(`/sessions/${sessionId}/regenerate`, { message_id: messageId, model: m !== model ? m : undefined, effort: effort || undefined });
|
||||
setOpen(false);
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="regen">
|
||||
<button className="small ghost" onClick={() => setOpen(!open)} title={t("regen.title")}>
|
||||
<Icon name="refresh" /> {t("regen.button")}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="regen-panel">
|
||||
<div className="hint">{t("regen.hint")}</div>
|
||||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||||
<ModelSelect value={m} onChange={setM} />
|
||||
<EffortSelect value={effort} onChange={setEffort} />
|
||||
<button className="small primary" disabled={busy} onClick={go}>
|
||||
<Icon name="refresh" /> {t("regen.go")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = { 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<Recognition | null>(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 (
|
||||
<button type="button" className={`ghost small voice-btn ${listening ? "on" : ""}`} onClick={toggle} title={t(listening ? "voice.stop" : "voice.start")} aria-label={t(listening ? "voice.stop" : "voice.start")} aria-pressed={listening}>
|
||||
<Icon name="mic" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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<WtStatus>();
|
||||
const [working, setWorking] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
get<WtStatus>(`/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<WtStatus>(`/sessions/${session.id}/worktree`));
|
||||
else setOpen(false);
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setWorking(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="wt-wrap">
|
||||
<button className="chip wt-chip" onClick={() => setOpen(!open)} title={t("worktree.title")}>
|
||||
<Icon name="fork" /> {wt.branch}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="menu-pop-body wt-panel">
|
||||
<div className="hint">{t("worktree.hint", { base: wt.base })}</div>
|
||||
{!status ? (
|
||||
<Spinner small />
|
||||
) : (
|
||||
<>
|
||||
<div className="wt-stats">
|
||||
<span>{t("worktree.dirty", { n: status.dirty_files ?? 0 })}</span>
|
||||
<span>{t("worktree.commits", { n: status.commits?.length ?? 0 })}</span>
|
||||
{status.diffstat && <span className="mono">{status.diffstat}</span>}
|
||||
</div>
|
||||
{!!status.commits?.length && (
|
||||
<pre className="wt-log">{status.commits.slice(0, 8).join("\n")}</pre>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="row" style={{ gap: 6, marginTop: 8 }}>
|
||||
<button className="small primary" disabled={busy || !!working} onClick={() => act("merge")}>
|
||||
{working === "merge" ? <Spinner small /> : <Icon name="check" />} {t("worktree.merge", { base: wt.base })}
|
||||
</button>
|
||||
<button className="small danger" disabled={busy || !!working} onClick={() => act("remove")}>
|
||||
<Icon name="trash" /> {t("worktree.remove")}
|
||||
</button>
|
||||
</div>
|
||||
{busy && <div className="hint">{t("worktree.busy")}</div>}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user