Compare commits

...
4 Commits
Author SHA1 Message Date
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
Richard Zink 6e9eda28b5 Release v202611-00018 2026-11-10 23:12:50 +01:00
Richard ZinkandClaude Opus 5.5 bd896794fd Answer budget within the context window; enforced reviews of completed todos
Context window
- Requests asked for the model's full max_output, but providers count
  input + output against the window: long chats with large-output models
  (DeepSeek V4.1 Flash, 384k) failed with "input too long" before automatic
  compaction (85 %) kicked in. max_tokens is now what the window leaves
  (estimated input = last usage + messages since), the chat is compacted when
  less than 32k would remain for an answer, and a context-overflow error
  compacts and retries once.
- ModelConfig.max_input (catalogue limit.input, editable in the provider
  settings) lowers the compaction threshold for models with an input limit.

Enforced reviews (modes with refine.rounds > 0, refine.after_todos default on)
- Every todo marked completed is reviewed against the work since the last
  review; with findings it is reopened and the feedback goes to the agent
  (max. rounds per todo). Passed reviews leave a visible note.
- Ending the turn with open todos sends the agent back to work while it makes
  progress (max. 10 reminders in a row without progress); such modes may take
  up to 2,000 steps per turn. Reaching the step limit is reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 23:12:49 +01:00
23 changed files with 1657 additions and 119 deletions

No files matched your search

+42
View File
@@ -3,6 +3,48 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes.
## [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
### Added
- **Enforced reviews:** when a review is configured for a mode, every todo the agent marks
completed is reviewed; with findings the todo is reopened and the agent has to fix them and
complete it again (up to the configured rounds per todo). Ending the turn with open todos sends
the agent back to work as long as it makes progress – for unattended runs, e.g. overnight
(such modes may take up to 2,000 steps per turn). The chat shows passed reviews and these
reminders; *Settings → Modes* can switch it off per mode (then only one review at the end of a
turn, as before).
- **Max input per model:** providers that limit the input below the context window (e.g. 272k of
400k) are taken into account; fetched from the model catalogue, editable in the provider settings.
### Fixed
- **Context overflow instead of compaction** (e.g. DeepSeek V4.1 Flash with 384k max output):
requests asked for the model's full max output, and providers count input + output against the
window, so long chats failed before automatic compaction kicked in. The output budget is now what
the window leaves, the chat is compacted when no reasonable room for an answer is left, and a
"context too long" error compacts and retries once instead of stopping. A turn that hits the
step limit now says so.
## [v202611-00017] – 2026-11-10
### Added
+1 -1
View File
@@ -11,7 +11,7 @@
</p>
<p align="center">
<img alt="Version v202611-00017" src="https://img.shields.io/badge/version-v202611--00017-d69e2e?style=flat-square">
<img alt="Version v202611-00019" src="https://img.shields.io/badge/version-v202611--00019-d69e2e?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="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
+3
View File
@@ -64,6 +64,9 @@ pub struct ModelConfig {
pub context: Option<u64>,
#[serde(default)]
pub max_output: Option<u64>,
/// Input limit when the provider sets one below the context window (e.g. 272k of 400k).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_input: Option<u64>,
#[serde(default)]
pub reasoning: bool,
#[serde(default)]
+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}/export", get(export_session))
.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}/prompt", post(prompt))
.route("/sessions/{id}/abort", post(abort))
@@ -813,14 +816,81 @@ async fn export_session(Extension(user): Extension<User>, State(app): State<AppS
.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 messages = app.store.messages(&id)?;
let busy = app.is_busy(&id);
let can_write = app.access(&user, &id) == Access::Full;
let queue = app.store.queue_list(&id)?;
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)]
+1
View File
@@ -409,6 +409,7 @@ fn opencode_model(m: &Value) -> Value {
"id": m["id"],
"context": m["limit"]["context"],
"max_output": m["limit"]["output"],
"max_input": m["limit"]["input"],
"vision": images,
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
"input_price": input["price"],
+3
View File
@@ -413,6 +413,9 @@ pub struct RefineConfig {
/// Review criteria; empty = the mode's defaults.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub criteria: Option<String>,
/// Also review every todo the agent marks completed (reopened when findings come back).
#[serde(default = "yes")]
pub after_todos: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
+1
View File
@@ -22,6 +22,7 @@ mod lsp;
mod mentions;
mod metrics;
mod mcp;
mod media;
mod permission;
mod prompts;
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");
}
}
+1 -1
View File
@@ -121,7 +121,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
source: "file".into(),
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 }),
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") }),
path: Some(p.display().to_string()),
});
}
+253 -17
View File
@@ -18,6 +18,8 @@ use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
const MAX_STEPS: usize = 250;
/// Modes with enforced reviews work through their todo list (e.g. overnight).
const MAX_STEPS_ENFORCED: usize = 2_000;
const MAX_SUBAGENT_DEPTH: usize = 3;
/// What happened to a prompt.
@@ -260,7 +262,15 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
let mut stop_hooks = 0;
let mut refine_done = 0u32;
let turn_start = now_ms();
for _ in 0..MAX_STEPS {
// Compacted for the current step (no second compaction before a request went through).
let mut compacted_now = false;
// Mandatory reviews of completed todos: rounds per todo, and where the last one ended.
let mut todo_reviews: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
let mut review_since = turn_start;
// Reminders about open todos at the end of a turn (see MAX_IDLE_CONTINUES).
let mut last_reminder: Option<Value> = None;
let mut idle_continues = 0u32;
for step_no in 0..=MAX_STEPS_ENFORCED {
if cancel.is_cancelled() {
break;
}
@@ -275,40 +285,65 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
agent.prompt = prompts::craft_prompt(clean_room);
}
let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone());
let step_limit = if enforced_review(&agent) > 0 { MAX_STEPS_ENFORCED } else { MAX_STEPS };
if step_no >= step_limit {
app.emit(Event::Error { session_id: session_id.into(), message: format!("Stopped after {step_limit} steps (limit of a turn) – send a message to continue.") });
break;
}
let (provider, mut model) = app.registry().resolve(&model_spec)?;
if let Some(effort) = app.with_runtime(session_id, |r| r.effort.clone()) {
model.effort = Some(effort);
}
// Auto-compaction when the last request used most of the context window.
if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) {
let history = active_history(app.store.messages_for_model(session_id)?);
// Providers count input + max_tokens against the context window: the answer needs
// room too. Estimated input = last request (input + its answer) + what came since.
let est_input = estimate_input(session.last_usage.as_ref(), &history);
let want_output = model.max_output.unwrap_or(16_000);
if let Some(ctx_len) = model.context {
let threshold = app.config().server.auto_compact_threshold;
if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold {
compact(app.clone(), session_id, cancel.clone(), Some((u.context_tokens(), ctx_len))).await?;
// Input limit: the context window, or the provider's lower input limit.
let input_limit = model.max_input.map_or(ctx_len, |m| m.min(ctx_len));
let room = ctx_len.saturating_sub(est_input);
// Most of the input limit used, or no room left for a reasonable answer.
if threshold > 0.0 && (est_input as f64 > input_limit as f64 * threshold || room < want_output.min(MIN_ANSWER_ROOM)) && !compacted_now {
compacted_now = true;
compact(app.clone(), session_id, cancel.clone(), Some((est_input, input_limit))).await?;
continue;
}
}
let max_tokens = answer_budget(want_output, model.context, est_input);
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
let tools = available_tools(&app, &agent, owner.as_ref());
let history = active_history(app.store.messages_for_model(session_id)?);
let req = ChatRequest {
system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions),
messages: history,
tools: tools.iter().map(|t| t.def()).collect(),
max_tokens: model.max_output.unwrap_or(16_000),
max_tokens,
reasoning: true,
session_id: Some(session_id.to_string()),
model: model.clone(),
};
let step = match stream_step(&app, &session, &agent.name, &model_spec, provider, req.clone(), &cancel).await {
Ok(step) => step,
Ok(step) => {
compacted_now = false;
step
}
// The estimate was off and the provider says the input is too long: compact
// once and try again instead of stopping.
Err(e) if is_context_overflow(&e) && !compacted_now && !cancel.is_cancelled() => {
compacted_now = true;
app.emit(Event::Error { session_id: session_id.into(), message: format!("context window full ({}) – compacting the conversation and continuing", short_error(&e)) });
compact(app.clone(), session_id, cancel.clone(), Some((est_input, model.context.unwrap_or(0)))).await?;
continue;
}
// Host down or budget used up: switch to the model's fallback for this step.
Err(e) if model.fallback.is_some() && !cancel.is_cancelled() && needs_fallback(&e) => {
let fb = model.fallback.clone().unwrap_or_default();
let (fb_provider, fb_model) = app.registry().resolve(&fb)?;
app.emit(Event::Error { session_id: session_id.into(), message: format!("{model_spec} is not available ({}) – using the fallback model {fb}", short_error(&e)) });
let req = ChatRequest { model: fb_model.clone(), max_tokens: fb_model.max_output.unwrap_or(16_000), ..req };
let req = ChatRequest { model: fb_model.clone(), max_tokens: answer_budget(fb_model.max_output.unwrap_or(16_000), fb_model.context, est_input), ..req };
stream_step(&app, &session, &agent.name, &fb, fb_provider, req, &cancel).await?
}
Err(e) => return Err(e),
@@ -353,12 +388,37 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
}
}
let normal_end = matches!(step.stop_reason.as_str(), "stop" | "end_turn" | "");
// Enforced reviews also mean: work until the todo list is done. Ending the turn with
// open todos sends the agent back (as long as it makes progress).
if calls.is_empty() && depth == 0 && normal_end && !cancel.is_cancelled() && enforced_review(&agent) > 0 {
let todos_now = app.session(session_id)?.todos;
let open = open_todos(&todos_now);
if !open.is_empty() {
let progressed = last_reminder.as_ref() != Some(&todos_now);
idle_continues = if progressed { 0 } else { idle_continues + 1 };
if idle_continues < MAX_IDLE_CONTINUES {
last_reminder = Some(todos_now);
save_user_parts(
&app,
session_id,
vec![Part::Text {
text: format!(
"<todo-reminder open=\"{}\">\n{}\n</todo-reminder>\nThese todos are still open. Continue working until all of them are completed (each completed todo is reviewed). Only if you are truly blocked and need the user, say clearly what you need and stop.",
open.len(),
open.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")
),
}],
)?;
continue;
}
}
}
if calls.is_empty() && depth == 0 && normal_end && !cancel.is_cancelled() {
// Refinement loop: a reviewer checks the turn; findings send the agent back to work.
let rounds = agent.refine.as_ref().map(|r| r.rounds).unwrap_or(0);
if refine_done < rounds {
refine_done += 1;
match review_turn(&app, &session, &agent, &model_spec, turn_start, refine_done, rounds, &cancel).await {
match review_turn(&app, &session, &agent, &model_spec, turn_start, None, refine_done, rounds, &cancel).await {
Ok(Some(feedback)) => {
save_user_parts(&app, session_id, vec![Part::Text { text: feedback }])?;
continue;
@@ -412,6 +472,40 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
results.push(execute_call(&app, &session, &base, by_name(name), id, name, input.clone(), depth).await);
}
}
// Mode with a review: every todo marked completed is reviewed; with findings it is
// reopened and the agent has to fix them and complete it again (max. `rounds` per todo).
// The verdict goes into the same message as the tool results.
let rounds = enforced_review(&agent);
if rounds > 0 && depth == 0 && !cancel.is_cancelled() && calls.iter().any(|(_, n, _)| n == "todo_write") {
let todos_now = app.session(session_id)?.todos;
let due: Vec<String> = newly_completed(&session.todos, &todos_now)
.into_iter()
.filter(|t| todo_reviews.get(t).copied().unwrap_or(0) < rounds)
.collect();
if !due.is_empty() {
for t in &due {
*todo_reviews.entry(t.clone()).or_default() += 1;
}
let round = due.iter().filter_map(|t| todo_reviews.get(t)).copied().max().unwrap_or(1);
let fresh = app.session(session_id)?;
let reviewed = review_turn(&app, &fresh, &agent, &model_spec, review_since, Some(&due), round, rounds, &cancel).await;
review_since = now_ms();
match reviewed {
Ok(Some(feedback)) => {
app.set_todos(session_id, reopen_todos(&todos_now, &due))?;
results.push(Part::Text { text: feedback });
}
Ok(None) => {
let open = open_todos(&todos_now);
let next = if open.is_empty() { "All todos are done – check the whole request once more, then finish." } else { "Continue with the next open todo." };
results.push(Part::Text {
text: format!("<refine-approved reviewer=\"{}\">\n{}\n</refine-approved>\nReview passed. {next}", review_model(&agent, &model_spec), due.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")),
});
}
Err(e) => tracing::warn!("todo review of {session_id} failed: {e:#}"),
}
}
}
// Deliver messages the user queued while we were working (with attachments).
results.extend(take_queue(&app, session_id));
save_user_parts(&app, session_id, results)?;
@@ -419,6 +513,55 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
Ok(final_text)
}
/// Review rounds when the mode enforces reviews of completed todos (0 = not enforced).
fn enforced_review(agent: &prompts::Agent) -> u32 {
agent.refine.as_ref().filter(|r| r.after_todos).map(|r| r.rounds).unwrap_or(0)
}
/// The model that reviews for this mode.
fn review_model(agent: &prompts::Agent, model_spec: &str) -> String {
agent.refine.as_ref().and_then(|r| r.model.clone()).filter(|m| !m.is_empty()).unwrap_or_else(|| model_spec.to_string())
}
/// Contents of the todos that are not completed yet.
fn open_todos(todos: &Value) -> Vec<String> {
todos.as_array().into_iter().flatten().filter(|t| t["status"] != "completed").filter_map(|t| t["content"].as_str()).map(String::from).collect()
}
/// Times in a row the agent may end its turn with open todos without making progress.
const MAX_IDLE_CONTINUES: u32 = 10;
/// Contents of the todos that are completed in `after` but were not in `before`.
fn newly_completed(before: &Value, after: &Value) -> Vec<String> {
let done_before: std::collections::HashSet<&str> = before
.as_array()
.into_iter()
.flatten()
.filter(|t| t["status"] == "completed")
.filter_map(|t| t["content"].as_str())
.collect();
after
.as_array()
.into_iter()
.flatten()
.filter(|t| t["status"] == "completed")
.filter_map(|t| t["content"].as_str())
.filter(|c| !done_before.contains(c))
.map(String::from)
.collect()
}
/// The todo list with the given items open again (in progress).
fn reopen_todos(todos: &Value, items: &[String]) -> Value {
let mut out = todos.clone();
for t in out.as_array_mut().into_iter().flatten() {
if t["content"].as_str().is_some_and(|c| items.iter().any(|i| i == c)) {
t["status"] = Value::from("in_progress");
}
}
out
}
fn tool_result(call_id: &str, name: &str, content: String, is_error: bool, meta: Option<Value>) -> Part {
Part::ToolResult { call_id: call_id.into(), name: name.into(), content, images: vec![], is_error, meta }
}
@@ -779,6 +922,47 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
/// Errors after which the fallback model takes over: the host is unreachable
/// (connection, timeout, gateway errors) or the provider's budget is used up.
/// Room an answer gets at least before the conversation is compacted.
const MIN_ANSWER_ROOM: u64 = 32_000;
/// Estimated input tokens of the next request: the last request (its input and answer)
/// plus the messages that came after that answer (tool results, user messages), at about
/// 3 characters per token; without usage (fresh or just compacted) the whole history.
fn estimate_input(last: Option<&crate::types::Usage>, history: &[Message]) -> u64 {
let chars_of = |m: &Message| -> u64 {
m.parts
.iter()
.map(|p| match p {
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text, .. } => text.len() as u64,
Part::ToolCall { input, .. } => input.to_string().len() as u64,
Part::ToolResult { content, images, .. } => content.len() as u64 + images.len() as u64 * 4_500,
Part::Image { .. } => 4_500,
})
.sum()
};
match last {
Some(u) => {
let after = history.iter().rposition(|m| m.role == Role::Assistant).map(|i| &history[i + 1..]).unwrap_or(&[]);
u.context_tokens() + after.iter().map(chars_of).sum::<u64>() / 3
}
None => history.iter().map(chars_of).sum::<u64>() / 3 + 8_000,
}
}
/// max_tokens for a request: the model's maximum, but no more than the window leaves
/// (with a margin for the estimate), and never below a small floor.
fn answer_budget(max_output: u64, context: Option<u64>, est_input: u64) -> u64 {
let Some(ctx) = context else { return max_output };
let room = ctx.saturating_sub(est_input + est_input / 50 + 2_000);
max_output.min(room).max(4_096.min(max_output))
}
/// The provider rejected the request because input + output exceed the context window.
fn is_context_overflow(e: &anyhow::Error) -> bool {
let s = format!("{e:#}").to_lowercase();
["input too long", "context length", "context_length", "maximum context", "context window", "prompt is too long", "too many tokens", "reduce the length"].iter().any(|k| s.contains(k))
}
fn needs_fallback(e: &anyhow::Error) -> bool {
let s = format!("{e:#}").to_lowercase();
["connect", "timed out", "timeout", "http 502", "http 503", "http 504", "dns", "budget"].iter().any(|k| s.contains(k))
@@ -1133,7 +1317,7 @@ fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&st
/// Review the last turn against the user's request. Returns feedback for the
/// agent, or None when the reviewer approves.
#[allow(clippy::too_many_arguments)]
async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent, model_spec: &str, turn_start: i64, round: u32, rounds: u32, cancel: &CancellationToken) -> Result<Option<String>> {
async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent, model_spec: &str, since: i64, todos: Option<&[String]>, round: u32, rounds: u32, cancel: &CancellationToken) -> Result<Option<String>> {
let refine = agent.refine.clone().unwrap_or_default();
let spec = refine.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| model_spec.to_string());
let (provider, mut model) = app.registry().resolve(&spec)?;
@@ -1147,7 +1331,12 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
.rposition(|m| matches!(m.role, Role::User) && m.parts.iter().any(|p| matches!(p, Part::Text { text } if !text.starts_with("<refine-feedback") && !text.starts_with("<hook-"))))
.unwrap_or(0);
let request: String = history.get(req_idx).map(|m| m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n")).unwrap_or_default();
let mut work = transcript_of(&history[(req_idx + 1).min(history.len())..]);
// A todo review looks at the work since the last review, the end-of-turn review at the whole turn.
let from = match todos {
Some(_) => history.iter().position(|m| m.created_at >= since).unwrap_or(history.len()).max(req_idx + 1),
None => req_idx + 1,
};
let mut work = transcript_of(&history[from.min(history.len())..]);
if work.len() > 30_000 {
let mut start = work.len() - 30_000;
while !work.is_char_boundary(start) {
@@ -1158,7 +1347,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
// Actual file changes of this turn (checkpoint vs. current content).
let mut changes = String::new();
let mut seen = std::collections::HashSet::new();
for (path, before) in app.store.checkpoints_since(&session.id, turn_start)? {
for (path, before) in app.store.checkpoints_since(&session.id, since)? {
if !seen.insert(path.clone()) || changes.len() > 30_000 {
continue;
}
@@ -1172,10 +1361,17 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
let system = "You review the work of an AI coding agent for the user. Be strict about what matters for the user's request and ignore style nitpicks. \
Reply in exactly this format: the first line is APPROVED or CHANGES_NEEDED. For CHANGES_NEEDED, follow with at most 8 concrete, actionable findings as a bullet list \
(with file:line where possible). If the important work is done and correct, reply APPROVED.";
let focus = match todos {
Some(items) => format!(
"<completed-todos>\n{}\n</completed-todos>\nThe agent just marked these todo items as completed. Review only whether they are really done and correct; other parts of the request may still be in progress.\n\n",
items.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")
),
None => String::new(),
};
let body = format!(
"<request>\n{request}\n</request>\n\n<criteria>\n{criteria}\n</criteria>\n\n<agent-work mode=\"{}\">\n{work}\n</agent-work>\n\n<file-changes>\n{}\n</file-changes>",
"<request>\n{request}\n</request>\n\n{focus}<criteria>\n{criteria}\n</criteria>\n\n<agent-work mode=\"{}\">\n{work}\n</agent-work>\n\n<file-changes>\n{}\n</file-changes>",
agent.name,
if changes.is_empty() { "(no files changed in this turn)" } else { changes.as_str() }
if changes.is_empty() { "(no files changed since the last review)" } else { changes.as_str() }
);
let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone(), session_id: Some(session.id.clone()) };
let (text, usage) = complete_text(app, &spec, provider, req, cancel.clone()).await?;
@@ -1188,10 +1384,17 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
}
let findings: String = lines.collect::<Vec<_>>().join("\n");
let findings = if findings.trim().is_empty() { text.clone() } else { findings };
Ok(Some(format!(
Ok(Some(match todos {
Some(items) => format!(
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\" todos=\"{}\">\n{}\n</refine-feedback>\nThe review of the todo(s) you just completed found problems, so they are open again. Fix these findings (or explain briefly why one does not apply), verify, then mark the todo(s) completed again – that triggers the next review.",
items.len(),
findings.trim()
),
None => format!(
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\">\n{}\n</refine-feedback>\nAddress these review findings (or explain briefly why one does not apply), verify, then finish.",
findings.trim()
)))
),
}))
}
.await;
app.emit(Event::Waiting { session_id: session.id.clone(), reason: None });
@@ -1314,6 +1517,39 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
mod tests {
use super::*;
#[test]
fn todo_completion_and_reopening() {
let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
let after = serde_json::json!([{"content": "A", "status": "completed"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
assert_eq!(newly_completed(&before, &after), vec!["A".to_string()]);
assert_eq!(newly_completed(&Value::Null, &after), vec!["A".to_string(), "B".to_string()]);
assert_eq!(open_todos(&after), vec!["C".to_string()]);
let reopened = reopen_todos(&after, &["A".to_string()]);
assert_eq!(reopened[0]["status"], "in_progress");
assert_eq!(reopened[1]["status"], "completed");
}
#[test]
fn answer_room_and_overflow() {
// The case from production: DeepSeek V4.1 Flash, 1M window, 384K output, 720K input.
let (ctx, out) = (1_048_576u64, 393_216u64);
let b = answer_budget(out, Some(ctx), 720_000);
assert!(720_000 + b <= ctx && b > 300_000, "{b}");
assert_eq!(answer_budget(out, Some(ctx), 100_000), out);
assert_eq!(answer_budget(16_000, None, 5_000_000), 16_000);
assert_eq!(answer_budget(out, Some(ctx), 1_040_000), 4_096);
assert!(is_context_overflow(&anyhow!("HTTP 400: [invalid_request_error] Input too long: model limit 1048576 tokens, requested 1108739 tokens")));
assert!(is_context_overflow(&anyhow!("This model's maximum context length is 128000 tokens")));
assert!(!is_context_overflow(&anyhow!("HTTP 429 rate limited")));
// Estimate: last request + what came after its answer (about 3 chars per token).
let u = crate::types::Usage { input_tokens: 1_000, cache_read_tokens: 9_000, output_tokens: 500, ..Default::default() };
let mut call = Message::new(Role::Assistant, vec![call("c1")]);
call.id = "a".into();
let result = Message::new(Role::User, vec![Part::ToolResult { call_id: "c1".into(), name: "bash".into(), content: "x".repeat(3_000), images: vec![], is_error: false, meta: None }]);
assert_eq!(estimate_input(Some(&u), &[call.clone(), result.clone()]), 10_500 + 1_000);
assert!(estimate_input(None, &[call, result]) >= 1_000 + 8_000);
}
#[test]
fn detects_commands_that_move_files() {
assert!(moves_files("mkdir -p art/old && mv art/*.png art/old/"));
+170 -23
View File
@@ -1,6 +1,6 @@
//! 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_json::{Value, json};
use anyhow::Result;
@@ -244,28 +244,7 @@ impl Store {
let (sdata, mdata) = row?;
let session: Session = serde_json::from_str(&sdata)?;
// Snippet from the visible text of the matching message (not JSON keys).
let mut snippet = String::new();
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;
}
}
}
let snippet = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()).and_then(|m| snippet_in(&m, &needle)).unwrap_or_default();
if snippet.is_empty() && !session.title.to_lowercase().contains(&needle) {
continue; // matched only JSON structure (keys, ids)
}
@@ -274,6 +253,121 @@ impl Store {
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<()> {
let conn = self.conn.lock().unwrap();
// Child sessions (subagents) go with their parent.
@@ -1045,6 +1139,17 @@ impl Store {
}
/// 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>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(&format!(
@@ -1392,3 +1497,45 @@ pub struct PushSub {
pub p256dh: 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
}
+392 -69
View File
@@ -1,12 +1,12 @@
import { takePrefill } from "../share";
import { Icon } from "./Icon";
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 { useFetch, useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
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 { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
import { useReasoningMode, useTodosCollapsed } from "../prefs";
@@ -26,8 +26,10 @@ import { ShellsChip } from "./ShellsPanel";
import { TodoList, ToolCard } from "./ToolCard";
import { confirmDialog, promptDialog, toast } from "../dialogs";
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
import { MediaGallery } from "./MediaGallery";
type ToolResult = Extract<Part, { type: "tool_result" }>;
type ToolCall = Extract<Part, { type: "tool_call" }>;
const EMPTY: Message[] = [];
@@ -115,7 +117,36 @@ function BackgroundResult({ text }: { text: string }) {
);
}
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)">\n?([\s\S]*?)\n?<\/refine-feedback>/;
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)"(?: todos="\d+")?>\n?([\s\S]*?)\n?<\/refine-feedback>/;
/** Enforced reviews: a completed todo passed, or a reminder about open todos. */
const NOTE_RE = /^<(refine-approved|todo-reminder)(?: reviewer="([^"]*)")?(?: open="\d+")?>\n?([\s\S]*?)\n?<\/(?:refine-approved|todo-reminder)>/;
/** Messages from the harness shown as cards instead of user bubbles. */
const isHarnessNote = (text: string) => BG_RE.test(text) || REFINE_RE.test(text) || NOTE_RE.test(text);
/** A todo passed its review, or the agent was sent back to its open todos. */
function HarnessNote({ text }: { text: string }) {
const t = useT();
const m = text.match(NOTE_RE);
if (!m) return null;
const [, kind, reviewer, body] = m;
const items = body.split("\n").map((l) => l.replace(/^- /, "").trim()).filter(Boolean);
const approved = kind === "refine-approved";
return (
<div className={`harness-note ${approved ? "ok" : ""}`}>
<Icon name={approved ? "check" : "refresh"} />
<span className="grow">
<b>{t(approved ? "chat.reviewPassed" : "chat.todoReminder", { n: items.length })}</b> {items.join(" · ")}
</span>
{reviewer && <span className="chip">{reviewer}</span>}
</div>
);
}
function HarnessMessage({ text }: { text: string }) {
if (REFINE_RE.test(text)) return <RefineFeedback text={text} />;
if (NOTE_RE.test(text)) return <HarnessNote text={text} />;
return <BackgroundResult text={text} />;
}
/** Findings of the review loop, sent back to the agent. */
function RefineFeedback({ text }: { text: string }) {
@@ -137,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 }) {
const t = useT();
const session = useStore((s) => s.sessionById[sessionId]);
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 busy = useStore((s) => !!s.busy[sessionId]);
const errors = useStore((s) => s.errors[sessionId]);
@@ -197,11 +517,36 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
return m;
}, [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(() => {
const el = scrollRef.current;
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.
const hasSession = !!session;
useEffect(() => {
@@ -251,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 rewind = (m: Message) => setRewinding(m);
rewindRef.current = rewind;
const rewindDone = async () => {
const m = rewinding!;
setRewinding(undefined);
@@ -342,6 +688,9 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
>
<Icon name="terminal" />
</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")}>
<Icon name="folder" />
</button>
@@ -365,6 +714,16 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
>
<Icon name="edit" /> {t("instructions.title")}
</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 && (
<a
href="#"
@@ -418,6 +777,17 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
</button>
)}
</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} />
<ChatTabPanes state={tabs} />
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
@@ -443,71 +813,24 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<div className="chat">
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
{messages.map((m, idx) => {
if (m.role === "assistant") {
const lastAnswer = idx === lastAssistantIdx && !busy && !readOnly && !session.parent_id;
return (
<div key={m.id} className="msg">
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={session.cwd} />
{lastAnswer && <RegenerateButton sessionId={sessionId} messageId={m.id} model={session.model} />}
</div>
);
}
const summary = m.parts.find((p) => p.type === "summary");
if (summary && summary.type === "summary") {
return (
<details key={m.id} className="summary-divider">
<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>
<Markdown text={summary.text} />
</details>
);
}
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
const background = allTexts.filter((p) => BG_RE.test(p.text) || REFINE_RE.test(p.text));
const texts = allTexts.filter((p) => !BG_RE.test(p.text) && !REFINE_RE.test(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) => (
REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult key={i} text={p.text} />
))}
</div>
) : null;
}
return (
<div key={m.id} className="msg">
{background.map((p, i) => (
REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult 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" />
{win?.older && <OlderLoader sessionId={sessionId} afterSummary={messages[0]?.parts.some((p) => p.type === "summary")} scrollRef={scrollRef} />}
<MessageList
messages={messages}
results={results}
busy={busy}
canEdit={!busy && !readOnly}
canRegenerate={!busy && !readOnly && !session.parent_id && !win?.newer}
sessionId={sessionId}
model={session.model}
baseDir={session.cwd}
onRewind={onRewind}
highlight={highlight}
/>
{win?.newer && (
<button className="back-to-latest" onClick={() => void showLatest()}>
<Icon name="arrow-down" /> {t("chat.backToLatest")}
</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 && (
<div className="msg">
<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" />
</>
),
search: (
<>
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" />
</>
),
eye: (
<>
<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>
);
}
+6
View File
@@ -376,6 +376,7 @@ interface ModelRow {
name?: string;
context?: number;
max_output?: number;
max_input?: number;
reasoning?: boolean;
vision?: boolean;
effort?: string;
@@ -454,6 +455,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
price_multipliers: f.price_multipliers ?? m.price_multipliers,
context: m.context ?? f.context,
max_output: m.max_output ?? f.max_output,
max_input: m.max_input ?? f.max_input ?? undefined,
vision: m.vision || !!f.vision,
reasoning: m.reasoning || !!f.reasoning,
};
@@ -616,6 +618,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
<th>ID</th>
<th>{t("settings.context")}</th>
<th>{t("settings.maxOutput")}</th>
<th title={t("settings.maxInputHint")}>{t("settings.maxInput")}</th>
<th title={t("settings.reasoning")}><Icon name="brain" /></th>
<th title={t("settings.vision")}><Icon name="eye" /></th>
<th>{t("settings.effort")}</th>
@@ -640,6 +643,9 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
<td>
<input type="number" value={m.max_output ?? ""} onChange={(e) => setModel(i, { max_output: num(e.target.value) })} />
</td>
<td>
<input type="number" value={m.max_input ?? ""} placeholder="–" onChange={(e) => setModel(i, { max_input: num(e.target.value) })} />
</td>
<td>
<input type="checkbox" checked={!!m.reasoning} onChange={(e) => setModel(i, { reasoning: e.target.checked })} />
</td>
+10 -1
View File
@@ -1,5 +1,6 @@
import { Icon } from "./Icon";
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
import { useState } from "react";
import { useT } from "../i18n";
import { navigate } from "../router";
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 }) {
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 status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
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 =
(status === "err" && !!result) ||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image"));
const open = toggled ?? openByDefault;
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>
<span className="tool-name">{call.name}</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>}
{status === "run" ? <Spinner small /> : <span className={`status ${status}`}>{statusText}</span>}
</summary>
{open && (
<div className="tool-body">
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
{(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>
)}
</details>
);
}
+7 -1
View File
@@ -61,6 +61,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
refine_model: raw?.refine?.model ?? "",
refine_criteria: raw?.refine?.criteria ?? "",
refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean,
});
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
@@ -74,7 +75,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
const rounds = Math.max(0, Math.min(10, Number(form.refine_rounds) || 0));
patches.push({
path: [...base, "refine"],
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null } : 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 (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
@@ -158,6 +159,11 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
<label>{t("agents.refineCriteria")}</label>
<textarea rows={3} value={form.refine_criteria} onChange={(e) => setForm({ ...form, refine_criteria: e.target.value })} />
<div className="hint">{t("agents.refineCriteriaHint")}</div>
<label className="check">
<input type="checkbox" checked={form.refine_after_todos} onChange={(e) => setForm({ ...form, refine_after_todos: e.target.checked })} />
{t("agents.refineAfterTodos")}
</label>
<div className="hint">{t("agents.refineAfterTodosHint")}</div>
</>
)}
<label>{t("agents.description")}</label>
+33
View File
@@ -334,6 +334,37 @@ export const de: Messages = {
"wizard.idea.cadBox": "Eine Box mit Schnappdeckel, Innenmaß 80 × 50 × 30 mm",
"wizard.idea.cadBracket": "Eine Wandhalterung für ein 25-mm-Rohr mit zwei M4-Schraublöchern",
"activity.reviewing": "Ergebnis wird geprüft (Runde {round})…",
"chat.reviewPassed": "Review bestanden:",
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:",
"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.",
"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",
"agents.refine": "Review-Schleife",
"agents.refineRounds": "Review-Runden pro Aufgabe",
@@ -757,6 +788,8 @@ export const de: Messages = {
"settings.pricesHint": "Preise pro 1 Mio. Tokens in der eingestellten Währung. Cache-Preise fallen auf den Input-Preis zurück.",
"settings.modelsEmptyHint": "Keine Modelle eingetragen: Sie werden automatisch beim Provider abgefragt.",
"settings.context": "Kontext",
"settings.maxInput": "Max. Eingabe",
"settings.maxInputHint": "Eingabe-Limit, falls der Anbieter eines unterhalb des Kontextfensters setzt (leer: das Kontextfenster). Der Chat wird zusammengefasst, bevor die Eingabe es erreicht.",
"settings.maxOutput": "Max. Ausgabe",
"settings.reasoning": "Reasoning",
"settings.vision": "Bilder",
+33
View File
@@ -333,6 +333,37 @@ export const en = {
"wizard.idea.cadBox": "A box with a snap-on lid, inner size 80 × 50 × 30 mm",
"wizard.idea.cadBracket": "A wall bracket for a 25 mm pipe with two M4 screw holes",
"activity.reviewing": "Reviewing the result (round {round})…",
"chat.reviewPassed": "Review passed:",
"chat.todoReminder": "Back to work – {n} open todos:",
"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.",
"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",
"agents.refine": "Review loop",
"agents.refineRounds": "Review rounds per task",
@@ -756,6 +787,8 @@ export const en = {
"settings.pricesHint": "Prices per 1M tokens in the configured currency. Cache prices default to the input price.",
"settings.modelsEmptyHint": "No models listed: they are discovered automatically from the provider.",
"settings.context": "Context",
"settings.maxInput": "Max input",
"settings.maxInputHint": "Input limit, if the provider sets one below the context window (empty: the context window). The chat is compacted before the input reaches it.",
"settings.maxOutput": "Max output",
"settings.reasoning": "Reasoning",
"settings.vision": "Vision",
+39 -2
View File
@@ -32,6 +32,8 @@ export interface State {
/** Includes subagent sessions loaded on demand. */
sessionById: Record<string, Session>;
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). */
readOnly: Record<string, boolean>;
drafts: Record<string, Draft | undefined>;
@@ -58,6 +60,7 @@ let state: State = {
sessions: [],
sessionById: {},
messages: {},
windows: {},
readOnly: {},
drafts: {},
busy: {},
@@ -130,7 +133,8 @@ export function applyEvent(ev: ServerEvent) {
setState((s) => {
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;
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 };
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) {
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) => ({
windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } },
...upsertSession(s, data.session),
sessionById: { ...s.sessionById, [id]: data.session },
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. */
export async function resync() {
const isAdmin = getState().me?.role === "admin";
+242
View File
@@ -1068,6 +1068,13 @@ button.session-group:hover {
.form label {
color: var(--fg2);
}
.form label.check {
grid-column: 2;
display: flex;
align-items: center;
gap: 8px;
color: var(--fg1);
}
.form .hint {
grid-column: 2;
font-size: 12px;
@@ -3164,6 +3171,28 @@ button.danger-solid {
.refine-card summary .icon {
color: var(--purple);
}
.harness-note {
display: flex;
align-items: baseline;
gap: 8px;
margin: 6px 0;
padding: 6px 10px;
border-left: 3px solid var(--blue);
border-radius: 4px;
background: var(--bg1);
font-size: 12.5px;
color: var(--fg2);
}
.harness-note .icon {
color: var(--blue);
flex-shrink: 0;
}
.harness-note.ok {
border-left-color: var(--green);
}
.harness-note.ok .icon {
color: var(--green);
}
/* --- automations ---------------------------------------------------------------- */
.job.off {
@@ -4156,3 +4185,216 @@ span.chat-tab > button {
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 -1
View File
@@ -141,6 +141,7 @@ export interface ModelInfo {
name: string;
context?: number;
max_output?: number | null;
max_input?: number | null;
reasoning: boolean;
vision: boolean;
effort?: string | null;
@@ -166,7 +167,7 @@ export interface AgentInfo {
tools?: string[] | null;
source: "builtin" | "config" | "file" | "personal";
max_parallel?: number | null;
refine?: { rounds: number; model?: string | null; criteria?: string | null } | null;
refine?: { rounds: number; model?: string | null; criteria?: string | null; after_todos?: boolean } | null;
/** Source file of Markdown agents. */
path?: string | null;
}
+14
View File
@@ -3,6 +3,20 @@
* under "unreleased"; scripts/release.sh renames the key to the release version.
*/
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"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": {
en: [
"Modes with a review: every completed todo is reviewed, and the agent keeps working until the todo list is done – for overnight runs",
"Long chats are compacted in time instead of failing with “input too long”",
],
de: [
"Modi mit Review: jedes erledigte Todo wird geprüft, und der Agent arbeitet weiter, bis die Liste abgehakt ist – für Läufe über Nacht",
"Lange Chats werden rechtzeitig zusammengefasst, statt mit „Eingabe zu lang“ abzubrechen",
],
},
"202611-00017": {
en: ["Your messages are shown as Markdown; the eye button next to the text field previews it before sending"],
de: ["Deine Nachrichten werden als Markdown dargestellt; das Augen-Symbol neben dem Textfeld zeigt vor dem Senden eine Vorschau"],