From 62fe5bf4672bf98021a80cfb343bae7ea44bb3f9 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Sat, 14 Nov 2026 22:30:12 +0100 Subject: [PATCH] Claude Code engine, image viewer, colour tags, checks per turn and more - Claude Code as an engine: chats on a "claude-code/" model run through the claude CLI (stream-json); tool approvals go through our permission system. Sign in from the web UI (URL + pasted code), CLI version and update from Settings; the Docker image contains the CLI. - Small models (8k context) for side tasks: input is cut to the model's context window; one-line chat status, "What does this do?" on approvals, "Plan as automation" (job draft from a chat). - Mobile: top bar no longer widens the page, image viewer (zoom, swipe, mark up, use in message), jump to previous own message, message menu, time under every message. - Approvals: push actions (allow/reject), "always allow in this project" (never for dangerous commands) with revoke. - Per turn: changed files with diffs and rollback; project check command with a green/red badge and "let the agent fix it". - Colour tags for chats in the colours of the active theme. - Project defaults for new chats, project page (timeline), offline queue. Tested in a throwaway container with a mock model (8k small model): 26 API checks passed (permissions, diffs, checks, defaults, job draft, side-task budget, Claude Code status/login URL, unsigned-in chat). Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 28 + Dockerfile | 2 +- core/src/types.rs | 14 +- server/src/api.rs | 266 ++++++- server/src/app.rs | 2 + server/src/checks.rs | 87 ++ server/src/claude_code.rs | 883 +++++++++++++++++++++ server/src/jobs.rs | 2 + server/src/main.rs | 2 + server/src/provider/mod.rs | 12 + server/src/push.rs | 8 +- server/src/runner.rs | 179 ++++- server/src/store.rs | 144 +++- server/src/telegram.rs | 2 + web/public/push-sw.mjs | 35 +- web/src/App.tsx | 4 + web/src/components/ChatView.tsx | 189 ++++- web/src/components/ColorSwatches.tsx | 29 + web/src/components/Composer.tsx | 22 + web/src/components/Icon.tsx | 1 + web/src/components/ImageAnnotator.tsx | 160 ++++ web/src/components/ImageViewer.tsx | 242 ++++++ web/src/components/Interaction.tsx | 40 +- web/src/components/MessageMenu.tsx | 75 ++ web/src/components/NewSession.tsx | 3 +- web/src/components/OutboxBar.tsx | 39 + web/src/components/ProjectPage.tsx | 128 +++ web/src/components/ProjectPermissions.tsx | 55 ++ web/src/components/Sidebar.tsx | 14 +- web/src/components/TurnChanges.tsx | 85 ++ web/src/components/TurnCheck.tsx | 43 + web/src/components/settings/Account.tsx | 2 + web/src/components/settings/ClaudeCode.tsx | 191 +++++ web/src/compose.ts | 20 + web/src/i18n/de.ts | 102 +++ web/src/i18n/en.ts | 102 +++ web/src/i18n/index.ts | 7 + web/src/outbox.ts | 85 ++ web/src/store.ts | 15 + web/src/styles.css | 445 ++++++++++- web/src/types.ts | 15 + web/src/whatsnew.ts | 10 + 42 files changed, 3734 insertions(+), 55 deletions(-) create mode 100644 server/src/checks.rs create mode 100644 server/src/claude_code.rs create mode 100644 web/src/components/ColorSwatches.tsx create mode 100644 web/src/components/ImageAnnotator.tsx create mode 100644 web/src/components/ImageViewer.tsx create mode 100644 web/src/components/MessageMenu.tsx create mode 100644 web/src/components/OutboxBar.tsx create mode 100644 web/src/components/ProjectPage.tsx create mode 100644 web/src/components/ProjectPermissions.tsx create mode 100644 web/src/components/TurnChanges.tsx create mode 100644 web/src/components/TurnCheck.tsx create mode 100644 web/src/components/settings/ClaudeCode.tsx create mode 100644 web/src/compose.ts create mode 100644 web/src/outbox.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a4cecfd..ca76cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,34 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **Claude Code as an engine:** chats with a "Claude Code" model (Opus, Sonnet, Haiku, default) are + run by the `claude` CLI. Its events become normal chat messages; its tool approvals go through + CodeCraft's permission system (cards, push buttons, "allow for this project", diffs, sandbox). + Sign in from the web UI (Settings → Account → Claude Code: open the sign-in URL, paste the code), + see the CLI version and install updates (admins; installed into the persistent home). The Docker + image now contains the CLI. +- **Small models (8k context) for side tasks:** `small_model` jobs cut their input to the model's + context window. New with it: a one-line status per chat in the session list, "What does this + do?" on approvals, and "Plan as automation" (drafts job instructions from a chat). +- **Mobile:** the top bar no longer pushes the page sideways (extra actions moved into the ⋯ menu); + an image viewer for all images (zoom, swipe between images, mark up and attach to the message, + use in the message); jump to the previous own message; message menu (copy, quote, fork from + here, edit and resend); date and time under every message. +- **Approvals:** push notifications can allow or reject right away; "always allow in this project" + is remembered per folder (never for dangerous commands) and can be revoked in the ⋯ menu. +- **Changes per turn:** files changed by a turn with diffs and "roll back to before this turn". +- **Check after each turn:** a command per project (e.g. `cargo test`) runs after turns that + changed files; green/red badge with output and "let the agent fix it". +- **Colour tags for chats:** tinted chat background and a badge in the list, drawn in the colours + of the active theme. +- **Project defaults, project page, offline queue:** new chats in a folder inherit model, mode, + colour, instructions and folder; a timeline of a project's chats (sidebar → project → ⋯); + messages written without a connection are sent when it is back. + ## [v202611-00025] – 2026-11-12 ### Fixed diff --git a/Dockerfile b/Dockerfile index cf20d16..5e5b300 100644 --- a/Dockerfile +++ b/Dockerfile @@ -89,7 +89,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && 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 …). -RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force +RUN npm install -g typescript@5 typescript-language-server pyright @anthropic-ai/claude-code && npm cache clean --force COPY --from=server /codecraft /usr/local/bin/codecraft COPY --from=web /web/dist /app/web COPY --from=cli /out /app/cli diff --git a/core/src/types.rs b/core/src/types.rs index 8be3794..46866b8 100644 --- a/core/src/types.rs +++ b/core/src/types.rs @@ -187,8 +187,17 @@ pub struct Session { /// unless it is the chat currently open in Telegram. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub telegram_quiet: bool, + /// One-line "where things stand", written by the small model after a turn (session list). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub summary: String, + /// Colour tag of the chat (a palette name, see `SESSION_COLORS`); the client maps it to the theme. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, } +/// Names of the colour tags: the client draws them with the colours of the active theme. +pub const SESSION_COLORS: &[&str] = &["red", "orange", "yellow", "green", "aqua", "blue", "purple"]; + impl Session { pub fn clean_room(&self, default: bool) -> bool { self.clean_room.unwrap_or(default) @@ -243,6 +252,8 @@ pub enum Event { /// Compaction of the history: state "started", "done" or "failed"; auto = triggered by /// a nearly full context (tokens of limit). Compaction { session_id: String, state: String, auto: bool, tokens: Option, limit: Option }, + /// The project's check command ran after a turn: status "running" | "passed" | "failed" | "timeout". + CheckResult { session_id: String, message_id: String, status: String, exit_code: Option, duration_ms: u64, output: String }, } impl Event { @@ -265,7 +276,8 @@ impl Event { | Waiting { session_id, .. } | QueueUpdated { session_id, .. } | StepStarted { session_id, .. } - | Compaction { session_id, .. } => Some(session_id), + | Compaction { session_id, .. } + | CheckResult { session_id, .. } => Some(session_id), McpStatus { .. } => None, } } diff --git a/server/src/api.rs b/server/src/api.rs index 4240a23..91fbe10 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -89,6 +89,12 @@ pub fn router(app: AppState) -> Router { .route("/sessions/{id}/abort", post(abort)) .route("/sessions/{id}/compact", post(compact)) .route("/sessions/{id}/rewind", post(rewind)) + .route("/sessions/{id}/job-draft", post(job_draft)) + .route("/projects/overview", get(project_overview)) + .route("/sessions/{id}/project-defaults", get(get_project_defaults).put(put_project_defaults).delete(delete_project_defaults)) + .route("/sessions/{id}/check", get(get_check).put(put_check)) + .route("/sessions/{id}/checks", get(list_checks)) + .route("/sessions/{id}/check/run", post(run_check)) .route("/sessions/{id}/changes", get(changed_files)) .route("/sessions/{id}/shells", get(list_shells)) .route("/sessions/{id}/shells/{shell}/kill", post(kill_shell)) @@ -98,6 +104,13 @@ pub fn router(app: AppState) -> Router { .route("/sessions/{id}/queue/flush", post(flush_queue)) .route("/sessions/{id}/queue/{item}", delete(delete_queue_item)) .route("/permissions/{id}", post(reply_permission)) + .route("/permissions/{id}/explain", post(explain_permission)) + .route("/claude/status", get(claude_status)) + .route("/claude/login", post(claude_login_start).delete(claude_login_cancel)) + .route("/claude/login/code", post(claude_login_code)) + .route("/claude/logout", post(claude_logout)) + .route("/claude/update", post(claude_update)) + .route("/sessions/{id}/approvals", get(project_approvals).delete(delete_project_approval)) .route("/questions/{id}", post(answer_question)) .route("/pending", get(pending)) .route("/mcp", get(mcp_status)) @@ -595,13 +608,17 @@ fn validate_cwd(app: &App, user: &User, cwd: Option) -> Result, State(app): State, Json(body): Json) -> ApiResult { let cwd = validate_cwd(&app, &user, body.cwd)?; let cfg = app.config(); + // What the project's chats start with (⋯ menu "use as project default"). + let defaults = app.store.project_defaults(&cwd.display().to_string()).ok().flatten().unwrap_or(Value::Null); + let dstr = |k: &str| defaults[k].as_str().filter(|v| !v.is_empty()).map(String::from); let model = body .model .filter(|m| !m.is_empty()) + .or_else(|| dstr("model").filter(|m| app.registry().resolve(m).is_ok())) .or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty())) .or_else(|| app.registry().first_model()) .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?; - let mode = body.permission_mode.unwrap_or_else(|| "auto".into()); + let mode = body.permission_mode.or_else(|| dstr("permission_mode")).unwrap_or_else(|| "auto".into()); if !crate::permission::MODES.contains(&mode.as_str()) { return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into())); } @@ -624,10 +641,12 @@ async fn create_session(Extension(user): Extension, State(app): State, /// Approvals and questions of this chat are not sent to Telegram. telegram_quiet: Option, + /// Colour tag ("" removes it). + color: Option, } async fn update_session(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { @@ -933,6 +954,9 @@ async fn update_session(Extension(user): Extension, State(app): State, State(app): State, State(app): State, } /// Files the agent changed since a message (for the rewind dialog). async fn changed_files(Extension(user): Extension, State(app): State, Path(id): Path, Query(q): Query) -> ApiResult { readable(&app, &user, &id)?; let since = app.store.message_created_at(&id, &q.since_message)?.unwrap_or(i64::MAX); - let files: Vec = app.store.checkpoints_since(&id, since)?.into_iter().map(|(p, c)| json!({"path": p, "created": c.is_none()})).collect(); + let until = match &q.until_message { + Some(m) => app.store.message_created_at(&id, m)?.unwrap_or(i64::MAX), + None => i64::MAX, + }; + let files: Vec = app + .store + .checkpoints_between(&id, since, until)? + .into_iter() + .map(|(p, c)| { + let mut v = json!({"path": p, "created": c.is_none()}); + if q.diff { + // Checkpoint (state before the agent's first change) against the file now. + let old = c.map(|b| String::from_utf8_lossy(&b).into_owned()).unwrap_or_default(); + let new = std::fs::read_to_string(&p); + v["deleted"] = json!(new.is_err()); + let new = new.unwrap_or_default(); + let mut diff = similar::TextDiff::from_lines(&old, &new).unified_diff().context_radius(3).header(&p, &p).to_string(); + if diff.len() > 60_000 { + let mut end = 60_000; + while !diff.is_char_boundary(end) { + end -= 1; + } + diff.truncate(end); + v["truncated"] = json!(true); + } + v["diff"] = json!(diff); + } + v + }) + .collect(); Ok(Json(json!(files))) } @@ -1291,6 +1352,201 @@ async fn reply_permission(Extension(user): Extension, State(app): State, State(app): State, Path(id): Path) -> ApiResult { + let s = writable(&app, &user, &id)?; + let (name, prompt) = runner::draft_job(&app, &s).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?; + Ok(Json(json!({"name": name, "prompt": prompt, "cwd": s.cwd, "agent": s.agent}))) +} + +// --- Claude Code: sign-in from the web UI --------------------------------------------------- + +#[derive(Deserialize, Default)] +struct ClaudeStatusQuery { + #[serde(default)] + refresh: bool, +} + +async fn claude_status(Extension(user): Extension, State(app): State, Query(q): Query) -> Json { + if q.refresh { + crate::claude_code::refresh_latest().await; + } + Json(crate::claude_code::status(&app, &user.id).await) +} + +#[derive(Deserialize, Default)] +struct ClaudeLoginBody { + /// Anthropic Console (API billing) instead of the Claude subscription. + #[serde(default)] + console: bool, +} + +async fn claude_login_start(Extension(user): Extension, State(app): State, body: Option>) -> ApiResult { + let console = body.map(|b| b.0.console).unwrap_or(false); + let url = crate::claude_code::login_start(&app, &user.id, console).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?; + app.audit(Some(&user), "claude.login_start", "", ""); + Ok(Json(json!({"url": url}))) +} + +#[derive(Deserialize)] +struct ClaudeCodeBody { + code: String, +} + +async fn claude_login_code(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult { + crate::claude_code::login_code(&user.id, &b.code).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?; + app.audit(Some(&user), "claude.login", "", ""); + // New models may have appeared (the CLI is signed in now): clients reload them. + Ok(Json(crate::claude_code::status(&app, &user.id).await)) +} + +async fn claude_login_cancel(Extension(user): Extension) -> Json { + crate::claude_code::login_cancel(&user.id); + Json(json!({"ok": true})) +} + +/// Install the latest Claude Code CLI on the server (admins: it is shared by all accounts). +async fn claude_update(Extension(user): Extension, State(app): State) -> ApiResult { + require_admin(&user)?; + let output = crate::claude_code::update().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?; + app.audit(Some(&user), "claude.update", "", ""); + let mut v = crate::claude_code::status(&app, &user.id).await; + v["output"] = json!(output); + Ok(Json(v)) +} + +async fn claude_logout(Extension(user): Extension, State(app): State) -> ApiResult { + crate::claude_code::logout(&app, &user.id).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?; + app.audit(Some(&user), "claude.logout", "", ""); + Ok(Json(crate::claude_code::status(&app, &user.id).await)) +} + +// --- project page --------------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct OverviewQuery { + cwd: String, +} + +/// Chats of a project folder with what they did (files, checks, cost) plus the folder's settings. +async fn project_overview(Extension(user): Extension, State(app): State, Query(q): Query) -> ApiResult { + let filter = if user.is_admin() { None } else { Some(user.id.as_str()) }; + let sessions: Vec = app.store.list_sessions(filter)?.into_iter().filter(|s| s.cwd == q.cwd).collect(); + let ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); + let check = app.store.project_check(&q.cwd)?; + Ok(Json(json!({ + "sessions": app.store.project_overview(&ids)?, + "check": check.map(|c| json!({"command": c.0, "enabled": c.1})), + "defaults": app.store.project_defaults(&q.cwd)?, + "currency": app.config().server.currency, + }))) +} + +// --- defaults of new chats per project --------------------------------------------------------- + +async fn get_project_defaults(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + let s = readable(&app, &user, &id)?; + Ok(Json(app.store.project_defaults(&s.cwd)?.unwrap_or(Value::Null))) +} + +/// Remember this chat's colour, model, mode, instructions and folder for new chats in the project folder. +async fn put_project_defaults(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + let s = writable(&app, &user, &id)?; + let data = json!({ + "model": s.model, "agent": s.agent, "permission_mode": s.permission_mode, + "color": s.color, "instructions": s.instructions, "folder": s.folder, + }); + app.store.set_project_defaults(&s.cwd, Some(&data))?; + Ok(Json(data)) +} + +async fn delete_project_defaults(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + let s = writable(&app, &user, &id)?; + app.store.set_project_defaults(&s.cwd, None)?; + Ok(Json(json!({"ok": true}))) +} + +// --- check command per project ------------------------------------------------------------------ + +async fn get_check(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + let s = readable(&app, &user, &id)?; + let c = app.store.project_check(&s.cwd)?; + Ok(Json(json!({"command": c.as_ref().map(|c| c.0.clone()).unwrap_or_default(), "enabled": c.map(|c| c.1).unwrap_or(false)}))) +} + +#[derive(Deserialize)] +struct CheckBody { + command: String, + #[serde(default = "yes")] + enabled: bool, +} + +fn yes() -> bool { + true +} + +async fn put_check(Extension(user): Extension, State(app): State, Path(id): Path, Json(b): Json) -> ApiResult { + let s = writable(&app, &user, &id)?; + if b.command.len() > 2000 { + return Err(ApiError(StatusCode::BAD_REQUEST, "command too long".into())); + } + app.store.set_project_check(&s.cwd, &b.command, b.enabled)?; + app.audit(Some(&user), "project.check", &s.cwd, &b.command); + Ok(Json(json!({"ok": true}))) +} + +async fn list_checks(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + readable(&app, &user, &id)?; + Ok(Json(json!(app.store.turn_checks(&id)?))) +} + +/// Run the check now for the latest answer. +async fn run_check(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + let s = writable(&app, &user, &id)?; + let last = app.store.messages(&id)?.into_iter().rev().find(|m| m.role == crate::types::Role::Assistant).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no answer yet".into()))?; + let app2 = app.clone(); + tokio::spawn(async move { + let _ = crate::checks::run(&app2, &s, &last.id).await; + }); + Ok(Json(json!({"ok": true}))) +} + +#[derive(Deserialize, Default)] +struct ExplainBody { + lang: Option, +} + +/// Plain-words explanation of a pending approval (small model). +async fn explain_permission(Extension(user): Extension, State(app): State, Path(id): Path, body: Option>) -> ApiResult { + check_pending(&app, &user, &id)?; + let ev = app.pending_events().into_iter().find(|e| matches!(e, Event::PermissionRequest { id: i, .. } if *i == id)).ok_or_else(|| not_found("request"))?; + let Event::PermissionRequest { session_id, tool, title, input, diff, .. } = ev else { return Err(not_found("request")) }; + let session = app.session(&session_id)?; + let lang = body.and_then(|b| b.0.lang).unwrap_or_default(); + let text = crate::runner::explain_tool_call(&app, &session, &tool, &title, &input, diff.as_deref(), &lang) + .await + .map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?; + Ok(Json(json!({"text": text}))) +} + +/// What is allowed "for this project" (remembered across chats). +async fn project_approvals(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + let s = readable(&app, &user, &id)?; + Ok(Json(json!(app.store.project_approvals(&s.cwd)?))) +} + +#[derive(Deserialize)] +struct ApprovalKey { + key: String, +} + +async fn delete_project_approval(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { + let s = writable(&app, &user, &id)?; + app.store.delete_project_approval(&s.cwd, &body.key)?; + app.audit(Some(&user), "permission.project_revoked", body.key, ""); + Ok(Json(json!({"ok": true}))) +} + #[derive(Deserialize)] struct QuestionBody { /// question text → answer (string or array of strings); null = dismissed @@ -1887,6 +2143,8 @@ async fn job_assistant(Extension(user): Extension, State(app): State String { + if s.len() <= max { + return s.to_string(); + } + let mut start = s.len() - max; + while !s.is_char_boundary(start) { + start += 1; + } + format!("…\n{}", &s[start..]) +} + +fn command(app: &App, session: &Session, script: &str) -> Result { + let script = format!("exec 2>&1\n{script}"); + let cwd = std::path::Path::new(&session.cwd); + if let Some(spec) = crate::sandbox::spec_for(app, session.owner_id.as_deref()) { + if !crate::sandbox::available() { + bail!("the sandbox (bubblewrap) is not available"); + } + return Ok(crate::sandbox::command(&spec, cwd, "bash", &["-c", &script])); + } + let mut c = tokio::process::Command::new("bash"); + c.arg("-c").arg(&script).current_dir(cwd).env("CODECRAFT", "1").env("CI", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat"); + Ok(c) +} + +/// Run the project's check for the turn that ended with `message_id` and publish the result. +pub async fn run(app: &Arc, session: &Session, message_id: &str) -> Result<()> { + let (cmd, _) = app.store.project_check(&session.cwd)?.ok_or_else(|| anyhow!("no check command set for this project"))?; + let sid = session.id.clone(); + let emit = |status: &str, code: Option, ms: u64, out: &str| { + let _ = app.store.set_turn_check(&sid, message_id, status, code, ms, out); + app.emit(Event::CheckResult { session_id: sid.clone(), message_id: message_id.into(), status: status.into(), exit_code: code, duration_ms: ms, output: out.into() }); + }; + emit("running", None, 0, ""); + let started = now_ms(); + let mut c = match command(app, session, &cmd) { + Ok(c) => c, + Err(e) => { + emit("failed", None, 0, &format!("{e:#}")); + return Ok(()); + } + }; + let res = tokio::time::timeout(TIMEOUT, c.stdin(Stdio::null()).kill_on_drop(true).output()).await; + let ms = (now_ms() - started).max(0) as u64; + match res { + Err(_) => emit("timeout", None, ms, &format!("Stopped after {} minutes.", TIMEOUT.as_secs() / 60)), + Ok(Err(e)) => emit("failed", None, ms, &format!("could not run the command: {e}")), + Ok(Ok(o)) => { + let mut text = String::from_utf8_lossy(&o.stdout).to_string(); + text.push_str(&String::from_utf8_lossy(&o.stderr)); + emit(if o.status.success() { "passed" } else { "failed" }, o.status.code(), ms, &tail(text.trim(), MAX_OUTPUT)); + } + } + Ok(()) +} + +/// After a turn: run the check if the project has one and the turn changed files. +pub fn spawn_after_turn(app: Arc, session_id: String, turn_started: i64) { + tokio::spawn(async move { + let Ok(session) = app.session(&session_id) else { return }; + if session.parent_id.is_some() { + return; + } + let Ok(Some((_, true))) = app.store.project_check(&session.cwd) else { return }; + if app.store.checkpoints_since(&session_id, turn_started).map(|c| c.is_empty()).unwrap_or(true) { + return; + } + let Some(last) = app.store.messages(&session_id).ok().and_then(|m| m.into_iter().rev().find(|m| m.role == Role::Assistant)) else { return }; + if let Err(e) = run(&app, &session, &last.id).await { + tracing::debug!("check failed to start: {e:#}"); + } + }); +} diff --git a/server/src/claude_code.rs b/server/src/claude_code.rs new file mode 100644 index 0000000..d69cdb9 --- /dev/null +++ b/server/src/claude_code.rs @@ -0,0 +1,883 @@ +//! Claude Code as an engine: a chat whose model is "claude-code/" is not run by our own +//! agent loop but by the `claude` CLI (`-p`, stream-json). Its events become normal chat +//! messages; its tool approvals (`can_use_tool`) go through our permission system, so cards, +//! push actions, "allow for this project" and the sandbox work as for the native agent. +//! +//! Login is done in the web UI: `claude auth login` runs in a PTY, the sign-in URL is shown +//! and the code the Anthropic page displays is pasted back (see the `login_*` functions). + +use crate::app::{App, PermissionReply}; +use crate::config::ModelConfig; +use crate::permission::{Decision, Evaluator, ToolInfo, bash_prefix, is_dangerous, split_command}; +use crate::provider::{ChatRequest, Provider, StreamEvent}; +use crate::store::UsageRecord; +use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms}; +use anyhow::{Result, anyhow, bail}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +pub const PROVIDER: &str = "claude-code"; + +/// The `claude` binary of the server user (outside the sandbox); None = not installed. +/// Where updates from the web UI are installed (npm prefix in the persistent home); it is first in +/// `binary()`'s search and is made readable inside the sandbox, so one update serves all users. +fn managed_dir() -> Option { + std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".npm-global")) +} + +fn managed_binary() -> Option { + managed_dir().map(|d| d.join("bin/claude")).filter(|p| p.is_file()) +} + +pub fn binary() -> Option { + if let Some(m) = managed_binary() { + return Some(m); + } + let mut dirs: Vec = std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect(); + if let Some(h) = std::env::var_os("HOME") { + dirs.push(PathBuf::from(&h).join(".local/bin")); + dirs.push(PathBuf::from(&h).join(".npm-global/bin")); + } + dirs.into_iter().map(|d| d.join("claude")).find(|p| p.is_file()) +} + +/// Registered like a provider so the model picker, validation and statistics need no special cases; +/// requests never reach it (the runner hands such chats to `run`). +pub struct Marker; + +#[async_trait] +impl Provider for Marker { + async fn stream(&self, _req: ChatRequest, _tx: mpsc::Sender, _cancel: CancellationToken) -> Result<()> { + bail!("this model runs through Claude Code and cannot be used for background tasks – set a small_model in the configuration") + } + async fn list_models(&self) -> Result> { + Ok(models()) + } +} + +pub fn models() -> Vec { + [("default", "Claude Code (default model)"), ("opus", "Claude Code · Opus"), ("sonnet", "Claude Code · Sonnet"), ("haiku", "Claude Code · Haiku")] + .into_iter() + .map(|(id, name)| ModelConfig { id: id.into(), name: Some(name.into()), vision: true, reasoning: true, ..Default::default() }) + .collect() +} + +pub fn is_claude_code(model_spec: &str) -> bool { + model_spec.starts_with("claude-code/") +} + +/// A command running `claude` for this user: in the user's sandbox, or directly with a minimal +/// environment (no server secrets; the CLI uses its own sign-in from HOME). +fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Result { + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + if let Some(spec) = crate::sandbox::spec_for(app, owner) { + if !crate::sandbox::available() { + bail!("Claude Code is disabled for this account: the sandbox (bubblewrap) is not available"); + } + // An update installed from the web UI lives outside the image: mounted read-only, called by path. + if let (Some(bin), Some(dir)) = (managed_binary(), managed_dir()) { + let program = bin.display().to_string(); + return Ok(crate::sandbox::command_with(&spec, cwd, &[dir.as_path()], &program, &refs)); + } + return Ok(crate::sandbox::command(&spec, cwd, "claude", &refs)); + } + let bin = binary().ok_or_else(|| anyhow!("Claude Code is not installed on the server (`claude` not found)"))?; + let mut c = tokio::process::Command::new(bin); + c.current_dir(cwd).env_clear(); + for k in ["PATH", "HOME", "LANG", "USER", "SHELL"] { + if let Some(v) = std::env::var_os(k) { + c.env(k, v); + } + } + c.env("TERM", "xterm-256color").env("CODECRAFT", "1").args(args); + Ok(c) +} + +// --- tool mapping ----------------------------------------------------------------------- + +/// Claude Code tool → (our tool name, input with our key names), so the chat renders it like ours. +fn map_tool(name: &str, input: &Value) -> (String, Value) { + let mut v = input.clone(); + let rename = |v: &mut Value, from: &str, to: &str| { + if let Some(x) = v.get(from).cloned() { + v[to] = x; + if let Some(o) = v.as_object_mut() { + o.remove(from); + } + } + }; + let ours = match name { + "Bash" => "bash", + "Read" => "read", + "Write" => "write", + "Edit" | "MultiEdit" | "NotebookEdit" => "edit", + "Glob" => "glob", + "Grep" => "grep", + "WebFetch" => "web_fetch", + "WebSearch" => "web_search", + "Task" | "Agent" => "task", + other => return (other.to_lowercase(), v), + }; + rename(&mut v, "file_path", "path"); + rename(&mut v, "notebook_path", "path"); + (ours.to_string(), v) +} + +struct CcTool { + name: String, +} + +impl ToolInfo for CcTool { + fn name(&self) -> &str { + &self.name + } + fn read_only(&self) -> bool { + matches!(self.name.as_str(), "read" | "glob" | "grep" | "web_fetch" | "web_search" | "task" | "todowrite" | "list") + } + fn is_edit(&self) -> bool { + matches!(self.name.as_str(), "write" | "edit") + } +} + +fn unified(path: &str, old: &str, new: &str) -> String { + similar::TextDiff::from_lines(old, new).unified_diff().context_radius(3).header(path, path).to_string() +} + +/// Diff of a pending write/edit (shown on the approval card and kept as the result's diff). +fn preview_diff(name: &str, input: &Value) -> Option { + let path = input["path"].as_str()?; + let old = std::fs::read_to_string(path).unwrap_or_default(); + match name { + "write" => Some(unified(path, &old, input["content"].as_str()?)), + "edit" => { + let (from, to) = (input["old_string"].as_str()?, input["new_string"].as_str()?); + let new = if input["replace_all"].as_bool() == Some(true) { old.replace(from, to) } else { old.replacen(from, to, 1) }; + Some(unified(path, &old, &new)) + } + _ => None, + } +} + +fn title_of(name: &str, input: &Value, claude_input: &Value) -> String { + match name { + "bash" => input["description"].as_str().filter(|d| !d.is_empty()).map(String::from).unwrap_or_else(|| input["command"].as_str().unwrap_or("").chars().take(120).collect()), + "write" | "edit" | "read" => input["path"].as_str().unwrap_or("").to_string(), + _ => claude_input["description"].as_str().map(String::from).unwrap_or_else(|| name.to_string()), + } +} + +/// Ask our permission system whether a tool call of Claude Code may run: (allow, message for a rejection). +async fn decide(app: &Arc, session: &Session, claude_name: &str, claude_input: &Value, cancel: &CancellationToken) -> (bool, Option) { + let (name, input) = map_tool(claude_name, claude_input); + if claude_name == "AskUserQuestion" { + return (false, Some("Questions to the user are not available in this mode – decide yourself or state your assumption.".into())); + } + let info = CcTool { name: name.clone() }; + let cfg = app.config(); + let root = app.root_session_id(&session.id); + let (mut decision, approved) = app.with_runtime(&root, |r| { + (Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(&info, &input), r.approved.clone()) + }); + if decision == Decision::Ask { + let project = app.store.project_approvals(&session.cwd).unwrap_or_default(); + if !project.is_empty() { + let mut all = approved; + all.extend(project); + decision = Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &all }.evaluate(&info, &input); + } + } + match decision { + Decision::Deny => return (false, Some("Denied by the permission rules in the configuration.".into())), + Decision::Allow => {} + Decision::Ask => { + let diff = preview_diff(&name, &input); + let (reply, message, _) = app.request_permission(&session.id, &name, title_of(&name, &input, claude_input), input.clone(), diff, cancel.clone()).await; + match reply { + PermissionReply::Reject => { + let mut text = "The user rejected this tool call.".to_string(); + if let Some(m) = message.filter(|m| !m.trim().is_empty()) { + text.push_str(&format!(" User feedback: {m}")); + } + return (false, Some(text)); + } + PermissionReply::Always | PermissionReply::Project => { + let mut keys: Vec<(String, bool)> = Vec::new(); + if name == "bash" { + for part in split_command(input["command"].as_str().unwrap_or("")).unwrap_or_default() { + keys.push((format!("bash:{}", bash_prefix(&part)), !is_dangerous(&part))); + } + } else { + keys.push((name.clone(), true)); + } + app.with_runtime(&root, |r| r.approved.extend(keys.iter().map(|(k, _)| k.clone()))); + if reply == PermissionReply::Project { + for (k, ok) in &keys { + if *ok { + let _ = app.store.add_project_approval(&session.cwd, k); + } + } + } + } + PermissionReply::Once => {} + } + } + } + // The state before the agent's change (turn changes, rewind). + if matches!(name.as_str(), "write" | "edit") { + if let Some(path) = input["path"].as_str() { + let before = std::fs::read(path).ok(); + let _ = app.store.add_checkpoint(&session.id, path, before.as_deref()); + } + } + (true, None) +} + +// --- the turn --------------------------------------------------------------------------- + +struct Cur { + id: String, + api_id: String, + parts: Vec, + usage: Usage, + model: Option, + started: i64, +} + +fn tokens(u: &Value) -> Usage { + Usage { + input_tokens: u["input_tokens"].as_u64().unwrap_or(0), + output_tokens: u["output_tokens"].as_u64().unwrap_or(0), + cache_read_tokens: u["cache_read_input_tokens"].as_u64().unwrap_or(0), + cache_write_tokens: u["cache_creation_input_tokens"].as_u64().unwrap_or(0), + cost: None, + allowance: None, + } +} + +fn finalize(app: &App, session_id: &str, cur: &mut Option, model_id: &str) { + let Some(c) = cur.take() else { return }; + if c.parts.is_empty() { + return; + } + let mut m = Message::new(Role::Assistant, c.parts); + m.id = c.id; + m.created_at = c.started; + m.model = Some(format!("{PROVIDER}/{}", c.model.as_deref().unwrap_or(model_id))); + m.usage = Some(c.usage.clone()); + if app.store.add_message(session_id, &m).is_ok() { + app.emit(Event::MessageAdded { session_id: session_id.into(), message: m }); + } +} + +/// Text of a tool result: a string, or a list of blocks. +fn result_text(content: &Value) -> String { + match content { + Value::String(s) => s.clone(), + Value::Array(a) => a.iter().filter_map(|b| b["text"].as_str()).collect::>().join("\n"), + other => other.to_string(), + } +} + +/// Images of the prompt as files the CLI can read (it has no inline image input in this mode). +fn save_images(cwd: &str, parts: &[Part]) -> Vec { + use base64::Engine; + let dir = Path::new(cwd).join(".codecraft/uploads"); + let mut out = Vec::new(); + for (i, p) in parts.iter().enumerate() { + let Part::Image { image } = p else { continue }; + let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) else { continue }; + let ext = image.media_type.split('/').nth(1).unwrap_or("png").split('+').next().unwrap_or("png"); + let path = dir.join(format!("chat-{}-{i}.{ext}", now_ms())); + if std::fs::create_dir_all(&dir).is_ok() { + let _ = std::fs::write(dir.join(".gitignore"), "*\n"); + if std::fs::write(&path, bytes).is_ok() { + out.push(path.display().to_string()); + } + } + } + out +} + +/// Run one turn of a "claude-code/…" chat. Returns the final text. +pub async fn run(app: Arc, session_id: &str, cancel: CancellationToken) -> Result { + let session = app.session(session_id)?; + let owner = session.owner_id.clone(); + let model_id = session.model.strip_prefix("claude-code/").unwrap_or("default").to_string(); + let history = crate::runner::active_history(app.store.messages(session_id)?); + let Some(last_user) = history.iter().rposition(|m| m.role == Role::User && m.parts.iter().any(|p| matches!(p, Part::Text { .. } | Part::Image { .. }))) else { + bail!("nothing to do: no user message"); + }; + let mut prompt = history[last_user].parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::>().join("\n\n"); + let images = save_images(&session.cwd, &history[last_user].parts); + if !images.is_empty() { + prompt.push_str(&format!("\n\nAttached images (read them with the Read tool):\n{}", images.iter().map(|p| format!("- {p}")).collect::>().join("\n"))); + } + + // Continue the CLI's own conversation; without one (new chat, fork, model switch) give it the history as context. + let key = format!("claude_sid:{session_id}"); + let resume: Option = owner.as_deref().and_then(|u| app.store.user_setting::>(u, &key).ok().flatten()); + if resume.is_none() && last_user > 0 { + let earlier = crate::runner::transcript_of(&history[..last_user]); + prompt = format!("\n{}\n\n\n{prompt}", crate::runner::fit(&earlier, 20_000)); + } + + let mode = match (session.agent.as_str(), session.permission_mode.as_str()) { + ("plan", _) => "plan", + _ => "default", + }; + let mut args: Vec = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--permission-prompt-tool", "stdio", "--permission-mode", mode] + .iter() + .map(|s| s.to_string()) + .collect(); + if model_id != "default" { + args.extend(["--model".into(), model_id.clone()]); + } + if let Some(r) = &resume { + args.extend(["--resume".into(), r.clone()]); + } + if !session.instructions.trim().is_empty() { + args.extend(["--append-system-prompt".into(), session.instructions.trim().to_string()]); + } + + let mut cmd = command(&app, owner.as_deref(), Path::new(&session.cwd), &args)?; + let mut child = cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true).spawn().map_err(|e| anyhow!("could not start Claude Code: {e}"))?; + let mut stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?; + let stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?; + let stderr_tail = Arc::new(Mutex::new(String::new())); + { + let tail = stderr_tail.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(l)) = lines.next_line().await { + let mut t = tail.lock().unwrap(); + t.push_str(&l); + t.push('\n'); + if t.len() > 4000 { + let cut = t.len() - 3000; + let cut = (cut..t.len()).find(|i| t.is_char_boundary(*i)).unwrap_or(t.len()); + t.drain(..cut); + } + } + }); + } + let first = json!({"type": "user", "message": {"role": "user", "content": prompt}}); + stdin.write_all(format!("{first}\n").as_bytes()).await?; + let mut lines = BufReader::new(child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?).lines(); + + let started = now_ms(); + let mut cur: Option = None; + let mut calls: HashMap = HashMap::new(); + let mut diffs: HashMap = HashMap::new(); + let mut final_text = String::new(); + let mut got_result = false; + let mut failure: Option = None; + + loop { + let line = tokio::select! { + l = lines.next_line() => l?, + _ = cancel.cancelled() => { let _ = child.start_kill(); break; } + }; + let Some(line) = line else { break }; + let Ok(ev) = serde_json::from_str::(&line) else { continue }; + let sub = ev["parent_tool_use_id"].as_str().is_some(); + match ev["type"].as_str().unwrap_or("") { + "system" if ev["subtype"] == "init" => { + if let (Some(sid), Some(u)) = (ev["session_id"].as_str(), owner.as_deref()) { + let _ = app.store.set_user_setting(u, &key, &Some(sid.to_string())); + } + } + "stream_event" if !sub => { + let e = &ev["event"]; + match e["type"].as_str().unwrap_or("") { + "message_start" => { + finalize(&app, session_id, &mut cur, &model_id); + let u = tokens(&e["message"]["usage"]); + let id = new_id(); + app.emit(Event::StepStarted { session_id: session_id.into(), message_id: id.clone(), started_at: now_ms(), prompt_tokens: u.input_tokens + u.cache_read_tokens + u.cache_write_tokens, estimated: false }); + cur = Some(Cur { id, api_id: e["message"]["id"].as_str().unwrap_or("").into(), parts: vec![], usage: u, model: e["message"]["model"].as_str().map(String::from), started: now_ms() }); + } + "content_block_delta" => { + if let Some(c) = &cur { + match e["delta"]["type"].as_str().unwrap_or("") { + "text_delta" => app.emit(Event::TextDelta { session_id: session_id.into(), message_id: c.id.clone(), text: e["delta"]["text"].as_str().unwrap_or("").into() }), + "thinking_delta" => app.emit(Event::ReasoningDelta { session_id: session_id.into(), message_id: c.id.clone(), text: e["delta"]["thinking"].as_str().unwrap_or("").into() }), + _ => {} + } + } + } + "message_delta" => { + if let Some(c) = &mut cur { + if let Some(o) = e["usage"]["output_tokens"].as_u64() { + c.usage.output_tokens = o; + } + } + } + "message_stop" => finalize(&app, session_id, &mut cur, &model_id), + _ => {} + } + } + "assistant" if !sub => { + let api_id = ev["message"]["id"].as_str().unwrap_or("").to_string(); + if cur.as_ref().is_none_or(|c| c.api_id != api_id) { + finalize(&app, session_id, &mut cur, &model_id); + cur = Some(Cur { id: new_id(), api_id, parts: vec![], usage: tokens(&ev["message"]["usage"]), model: ev["message"]["model"].as_str().map(String::from), started: now_ms() }); + } + let c = cur.as_mut().unwrap(); + for b in ev["message"]["content"].as_array().cloned().unwrap_or_default() { + let part = match b["type"].as_str().unwrap_or("") { + "text" => Part::Text { text: b["text"].as_str().unwrap_or("").to_string() }, + "thinking" => Part::Reasoning { text: b["thinking"].as_str().unwrap_or("").to_string(), signature: b["signature"].as_str().map(String::from) }, + "tool_use" => { + let (name, input) = map_tool(b["name"].as_str().unwrap_or(""), &b["input"]); + let id = b["id"].as_str().unwrap_or("").to_string(); + calls.insert(id.clone(), name.clone()); + Part::ToolCall { id, name, input, extra: None } + } + _ => continue, + }; + if !c.parts.contains(&part) && !matches!(&part, Part::ToolCall { id, .. } if c.parts.iter().any(|p| matches!(p, Part::ToolCall { id: i, .. } if i == id))) { + c.parts.push(part); + } + } + } + "user" if !sub => { + finalize(&app, session_id, &mut cur, &model_id); + let mut parts = Vec::new(); + for b in ev["message"]["content"].as_array().cloned().unwrap_or_default() { + if b["type"] != "tool_result" { + continue; + } + let id = b["tool_use_id"].as_str().unwrap_or("").to_string(); + let is_error = b["is_error"].as_bool().unwrap_or(false); + let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})) }; + parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: vec![], is_error, meta }); + } + if !parts.is_empty() { + let m = Message::new(Role::User, parts); + if app.store.add_message(session_id, &m).is_ok() { + app.emit(Event::MessageAdded { session_id: session_id.into(), message: m }); + } + } + } + "control_request" if ev["request"]["subtype"] == "can_use_tool" => { + finalize(&app, session_id, &mut cur, &model_id); + let req = &ev["request"]; + let (allow, message) = decide(&app, &session, req["tool_name"].as_str().unwrap_or(""), &req["input"], &cancel).await; + if allow { + let (name, input) = map_tool(req["tool_name"].as_str().unwrap_or(""), &req["input"]); + if let (Some(d), Some(id)) = (preview_diff(&name, &input), req["tool_use_id"].as_str()) { + diffs.insert(id.to_string(), d); + } + } + let response = if allow { json!({"behavior": "allow", "updatedInput": req["input"]}) } else { json!({"behavior": "deny", "message": message.unwrap_or_default()}) }; + let reply = json!({"type": "control_response", "response": {"subtype": "success", "request_id": ev["request_id"], "response": response}}); + if stdin.write_all(format!("{reply}\n").as_bytes()).await.is_err() { + break; + } + } + "result" => { + finalize(&app, session_id, &mut cur, &model_id); + got_result = true; + final_text = ev["result"].as_str().unwrap_or("").to_string(); + if ev["is_error"].as_bool().unwrap_or(false) || ev["subtype"].as_str().is_some_and(|s| s != "success") { + failure = Some(if final_text.trim().is_empty() { format!("Claude Code stopped: {}", ev["subtype"].as_str().unwrap_or("error")) } else { final_text.clone() }); + } + let u = tokens(&ev["usage"]); + let (p, m) = (PROVIDER, model_id.as_str()); + let _ = app.store.add_usage(&UsageRecord { + ts: now_ms(), + session_id: session_id.into(), + root_session_id: app.root_session_id(session_id), + provider: p.into(), + model: m.into(), + agent: "claude-code".into(), + input_tokens: u.input_tokens, + output_tokens: u.output_tokens, + cache_read_tokens: u.cache_read_tokens, + cache_write_tokens: u.cache_write_tokens, + ttft_ms: 0, + duration_ms: (now_ms() - started).max(0) as u64, + cost: ev["total_cost_usd"].as_f64().unwrap_or(0.0), + error: failure.is_some(), + user_id: None, + allowance: None, + }); + break; + } + _ => {} + } + } + finalize(&app, session_id, &mut cur, &model_id); + drop(stdin); + let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; + let _ = child.start_kill(); + if cancel.is_cancelled() { + return Ok(final_text); + } + if let Some(f) = failure { + // An expired sign-in is the common case: say where to fix it. + if f.to_lowercase().contains("login") || f.to_lowercase().contains("authenticat") || f.contains("401") { + bail!("Claude Code is not signed in (or the sign-in expired): Settings → Claude Code → Sign in. ({f})"); + } + bail!("{f}"); + } + if !got_result { + let tail = stderr_tail.lock().unwrap().trim().to_string(); + if tail.to_lowercase().contains("login") || tail.to_lowercase().contains("authenticat") { + bail!("Claude Code is not signed in: Settings → Claude Code → Sign in."); + } + bail!("Claude Code ended without an answer{}", if tail.is_empty() { String::new() } else { format!(": {tail}") }); + } + Ok(final_text) +} + +// --- sign-in from the web UI ---------------------------------------------------------------- + +/// `claude auth login` running in a PTY for one user: its output (with the sign-in URL) and stdin. +struct Login { + writer: tokio::sync::Mutex, + out: Arc>, + child: Mutex>, + exited: Arc>>, + started: Instant, +} + +static LOGINS: OnceLock>>> = OnceLock::new(); + +fn logins() -> &'static Mutex>> { + LOGINS.get_or_init(Default::default) +} + +/// Terminal escape sequences removed. +fn strip_ansi(s: &str) -> String { + let mut out = String::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\x1b' { + match chars.peek() { + Some('[') => { + chars.next(); + for n in chars.by_ref() { + if ('@'..='~').contains(&n) { + break; + } + } + } + Some(']') => { + chars.next(); + for n in chars.by_ref() { + if n == '\x07' { + break; + } + } + } + _ => { + chars.next(); + } + } + } else if c != '\r' { + out.push(c); + } + } + out +} + +fn find_url(text: &str) -> Option { + let t = strip_ansi(text); + let start = t.find("https://")?; + let url: String = t[start..].chars().take_while(|c| !c.is_whitespace()).collect(); + // Complete once whitespace follows it (the output arrives in chunks); the wide PTY avoids wrapping. + let complete = t.len() > start + url.len(); + (complete && (url.contains("oauth") || url.contains("authorize"))).then_some(url) +} + +fn stop_login(user_id: &str) { + if let Some(l) = logins().lock().unwrap().remove(user_id) { + if let Some(mut c) = l.child.lock().unwrap().take() { + let _ = c.start_kill(); + } + } +} + +/// Start `claude auth login` for the user and return the sign-in URL to open in any browser. +/// `console` = Anthropic Console (API billing) instead of the Claude subscription. +pub async fn login_start(app: &Arc, user_id: &str, console: bool) -> Result { + stop_login(user_id); + let (pty, pts) = pty_process::open()?; + pty.resize(pty_process::Size::new(24, 500))?; + let mode = if console { "--console" } else { "--claudeai" }; + let cwd = app.user_roots(Some(user_id)).first().cloned().unwrap_or_else(|| PathBuf::from("/")); + let cmd = match crate::sandbox::spec_for(app, Some(user_id)) { + Some(spec) => { + if !crate::sandbox::available() { + bail!("the sandbox (bubblewrap) is not available"); + } + pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, &cwd, &[], false, "env", &["BROWSER=/bin/false", "claude", "auth", "login", mode])) + } + None => { + let bin = binary().ok_or_else(|| anyhow!("Claude Code is not installed on the server (`claude` not found)"))?; + let mut c = pty_process::Command::new(bin).args(["auth", "login", mode]).current_dir(&cwd).env_clear().env("BROWSER", "/bin/false").env("TERM", "xterm-256color"); + for k in ["PATH", "HOME", "LANG", "USER"] { + if let Some(v) = std::env::var_os(k) { + c = c.env(k, v); + } + } + c + } + }; + let child = cmd.kill_on_drop(true).spawn(pts).map_err(|e| anyhow!("could not start `claude auth login`: {e}"))?; + let (mut reader, writer) = pty.into_split(); + let login = Arc::new(Login { writer: tokio::sync::Mutex::new(writer), out: Arc::default(), child: Mutex::new(Some(child)), exited: Arc::default(), started: Instant::now() }); + { + let (out, exited, l) = (login.out.clone(), login.exited.clone(), login.clone()); + tokio::spawn(async move { + let mut buf = vec![0u8; 8192]; + loop { + match reader.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + let mut o = out.lock().unwrap(); + o.push_str(&String::from_utf8_lossy(&buf[..n])); + if o.len() > 64_000 { + let cut = o.len() - 32_000; + let cut = (cut..o.len()).find(|i| o.is_char_boundary(*i)).unwrap_or(o.len()); + o.drain(..cut); + } + } + } + } + let code = l.child.lock().unwrap().as_mut().and_then(|c| c.try_wait().ok().flatten()).and_then(|s| s.code()).unwrap_or(0); + *exited.lock().unwrap() = Some(code); + }); + } + logins().lock().unwrap().insert(user_id.to_string(), login.clone()); + // The URL shows up within a few seconds. + for _ in 0..60 { + if let Some(u) = find_url(&login.out.lock().unwrap()) { + return Ok(u); + } + if login.exited.lock().unwrap().is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + let out = strip_ansi(&login.out.lock().unwrap()); + stop_login(user_id); + bail!("no sign-in URL received from `claude auth login`{}", if out.trim().is_empty() { String::new() } else { format!(": {}", out.trim().chars().take(300).collect::()) }) +} + +/// Hand the code from the sign-in page to the waiting CLI; true once it reports success. +pub async fn login_code(user_id: &str, code: &str) -> Result { + let login = logins().lock().unwrap().get(user_id).cloned().ok_or_else(|| anyhow!("no sign-in in progress – start it again"))?; + if login.started.elapsed() > Duration::from_secs(15 * 60) { + stop_login(user_id); + bail!("the sign-in expired – start it again"); + } + let code: String = code.chars().filter(|c| !c.is_control()).collect::().trim().to_string(); + if code.is_empty() || code.len() > 2000 { + bail!("empty or invalid code"); + } + login.writer.lock().await.write_all(format!("{code}\r").as_bytes()).await?; + for _ in 0..80 { + if login.exited.lock().unwrap().is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + let exited = *login.exited.lock().unwrap(); + let out = strip_ansi(&login.out.lock().unwrap()); + stop_login(user_id); + match exited { + Some(0) => Ok(true), + _ => bail!("sign-in failed: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("no answer from the CLI").trim()), + } +} + +pub fn login_cancel(user_id: &str) { + stop_login(user_id); +} + +/// `claude auth status` for the user (their sandbox home or the server's): the CLI's JSON. +pub async fn status(app: &App, user_id: &str) -> Value { + let mut v = status_inner(app, user_id).await; + if v["installed"] == true { + if let (Some(o), Some(i)) = (v.as_object_mut(), version_info(false).await.as_object()) { + o.extend(i.clone()); + } + } + v +} + +async fn status_inner(app: &App, user_id: &str) -> Value { + let Some(_) = binary().or_else(|| crate::sandbox::spec_for(app, Some(user_id)).map(|_| PathBuf::from("claude"))) else { + return json!({"installed": false}); + }; + let cwd = app.user_roots(Some(user_id)).first().cloned().unwrap_or_else(|| PathBuf::from("/")); + let mut cmd = match command(app, Some(user_id), &cwd, &["auth".into(), "status".into(), "--json".into()]) { + Ok(c) => c, + Err(e) => return json!({"installed": true, "error": format!("{e:#}")}), + }; + let out = tokio::time::timeout(Duration::from_secs(15), cmd.stdin(Stdio::null()).kill_on_drop(true).output()).await; + let in_progress = logins().lock().unwrap().contains_key(user_id); + match out { + Ok(Ok(o)) => { + let v: Value = serde_json::from_slice(&o.stdout).unwrap_or(Value::Null); + json!({ + "installed": true, + "logged_in": v["loggedIn"].as_bool().unwrap_or(false), + "method": v["authMethod"], "email": v["email"], "org": v["orgName"], "expires": v["loginExpiresAt"], + "login_in_progress": in_progress, + }) + } + Ok(Err(e)) => json!({"installed": false, "error": e.to_string()}), + Err(_) => json!({"installed": true, "error": "timeout", "login_in_progress": in_progress}), + } +} + +pub async fn logout(app: &App, user_id: &str) -> Result<()> { + let cwd = app.user_roots(Some(user_id)).first().cloned().unwrap_or_else(|| PathBuf::from("/")); + let mut cmd = command(app, Some(user_id), &cwd, &["auth".into(), "logout".into()])?; + let _ = tokio::time::timeout(Duration::from_secs(15), cmd.stdin(Stdio::null()).kill_on_drop(true).output()).await; + Ok(()) +} + +// --- version and update ------------------------------------------------------------------------- + +/// "2.1.325" or "2.1.325 (Claude Code)" → [2, 1, 325]. +fn version_key(v: &str) -> Vec { + v.split_whitespace().next().unwrap_or("").trim_start_matches('v').split('.').map(|p| p.parse().unwrap_or(0)).collect() +} + +fn newer_version(latest: &str, current: &str) -> bool { + version_key(latest) > version_key(current) +} + +/// Version of the installed CLI (what `claude --version` prints). +async fn installed_version() -> Option { + let bin = binary()?; + let out = tokio::time::timeout(Duration::from_secs(10), tokio::process::Command::new(bin).arg("--version").stdin(Stdio::null()).kill_on_drop(true).output()).await.ok()?.ok()?; + let text = String::from_utf8_lossy(&out.stdout); + let v = text.split_whitespace().next()?.to_string(); + (!v.is_empty()).then_some(v) +} + +static LATEST: OnceLock>> = OnceLock::new(); + +/// Latest release on npm (cached for 15 minutes; `force` asks again). +async fn latest_version(force: bool) -> Option { + let cache = LATEST.get_or_init(Default::default); + if !force { + if let Some((t, v)) = cache.lock().unwrap().as_ref() { + if t.elapsed() < Duration::from_secs(15 * 60) { + return Some(v.clone()); + } + } + } + let v: Value = crate::provider::http_client().get("https://registry.npmjs.org/@anthropic-ai/claude-code/latest").timeout(Duration::from_secs(10)).send().await.ok()?.json().await.ok()?; + let version = v["version"].as_str()?.to_string(); + *cache.lock().unwrap() = Some((Instant::now(), version.clone())); + Some(version) +} + +/// Ask the registry for the latest version now (the "check" button). +pub async fn refresh_latest() { + let _ = latest_version(true).await; +} + +/// {version, latest, update_available} of the CLI on the server. +pub async fn version_info(force: bool) -> Value { + let (current, latest) = tokio::join!(installed_version(), latest_version(force)); + let update = match (¤t, &latest) { + (Some(c), Some(l)) => newer_version(l, c), + _ => false, + }; + json!({"version": current, "latest": latest, "update_available": update}) +} + +static UPDATING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Install the latest version: a native install updates itself (`claude update`), otherwise npm installs +/// into the persistent home (`~/.npm-global`, ahead of the image's copy). Returns the tool output. +pub async fn update() -> Result { + use std::sync::atomic::Ordering; + if UPDATING.swap(true, Ordering::SeqCst) { + bail!("an update is already running"); + } + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + UPDATING.store(false, std::sync::atomic::Ordering::SeqCst); + } + } + let _reset = Reset; + let bin = binary().ok_or_else(|| anyhow!("Claude Code is not installed on the server"))?; + let native = std::fs::canonicalize(&bin).is_ok_and(|p| p.to_string_lossy().contains("/.local/share/claude/")); + let mut cmd = if native { + let mut c = tokio::process::Command::new(&bin); + c.arg("update"); + c + } else { + let prefix = managed_dir().ok_or_else(|| anyhow!("no HOME"))?; + let mut c = tokio::process::Command::new("npm"); + c.args(["install", "-g", "--no-audit", "--no-fund", "@anthropic-ai/claude-code@latest"]).env("NPM_CONFIG_PREFIX", prefix); + c + }; + cmd.stdin(Stdio::null()).kill_on_drop(true); + for k in ["PATH", "HOME", "LANG", "USER"] { + if let Some(v) = std::env::var_os(k) { + cmd.env(k, v); + } + } + let out = tokio::time::timeout(Duration::from_secs(300), cmd.output()).await.map_err(|_| anyhow!("the update timed out after 5 minutes"))?.map_err(|e| anyhow!("could not run the updater: {e}"))?; + let mut text = String::from_utf8_lossy(&out.stdout).to_string(); + text.push_str(&String::from_utf8_lossy(&out.stderr)); + let tail: String = { let t = text.trim(); t.chars().rev().take(1500).collect::>().into_iter().rev().collect() }; + if !out.status.success() { + bail!("the update failed: {tail}"); + } + // Ask the registry again next time. + if let Some(c) = LATEST.get() { + *c.lock().unwrap() = None; + } + Ok(tail) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_the_login_url_only_when_complete() { + let partial = "Opening browser…\r\nIf the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&cli"; + assert_eq!(find_url(partial), None); + let full = format!("{partial}ent_id=1&state=x\r\nPaste code here if prompted > "); + assert_eq!(find_url(&full).as_deref(), Some("https://claude.com/cai/oauth/authorize?code=true&client_id=1&state=x")); + assert_eq!(strip_ansi("\x1b[2Khello\x1b[0m\r\n"), "hello\n"); + } + + #[test] + fn compares_cli_versions() { + assert!(newer_version("2.1.325", "2.1.311")); + assert!(newer_version("2.2.0", "2.1.999")); + assert!(!newer_version("2.1.325", "2.1.325 (Claude Code)")); + assert!(!newer_version("2.1.9", "2.1.10")); + } + + #[test] + fn maps_claude_tools_to_ours() { + let (n, i) = map_tool("Write", &json!({"file_path": "/a/b.txt", "content": "x"})); + assert_eq!((n.as_str(), i["path"].as_str()), ("write", Some("/a/b.txt"))); + assert!(i.get("file_path").is_none()); + assert_eq!(map_tool("Bash", &json!({"command": "ls"})).0, "bash"); + assert_eq!(map_tool("mcp__x__y", &json!({})).0, "mcp__x__y"); + assert!(CcTool { name: "edit".into() }.is_edit() && CcTool { name: "read".into() }.read_only()); + } +} diff --git a/server/src/jobs.rs b/server/src/jobs.rs index ee8e11f..cf9c772 100644 --- a/server/src/jobs.rs +++ b/server/src/jobs.rs @@ -321,6 +321,8 @@ pub async fn run_with(app: &Arc, job: &Job, trigger: &str, payload: Option< instructions: String::new(), // New chats keep their approvals and questions out of Telegram (⋯ menu turns it on). telegram_quiet: true, + summary: String::new(), + color: None, }; let session = app.save_session(session)?; app.store.job_started(&job.id, &session.id, now)?; diff --git a/server/src/main.rs b/server/src/main.rs index aa62f92..04427c6 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -5,6 +5,8 @@ mod app; mod auth; mod backup; mod blobs; +mod checks; +mod claude_code; mod cli_api; mod budget; mod config; diff --git a/server/src/provider/mod.rs b/server/src/provider/mod.rs index b3e2b30..db6260e 100644 --- a/server/src/provider/mod.rs +++ b/server/src/provider/mod.rs @@ -61,9 +61,21 @@ impl Registry { ProviderEntry { name: name.clone(), provider, models }, ); } + // Claude Code (the CLI's own agent) appears as a model list when it is installed. + if !providers.contains_key(crate::claude_code::PROVIDER) && crate::claude_code::binary().is_some() { + providers.insert( + crate::claude_code::PROVIDER.to_string(), + ProviderEntry { name: crate::claude_code::PROVIDER.to_string(), provider: Arc::new(crate::claude_code::Marker), models: crate::claude_code::models() }, + ); + } Self { providers, limits } } + /// First model of any provider but `skip`. + pub fn first_model_excluding(&self, skip: &str) -> Option { + self.providers.values().filter(|e| e.name != skip).find_map(|e| e.models.first().map(|m| format!("{}/{}", e.name, m.id))) + } + /// Wait for a free slot on the model and its provider. Always acquired in the /// same order (model, then provider), so concurrent requests cannot deadlock. pub async fn acquire(&self, spec: &str) -> Slot { diff --git a/server/src/push.rs b/server/src/push.rs index c08bd8d..f4d908e 100644 --- a/server/src/push.rs +++ b/server/src/push.rs @@ -200,8 +200,12 @@ pub fn spawn_listener(app: Arc) { Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, Err(_) => break, }; + let mut permission_id = None; let (session_id, kind, body) = match &ev { - Event::PermissionRequest { session_id, title, tool, .. } => (session_id.clone(), "permission", format!("{tool}: {title}")), + Event::PermissionRequest { id, session_id, title, tool, .. } => { + permission_id = Some(id.clone()); + (session_id.clone(), "permission", format!("{tool}: {title}")) + } Event::QuestionRequest { session_id, questions, .. } => { let q = questions.first().map(|q| q.question.clone()).unwrap_or_default(); (session_id.clone(), "question", q) @@ -228,6 +232,8 @@ pub fn spawn_listener(app: Arc) { continue; } let payload = json!({ + // Lets the notification itself approve or reject (service worker → POST /api/permissions/). + "permission_id": permission_id, "kind": kind, "title": title, "body": body.chars().take(180).collect::(), diff --git a/server/src/runner.rs b/server/src/runner.rs index 09de854..1abbdf3 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -2,7 +2,7 @@ //! checks), feed results back, repeat until the model stops. use crate::app::{App, PermissionReply}; -use crate::permission::{Decision, Evaluator, bash_prefix, split_command}; +use crate::permission::{Decision, Evaluator, bash_prefix, is_dangerous, split_command}; use crate::prompts::{self, Agent}; use crate::provider::{ChatRequest, Provider, StreamEvent}; use crate::store::UsageRecord; @@ -73,6 +73,7 @@ pub fn submit(app: Arc, session_id: String, parts: Vec, source: &str) .join("\n"); app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true }); let source = source.to_string(); + let turn_started = now_ms(); tokio::spawn(async move { let res = async { @@ -114,6 +115,10 @@ pub fn submit(app: Arc, session_id: String, parts: Vec, source: &str) if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) } }); app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false }); + if leftover.is_empty() && !cancel.is_cancelled() { + spawn_summary(app.clone(), session_id.clone()); + crate::checks::spawn_after_turn(app.clone(), session_id.clone(), turn_started); + } if !leftover.is_empty() { let _ = start_prompt(app, session_id, leftover); } @@ -145,7 +150,7 @@ pub fn abort(app: &App, session_id: &str) { /// Messages since the last compaction summary. Tool calls left without a /// result (run interrupted, e.g. by a server restart) get a synthetic result: /// providers reject histories with unanswered tool calls. -fn active_history(all: Vec) -> Vec { +pub(crate) fn active_history(all: Vec) -> Vec { let start = all .iter() .rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. }))) @@ -263,6 +268,10 @@ fn run_session_boxed( /// Run the loop for a session until the model stops. Returns the final text. pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationToken, depth: usize) -> Result { + // Chats on a "claude-code/…" model are run by the Claude Code CLI. + if depth == 0 && crate::claude_code::is_claude_code(&app.session(session_id)?.model) { + return crate::claude_code::run(app, session_id, cancel).await; + } let mut final_text = String::new(); let mut stop_hooks = 0; let mut refine_done = 0u32; @@ -639,9 +648,18 @@ async fn execute_call( // Permission check. Approvals are tracked on the top-level session. let root = app.root_session_id(&session.id); let cfg = app.config(); - let decision = app.with_runtime(&root, |r| { - Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input) + let (mut decision, session_approved) = app.with_runtime(&root, |r| { + (Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input), r.approved.clone()) }); + // What the user allowed "for this project" earlier counts as well. + if decision == Decision::Ask { + let project = app.store.project_approvals(&session.cwd).unwrap_or_default(); + if !project.is_empty() { + let mut all = session_approved; + all.extend(project); + decision = Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &all }.evaluate(tool.as_ref(), &input); + } + } match decision { Decision::Deny => { return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None); @@ -659,16 +677,27 @@ async fn execute_call( } return tool_result(call_id, call_name, text, true, None); } - PermissionReply::Always => app.with_runtime(&root, |r| { + PermissionReply::Always | PermissionReply::Project => { + // (key, may be remembered for the whole project) + let mut keys: Vec<(String, bool)> = Vec::new(); if name == "bash" { let cmd = input["command"].as_str().unwrap_or(""); for part in split_command(cmd).unwrap_or_default() { - r.approved.insert(format!("bash:{}", bash_prefix(&part))); + // Dangerous commands (rm -r, git push, docker …) are never remembered beyond this chat. + keys.push((format!("bash:{}", bash_prefix(&part)), !is_dangerous(&part))); } } else { - r.approved.insert(name.to_string()); + keys.push((name.to_string(), true)); } - }), + app.with_runtime(&root, |r| r.approved.extend(keys.iter().map(|(k, _)| k.clone()))); + if reply == PermissionReply::Project { + for (k, ok) in &keys { + if *ok { + let _ = app.store.add_project_approval(&session.cwd, k); + } + } + } + } PermissionReply::Once => {} } if let Some(sel) = hunks { @@ -868,6 +897,8 @@ fn create_child(app: &Arc, ctx: &ToolCtx, agent_name: &str, description: &s folder: None, instructions: String::new(), telegram_quiet: false, + summary: String::new(), + color: None, }; let child = app.save_session(child)?; let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]); @@ -1149,6 +1180,9 @@ fn small_model(app: &App, session: &Session) -> String { let cfg = app.config(); if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() { cfg.server.small_model.clone() + } else if crate::claude_code::is_claude_code(&session.model) { + // Claude Code runs itself; side tasks need an ordinary model. + app.registry().first_model_excluding(crate::claude_code::PROVIDER).unwrap_or_else(|| session.model.clone()) } else { session.model.clone() } @@ -1176,6 +1210,122 @@ fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::co }); } +/// Cut a text down to `max` bytes keeping the beginning (the goal) and the end (where it went). +pub(crate) fn fit(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let head = clip(s, max * 2 / 3); + let mut start = s.len() - max / 3; + while !s.is_char_boundary(start) { + start += 1; + } + format!("{}\n…\n{}", head.split(" […").next().unwrap_or(&head), &s[start..]) +} + +/// A short, tool-free job for the small model (summaries, explanations). The input is cut to the +/// model's context window, so models with 8k tokens work as well as large ones: tokens left for +/// the input = context − answer − prompt, at a conservative 2.5 bytes per token. +async fn side_task(app: &App, session: &Session, kind: &str, system: &str, input: &str, max_out: u64) -> Result { + let spec = small_model(app, session); + let (provider, mut model) = app.registry().resolve(&spec)?; + model.effort = Some("low".into()); + let ctx = model.context.unwrap_or(32_000); + let room_tokens = ctx.saturating_sub(max_out + system.len() as u64 / 3 + 400).max(600); + let input = fit(input, (room_tokens as usize * 5 / 2).min(60_000)); + let req = ChatRequest { + system: system.into(), + messages: vec![Message::new(Role::User, vec![Part::Text { text: input }])], + tools: vec![], + max_tokens: max_out, + reasoning: false, + session_id: Some(session.id.clone()), + model: model.clone(), + }; + let (text, usage) = complete_text(app, &spec, provider, req, CancellationToken::new()).await?; + record_side_usage(app, session, &spec, &model, &usage, kind); + Ok(strip_think(&text)) +} + +fn message_text(m: &Message) -> String { + m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::>().join("\n") +} + +/// After a turn: one line "where things stand" for the session list (small model, ~1k tokens of input). +fn spawn_summary(app: Arc, session_id: String) { + tokio::spawn(async move { + let Ok(session) = app.session(&session_id) else { return }; + if session.parent_id.is_some() { + return; + } + let Ok(history) = app.store.messages(&session_id) else { return }; + let history = active_history(history); + let last_user = history.iter().rev().find(|m| matches!(m.role, Role::User) && !message_text(m).trim().is_empty() && !message_text(m).trim_start().starts_with('<')); + let last_answer = history.iter().rev().find(|m| matches!(m.role, Role::Assistant) && !message_text(m).trim().is_empty()); + let (Some(u), Some(a)) = (last_user, last_answer) else { return }; + let input = format!( + "{}\n\n{}\n\n\n{}\n", + session.summary, + fit(&message_text(u), 1500), + fit(&message_text(a), 2500) + ); + let system = "You write the status line of a coding chat for a list. One line, at most 14 words: what was done or where things stand, concrete (file/feature names). \ + Use the language of the user's request. No quotes, no markdown, no trailing period. Reply with the line only."; + match side_task(&app, &session, "summary", system, &input, 120).await { + Ok(text) => { + let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches(['"', '\'', '*', '#', ' ', '.']).chars().take(140).collect::(); + if !line.is_empty() { + let _ = app.modify_session(&session_id, false, |s| s.summary = line); + } + } + Err(e) => tracing::debug!("summary failed: {e:#}"), + } + }); +} + +/// "What does this do?" for a pending approval, in plain words (for phones: approve without reading code). +pub async fn explain_tool_call(app: &App, session: &Session, tool: &str, title: &str, input: &Value, diff: Option<&str>, lang: &str) -> Result { + let language = if lang.starts_with("de") { "German" } else { "English" }; + let system = format!( + "You explain a pending action of an AI coding agent to the user who has to approve it. Answer in {language}, plain words, at most 3 short sentences: \ + what it does, and whether anything is risky (deletes or overwrites data, network access, secrets, changes outside the project, hard to undo). \ + If it is harmless, say so. No markdown headings, no code blocks." + ); + let mut body = format!("{tool}\n{title}\n\n{}\n", clip(&input.to_string(), 3000)); + if let Some(d) = diff { + body.push_str(&format!("\n\n{}\n", clip(d, 3000))); + } + side_task(app, session, "explain", &system, &body, 300).await +} + +/// Turn a chat into the instructions of a repeatable automation: (name, instructions in Markdown). +/// Works with a small model: the transcript is cut to its context window. +pub async fn draft_job(app: &App, session: &Session) -> Result<(String, String)> { + let history = active_history(app.store.messages(&session.id)?); + let transcript = transcript_of(&history); + if transcript.trim().is_empty() { + bail!("the session has no messages yet"); + } + let system = "You turn a finished coding-assistant conversation into instructions for an AUTOMATION that repeats the useful work on a schedule, without a human in the loop. \ + Reply in exactly this format. First line: NAME: . Then the instructions in Markdown with the headings '## Goal', '## Steps' (numbered), '## Result' and '## Limits'. \ + Write them standalone: never refer to 'the conversation' or 'above'; name concrete paths, commands and checks that were used. \ + Use the language of the user's messages. Keep it under 300 words."; + let text = side_task(app, session, "job-draft", system, &format!("\n{transcript}\n"), 1200).await?; + let mut lines = text.lines().skip_while(|l| l.trim().is_empty()); + let first = lines.next().unwrap_or(""); + let (name, body) = match first.trim().strip_prefix("NAME:").or_else(|| first.trim().strip_prefix("Name:")) { + Some(n) => (n.trim().trim_matches(['"', '*', '#', ' ']).to_string(), lines.collect::>().join("\n")), + None => (String::new(), text.clone()), + }; + let body = body.trim().to_string(); + if body.is_empty() { + bail!("the model returned no instructions"); + } + let name = if name.is_empty() { session.title.clone() } else { name }; + let name: String = if name.trim().is_empty() { "Automation".into() } else { name.chars().take(60).collect() }; + Ok((name.clone(), format!("# {name}\n\n{body}\n"))) +} + fn maybe_generate_title(app: Arc, session_id: &str, text: &str) { let Ok(session) = app.session(session_id) else { return }; if session.parent_id.is_some() || !session.title.is_empty() { @@ -1524,6 +1674,19 @@ async fn compact_inner(app: Arc, session_id: &str, cancel: CancellationToke mod tests { use super::*; + #[test] + fn fit_keeps_head_and_tail_within_budget() { + let text = format!("{}{}{}", "A".repeat(5000), "M".repeat(5000), "Z".repeat(5000)); + let out = fit(&text, 3000); + assert!(out.len() <= 3100, "{}", out.len()); + assert!(out.starts_with("AAAA") && out.ends_with("ZZZZ") && out.contains('…')); + // Multi-byte text is cut on character boundaries. + let de = "äöüß".repeat(2000); + assert!(fit(&de, 1000).is_char_boundary(0)); + assert_eq!(fit("short", 100), "short"); + } + + #[test] fn todo_completion_and_reopening() { let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]); diff --git a/server/src/store.rs b/server/src/store.rs index 86a523a..8065759 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -157,6 +157,31 @@ impl Store { content BLOB ); CREATE INDEX IF NOT EXISTS checkpoints_session ON checkpoints(session_id, ts); + CREATE TABLE IF NOT EXISTS project_approvals ( + cwd TEXT NOT NULL, + key TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (cwd, key) + ); + CREATE TABLE IF NOT EXISTS project_defaults ( + cwd TEXT PRIMARY KEY, + data TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS project_checks ( + cwd TEXT PRIMARY KEY, + command TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS turn_checks ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + message_id TEXT NOT NULL, + status TEXT NOT NULL, + exit_code INTEGER, + duration_ms INTEGER NOT NULL DEFAULT 0, + output TEXT NOT NULL DEFAULT '', + ts INTEGER NOT NULL, + PRIMARY KEY (session_id, message_id) + ); CREATE TABLE IF NOT EXISTS session_shares ( session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -1481,6 +1506,114 @@ impl Store { } } +// --- project overview ------------------------------------------------------------------------ + +impl Store { + /// Per chat: files the agent changed, the latest check result and the cost. + pub fn project_overview(&self, session_ids: &[String]) -> Result { + let conn = self.conn.lock().unwrap(); + let mut out = serde_json::Map::new(); + for id in session_ids { + let files: i64 = conn.query_row("SELECT COUNT(DISTINCT path) FROM checkpoints WHERE session_id = ?1", params![id], |r| r.get(0))?; + let check: Option = conn + .query_row("SELECT status FROM turn_checks WHERE session_id = ?1 ORDER BY ts DESC LIMIT 1", params![id], |r| r.get(0)) + .optional()?; + let failed: i64 = conn.query_row("SELECT COUNT(*) FROM turn_checks WHERE session_id = ?1 AND status IN ('failed','timeout')", params![id], |r| r.get(0))?; + let cost: Option = conn.query_row("SELECT SUM(cost) FROM usage WHERE root_session_id = ?1", params![id], |r| r.get(0))?; + out.insert(id.clone(), json!({"files": files, "check": check, "checks_failed": failed, "cost": cost.unwrap_or(0.0)})); + } + Ok(Value::Object(out)) + } +} + +// --- defaults of new chats in a project ------------------------------------------------------- + +impl Store { + /// {model, agent, permission_mode, color, instructions, folder} new chats of the folder start with. + pub fn project_defaults(&self, cwd: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let v: Option = conn.query_row("SELECT data FROM project_defaults WHERE cwd = ?1", params![cwd], |r| r.get(0)).optional()?; + Ok(v.and_then(|v| serde_json::from_str(&v).ok())) + } + + pub fn set_project_defaults(&self, cwd: &str, data: Option<&Value>) -> Result<()> { + let conn = self.conn.lock().unwrap(); + match data { + Some(d) => conn.execute("INSERT INTO project_defaults (cwd, data) VALUES (?1, ?2) ON CONFLICT(cwd) DO UPDATE SET data = ?2", params![cwd, d.to_string()])?, + None => conn.execute("DELETE FROM project_defaults WHERE cwd = ?1", params![cwd])?, + }; + Ok(()) + } +} + +// --- checks after a turn ------------------------------------------------------------------ + +impl Store { + /// (command, enabled) of a project folder. + pub fn project_check(&self, cwd: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + Ok(conn.query_row("SELECT command, enabled FROM project_checks WHERE cwd = ?1", params![cwd], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? != 0))).optional()?) + } + + pub fn set_project_check(&self, cwd: &str, command: &str, enabled: bool) -> Result<()> { + let conn = self.conn.lock().unwrap(); + if command.trim().is_empty() { + conn.execute("DELETE FROM project_checks WHERE cwd = ?1", params![cwd])?; + } else { + conn.execute( + "INSERT INTO project_checks (cwd, command, enabled) VALUES (?1, ?2, ?3) ON CONFLICT(cwd) DO UPDATE SET command = ?2, enabled = ?3", + params![cwd, command.trim(), enabled as i64], + )?; + } + Ok(()) + } + + pub fn set_turn_check(&self, session_id: &str, message_id: &str, status: &str, exit_code: Option, duration_ms: u64, output: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO turn_checks (session_id, message_id, status, exit_code, duration_ms, output, ts) VALUES (?1,?2,?3,?4,?5,?6,?7) + ON CONFLICT(session_id, message_id) DO UPDATE SET status = ?3, exit_code = ?4, duration_ms = ?5, output = ?6, ts = ?7", + params![session_id, message_id, status, exit_code, duration_ms as i64, output, now_ms()], + )?; + Ok(()) + } + + pub fn turn_checks(&self, session_id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT message_id, status, exit_code, duration_ms, output FROM turn_checks WHERE session_id = ?1 ORDER BY ts DESC LIMIT 300")?; + let rows = stmt + .query_map(params![session_id], |r| { + Ok(json!({"message_id": r.get::<_, String>(0)?, "status": r.get::<_, String>(1)?, "exit_code": r.get::<_, Option>(2)?, "duration_ms": r.get::<_, i64>(3)?, "output": r.get::<_, String>(4)?})) + })? + .collect::>>()?; + Ok(rows) + } +} + +// --- project-wide approvals --------------------------------------------------------- + +impl Store { + /// Approvals ("bash:" or a tool name) remembered for a project folder. + pub fn project_approvals(&self, cwd: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT key FROM project_approvals WHERE cwd = ?1 ORDER BY key")?; + let rows = stmt.query_map(params![cwd], |r| r.get::<_, String>(0))?.collect::>>()?; + Ok(rows) + } + + pub fn add_project_approval(&self, cwd: &str, key: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("INSERT OR IGNORE INTO project_approvals (cwd, key, created_at) VALUES (?1, ?2, ?3)", params![cwd, key, now_ms()])?; + Ok(()) + } + + pub fn delete_project_approval(&self, cwd: &str, key: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM project_approvals WHERE cwd = ?1 AND key = ?2", params![cwd, key])?; + Ok(()) + } +} + // --- file checkpoints ------------------------------------------------------------ impl Store { @@ -1496,13 +1629,18 @@ impl Store { /// Per path, the oldest snapshot taken at or after `since` (ms): the state to restore. pub fn checkpoints_since(&self, session_id: &str, since: i64) -> Result>)>> { + self.checkpoints_between(session_id, since, i64::MAX) + } + + /// Per file the state before its first change in the time range [since, until). + pub fn checkpoints_between(&self, session_id: &str, since: i64, until: i64) -> Result>)>> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT path, existed, content FROM checkpoints c WHERE session_id = ?1 AND ts >= ?2 - AND id = (SELECT MIN(id) FROM checkpoints WHERE session_id = ?1 AND ts >= ?2 AND path = c.path)", + "SELECT path, existed, content FROM checkpoints c WHERE session_id = ?1 AND ts >= ?2 AND ts < ?3 + AND id = (SELECT MIN(id) FROM checkpoints WHERE session_id = ?1 AND ts >= ?2 AND ts < ?3 AND path = c.path)", )?; let rows = stmt - .query_map(params![session_id, since], |r| { + .query_map(params![session_id, since, until], |r| { let existed: i64 = r.get(1)?; Ok((r.get::<_, String>(0)?, if existed != 0 { r.get::<_, Option>>(2)? } else { None })) })? diff --git a/server/src/telegram.rs b/server/src/telegram.rs index a3dc1d8..8788917 100644 --- a/server/src/telegram.rs +++ b/server/src/telegram.rs @@ -541,6 +541,8 @@ async fn new_session(app: &Arc, u: &User, bot: &Bot, chat: i64, arg: &str, instructions: String::new(), // Started from Telegram: keeps reporting there after switching to another chat. telegram_quiet: false, + summary: String::new(), + color: None, })?; update(app, &u.id, |st| st.session = Some(s.id.clone())); bot.send(chat, &format!("{} {} · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?; diff --git a/web/public/push-sw.mjs b/web/public/push-sw.mjs index b448705..769f1ce 100644 --- a/web/public/push-sw.mjs +++ b/web/public/push-sw.mjs @@ -7,6 +7,12 @@ self.addEventListener("push", (event) => { data = { body: event.data ? event.data.text() : "" }; } const title = data.title || "CodeCraft"; + const de = String((self.navigator && self.navigator.language) || "").toLowerCase().startsWith("de"); + // Approvals can be answered right from the notification. + const actions = data.kind === "permission" && data.permission_id ? [ + { action: "allow", title: de ? "Erlauben" : "Allow" }, + { action: "reject", title: de ? "Ablehnen" : "Reject" }, + ] : []; const prefix = { permission: "🔐 ", question: "❓ ", done: "✅ " }[data.kind] || ""; event.waitUntil( self.registration.showNotification(prefix + title, { @@ -15,13 +21,40 @@ self.addEventListener("push", (event) => { renotify: true, icon: "/icon-192.png", badge: "/icon-192.png", - data: { url: data.url || "/" }, + actions, + requireInteraction: data.kind === "permission", + data: { url: data.url || "/", permission_id: data.permission_id || null }, }), ); }); self.addEventListener("notificationclick", (event) => { event.notification.close(); + const info = event.notification.data || {}; + if ((event.action === "allow" || event.action === "reject") && info.permission_id) { + const de = String((self.navigator && self.navigator.language) || "").toLowerCase().startsWith("de"); + const target = new URL(info.url || "/", self.location.origin).href; + event.waitUntil( + fetch("/api/permissions/" + encodeURIComponent(info.permission_id), { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reply: event.action === "allow" ? "once" : "reject" }), + }) + .then((r) => { + if (!r.ok) throw new Error(String(r.status)); + // Short confirmation, so the tap does not feel lost. + return self.registration.showNotification(event.action === "allow" ? (de ? "Erlaubt" : "Allowed") : (de ? "Abgelehnt" : "Rejected"), { + tag: "perm-" + info.permission_id, + icon: "/icon-192.png", + silent: true, + data: { url: info.url }, + }).then(() => new Promise((res) => setTimeout(res, 2500))).then(() => self.registration.getNotifications({ tag: "perm-" + info.permission_id })).then((l) => l.forEach((n) => n.close())); + }) + .catch(() => self.clients.openWindow(target)), // expired, answered elsewhere or signed out: open the app + ); + return; + } const url = new URL((event.notification.data && event.notification.data.url) || "/", self.location.origin).href; event.waitUntil( self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => { diff --git a/web/src/App.tsx b/web/src/App.tsx index cb36903..013bdbe 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,8 @@ import { useT } from "./i18n"; import { navigate, useRoute, closePane, MAX_PANES } from "./router"; import { applyEvent, loadSessions, resync, setState } from "./store"; import type { Info } from "./types"; +import { ImageViewer } from "./components/ImageViewer"; +import { ProjectPage } from "./components/ProjectPage"; import { Sidebar } from "./components/Sidebar"; import { Login } from "./components/Login"; import { ChatView } from "./components/ChatView"; @@ -94,6 +96,7 @@ export function App() { ); else if (section === "settings") content = setMenuOpen(true)} />; + else if (section === "p" && arg) content = setMenuOpen(true)} />; else if (section === "stats") content = setMenuOpen(true)} />; else if (section === "images") content = setMenuOpen(true)} />; else if (section === "jobs" && arg) content = setMenuOpen(true)} />; @@ -119,6 +122,7 @@ export function App() {
{content}
+ {shared && setShared(null)} />} diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 6b9e6b6..90c0173 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -2,11 +2,11 @@ import { takePrefill } from "../share"; import { Icon } from "./Icon"; import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; -import { del, get, patch, post } from "../api"; +import { del, get, patch, post, put } from "../api"; import { useFetch, useModels } from "../hooks"; -import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n"; +import { fmtCompact, fmtCost, fmtMsgTime, fmtNumber, fmtPercent, useT, type Key } from "../i18n"; import { navigate } from "../router"; -import { dismissError, getState, loadAround, loadOlder, loadSession, loadSessions, useStore } from "../store"; +import { dismissError, getState, loadAround, loadChecks, loadOlder, loadSession, loadSessions, useStore } from "../store"; import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types"; import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity"; import { useReasoningMode, useTodosCollapsed } from "../prefs"; @@ -26,6 +26,13 @@ import { ShellsChip } from "./ShellsPanel"; import { TodoList, ToolCard } from "./ToolCard"; import { confirmDialog, promptDialog, toast } from "../dialogs"; import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs"; +import { ColorSwatches } from "./ColorSwatches"; +import { ProjectPermissions } from "./ProjectPermissions"; +import { OutboxBar } from "./OutboxBar"; +import { addToOutbox, isOffline } from "../outbox"; +import { TurnCheck } from "./TurnCheck"; +import { TurnChanges } from "./TurnChanges"; +import { ChatCtx, MessageMenu } from "./MessageMenu"; import { MediaGallery } from "./MediaGallery"; type ToolResult = Extract; @@ -283,11 +290,28 @@ const MessageList = memo(function MessageList({ // Finished turns with tool steps fold into one line (the answer stays visible). const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[]; const usage = !(busy && i === lastWork) && ; + // Files the agent wrote or edited in this turn: diffs and "roll back" (not while it is still working). + const changed = new Set(steps.filter((c) => (c.name === "write" || c.name === "edit") && typeof c.input?.path === "string").map((c) => c.input.path as string)); + const lastAnswer = [...b.messages].reverse().find((m) => m.role === "assistant"); + const checkBadge = lastAnswer && !(busy && i === lastWork) && ; + const prev = blocks[i - 1]; + const changes = changed.size > 0 && !(busy && i === lastWork) && ( + + ); if (i === lastWork || steps.length < 2) return (
{items} {usage} + {checkBadge} + {changes}
); return ( @@ -296,6 +320,8 @@ const MessageList = memo(function MessageList({ {items} {usage} + {checkBadge} + {changes} ); })} @@ -355,14 +381,29 @@ function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messa {answerText && ( <> + {answer && p.type === "tool_call")} />} + {answer && } )} ); } +/** Text parts of a message. */ +const textOfMessage = (m: Message) => (m.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n"); + +/** Small date/time line under a message. */ +function MsgTime({ ms }: { ms: number }) { + if (!ms) return null; + return ( + + ); +} + /** An assistant message, or a user message that only carries tool results / harness notes. */ const AssistantOrNote = memo(function AssistantOrNote({ m, @@ -383,7 +424,9 @@ const AssistantOrNote = memo(function AssistantOrNote({ if (m.role === "assistant") { return (
+ p.type === "tool_call")} /> + {regenerate && }
); @@ -427,15 +470,10 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight {background.map((p, i) => ( ))} + p.text).join("\n\n")} forkable onRewind={canEdit ? () => onRewind(m) : undefined} />
- {canEdit && ( -
- -
- )} p.text).join("\n\n")} /> + {imgs.length > 0 && (
{imgs.map((p, i) => ( @@ -516,6 +554,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string // The Telegram switch in the ⋯ menu only matters with a paired bot. const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false; const [editingInstructions, setEditingInstructions] = useState(false); + const [showApprovals, setShowApprovals] = useState(false); + const [planning, setPlanning] = useState(false); const models = useModels(); const [loadError, setLoadError] = useState(); const [text, setText] = useState(() => takePrefill(sessionId) ?? ""); @@ -537,11 +577,25 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); }; + // Scrolled away from the top: the jump buttons show up. + const [scrolled, setScrolled] = useState(false); + /** Scroll to the closest own message above the visible area (long chats on a phone). */ + const jumpPrevOwn = () => { + const el = scrollRef.current; + if (!el) return; + const top = el.getBoundingClientRect().top; + const above = [...el.querySelectorAll(".msg-user")].filter((n) => n.getBoundingClientRect().top < top - 12); + const target = above[above.length - 1]; + if (target) target.scrollIntoView({ behavior: "smooth", block: "start" }); + else el.scrollTo({ top: 0, behavior: "smooth" }); + }; + // Every chat starts following. useEffect(() => follow(true), [sessionId]); useEffect(() => { loadSession(sessionId).catch((e) => setLoadError(e.message)); + void loadChecks(sessionId); }, [sessionId]); const permissions = allPermissions.filter((p) => p.session_id === sessionId || p.root_session_id === sessionId); @@ -633,7 +687,12 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string setText(""); follow(true); } catch (e: any) { - toast(e.message); + // No connection: keep the text on this device and send it when the server is reachable again. + if (isOffline(e) && txt && images.length === 0) { + addToOutbox(sessionId, txt, effort || undefined); + setText(""); + toast(t("outbox.saved")); + } else toast(e.message); } }; @@ -648,6 +707,46 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string setText(m.parts.map((p) => (p.type === "text" ? p.text : "")).join("")); }; + /** New chats in this project start like this one (model, mode, colour, instructions, folder). */ + const saveDefaults = async (remove: boolean) => { + try { + if (remove) await del(`/sessions/${sessionId}/project-defaults`); + else await put(`/sessions/${sessionId}/project-defaults`, {}); + toast(t(remove ? "projectDefaults.removed" : "projectDefaults.saved"), "success"); + } catch (e: any) { + toast(e.message); + } + }; + + /** The project's check command (tests, build …), run after every turn that changed files. */ + const editCheck = async () => { + try { + const cur = await get<{ command: string; enabled: boolean }>(`/sessions/${sessionId}/check`); + const cmd = await promptDialog({ title: t("check.title"), message: t("check.hint", { project: session.cwd }), defaultValue: cur.command, placeholder: "cargo test", confirmLabel: t("common.save") }); + if (cmd === null) return; + await put(`/sessions/${sessionId}/check`, { command: cmd.trim(), enabled: cmd.trim() !== "" }); + toast(cmd.trim() ? t("check.saved") : t("check.removed"), "success"); + } catch (e: any) { + toast(e.message); + } + }; + + /** Draft an automation from this chat (small model), create it switched off and open its editor. */ + const planJob = async () => { + if (planning) return; + setPlanning(true); + toast(t("job.drafting")); + try { + const d = await post<{ name: string; prompt: string; cwd: string; agent: string }>(`/sessions/${sessionId}/job-draft`, {}); + const job = await post<{ id: string }>("/jobs/create", { cwd: d.cwd, name: d.name, prompt: d.prompt, agent: d.agent === "plan" ? undefined : d.agent }); + navigate(`/jobs/${job.id}`); + } catch (e: any) { + toast(e.message); + } finally { + setPlanning(false); + } + }; + const remove = async () => { if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; await del(`/sessions/${sessionId}`); @@ -670,8 +769,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined; return ( - <> -
+ +
@@ -718,7 +817,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string {!readOnly && ( <> {!session.parent_id && ( - )} @@ -732,11 +831,11 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string - {!session.parent_id && ( - )} @@ -745,6 +844,28 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
+ {/* The buttons that do not fit into the narrow bar. */} + {!session.parent_id && ( + (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, setSharing(true))}> + {t("share.title")} + + )} + (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, tabs.openFiles())}> + {t("files.newTab")} + + {!session.parent_id && !busy && ( + (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, forkSession())}> + {t("chat.fork")} + + )} + {!busy && ( + (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, post(`/sessions/${sessionId}/compact`).catch((err) => toast(err.message)))}> + {t("command.compact")} + + )} + (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, remove())}> + {t("chat.delete")} + { @@ -755,6 +876,22 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string > {t("instructions.title")} + void update({ color })} /> + (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void planJob())} title={t("job.planHint")}> + {t("job.plan")} + + (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void saveDefaults(false))}> + {t("projectDefaults.save")} + + (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void saveDefaults(true))}> + {t("projectDefaults.remove")} + + (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void editCheck())}> + {t("check.menu")} + + (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), setShowApprovals(true))}> + {t("permProject.menu")} + { @@ -804,10 +941,10 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string )}
- - @@ -831,7 +968,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string {searching && void jumpTo(id)} onClose={() => setSearching(false)} />} - + {showApprovals && setShowApprovals(false)} />} {editingInstructions && setEditingInstructions(false)} />} {editingTitle && setEditingTitle(false)} />} {sharing && setSharing(false)} />} {rewinding && setRewinding(undefined)} />} + {!readOnly && } {!readOnly && } {readOnly ? (
{t("chat.readOnlyHint")}
@@ -952,7 +1095,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string /> )}
- +
); } diff --git a/web/src/components/ColorSwatches.tsx b/web/src/components/ColorSwatches.tsx new file mode 100644 index 0000000..cacb88e --- /dev/null +++ b/web/src/components/ColorSwatches.tsx @@ -0,0 +1,29 @@ +import { useT, type Key } from "../i18n"; + +/** Colour tags of chats: names, drawn in the colours of the active theme (see `[data-color]` in styles.css). */ +export const SESSION_COLORS = ["red", "orange", "yellow", "green", "aqua", "blue", "purple"] as const; + +/** Row of colour dots; the selected one is ringed, a second click (or ✕) removes the tag. */ +export function ColorSwatches({ value, onChange }: { value?: string; onChange: (color: string) => void }) { + const t = useT(); + return ( +
+ {SESSION_COLORS.map((c) => ( +
+ ); +} diff --git a/web/src/components/Composer.tsx b/web/src/components/Composer.tsx index 1b1645e..89f53d5 100644 --- a/web/src/components/Composer.tsx +++ b/web/src/components/Composer.tsx @@ -10,6 +10,7 @@ import { ModelSelect } from "./ModelSelect"; import { EffortSelect } from "./Regenerate"; import { VoiceButton } from "./Voice"; import { Markdown } from "./Markdown"; +import { COMPOSE_EVENT, type ComposeDetail } from "../compose"; import { confirmDialog, promptDialog, toast } from "../dialogs"; interface Suggestion { @@ -207,6 +208,27 @@ export function Composer({ } }; + // Content sent from elsewhere (quote a message, image viewer): latest closures via refs. + const live = useRef({ text, addFiles, setText }); + live.current = { text, addFiles, setText }; + useEffect(() => { + const on = (e: Event) => { + const d = (e as CustomEvent).detail; + if (d.sessionId !== session.id) return; + if (d.text) live.current.setText(live.current.text.trim() ? `${live.current.text.replace(/\s+$/, "")}\n\n${d.text}` : d.text); + if (d.files?.length) void live.current.addFiles(d.files); + requestAnimationFrame(() => { + const el = ref.current; + if (!el) return; + setPreview(false); + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); + }); + }; + window.addEventListener(COMPOSE_EVENT, on); + return () => window.removeEventListener(COMPOSE_EVENT, on); + }, [session.id]); + const removeFile = (path: string) => { setFiles((cur) => cur.filter((f) => f.path !== path)); void fetch(`/api/files/delete?path=${encodeURIComponent(path)}`, { method: "DELETE", credentials: "same-origin" }); diff --git a/web/src/components/Icon.tsx b/web/src/components/Icon.tsx index 02b34e3..73ecab7 100644 --- a/web/src/components/Icon.tsx +++ b/web/src/components/Icon.tsx @@ -34,6 +34,7 @@ const P: Record = { download: , compact: , trash: , + reply: , edit: , undo: , clip: , diff --git a/web/src/components/ImageAnnotator.tsx b/web/src/components/ImageAnnotator.tsx new file mode 100644 index 0000000..e6ffda1 --- /dev/null +++ b/web/src/components/ImageAnnotator.tsx @@ -0,0 +1,160 @@ +import { useEffect, useRef, useState } from "react"; +import { useT } from "../i18n"; +import { toast } from "../dialogs"; +import { Icon } from "./Icon"; + +type Tool = "pen" | "arrow" | "rect"; +interface Shape { + tool: Tool; + color: string; + /** pen: all points; arrow/rect: start and end. */ + pts: { x: number; y: number }[]; +} + +const COLORS = ["#ff3b30", "#ffcc00", "#34c759", "#0a84ff", "#ffffff", "#000000"]; +const MAX_SIDE = 2000; + +function paint(ctx: CanvasRenderingContext2D, shapes: Shape[], w: number) { + const lw = Math.max(3, Math.round(w / 280)); + for (const s of shapes) { + ctx.strokeStyle = s.color; + ctx.fillStyle = s.color; + ctx.lineWidth = lw; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + const a = s.pts[0]; + const b = s.pts[s.pts.length - 1]; + ctx.beginPath(); + if (s.tool === "pen") { + ctx.moveTo(a.x, a.y); + for (const p of s.pts.slice(1)) ctx.lineTo(p.x, p.y); + if (s.pts.length === 1) ctx.lineTo(a.x + 0.1, a.y); + ctx.stroke(); + } else if (s.tool === "rect") { + ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y); + } else { + // Arrow: line with a filled head at the end. + const ang = Math.atan2(b.y - a.y, b.x - a.x); + const head = lw * 5; + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x - Math.cos(ang) * head * 0.6, b.y - Math.sin(ang) * head * 0.6); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(b.x, b.y); + ctx.lineTo(b.x - head * Math.cos(ang - 0.45), b.y - head * Math.sin(ang - 0.45)); + ctx.lineTo(b.x - head * Math.cos(ang + 0.45), b.y - head * Math.sin(ang + 0.45)); + ctx.closePath(); + ctx.fill(); + } + } +} + +/** Draw on an image (pen, arrow, rectangle) and hand the result back as a PNG file. */ +export function ImageAnnotator({ src, name, onDone, onCancel }: { src: string; name: string; onDone: (file: File) => void; onCancel: () => void }) { + const t = useT(); + const canvas = useRef(null); + const bitmap = useRef(undefined); + const [shapes, setShapes] = useState([]); + const [tool, setTool] = useState("pen"); + const [color, setColor] = useState(COLORS[0]); + const [ready, setReady] = useState(false); + const drawing = useRef(null); + + const redraw = (extra?: Shape | null) => { + const c = canvas.current; + const img = bitmap.current; + if (!c || !img) return; + const ctx = c.getContext("2d")!; + ctx.clearRect(0, 0, c.width, c.height); + ctx.drawImage(img, 0, 0, c.width, c.height); + paint(ctx, extra ? [...shapes, extra] : shapes, c.width); + }; + + useEffect(() => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + const c = canvas.current; + if (!c) return; + const scale = Math.min(1, MAX_SIDE / Math.max(img.naturalWidth || 1, img.naturalHeight || 1)); + c.width = Math.max(1, Math.round((img.naturalWidth || 300) * scale)); + c.height = Math.max(1, Math.round((img.naturalHeight || 200) * scale)); + bitmap.current = img; + setReady(true); + }; + img.onerror = () => { + toast(t("viewer.annotateFailed")); + onCancel(); + }; + img.src = src; + }, [src]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => redraw(), [shapes, ready]); // eslint-disable-line react-hooks/exhaustive-deps + + const point = (e: React.PointerEvent) => { + const c = canvas.current!; + const r = c.getBoundingClientRect(); + return { x: ((e.clientX - r.left) / r.width) * c.width, y: ((e.clientY - r.top) / r.height) * c.height }; + }; + + const finish = () => { + const c = canvas.current; + if (!c) return; + c.toBlob((blob) => { + if (!blob) return toast(t("viewer.annotateFailed")); + const base = name.replace(/\.[a-z0-9]{1,5}$/i, "") || "image"; + onDone(new File([blob], `${base}-marked.png`, { type: "image/png" })); + }, "image/png"); + }; + + return ( +
e.stopPropagation()} onWheel={(e) => e.stopPropagation()}> + { + e.currentTarget.setPointerCapture(e.pointerId); + drawing.current = { tool, color, pts: [point(e)] }; + }} + onPointerMove={(e) => { + const d = drawing.current; + if (!d) return; + const p = point(e); + d.pts = d.tool === "pen" ? [...d.pts, p] : [d.pts[0], p]; + redraw(d); + }} + onPointerUp={() => { + const d = drawing.current; + drawing.current = null; + if (d) setShapes((s) => [...s, d]); + }} + onPointerCancel={() => { + drawing.current = null; + redraw(); + }} + /> +
+ {(["pen", "arrow", "rect"] as Tool[]).map((k) => ( + + ))} + + {COLORS.map((c) => ( + + + + +
+
+ ); +} diff --git a/web/src/components/ImageViewer.tsx b/web/src/components/ImageViewer.tsx new file mode 100644 index 0000000..82036d0 --- /dev/null +++ b/web/src/components/ImageViewer.tsx @@ -0,0 +1,242 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Icon } from "./Icon"; +import { ImageAnnotator } from "./ImageAnnotator"; +import { compose } from "../compose"; +import { toast } from "../dialogs"; +import { useT } from "../i18n"; + +/** Images that open in the viewer: everything in the app, except UI chrome. */ +const SKIP = ".viewer-overlay, .sidebar, .topbar, button, .modal-backdrop .viewer, .model-viewer, .media-stage, .composer, .brand, .login"; + +interface Item { + src: string; + alt: string; +} + +const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v)); + +/** + * Popup image viewer: a click on any image of the app opens it fullscreen. + * Pinch / wheel / double tap to zoom, drag to pan, arrows or swipe for the other + * images of the same chat, Esc to close. Mounted once; listens for clicks globally. + */ +export function ImageViewer() { + const t = useT(); + const [list, setList] = useState([]); + const [idx, setIdx] = useState(0); + const [open, setOpen] = useState(false); + // Chat the image belongs to (for "use in message"); drawing mode. + const [sessionId, setSessionId] = useState(); + const [marking, setMarking] = useState(false); + const [view, setView] = useState({ x: 0, y: 0, s: 1 }); + const pointers = useRef(new Map()); + const gesture = useRef<{ d: number; s: number } | null>(null); + const moved = useRef(false); + const lastTap = useRef(0); + const swipe = useRef<{ x: number; y: number } | null>(null); + + const reset = useCallback(() => setView({ x: 0, y: 0, s: 1 }), []); + const close = useCallback(() => setOpen(false), []); + const go = useCallback( + (d: number) => { + setIdx((i) => clamp(i + d, 0, list.length - 1)); + reset(); + }, + [list.length, reset], + ); + + useEffect(() => { + const onClick = (e: MouseEvent) => { + const img = (e.target as HTMLElement | null)?.closest?.("img"); + if (!img || img.closest(SKIP) || e.defaultPrevented || e.button !== 0) return; + if (img.naturalWidth && img.naturalWidth < 24) return; // icons + e.preventDefault(); // the surrounding link would open a new tab + e.stopPropagation(); + const scope = img.closest(".chat, .files-main, .gallery, .main") ?? document; + const all = [...scope.querySelectorAll("img")].filter((i) => !i.closest(SKIP) && i.src && (i.naturalWidth === 0 || i.naturalWidth >= 24)); + const seen = new Set(); + const items: Item[] = []; + let start = 0; + for (const i of all) { + if (seen.has(i.currentSrc || i.src)) { + if (i === img) start = items.findIndex((x) => x.src === (i.currentSrc || i.src)); + continue; + } + seen.add(i.currentSrc || i.src); + if (i === img) start = items.length; + items.push({ src: i.currentSrc || i.src, alt: i.alt }); + } + setSessionId((img.closest("[data-session-id]") as HTMLElement | null)?.dataset.sessionId); + setMarking(false); + setList(items.length ? items : [{ src: img.currentSrc || img.src, alt: img.alt }]); + setIdx(Math.max(0, start)); + reset(); + setOpen(true); + }; + // Capture phase: before the link around a thumbnail navigates. + document.addEventListener("click", onClick, true); + return () => document.removeEventListener("click", onClick, true); + }, [reset]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (marking) return; + if (e.key === "Escape") close(); + else if (e.key === "ArrowLeft") go(-1); + else if (e.key === "ArrowRight") go(1); + else if (e.key === "+" || e.key === "=") setView((v) => ({ ...v, s: clamp(v.s * 1.25, 1, 8) })); + else if (e.key === "-") setView((v) => ({ ...v, s: clamp(v.s / 1.25, 1, 8), ...(v.s / 1.25 <= 1 ? { x: 0, y: 0 } : {}) })); + else return; + e.preventDefault(); + }; + window.addEventListener("keydown", onKey); + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + window.removeEventListener("keydown", onKey); + document.body.style.overflow = prev; + }; + }, [open, close, go, marking]); + + if (!open || !list[idx]) return null; + const cur = list[idx]; + + /** The image as a file for the composer (variants, questions about it, …). */ + async function useInChat() { + try { + const blob = await (await fetch(cur.src, { credentials: "same-origin" })).blob(); + const ext = blob.type.split("/")[1]?.split("+")[0] || "png"; + compose({ sessionId: sessionId!, files: [new File([blob], `image.${ext}`, { type: blob.type || "image/png" })] }); + toast(t("viewer.attached"), "success"); + close(); + } catch { + toast(t("viewer.annotateFailed")); + } + } + + const dist = () => { + const [a, b] = [...pointers.current.values()]; + return Math.hypot(a.x - b.x, a.y - b.y); + }; + + return ( +
{ + if (!moved.current) close(); + }} + onWheel={(e) => setView((v) => { + const s = clamp(v.s * (e.deltaY < 0 ? 1.15 : 1 / 1.15), 1, 8); + return s === 1 ? { x: 0, y: 0, s } : { ...v, s }; + })} + > + + {marking && ( + setMarking(false)} + onDone={(file) => { + compose({ sessionId: sessionId!, files: [file] }); + toast(t("viewer.attached"), "success"); + close(); + }} + /> + )} + {!marking && idx > 0 && ( + + )} + {!marking && idx < list.length - 1 && ( + + )} + {!marking && {cur.alt} 1 ? "grab" : "zoom-in" }} + onClick={(e) => e.stopPropagation()} + onPointerDown={(e) => { + e.currentTarget.setPointerCapture(e.pointerId); + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + moved.current = false; + if (pointers.current.size === 2) gesture.current = { d: dist(), s: view.s }; + else swipe.current = { x: e.clientX, y: e.clientY }; + }} + onPointerMove={(e) => { + const p = pointers.current.get(e.pointerId); + if (!p) return; + const dx = e.clientX - p.x; + const dy = e.clientY - p.y; + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (pointers.current.size === 2 && gesture.current) { + moved.current = true; + const s = clamp((gesture.current.s * dist()) / gesture.current.d, 1, 8); + setView((v) => (s === 1 ? { x: 0, y: 0, s } : { ...v, s })); + } else if (view.s > 1) { + if (Math.abs(dx) + Math.abs(dy) > 1) moved.current = true; + setView((v) => ({ ...v, x: v.x + dx, y: v.y + dy })); + } + }} + onPointerUp={(e) => { + pointers.current.delete(e.pointerId); + gesture.current = null; + const s0 = swipe.current; + swipe.current = null; + if (s0 && view.s === 1) { + const dx = e.clientX - s0.x; + const dy = e.clientY - s0.y; + if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) { + moved.current = true; + go(dx < 0 ? 1 : -1); + } else if (dy > 90 && Math.abs(dy) > Math.abs(dx) * 1.5) { + moved.current = true; + close(); + } + } + // Double tap / double click: zoom in or back. + if (!moved.current) { + const now = Date.now(); + if (now - lastTap.current < 300) setView((v) => (v.s > 1 ? { x: 0, y: 0, s: 1 } : { ...v, s: 2.5 })); + lastTap.current = now; + } + }} + onPointerCancel={(e) => { + pointers.current.delete(e.pointerId); + gesture.current = null; + }} + />} +
+ ); +} diff --git a/web/src/components/Interaction.tsx b/web/src/components/Interaction.tsx index fab73b8..f6c577a 100644 --- a/web/src/components/Interaction.tsx +++ b/web/src/components/Interaction.tsx @@ -1,7 +1,7 @@ import { Icon } from "./Icon"; import { useState } from "react"; import { post } from "../api"; -import { useT } from "../i18n"; +import { getLang, useT } from "../i18n"; import { navigate } from "../router"; import { useStore } from "../store"; import type { PermissionRequest, QuestionRequest } from "../types"; @@ -31,7 +31,24 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur const chosen = selected.filter(Boolean).length; const partial = hunkCount > 1 && chosen < hunkCount; - const reply = async (r: "once" | "always" | "reject") => { + const [explaining, setExplaining] = useState(false); + const [explanation, setExplanation] = useState(); + const [explainError, setExplainError] = useState(); + /** The small model explains in plain words what the action does (approving on a phone without reading code). */ + const explain = async () => { + setExplaining(true); + setExplainError(undefined); + try { + const r = await post<{ text: string }>(`/permissions/${req.id}/explain`, { lang: getLang() }); + setExplanation(r.text); + } catch (e: any) { + setExplainError(e.message); + } finally { + setExplaining(false); + } + }; + + const reply = async (r: "once" | "always" | "project" | "reject") => { setBusy(true); try { const hunks = r === "once" && partial ? selected.flatMap((on, i) => (on ? [i] : [])) : undefined; @@ -47,6 +64,10 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur ? t("permission.alwaysCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") }) : t("permission.alwaysTool", { tool: req.tool }); + // Dangerous commands are only ever allowed for the session (the server decides; this hides the button). + const dangerous = !!command && /\b(rm\s+-[a-zA-Z]*[rRf]|sudo|su|git\s+(push|reset|clean|rebase|checkout|restore)|docker|docker-compose|systemctl|chmod\s+-R|chown\s+-R|dd|mkfs|shutdown|reboot|kill|pkill|killall|crontab)\b|\|\s*(ba)?sh\b|publish/.test(command); + const projectLabel = req.tool === "bash" ? t("permission.projectCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") }) : t("permission.projectTool", { tool: req.tool }); + return (

@@ -72,6 +93,16 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur ) )} + {explanation ? ( +
+ {explanation} +
+ ) : ( + + )} + {explainError &&
{explainError}
} reply("always")}> {alwaysLabel} + {!dangerous && ( + + )} diff --git a/web/src/components/MessageMenu.tsx b/web/src/components/MessageMenu.tsx new file mode 100644 index 0000000..3febd48 --- /dev/null +++ b/web/src/components/MessageMenu.tsx @@ -0,0 +1,75 @@ +import { createContext, useContext } from "react"; +import { post } from "../api"; +import { asQuote, compose } from "../compose"; +import { useT } from "../i18n"; +import { navigate } from "../router"; +import { loadSessions } from "../store"; +import { toast } from "../dialogs"; +import { Icon } from "./Icon"; + +/** What the message menus of one chat need to know. */ +export const ChatCtx = createContext<{ sessionId: string; canFork: boolean; canReply: boolean }>({ sessionId: "", canFork: false, canReply: false }); + +/** + * Actions of one message: copy, quote into the reply, fork the chat from here and + * (for own messages) edit and resend. Hover on desktop, always faintly visible on touch. + */ +export function MessageMenu({ messageId, text, forkable, onRewind }: { messageId: string; text: string; forkable: boolean; onRewind?: () => void }) { + const t = useT(); + const { sessionId, canFork, canReply } = useContext(ChatCtx); + const close = (e: React.MouseEvent) => { + e.preventDefault(); + (e.currentTarget.closest("details") as HTMLDetailsElement).open = false; + }; + if (!text.trim() && !onRewind) return null; + return ( +
+ + + + +
+ ); +} diff --git a/web/src/components/NewSession.tsx b/web/src/components/NewSession.tsx index 0b65ebc..b236a9a 100644 --- a/web/src/components/NewSession.tsx +++ b/web/src/components/NewSession.tsx @@ -328,7 +328,8 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate const isCraft = task === "craft"; const s = await post<{ id: string }>("/sessions", { cwd, - model: model || undefined, + // The server default is not sent, so the project's own default model can apply. + model: model && model !== config?.default_model ? model : undefined, agent: task === "cad" ? "build" : task, clean_room: isCraft ? !deep : undefined, }); diff --git a/web/src/components/OutboxBar.tsx b/web/src/components/OutboxBar.tsx new file mode 100644 index 0000000..12ba45d --- /dev/null +++ b/web/src/components/OutboxBar.tsx @@ -0,0 +1,39 @@ +import { useEffect } from "react"; +import { useT } from "../i18n"; +import { flushOutbox, removeFromOutbox, useOutbox } from "../outbox"; +import { useStore } from "../store"; +import { Icon } from "./Icon"; + +/** Messages waiting for a connection: sent automatically once the server is reachable again. */ +export function OutboxBar({ sessionId }: { sessionId: string }) { + const t = useT(); + const items = useOutbox(sessionId); + const connected = useStore((s) => s.connected); + useEffect(() => { + if (!items.length) return; + const go = () => void flushOutbox(sessionId); + if (connected && navigator.onLine) go(); + window.addEventListener("online", go); + return () => window.removeEventListener("online", go); + }, [sessionId, connected, items.length]); + if (!items.length) return null; + return ( +
+
+ {t("outbox.waiting", { n: items.length })} + + +
+ {items.map((i) => ( +
+ {i.text.length > 120 ? `${i.text.slice(0, 120)}…` : i.text} + +
+ ))} +
+ ); +} diff --git a/web/src/components/ProjectPage.tsx b/web/src/components/ProjectPage.tsx new file mode 100644 index 0000000..0b0a41b --- /dev/null +++ b/web/src/components/ProjectPage.tsx @@ -0,0 +1,128 @@ +import { useMemo } from "react"; +import { useFetch } from "../hooks"; +import { fmtCost, fmtDate, fmtRelative, useT, type Key } from "../i18n"; +import { navigate } from "../router"; +import { useStore } from "../store"; +import type { Session } from "../types"; +import { Icon } from "./Icon"; +import { basename } from "./FileFigure"; + +interface Overview { + sessions: Record; + check: { command: string; enabled: boolean } | null; + defaults: { model?: string; agent?: string; permission_mode?: string; color?: string; instructions?: string; folder?: string } | null; + currency: string; +} + +const day = (ms: number) => new Date(ms).toDateString(); + +/** One project folder: its chats as a timeline (newest first) with what each one did. */ +export function ProjectPage({ cwd, onMenu }: { cwd: string; onMenu: () => void }) { + const t = useT(); + const all = useStore((s) => s.sessions); + const { data } = useFetch(`/projects/overview?cwd=${encodeURIComponent(cwd)}`); + const sessions = useMemo(() => all.filter((s) => s.cwd === cwd && !s.archived).sort((a, b) => b.updated_at - a.updated_at), [all, cwd]); + const archived = all.filter((s) => s.cwd === cwd && s.archived).length; + + // Day groups: "Today", "Yesterday", date. + const groups = useMemo(() => { + const out: { label: string; items: Session[] }[] = []; + const today = day(Date.now()); + const yesterday = day(Date.now() - 86_400_000); + for (const s of sessions) { + const d = day(s.updated_at); + const label = d === today ? t("project.today") : d === yesterday ? t("project.yesterday") : fmtDate(s.updated_at, false); + const last = out[out.length - 1]; + if (last?.label === label) last.items.push(s); + else out.push({ label, items: [s] }); + } + return out; + }, [sessions, t]); + + const totalCost = sessions.reduce((n, s) => n + (data?.sessions[s.id]?.cost ?? 0), 0); + const totalFiles = sessions.reduce((n, s) => n + (data?.sessions[s.id]?.files ?? 0), 0); + + const newChat = () => { + try { + localStorage.setItem("codecraft.lastDir", cwd); + } catch { + // Without storage the wizard starts at its usual folder. + } + navigate("/new"); + }; + + return ( + <> +
+ +
+

+ {basename(cwd) || "/"} +

+
{cwd}
+
+ +
+
+
+
+ {t("project.chats", { n: sessions.length })} + {totalFiles > 0 && {t("project.files", { n: totalFiles })}} + {data && totalCost > 0 && {fmtCost(totalCost, data.currency)}} + {data?.check?.enabled && ( + + {data.check.command} + + )} + {data?.defaults?.model && ( + + {data.defaults.model.split("/").pop()} + + )} + {archived > 0 && {t("project.archived", { n: archived })}} +
+ {sessions.length === 0 &&
{t("project.empty")}
} + {groups.map((g) => ( +
+

{g.label}

+
    + {g.items.map((s) => { + const info = data?.sessions[s.id]; + return ( +
  1. + (e.preventDefault(), navigate(`/s/${s.id}`))}> +
    + {s.title || t("session.untitled")} + + {fmtRelative(s.updated_at)} + +
    + {s.summary &&
    {s.summary}
    } +
    + {s.agent} + {s.model.split("/").pop()} + {!!info?.files && {t("project.files", { n: info.files })}} + {info?.check && ( + + {t(`check.${info.check}` as Key)} + + )} + {!!info?.checks_failed && {t("project.checksFailed", { n: info.checks_failed })}} + {!!info?.cost && data && {fmtCost(info.cost, data.currency)}} +
    +
    +
  2. + ); + })} +
+
+ ))} +
+
+ + ); +} diff --git a/web/src/components/ProjectPermissions.tsx b/web/src/components/ProjectPermissions.tsx new file mode 100644 index 0000000..4a3f47b --- /dev/null +++ b/web/src/components/ProjectPermissions.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from "react"; +import { api, get } from "../api"; +import { useT } from "../i18n"; +import { toast } from "../dialogs"; +import { Icon } from "./Icon"; + +/** "bash:git status" → "git status"; tool names stay. */ +const label = (key: string) => (key.startsWith("bash:") ? `$ ${key.slice(5)}` : key); + +/** What the user allowed "for this project" (all chats of the folder) – with a way to revoke it. */ +export function ProjectPermissions({ sessionId, cwd, onClose }: { sessionId: string; cwd: string; onClose: () => void }) { + const t = useT(); + const [keys, setKeys] = useState(); + useEffect(() => { + get(`/sessions/${sessionId}/approvals`).then(setKeys, (e) => (toast(e.message), setKeys([]))); + }, [sessionId]); + const revoke = async (key: string) => { + try { + await api(`/sessions/${sessionId}/approvals`, { method: "DELETE", json: { key } }); + setKeys((k) => k?.filter((x) => x !== key)); + } catch (e: any) { + toast(e.message); + } + }; + return ( +
+
e.stopPropagation()}> +

+ {t("permProject.title")} +

+

{t("permProject.intro")}

+
{cwd}
+ {keys === undefined ? ( +
…
+ ) : keys.length === 0 ? ( +

{t("permProject.empty")}

+ ) : ( +
    + {keys.map((k) => ( +
  • + {label(k)} + +
  • + ))} +
+ )} +
+ +
+
+
+ ); +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 8b1f32a..7db3523 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { ColorSwatches } from "./ColorSwatches"; import { Icon } from "./Icon"; import { StatusLeds } from "./StatusLeds"; import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme"; @@ -25,7 +26,7 @@ function groupKey(ms: number): Key { const GROUP_BY = "codecraft.sidebarGroupBy"; const COLLAPSED = "codecraft.sidebarCollapsed"; -const organise = (id: string, body: Partial> & { folder?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message)); +const organise = (id: string, body: Partial> & { folder?: string; color?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message)); /** One session in the list, with a menu to pin, file or archive it. */ function SessionRow({ s, active, attention, busy, folders }: { s: Session; active: boolean; attention: boolean; busy: boolean; folders: string[] }) { @@ -53,11 +54,12 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ }; return (
- )} + (setMenu(false), organise(s.id, { color }))} /> @@ -194,6 +197,11 @@ function GroupHeader({ )} {menu && (
e.stopPropagation()}> + {g.id.startsWith("project:") && ( + + )} diff --git a/web/src/components/TurnChanges.tsx b/web/src/components/TurnChanges.tsx new file mode 100644 index 0000000..3ea9064 --- /dev/null +++ b/web/src/components/TurnChanges.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import { get } from "../api"; +import { useT } from "../i18n"; +import type { Message } from "../types"; +import { Diff } from "./Diff"; +import { Icon } from "./Icon"; +import { basename } from "./FileFigure"; + +interface Change { + path: string; + created: boolean; + deleted?: boolean; + diff?: string; + truncated?: boolean; +} + +const count = (diff: string | undefined, sign: "+" | "-") => (diff ?? "").split("\n").filter((l) => l.startsWith(sign) && !l.startsWith(sign.repeat(3))).length; + +/** + * What one turn changed: files with their diffs (from the checkpoints the agent leaves before + * every write/edit) and a button to roll the chat and the files back to before the turn. + */ +export function TurnChanges({ + sessionId, + fromId, + untilId, + fileCount, + userMessage, + onRewind, +}: { + sessionId: string; + fromId: string; + untilId?: string; + fileCount: number; + userMessage?: Message; + onRewind?: (m: Message) => void; +}) { + const t = useT(); + const [open, setOpen] = useState(false); + const [changes, setChanges] = useState(); + const [error, setError] = useState(); + + const toggle = () => { + const next = !open; + setOpen(next); + if (next && !changes) { + const q = `since_message=${encodeURIComponent(fromId)}&diff=true${untilId ? `&until_message=${encodeURIComponent(untilId)}` : ""}`; + get(`/sessions/${sessionId}/changes?${q}`).then(setChanges, (e) => setError(e.message)); + } + }; + + return ( +
+ + {open && ( +
+ {error &&
{error}
} + {!changes && !error &&
…
} + {changes?.length === 0 &&
{t("turn.noChanges")}
} + {changes?.map((c) => ( +
+ + {basename(c.path)} + {c.created && {t("turn.new")}} + {c.deleted && {t("turn.deleted")}} + + +{count(c.diff, "+")} −{count(c.diff, "-")} + + + {c.diff ? :
{t("turn.noDiff")}
} + {c.truncated &&
{t("turn.truncated")}
} +
+ ))} + {userMessage && onRewind && changes && changes.length > 0 && ( + + )} +
+ )} +
+ ); +} diff --git a/web/src/components/TurnCheck.tsx b/web/src/components/TurnCheck.tsx new file mode 100644 index 0000000..678186d --- /dev/null +++ b/web/src/components/TurnCheck.tsx @@ -0,0 +1,43 @@ +import { useState } from "react"; +import { post } from "../api"; +import { toast } from "../dialogs"; +import { useT } from "../i18n"; +import { useStore } from "../store"; +import { Spinner } from "./Activity"; +import { Icon } from "./Icon"; + +/** Green/red badge of the project's check (tests, build …) for one turn; output and "let the agent fix it" on click. */ +export function TurnCheck({ sessionId, messageId, canSend }: { sessionId: string; messageId: string; canSend: boolean }) { + const t = useT(); + const check = useStore((s) => s.checks[sessionId]?.[messageId]); + const [open, setOpen] = useState(false); + if (!check) return null; + const seconds = check.duration_ms >= 1000 ? ` · ${(check.duration_ms / 1000).toFixed(check.duration_ms >= 10_000 ? 0 : 1)} s` : ""; + const label = + check.status === "running" ? t("check.running") : check.status === "passed" ? t("check.passed") : check.status === "timeout" ? t("check.timeout") : t("check.failed"); + const fix = async () => { + try { + await post(`/sessions/${sessionId}/prompt`, { text: `${t("check.fixPrompt")}\n\n\`\`\`\n${check.output}\n\`\`\`` }); + } catch (e: any) { + toast(e.message); + } + }; + return ( +
+ + {open && check.status !== "running" && ( +
+
{check.output || t("check.noOutput")}
+ {check.status !== "passed" && canSend && ( + + )} +
+ )} +
+ ); +} diff --git a/web/src/components/settings/Account.tsx b/web/src/components/settings/Account.tsx index f4c8326..2d141b9 100644 --- a/web/src/components/settings/Account.tsx +++ b/web/src/components/settings/Account.tsx @@ -6,6 +6,7 @@ import { GitAccount } from "./GitAccount"; import { ApiTokens } from "./ApiTokens"; import { CliInstall } from "./CliInstall"; import { Memory } from "./Memory"; +import { ClaudeCode } from "./ClaudeCode"; import { Telegram } from "./Telegram"; export function Account() { @@ -52,6 +53,7 @@ export function Account() { + diff --git a/web/src/components/settings/ClaudeCode.tsx b/web/src/components/settings/ClaudeCode.tsx new file mode 100644 index 0000000..b743e07 --- /dev/null +++ b/web/src/components/settings/ClaudeCode.tsx @@ -0,0 +1,191 @@ +import { useEffect, useState } from "react"; +import { api, get, post } from "../../api"; +import { confirmDialog, toast } from "../../dialogs"; +import { useT } from "../../i18n"; +import { Icon } from "../Icon"; +import { Spinner } from "../Activity"; +import { useStore } from "../../store"; + +interface Status { + installed: boolean; + logged_in?: boolean; + method?: string; + email?: string; + org?: string; + expires?: string; + login_in_progress?: boolean; + error?: string; + version?: string | null; + latest?: string | null; + update_available?: boolean; + output?: string; +} + +/** + * Claude Code: sign in from the browser. The CLI prints a sign-in URL; the Anthropic page shows + * a code after login that is pasted here and handed back to the CLI on the server. + */ +export function ClaudeCode() { + const t = useT(); + const [status, setStatus] = useState(); + const [url, setUrl] = useState(); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + const [console_, setConsole] = useState(false); + const isAdmin = useStore((s) => s.me?.role === "admin"); + const [updating, setUpdating] = useState(false); + const [checking, setChecking] = useState(false); + const [updateLog, setUpdateLog] = useState(); + + const check = async () => { + setChecking(true); + try { + setStatus(await get("/claude/status?refresh=true")); + } catch (e: any) { + toast(e.message); + } finally { + setChecking(false); + } + }; + const update = async () => { + setUpdating(true); + setUpdateLog(undefined); + try { + const r = await post("/claude/update", {}); + setStatus(r); + setUpdateLog(r.output); + toast(t("claude.updated", { version: r.version ?? "" }), "success"); + } catch (e: any) { + setUpdateLog(e.message); + toast(t("claude.updateFailed")); + } finally { + setUpdating(false); + } + }; + + const load = () => get("/claude/status").then(setStatus, (e) => setStatus({ installed: false, error: e.message })); + useEffect(() => void load(), []); + + const start = async () => { + setBusy(true); + try { + const r = await post<{ url: string }>("/claude/login", { console: console_ }); + setUrl(r.url); + setCode(""); + } catch (e: any) { + toast(e.message); + } finally { + setBusy(false); + } + }; + const submit = async () => { + setBusy(true); + try { + setStatus(await post("/claude/login/code", { code })); + setUrl(undefined); + setCode(""); + toast(t("claude.signedIn"), "success"); + } catch (e: any) { + toast(e.message); + } finally { + setBusy(false); + } + }; + const cancel = async () => { + await api("/claude/login", { method: "DELETE" }).catch(() => undefined); + setUrl(undefined); + setCode(""); + }; + const logout = async () => { + if (!(await confirmDialog({ message: t("claude.logoutConfirm"), danger: true, confirmLabel: t("claude.logout") }))) return; + try { + setStatus(await post("/claude/logout", {})); + } catch (e: any) { + toast(e.message); + } + }; + + return ( +
+

+ {t("claude.title")} + + {status?.logged_in && ( + + )} +

+
{t("claude.intro")}
+ {!status && } + {status && !status.installed &&
{status.error ?? t("claude.notInstalled")}
} + {status?.installed && ( +
+ {t("claude.version", { version: status.version ?? "?" })} + {status.update_available ? ( + <> + {t("claude.updateAvailable", { version: status.latest ?? "" })} + {isAdmin ? ( + + ) : ( + {t("claude.updateAdmin")} + )} + + ) : ( + status.latest && {t("claude.upToDate")} + )} + +
+ )} + {updateLog && ( +
+ {t("claude.updateLog")} +
{updateLog}
+
+ )} + {status?.installed && status.logged_in && ( +
+ + {t("claude.signedInAs", { who: status.email || status.org || status.method || "" })} + + {status.method && {status.method}} + {status.expires && {t("claude.expires", { date: new Date(status.expires).toLocaleDateString() })}} +
+ )} + {status?.installed && !status.logged_in && !url && ( + <> + + + + )} + {url && ( +
    +
  1. + + {t("claude.openLogin")} + +
    {t("claude.step1")}
    +
  2. +
  3. + {t("claude.step2")} +
    + setCode(e.target.value)} onKeyDown={(e) => e.key === "Enter" && code.trim() && void submit()} /> + + +
    +
  4. +
+ )} +
+ ); +} diff --git a/web/src/compose.ts b/web/src/compose.ts new file mode 100644 index 0000000..697a1a4 --- /dev/null +++ b/web/src/compose.ts @@ -0,0 +1,20 @@ +/** Put content into the composer of a chat from anywhere in the app (quote, image viewer, …). */ +export interface ComposeDetail { + sessionId: string; + /** Appended to the text field (a blank line before it if there is text already). */ + text?: string; + /** Attached like dropped files (images stay images). */ + files?: File[]; +} + +export const COMPOSE_EVENT = "codecraft:compose"; + +export function compose(detail: ComposeDetail) { + window.dispatchEvent(new CustomEvent(COMPOSE_EVENT, { detail })); +} + +/** "> quoted\n> lines\n\n" for the composer. */ +export function asQuote(text: string) { + const lines = text.trim().split("\n").map((l) => `> ${l}`.trimEnd()); + return `${lines.join("\n")}\n\n`; +} diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index 6afb50a..33d78eb 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -366,6 +366,98 @@ export const de: Messages = { "media.showInChat": "Im Chat zeigen", "media.open": "Öffnen", "media.prev": "Vorheriges", + "msg.actions": "Nachrichtenaktionen", + "msg.copy": "Text kopieren", + "msg.copied": "Kopiert", + "msg.copyFailed": "Kopieren ist hier nicht möglich", + "msg.quote": "In Antwort zitieren", + "msg.forkHere": "Ab hier abzweigen", + "chat.jumpPrevOwn": "Vorherige eigene Nachricht", + "viewer.annotate": "Markieren", + "viewer.annotateFailed": "Dieses Bild lässt sich nicht bearbeiten", + "viewer.attach": "An Nachricht anhängen", + "viewer.undo": "Rückgängig", + "viewer.tool.pen": "Stift", + "viewer.tool.arrow": "Pfeil", + "viewer.tool.rect": "Rechteck", + "viewer.useInChat": "In Nachricht verwenden", + "viewer.attached": "Bild an die Nachricht angehängt", + "turn.changes": "{n} Datei(en) geändert", + "turn.noChanges": "Für diesen Schritt wurden keine Dateiänderungen gefunden.", + "turn.noDiff": "Kein Text-Diff verfügbar.", + "turn.truncated": "Der Diff ist gekürzt.", + "turn.new": "neu", + "turn.deleted": "gelöscht", + "turn.rollback": "Auf den Stand vor diesem Schritt zurücksetzen", + "job.plan": "Als Automatisierung planen", + "job.planHint": "Macht aus diesem Chat Anweisungen für eine wiederkehrende Automatisierung (aus, bis du den Zeitplan setzt)", + "job.drafting": "Automatisierung wird aus diesem Chat entworfen …", + "color.label": "Farbe dieses Chats", + "color.red": "Rot", + "color.orange": "Orange", + "color.yellow": "Gelb", + "color.green": "Grün", + "color.aqua": "Türkis", + "color.blue": "Blau", + "color.purple": "Violett", + "claude.title": "Claude Code", + "claude.intro": "Chats mit einem „Claude Code“-Modell führt die Claude-Code-CLI auf dem Server aus – mit deinem Claude-Abo oder Anthropic-Console-Konto. Einmal hier anmelden; Freigaben, Push und Sandbox funktionieren wie gewohnt.", + "claude.notInstalled": "Claude Code ist auf dem Server nicht installiert.", + "claude.console": "Anthropic Console (API-Abrechnung) statt Claude-Abo", + "claude.signIn": "Anmelden", + "claude.openLogin": "Anmeldeseite öffnen", + "claude.step1": "Bei Anthropic in einem beliebigen Browser (auch auf einem anderen Gerät) anmelden und den Zugriff erlauben.", + "claude.step2": "Den Code einfügen, den die Seite danach anzeigt:", + "claude.codePlaceholder": "Code von der Anmeldeseite", + "claude.confirm": "Bestätigen", + "claude.signedIn": "Bei Claude Code angemeldet", + "claude.signedInAs": "Angemeldet: {who}", + "claude.expires": "gültig bis {date}", + "claude.logout": "Abmelden", + "claude.logoutConfirm": "Auf dem Server von Claude Code abmelden?", + "claude.version": "CLI {version}", + "claude.updateAvailable": "Update verfügbar: {version}", + "claude.upToDate": "aktuell", + "claude.update": "Update installieren", + "claude.updating": "Installiere …", + "claude.updated": "Claude Code auf {version} aktualisiert", + "claude.updateFailed": "Das Update ist fehlgeschlagen (Details unten)", + "claude.updateAdmin": "Ein Administrator kann es installieren.", + "claude.checkNow": "Nach Updates suchen", + "claude.updateLog": "Ausgabe des Updates", + "check.menu": "Prüfung nach jedem Schritt …", + "check.title": "Prüfung nach jedem Schritt", + "check.hint": "Befehl, der in {project} nach jedem Schritt mit Dateiänderungen läuft (z. B. cargo test, npm test). Das Ergebnis erscheint als Badge am Schritt. Leer = aus.", + "check.saved": "Prüfung gespeichert", + "check.removed": "Prüfung entfernt", + "check.running": "Prüfung läuft …", + "check.passed": "Prüfung bestanden", + "check.failed": "Prüfung fehlgeschlagen", + "check.timeout": "Prüfung: Zeitüberschreitung", + "check.noOutput": "(keine Ausgabe)", + "check.fix": "Vom Agenten beheben lassen", + "check.fixPrompt": "Die Prüfung des Projekts ist nach deiner letzten Änderung fehlgeschlagen. Bitte beheben und erneut ausführen. Ausgabe:", + "projectDefaults.save": "Als Projekt-Standard verwenden", + "projectDefaults.remove": "Projekt-Standard entfernen", + "projectDefaults.hint": "Neue Chats in diesem Projektordner starten mit Modell, Modus, Farbe, Anweisungen und Ordner dieses Chats", + "projectDefaults.saved": "Neue Chats in diesem Projekt starten wie dieser", + "projectDefaults.removed": "Projekt-Standard entfernt", + "outbox.waiting": "{n} Nachricht(en) warten auf eine Verbindung", + "outbox.sendNow": "Jetzt senden", + "outbox.discard": "Verwerfen", + "outbox.saved": "Keine Verbindung – die Nachricht ist gespeichert und wird gesendet, sobald du wieder online bist", + "project.open": "Projektübersicht", + "project.newChat": "Neuer Chat", + "project.today": "Heute", + "project.yesterday": "Gestern", + "project.chats": "{n} Chat(s)", + "project.files": "{n} Datei(en) geändert", + "project.archived": "{n} archiviert", + "project.checksFailed": "{n} Prüfung(en) fehlgeschlagen", + "project.empty": "In diesem Ordner gibt es noch keine Chats.", + "viewer.title": "Bildbetrachter", + "viewer.open": "In neuem Tab öffnen", + "viewer.download": "Herunterladen", "media.next": "Nächstes", "media.back": "Zurück zur Übersicht", "chat.turnUsageHint": "Alle {n} Anfragen dieser Antwort (Tokens gesendet/empfangen, Kosten)", @@ -724,6 +816,16 @@ export const de: Messages = { "permission.title": "Freigabe erforderlich", "permission.allowOnce": "Einmal erlauben", + "permission.projectCommand": "„{prefix}“ in diesem Projekt immer erlauben", + "permission.projectTool": "{tool} in diesem Projekt immer erlauben", + "permission.projectHint": "Wird für diesen Projektordner gemerkt: in allen Chats, auch nach einem Neustart. Widerrufen im ⋯-Menü.", + "permission.explain": "Was macht das?", + "permission.explaining": "Erkläre …", + "permProject.title": "Projekt-Freigaben", + "permProject.intro": "In diesem Projektordner ohne Rückfrage erlaubt (alle Chats):", + "permProject.empty": "Für dieses Projekt ist noch nichts freigegeben. Der Knopf „… in diesem Projekt immer erlauben“ bei einer Freigabe-Anfrage legt hier Einträge an.", + "permProject.revoke": "Widerrufen", + "permProject.menu": "Projekt-Freigaben", "permission.alwaysTool": "{tool} in dieser Session immer erlauben", "permission.alwaysCommand": "„{prefix}“ in dieser Session immer erlauben", "permission.reject": "Ablehnen", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 0fa263d..95fb5e5 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -366,6 +366,98 @@ export const en = { "media.open": "Open", "media.prev": "Previous", "media.next": "Next", + "msg.actions": "Message actions", + "msg.copy": "Copy text", + "msg.copied": "Copied", + "msg.copyFailed": "Copying is not possible here", + "msg.quote": "Quote in reply", + "msg.forkHere": "Fork from here", + "chat.jumpPrevOwn": "Previous own message", + "viewer.annotate": "Mark up", + "viewer.annotateFailed": "This image cannot be edited", + "viewer.attach": "Attach to message", + "viewer.undo": "Undo", + "viewer.tool.pen": "Pen", + "viewer.tool.arrow": "Arrow", + "viewer.tool.rect": "Rectangle", + "viewer.useInChat": "Use in message", + "viewer.attached": "Image attached to the message", + "turn.changes": "{n} file(s) changed", + "turn.noChanges": "No file changes found for this turn.", + "turn.noDiff": "No text diff available.", + "turn.truncated": "The diff is shortened.", + "turn.new": "new", + "turn.deleted": "deleted", + "turn.rollback": "Roll back to before this turn", + "job.plan": "Plan as automation", + "job.planHint": "Turns this chat into instructions for a recurring automation (switched off until you set the schedule)", + "job.drafting": "Drafting the automation from this chat …", + "color.label": "Colour of this chat", + "color.red": "Red", + "color.orange": "Orange", + "color.yellow": "Yellow", + "color.green": "Green", + "color.aqua": "Teal", + "color.blue": "Blue", + "color.purple": "Purple", + "claude.title": "Claude Code", + "claude.intro": "Chats with a \"Claude Code\" model are run by the Claude Code CLI on the server – with your Claude subscription or Anthropic Console account. Sign in here once; approvals, push and the sandbox work as usual.", + "claude.notInstalled": "Claude Code is not installed on the server.", + "claude.console": "Anthropic Console (API billing) instead of the Claude subscription", + "claude.signIn": "Sign in", + "claude.openLogin": "Open the sign-in page", + "claude.step1": "Sign in at Anthropic in any browser (also on another device) and allow access.", + "claude.step2": "Paste the code the page shows afterwards:", + "claude.codePlaceholder": "Code from the sign-in page", + "claude.confirm": "Confirm", + "claude.signedIn": "Signed in to Claude Code", + "claude.signedInAs": "Signed in: {who}", + "claude.expires": "valid until {date}", + "claude.logout": "Sign out", + "claude.logoutConfirm": "Sign out of Claude Code on the server?", + "claude.version": "CLI {version}", + "claude.updateAvailable": "Update available: {version}", + "claude.upToDate": "up to date", + "claude.update": "Install update", + "claude.updating": "Installing …", + "claude.updated": "Claude Code updated to {version}", + "claude.updateFailed": "The update failed (details below)", + "claude.updateAdmin": "An administrator can install it.", + "claude.checkNow": "Check for updates", + "claude.updateLog": "Output of the update", + "check.menu": "Check after each turn …", + "check.title": "Check after each turn", + "check.hint": "Command that runs in {project} after every turn that changed files (e.g. cargo test, npm test). The result appears as a badge on the turn. Empty = off.", + "check.saved": "Check saved", + "check.removed": "Check removed", + "check.running": "Check running …", + "check.passed": "Check passed", + "check.failed": "Check failed", + "check.timeout": "Check timed out", + "check.noOutput": "(no output)", + "check.fix": "Let the agent fix it", + "check.fixPrompt": "The check of the project failed after your last change. Please fix the cause and run it again. Output:", + "projectDefaults.save": "Use as project default", + "projectDefaults.remove": "Remove project default", + "projectDefaults.hint": "New chats in this project folder start with this chat's model, mode, colour, instructions and folder", + "projectDefaults.saved": "New chats in this project will start like this one", + "projectDefaults.removed": "Project default removed", + "outbox.waiting": "{n} message(s) waiting for a connection", + "outbox.sendNow": "Send now", + "outbox.discard": "Discard", + "outbox.saved": "No connection – the message is saved and sent when you are back online", + "project.open": "Project overview", + "project.newChat": "New chat", + "project.today": "Today", + "project.yesterday": "Yesterday", + "project.chats": "{n} chat(s)", + "project.files": "{n} file(s) changed", + "project.archived": "{n} archived", + "project.checksFailed": "{n} failed check(s)", + "project.empty": "No chats in this folder yet.", + "viewer.title": "Image viewer", + "viewer.open": "Open in new tab", + "viewer.download": "Download", "media.back": "Back to the overview", "chat.turnUsageHint": "All {n} requests of this answer (tokens sent/received, cost)", "chat.reviewRound": "Review {round}: changes requested", @@ -723,6 +815,16 @@ export const en = { "permission.title": "Permission required", "permission.allowOnce": "Allow once", + "permission.projectCommand": "Always allow \"{prefix}\" in this project", + "permission.projectTool": "Always allow {tool} in this project", + "permission.projectHint": "Remembered for this project folder, in all chats, also after a restart. Revoke it in the ⋯ menu.", + "permission.explain": "What does this do?", + "permission.explaining": "Explaining …", + "permProject.title": "Project permissions", + "permProject.intro": "Allowed without asking in this project folder (all chats):", + "permProject.empty": "Nothing is allowed for this project yet. The button \"Always allow … in this project\" on an approval adds entries here.", + "permProject.revoke": "Revoke", + "permProject.menu": "Project permissions", "permission.alwaysTool": "Always allow {tool} in this session", "permission.alwaysCommand": "Always allow \"{prefix}\" in this session", "permission.reject": "Reject", diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts index 8e6b2a8..9359ffc 100644 --- a/web/src/i18n/index.ts +++ b/web/src/i18n/index.ts @@ -72,6 +72,13 @@ export function fmtCompact(n: number) { return new Intl.NumberFormat(current, { notation: "compact", maximumFractionDigits: 1 }).format(n); } +/** Short date and time of a message: only the time for today. */ +export function fmtMsgTime(ms: number) { + const d = new Date(ms); + const today = d.toDateString() === new Date().toDateString(); + return new Intl.DateTimeFormat(current, today ? { timeStyle: "short" } : { dateStyle: "short", timeStyle: "short" }).format(d); +} + export function fmtCost(n: number, currency: string) { const digits = n !== 0 && Math.abs(n) < 1 ? 4 : 2; try { diff --git a/web/src/outbox.ts b/web/src/outbox.ts new file mode 100644 index 0000000..a4d89c7 --- /dev/null +++ b/web/src/outbox.ts @@ -0,0 +1,85 @@ +import { useSyncExternalStore } from "react"; +import { ApiError, post } from "./api"; + +/** Messages written without a connection: kept on this device and sent when it is back. */ +export interface OutboxItem { + id: string; + sessionId: string; + text: string; + effort?: string; + createdAt: number; +} + +const KEY = "codecraft.outbox"; +const listeners = new Set<() => void>(); +let cache: OutboxItem[] | undefined; + +function load(): OutboxItem[] { + if (cache) return cache; + try { + const v = JSON.parse(localStorage.getItem(KEY) ?? "[]"); + cache = Array.isArray(v) ? v : []; + } catch { + cache = []; + } + return cache!; +} + +function save(items: OutboxItem[]) { + cache = items; + try { + localStorage.setItem(KEY, JSON.stringify(items)); + } catch { + // Storage blocked: the list still works until the page is closed. + } + listeners.forEach((l) => l()); +} + +export function addToOutbox(sessionId: string, text: string, effort?: string) { + save([...load(), { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, sessionId, text, effort, createdAt: Date.now() }]); +} + +export function removeFromOutbox(id: string) { + save(load().filter((i) => i.id !== id)); +} + +export function useOutbox(sessionId: string): OutboxItem[] { + const all = useSyncExternalStore( + (l) => { + listeners.add(l); + return () => listeners.delete(l); + }, + () => load(), + ); + return all.filter((i) => i.sessionId === sessionId); +} + +/** True for failures of the connection itself (not answers of the server). */ +export function isOffline(e: unknown) { + return !(e instanceof ApiError) && (!navigator.onLine || e instanceof TypeError); +} + +let flushing = false; + +/** Send the waiting messages of a chat in order; stops at the first connection failure. Returns how many were sent. */ +export async function flushOutbox(sessionId: string): Promise { + if (flushing) return 0; + flushing = true; + let sent = 0; + try { + for (const item of load().filter((i) => i.sessionId === sessionId)) { + try { + await post(`/sessions/${item.sessionId}/prompt`, { text: item.text, effort: item.effort }); + removeFromOutbox(item.id); + sent++; + } catch (e) { + if (isOffline(e)) break; + // The server refused it (deleted chat, read-only …): it would never go through. + removeFromOutbox(item.id); + } + } + } finally { + flushing = false; + } + return sent; +} diff --git a/web/src/store.ts b/web/src/store.ts index caad56f..9470c55 100644 --- a/web/src/store.ts +++ b/web/src/store.ts @@ -9,6 +9,7 @@ import type { PermissionRequest, QuestionRequest, ServerEvent, + TurnCheckInfo, Session, } from "./types"; @@ -51,6 +52,8 @@ export interface State { waiting: Record; /** Compaction running in a session (auto = context nearly full). */ compacting: Record; + /** Check results per session, by the id of the turn's last answer. */ + checks: Record | undefined>; } let state: State = { @@ -73,6 +76,7 @@ let state: State = { steps: {}, waiting: {}, compacting: {}, + checks: {}, }; const listeners = new Set<() => void>(); @@ -203,6 +207,11 @@ export function applyEvent(ev: ServerEvent) { }, })); break; + case "check_result": { + const info: TurnCheckInfo = { message_id: ev.message_id, status: ev.status, exit_code: ev.exit_code, duration_ms: ev.duration_ms, output: ev.output }; + setState((s) => ({ checks: { ...s.checks, [ev.session_id]: { ...s.checks[ev.session_id], [ev.message_id]: info } } })); + break; + } case "mcp_status": setState(() => ({ mcp: ev.servers })); break; @@ -259,6 +268,12 @@ export async function loadSession(id: string) { })); } +/** Check results of a chat (badges on its turns). */ +export async function loadChecks(id: string) { + const list = await get(`/sessions/${id}/checks`).catch(() => [] as TurnCheckInfo[]); + setState((s) => ({ checks: { ...s.checks, [id]: { ...Object.fromEntries(list.map((c) => [c.message_id, c])), ...s.checks[id] } } })); +} + /** Older messages before the loaded ones (scrolling up). Returns how many came. */ export async function loadOlder(id: string, stopAtSummary = true): Promise { const first = getState().messages[id]?.[0]; diff --git a/web/src/styles.css b/web/src/styles.css index acf84a5..702f029 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -439,6 +439,9 @@ button.session-group:hover { .main { flex: 1; min-width: 0; + /* Nothing in the bars may widen the page (and push it sideways on phones). */ + overflow-x: clip; + max-width: 100vw; display: flex; flex-direction: column; min-height: 0; @@ -518,6 +521,31 @@ button.session-group:hover { .msg { margin-bottom: 14px; + position: relative; +} +/* Message actions: hover on desktop, faintly visible on touch. */ +.msg-menu { + position: absolute; + top: 2px; + right: 2px; + z-index: 3; + opacity: 0; + transition: opacity 0.12s; +} +.msg:hover > .msg-menu, +.msg-menu:focus-within, +.msg-menu[open] { + opacity: 1; +} +@media (pointer: coarse) { + .msg-menu { + opacity: 0.5; + } +} +.msg-menu > summary { + background: var(--bg1); + border: 1px solid var(--bg2); + line-height: 0; } .msg-user { background: var(--bg1); @@ -542,14 +570,16 @@ button.session-group:hover { background: var(--bg1); cursor: text; } -.msg-user .msg-actions { - position: absolute; - top: 4px; - right: 4px; - display: none; -} -.msg-user:hover .msg-actions { +.msg-time { display: block; + margin-top: 4px; + font-size: 11px; + color: var(--muted); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.msg-user .msg-time { + text-align: right; } .msg-user img { max-height: 160px; @@ -1357,6 +1387,23 @@ table.grid input[type="number"] { .topbar .path { display: none; } + .topbar .grow { + min-width: 0; + } + .topbar .chip { + flex-shrink: 1; + min-width: 0; + max-width: 30vw; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .menu-pop-body { + max-width: calc(100vw - 16px); + } + .menu-pop-body .mobile-only { + display: block; + } .topbar button.small { padding: 2px 5px; } @@ -2526,6 +2573,7 @@ button.danger-solid { height: 0; display: flex; justify-content: center; + gap: 10px; pointer-events: none; z-index: 4; } @@ -4439,3 +4487,386 @@ span.chat-tab > button { height: 64px; color: var(--accent); } + +/* --- image viewer ------------------------------------------------------------ */ +.viewer-overlay { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.92); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + touch-action: none; + overscroll-behavior: contain; +} +.viewer-img { + max-width: 100vw; + max-height: 100dvh; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; + touch-action: none; + transition: transform 0.08s ease-out; + background: repeating-conic-gradient(#222 0% 25%, #111 0% 50%) 50% / 16px 16px; +} +.viewer-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 1; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + padding-top: max(8px, env(safe-area-inset-top)); + background: linear-gradient(rgba(0, 0, 0, 0.6), transparent); + color: #eee; + font-size: 13px; +} +.viewer-bar .button, +.viewer-bar button { + color: #eee; +} +.viewer-count { + font-variant-numeric: tabular-nums; + min-width: 3.5em; +} +.viewer-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.8; +} +.viewer-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 1; + width: 40px; + height: 56px; + border: none; + border-radius: 8px; + background: rgba(0, 0, 0, 0.45); + color: #eee; +} +.viewer-nav.prev { + left: 6px; +} +.viewer-nav.next { + right: 6px; +} +@media (pointer: coarse) { + .viewer-nav { + display: none; + } +} +/* Thumbnails invite a tap. */ +.chat img.inline-image, +.image-refs img, +.user-files img, +.image-result img, +.svg-block img { + cursor: zoom-in; +} + +/* --- image markup ------------------------------------------------------------ */ +.annotator { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: 52px 8px 64px; +} +.annotator canvas { + max-width: 100%; + max-height: 100%; + object-fit: contain; + touch-action: none; + cursor: crosshair; + background: #111; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15); +} +.annotator-tools { + position: absolute; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 8px 10px; + padding-bottom: max(8px, env(safe-area-inset-bottom)); + background: rgba(0, 0, 0, 0.75); +} +.annotator-colors { + display: inline-flex; + gap: 6px; + margin: 0 6px; +} +.annotator-colors .swatch { + width: 22px; + height: 22px; + padding: 0; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.35); +} +.annotator-colors .swatch.on { + border-color: #fff; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.35); +} + +/* --- approvals: explanation, session summary ------------------------------------ */ +.explain { + margin: 8px 0 0; + padding: 6px 10px; + border-left: 3px solid var(--purple); + border-radius: 4px; + background: var(--bg1); + font-size: 12.5px; + color: var(--fg2); +} +.explain .icon { + color: var(--purple); +} +.explain-btn { + margin-top: 6px; +} +.session-item .summary { + font-size: 12px; + color: var(--fg3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* --- changes of one turn ------------------------------------------------------- */ +.turn-changes { + margin: -6px 0 12px; +} +.turn-changes-body { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; + padding: 6px 8px; + border: 1px solid var(--bg2); + border-radius: var(--radius); + background: var(--bg1); +} +.turn-changes-body > button { + align-self: flex-start; +} +.turn-file > summary { + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; +} +.turn-file .turn-stat { + margin-left: auto; + font-variant-numeric: tabular-nums; +} +.turn-file .turn-stat .add { + color: var(--green); +} +.turn-file .turn-stat .del { + color: var(--red); +} +.turn-file .diff { + max-height: 50vh; + overflow: auto; +} + +/* --- colour tags of chats ------------------------------------------------------ + * The tag is a name; the colour comes from the active theme (--red, --accent, …), + * the background is mixed into --bg0, so every theme (light or dark) stays readable. */ +[data-color="red"] { --tag: var(--red); } +[data-color="orange"] { --tag: var(--accent2, var(--red)); } +[data-color="yellow"] { --tag: var(--accent, var(--green)); } +[data-color="green"] { --tag: var(--green); } +[data-color="aqua"] { --tag: var(--aqua); } +[data-color="blue"] { --tag: var(--blue); } +[data-color="purple"] { --tag: var(--purple); } + +.chat-pane[data-color] { + background: color-mix(in srgb, var(--tag) 9%, var(--bg0)); +} +.topbar[data-color] { + border-bottom: 2px solid var(--tag); + background: color-mix(in srgb, var(--tag) 14%, var(--bg0)); +} +.session-item[data-color] { + box-shadow: inset 3px 0 0 var(--tag); +} +.color-badge { + padding: 0 7px; + border-radius: 999px; + color: var(--fg0); + background: color-mix(in srgb, var(--tag) 30%, var(--bg1)); + border: 1px solid color-mix(in srgb, var(--tag) 60%, transparent); +} +.color-swatches { + display: flex; + gap: 8px; + padding: 6px 10px; +} +.color-swatches .swatch { + width: 20px; + height: 20px; + padding: 0; + border-radius: 50%; + background: var(--tag); + border: 2px solid transparent; +} +.color-swatches .swatch.on { + border-color: var(--fg0); + box-shadow: 0 0 0 2px var(--bg1), 0 0 0 4px var(--tag); +} +.chat-pane[data-color] .composer-wrap { + background: color-mix(in srgb, var(--tag) 9%, var(--bg0)); +} + +/* --- check badge of a turn ------------------------------------------------------ */ +.turn-check { + margin: -6px 0 12px; +} +.turn-check .chip { + border: none; + cursor: pointer; +} +.turn-check .check-passed { + color: var(--green); +} +.turn-check .check-failed, +.turn-check .check-timeout { + color: var(--red); +} +.turn-check-body { + margin-top: 4px; + padding: 6px 8px; + border: 1px solid var(--bg2); + border-radius: var(--radius); + background: var(--bg1); +} +.turn-check-body pre { + margin: 0 0 6px; + max-height: 40vh; + overflow: auto; + white-space: pre-wrap; + font-size: 12px; +} + +/* --- messages waiting for a connection ------------------------------------------ */ +.outbox { + margin: 0 12px 6px; + padding: 6px 10px; + border: 1px dashed var(--accent2, var(--accent)); + border-radius: var(--radius); + background: var(--bg1); + font-size: 12.5px; +} +.outbox-head, +.outbox-item { + display: flex; + align-items: center; + gap: 8px; +} +.outbox-item span { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg3); +} + +/* --- project page: timeline -------------------------------------------------------- */ +.project-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} +.timeline-day h3 { + margin: 14px 0 6px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); +} +.timeline { + list-style: none; + margin: 0; + padding: 0 0 0 14px; + border-left: 2px solid var(--bg3); +} +.timeline li { + position: relative; + margin: 0 0 8px; +} +.timeline li::before { + content: ""; + position: absolute; + left: -20px; + top: 12px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--tag, var(--bg3)); + border: 2px solid var(--bg0); +} +.timeline li > a { + display: block; + padding: 8px 12px; + border: 1px solid var(--bg2); + border-radius: var(--radius); + background: var(--bg1); + color: var(--fg1); + text-decoration: none; +} +.timeline li > a:hover { + border-color: var(--bg3); +} +.tl-title { + display: flex; + align-items: baseline; + gap: 8px; + color: var(--fg0); + font-weight: 600; +} +.tl-when { + margin-left: auto; + font-weight: 400; + font-size: 11.5px; + color: var(--muted); +} +.tl-summary { + margin-top: 2px; + color: var(--fg2); + font-size: 13px; +} +.tl-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + margin-top: 4px; + font-size: 11.5px; + color: var(--muted); +} +.tl-meta .check-passed { + color: var(--green); +} +.tl-meta .check-failed, +.tl-meta .check-timeout { + color: var(--red); +} diff --git a/web/src/types.ts b/web/src/types.ts index e99f85a..222a93f 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -59,6 +59,10 @@ export interface Session { parent_id?: string; created_at: number; updated_at: number; + /** One line "where things stand" (small model), for the session list. */ + summary?: string; + /** Colour tag: a palette name (red … purple), drawn in the colours of the theme. */ + color?: string; todos: Todo[] | null; last_usage?: Usage | null; busy?: boolean; @@ -112,6 +116,16 @@ export interface QuestionRequest { questions: Question[]; } +/** Result of the project's check command after a turn. */ +export type CheckStatus = "running" | "passed" | "failed" | "timeout"; +export interface TurnCheckInfo { + message_id: string; + status: CheckStatus; + exit_code: number | null; + duration_ms: number; + output: string; +} + export type ServerEvent = | { type: "session_updated"; session: Session } | { type: "session_deleted"; session_id: string } @@ -129,6 +143,7 @@ export type ServerEvent = | { type: "waiting"; session_id: string; reason: string | null } | { type: "queue_updated"; session_id: string; items: QueueItem[] } | { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean } + | { type: "check_result"; session_id: string; message_id: string; status: CheckStatus; exit_code: number | null; duration_ms: number; output: string } | { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null } | { type: "mcp_status"; servers: Record } | { type: "resync" }; diff --git a/web/src/whatsnew.ts b/web/src/whatsnew.ts index 98321e9..91253bb 100644 --- a/web/src/whatsnew.ts +++ b/web/src/whatsnew.ts @@ -3,6 +3,16 @@ * under "unreleased"; scripts/release.sh renames the key to the release version. */ export const WHATS_NEW: Record = { + unreleased: { + en: [ + "Claude Code as an engine: sign in under Settings → Account, then pick a “Claude Code” model", + "Image viewer, message menu, colour tags for chats, approvals from push notifications, check after each turn, project page", + ], + de: [ + "Claude Code als Engine: unter Einstellungen → Konto anmelden, dann ein „Claude Code“-Modell wählen", + "Bildbetrachter, Nachrichtenmenü, Farben für Chats, Freigaben per Push, Prüfung nach jedem Schritt, Projektseite", + ], + }, "202611-00024": { en: ["3D models with fal.ai: from an image (also pasted into the chat), a text prompt or both – open them in the 3D view"], de: ["3D-Modelle mit fal.ai: aus einem Bild (auch eingefügt), einem Text oder beidem – öffnen in der 3D-Ansicht"],