Compare commits

...
4 Commits
Author SHA1 Message Date
Richard Zink 1253430ed4 Release v202611-00020 2026-11-11 07:47:05 +01:00
Richard ZinkandClaude Opus 5.5 e7d4b9ff76 Reverse engineering tools per mode; raw firmware in decompile; skill
- AgentConfig.reverse_tools (Settings -> Agents & commands) gives any mode
  binary_info and decompile; the clean-room check of the tools applies to
  craft only (ToolCtx.clean_room = clean room && craft).
- decompile: optional processor (Ghidra language id) and base_address for raw
  images (BinaryLoader); separate cache per processor/address.
- Skill "reverse-engineering": identify, convert HEX, processor table for
  common chips, decompile, explore, notes in analysis/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 07:47:05 +01:00
Richard Zink b71dfc49c3 Release v202611-00019 2026-11-11 07:32:19 +01:00
Richard ZinkandClaude Opus 5.5 3b0764450a Long chats stay fast; search in a chat; media overview
Performance (measured with 5,900 messages: open 9 s -> 0.7 s, typing 440 ms -> 25 ms)
- GET /sessions/{id}?limit=N loads the end of a chat (from the last summary
  on, at most N); /sessions/{id}/messages?before|after|around loads more
  (scrolling up stops at a summary unless asked). Without parameters the
  full history as before (terminal client).
- Chat view: memoised message list (typing, streaming and status changes no
  longer re-render the history), finished turns fold into "N steps · M files"
  with the answer visible, tool card bodies and summaries are built only when
  opened, scroll-to-bottom only when content changes.

Search: /sessions/{id}/search over the whole chat; a hit loads its part
(around) and marks the message, "back to the latest messages" returns.

Media: /sessions/{id}/media lists images, videos and audio of a chat and its
subagents (generated images, tool output, files written, media in answers,
attachments); ⋯ -> Media shows them with filters, a large view and a jump to
the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 07:32:18 +01:00
22 changed files with 1428 additions and 112 deletions

No files matched your search

+30
View File
@@ -3,6 +3,36 @@
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-00020] – 2026-11-11
### Added
- **Reverse engineering outside craft:** *Settings → Agents & commands* can give any mode the
`binary_info` and `decompile` (Ghidra) tools; the clean-room rule stays a craft rule.
- **Raw firmware in `decompile`:** optional `processor` (Ghidra language id, e.g.
`ARM:LE:32:Cortex`) and `base_address` for images without a file format (.bin, converted .hex).
- **Skill "reverse-engineering":** how to identify, decompile and explore programs and firmware
(processor table for common chips, HEX conversion, notes in `analysis/`).
## [v202611-00019] – 2026-11-11
### Added
- **Search in a chat:** the magnifier in the chat's top bar searches all of it (messages, tool
output) on the server; a hit loads its part of the chat and marks the message.
- **Media of a chat:** *⋯ → Media* shows every image, video and audio file of the chat and its
subagents – generated images, screenshots, files the agent wrote, media in answers, your
attachments – filterable, large view, "show in chat" jumps to the message.
### Changed
- **Long chats stay fast:** a chat opens with its messages since the last summary (at most 200);
older ones load when scrolling up, the history before a summary on request. Finished turns
fold into one line ("42 steps · 3 files", the answer stays visible). Typing, streaming and
status changes no longer re-render the whole history, and tool output is only built when a
card is opened. Measured with a chat of 5,900 messages: opening 9 s → 0.7 s (phone-like
CPU: 22 s → 1 s), typing 440 ms → 25 ms per key.
## [v202611-00018] – 2026-11-10 ## [v202611-00018] – 2026-11-10
### Added ### Added
+1 -1
View File
@@ -11,7 +11,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Version v202611-00018" src="https://img.shields.io/badge/version-v202611--00018-d69e2e?style=flat-square"> <img alt="Version v202611-00020" src="https://img.shields.io/badge/version-v202611--00020-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">
@@ -0,0 +1,64 @@
---
name: reverse-engineering
description: Analyse programs and firmware – identify the format, inspect with binary_info, decompile with Ghidra (decompile tool, also raw firmware with processor/base address), explore the output and write down findings. For file formats, protocols, bugs and interoperability.
---
# Reverse engineering of programs and firmware
You analyse a binary to understand how it works – a file format, a protocol, an algorithm,
a bug, or how to talk to a device. Work from cheap to expensive and write down what you learn.
Only analyse binaries the user owns or may analyse (their own builds, their devices, programs
for interoperability). Decompiled code is third-party material: never commit it and never paste
it into the user's source – describe behaviour in your own words.
## 1. Identify
- `file <path>` – format, architecture, stripped or not.
- Executables and libraries (PE .exe/.dll, ELF, Mach-O): `binary_info` – sections, imports,
exports, strings. Imports and strings tell a lot (file names, error messages, protocol
keywords, library names) before any decompiling.
- Archives and update packages: unpack first (`unzip`, `7z x`), then identify the parts.
- Intel HEX / S-record firmware: convert to a raw image –
`objcopy -I ihex -O binary fw.hex fw.bin` (the lowest address in the HEX file is the base
address). An ELF from the firmware's build (e.g. `zephyr.elf`, `firmware.elf`) is far better
than a raw image: symbols and addresses are included – look for it first.
- Raw images: look for clues with `strings -n 6 fw.bin | head -200` and
`python3 -c` one-liners (vector table, magic numbers, embedded file systems).
## 2. Raw firmware: processor and load address
`decompile` needs both for images without a file format:
| Chip family | `processor` | typical `base_address` |
|---|---|---|
| ARM Cortex-M (STM32, nRF52, RP2040, SAMD) | `ARM:LE:32:Cortex` | STM32 `0x08000000`, nRF52 `0x00000000`, RP2040 `0x10000000` |
| ARM Cortex-A / ARMv7 | `ARM:LE:32:v7` | from the bootloader / datasheet |
| AArch64 | `AARCH64:LE:64:v8A` | |
| ESP32 (Xtensa) | `Xtensa:LE:32:default` | per segment (use the ELF if possible) |
| AVR (Arduino) | `AVR8:LE:16:atmega256` or `AVR8:LE:16:default` | `0x0` |
| MIPS (routers) | `MIPS:BE:32:default` or `MIPS:LE:32:default` | e.g. `0x80000000` |
Cortex-M check: the first word of the image is the initial stack pointer (0x2000xxxx = RAM),
the second the reset vector – it points just behind the base address. If the decompiled output
is mostly garbage, the processor or base address is wrong: fix it and run again (results are
cached per file *and* processor/address).
## 3. Decompile
- `decompile` with the path (plus `processor`/`base_address` for raw images). Large programs:
start with `max_functions` (e.g. 2000), widen when needed.
- Output in `.codecraft/decompiled/<name>/`: `functions.tsv` (address, name, size, file,
signature), `functions/*.c`, `strings.tsv`, `imports.tsv`, `exports.tsv`.
## 4. Explore
- Start from what you know: a string, an import or an export.
`grep -l "SetCommState" .codecraft/decompiled/<name>/functions` finds the functions that use it.
- Follow the calls by reading the functions; note addresses (`FUN_00401a30`) as you go.
- Broad searches through thousands of functions: delegate them to an `explore` subagent with a
precise question and let it report file:line.
- File formats: compare the reading/writing code with a sample file (`python3` to dump the bytes)
until every field is explained.
## 5. Write it down
Keep findings in `analysis/notes.md` (create it; it is not committed): structure of the
program, important functions with addresses, data structures, file formats and protocols with
field tables, open questions. Make sure `.gitignore` excludes `analysis/`, `original/` and
`.codecraft/decompiled/`. At the end, report what you found and how sure you are – mark guesses
as guesses.
+73 -3
View File
@@ -81,6 +81,9 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs)) .route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
.route("/sessions/{id}/export", get(export_session)) .route("/sessions/{id}/export", get(export_session))
.route("/blobs/{hash}", get(get_blob)) .route("/blobs/{hash}", get(get_blob))
.route("/sessions/{id}/messages", get(session_messages))
.route("/sessions/{id}/search", get(search_in_session))
.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}/prompt", post(prompt)) .route("/sessions/{id}/prompt", post(prompt))
.route("/sessions/{id}/abort", post(abort)) .route("/sessions/{id}/abort", post(abort))
@@ -813,14 +816,81 @@ async fn export_session(Extension(user): Extension<User>, State(app): State<AppS
.into_response()) .into_response())
} }
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult { #[derive(Deserialize)]
struct WindowQ {
/// Only the end of the chat (from the last summary on, at most this many messages);
/// without it, all messages (terminal client).
limit: Option<i64>,
before: Option<String>,
after: Option<String>,
around: Option<String>,
/// Loading older messages stops at a summary (the history before it on request).
#[serde(default)]
stop_at_summary: bool,
}
impl WindowQ {
fn window(&self) -> Option<crate::store::MessageWindow> {
use crate::store::MessageWindow as W;
let limit = self.limit.unwrap_or(200).clamp(1, 1000);
Some(match (&self.before, &self.after, &self.around) {
(Some(id), _, _) => W::Before { id: id.clone(), limit, stop_at_summary: self.stop_at_summary },
(_, Some(id), _) => W::After { id: id.clone(), limit },
(_, _, Some(id)) => W::Around { id: id.clone(), limit },
_ if self.limit.is_some() => W::Tail { limit },
_ => return None,
})
}
}
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<WindowQ>) -> ApiResult {
let s = readable(&app, &user, &id)?; let s = readable(&app, &user, &id)?;
let messages = app.store.messages(&id)?;
let busy = app.is_busy(&id); let busy = app.is_busy(&id);
let can_write = app.access(&user, &id) == Access::Full; let can_write = app.access(&user, &id) == Access::Full;
let queue = app.store.queue_list(&id)?; let queue = app.store.queue_list(&id)?;
let step = app.with_runtime(&id, |r| r.step.clone()); let step = app.with_runtime(&id, |r| r.step.clone());
Ok(Json(json!({"session": s, "messages": messages, "busy": busy, "can_write": can_write, "queue": queue, "step": step}))) let mut out = json!({"session": s, "busy": busy, "can_write": can_write, "queue": queue, "step": step});
match q.window() {
Some(w) => {
let (messages, older, newer) = app.store.messages_window(&id, &w)?;
out["messages"] = json!(messages);
out["has_older"] = json!(older);
out["has_newer"] = json!(newer);
}
None => out["messages"] = json!(app.store.messages(&id)?),
}
Ok(Json(out))
}
/// More messages of a chat: before / after / around a message.
async fn session_messages(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<WindowQ>) -> ApiResult {
readable(&app, &user, &id)?;
let w = q.window().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "before, after, around or limit is required".into()))?;
let (messages, older, newer) = app.store.messages_window(&id, &w).map_err(|e| ApiError(StatusCode::NOT_FOUND, format!("{e:#}")))?;
Ok(Json(json!({"messages": messages, "has_older": older, "has_newer": newer})))
}
/// Images, videos and audio files shown in a chat (all of it, with subagents).
async fn session_media(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
readable(&app, &user, &id)?;
let a = app.clone();
let items = tokio::task::spawn_blocking(move || crate::media::collect(&a, &id)).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
Ok(Json(json!(items)))
}
/// Search within one chat (all of it, not only the loaded part).
async fn search_in_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<SearchQ>) -> ApiResult {
readable(&app, &user, &id)?;
if q.q.trim().chars().count() < 2 {
return Ok(Json(json!([])));
}
let hits: Vec<Value> = app
.store
.search_messages(&id, q.q.trim(), 100)?
.into_iter()
.map(|(mid, role, at, snippet)| json!({"id": mid, "role": role, "created_at": at, "snippet": snippet}))
.collect();
Ok(Json(json!(hits)))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
+4
View File
@@ -398,6 +398,10 @@ pub struct AgentConfig {
/// Review loop after each turn of this mode. /// Review loop after each turn of this mode.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub refine: Option<RefineConfig>, pub refine: Option<RefineConfig>,
/// Reverse engineering tools (binary_info, decompile with Ghidra) in this mode;
/// craft has them anyway (deep mode).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reverse_tools: Option<bool>,
} }
/// Refinement loop: after the agent finishes, a reviewer checks the result /// Refinement loop: after the agent finishes, a reviewer checks the result
+1
View File
@@ -22,6 +22,7 @@ mod lsp;
mod mentions; mod mentions;
mod metrics; mod metrics;
mod mcp; mod mcp;
mod media;
mod permission; mod permission;
mod prompts; mod prompts;
mod push; mod push;
+200
View File
@@ -0,0 +1,200 @@
//! Media overview of a chat: every image, video and audio file that was shown in it –
//! generated images, images in tool results (screenshots), media files the agent wrote,
//! media referenced in answers, and the user's own attachments. Includes subagents.
use crate::app::App;
use crate::types::{Message, Part, Role};
use serde::Serialize;
use serde_json::Value;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, PartialEq)]
pub struct MediaItem {
/// "image", "video" or "audio".
pub kind: &'static str,
pub url: String,
pub name: String,
/// "generated", "tool", "written", "answer" or "upload".
pub source: &'static str,
pub created_at: i64,
/// Message to jump to (only for messages of the chat itself, not of subagents).
#[serde(skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>,
}
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"];
const VIDEO: &[&str] = &["mp4", "webm", "mov", "m4v", "ogv"];
const AUDIO: &[&str] = &["mp3", "wav", "ogg", "oga", "m4a", "flac", "opus", "aac"];
/// Kind of a media file by its extension.
pub fn kind_of(path: &str) -> Option<&'static str> {
let ext = Path::new(path.split(['?', '#']).next().unwrap_or(path)).extension()?.to_str()?.to_ascii_lowercase();
if IMAGE.contains(&ext.as_str()) {
Some("image")
} else if VIDEO.contains(&ext.as_str()) {
Some("video")
} else if AUDIO.contains(&ext.as_str()) {
Some("audio")
} else {
None
}
}
fn kind_of_mime(mime: &str) -> &'static str {
if mime.starts_with("video/") {
"video"
} else if mime.starts_with("audio/") {
"audio"
} else {
"image"
}
}
/// Like JavaScript's encodeURIComponent.
fn encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
/// Absolute, normalised path of `p` (relative to `cwd`).
fn resolve(p: &str, cwd: &Path) -> PathBuf {
let joined = if p.starts_with('/') { PathBuf::from(p) } else { cwd.join(p) };
crate::tools::normalize(&joined)
}
/// Media file paths in a text: Markdown images/links and plain paths with a media extension.
fn paths_in(text: &str) -> Vec<String> {
let mut out = Vec::new();
for token in text.split(|c: char| c.is_whitespace() || "()[]<>\"'`,;".contains(c)) {
let t = token.trim_end_matches(['.', ':', '!', '?']);
if t.is_empty() || t.contains("://") || t.starts_with("data:") {
continue;
}
if kind_of(t).is_some() && t.chars().any(|c| c == '/' || c == '.') {
out.push(t.to_string());
}
}
out
}
/// All media of a chat and its subagents, newest first.
pub fn collect(app: &App, session_id: &str) -> anyhow::Result<Vec<MediaItem>> {
let mut sessions = vec![session_id.to_string()];
// Subagents (and theirs).
let mut i = 0;
while i < sessions.len() && sessions.len() < 500 {
let children = app.store.child_sessions(&sessions[i])?;
sessions.extend(children);
i += 1;
}
let mut out: Vec<MediaItem> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut push = |item: MediaItem, out: &mut Vec<MediaItem>| {
if seen.insert(item.url.clone()) {
out.push(item);
}
};
for sid in &sessions {
let Some(session) = app.store.get_session(sid)? else { continue };
let cwd = PathBuf::from(&session.cwd);
let own = sid == session_id;
// Tool results are shown in the card of the call: jump to that message.
let mut call_msg: Option<String> = None;
for m in app.store.messages(sid)? {
collect_message(&m, &cwd, own, call_msg.as_deref(), &mut |item| push(item, &mut out));
if m.role == Role::Assistant {
call_msg = Some(m.id.clone());
}
}
}
// Generated images whose tool result was not found (e.g. removed by a rewind).
for img in app.store.images_of_sessions(&sessions)? {
push(
MediaItem { kind: "image", url: format!("/api/images/{}/file", img.id), name: file_name(&img.path), source: "generated", created_at: img.created_at, message_id: None },
&mut out,
);
}
// Names of generated images (their files).
let names: std::collections::HashMap<String, String> = app.store.images_of_sessions(&sessions)?.into_iter().map(|i| (format!("/api/images/{}/file", i.id), file_name(&i.path))).collect();
for item in &mut out {
if let Some(n) = names.get(&item.url) {
item.name = n.clone();
}
}
out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(out)
}
fn file_name(p: &str) -> String {
Path::new(p).file_name().and_then(|n| n.to_str()).unwrap_or(p).to_string()
}
fn collect_message(m: &Message, cwd: &Path, own: bool, call_msg: Option<&str>, add: &mut dyn FnMut(MediaItem)) {
let mid = own.then(|| m.id.clone());
let result_mid = own.then(|| call_msg.unwrap_or(&m.id).to_string());
let at = m.created_at;
let file = |p: &str, source: &'static str| -> Option<MediaItem> {
let kind = kind_of(p)?;
let abs = resolve(p, cwd);
abs.is_file().then(|| MediaItem { kind, url: format!("/api/files/raw?path={}", encode(&abs.display().to_string())), name: file_name(p), source, created_at: at, message_id: mid.clone() })
};
for p in &m.parts {
match p {
Part::Image { image } if m.role == Role::User => {
if let Some(h) = &image.blob {
add(MediaItem { kind: kind_of_mime(&image.media_type), url: format!("/api/blobs/{h}"), name: "image".into(), source: "upload", created_at: at, message_id: mid.clone() });
}
}
Part::ToolResult { images, meta, .. } => {
let ids: Vec<&str> = meta.as_ref().and_then(|v| v["image_ids"].as_array()).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
if !ids.is_empty() {
for id in ids {
add(MediaItem { kind: "image", url: format!("/api/images/{id}/file"), name: "image".into(), source: "generated", created_at: at, message_id: result_mid.clone() });
}
} else {
for img in images {
if let Some(h) = &img.blob {
add(MediaItem { kind: kind_of_mime(&img.media_type), url: format!("/api/blobs/{h}"), name: "screenshot".into(), source: "tool", created_at: at, message_id: result_mid.clone() });
}
}
}
}
Part::ToolCall { name, input, .. } if matches!(name.as_str(), "write" | "edit") => {
if let Some(item) = input["path"].as_str().and_then(|p| file(p, "written")) {
add(item);
}
}
Part::Text { text } if m.role == Role::Assistant => {
for p in paths_in(text) {
if let Some(item) = file(&p, "answer") {
add(item);
}
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kinds_paths_and_encoding() {
assert_eq!(kind_of("a/b.PNG"), Some("image"));
assert_eq!(kind_of("clip.mp4?v=1"), Some("video"));
assert_eq!(kind_of("song.flac"), Some("audio"));
assert_eq!(kind_of("main.rs"), None);
assert_eq!(paths_in("Saved to `art/final/hero.png` and ![x](shots/a.webp). See https://x.org/y.png"), vec!["art/final/hero.png", "shots/a.webp"]);
assert_eq!(encode("/w/ä b.png"), "%2Fw%2F%C3%A4%20b.png");
}
}
+11 -6
View File
@@ -32,6 +32,8 @@ pub struct Agent {
pub max_parallel: Option<usize>, pub max_parallel: Option<usize>,
/// Review loop after each turn (None/0 rounds = off). /// Review loop after each turn (None/0 rounds = off).
pub refine: Option<crate::config::RefineConfig>, pub refine: Option<crate::config::RefineConfig>,
/// binary_info and decompile available (besides craft).
pub reverse: bool,
/// Source file of Markdown agents. /// Source file of Markdown agents.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>, pub path: Option<String>,
@@ -43,12 +45,12 @@ pub use codecraft_core::prompts::*;
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> { pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
let craft = craft_prompt(clean_room); let craft = craft_prompt(clean_room);
vec![ vec![
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: "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, reverse: false, 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: "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, reverse: false, 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: "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, reverse: false, 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: "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, reverse: false, 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: "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, reverse: false, 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 }, 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, reverse: false, path: None },
] ]
} }
@@ -122,6 +124,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
source: "file".into(), source: "file".into(),
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()), max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None, after_todos: meta.get("refine_after_todos").is_none_or(|v| v != "false") }), refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None, after_todos: meta.get("refine_after_todos").is_none_or(|v| v != "false") }),
reverse: meta.get("reverse_tools").is_some_and(|v| v == "true"),
path: Some(p.display().to_string()), path: Some(p.display().to_string()),
}); });
} }
@@ -141,6 +144,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
source: "config".into(), source: "config".into(),
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)), max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())), refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())),
reverse: a.reverse_tools.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.reverse)),
path: None, path: None,
}); });
} }
@@ -173,6 +177,7 @@ pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) ->
source: "personal".into(), source: "personal".into(),
max_parallel: None, max_parallel: None,
refine: a.refine, refine: a.refine,
reverse: a.reverse_tools.unwrap_or(false),
path: None, path: None,
}); });
} }
+30 -4
View File
@@ -142,7 +142,10 @@ impl Tool for Decompile {
"Decompile an executable or library with Ghidra (x86/x64/ARM/MIPS/PowerPC/68k …, PE, ELF, \ "Decompile an executable or library with Ghidra (x86/x64/ARM/MIPS/PowerPC/68k …, PE, ELF, \
Mach-O, raw DOS). Writes pseudo-C for every function plus strings, imports and exports \ Mach-O, raw DOS). Writes pseudo-C for every function plus strings, imports and exports \
into .codecraft/decompiled/<name>/ and returns an overview. Can take several minutes for \ into .codecraft/decompiled/<name>/ and returns an overview. Can take several minutes for \
large programs; results are cached per file." large programs; results are cached per file. Raw firmware images (.bin without a file \
format) need `processor` (Ghidra language id, e.g. \"ARM:LE:32:Cortex\" for Cortex-M, \
\"ARM:LE:32:v8\", \"AVR8:LE:16:atmega256\", \"Xtensa:LE:32:default\" for ESP32) and usually \
`base_address` (load address, e.g. \"0x08000000\" for STM32 flash)."
.into() .into()
} }
fn schema(&self) -> Value { fn schema(&self) -> Value {
@@ -151,7 +154,9 @@ impl Tool for Decompile {
"properties": { "properties": {
"path": {"type": "string"}, "path": {"type": "string"},
"max_functions": {"type": "integer", "description": "Limit for very large binaries (default: all)"}, "max_functions": {"type": "integer", "description": "Limit for very large binaries (default: all)"},
"force": {"type": "boolean", "description": "Re-run even if cached"} "force": {"type": "boolean", "description": "Re-run even if cached"},
"processor": {"type": "string", "description": "Ghidra language id for raw images, e.g. ARM:LE:32:Cortex (default: detected from the file format)"},
"base_address": {"type": "string", "description": "Load address of a raw image, e.g. 0x08000000"}
}, },
"required": ["path"] "required": ["path"]
}) })
@@ -171,7 +176,20 @@ impl Tool for Decompile {
bail!("{} is not a file", path.display()); bail!("{} is not a file", path.display());
} }
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("binary").replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '-', "_"); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("binary").replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '-', "_");
let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}", file_hash(&path)?)); let processor = input["processor"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
let base = input["base_address"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
if processor.as_deref().is_some_and(|p| !p.chars().all(|c| c.is_ascii_alphanumeric() || ":_-.".contains(c))) {
bail!("invalid processor id (expected e.g. ARM:LE:32:Cortex)");
}
if base.as_deref().is_some_and(|b| !b.trim_start_matches("0x").chars().all(|c| c.is_ascii_hexdigit())) {
bail!("invalid base_address (expected e.g. 0x08000000)");
}
// Another processor or load address is another analysis.
let variant = match (&processor, &base) {
(None, None) => String::new(),
(p, b) => format!("-{}", crate::blobs::hash(format!("{p:?}{b:?}").as_bytes()).get(..8).unwrap_or("")),
};
let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}{variant}", file_hash(&path)?));
if out_dir.join(".done").exists() && !input["force"].as_bool().unwrap_or(false) { if out_dir.join(".done").exists() && !input["force"].as_bool().unwrap_or(false) {
return Ok(ToolOutput::text(format!("(cached)\n{}", summarize(&out_dir, &ctx.cwd)?))); return Ok(ToolOutput::text(format!("(cached)\n{}", summarize(&out_dir, &ctx.cwd)?)));
} }
@@ -183,7 +201,15 @@ impl Tool for Decompile {
let scripts = scripts_dir().display().to_string(); let scripts = scripts_dir().display().to_string();
let max = input["max_functions"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "2147483647".into()); let max = input["max_functions"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "2147483647".into());
let (p, proj, out) = (path.display().to_string(), project.display().to_string(), out_dir.display().to_string()); let (p, proj, out) = (path.display().to_string(), project.display().to_string(), out_dir.display().to_string());
let args = vec![proj.as_str(), "cc", "-import", p.as_str(), "-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"]; let mut args = vec![proj.as_str(), "cc", "-import", p.as_str()];
// Raw firmware: the binary loader with the given processor and load address.
if let Some(proc_id) = processor.as_deref() {
args.extend(["-processor", proc_id]);
}
if let Some(addr) = base.as_deref() {
args.extend(["-loader", "BinaryLoader", "-loader-baseAddr", addr]);
}
args.extend(["-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"]);
let java = tools.join("jdk").display().to_string(); let java = tools.join("jdk").display().to_string();
let mut cmd = match &ctx.sandbox { let mut cmd = match &ctx.sandbox {
Some(spec) => { Some(spec) => {
+8 -2
View File
@@ -226,7 +226,12 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
all.into_iter() all.into_iter()
.filter(|t| !agent.read_only || t.read_only()) .filter(|t| !agent.read_only || t.read_only())
// Craft tools only where they are useful. // Craft tools only where they are useful.
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase") || agent.tools.is_some()) .filter(|t| {
agent.name == "craft"
|| agent.tools.is_some()
|| (agent.reverse && matches!(t.name(), "binary_info" | "decompile"))
|| !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase")
})
.filter(|t| match &agent.tools { .filter(|t| match &agent.tools {
None => true, None => true,
Some(list) => { Some(list) => {
@@ -456,7 +461,8 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
agent: agent.name.clone(), agent: agent.name.clone(),
roots: roots.clone(), roots: roots.clone(),
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()), sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
clean_room, // Clean room is a craft rule; other modes with reverse tools may analyse binaries.
clean_room: clean_room && agent.name == "craft",
owner_id: None, owner_id: None,
}; };
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned(); let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
+170 -23
View File
@@ -1,6 +1,6 @@
//! SQLite persistence for sessions and messages. //! SQLite persistence for sessions and messages.
use crate::types::{ImageData, Message, Part, Session, now_ms}; use crate::types::{ImageData, Message, Part, Role, Session, now_ms};
use serde::Serialize; use serde::Serialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use anyhow::Result; use anyhow::Result;
@@ -244,28 +244,7 @@ impl Store {
let (sdata, mdata) = row?; let (sdata, mdata) = row?;
let session: Session = serde_json::from_str(&sdata)?; let session: Session = serde_json::from_str(&sdata)?;
// Snippet from the visible text of the matching message (not JSON keys). // Snippet from the visible text of the matching message (not JSON keys).
let mut snippet = String::new(); let snippet = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()).and_then(|m| snippet_in(&m, &needle)).unwrap_or_default();
if let Some(m) = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()) {
for p in &m.parts {
let text = match p {
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
Part::ToolResult { content, .. } => content.as_str(),
_ => continue,
};
if let Some(i) = text.to_lowercase().find(&needle) {
let mut a = i.saturating_sub(60);
while !text.is_char_boundary(a) {
a -= 1;
}
let mut b = (i + needle.len() + 100).min(text.len());
while !text.is_char_boundary(b) {
b += 1;
}
snippet = format!("{}{}{}", if a > 0 { "…" } else { "" }, text[a..b].replace('\n', " "), if b < text.len() { "…" } else { "" });
break;
}
}
}
if snippet.is_empty() && !session.title.to_lowercase().contains(&needle) { if snippet.is_empty() && !session.title.to_lowercase().contains(&needle) {
continue; // matched only JSON structure (keys, ids) continue; // matched only JSON structure (keys, ids)
} }
@@ -274,6 +253,121 @@ impl Store {
Ok(out) Ok(out)
} }
/// A part of a session's messages for the chat view (long chats are not loaded at
/// once). Returns the messages (oldest first) and whether older / newer ones exist.
pub fn messages_window(&self, session_id: &str, w: &MessageWindow) -> Result<(Vec<Message>, bool, bool)> {
let conn = self.conn.lock().unwrap();
let seq_of = |id: &str| -> Result<i64> {
conn.query_row("SELECT seq FROM messages WHERE session_id = ?1 AND id = ?2", params![session_id, id], |r| r.get(0))
.optional()?
.ok_or_else(|| anyhow::anyhow!("message {id} not found"))
};
let (sql, a, limit): (&str, i64, i64) = match w {
// From the last summary (compaction) on, at most `limit`.
MessageWindow::Tail { limit } => {
let from: i64 = conn
.query_row(
"SELECT seq FROM messages WHERE session_id = ?1 AND data LIKE '%\"type\":\"summary\"%' ORDER BY seq DESC LIMIT 1",
[session_id],
|r| r.get(0),
)
.optional()?
.unwrap_or(i64::MIN);
("SELECT data FROM messages WHERE session_id = ?1 AND seq >= ?2 ORDER BY seq DESC LIMIT ?3", from, *limit)
}
// Scrolling up stops at a summary (compaction); the history before it on request.
MessageWindow::Before { id, limit, stop_at_summary } => {
let before = seq_of(id)?;
let summary: Option<i64> = if *stop_at_summary {
conn.query_row(
"SELECT seq FROM messages WHERE session_id = ?1 AND seq < ?2 AND data LIKE '%\"type\":\"summary\"%' ORDER BY seq DESC LIMIT 1",
params![session_id, before],
|r| r.get(0),
)
.optional()?
} else {
None
};
match summary {
Some(from) => {
let mut stmt = conn.prepare("SELECT seq, data FROM messages WHERE session_id = ?1 AND seq >= ?2 AND seq < ?3 ORDER BY seq DESC LIMIT ?4")?;
let mut rows: Vec<(i64, Message)> = Vec::new();
for r in stmt.query_map(params![session_id, from, before, limit], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
let (seq, data) = r?;
rows.push((seq, serde_json::from_str(&data)?));
}
drop(stmt);
return Self::finish_window(&conn, session_id, rows, false);
}
None => ("SELECT data FROM messages WHERE session_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3", before, *limit),
}
}
MessageWindow::After { id, limit } => ("SELECT data FROM messages WHERE session_id = ?1 AND seq > ?2 ORDER BY seq ASC LIMIT ?3", seq_of(id)?, *limit),
// The message and about half the limit before it.
MessageWindow::Around { id, limit } => {
let seq = seq_of(id)?;
let before: i64 = conn.query_row(
"SELECT COALESCE(MIN(seq), ?2) FROM (SELECT seq FROM messages WHERE session_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3)",
params![session_id, seq, limit / 2],
|r| r.get(0),
)?;
("SELECT data FROM messages WHERE session_id = ?1 AND seq >= ?2 ORDER BY seq ASC LIMIT ?3", before, *limit)
}
};
let mut rows: Vec<(i64, Message)> = Vec::new();
let mut stmt = conn.prepare(&sql.replace("SELECT data", "SELECT seq, data"))?;
for r in stmt.query_map(params![session_id, a, limit], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
let (seq, data) = r?;
rows.push((seq, serde_json::from_str(&data)?));
}
drop(stmt);
Self::finish_window(&conn, session_id, rows, matches!(w, MessageWindow::Tail { .. }))
}
/// Sort a window and tell whether older / newer messages exist around it.
fn finish_window(conn: &Connection, session_id: &str, mut rows: Vec<(i64, Message)>, tail: bool) -> Result<(Vec<Message>, bool, bool)> {
rows.sort_by_key(|(seq, _)| *seq);
let exists = |q: &str, seq: i64| -> Result<bool> { Ok(conn.prepare(q)?.exists(params![session_id, seq])?) };
let older = match rows.first() {
Some((first, _)) => exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq < ?2", *first)?,
None => tail && exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq > ?2", i64::MIN)?,
};
let newer = match rows.last() {
Some((last, _)) => exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq > ?2", *last)?,
None => false,
};
Ok((rows.into_iter().map(|(_, m)| m).collect(), older, newer))
}
/// Messages of one session containing `q` (case-insensitive), newest first:
/// (message id, role, created_at, snippet).
pub fn search_messages(&self, session_id: &str, q: &str, limit: usize) -> Result<Vec<(String, Role, i64, String)>> {
let needle = q.to_lowercase();
let like = format!("%{}%", q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"));
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT data FROM messages WHERE session_id = ?1 AND data LIKE ?2 ESCAPE '\\' ORDER BY seq DESC")?;
let mut out = Vec::new();
for r in stmt.query_map(params![session_id, like], |r| r.get::<_, String>(0))? {
let Ok(m) = serde_json::from_str::<Message>(&r?) else { continue };
// Only visible text counts (not JSON keys or ids).
if let Some(snippet) = snippet_in(&m, &needle) {
out.push((m.id.clone(), m.role, m.created_at, snippet));
if out.len() >= limit {
break;
}
}
}
Ok(out)
}
/// Ids of the subagent sessions started by a session.
pub fn child_sessions(&self, id: &str) -> Result<Vec<String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id FROM sessions WHERE parent_id = ?1")?;
let rows = stmt.query_map([id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
Ok(rows)
}
pub fn delete_session(&self, id: &str) -> Result<()> { pub fn delete_session(&self, id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
// Child sessions (subagents) go with their parent. // Child sessions (subagents) go with their parent.
@@ -1045,6 +1139,17 @@ impl Store {
} }
/// Latest images; `user = None` lists everybody's (admins). /// Latest images; `user = None` lists everybody's (admins).
/// Generated images of these sessions.
pub fn images_of_sessions(&self, sessions: &[String]) -> Result<Vec<ImageRecord>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(&format!("SELECT {IMAGE_COLS} FROM images WHERE session_id = ?1"))?;
let mut out = Vec::new();
for s in sessions {
out.extend(stmt.query_map([s], image_row)?.collect::<rusqlite::Result<Vec<_>>>()?);
}
Ok(out)
}
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> { pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(&format!( let mut stmt = conn.prepare(&format!(
@@ -1392,3 +1497,45 @@ pub struct PushSub {
pub p256dh: String, pub p256dh: String,
pub auth: String, pub auth: String,
} }
/// Which messages of a session the chat view loads (see Store::messages_window).
pub enum MessageWindow {
Tail { limit: i64 },
Before { id: String, limit: i64, stop_at_summary: bool },
After { id: String, limit: i64 },
Around { id: String, limit: i64 },
}
/// Text around the first occurrence of `needle` (lowercase) in the visible text of a
/// message: its text, reasoning, summaries, tool calls and tool output.
fn snippet_in(m: &Message, needle: &str) -> Option<String> {
for p in &m.parts {
let owned;
let text = match p {
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
Part::ToolResult { content, .. } => content.as_str(),
Part::ToolCall { input, .. } => {
owned = input.to_string();
owned.as_str()
}
_ => continue,
};
let lower = text.to_lowercase();
// Lowercasing can change byte lengths (rare): then cut the lowercase text.
let (src, i) = match lower.find(needle) {
Some(i) if lower.len() == text.len() => (text, i),
Some(i) => (lower.as_str(), i),
None => continue,
};
let mut a = i.saturating_sub(60);
while !src.is_char_boundary(a) {
a -= 1;
}
let mut b = (i + needle.len() + 100).min(src.len());
while !src.is_char_boundary(b) {
b += 1;
}
return Some(format!("{}{}{}", if a > 0 { "…" } else { "" }, src[a..b].replace('\n', " "), if b < src.len() { "…" } else { "" }));
}
None
}
+362 -68
View File
@@ -1,12 +1,12 @@
import { takePrefill } from "../share"; import { takePrefill } from "../share";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure"; import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; 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 } from "../api";
import { useFetch, useModels } from "../hooks"; import { useFetch, useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n"; import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
import { dismissError, loadSession, loadSessions, useStore } from "../store"; import { dismissError, getState, loadAround, loadOlder, loadSession, loadSessions, useStore } from "../store";
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types"; import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity"; import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
import { useReasoningMode, useTodosCollapsed } from "../prefs"; import { useReasoningMode, useTodosCollapsed } from "../prefs";
@@ -26,8 +26,10 @@ import { ShellsChip } from "./ShellsPanel";
import { TodoList, ToolCard } from "./ToolCard"; import { TodoList, ToolCard } from "./ToolCard";
import { confirmDialog, promptDialog, toast } from "../dialogs"; import { confirmDialog, promptDialog, toast } from "../dialogs";
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs"; import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
import { MediaGallery } from "./MediaGallery";
type ToolResult = Extract<Part, { type: "tool_result" }>; type ToolResult = Extract<Part, { type: "tool_result" }>;
type ToolCall = Extract<Part, { type: "tool_call" }>;
const EMPTY: Message[] = []; const EMPTY: Message[] = [];
@@ -166,10 +168,299 @@ function RefineFeedback({ text }: { text: string }) {
); );
} }
/** Search the whole chat on the server; a hit loads its part of the chat. */
function ChatSearch({ sessionId, onPick, onClose }: { sessionId: string; onPick: (messageId: string) => void; onClose: () => void }) {
const t = useT();
const [q, setQ] = useState("");
const [hits, setHits] = useState<{ id: string; role: string; created_at: number; snippet: string }[]>();
useEffect(() => {
const query = q.trim();
if (query.length < 2) return setHits(undefined);
let alive = true;
const h = setTimeout(() => {
get<typeof hits>(`/sessions/${sessionId}/search?q=${encodeURIComponent(query)}`)
.then((r) => alive && setHits(r ?? []))
.catch(() => alive && setHits([]));
}, 250);
return () => {
alive = false;
clearTimeout(h);
};
}, [q, sessionId]);
return (
<div className="chat-search" role="search">
<div className="row">
<Icon name="search" />
<input autoFocus value={q} placeholder={t("chat.searchPlaceholder")} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Escape" && onClose()} aria-label={t("chat.search")} />
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
<Icon name="close" />
</button>
</div>
{hits && (
<div className="chat-search-hits">
<div className="hint">{hits.length ? t("chat.searchHits", { n: hits.length }) : t("chat.searchNone")}</div>
{hits.map((h) => (
<button key={h.id} onClick={() => onPick(h.id)}>
<span className="who">
{t(h.role === "assistant" ? "chat.agent" : "chat.you")} · {new Date(h.created_at).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
</span>
<span className="snippet">{h.snippet}</span>
</button>
))}
</div>
)}
</div>
);
}
/** A part of the chat: a user message, a summary, or the work of the agent after it. */
type Block = { kind: "user" | "summary" | "work"; messages: Message[] };
const hasUserText = (m: Message) => m.role === "user" && m.parts.some((p) => (p.type === "text" && !isHarnessNote(p.text)) || p.type === "image");
/** Group the messages into user messages and the agent's work between them. */
function blocksOf(messages: Message[]): Block[] {
const out: Block[] = [];
for (const m of messages) {
const kind = m.parts.some((p) => p.type === "summary") ? "summary" : hasUserText(m) ? "user" : "work";
const last = out[out.length - 1];
if (kind === "work" && last?.kind === "work") last.messages.push(m);
else out.push({ kind, messages: [m] });
}
return out;
}
/**
* The messages of a chat. Memoised: typing, streaming and status changes of the
* chat view do not touch it – only new messages, tool results or the busy state.
*/
const MessageList = memo(function MessageList({
messages,
results,
busy,
canEdit,
canRegenerate,
sessionId,
model,
baseDir,
onRewind,
highlight,
}: {
messages: Message[];
results: Map<string, ToolResult>;
busy: boolean;
canEdit: boolean;
canRegenerate: boolean;
sessionId: string;
model: string;
baseDir: string;
onRewind: (m: Message) => void;
highlight?: string;
}) {
const blocks = useMemo(() => blocksOf(messages), [messages]);
const lastWork = blocks.map((b) => b.kind).lastIndexOf("work");
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")?.id;
return (
<>
{blocks.map((b, i) => {
// Stable while older messages are loaded above (they may join the first block):
// the last message; the block still growing at the end keeps its first one.
const key = b.kind === "work" && i !== lastWork ? b.messages[b.messages.length - 1].id : b.messages[0].id;
if (b.kind !== "work") return <MessageItem key={key} m={b.messages[0]} canEdit={canEdit} onRewind={onRewind} highlight={highlight} />;
const items = b.messages.map((m) => (
<AssistantOrNote
key={m.id}
m={m}
results={results}
busy={busy}
baseDir={baseDir}
highlight={highlight}
regenerate={canRegenerate && m.id === lastAssistant ? { sessionId, model } : undefined}
/>
));
// 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[];
if (i === lastWork || steps.length < 2) return <div key={key}>{items}</div>;
return <WorkBlock key={key} messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>{items}</WorkBlock>;
})}
</>
);
});
/** A finished turn: "42 steps · 3 files" and the answer; the steps on click. */
function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messages: Message[]; steps: ToolCall[]; open: boolean; baseDir: string; children: ReactNode }) {
const t = useT();
const [open, setOpen] = useState(false);
const files = new Set(steps.filter((c) => ["write", "edit"].includes(c.name) && typeof c.input?.path === "string").map((c) => c.input.path as string));
// The answer: text of the last assistant message of the turn.
const answer = [...messages].reverse().find((m) => m.role === "assistant" && m.parts.some((p) => p.type === "text" && p.text.trim()));
const answerText = answer ? (answer.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n") : "";
if (open || forced) {
return (
<div className="work-block open">
<button className="work-head" onClick={() => setOpen(false)} aria-expanded>
<Icon name="chevron-down" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
</button>
{children}
</div>
);
}
return (
<div className="msg work-block" data-mid={answer?.id}>
<button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}>
<Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
</button>
{answerText && (
<>
<Markdown text={answerText} baseDir={baseDir} />
<ImageRefs text={answerText} baseDir={baseDir} />
</>
)}
</div>
);
}
/** An assistant message, or a user message that only carries tool results / harness notes. */
const AssistantOrNote = memo(function AssistantOrNote({
m,
results,
busy,
baseDir,
highlight,
regenerate,
}: {
m: Message;
results: Map<string, ToolResult>;
busy: boolean;
baseDir: string;
highlight?: string;
regenerate?: { sessionId: string; model: string };
}) {
const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
if (m.role === "assistant") {
return (
<div className={cls} data-mid={m.id}>
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
{regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
</div>
);
}
const notes = (m.parts.filter((p) => p.type === "text") as { text: string }[]).filter((p) => isHarnessNote(p.text));
if (!notes.length) return null;
return (
<div className={cls} data-mid={m.id}>
{notes.map((p, i) => (
<HarnessMessage key={i} text={p.text} />
))}
</div>
);
});
/** A user message or a summary of the history before it. */
const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight }: { m: Message; canEdit: boolean; onRewind: (m: Message) => void; highlight?: string }) {
const t = useT();
const [summaryOpen, setSummaryOpen] = useState(false);
const summary = m.parts.find((p) => p.type === "summary");
if (summary && summary.type === "summary") {
return (
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
<summary>
{summary.auto
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
: summary.tokens
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
: t("chat.compacted")}
</summary>
{summaryOpen && <Markdown text={summary.text} />}
</details>
);
}
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
const background = allTexts.filter((p) => isHarnessNote(p.text));
const texts = allTexts.filter((p) => !isHarnessNote(p.text));
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
return (
<div className={`msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}>
{background.map((p, i) => (
<HarnessMessage key={i} text={p.text} />
))}
<div className="msg-user">
{canEdit && (
<div className="msg-actions">
<button className="small ghost" onClick={() => onRewind(m)} title={t("chat.rewind")}>
<Icon name="edit" />
</button>
</div>
)}
<UserText text={texts.map((p) => p.text).join("\n\n")} />
{imgs.length > 0 && (
<div className="user-files">
{imgs.map((p, i) => (
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
</FileFigure>
))}
</div>
)}
</div>
</div>
);
});
/**
* Older messages: loaded automatically when scrolled to the top; the history before a
* summary only on request. Keeps the scroll position while the list grows upwards.
*/
function OlderLoader({ sessionId, afterSummary, scrollRef }: { sessionId: string; afterSummary?: boolean; scrollRef: RefObject<HTMLDivElement | null> }) {
const t = useT();
const [loading, setLoading] = useState(false);
const sentinel = useRef<HTMLDivElement>(null);
const load = useCallback(async () => {
const el = scrollRef.current;
if (loading || !el) return;
setLoading(true);
const before = { height: el.scrollHeight, top: el.scrollTop };
try {
// Scrolling stops at a summary; its button loads the history before it.
await loadOlder(sessionId, !afterSummary);
requestAnimationFrame(() => {
el.scrollTop = before.top + (el.scrollHeight - before.height);
});
} finally {
setLoading(false);
}
}, [loading, sessionId, scrollRef, afterSummary]);
useEffect(() => {
const node = sentinel.current;
if (!node || afterSummary || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver((e) => e[0]?.isIntersecting && void load(), { root: scrollRef.current, rootMargin: "300px 0px 0px 0px" });
io.observe(node);
return () => io.disconnect();
}, [afterSummary, load, scrollRef]);
return (
<div ref={sentinel} className="older-loader">
{loading ? (
<Spinner small />
) : (
<button className="small ghost" onClick={() => void load()}>
<Icon name="arrow-up" /> {t(afterSummary ? "chat.showBeforeSummary" : "chat.loadOlder")}
</button>
)}
</div>
);
}
export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) { export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) {
const t = useT(); const t = useT();
const session = useStore((s) => s.sessionById[sessionId]); const session = useStore((s) => s.sessionById[sessionId]);
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY); const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
const win = useStore((s) => s.windows[sessionId]);
// Message to show (search hit, media): scrolled into view and marked.
const [highlight, setHighlight] = useState<string>();
const [searching, setSearching] = useState(false);
const [gallery, setGallery] = useState(false);
const draft = useStore((s) => s.drafts[sessionId]); const draft = useStore((s) => s.drafts[sessionId]);
const busy = useStore((s) => !!s.busy[sessionId]); const busy = useStore((s) => !!s.busy[sessionId]);
const errors = useStore((s) => s.errors[sessionId]); const errors = useStore((s) => s.errors[sessionId]);
@@ -226,11 +517,36 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
return m; return m;
}, [messages]); }, [messages]);
// Keep scrolled to the bottom while the user hasn't scrolled up. // Keep scrolled to the bottom while the user hasn't scrolled up (only when the
// content changes – reading the height forces a layout).
const permCount = permissions.length + questions.length;
useLayoutEffect(() => { useLayoutEffect(() => {
const el = scrollRef.current; const el = scrollRef.current;
if (el && stick.current) el.scrollTop = el.scrollHeight; if (el && stick.current) el.scrollTop = el.scrollHeight;
}); }, [messages, draft, busy, waiting, compacting, permCount, errors]);
// Stable callbacks for the (memoised) message list.
const rewindRef = useRef<(m: Message) => void>(() => undefined);
const onRewind = useCallback((m: Message) => rewindRef.current(m), []);
const showLatest = useCallback(async () => {
setHighlight(undefined);
await loadSession(sessionId);
follow(true);
}, [sessionId]);
/** Load the part of the chat around a message and scroll to it. */
const jumpTo = useCallback(
async (messageId: string) => {
follow(false);
if (!getState().messages[sessionId]?.some((m) => m.id === messageId)) await loadAround(sessionId, messageId);
setHighlight(messageId);
},
[sessionId],
);
useEffect(() => {
if (!highlight) return;
const h = setTimeout(() => document.querySelector(`[data-mid="${highlight}"]`)?.scrollIntoView({ block: "center" }), 50);
return () => clearTimeout(h);
}, [highlight, messages]);
// … also when the pane gets smaller (on-screen keyboard opens) without a render. // … also when the pane gets smaller (on-screen keyboard opens) without a render.
const hasSession = !!session; const hasSession = !!session;
useEffect(() => { useEffect(() => {
@@ -280,9 +596,10 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
} }
}; };
const lastAssistantIdx = messages.reduce((acc, m, i) => (m.role === "assistant" ? i : acc), -1);
const [rewinding, setRewinding] = useState<Message>(); const [rewinding, setRewinding] = useState<Message>();
const rewind = (m: Message) => setRewinding(m); const rewind = (m: Message) => setRewinding(m);
rewindRef.current = rewind;
const rewindDone = async () => { const rewindDone = async () => {
const m = rewinding!; const m = rewinding!;
setRewinding(undefined); setRewinding(undefined);
@@ -371,6 +688,9 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
> >
<Icon name="terminal" /> <Icon name="terminal" />
</button> </button>
<button className={`small ghost ${searching ? "active" : ""}`} onClick={() => setSearching(!searching)} title={t("chat.search")} aria-label={t("chat.search")}>
<Icon name="search" />
</button>
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}> <button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
<Icon name="folder" /> <Icon name="folder" />
</button> </button>
@@ -394,6 +714,16 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
> >
<Icon name="edit" /> {t("instructions.title")} <Icon name="edit" /> {t("instructions.title")}
</a> </a>
<a
href="#"
onClick={(e) => {
e.preventDefault();
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
setGallery(true);
}}
>
<Icon name="image" /> {t("media.title")}
</a>
{telegramPaired && !session.parent_id && ( {telegramPaired && !session.parent_id && (
<a <a
href="#" href="#"
@@ -447,6 +777,17 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
</button> </button>
)} )}
</div> </div>
{gallery && (
<MediaGallery
sessionId={sessionId}
onClose={() => setGallery(false)}
onJump={(id) => {
setGallery(false);
void jumpTo(id);
}}
/>
)}
{searching && <ChatSearch sessionId={sessionId} onPick={(id) => void jumpTo(id)} onClose={() => setSearching(false)} />}
<ChatTabBar state={tabs} /> <ChatTabBar state={tabs} />
<ChatTabPanes state={tabs} /> <ChatTabPanes state={tabs} />
<div className="chat-pane" hidden={tabs.tab !== "chat"}> <div className="chat-pane" hidden={tabs.tab !== "chat"}>
@@ -472,71 +813,24 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<div className="chat"> <div className="chat">
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />} {showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>} {messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
{messages.map((m, idx) => { {win?.older && <OlderLoader sessionId={sessionId} afterSummary={messages[0]?.parts.some((p) => p.type === "summary")} scrollRef={scrollRef} />}
if (m.role === "assistant") { <MessageList
const lastAnswer = idx === lastAssistantIdx && !busy && !readOnly && !session.parent_id; messages={messages}
return ( results={results}
<div key={m.id} className="msg"> busy={busy}
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={session.cwd} /> canEdit={!busy && !readOnly}
{lastAnswer && <RegenerateButton sessionId={sessionId} messageId={m.id} model={session.model} />} canRegenerate={!busy && !readOnly && !session.parent_id && !win?.newer}
</div> sessionId={sessionId}
); model={session.model}
} baseDir={session.cwd}
const summary = m.parts.find((p) => p.type === "summary"); onRewind={onRewind}
if (summary && summary.type === "summary") { highlight={highlight}
return ( />
<details key={m.id} className="summary-divider"> {win?.newer && (
<summary> <button className="back-to-latest" onClick={() => void showLatest()}>
{summary.auto <Icon name="arrow-down" /> {t("chat.backToLatest")}
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
: summary.tokens
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
: t("chat.compacted")}
</summary>
<Markdown text={summary.text} />
</details>
);
}
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
const background = allTexts.filter((p) => isHarnessNote(p.text));
const texts = allTexts.filter((p) => !isHarnessNote(p.text));
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
if (texts.length === 0 && imgs.length === 0) {
return background.length ? (
<div key={m.id} className="msg">
{background.map((p, i) => (
<HarnessMessage key={i} text={p.text} />
))}
</div>
) : null;
}
return (
<div key={m.id} className="msg">
{background.map((p, i) => (
<HarnessMessage key={i} text={p.text} />
))}
<div className="msg-user">
{!busy && !readOnly && (
<div className="msg-actions">
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
<Icon name="edit" />
</button> </button>
</div>
)} )}
<UserText text={texts.map((p) => p.text).join("\n\n")} />
{imgs.length > 0 && (
<div className="user-files">
{imgs.map((p, i) => (
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
</FileFigure>
))}
</div>
)}
</div>
</div>
);
})}
{draft && ( {draft && (
<div className="msg"> <div className="msg">
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} /> <AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
+6
View File
@@ -125,6 +125,12 @@ const P: Record<string, ReactNode> = {
<path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1" /> <path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1" />
</> </>
), ),
search: (
<>
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" />
</>
),
eye: ( eye: (
<> <>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" /> <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import { get } from "../api";
import { useT, type Key } from "../i18n";
import { Spinner } from "./Activity";
import { Icon } from "./Icon";
interface MediaItem {
kind: "image" | "video" | "audio";
url: string;
name: string;
source: "generated" | "tool" | "written" | "answer" | "upload";
created_at: number;
message_id?: string;
}
type Filter = "all" | MediaItem["kind"];
/**
* All images, videos and audio files of a chat (with its subagents), newest first.
* An item opens large; "show in chat" jumps to the message it came with.
*/
export function MediaGallery({ sessionId, onClose, onJump }: { sessionId: string; onClose: () => void; onJump: (messageId: string) => void }) {
const t = useT();
const [items, setItems] = useState<MediaItem[]>();
const [error, setError] = useState<string>();
const [filter, setFilter] = useState<Filter>("all");
const [open, setOpen] = useState<number>();
useEffect(() => {
get<MediaItem[]>(`/sessions/${sessionId}/media`)
.then(setItems)
.catch((e) => setError(e.message));
}, [sessionId]);
const shown = (items ?? []).filter((i) => filter === "all" || i.kind === filter);
const count = (k: Filter) => (items ?? []).filter((i) => k === "all" || i.kind === k).length;
const current = open !== undefined ? shown[open] : undefined;
// Keyboard: Esc closes, arrows step through the open item.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") return current ? setOpen(undefined) : onClose();
if (current && e.key === "ArrowRight") setOpen((o) => Math.min((o ?? 0) + 1, shown.length - 1));
if (current && e.key === "ArrowLeft") setOpen((o) => Math.max((o ?? 0) - 1, 0));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [current, shown.length, onClose]);
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal media-gallery" role="dialog" aria-label={t("media.title")} onClick={(e) => e.stopPropagation()}>
<div className="media-head">
<h3>
<Icon name="image" /> {t("media.title")}
</h3>
<div className="media-filters" role="radiogroup">
{(["all", "image", "video", "audio"] as Filter[]).map((k) => (
<button key={k} role="radio" aria-checked={filter === k} className={`chip-btn ${filter === k ? "active" : ""}`} onClick={() => (setFilter(k), setOpen(undefined))} disabled={!!items && count(k) === 0 && k !== "all"}>
{t(`media.filter.${k}` as Key)} {items && <small>{count(k)}</small>}
</button>
))}
</div>
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
<Icon name="close" />
</button>
</div>
{error && <div className="notice err">{error}</div>}
{!items && !error && (
<div className="empty">
<Spinner />
</div>
)}
{items && shown.length === 0 && <div className="empty">{t("media.none")}</div>}
{current ? (
<div className="media-viewer">
<div className="media-stage">
{current.kind === "image" && <img src={current.url} alt={current.name} />}
{current.kind === "video" && <video src={current.url} controls autoPlay />}
{current.kind === "audio" && <audio src={current.url} controls autoPlay />}
</div>
<div className="media-bar">
<button className="small ghost" disabled={open === 0} onClick={() => setOpen((o) => (o ?? 1) - 1)} aria-label={t("media.prev")}>
<Icon name="arrow-left" />
</button>
<span className="grow">
{current.name} · {t(`media.source.${current.source}` as Key)} · {new Date(current.created_at).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
</span>
{current.message_id && (
<button className="small" onClick={() => onJump(current.message_id!)}>
{t("media.showInChat")}
</button>
)}
<a className="small button" href={current.url} target="_blank" rel="noreferrer">
<Icon name="external" /> {t("media.open")}
</a>
<button className="small ghost" disabled={open === shown.length - 1} onClick={() => setOpen((o) => (o ?? 0) + 1)} aria-label={t("media.next")}>
<Icon name="arrow-right" />
</button>
<button className="small ghost" onClick={() => setOpen(undefined)} aria-label={t("media.back")}>
<Icon name="close" />
</button>
</div>
</div>
) : (
<div className="media-grid">
{shown.map((m, i) => (
<button key={m.url} className={`media-tile ${m.kind}`} onClick={() => setOpen(i)} title={`${m.name} · ${t(`media.source.${m.source}` as Key)}`}>
{m.kind === "image" && <img src={m.url} alt="" loading="lazy" decoding="async" />}
{m.kind === "video" && <video src={`${m.url}#t=0.1`} preload="metadata" muted />}
{m.kind === "audio" && (
<span className="media-audio">
<Icon name="music" />
<span>{m.name}</span>
</span>
)}
{m.kind === "video" && <Icon name="play" className="media-play" />}
</button>
))}
</div>
)}
</div>
</div>
);
}
+10 -1
View File
@@ -1,5 +1,6 @@
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { FileFigure, imageSize, imageSrc } from "./FileFigure"; import { FileFigure, imageSize, imageSrc } from "./FileFigure";
import { useState } from "react";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { navigate } from "../router"; import { navigate } from "../router";
import { useStore } from "../store"; import { useStore } from "../store";
@@ -68,6 +69,8 @@ export function TodoList({ todos }: { todos: Todo[] }) {
export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; result?: ToolResult; running: boolean; baseDir?: string }) { export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; result?: ToolResult; running: boolean; baseDir?: string }) {
const t = useT(); const t = useT();
// The body (diff, output) is only built while the card is open – long chats have thousands.
const [toggled, setToggled] = useState<boolean | null>(null);
const progress = useStore((s) => s.progress[call.id]); const progress = useStore((s) => s.progress[call.id]);
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err"; const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
const statusText = result ? <Icon name={result.is_error ? "x-mark" : "check"} /> : running ? "…" : "–"; const statusText = result ? <Icon name={result.is_error ? "x-mark" : "check"} /> : running ? "…" : "–";
@@ -119,9 +122,13 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
const openByDefault = const openByDefault =
(status === "err" && !!result) || (status === "err" && !!result) ||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image")); (!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image"));
const open = toggled ?? openByDefault;
return ( return (
<details className={`tool ${status === "run" ? "running" : ""}`} open={openByDefault}> <details className={`tool ${status === "run" ? "running" : ""}`} open={open} onToggle={(e) => {
const now = (e.currentTarget as HTMLDetailsElement).open;
if (now !== open) setToggled(now);
}}>
<summary> <summary>
<span className="tool-name">{call.name}</span> <span className="tool-name">{call.name}</span>
<span className="tool-title">{toolTitle(call.name, call.input)}</span> <span className="tool-title">{toolTitle(call.name, call.input)}</span>
@@ -130,6 +137,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
{call.name === "task" && call.input?.background && <span className="chip">{t("tool.background")}</span>} {call.name === "task" && call.input?.background && <span className="chip">{t("tool.background")}</span>}
{status === "run" ? <Spinner small /> : <span className={`status ${status}`}>{statusText}</span>} {status === "run" ? <Spinner small /> : <span className={`status ${status}`}>{statusText}</span>}
</summary> </summary>
{open && (
<div className="tool-body"> <div className="tool-body">
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>} {call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
{(call.name === "edit" || call.name === "write") && meta?.diff && <Diff diff={meta.diff} />} {(call.name === "edit" || call.name === "write") && meta?.diff && <Diff diff={meta.diff} />}
@@ -186,6 +194,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
)) ))
)} )}
</div> </div>
)}
</details> </details>
); );
} }
@@ -62,6 +62,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
refine_model: raw?.refine?.model ?? "", refine_model: raw?.refine?.model ?? "",
refine_criteria: raw?.refine?.criteria ?? "", refine_criteria: raw?.refine?.criteria ?? "",
refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean, refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean,
reverse_tools: !!(raw?.reverse_tools ?? a.reverse),
}); });
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name)); const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
@@ -78,6 +79,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null, after_todos: form.refine_after_todos } : null, value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null, after_todos: form.refine_after_todos } : null,
}); });
} }
if (a.name !== "craft") patches.push({ path: [...base, "reverse_tools"], value: form.reverse_tools || null });
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null }); if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
if (!builtin) { if (!builtin) {
patches.push({ path: [...base, "mode"], value: form.mode }); patches.push({ path: [...base, "mode"], value: form.mode });
@@ -166,6 +168,15 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
<div className="hint">{t("agents.refineAfterTodosHint")}</div> <div className="hint">{t("agents.refineAfterTodosHint")}</div>
</> </>
)} )}
{a.name !== "craft" && (
<>
<label className="check">
<input type="checkbox" checked={form.reverse_tools} onChange={(e) => setForm({ ...form, reverse_tools: e.target.checked })} />
{t("agents.reverseTools")}
</label>
<div className="hint">{t("agents.reverseToolsHint")}</div>
</>
)}
<label>{t("agents.description")}</label> <label>{t("agents.description")}</label>
<input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /> <input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
{a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>} {a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>}
+30 -1
View File
@@ -189,7 +189,7 @@ export const de: Messages = {
"tools.install": "Ghidra installieren", "tools.install": "Ghidra installieren",
"tools.reinstall": "Installation prüfen / reparieren", "tools.reinstall": "Installation prüfen / reparieren",
"tools.installing": "Installiere (Download ca. 800 MB, auf ARM wird der Dekompiler kompiliert – das dauert einige Minuten)…", "tools.installing": "Installiere (Download ca. 800 MB, auf ARM wird der Dekompiler kompiliert – das dauert einige Minuten)…",
"tools.ghidraHint": "Mit Ghidra können Agenten im Craft-Deep-Modus Programme dekompilieren (Windows .exe/.dll, Linux, DOS …), um zu verstehen, wie sie funktionieren. Wird samt eigenem JDK ins Daten-Volume installiert.", "tools.ghidraHint": "Mit Ghidra können Agenten Programme und Firmware dekompilieren (Windows .exe/.dll, Linux, DOS …), um zu verstehen, wie sie funktionieren – im Craft-Deep-Modus und in Modi mit Reverse-Engineering-Werkzeugen (Einstellungen → Agenten & Befehle). Wird samt eigenem JDK ins Daten-Volume installiert.",
"rewind.intro": "Die Unterhaltung wird auf den Stand vor dieser Nachricht zurückgesetzt; du kannst sie bearbeiten und neu senden.", "rewind.intro": "Die Unterhaltung wird auf den Stand vor dieser Nachricht zurückgesetzt; du kannst sie bearbeiten und neu senden.",
"rewind.noFiles": "Der Agent hat seitdem keine Dateien geändert.", "rewind.noFiles": "Der Agent hat seitdem keine Dateien geändert.",
"rewind.restore": "Auch die {n} vom Agenten geänderte(n) Datei(en) zurücksetzen", "rewind.restore": "Auch die {n} vom Agenten geänderte(n) Datei(en) zurücksetzen",
@@ -336,8 +336,37 @@ export const de: Messages = {
"activity.reviewing": "Ergebnis wird geprüft (Runde {round})…", "activity.reviewing": "Ergebnis wird geprüft (Runde {round})…",
"chat.reviewPassed": "Review bestanden:", "chat.reviewPassed": "Review bestanden:",
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:", "chat.todoReminder": "Weiterarbeiten – {n} offene Todos:",
"agents.reverseTools": "Reverse-Engineering-Werkzeuge (binary_info, decompile mit Ghidra)",
"agents.reverseToolsHint": "Der Agent kann in diesem Modus Programme und Firmware untersuchen und dekompilieren (Ghidra muss unter Einstellungen → Werkzeuge installiert sein). Craft hat sie im Deep-Modus ohnehin.",
"agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten", "agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten",
"agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.", "agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.",
"chat.workSteps": "{n} Schritte",
"chat.workFiles": "{n} Dateien",
"chat.loadOlder": "Ältere Nachrichten laden",
"chat.showBeforeSummary": "Verlauf vor der Zusammenfassung anzeigen",
"chat.backToLatest": "Zu den neuesten Nachrichten",
"chat.search": "Im Chat suchen",
"chat.searchPlaceholder": "Nachrichten, Tool-Ausgaben … durchsuchen",
"chat.searchNone": "Keine Treffer",
"chat.searchHits": "{n} Treffer",
"chat.you": "Du",
"chat.agent": "Agent",
"media.title": "Medien",
"media.none": "Noch keine Bilder, Videos oder Audiodateien in diesem Chat.",
"media.filter.all": "Alle",
"media.filter.image": "Bilder",
"media.filter.video": "Videos",
"media.filter.audio": "Audio",
"media.source.generated": "generiert",
"media.source.tool": "Tool-Ausgabe",
"media.source.written": "vom Agenten geschrieben",
"media.source.answer": "in einer Antwort",
"media.source.upload": "dein Anhang",
"media.showInChat": "Im Chat zeigen",
"media.open": "Öffnen",
"media.prev": "Vorheriges",
"media.next": "Nächstes",
"media.back": "Zurück zur Übersicht",
"chat.reviewRound": "Review {round}: Änderungen nötig", "chat.reviewRound": "Review {round}: Änderungen nötig",
"agents.refine": "Review-Schleife", "agents.refine": "Review-Schleife",
"agents.refineRounds": "Review-Runden pro Aufgabe", "agents.refineRounds": "Review-Runden pro Aufgabe",
+30 -1
View File
@@ -188,7 +188,7 @@ export const en = {
"tools.install": "Install Ghidra", "tools.install": "Install Ghidra",
"tools.reinstall": "Check / repair installation", "tools.reinstall": "Check / repair installation",
"tools.installing": "Installing (download ~800 MB, on ARM the decompiler is compiled – this takes several minutes)…", "tools.installing": "Installing (download ~800 MB, on ARM the decompiler is compiled – this takes several minutes)…",
"tools.ghidraHint": "Ghidra lets agents in craft Deep mode decompile programs (Windows .exe/.dll, Linux, DOS, …) to study how they work. Installed into the data volume together with its own JDK.", "tools.ghidraHint": "Ghidra lets agents decompile programs and firmware (Windows .exe/.dll, Linux, DOS, …) to study how they work – in craft Deep mode and in modes with reverse engineering tools (Settings → Agents & commands). Installed into the data volume together with its own JDK.",
"rewind.intro": "The conversation is reset to before this message; you can edit and resend it.", "rewind.intro": "The conversation is reset to before this message; you can edit and resend it.",
"rewind.noFiles": "The agent did not change files since then.", "rewind.noFiles": "The agent did not change files since then.",
"rewind.restore": "Also restore the {n} file(s) the agent changed", "rewind.restore": "Also restore the {n} file(s) the agent changed",
@@ -335,8 +335,37 @@ export const en = {
"activity.reviewing": "Reviewing the result (round {round})…", "activity.reviewing": "Reviewing the result (round {round})…",
"chat.reviewPassed": "Review passed:", "chat.reviewPassed": "Review passed:",
"chat.todoReminder": "Back to work – {n} open todos:", "chat.todoReminder": "Back to work – {n} open todos:",
"agents.reverseTools": "Reverse engineering tools (binary_info, decompile with Ghidra)",
"agents.reverseToolsHint": "The agent can inspect and decompile programs and firmware in this mode (Ghidra must be installed under Settings → Tools). Craft has them anyway in deep mode.",
"agents.refineAfterTodos": "Review every completed todo and work until the list is done", "agents.refineAfterTodos": "Review every completed todo and work until the list is done",
"agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.", "agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.",
"chat.workSteps": "{n} steps",
"chat.workFiles": "{n} files",
"chat.loadOlder": "Load older messages",
"chat.showBeforeSummary": "Show the history before the summary",
"chat.backToLatest": "Back to the latest messages",
"chat.search": "Search in this chat",
"chat.searchPlaceholder": "Search messages, tool output …",
"chat.searchNone": "No matches",
"chat.searchHits": "{n} matches",
"chat.you": "You",
"chat.agent": "Agent",
"media.title": "Media",
"media.none": "No images, videos or audio in this chat yet.",
"media.filter.all": "All",
"media.filter.image": "Images",
"media.filter.video": "Videos",
"media.filter.audio": "Audio",
"media.source.generated": "generated",
"media.source.tool": "tool output",
"media.source.written": "written by the agent",
"media.source.answer": "in an answer",
"media.source.upload": "your attachment",
"media.showInChat": "Show in chat",
"media.open": "Open",
"media.prev": "Previous",
"media.next": "Next",
"media.back": "Back to the overview",
"chat.reviewRound": "Review {round}: changes requested", "chat.reviewRound": "Review {round}: changes requested",
"agents.refine": "Review loop", "agents.refine": "Review loop",
"agents.refineRounds": "Review rounds per task", "agents.refineRounds": "Review rounds per task",
+39 -2
View File
@@ -32,6 +32,8 @@ export interface State {
/** Includes subagent sessions loaded on demand. */ /** Includes subagent sessions loaded on demand. */
sessionById: Record<string, Session>; sessionById: Record<string, Session>;
messages: Record<string, Message[]>; messages: Record<string, Message[]>;
/** Long chats are loaded in parts: are there older / newer messages than the loaded ones? */
windows: Record<string, { older: boolean; newer: boolean } | undefined>;
/** Sessions the current user may only read (shared with them). */ /** Sessions the current user may only read (shared with them). */
readOnly: Record<string, boolean>; readOnly: Record<string, boolean>;
drafts: Record<string, Draft | undefined>; drafts: Record<string, Draft | undefined>;
@@ -58,6 +60,7 @@ let state: State = {
sessions: [], sessions: [],
sessionById: {}, sessionById: {},
messages: {}, messages: {},
windows: {},
readOnly: {}, readOnly: {},
drafts: {}, drafts: {},
busy: {}, busy: {},
@@ -130,7 +133,8 @@ export function applyEvent(ev: ServerEvent) {
setState((s) => { setState((s) => {
const list = s.messages[ev.session_id]; const list = s.messages[ev.session_id];
const drafts = s.drafts[ev.session_id]?.id === ev.message.id ? { ...s.drafts, [ev.session_id]: undefined } : s.drafts; const drafts = s.drafts[ev.session_id]?.id === ev.message.id ? { ...s.drafts, [ev.session_id]: undefined } : s.drafts;
if (!list) return { drafts }; // Viewing an older part of the chat: the new message comes with "back to the end".
if (!list || s.windows[ev.session_id]?.newer) return { drafts };
if (list.some((m) => m.id === ev.message.id)) return { drafts }; if (list.some((m) => m.id === ev.message.id)) return { drafts };
return { messages: { ...s.messages, [ev.session_id]: [...list, ev.message] }, drafts }; return { messages: { ...s.messages, [ev.session_id]: [...list, ev.message] }, drafts };
}); });
@@ -231,9 +235,17 @@ export async function loadSessions() {
})); }));
} }
/** Messages loaded when a chat opens (from the last summary on, at most this many). */
export const PAGE = 200;
type Page = { messages: Message[]; has_older: boolean; has_newer: boolean };
export async function loadSession(id: string) { export async function loadSession(id: string) {
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any }>(`/sessions/${id}`); const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any; has_older?: boolean; has_newer?: boolean }>(
`/sessions/${id}?limit=${PAGE}`,
);
setState((s) => ({ setState((s) => ({
windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } },
...upsertSession(s, data.session), ...upsertSession(s, data.session),
sessionById: { ...s.sessionById, [id]: data.session }, sessionById: { ...s.sessionById, [id]: data.session },
messages: { ...s.messages, [id]: data.messages }, messages: { ...s.messages, [id]: data.messages },
@@ -247,6 +259,31 @@ export async function loadSession(id: string) {
})); }));
} }
/** Older messages before the loaded ones (scrolling up). Returns how many came. */
export async function loadOlder(id: string, stopAtSummary = true): Promise<number> {
const first = getState().messages[id]?.[0];
if (!first) return 0;
const page = await get<Page>(`/sessions/${id}/messages?before=${encodeURIComponent(first.id)}&limit=${PAGE}${stopAtSummary ? "&stop_at_summary=true" : ""}`);
setState((s) => {
const cur = s.messages[id] ?? [];
if (cur[0]?.id !== first.id) return {}; // changed meanwhile
return {
messages: { ...s.messages, [id]: [...page.messages, ...cur] },
windows: { ...s.windows, [id]: { older: page.has_older, newer: s.windows[id]?.newer ?? false } },
};
});
return page.messages.length;
}
/** Show the part of the chat around a message (search hit, media). */
export async function loadAround(id: string, messageId: string) {
const page = await get<Page>(`/sessions/${id}/messages?around=${encodeURIComponent(messageId)}&limit=${PAGE}`);
setState((s) => ({
messages: { ...s.messages, [id]: page.messages },
windows: { ...s.windows, [id]: { older: page.has_older, newer: page.has_newer } },
}));
}
/** After reconnects: reload everything that could have changed. */ /** After reconnects: reload everything that could have changed. */
export async function resync() { export async function resync() {
const isAdmin = getState().me?.role === "admin"; const isAdmin = getState().me?.role === "admin";
+213
View File
@@ -4185,3 +4185,216 @@ span.chat-tab > button {
font-size: 12px; font-size: 12px;
} }
} }
/* --- long chats ------------------------------------------------------------------
* Finished turns fold into one line, older messages load on demand, search hits. */
.work-block > .work-head {
display: inline-flex;
align-items: center;
gap: 5px;
border: none;
background: none;
padding: 2px 0;
margin-bottom: 4px;
font-size: 12.5px;
color: var(--muted);
cursor: pointer;
}
.work-block > .work-head:hover {
color: var(--fg1);
}
.work-block.open {
border-left: 2px solid var(--bg2);
padding-left: 10px;
margin-bottom: 14px;
}
.older-loader {
display: flex;
justify-content: center;
padding: 6px 0 12px;
}
.back-to-latest {
position: sticky;
bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
margin: 10px auto 0;
box-shadow: var(--shadow);
}
.msg.highlight,
.summary-divider[data-mid].highlight {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: var(--radius);
}
.chat-search {
border-bottom: 1px solid var(--bg2);
padding: 6px 12px;
background: var(--bg0);
}
.chat-search > .row {
display: flex;
align-items: center;
gap: 8px;
}
.chat-search > .row input {
flex: 1;
min-width: 0;
}
.chat-search-hits {
max-height: 40vh;
overflow-y: auto;
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.chat-search-hits button {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
text-align: left;
border: none;
background: transparent;
padding: 6px 8px;
border-radius: 6px;
}
.chat-search-hits button:hover {
background: var(--bg1);
}
.chat-search-hits .who {
font-size: 11.5px;
color: var(--muted);
}
.chat-search-hits .snippet {
font-size: 13px;
color: var(--fg1);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* --- media overview of a chat --------------------------------------------------- */
.media-gallery {
width: min(1100px, calc(100vw - 24px));
height: min(820px, calc(100dvh - 24px));
display: flex;
flex-direction: column;
gap: 10px;
}
.media-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.media-head h3 {
margin: 0;
display: flex;
align-items: center;
gap: 6px;
}
.media-filters {
display: flex;
gap: 6px;
flex-wrap: wrap;
flex: 1;
}
.media-filters .chip-btn {
padding: 3px 10px;
font-size: 13px;
}
.media-filters small {
color: var(--muted);
margin-left: 2px;
}
.media-grid {
flex: 1;
min-height: 0;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-auto-rows: 150px;
gap: 8px;
}
.media-tile {
position: relative;
padding: 0;
overflow: hidden;
border-radius: 8px;
background: var(--bg2);
border: 1px solid var(--bg3);
}
.media-tile img,
.media-tile video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.media-tile .media-play {
position: absolute;
inset: 0;
margin: auto;
width: 34px;
height: 34px;
color: #fff;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
.media-audio {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
height: 100%;
padding: 8px;
font-size: 12px;
color: var(--fg2);
word-break: break-all;
}
.media-audio .icon {
width: 28px;
height: 28px;
color: var(--accent);
}
.media-viewer {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.media-stage {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg1);
border-radius: 8px;
}
.media-stage img,
.media-stage video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.media-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
font-size: 12.5px;
color: var(--fg2);
}
.media-bar .grow {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+2
View File
@@ -168,6 +168,8 @@ export interface AgentInfo {
source: "builtin" | "config" | "file" | "personal"; source: "builtin" | "config" | "file" | "personal";
max_parallel?: number | null; max_parallel?: number | null;
refine?: { rounds: number; model?: string | null; criteria?: string | null; after_todos?: boolean } | null; refine?: { rounds: number; model?: string | null; criteria?: string | null; after_todos?: boolean } | null;
/** binary_info and decompile (Ghidra) available in this mode. */
reverse?: boolean;
/** Source file of Markdown agents. */ /** Source file of Markdown agents. */
path?: string | null; path?: string | null;
} }
+8
View File
@@ -3,6 +3,14 @@
* 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-00020": {
en: ["Reverse engineering: decompile with Ghidra can be enabled for any mode (Settings → Agents & commands), now also for raw firmware"],
de: ["Reverse Engineering: Dekompilieren mit Ghidra lässt sich für jeden Modus einschalten (Einstellungen → Agenten & Befehle), jetzt auch für rohe Firmware"],
},
"202611-00019": {
en: ["Long chats open and react much faster: older messages load when you scroll up, finished turns fold into one line", "Search in a chat (magnifier in the top bar) and ⋯ → Media: all images, videos and audio of a chat"],
de: ["Lange Chats öffnen und reagieren viel schneller: ältere Nachrichten laden beim Hochscrollen, abgeschlossene Turns sind eingeklappt", "Suche im Chat (Lupe oben) und ⋯ → Medien: alle Bilder, Videos und Audiodateien eines Chats"],
},
"202611-00018": { "202611-00018": {
en: [ en: [
"Modes with a review: every completed todo is reviewed, and the agent keeps working until the todo list is done – for overnight runs", "Modes with a review: every completed todo is reviewed, and the agent keeps working until the todo list is done – for overnight runs",