From c850586a817c5363c6166a3b83fcb85bac6c40cb Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Mon, 9 Nov 2026 11:24:58 +0100 Subject: [PATCH] Chat tabs, chat mode, own modes, compaction notice, CLI binaries again - Terminals and file managers as tabs of a chat; chat terminals keep running on the server (no idle timeout) and re-attach from any device - "Just chat" in New session and a built-in chat mode with a lean prompt and only web tools; custom modes with kind (coding/conversation), read-only and tool picker - Mode selector as a dropdown - The chat shows running compactions (automatic: tokens and share of the limit) and labels summaries - Docker image builds and serves the terminal client binaries again - Fix: atomic session updates; background title/usage writes no longer undo each other (auto compaction triggered one turn late) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 24 +++ Dockerfile | 20 ++- README.md | 13 +- cli/src/agent.rs | 4 +- core/src/prompts.rs | 3 + core/src/provider/anthropic.rs | 2 +- core/src/provider/openai.rs | 2 +- core/src/types.rs | 12 +- server/src/api.rs | 107 ++++++----- server/src/app.rs | 26 ++- server/src/config.rs | 7 + server/src/export.rs | 2 +- server/src/prompts.rs | 36 +++- server/src/runner.rs | 51 ++++-- server/src/store.rs | 2 +- server/src/terminal.rs | 37 +++- web/src/components/ChatTabs.tsx | 179 +++++++++++++++++++ web/src/components/ChatView.tsx | 38 +++- web/src/components/Composer.tsx | 22 +-- web/src/components/Files.tsx | 27 ++- web/src/components/NewSession.tsx | 119 ++++++------ web/src/components/Terminal.tsx | 5 +- web/src/components/settings/AgentsEditor.tsx | 78 ++++++-- web/src/i18n/de.ts | 23 ++- web/src/i18n/en.ts | 23 ++- web/src/store.ts | 11 ++ web/src/styles.css | 122 +++++++++++++ web/src/types.ts | 5 +- web/src/whatsnew.ts | 18 +- 29 files changed, 831 insertions(+), 187 deletions(-) create mode 100644 web/src/components/ChatTabs.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f331d83..f53e09e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,37 @@ until 1.0, minor versions may contain breaking changes. ### Added +- The Docker image ships the terminal client again (static Linux x86_64 and aarch64 binaries): + `curl -fsSL https:///cli/install.sh | sh`, download links in the settings and + `codecraft update` work without building from source. - File manager: *Open chat here* in a folder's menu starts a chat in that folder. +- **Tabs in a chat:** open terminals and file managers next to the chat (topbar buttons or + "+" in the tab strip), as many as needed. Terminals of a chat keep running on the server until + they are closed, so they are there again after a reload or on another device (output is + replayed); they end when the chat is deleted or the server restarts. +- **Chat mode:** *New session → Just chat* (and mode `chat`) for plain conversation – a lean + prompt with date and personal memory, web search and fetch only, no project or files. +- **Own modes:** *Settings → Agents & commands → New mode* with kind (coding or conversation), + read-only switch, tool checkboxes, prompt, model and review loop; custom modes show up in + *New session* and the mode selector. +- The chat shows when the conversation is being compacted – automatically because the context + is nearly full (with tokens and share of the limit) or on request – and labels the summary. + +### Changed + +- The mode selector in the composer is a dropdown. ### Removed - *Settings → Config file* no longer offers to move `${NAME}` values into the configuration (a one-time migration); `${NAME}` references keep working. +### Fixed + +- Session updates from background tasks (title, token usage, phase, todos) and from the UI + no longer overwrite each other. Often the first answer's token usage was lost, so automatic + compaction and the context display started one turn late. + ## [v202611-00002] – 2026-11-09 ### Added diff --git a/Dockerfile b/Dockerfile index 98ee4a2..36e271e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -# Multi-arch: the web UI and the server are built once on the build +# Multi-arch: the web UI, the server and the terminal client are built once on the build # machine (cross-compiled with cargo-zigbuild); only the runtime stage runs # per target platform. @@ -19,7 +19,7 @@ ARG ZIG_VERSION=0.17.1 RUN curl -fsSL https://ziglang.org/download/${ZIG_VERSION}/zig-$(uname -m)-linux-${ZIG_VERSION}.tar.xz | tar -xJ -C /opt \ && ln -s /opt/zig-*/zig /usr/local/bin/zig \ && cargo install --locked cargo-zigbuild \ - && rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu + && rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-unknown-linux-musl aarch64-unknown-linux-musl WORKDIR /src # --- server (for the target platform, glibc of Debian bookworm) ---------------- @@ -52,6 +52,21 @@ RUN touch core/src/lib.rs server/src/main.rs \ && cargo zigbuild --release -p codecraft --target "$(cat /target).2.36" \ && cp "target/$(cat /target)/release/codecraft" /codecraft +# --- terminal client (static Linux binaries for x86_64 and aarch64) ----------- +# Served by the server for download (/cli/install.sh, codecraft update). +FROM toolchain AS cli +COPY Cargo.toml Cargo.lock ./ +COPY core ./core +COPY server/Cargo.toml ./server/ +COPY server/resources ./server/resources +COPY cli ./cli +ARG CODECRAFT_VERSION="" +ENV CODECRAFT_VERSION=${CODECRAFT_VERSION} +RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs \ + && cargo zigbuild --release -p codecraft-cli --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl \ + && mkdir -p /out && cp target/x86_64-unknown-linux-musl/release/codecraft /out/codecraft-linux-x86_64 \ + && cp target/aarch64-unknown-linux-musl/release/codecraft /out/codecraft-linux-aarch64 + # --- runtime ------------------------------------------------------------------ # Node is included so agents can work on JS projects and run npx-based MCP servers. FROM node:22-bookworm-slim @@ -69,6 +84,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN npm install -g typescript@5 typescript-language-server pyright && 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 COPY server/ghidra /app/ghidra COPY server/resources /app/resources # The agent's home lives on the data volume: tools it installs in user space diff --git a/README.md b/README.md index ca5aead..f54d3d0 100644 --- a/README.md +++ b/README.md @@ -173,15 +173,14 @@ Anthropic, OpenAI, Gemini, OpenRouter, opencode or a local model server (Ollama, llama.cpp, vLLM – detected automatically); both can be mixed. ```sh -cargo install --locked --git https://git.familie-zink.org/zink/codecraft codecraft-cli +curl -fsSL https:///cli/install.sh | sh # Linux x86_64 / aarch64 codecraft login # server URL, user name, password → creates an API token codecraft # start in the current folder; /help lists the commands ``` -There are no prebuilt binaries yet, so the client is installed from source (needs a Rust -toolchain). A server that has binaries in `CODECRAFT_CLI_DIR` (`codecraft-linux-x86_64`, -`codecraft-linux-aarch64`) also offers `curl -fsSL https:///cli/install.sh | sh` -and `codecraft update`. +The Docker image ships static binaries for Linux x86_64 and aarch64; the server offers them +for download (*Settings → Account*) and `codecraft update` fetches new versions from it. Other +platforms: `cargo install --locked --git https://git.familie-zink.org/zink/codecraft codecraft-cli`. ## Automations @@ -335,7 +334,7 @@ web/ React + Vite PWA src/i18n English (reference) and German ``` -The Docker build cross-compiles the server with +The Docker build cross-compiles the server and the terminal client with [cargo-zigbuild](https://github.com/rust-cross/cargo-zigbuild), so a multi-arch image builds quickly on either architecture: `docker buildx build --platform linux/amd64,linux/arm64 -t /codecraft: --push .` @@ -353,7 +352,7 @@ Translations: copy `web/src/i18n/en.ts`, translate the values and register the l Early, but in daily use. See the [changelog](CHANGELOG.md). Planned next: -- Prebuilt terminal client binaries (Linux, macOS, Windows) +- Terminal client builds for macOS and Windows - MCP OAuth, resources and prompts - The OpenAI Responses API diff --git a/cli/src/agent.rs b/cli/src/agent.rs index db956a6..e7e6f9f 100644 --- a/cli/src/agent.rs +++ b/cli/src/agent.rs @@ -508,7 +508,7 @@ impl Agent { let who = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" }; for p in &m.parts { let line = match p { - Part::Text { text } | Part::Summary { text } => text.clone(), + Part::Text { text } | Part::Summary { text, .. } => text.clone(), Part::ToolCall { name, input, .. } => format!("[tool call {name}: {}]", input.to_string().chars().take(1500).collect::()), Part::ToolResult { name, content, .. } => format!("[result of {name}]\n{}", content.chars().take(3000).collect::()), _ => continue, @@ -524,7 +524,7 @@ impl Agent { if summary.trim().is_empty() { return Err(anyhow!("compaction produced no summary")); } - self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary }])); + self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary, auto: false, tokens: None }])); self.session.context_tokens = 0; self.read_files.lock().unwrap().clear(); let _ = self.session.save(); diff --git a/core/src/prompts.rs b/core/src/prompts.rs index d545c16..28b0188 100644 --- a/core/src/prompts.rs +++ b/core/src/prompts.rs @@ -34,6 +34,9 @@ Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pa pub const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused)."; pub const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts, and analyse programs (binary_info, decompile with Ghidra) for the user's private use. The goal is a faithful RE-CREATION, not a copy. For software:\n1. STUDY: binary_info, then decompile. Find the program's structure: entry point and main loop, UI (windows, menus, dialogs, resources), input handling, file formats, algorithms and data structures, timing.\n2. SPECIFY: write .codecraft/spec.md – behaviour, screens, data and file formats, algorithms in your own words (pseudocode allowed), edge cases. This spec is the only input for the build.\n3. BUILD: implement from the spec in a modern stack (the user's choice, or a sensible default), with clean, idiomatic, documented code. Never paste or mechanically translate decompiled code; names, structure and code must be your own. Reuse only facts (file formats, constants needed for compatibility).\n4. CHECK: compare with the original – same files read/written, same outputs for the same inputs, same look where it matters – and refine."; +/// Plain conversation (mode "chat"): no project, no coding workflow. +pub const CHAT_PROMPT: &str = "You are a helpful, knowledgeable assistant having a conversation with the user. Answer directly and naturally in the user's language; keep answers as short as the question allows and go into depth when asked. Use Markdown only where it helps (lists, code, tables). You can search the web (web_search) and read pages (web_fetch) for current or specific facts – say where information comes from. You have no access to files or a shell in this mode; if the user wants something built or changed in a project, suggest switching to build mode."; + pub const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees."; pub const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything."; diff --git a/core/src/provider/anthropic.rs b/core/src/provider/anthropic.rs index d191da2..7681474 100644 --- a/core/src/provider/anthropic.rs +++ b/core/src/provider/anthropic.rs @@ -48,7 +48,7 @@ pub fn to_wire_messages(messages: &[Message]) -> Vec { Part::Text { text } if !text.is_empty() => { content.push(json!({"type": "text", "text": text})) } - Part::Summary { text } => content.push(json!({ + Part::Summary { text, .. } => content.push(json!({ "type": "text", "text": format!("\n{text}\n") })), diff --git a/core/src/provider/openai.rs b/core/src/provider/openai.rs index 4533164..10f0068 100644 --- a/core/src/provider/openai.rs +++ b/core/src/provider/openai.rs @@ -40,7 +40,7 @@ pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec { Part::Text { text } if !text.is_empty() => { parts.push(json!({"type": "text", "text": text})) } - Part::Summary { text } => parts.push(json!({ + Part::Summary { text, .. } => parts.push(json!({ "type": "text", "text": format!("\n{text}\n") })), diff --git a/core/src/types.rs b/core/src/types.rs index 2943695..e8ee0dd 100644 --- a/core/src/types.rs +++ b/core/src/types.rs @@ -57,6 +57,12 @@ pub enum Part { /// Marks the start of a compacted history; the text is the summary. Summary { text: String, + /// Started automatically because the context was nearly full. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + auto: bool, + /// Context size (tokens) when it was compacted. + #[serde(default, skip_serializing_if = "Option::is_none")] + tokens: Option, }, } @@ -213,6 +219,9 @@ pub enum Event { StepStarted { session_id: String, message_id: String, started_at: i64, prompt_tokens: u64, estimated: bool }, /// Messages waiting for a busy session (full list). QueueUpdated { session_id: String, items: Value }, + /// 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 }, } impl Event { @@ -234,7 +243,8 @@ impl Event { | Error { session_id, .. } | Waiting { session_id, .. } | QueueUpdated { session_id, .. } - | StepStarted { session_id, .. } => Some(session_id), + | StepStarted { session_id, .. } + | Compaction { session_id, .. } => Some(session_id), McpStatus { .. } => None, } } diff --git a/server/src/api.rs b/server/src/api.rs index cec735b..f16afc6 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -69,6 +69,7 @@ pub fn router(app: AppState) -> Router { .route("/providers/reload", post(reload_providers)) .route("/models", get(models)) .route("/agents", get(agents)) + .route("/agents/tools", get(agent_tools)) .route("/agents/{name}", delete(delete_agent)) .route("/commands", get(commands)) .route("/sessions", get(list_sessions).post(create_session)) @@ -325,6 +326,16 @@ async fn agents(Extension(user): Extension, State(app): State, Q } /// Delete a user-defined agent: its config entry and/or its Markdown file. +/// Built-in tools for the mode editor (name, read-only, first line of the description). +async fn agent_tools(Extension(user): Extension) -> ApiResult { + require_admin(&user)?; + let list: Vec = crate::tools::builtin_tools() + .iter() + .map(|t| json!({"name": t.name(), "read_only": t.read_only(), "description": t.description().split(['.', '\n']).next().unwrap_or("").trim()})) + .collect(); + Ok(Json(json!(list))) +} + async fn delete_agent(Extension(user): Extension, State(app): State, Path(name): Path, Query(q): Query) -> ApiResult { let (root, cwd) = scope(&app, &user, q.cwd); let agent = prompts::agents(&app, &root, &cwd).into_iter().find(|a| a.name == name).ok_or_else(|| not_found("agent"))?; @@ -702,47 +713,47 @@ struct UpdateSession { } async fn update_session(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { - let mut s = writable(&app, &user, &id)?; + writable(&app, &user, &id)?; // Organising the list must not move the session to "today". let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none(); - if let Some(t) = body.title { - s.title = t; + if let Some(m) = &body.model { + app.registry().resolve(m)?; } - if let Some(m) = body.model { - app.registry().resolve(&m)?; - s.model = m; + if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) { + return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into())); } - if let Some(a) = body.agent { - s.agent = a; - } - if let Some(c) = body.clean_room { - s.clean_room = Some(c); - } - if let Some(p) = body.pinned { - s.pinned = p; - } - if let Some(a) = body.archived { - s.archived = a; - if a { - s.pinned = false; + // Atomic: a running turn updates the same session (usage, title, todos) meanwhile. + let s = app.modify_session(&id, !organise_only, |s| { + if let Some(t) = body.title { + s.title = t; } - } - if let Some(f) = body.folder { - let f = f.trim().chars().take(60).collect::(); - s.folder = (!f.is_empty()).then_some(f); - } - if organise_only { - app.store.save_session(&s)?; - app.emit(Event::SessionUpdated { session: s.clone() }); - return Ok(Json(json!(s))); - } - if let Some(p) = body.permission_mode { - if !crate::permission::MODES.contains(&p.as_str()) { - return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into())); + if let Some(m) = body.model { + s.model = m; } - s.permission_mode = p; - } - Ok(Json(json!(app.save_session(s)?))) + if let Some(a) = body.agent { + s.agent = a; + } + if let Some(c) = body.clean_room { + s.clean_room = Some(c); + } + if let Some(p) = body.pinned { + s.pinned = p; + } + if let Some(a) = body.archived { + s.archived = a; + if a { + s.pinned = false; + } + } + if let Some(f) = body.folder { + let f = f.trim().chars().take(60).collect::(); + s.folder = (!f.is_empty()).then_some(f); + } + if let Some(p) = body.permission_mode { + s.permission_mode = p; + } + })?; + Ok(Json(json!(s))) } async fn delete_session(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { @@ -751,6 +762,7 @@ async fn delete_session(Extension(user): Extension, State(app): State, State(app): State, State(app): State) -> ApiResult { - Ok(Json(json!(app.terminals.list(&user.id)))) +#[derive(Deserialize)] +struct TerminalQuery { + session: Option, +} + +/// All terminals of the user, or the tabs of one chat (?session=…). +async fn list_terminals(Extension(user): Extension, State(app): State, Query(q): Query) -> ApiResult { + Ok(Json(json!(match q.session { + Some(s) => app.terminals.for_session(&s, &user.id), + None => app.terminals.list(&user.id), + }))) } #[derive(Deserialize)] struct OpenTerminal { cwd: Option, + /// Open as a tab of this chat (in its folder unless `cwd` is given). + session_id: Option, cols: Option, rows: Option, } async fn open_terminal(Extension(user): Extension, State(app): State, headers: axum::http::HeaderMap, Json(b): Json) -> ApiResult { - let cwd = validate_cwd(&app, &user, b.cwd)?; - let term = crate::terminal::spawn(&app, &user, &cwd, b.cols.unwrap_or(100), b.rows.unwrap_or(30)).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?; + let session = match &b.session_id { + Some(id) => Some(writable(&app, &user, id)?), + None => None, + }; + let cwd = validate_cwd(&app, &user, b.cwd.or_else(|| session.as_ref().map(|s| s.cwd.clone())))?; + let term = crate::terminal::spawn(&app, &user, &cwd, session.map(|s| s.id), b.cols.unwrap_or(100), b.rows.unwrap_or(30)).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?; app.audit(Some(&user), "terminal.open", cwd.display().to_string(), &crate::auth::client_ip(&headers)); Ok(Json(term.info())) } diff --git a/server/src/app.rs b/server/src/app.rs index 36f9279..f0ed2ec 100644 --- a/server/src/app.rs +++ b/server/src/app.rs @@ -46,6 +46,8 @@ pub struct Runtime { } pub struct App { + /// Serializes read-modify-write of sessions (see `update_session`). + session_write: Mutex<()>, config_path: PathBuf, config: RwLock>, registry: RwLock>, @@ -101,6 +103,7 @@ impl App { let registry = Registry::build(&cfg).await; let (events, _) = broadcast::channel(4096); let app = Arc::new(Self { + session_write: Mutex::new(()), config_path, config: RwLock::new(Arc::new(cfg)), registry: RwLock::new(Arc::new(registry)), @@ -326,10 +329,27 @@ impl App { Ok(s) } + /// Load, change and save a session atomically: background tasks (title, usage, phase, + /// todos) change different fields at the same time and must not undo each other. + pub fn update_session(&self, id: &str, f: impl FnOnce(&mut Session)) -> Result { + self.modify_session(id, true, f) + } + + /// Like `update_session`; `touch = false` keeps `updated_at` (organising the list). + pub fn modify_session(&self, id: &str, touch: bool, f: impl FnOnce(&mut Session)) -> Result { + let _guard = self.session_write.lock().unwrap(); + let mut s = self.session(id)?; + f(&mut s); + if touch { + return self.save_session(s); + } + self.store.save_session(&s)?; + self.emit(Event::SessionUpdated { session: s.clone() }); + Ok(s) + } + pub fn set_todos(&self, session_id: &str, todos: Value) -> Result<()> { - let mut s = self.session(session_id)?; - s.todos = todos; - self.save_session(s)?; + self.update_session(session_id, |s| s.todos = todos)?; Ok(()) } diff --git a/server/src/config.rs b/server/src/config.rs index dcc9ad6..0c6cbd1 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -385,6 +385,13 @@ pub struct AgentConfig { pub tools: Option>, #[serde(default)] pub prompt: Option, + /// Only read-only tools (like plan mode). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub read_only: Option, + /// "coding" (default: project context and coding instructions) or "chat" (plain + /// conversation with only this prompt, like the built-in chat mode). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub style: Option, /// Max. instances of this subagent running at the same time. #[serde(default, skip_serializing_if = "Option::is_none")] pub max_parallel: Option, diff --git a/server/src/export.rs b/server/src/export.rs index d4a5839..184d78b 100644 --- a/server/src/export.rs +++ b/server/src/export.rs @@ -53,7 +53,7 @@ pub fn markdown(s: &Session, messages: &[Message]) -> String { for p in &m.parts { match p { Part::Text { text } => out.push_str(&format!("{}\n\n", text.trim())), - Part::Summary { text } => out.push_str(&format!("
Summary of the earlier conversation\n\n{}\n\n
\n\n", text.trim())), + Part::Summary { text, .. } => out.push_str(&format!("
Summary of the earlier conversation\n\n{}\n\n
\n\n", text.trim())), Part::Reasoning { text, .. } if !text.trim().is_empty() => { out.push_str(&format!("
Thinking\n\n{}\n\n
\n\n", text.trim())) } diff --git a/server/src/prompts.rs b/server/src/prompts.rs index fa1f5a6..05bebc0 100644 --- a/server/src/prompts.rs +++ b/server/src/prompts.rs @@ -23,6 +23,8 @@ pub struct Agent { /// Allowed tools; None = all. pub tools: Option>, pub read_only: bool, + /// "coding" or "chat" (plain conversation prompt). + pub style: String, pub model: Option, /// "builtin", "config" or "file". pub source: String, @@ -41,11 +43,12 @@ pub use codecraft_core::prompts::*; pub fn builtin_agents(clean_room: bool) -> Vec { let craft = craft_prompt(clean_room); vec![ - Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, - Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, - Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, - Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, - Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, + Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, + Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, + Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, + Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, + Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, + Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None }, ] } @@ -113,7 +116,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec { mode: meta.get("mode").cloned().unwrap_or_else(|| "subagent".into()), prompt: body, tools, - read_only: false, + read_only: meta.get("read_only").is_some_and(|v| v == "true"), + style: meta.get("style").filter(|v| *v == "chat").cloned().unwrap_or_else(|| "coding".into()), model: meta.get("model").filter(|m| m.contains('/')).cloned(), source: "file".into(), max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()), @@ -131,7 +135,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec { mode: a.mode.clone().or(base.as_ref().map(|b| b.mode.clone())).unwrap_or_else(|| "subagent".into()), prompt: a.prompt.clone().or(base.as_ref().map(|b| b.prompt.clone())).unwrap_or_default(), tools: a.tools.clone().or(base.as_ref().and_then(|b| b.tools.clone())), - read_only: base.as_ref().is_some_and(|b| b.read_only), + read_only: a.read_only.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.read_only)), + style: a.style.clone().filter(|s| s == "chat" || s == "coding").or(base.as_ref().map(|b| b.style.clone())).unwrap_or_else(|| "coding".into()), model: a.model.clone().filter(|m| !m.is_empty()).or(base.as_ref().and_then(|b| b.model.clone())), source: "config".into(), max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)), @@ -177,7 +182,24 @@ fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String out } +/// The plain chat mode: no coding instructions, project files or memory – only the +/// date, the model and what the user keeps in their personal memory. +fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String { + let mut s = agent.prompt.clone(); + s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d"))); + if let Some(u) = owner { + let memory: String = app.store.user_setting(&u.id, "memory").unwrap_or_default(); + if !memory.trim().is_empty() { + s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim())); + } + } + s +} + pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str, owner: Option<&crate::store::User>) -> String { + if agent.style == "chat" { + return chat_prompt(app, agent, model, owner); + } let mut s = String::from(BASE_PROMPT); s.push_str("\n\n"); s.push_str(&agent.prompt); diff --git a/server/src/runner.rs b/server/src/runner.rs index e14987d..bec87fc 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -284,7 +284,7 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) { let threshold = app.config().server.auto_compact_threshold; if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold { - compact(app.clone(), session_id, cancel.clone()).await?; + compact(app.clone(), session_id, cancel.clone(), Some((u.context_tokens(), ctx_len))).await?; continue; } } @@ -746,7 +746,7 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 { for m in &req.messages { for p in &m.parts { match p { - Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text } => chars += text.len(), + Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text, .. } => chars += text.len(), Part::ToolCall { input, .. } => chars += input.to_string().len(), Part::ToolResult { content, images: imgs, .. } => { chars += content.len(); @@ -913,9 +913,7 @@ async fn stream_step( app.store.add_message(&session.id, &message)?; app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() }); } - let mut s = app.session(&session.id)?; - s.last_usage = Some(usage); - app.save_session(s)?; + app.update_session(&session.id, |s| s.last_usage = Some(usage))?; return Ok(Step { message, stop_reason }); } } @@ -995,11 +993,12 @@ fn maybe_generate_title(app: Arc, session_id: &str, text: &str) { Ok((title, usage)) => { record_side_usage(&app, &session, &spec, &model, &usage, "title"); let title = title.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches('"').to_string(); - if let (false, Ok(mut s)) = (title.is_empty(), app.session(&session.id)) { - if s.title.is_empty() { - s.title = title.chars().take(80).collect(); - let _ = app.save_session(s); - } + if !title.is_empty() { + let _ = app.update_session(&session.id, |s| { + if s.title.is_empty() { + s.title = title.chars().take(80).collect(); + } + }); } } Err(e) => tracing::debug!("title generation failed: {e:#}"), @@ -1096,7 +1095,7 @@ fn infer_phase(tool: &str, input: &Value, deep: bool) -> Option<&'static str> { /// Move the craft phase forward (never backwards); `only_from` restricts the transition. fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&str>) { let root = app.root_session_id(session_id); - let Ok(mut s) = app.session(&root) else { return }; + let Ok(s) = app.session(&root) else { return }; let cur = s.phase.as_deref().and_then(|p| PHASES.iter().position(|x| *x == p)); if let Some(from) = only_from { if s.phase.as_deref() != Some(from) { @@ -1113,8 +1112,7 @@ fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&st if s.phase.as_deref() == Some(phase) { return; } - s.phase = Some(phase.to_string()); - let _ = app.save_session(s); + let _ = app.update_session(&root, |s| s.phase = Some(phase.to_string())); } /// Review the last turn against the user's request. Returns feedback for the @@ -1217,7 +1215,7 @@ pub fn transcript_of(history: &[Message]) -> String { for p in &m.parts { match p { Part::Text { text } => body.push_str(&format!("{text}\n")), - Part::Summary { text } => body.push_str(&format!("[Summary of the earlier conversation]\n{text}\n")), + Part::Summary { text, .. } => body.push_str(&format!("[Summary of the earlier conversation]\n{text}\n")), Part::ToolCall { name, input, .. } => body.push_str(&format!("[tool call {name}: {}]\n", clip(&input.to_string(), 1500))), Part::ToolResult { name, content, is_error, .. } => { body.push_str(&format!("[{} of {name}]\n{}\n", if *is_error { "error" } else { "result" }, clip(content, 3000))) @@ -1234,7 +1232,23 @@ pub fn transcript_of(history: &[Message]) -> String { } /// Replace the active history with a summary (the old messages stay visible in the UI). -pub async fn compact(app: Arc, session_id: &str, cancel: CancellationToken) -> Result<()> { +/// Summarize the history. `auto` = (context tokens, context limit) when triggered by a +/// nearly full context; the chat shows the progress and the reason. +pub async fn compact(app: Arc, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> { + let event = |state: &str| Event::Compaction { + session_id: session_id.to_string(), + state: state.into(), + auto: auto.is_some(), + tokens: auto.map(|a| a.0), + limit: auto.map(|a| a.1), + }; + app.emit(event("started")); + let res = compact_inner(app.clone(), session_id, cancel, auto).await; + app.emit(event(if res.is_ok() { "done" } else { "failed" })); + res +} + +async fn compact_inner(app: Arc, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> { let session = app.session(session_id)?; let history = active_history(app.store.messages(session_id)?); if history.is_empty() { @@ -1273,12 +1287,11 @@ pub async fn compact(app: Arc, session_id: &str, cancel: CancellationToken) if summary.trim().is_empty() { bail!("compaction produced an empty summary"); } - save_user_parts(&app, session_id, vec![Part::Summary { text: summary }])?; - let mut s = app.session(session_id)?; - s.last_usage = None; + let tokens = auto.map(|a| a.0).or_else(|| session.last_usage.as_ref().map(|u| u.context_tokens())); + save_user_parts(&app, session_id, vec![Part::Summary { text: summary, auto: auto.is_some(), tokens }])?; + app.update_session(session_id, |s| s.last_usage = None)?; // Files must be re-read after compaction before editing. app.with_runtime(session_id, |r| r.read_files.clear()); - app.save_session(s)?; Ok(()) } diff --git a/server/src/store.rs b/server/src/store.rs index f955ea2..1a29ada 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -239,7 +239,7 @@ impl Store { if let Some(m) = mdata.and_then(|d| serde_json::from_str::(&d).ok()) { for p in &m.parts { let text = match p { - Part::Text { text } | Part::Summary { text } | Part::Reasoning { text, .. } => text.as_str(), + Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(), Part::ToolResult { content, .. } => content.as_str(), _ => continue, }; diff --git a/server/src/terminal.rs b/server/src/terminal.rs index 6af6d5c..ae3b9df 100644 --- a/server/src/terminal.rs +++ b/server/src/terminal.rs @@ -18,13 +18,16 @@ use tokio::sync::broadcast; /// Output kept for re-attaching clients. const SCROLLBACK: usize = 256 * 1024; -/// Detached terminals are stopped after this time. +/// Detached terminals outside a chat are stopped after this time; terminals that +/// belong to a chat stay open until they are closed, exit or the chat is deleted. const DETACHED_TTL: Duration = Duration::from_secs(30 * 60); -const MAX_PER_USER: usize = 8; +const MAX_PER_USER: usize = 16; pub struct Term { pub id: String, pub owner_id: String, + /// Chat the terminal is a tab of. + pub session_id: Option, pub cwd: PathBuf, pub created_at: i64, writer: tokio::sync::Mutex, @@ -57,6 +60,7 @@ impl Term { json!({ "id": self.id, "cwd": self.cwd, + "session_id": self.session_id, "created_at": self.created_at, "alive": self.alive(), "attached": *self.attached.lock().unwrap() > 0, @@ -76,6 +80,30 @@ impl Terminals { v.into_iter().map(|(_, i)| i).collect() } + /// Terminals of one chat (oldest first). + pub fn for_session(&self, session_id: &str, owner_id: &str) -> Vec { + let mut v: Vec<_> = self + .map + .lock() + .unwrap() + .values() + .filter(|t| t.owner_id == owner_id && t.session_id.as_deref() == Some(session_id)) + .map(|t| (t.created_at, t.info())) + .collect(); + v.sort_by_key(|(c, _)| *c); + v.into_iter().map(|(_, i)| i).collect() + } + + /// Close all terminals of a deleted chat. + pub fn kill_session(&self, session_id: &str) { + let gone: Vec> = { + let mut map = self.map.lock().unwrap(); + let ids: Vec = map.values().filter(|t| t.session_id.as_deref() == Some(session_id)).map(|t| t.id.clone()).collect(); + ids.iter().filter_map(|id| map.remove(id)).collect() + }; + gone.iter().for_each(|t| t.kill()); + } + pub fn get(&self, id: &str, owner_id: &str) -> Option> { self.map.lock().unwrap().get(id).filter(|t| t.owner_id == owner_id).cloned() } @@ -100,7 +128,7 @@ impl Terminals { pub fn reap(&self) { let mut map = self.map.lock().unwrap(); map.retain(|_, t| { - let stale = t.detached_since.lock().unwrap().is_some_and(|d| d.elapsed() > DETACHED_TTL); + let stale = t.session_id.is_none() && t.detached_since.lock().unwrap().is_some_and(|d| d.elapsed() > DETACHED_TTL); let gone = !t.alive() && *t.attached.lock().unwrap() == 0; if stale || gone { t.kill(); @@ -113,7 +141,7 @@ impl Terminals { } /// Start a shell for `user` in `cwd` (already access-checked). -pub fn spawn(app: &Arc, user: &User, cwd: &Path, cols: u16, rows: u16) -> Result> { +pub fn spawn(app: &Arc, user: &User, cwd: &Path, session_id: Option, cols: u16, rows: u16) -> Result> { if app.terminals.list(&user.id).iter().filter(|t| t["alive"] == true).count() >= MAX_PER_USER { anyhow::bail!("too many open terminals (max {MAX_PER_USER}) – close one first"); } @@ -142,6 +170,7 @@ pub fn spawn(app: &Arc, user: &User, cwd: &Path, cols: u16, rows: u16) -> R let term = Arc::new(Term { id: crate::types::new_id(), owner_id: user.id.clone(), + session_id, cwd: cwd.to_path_buf(), created_at: crate::types::now_ms(), writer: tokio::sync::Mutex::new(writer), diff --git a/web/src/components/ChatTabs.tsx b/web/src/components/ChatTabs.tsx new file mode 100644 index 0000000..7069e78 --- /dev/null +++ b/web/src/components/ChatTabs.tsx @@ -0,0 +1,179 @@ +import { lazy, Suspense, useCallback, useEffect, useState } from "react"; +import { del, get, post } from "../api"; +import { confirmDialog, toast } from "../dialogs"; +import { useT } from "../i18n"; +import { Icon } from "./Icon"; +import type { TermInfo } from "./Terminal"; + +// xterm and the editor are large: load them only when such a tab is shown. +const TermView = lazy(() => import("./Terminal").then((m) => ({ default: m.TermView }))); +const Files = lazy(() => import("./Files").then((m) => ({ default: m.Files }))); + +const POLL_MS = 15_000; +const tabKey = (sessionId: string) => `codecraft.chatTab.${sessionId}`; +const filesKey = (sessionId: string) => `codecraft.chatFiles.${sessionId}`; + +/** A file manager tab: lives in this browser (it has no server state). */ +interface FileTab { + id: string; + dir: string; +} + +const loadFileTabs = (sessionId: string): FileTab[] => { + try { + return JSON.parse(localStorage.getItem(filesKey(sessionId)) ?? "[]"); + } catch { + return []; + } +}; + +/** + * Tabs of a chat: the chat itself, its terminals and file managers. Terminals live on + * the server until closed, so they show up again after a reload or on another device + * (the list is refreshed periodically); file manager tabs are kept per browser. + */ +export function useChatTabs(sessionId: string, cwd: string) { + const t = useT(); + const [terms, setTerms] = useState([]); + const [files, setFilesState] = useState(() => loadFileTabs(sessionId)); + const [tab, setTabState] = useState(() => sessionStorage.getItem(tabKey(sessionId)) ?? "chat"); + + const setTab = useCallback( + (v: string) => { + setTabState(v); + sessionStorage.setItem(tabKey(sessionId), v); + }, + [sessionId], + ); + const setFiles = useCallback( + (update: (f: FileTab[]) => FileTab[]) => + setFilesState((cur) => { + const next = update(cur); + localStorage.setItem(filesKey(sessionId), JSON.stringify(next)); + return next; + }), + [sessionId], + ); + + const reload = useCallback( + () => + get(`/terminals?session=${encodeURIComponent(sessionId)}`) + .then(setTerms) + .catch(() => undefined), + [sessionId], + ); + + useEffect(() => { + setTabState(sessionStorage.getItem(tabKey(sessionId)) ?? "chat"); + setFilesState(loadFileTabs(sessionId)); + setTerms([]); + void reload(); + const id = setInterval(reload, POLL_MS); + const onFocus = () => void reload(); + window.addEventListener("focus", onFocus); + return () => { + clearInterval(id); + window.removeEventListener("focus", onFocus); + }; + }, [sessionId, reload]); + + // A tab closed elsewhere (or a stale stored tab) falls back to the chat. + const activeTerm = terms.find((x) => x.id === tab); + const activeFiles = files.find((f) => f.id === tab); + const current = activeTerm || activeFiles ? tab : "chat"; + + const openTerminal = async () => { + try { + const info = await post("/terminals", { session_id: sessionId, cols: 100, rows: 30 }); + await reload(); + setTab(info.id); + } catch (e) { + toast(e); + } + }; + + const closeTerminal = async (x: TermInfo, n: number) => { + if (x.alive && !(await confirmDialog({ message: t("terminal.closeConfirm", { name: t("terminal.tab", { n }) }), danger: true, confirmLabel: t("terminal.close") }))) return; + await del(`/terminals/${x.id}`).catch(() => undefined); + if (tab === x.id) setTab("chat"); + await reload(); + }; + + const openFiles = () => { + const id = `files:${Date.now().toString(36)}`; + setFiles((f) => [...f, { id, dir: cwd }]); + setTab(id); + }; + + const closeFiles = (id: string) => { + setFiles((f) => f.filter((x) => x.id !== id)); + if (tab === id) setTab("chat"); + }; + + const fileDirChanged = useCallback((id: string, dir: string) => setFiles((f) => f.map((x) => (x.id === id ? { ...x, dir } : x))), [setFiles]); + + return { terms, files, tab: current, activeTerm, setTab, openTerminal, closeTerminal, openFiles, closeFiles, fileDirChanged }; +} + +type Tabs = ReturnType; + +const basename = (p: string) => p.split("/").filter(Boolean).pop() ?? "/"; + +/** Tab strip above the chat: the chat, one tab per terminal and file manager, and "+". */ +export function ChatTabBar({ state }: { state: Tabs }) { + const t = useT(); + const { terms, files, tab, setTab, openTerminal, closeTerminal, openFiles, closeFiles } = state; + if (terms.length === 0 && files.length === 0) return null; + return ( +
+ + {terms.map((x, i) => ( + + + + + ))} + {files.map((f) => ( + + + + + ))} + + +
+ ); +} + +/** + * Tab contents besides the chat. The active terminal is mounted (it re-attaches and + * replays its output); file managers stay mounted while hidden, so unsaved edits survive. + */ +export function ChatTabPanes({ state }: { state: Tabs }) { + const t = useT(); + const { tab, activeTerm, files, fileDirChanged } = state; + return ( + {t("common.loading")}}> + {activeTerm && } + {files.map((f) => ( + + ))} + + ); +} diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 0f32c07..accff79 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -8,7 +8,7 @@ import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i import { navigate } from "../router"; import { dismissError, loadSession, loadSessions, useStore } from "../store"; import type { Aggregate, ImageData, Message, Part, Session } from "../types"; -import { ActivityLine, Dots, ThinkIcon, WorkBar } from "./Activity"; +import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity"; import { useReasoningMode } from "../prefs"; import { Composer } from "./Composer"; import { ShareDialog } from "./ShareDialog"; @@ -24,6 +24,7 @@ import { RewindDialog } from "./RewindDialog"; import { ShellsChip } from "./ShellsPanel"; import { TodoList, ToolCard } from "./ToolCard"; import { confirmDialog, promptDialog, toast } from "../dialogs"; +import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs"; type ToolResult = Extract; @@ -145,8 +146,10 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () const allPermissions = useStore((s) => s.permissions); const allQuestions = useStore((s) => s.questions); const waiting = useStore((s) => s.waiting[sessionId]); + const compacting = useStore((s) => s.compacting[sessionId]); const readOnly = useStore((s) => !!s.readOnly[sessionId]); const isAdmin = useStore((s) => s.me?.role === "admin"); + const tabs = useChatTabs(sessionId, session?.cwd ?? ""); const [sharing, setSharing] = useState(false); const models = useModels(); const [loadError, setLoadError] = useState(); @@ -314,14 +317,14 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () )} + {!session.parent_id && ( +
+ {!embed && ( + + )}
-

{t("nav.files")}

+ {!embed &&

{t("nav.files")}

}
{listing?.path}
{listing?.repo && ( diff --git a/web/src/components/NewSession.tsx b/web/src/components/NewSession.tsx index ac293ce..810401d 100644 --- a/web/src/components/NewSession.tsx +++ b/web/src/components/NewSession.tsx @@ -103,6 +103,7 @@ const ICONS: Record = { ), upload: svg(), clip: svg(), + chat: svg(), start: svg(), }; @@ -185,7 +186,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate const [creating, setCreating] = useState(); const fileInput = useRef(null); const agents = useAgents(undefined).filter((a) => a.mode === "primary"); - const custom = agents.filter((a) => !["build", "plan", "craft"].includes(a.name)); + const custom = agents.filter((a) => !["build", "plan", "craft", "chat"].includes(a.name)); useEffect(() => { if (!model && config?.default_model) setModel(config.default_model); @@ -244,7 +245,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate const canStart = !creating && (text.trim() || (needsUrl && url.trim()) || (isRecreate && originals.length > 0)) && - (folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim()))); + (task === "chat" ? !!rootPath : folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim()))); /** The first message(s) for the agent. */ const prompts = (uploaded: string[]): string[] => { @@ -275,7 +276,10 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate try { // 1. Folder let cwd = picked; - if (folderMode === "git") { + if (task === "chat") { + // A plain conversation needs no project: it runs in the user's own folder. + cwd = rootPath; + } else if (folderMode === "git") { setCreating(t("wizard.cloning")); const target = `${rootPath}/${folderName.trim().replace(/[\\/]/g, "-")}`; await post("/git", { action: "clone", path: target, url: repoUrl.trim() }); @@ -295,7 +299,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate } if (!cwd) throw new Error("could not create the folder"); } - localStorage.setItem(LAST_DIR, cwd); + if (task !== "chat") localStorage.setItem(LAST_DIR, cwd); // 2. Files: originals of old software into original/, everything else into .codecraft/uploads/ const uploaded: string[] = []; const mkdirs = async (dir: string) => { @@ -327,7 +331,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate agent: task === "cad" ? "build" : task, clean_room: isCraft ? !deep : undefined, }); - if (isolated && folderMode !== "new") { + if (isolated && folderMode !== "new" && task !== "chat") { setCreating(t("worktree.creating")); await post(`/sessions/${s.id}/worktree`, { action: "create" }); } @@ -359,6 +363,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
+ setTask("chat")} /> setTask("craft")} /> setTask("cad")} /> setTask("build")} /> @@ -454,7 +459,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate >