Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5e2ef146c | ||
|
|
a2b9528e0b | ||
|
|
81d9df4e6e | ||
|
|
ead75e78e0 | ||
|
|
77f9d4b0e0 | ||
|
|
103132a359 | ||
|
|
5d5c0f8124 | ||
|
|
f77b101847 |
No files matched your search
@@ -3,6 +3,40 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
until 1.0, minor versions may contain breaking changes.
|
||||||
|
|
||||||
|
## [v202611-00040] – 2026-11-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Images you upload reach Claude Code**: its chats read the history without the image bytes, so the
|
||||||
|
files it was pointed to were empty. Images without data are no longer written as empty files.
|
||||||
|
|
||||||
|
## [v202611-00039] – 2026-11-15
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A chat's colour no longer tints the chat background (pane, message field, agent bubbles, top bar
|
||||||
|
fill); it still marks the top bar line, the list entry, the badge and your own bubbles.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Claude Code can ask questions again**: its `AskUserQuestion` was always rejected; now the question
|
||||||
|
appears as a question card in the chat (single and multiple choice) and the answers go back to it.
|
||||||
|
|
||||||
|
## [v202611-00038] – 2026-11-15
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The chat menu in the sidebar has a **Delete** entry (with the question about the chat's own folder);
|
||||||
|
colour and icon selection moved out of it – they remain in the ⋯ menu inside the chat.
|
||||||
|
|
||||||
|
## [v202611-00037] – 2026-11-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Deleting a chat asks about its folder**: if the chat has a folder of its own (inside your folders,
|
||||||
|
not shared with any other chat, no project folder), you can delete it with the chat – with the
|
||||||
|
number of files, size and a warning for git repositories. The server checks again before deleting.
|
||||||
|
|
||||||
## [v202611-00036] – 2026-11-15
|
## [v202611-00036] – 2026-11-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Version v202611-00036" src="https://img.shields.io/badge/version-v202611--00036-d69e2e?style=flat-square">
|
<img alt="Version v202611-00040" src="https://img.shields.io/badge/version-v202611--00040-d69e2e?style=flat-square">
|
||||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||||
|
|||||||
+92
-3
@@ -86,6 +86,7 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/sessions/{id}/search", get(search_in_session))
|
.route("/sessions/{id}/search", get(search_in_session))
|
||||||
.route("/sessions/{id}/media", get(session_media))
|
.route("/sessions/{id}/media", get(session_media))
|
||||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||||
|
.route("/sessions/{id}/folder", get(session_folder))
|
||||||
.route("/sessions/{id}/prompt", post(prompt))
|
.route("/sessions/{id}/prompt", post(prompt))
|
||||||
.route("/sessions/{id}/abort", post(abort))
|
.route("/sessions/{id}/abort", post(abort))
|
||||||
.route("/sessions/{id}/compact", post(compact))
|
.route("/sessions/{id}/compact", post(compact))
|
||||||
@@ -1056,8 +1057,83 @@ async fn put_file_tabs(Extension(user): Extension<User>, State(app): State<AppSt
|
|||||||
Ok(Json(json!({"ok": true})))
|
Ok(Json(json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
/// The folder a chat owns: inside the owner's folders (never one of them), not a link, and no
|
||||||
writable(&app, &user, &id)?;
|
/// other chat works in it, below it or above it (then it belongs to a project, not to this chat).
|
||||||
|
/// Returns the folder and the titles of chats that prevent it.
|
||||||
|
fn own_folder(app: &App, session: &Session) -> (Option<PathBuf>, Vec<String>) {
|
||||||
|
let Ok(dir) = std::fs::canonicalize(&session.cwd) else { return (None, vec![]) };
|
||||||
|
if std::fs::symlink_metadata(&session.cwd).map(|m| m.file_type().is_symlink()).unwrap_or(true) || !dir.is_dir() {
|
||||||
|
return (None, vec![]);
|
||||||
|
}
|
||||||
|
let roots: Vec<PathBuf> = app.user_roots(session.owner_id.as_deref()).iter().filter_map(|r| std::fs::canonicalize(r).ok()).collect();
|
||||||
|
if !roots.iter().any(|r| dir.starts_with(r) && dir != *r) {
|
||||||
|
return (None, vec![]);
|
||||||
|
}
|
||||||
|
let mut blockers = Vec::new();
|
||||||
|
for (title, cwd) in app.store.other_session_cwds(&session.id).unwrap_or_default() {
|
||||||
|
let Ok(other) = std::fs::canonicalize(&cwd) else { continue };
|
||||||
|
if other.starts_with(&dir) || dir.starts_with(&other) {
|
||||||
|
blockers.push(if title.is_empty() { "(untitled)".to_string() } else { title });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if blockers.is_empty() { (Some(dir), blockers) } else { (None, blockers) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Facts for the delete dialog: does the chat have a folder of its own, and what is in it?
|
||||||
|
async fn session_folder(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let session = writable(&app, &user, &id)?;
|
||||||
|
let (dir, blockers) = own_folder(&app, &session);
|
||||||
|
let Some(dir) = dir else { return Ok(Json(json!({"own": false, "shared_with": blockers}))) };
|
||||||
|
let (files, bytes, git, truncated) = tokio::task::spawn_blocking({
|
||||||
|
let dir = dir.clone();
|
||||||
|
move || {
|
||||||
|
let (mut files, mut bytes, mut seen) = (0u64, 0u64, 0u64);
|
||||||
|
let mut stack = vec![dir.clone()];
|
||||||
|
let mut truncated = false;
|
||||||
|
while let Some(d) = stack.pop() {
|
||||||
|
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||||
|
for e in rd.flatten() {
|
||||||
|
// Git's own files are not "the chat's files".
|
||||||
|
if e.file_name() == ".git" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen += 1;
|
||||||
|
if seen > 50_000 {
|
||||||
|
truncated = true;
|
||||||
|
stack.clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Ok(m) = e.metadata() else { continue };
|
||||||
|
if m.is_dir() && !e.file_type().map(|t| t.is_symlink()).unwrap_or(true) {
|
||||||
|
stack.push(e.path());
|
||||||
|
} else if m.is_file() {
|
||||||
|
files += 1;
|
||||||
|
bytes += m.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(files, bytes, dir.join(".git").exists(), truncated)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or((0, 0, false, false));
|
||||||
|
Ok(Json(json!({"own": true, "path": dir, "files": files, "bytes": bytes, "git": git, "truncated": truncated})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DeleteQ {
|
||||||
|
/// Also delete the chat's own folder (only if it really is one – checked again here).
|
||||||
|
#[serde(default)]
|
||||||
|
delete_folder: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<DeleteQ>) -> ApiResult {
|
||||||
|
let session = writable(&app, &user, &id)?;
|
||||||
|
// Decide about the folder while the chat and all the others still exist.
|
||||||
|
let folder = if q.delete_folder { own_folder(&app, &session).0 } else { None };
|
||||||
|
if q.delete_folder && folder.is_none() {
|
||||||
|
return Err(ApiError(StatusCode::CONFLICT, "this chat has no folder of its own (other chats use it, or it is a project folder)".into()));
|
||||||
|
}
|
||||||
// Notify before deleting: visibility is checked against the stored session.
|
// Notify before deleting: visibility is checked against the stored session.
|
||||||
app.emit(Event::SessionDeleted { session_id: id.clone() });
|
app.emit(Event::SessionDeleted { session_id: id.clone() });
|
||||||
runner::abort(&app, &id);
|
runner::abort(&app, &id);
|
||||||
@@ -1071,7 +1147,20 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
tracing::warn!("image cleanup: {e:#}");
|
tracing::warn!("image cleanup: {e:#}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Ok(Json(json!({"ok": true})))
|
let mut folder_error = None;
|
||||||
|
let mut folder_deleted = false;
|
||||||
|
if let Some(dir) = folder {
|
||||||
|
let shown = dir.display().to_string();
|
||||||
|
match tokio::task::spawn_blocking(move || std::fs::remove_dir_all(&dir)).await {
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
folder_deleted = true;
|
||||||
|
app.audit(Some(&user), "session.delete_folder", &shown, "");
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")),
|
||||||
|
Err(e) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(json!({"ok": true, "folder_deleted": folder_deleted, "folder_error": folder_error})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes,
|
/// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes,
|
||||||
|
|||||||
+111
-5
@@ -112,7 +112,47 @@ fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Resul
|
|||||||
// --- tool mapping -----------------------------------------------------------------------
|
// --- tool mapping -----------------------------------------------------------------------
|
||||||
|
|
||||||
/// Claude Code tool → (our tool name, input with our key names), so the chat renders it like ours.
|
/// Claude Code tool → (our tool name, input with our key names), so the chat renders it like ours.
|
||||||
|
/// Claude Code's `AskUserQuestion` input → our questions (it writes `multiSelect`).
|
||||||
|
fn cc_questions(input: &Value) -> Vec<crate::types::Question> {
|
||||||
|
input["questions"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|q| {
|
||||||
|
let question = q["question"].as_str()?.to_string();
|
||||||
|
let options = q["options"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|o| Some(crate::types::QuestionOption { label: o["label"].as_str()?.to_string(), description: o["description"].as_str().unwrap_or("").to_string() }))
|
||||||
|
.collect();
|
||||||
|
Some(crate::types::Question { question, header: q["header"].as_str().unwrap_or("").to_string(), options, multi_select: q["multiSelect"].as_bool().or(q["multi_select"].as_bool()).unwrap_or(false) })
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Our answers (question → text or list) as the CLI expects them: question → text, lists joined by ", ".
|
||||||
|
fn cc_answers(answers: &serde_json::Map<String, Value>) -> Value {
|
||||||
|
Value::Object(
|
||||||
|
answers
|
||||||
|
.iter()
|
||||||
|
.map(|(q, a)| {
|
||||||
|
let text = match a {
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
Value::Array(l) => l.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
(q.clone(), Value::String(text))
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn map_tool(name: &str, input: &Value) -> (String, Value) {
|
fn map_tool(name: &str, input: &Value) -> (String, Value) {
|
||||||
|
// Questions to the user show up as our question card / ask_user call.
|
||||||
|
if name == "AskUserQuestion" {
|
||||||
|
return ("ask_user".to_string(), json!({"questions": cc_questions(input)}));
|
||||||
|
}
|
||||||
// Our own tools reach the CLI over MCP: shown and judged under their usual names.
|
// Our own tools reach the CLI over MCP: shown and judged under their usual names.
|
||||||
if let Some(ours) = name.strip_prefix(crate::mcp_bridge::PREFIX) {
|
if let Some(ours) = name.strip_prefix(crate::mcp_bridge::PREFIX) {
|
||||||
return (ours.to_string(), input.clone());
|
return (ours.to_string(), input.clone());
|
||||||
@@ -194,9 +234,6 @@ fn title_of(name: &str, input: &Value, claude_input: &Value) -> String {
|
|||||||
/// Ask our permission system whether a tool call of Claude Code may run: (allow, message for a rejection).
|
/// Ask our permission system whether a tool call of Claude Code may run: (allow, message for a rejection).
|
||||||
async fn decide(app: &Arc<App>, session: &Session, claude_name: &str, claude_input: &Value, cancel: &CancellationToken) -> (bool, Option<String>) {
|
async fn decide(app: &Arc<App>, session: &Session, claude_name: &str, claude_input: &Value, cancel: &CancellationToken) -> (bool, Option<String>) {
|
||||||
let (name, input) = map_tool(claude_name, claude_input);
|
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 info = CcTool { name: name.clone() };
|
||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
let root = app.root_session_id(&session.id);
|
let root = app.root_session_id(&session.id);
|
||||||
@@ -324,7 +361,12 @@ fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
|
|||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for (i, p) in parts.iter().enumerate() {
|
for (i, p) in parts.iter().enumerate() {
|
||||||
let Part::Image { image } = p else { continue };
|
let Part::Image { image } = p else { continue };
|
||||||
|
// An image without bytes (an unresolved reference) would become an empty file.
|
||||||
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) else { continue };
|
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) else { continue };
|
||||||
|
if bytes.is_empty() {
|
||||||
|
tracing::warn!("claude code: image {i} of the message has no data – not saved");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let ext = image.media_type.split('/').nth(1).unwrap_or("png").split('+').next().unwrap_or("png");
|
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()));
|
let path = dir.join(format!("chat-{}-{i}.{ext}", now_ms()));
|
||||||
if std::fs::create_dir_all(&dir).is_ok() {
|
if std::fs::create_dir_all(&dir).is_ok() {
|
||||||
@@ -342,7 +384,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
|||||||
let session = app.session(session_id)?;
|
let session = app.session(session_id)?;
|
||||||
let owner = session.owner_id.clone();
|
let owner = session.owner_id.clone();
|
||||||
let model_id = session.model.strip_prefix("claude-code/").unwrap_or("default").to_string();
|
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)?);
|
// With the bytes of the images filled in (they are stored as references).
|
||||||
|
let history = crate::runner::active_history(app.store.messages_for_model(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 {
|
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");
|
bail!("nothing to do: no user message");
|
||||||
};
|
};
|
||||||
@@ -417,6 +460,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
|||||||
let mut cur: Option<Cur> = None;
|
let mut cur: Option<Cur> = None;
|
||||||
let mut calls: HashMap<String, String> = HashMap::new();
|
let mut calls: HashMap<String, String> = HashMap::new();
|
||||||
let mut diffs: HashMap<String, String> = HashMap::new();
|
let mut diffs: HashMap<String, String> = HashMap::new();
|
||||||
|
// Answers to AskUserQuestion calls, kept as the result's data for the question card.
|
||||||
|
let mut asked: HashMap<String, Value> = HashMap::new();
|
||||||
let mut final_text = String::new();
|
let mut final_text = String::new();
|
||||||
let mut got_result = false;
|
let mut got_result = false;
|
||||||
let mut failure: Option<String> = None;
|
let mut failure: Option<String> = None;
|
||||||
@@ -498,7 +543,7 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
|||||||
}
|
}
|
||||||
let id = b["tool_use_id"].as_str().unwrap_or("").to_string();
|
let id = b["tool_use_id"].as_str().unwrap_or("").to_string();
|
||||||
let is_error = b["is_error"].as_bool().unwrap_or(false);
|
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})) };
|
let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})).or_else(|| asked.remove(&id).map(|a| json!({"answers": a}))) };
|
||||||
parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: result_images(&b["content"]), is_error, meta });
|
parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: result_images(&b["content"]), is_error, meta });
|
||||||
}
|
}
|
||||||
if !parts.is_empty() {
|
if !parts.is_empty() {
|
||||||
@@ -511,6 +556,30 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
|||||||
"control_request" if ev["request"]["subtype"] == "can_use_tool" => {
|
"control_request" if ev["request"]["subtype"] == "can_use_tool" => {
|
||||||
finalize(&app, session_id, &mut cur, &model_id);
|
finalize(&app, session_id, &mut cur, &model_id);
|
||||||
let req = &ev["request"];
|
let req = &ev["request"];
|
||||||
|
// A question for the user: shown as a question card; the answers go back to the CLI.
|
||||||
|
if req["tool_name"] == "AskUserQuestion" {
|
||||||
|
let questions = cc_questions(&req["input"]);
|
||||||
|
let response = if questions.is_empty() {
|
||||||
|
json!({"behavior": "deny", "message": "No usable question was given."})
|
||||||
|
} else {
|
||||||
|
match app.ask_user(session_id, "agent", questions, cancel.clone()).await {
|
||||||
|
Ok(Some(answers)) => {
|
||||||
|
let mut input = req["input"].clone();
|
||||||
|
input["answers"] = cc_answers(&answers);
|
||||||
|
if let Some(id) = req["tool_use_id"].as_str() {
|
||||||
|
asked.insert(id.to_string(), Value::Object(answers));
|
||||||
|
}
|
||||||
|
json!({"behavior": "allow", "updatedInput": input})
|
||||||
|
}
|
||||||
|
_ => json!({"behavior": "deny", "message": "The user dismissed the questions without answering."}),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let (allow, message) = decide(&app, &session, req["tool_name"].as_str().unwrap_or(""), &req["input"], &cancel).await;
|
let (allow, message) = decide(&app, &session, req["tool_name"].as_str().unwrap_or(""), &req["input"], &cancel).await;
|
||||||
if allow {
|
if allow {
|
||||||
let (name, input) = map_tool(req["tool_name"].as_str().unwrap_or(""), &req["input"]);
|
let (name, input) = map_tool(req["tool_name"].as_str().unwrap_or(""), &req["input"]);
|
||||||
@@ -893,6 +962,43 @@ pub async fn update() -> Result<String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn uploaded_images_are_saved_with_their_bytes_only() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("cc-uploads-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let parts = vec![
|
||||||
|
Part::Image { image: ImageData { media_type: "image/png".into(), data: "QUJD".into(), blob: None, size: None } },
|
||||||
|
// A reference whose bytes were not filled in: must not become an empty file.
|
||||||
|
Part::Image { image: ImageData { media_type: "image/png".into(), data: String::new(), blob: Some("x".into()), size: None } },
|
||||||
|
];
|
||||||
|
let saved = save_images(&dir.display().to_string(), &parts);
|
||||||
|
assert_eq!(saved.len(), 1);
|
||||||
|
assert_eq!(std::fs::read(&saved[0]).unwrap(), b"ABC");
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn questions_roundtrip_with_the_cli_format() {
|
||||||
|
let input = json!({"questions": [
|
||||||
|
{"question": "Which database?", "header": "DB", "multiSelect": false, "options": [{"label": "SQLite", "description": "file"}, {"label": "Postgres"}]},
|
||||||
|
{"question": "Which extras?", "header": "Extras", "multiSelect": true, "options": [{"label": "Auth"}, {"label": "Docs"}]},
|
||||||
|
{"nonsense": true}
|
||||||
|
]});
|
||||||
|
let qs = cc_questions(&input);
|
||||||
|
assert_eq!(qs.len(), 2);
|
||||||
|
assert_eq!((qs[0].question.as_str(), qs[0].options.len(), qs[0].multi_select), ("Which database?", 2, false));
|
||||||
|
assert!(qs[1].multi_select);
|
||||||
|
// The tool card shows it as our ask_user.
|
||||||
|
let (name, shown) = map_tool("AskUserQuestion", &input);
|
||||||
|
assert_eq!(name, "ask_user");
|
||||||
|
assert_eq!(shown["questions"][1]["multi_select"], true);
|
||||||
|
// Answers: text stays, a multiple choice is joined.
|
||||||
|
let answers = json!({"Which database?": "SQLite", "Which extras?": ["Auth", "Docs"]});
|
||||||
|
let out = cc_answers(answers.as_object().unwrap());
|
||||||
|
assert_eq!(out["Which database?"], "SQLite");
|
||||||
|
assert_eq!(out["Which extras?"], "Auth, Docs");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_result_images_are_kept() {
|
fn tool_result_images_are_kept() {
|
||||||
let content = json!([{"type": "text", "text": "ok"}, {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "QUJD"}}]);
|
let content = json!([{"type": "text", "text": "ok"}, {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "QUJD"}}]);
|
||||||
|
|||||||
@@ -409,6 +409,16 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// (title, cwd) of all other chats (subagent sessions of this chat excluded).
|
||||||
|
pub fn other_session_cwds(&self, id: &str) -> Result<Vec<(String, String)>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT COALESCE(json_extract(data, '$.title'), ''), COALESCE(json_extract(data, '$.cwd'), '') FROM sessions WHERE id != ?1 AND COALESCE(parent_id, '') != ?1",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([id], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||||
|
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
|
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
|
||||||
// Images go to the blob table (encoded before taking the lock).
|
// Images go to the blob table (encoded before taking the lock).
|
||||||
let mut m = m.clone();
|
let mut m = m.clone();
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { del, get } from "./api";
|
||||||
|
import { choiceDialog, confirmDialog, toast } from "./dialogs";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
import { formatSize } from "./components/FileFigure";
|
||||||
|
|
||||||
|
interface FolderInfo {
|
||||||
|
own: boolean;
|
||||||
|
path?: string;
|
||||||
|
files?: number;
|
||||||
|
bytes?: number;
|
||||||
|
git?: boolean;
|
||||||
|
truncated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a chat after asking. A chat with a folder of its own (not shared with other chats, not a
|
||||||
|
* project) gets the question whether the folder goes with it. Resolves to true when it was deleted.
|
||||||
|
*/
|
||||||
|
export async function deleteChat(session: { id: string; parent_id?: string | null }): Promise<boolean> {
|
||||||
|
const folder = session.parent_id ? undefined : await get<FolderInfo>(`/sessions/${session.id}/folder`).catch(() => undefined);
|
||||||
|
let withFolder = false;
|
||||||
|
if (folder?.own) {
|
||||||
|
const size = `${folder.truncated ? "> " : ""}${t("chat.folderFiles", { n: folder.files ?? 0 })}, ${formatSize(folder.bytes ?? 0)}`;
|
||||||
|
const choice = await choiceDialog({
|
||||||
|
title: t("chat.deleteTitle"),
|
||||||
|
message: `${t("chat.deleteWithFolder", { path: folder.path ?? "", size })}${folder.git ? `\n\n${t("chat.folderHasGit")}` : ""}`,
|
||||||
|
choices: [
|
||||||
|
{ value: "chat", label: t("chat.deleteOnlyChat") },
|
||||||
|
{ value: "both", label: t("chat.deleteChatAndFolder"), danger: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!choice) return false;
|
||||||
|
withFolder = choice === "both";
|
||||||
|
} else if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await del<{ folder_deleted?: boolean; folder_error?: string }>(`/sessions/${session.id}${withFolder ? "?delete_folder=true" : ""}`);
|
||||||
|
if (r?.folder_error) toast(r.folder_error);
|
||||||
|
else if (r?.folder_deleted) toast(t("chat.folderDeleted"), "success");
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ import { UsageOverlay } from "./UsageOverlay";
|
|||||||
import { RewindDialog } from "./RewindDialog";
|
import { RewindDialog } from "./RewindDialog";
|
||||||
import { ShellsChip } from "./ShellsPanel";
|
import { ShellsChip } from "./ShellsPanel";
|
||||||
import { TodoList, ToolCard } from "./ToolCard";
|
import { TodoList, ToolCard } from "./ToolCard";
|
||||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
import { promptDialog, toast } from "../dialogs";
|
||||||
|
import { deleteChat } from "../chatActions";
|
||||||
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
||||||
import { ColorSwatches, IconPicker } from "./ColorSwatches";
|
import { ColorSwatches, IconPicker } from "./ColorSwatches";
|
||||||
import { ProjectPermissions } from "./ProjectPermissions";
|
import { ProjectPermissions } from "./ProjectPermissions";
|
||||||
@@ -757,9 +758,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
};
|
};
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
|
if (await deleteChat(session)) navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
|
||||||
await del(`/sessions/${sessionId}`);
|
|
||||||
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Context usage of the last request relative to the model's window.
|
// Context usage of the last request relative to the model's window.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ColorSwatches, IconPicker } from "./ColorSwatches";
|
import { deleteChat } from "../chatActions";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { StatusLeds } from "./StatusLeds";
|
import { StatusLeds } from "./StatusLeds";
|
||||||
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||||||
@@ -98,11 +98,21 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
|||||||
<Icon name="folder-up" /> {t("sidebar.removeFromFolder")}
|
<Icon name="folder-up" /> {t("sidebar.removeFromFolder")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<ColorSwatches value={s.color} onChange={(color) => (setMenu(false), organise(s.id, { color }))} />
|
|
||||||
<IconPicker value={s.icon} color={s.color} onChange={(icon) => (setMenu(false), organise(s.id, { icon }))} />
|
|
||||||
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
|
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
|
||||||
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
|
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
|
||||||
</button>
|
</button>
|
||||||
|
{!s.shared && (
|
||||||
|
<button
|
||||||
|
role="menuitem"
|
||||||
|
className="danger"
|
||||||
|
onClick={async () => {
|
||||||
|
setMenu(false);
|
||||||
|
if ((await deleteChat(s)) && active) navigate("/new");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="trash" /> {t("chat.delete")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,8 +20,15 @@ interface PromptOpts {
|
|||||||
confirmLabel?: string;
|
confirmLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChoiceOpts {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
choices: { value: string; label: string; danger?: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
type Dialog =
|
type Dialog =
|
||||||
| ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts)
|
| ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts)
|
||||||
|
| ({ kind: "choice"; id: number; resolve: (v: string | null) => void } & ChoiceOpts)
|
||||||
| ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts);
|
| ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts);
|
||||||
|
|
||||||
interface Toast {
|
interface Toast {
|
||||||
@@ -45,6 +52,14 @@ export function confirmDialog(opts: ConfirmOpts | string): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Several actions to choose from (plus Cancel): resolves to the chosen value, or null. */
|
||||||
|
export function choiceDialog(opts: ChoiceOpts): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
dialogs = [...dialogs, { kind: "choice", id: ++seq, resolve, ...opts }];
|
||||||
|
emit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function promptDialog(opts: PromptOpts | string): Promise<string | null> {
|
export function promptDialog(opts: PromptOpts | string): Promise<string | null> {
|
||||||
const o = typeof opts === "string" ? { message: opts } : opts;
|
const o = typeof opts === "string" ? { message: opts } : opts;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -108,6 +123,18 @@ function DialogView({ d }: { d: Dialog }) {
|
|||||||
style={{ width: "100%" }}
|
style={{ width: "100%" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{d.kind === "choice" ? (
|
||||||
|
<div className="row dialog-actions dialog-choices">
|
||||||
|
<button type="button" onClick={() => close(d, null)}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
{d.choices.map((c, i) => (
|
||||||
|
<button key={c.value} ref={i === d.choices.length - 1 ? ok : undefined} type="button" className={c.danger ? "danger-solid" : i === d.choices.length - 1 ? "primary" : undefined} onClick={() => close(d, c.value)}>
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="row dialog-actions">
|
<div className="row dialog-actions">
|
||||||
<button type="button" onClick={() => close(d, cancelValue)}>
|
<button type="button" onClick={() => close(d, cancelValue)}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
@@ -116,6 +143,7 @@ function DialogView({ d }: { d: Dialog }) {
|
|||||||
{d.confirmLabel ?? t("common.ok")}
|
{d.confirmLabel ?? t("common.ok")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1161,4 +1161,11 @@ export const de: Messages = {
|
|||||||
"ssh.copyFailed": "Kopieren nicht möglich – Text von Hand markieren",
|
"ssh.copyFailed": "Kopieren nicht möglich – Text von Hand markieren",
|
||||||
"ssh.added": "Schlüssel hinzugefügt",
|
"ssh.added": "Schlüssel hinzugefügt",
|
||||||
"ssh.deleteConfirm": "Schlüssel \"{name}\" löschen? Server, die ihm vertrauen, akzeptieren dich von hier aus nicht mehr.",
|
"ssh.deleteConfirm": "Schlüssel \"{name}\" löschen? Server, die ihm vertrauen, akzeptieren dich von hier aus nicht mehr.",
|
||||||
|
"chat.deleteTitle": "Chat löschen",
|
||||||
|
"chat.deleteWithFolder": "Dieser Chat hat einen eigenen Ordner:\n{path}\n({size})\n\nSoll der Ordner mit dem Chat gelöscht werden?",
|
||||||
|
"chat.deleteOnlyChat": "Nur Chat löschen",
|
||||||
|
"chat.deleteChatAndFolder": "Chat und Ordner löschen",
|
||||||
|
"chat.folderFiles": "{n} Dateien",
|
||||||
|
"chat.folderHasGit": "Der Ordner enthält ein Git-Repository – nicht gepushte Commits und Branches gehen verloren.",
|
||||||
|
"chat.folderDeleted": "Chat und Ordner gelöscht",
|
||||||
};
|
};
|
||||||
@@ -1160,6 +1160,13 @@ export const en = {
|
|||||||
"ssh.copyFailed": "Could not copy – select the text manually",
|
"ssh.copyFailed": "Could not copy – select the text manually",
|
||||||
"ssh.added": "Key added",
|
"ssh.added": "Key added",
|
||||||
"ssh.deleteConfirm": "Delete the key \"{name}\"? Servers that trust it will no longer accept you from here.",
|
"ssh.deleteConfirm": "Delete the key \"{name}\"? Servers that trust it will no longer accept you from here.",
|
||||||
|
"chat.deleteTitle": "Delete chat",
|
||||||
|
"chat.deleteWithFolder": "This chat has a folder of its own:\n{path}\n({size})\n\nDelete the folder with the chat?",
|
||||||
|
"chat.deleteOnlyChat": "Delete chat only",
|
||||||
|
"chat.deleteChatAndFolder": "Delete chat and folder",
|
||||||
|
"chat.folderFiles": "{n} files",
|
||||||
|
"chat.folderHasGit": "The folder contains a git repository – commits and branches that were not pushed are lost.",
|
||||||
|
"chat.folderDeleted": "Chat and folder deleted",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Messages = Record<keyof typeof en, string>;
|
export type Messages = Record<keyof typeof en, string>;
|
||||||
+4
-10
@@ -600,9 +600,6 @@ button.session-group:hover {
|
|||||||
background: color-mix(in srgb, var(--tag) 38%, var(--bg1));
|
background: color-mix(in srgb, var(--tag) 38%, var(--bg1));
|
||||||
border-color: color-mix(in srgb, var(--tag) 70%, transparent);
|
border-color: color-mix(in srgb, var(--tag) 70%, transparent);
|
||||||
}
|
}
|
||||||
.chat-pane[data-color] .msg.bubble.agent {
|
|
||||||
background: color-mix(in srgb, var(--tag) 7%, var(--bg1));
|
|
||||||
}
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.msg.user-msg {
|
.msg.user-msg {
|
||||||
max-width: 94%;
|
max-width: 94%;
|
||||||
@@ -4758,15 +4755,10 @@ span.chat-tab > button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-pane[data-color] {
|
/* The chat's colour marks the top bar (line), the list entry, the badge and your own bubbles –
|
||||||
background: color-mix(in srgb, var(--tag) 16%, var(--bg0));
|
* the chat background itself stays neutral. */
|
||||||
}
|
|
||||||
.chat-pane[data-color] .composer-wrap {
|
|
||||||
background: color-mix(in srgb, var(--tag) 16%, var(--bg0));
|
|
||||||
}
|
|
||||||
.topbar[data-color] {
|
.topbar[data-color] {
|
||||||
border-bottom: 3px solid var(--tag);
|
border-bottom: 3px solid var(--tag);
|
||||||
background: color-mix(in srgb, var(--tag) 28%, var(--bg0));
|
|
||||||
}
|
}
|
||||||
.session-item[data-color] {
|
.session-item[data-color] {
|
||||||
box-shadow: inset 4px 0 0 var(--tag);
|
box-shadow: inset 4px 0 0 var(--tag);
|
||||||
@@ -5026,3 +5018,5 @@ td.fold-indent {
|
|||||||
.ssh-key { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
|
.ssh-key { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
|
||||||
.ssh-key .row { align-items: center; flex-wrap: wrap; }
|
.ssh-key .row { align-items: center; flex-wrap: wrap; }
|
||||||
.ssh-key input.mono { width: 100%; font-size: 12px; }
|
.ssh-key input.mono { width: 100%; font-size: 12px; }
|
||||||
|
.dialog-message { overflow-wrap: anywhere; }
|
||||||
|
.dialog-choices { flex-wrap: wrap; }
|
||||||
@@ -3,6 +3,22 @@
|
|||||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||||
*/
|
*/
|
||||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||||
|
"202611-00040": {
|
||||||
|
en: ["Images you upload are now visible to Claude Code (they arrived empty before)"],
|
||||||
|
de: ["Hochgeladene Bilder sind für Claude Code jetzt sichtbar (sie kamen vorher leer an)"],
|
||||||
|
},
|
||||||
|
"202611-00039": {
|
||||||
|
en: ["The chat colour no longer tints the chat background", "Claude Code can ask you questions again (question card in the chat)"],
|
||||||
|
de: ["Die Chat-Farbe färbt den Hintergrund des Chats nicht mehr ein", "Claude Code kann dir wieder Fragen stellen (Fragenkarte im Chat)"],
|
||||||
|
},
|
||||||
|
"202611-00038": {
|
||||||
|
en: ["Sidebar chat menu: Delete added; colour and icon are now chosen in the chat's own menu"],
|
||||||
|
de: ["Chat-Menü in der Sidebar: Löschen hinzugefügt; Farbe und Icon wählst du jetzt im Menü des Chats"],
|
||||||
|
},
|
||||||
|
"202611-00037": {
|
||||||
|
en: ["Deleting a chat now asks whether to delete its own folder too"],
|
||||||
|
de: ["Beim Löschen eines Chats wirst du gefragt, ob sein eigener Ordner mitgelöscht werden soll"],
|
||||||
|
},
|
||||||
"202611-00036": {
|
"202611-00036": {
|
||||||
en: ["Claude Code can now use all CodeCraft tools (image generation, 3D, browser, scheduling …)"],
|
en: ["Claude Code can now use all CodeCraft tools (image generation, 3D, browser, scheduling …)"],
|
||||||
de: ["Claude Code kann jetzt alle CodeCraft-Tools nutzen (Bildgenerierung, 3D, Browser, Zeitpläne …)"],
|
de: ["Claude Code kann jetzt alle CodeCraft-Tools nutzen (Bildgenerierung, 3D, Browser, Zeitpläne …)"],
|
||||||
|
|||||||
Reference in new issue
Block a user