Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b71dfc49c3 | ||
|
|
3b0764450a |
No files matched your search
@@ -3,6 +3,25 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
until 1.0, minor versions may contain breaking changes.
|
||||||
|
|
||||||
|
## [v202611-00019] – 2026-11-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Search in a chat:** the magnifier in the chat's top bar searches all of it (messages, tool
|
||||||
|
output) on the server; a hit loads its part of the chat and marks the message.
|
||||||
|
- **Media of a chat:** *⋯ → Media* shows every image, video and audio file of the chat and its
|
||||||
|
subagents – generated images, screenshots, files the agent wrote, media in answers, your
|
||||||
|
attachments – filterable, large view, "show in chat" jumps to the message.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Long chats stay fast:** a chat opens with its messages since the last summary (at most 200);
|
||||||
|
older ones load when scrolling up, the history before a summary on request. Finished turns
|
||||||
|
fold into one line ("42 steps · 3 files", the answer stays visible). Typing, streaming and
|
||||||
|
status changes no longer re-render the whole history, and tool output is only built when a
|
||||||
|
card is opened. Measured with a chat of 5,900 messages: opening 9 s → 0.7 s (phone-like
|
||||||
|
CPU: 22 s → 1 s), typing 440 ms → 25 ms per key.
|
||||||
|
|
||||||
## [v202611-00018] – 2026-11-10
|
## [v202611-00018] – 2026-11-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Version v202611-00018" src="https://img.shields.io/badge/version-v202611--00018-d69e2e?style=flat-square">
|
<img alt="Version v202611-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="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||||
|
|||||||
+73
-3
@@ -81,6 +81,9 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
|
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
|
||||||
.route("/sessions/{id}/export", get(export_session))
|
.route("/sessions/{id}/export", get(export_session))
|
||||||
.route("/blobs/{hash}", get(get_blob))
|
.route("/blobs/{hash}", get(get_blob))
|
||||||
|
.route("/sessions/{id}/messages", get(session_messages))
|
||||||
|
.route("/sessions/{id}/search", get(search_in_session))
|
||||||
|
.route("/sessions/{id}/media", get(session_media))
|
||||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||||
.route("/sessions/{id}/prompt", post(prompt))
|
.route("/sessions/{id}/prompt", post(prompt))
|
||||||
.route("/sessions/{id}/abort", post(abort))
|
.route("/sessions/{id}/abort", post(abort))
|
||||||
@@ -813,14 +816,81 @@ async fn export_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
#[derive(Deserialize)]
|
||||||
|
struct WindowQ {
|
||||||
|
/// Only the end of the chat (from the last summary on, at most this many messages);
|
||||||
|
/// without it, all messages (terminal client).
|
||||||
|
limit: Option<i64>,
|
||||||
|
before: Option<String>,
|
||||||
|
after: Option<String>,
|
||||||
|
around: Option<String>,
|
||||||
|
/// Loading older messages stops at a summary (the history before it on request).
|
||||||
|
#[serde(default)]
|
||||||
|
stop_at_summary: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowQ {
|
||||||
|
fn window(&self) -> Option<crate::store::MessageWindow> {
|
||||||
|
use crate::store::MessageWindow as W;
|
||||||
|
let limit = self.limit.unwrap_or(200).clamp(1, 1000);
|
||||||
|
Some(match (&self.before, &self.after, &self.around) {
|
||||||
|
(Some(id), _, _) => W::Before { id: id.clone(), limit, stop_at_summary: self.stop_at_summary },
|
||||||
|
(_, Some(id), _) => W::After { id: id.clone(), limit },
|
||||||
|
(_, _, Some(id)) => W::Around { id: id.clone(), limit },
|
||||||
|
_ if self.limit.is_some() => W::Tail { limit },
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<WindowQ>) -> ApiResult {
|
||||||
let s = readable(&app, &user, &id)?;
|
let s = readable(&app, &user, &id)?;
|
||||||
let messages = app.store.messages(&id)?;
|
|
||||||
let busy = app.is_busy(&id);
|
let busy = app.is_busy(&id);
|
||||||
let can_write = app.access(&user, &id) == Access::Full;
|
let can_write = app.access(&user, &id) == Access::Full;
|
||||||
let queue = app.store.queue_list(&id)?;
|
let queue = app.store.queue_list(&id)?;
|
||||||
let step = app.with_runtime(&id, |r| r.step.clone());
|
let step = app.with_runtime(&id, |r| r.step.clone());
|
||||||
Ok(Json(json!({"session": s, "messages": messages, "busy": busy, "can_write": can_write, "queue": queue, "step": step})))
|
let mut out = json!({"session": s, "busy": busy, "can_write": can_write, "queue": queue, "step": step});
|
||||||
|
match q.window() {
|
||||||
|
Some(w) => {
|
||||||
|
let (messages, older, newer) = app.store.messages_window(&id, &w)?;
|
||||||
|
out["messages"] = json!(messages);
|
||||||
|
out["has_older"] = json!(older);
|
||||||
|
out["has_newer"] = json!(newer);
|
||||||
|
}
|
||||||
|
None => out["messages"] = json!(app.store.messages(&id)?),
|
||||||
|
}
|
||||||
|
Ok(Json(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// More messages of a chat: before / after / around a message.
|
||||||
|
async fn session_messages(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<WindowQ>) -> ApiResult {
|
||||||
|
readable(&app, &user, &id)?;
|
||||||
|
let w = q.window().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "before, after, around or limit is required".into()))?;
|
||||||
|
let (messages, older, newer) = app.store.messages_window(&id, &w).map_err(|e| ApiError(StatusCode::NOT_FOUND, format!("{e:#}")))?;
|
||||||
|
Ok(Json(json!({"messages": messages, "has_older": older, "has_newer": newer})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Images, videos and audio files shown in a chat (all of it, with subagents).
|
||||||
|
async fn session_media(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
readable(&app, &user, &id)?;
|
||||||
|
let a = app.clone();
|
||||||
|
let items = tokio::task::spawn_blocking(move || crate::media::collect(&a, &id)).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
|
||||||
|
Ok(Json(json!(items)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search within one chat (all of it, not only the loaded part).
|
||||||
|
async fn search_in_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<SearchQ>) -> ApiResult {
|
||||||
|
readable(&app, &user, &id)?;
|
||||||
|
if q.q.trim().chars().count() < 2 {
|
||||||
|
return Ok(Json(json!([])));
|
||||||
|
}
|
||||||
|
let hits: Vec<Value> = app
|
||||||
|
.store
|
||||||
|
.search_messages(&id, q.q.trim(), 100)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(mid, role, at, snippet)| json!({"id": mid, "role": role, "created_at": at, "snippet": snippet}))
|
||||||
|
.collect();
|
||||||
|
Ok(Json(json!(hits)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ mod lsp;
|
|||||||
mod mentions;
|
mod mentions;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
|
mod media;
|
||||||
mod permission;
|
mod permission;
|
||||||
mod prompts;
|
mod prompts;
|
||||||
mod push;
|
mod push;
|
||||||
|
|||||||
@@ -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 . 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+170
-23
@@ -1,6 +1,6 @@
|
|||||||
//! SQLite persistence for sessions and messages.
|
//! SQLite persistence for sessions and messages.
|
||||||
|
|
||||||
use crate::types::{ImageData, Message, Part, Session, now_ms};
|
use crate::types::{ImageData, Message, Part, Role, Session, now_ms};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -244,28 +244,7 @@ impl Store {
|
|||||||
let (sdata, mdata) = row?;
|
let (sdata, mdata) = row?;
|
||||||
let session: Session = serde_json::from_str(&sdata)?;
|
let session: Session = serde_json::from_str(&sdata)?;
|
||||||
// Snippet from the visible text of the matching message (not JSON keys).
|
// Snippet from the visible text of the matching message (not JSON keys).
|
||||||
let mut snippet = String::new();
|
let snippet = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()).and_then(|m| snippet_in(&m, &needle)).unwrap_or_default();
|
||||||
if let Some(m) = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()) {
|
|
||||||
for p in &m.parts {
|
|
||||||
let text = match p {
|
|
||||||
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
|
|
||||||
Part::ToolResult { content, .. } => content.as_str(),
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
if let Some(i) = text.to_lowercase().find(&needle) {
|
|
||||||
let mut a = i.saturating_sub(60);
|
|
||||||
while !text.is_char_boundary(a) {
|
|
||||||
a -= 1;
|
|
||||||
}
|
|
||||||
let mut b = (i + needle.len() + 100).min(text.len());
|
|
||||||
while !text.is_char_boundary(b) {
|
|
||||||
b += 1;
|
|
||||||
}
|
|
||||||
snippet = format!("{}{}{}", if a > 0 { "…" } else { "" }, text[a..b].replace('\n', " "), if b < text.len() { "…" } else { "" });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if snippet.is_empty() && !session.title.to_lowercase().contains(&needle) {
|
if snippet.is_empty() && !session.title.to_lowercase().contains(&needle) {
|
||||||
continue; // matched only JSON structure (keys, ids)
|
continue; // matched only JSON structure (keys, ids)
|
||||||
}
|
}
|
||||||
@@ -274,6 +253,121 @@ impl Store {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A part of a session's messages for the chat view (long chats are not loaded at
|
||||||
|
/// once). Returns the messages (oldest first) and whether older / newer ones exist.
|
||||||
|
pub fn messages_window(&self, session_id: &str, w: &MessageWindow) -> Result<(Vec<Message>, bool, bool)> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let seq_of = |id: &str| -> Result<i64> {
|
||||||
|
conn.query_row("SELECT seq FROM messages WHERE session_id = ?1 AND id = ?2", params![session_id, id], |r| r.get(0))
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("message {id} not found"))
|
||||||
|
};
|
||||||
|
let (sql, a, limit): (&str, i64, i64) = match w {
|
||||||
|
// From the last summary (compaction) on, at most `limit`.
|
||||||
|
MessageWindow::Tail { limit } => {
|
||||||
|
let from: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT seq FROM messages WHERE session_id = ?1 AND data LIKE '%\"type\":\"summary\"%' ORDER BY seq DESC LIMIT 1",
|
||||||
|
[session_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.unwrap_or(i64::MIN);
|
||||||
|
("SELECT data FROM messages WHERE session_id = ?1 AND seq >= ?2 ORDER BY seq DESC LIMIT ?3", from, *limit)
|
||||||
|
}
|
||||||
|
// Scrolling up stops at a summary (compaction); the history before it on request.
|
||||||
|
MessageWindow::Before { id, limit, stop_at_summary } => {
|
||||||
|
let before = seq_of(id)?;
|
||||||
|
let summary: Option<i64> = if *stop_at_summary {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT seq FROM messages WHERE session_id = ?1 AND seq < ?2 AND data LIKE '%\"type\":\"summary\"%' ORDER BY seq DESC LIMIT 1",
|
||||||
|
params![session_id, before],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
match summary {
|
||||||
|
Some(from) => {
|
||||||
|
let mut stmt = conn.prepare("SELECT seq, data FROM messages WHERE session_id = ?1 AND seq >= ?2 AND seq < ?3 ORDER BY seq DESC LIMIT ?4")?;
|
||||||
|
let mut rows: Vec<(i64, Message)> = Vec::new();
|
||||||
|
for r in stmt.query_map(params![session_id, from, before, limit], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
|
||||||
|
let (seq, data) = r?;
|
||||||
|
rows.push((seq, serde_json::from_str(&data)?));
|
||||||
|
}
|
||||||
|
drop(stmt);
|
||||||
|
return Self::finish_window(&conn, session_id, rows, false);
|
||||||
|
}
|
||||||
|
None => ("SELECT data FROM messages WHERE session_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3", before, *limit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageWindow::After { id, limit } => ("SELECT data FROM messages WHERE session_id = ?1 AND seq > ?2 ORDER BY seq ASC LIMIT ?3", seq_of(id)?, *limit),
|
||||||
|
// The message and about half the limit before it.
|
||||||
|
MessageWindow::Around { id, limit } => {
|
||||||
|
let seq = seq_of(id)?;
|
||||||
|
let before: i64 = conn.query_row(
|
||||||
|
"SELECT COALESCE(MIN(seq), ?2) FROM (SELECT seq FROM messages WHERE session_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3)",
|
||||||
|
params![session_id, seq, limit / 2],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
("SELECT data FROM messages WHERE session_id = ?1 AND seq >= ?2 ORDER BY seq ASC LIMIT ?3", before, *limit)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut rows: Vec<(i64, Message)> = Vec::new();
|
||||||
|
let mut stmt = conn.prepare(&sql.replace("SELECT data", "SELECT seq, data"))?;
|
||||||
|
for r in stmt.query_map(params![session_id, a, limit], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
|
||||||
|
let (seq, data) = r?;
|
||||||
|
rows.push((seq, serde_json::from_str(&data)?));
|
||||||
|
}
|
||||||
|
drop(stmt);
|
||||||
|
Self::finish_window(&conn, session_id, rows, matches!(w, MessageWindow::Tail { .. }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort a window and tell whether older / newer messages exist around it.
|
||||||
|
fn finish_window(conn: &Connection, session_id: &str, mut rows: Vec<(i64, Message)>, tail: bool) -> Result<(Vec<Message>, bool, bool)> {
|
||||||
|
rows.sort_by_key(|(seq, _)| *seq);
|
||||||
|
let exists = |q: &str, seq: i64| -> Result<bool> { Ok(conn.prepare(q)?.exists(params![session_id, seq])?) };
|
||||||
|
let older = match rows.first() {
|
||||||
|
Some((first, _)) => exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq < ?2", *first)?,
|
||||||
|
None => tail && exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq > ?2", i64::MIN)?,
|
||||||
|
};
|
||||||
|
let newer = match rows.last() {
|
||||||
|
Some((last, _)) => exists("SELECT 1 FROM messages WHERE session_id = ?1 AND seq > ?2", *last)?,
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
Ok((rows.into_iter().map(|(_, m)| m).collect(), older, newer))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Messages of one session containing `q` (case-insensitive), newest first:
|
||||||
|
/// (message id, role, created_at, snippet).
|
||||||
|
pub fn search_messages(&self, session_id: &str, q: &str, limit: usize) -> Result<Vec<(String, Role, i64, String)>> {
|
||||||
|
let needle = q.to_lowercase();
|
||||||
|
let like = format!("%{}%", q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"));
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare("SELECT data FROM messages WHERE session_id = ?1 AND data LIKE ?2 ESCAPE '\\' ORDER BY seq DESC")?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for r in stmt.query_map(params![session_id, like], |r| r.get::<_, String>(0))? {
|
||||||
|
let Ok(m) = serde_json::from_str::<Message>(&r?) else { continue };
|
||||||
|
// Only visible text counts (not JSON keys or ids).
|
||||||
|
if let Some(snippet) = snippet_in(&m, &needle) {
|
||||||
|
out.push((m.id.clone(), m.role, m.created_at, snippet));
|
||||||
|
if out.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ids of the subagent sessions started by a session.
|
||||||
|
pub fn child_sessions(&self, id: &str) -> Result<Vec<String>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare("SELECT id FROM sessions WHERE parent_id = ?1")?;
|
||||||
|
let rows = stmt.query_map([id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn delete_session(&self, id: &str) -> Result<()> {
|
pub fn delete_session(&self, id: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
// Child sessions (subagents) go with their parent.
|
// Child sessions (subagents) go with their parent.
|
||||||
@@ -1045,6 +1139,17 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Latest images; `user = None` lists everybody's (admins).
|
/// Latest images; `user = None` lists everybody's (admins).
|
||||||
|
/// Generated images of these sessions.
|
||||||
|
pub fn images_of_sessions(&self, sessions: &[String]) -> Result<Vec<ImageRecord>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(&format!("SELECT {IMAGE_COLS} FROM images WHERE session_id = ?1"))?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for s in sessions {
|
||||||
|
out.extend(stmt.query_map([s], image_row)?.collect::<rusqlite::Result<Vec<_>>>()?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
|
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare(&format!(
|
let mut stmt = conn.prepare(&format!(
|
||||||
@@ -1392,3 +1497,45 @@ pub struct PushSub {
|
|||||||
pub p256dh: String,
|
pub p256dh: String,
|
||||||
pub auth: String,
|
pub auth: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which messages of a session the chat view loads (see Store::messages_window).
|
||||||
|
pub enum MessageWindow {
|
||||||
|
Tail { limit: i64 },
|
||||||
|
Before { id: String, limit: i64, stop_at_summary: bool },
|
||||||
|
After { id: String, limit: i64 },
|
||||||
|
Around { id: String, limit: i64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text around the first occurrence of `needle` (lowercase) in the visible text of a
|
||||||
|
/// message: its text, reasoning, summaries, tool calls and tool output.
|
||||||
|
fn snippet_in(m: &Message, needle: &str) -> Option<String> {
|
||||||
|
for p in &m.parts {
|
||||||
|
let owned;
|
||||||
|
let text = match p {
|
||||||
|
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
|
||||||
|
Part::ToolResult { content, .. } => content.as_str(),
|
||||||
|
Part::ToolCall { input, .. } => {
|
||||||
|
owned = input.to_string();
|
||||||
|
owned.as_str()
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let lower = text.to_lowercase();
|
||||||
|
// Lowercasing can change byte lengths (rare): then cut the lowercase text.
|
||||||
|
let (src, i) = match lower.find(needle) {
|
||||||
|
Some(i) if lower.len() == text.len() => (text, i),
|
||||||
|
Some(i) => (lower.as_str(), i),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let mut a = i.saturating_sub(60);
|
||||||
|
while !src.is_char_boundary(a) {
|
||||||
|
a -= 1;
|
||||||
|
}
|
||||||
|
let mut b = (i + needle.len() + 100).min(src.len());
|
||||||
|
while !src.is_char_boundary(b) {
|
||||||
|
b += 1;
|
||||||
|
}
|
||||||
|
return Some(format!("{}{}{}", if a > 0 { "…" } else { "" }, src[a..b].replace('\n', " "), if b < src.len() { "…" } else { "" }));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
+364
-70
@@ -1,12 +1,12 @@
|
|||||||
import { takePrefill } from "../share";
|
import { takePrefill } from "../share";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||||
import { del, get, patch, post } from "../api";
|
import { del, get, patch, post } from "../api";
|
||||||
import { useFetch, useModels } from "../hooks";
|
import { useFetch, useModels } from "../hooks";
|
||||||
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
import { dismissError, getState, loadAround, loadOlder, loadSession, loadSessions, useStore } from "../store";
|
||||||
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
|
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
|
||||||
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
||||||
import { useReasoningMode, useTodosCollapsed } from "../prefs";
|
import { useReasoningMode, useTodosCollapsed } from "../prefs";
|
||||||
@@ -26,8 +26,10 @@ import { ShellsChip } from "./ShellsPanel";
|
|||||||
import { TodoList, ToolCard } from "./ToolCard";
|
import { TodoList, ToolCard } from "./ToolCard";
|
||||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
||||||
|
import { MediaGallery } from "./MediaGallery";
|
||||||
|
|
||||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||||
|
type ToolCall = Extract<Part, { type: "tool_call" }>;
|
||||||
|
|
||||||
const EMPTY: Message[] = [];
|
const EMPTY: Message[] = [];
|
||||||
|
|
||||||
@@ -166,10 +168,299 @@ function RefineFeedback({ text }: { text: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Search the whole chat on the server; a hit loads its part of the chat. */
|
||||||
|
function ChatSearch({ sessionId, onPick, onClose }: { sessionId: string; onPick: (messageId: string) => void; onClose: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [hits, setHits] = useState<{ id: string; role: string; created_at: number; snippet: string }[]>();
|
||||||
|
useEffect(() => {
|
||||||
|
const query = q.trim();
|
||||||
|
if (query.length < 2) return setHits(undefined);
|
||||||
|
let alive = true;
|
||||||
|
const h = setTimeout(() => {
|
||||||
|
get<typeof hits>(`/sessions/${sessionId}/search?q=${encodeURIComponent(query)}`)
|
||||||
|
.then((r) => alive && setHits(r ?? []))
|
||||||
|
.catch(() => alive && setHits([]));
|
||||||
|
}, 250);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearTimeout(h);
|
||||||
|
};
|
||||||
|
}, [q, sessionId]);
|
||||||
|
return (
|
||||||
|
<div className="chat-search" role="search">
|
||||||
|
<div className="row">
|
||||||
|
<Icon name="search" />
|
||||||
|
<input autoFocus value={q} placeholder={t("chat.searchPlaceholder")} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Escape" && onClose()} aria-label={t("chat.search")} />
|
||||||
|
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{hits && (
|
||||||
|
<div className="chat-search-hits">
|
||||||
|
<div className="hint">{hits.length ? t("chat.searchHits", { n: hits.length }) : t("chat.searchNone")}</div>
|
||||||
|
{hits.map((h) => (
|
||||||
|
<button key={h.id} onClick={() => onPick(h.id)}>
|
||||||
|
<span className="who">
|
||||||
|
{t(h.role === "assistant" ? "chat.agent" : "chat.you")} · {new Date(h.created_at).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
|
||||||
|
</span>
|
||||||
|
<span className="snippet">{h.snippet}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A part of the chat: a user message, a summary, or the work of the agent after it. */
|
||||||
|
type Block = { kind: "user" | "summary" | "work"; messages: Message[] };
|
||||||
|
|
||||||
|
const hasUserText = (m: Message) => m.role === "user" && m.parts.some((p) => (p.type === "text" && !isHarnessNote(p.text)) || p.type === "image");
|
||||||
|
|
||||||
|
/** Group the messages into user messages and the agent's work between them. */
|
||||||
|
function blocksOf(messages: Message[]): Block[] {
|
||||||
|
const out: Block[] = [];
|
||||||
|
for (const m of messages) {
|
||||||
|
const kind = m.parts.some((p) => p.type === "summary") ? "summary" : hasUserText(m) ? "user" : "work";
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (kind === "work" && last?.kind === "work") last.messages.push(m);
|
||||||
|
else out.push({ kind, messages: [m] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The messages of a chat. Memoised: typing, streaming and status changes of the
|
||||||
|
* chat view do not touch it – only new messages, tool results or the busy state.
|
||||||
|
*/
|
||||||
|
const MessageList = memo(function MessageList({
|
||||||
|
messages,
|
||||||
|
results,
|
||||||
|
busy,
|
||||||
|
canEdit,
|
||||||
|
canRegenerate,
|
||||||
|
sessionId,
|
||||||
|
model,
|
||||||
|
baseDir,
|
||||||
|
onRewind,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
messages: Message[];
|
||||||
|
results: Map<string, ToolResult>;
|
||||||
|
busy: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
canRegenerate: boolean;
|
||||||
|
sessionId: string;
|
||||||
|
model: string;
|
||||||
|
baseDir: string;
|
||||||
|
onRewind: (m: Message) => void;
|
||||||
|
highlight?: string;
|
||||||
|
}) {
|
||||||
|
const blocks = useMemo(() => blocksOf(messages), [messages]);
|
||||||
|
const lastWork = blocks.map((b) => b.kind).lastIndexOf("work");
|
||||||
|
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")?.id;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{blocks.map((b, i) => {
|
||||||
|
// Stable while older messages are loaded above (they may join the first block):
|
||||||
|
// the last message; the block still growing at the end keeps its first one.
|
||||||
|
const key = b.kind === "work" && i !== lastWork ? b.messages[b.messages.length - 1].id : b.messages[0].id;
|
||||||
|
if (b.kind !== "work") return <MessageItem key={key} m={b.messages[0]} canEdit={canEdit} onRewind={onRewind} highlight={highlight} />;
|
||||||
|
const items = b.messages.map((m) => (
|
||||||
|
<AssistantOrNote
|
||||||
|
key={m.id}
|
||||||
|
m={m}
|
||||||
|
results={results}
|
||||||
|
busy={busy}
|
||||||
|
baseDir={baseDir}
|
||||||
|
highlight={highlight}
|
||||||
|
regenerate={canRegenerate && m.id === lastAssistant ? { sessionId, model } : undefined}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
// Finished turns with tool steps fold into one line (the answer stays visible).
|
||||||
|
const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[];
|
||||||
|
if (i === lastWork || steps.length < 2) return <div key={key}>{items}</div>;
|
||||||
|
return <WorkBlock key={key} messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>{items}</WorkBlock>;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A finished turn: "42 steps · 3 files" and the answer; the steps on click. */
|
||||||
|
function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messages: Message[]; steps: ToolCall[]; open: boolean; baseDir: string; children: ReactNode }) {
|
||||||
|
const t = useT();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const files = new Set(steps.filter((c) => ["write", "edit"].includes(c.name) && typeof c.input?.path === "string").map((c) => c.input.path as string));
|
||||||
|
// The answer: text of the last assistant message of the turn.
|
||||||
|
const answer = [...messages].reverse().find((m) => m.role === "assistant" && m.parts.some((p) => p.type === "text" && p.text.trim()));
|
||||||
|
const answerText = answer ? (answer.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n") : "";
|
||||||
|
if (open || forced) {
|
||||||
|
return (
|
||||||
|
<div className="work-block open">
|
||||||
|
<button className="work-head" onClick={() => setOpen(false)} aria-expanded>
|
||||||
|
<Icon name="chevron-down" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
|
||||||
|
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
|
||||||
|
</button>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="msg work-block" data-mid={answer?.id}>
|
||||||
|
<button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}>
|
||||||
|
<Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
|
||||||
|
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
|
||||||
|
</button>
|
||||||
|
{answerText && (
|
||||||
|
<>
|
||||||
|
<Markdown text={answerText} baseDir={baseDir} />
|
||||||
|
<ImageRefs text={answerText} baseDir={baseDir} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An assistant message, or a user message that only carries tool results / harness notes. */
|
||||||
|
const AssistantOrNote = memo(function AssistantOrNote({
|
||||||
|
m,
|
||||||
|
results,
|
||||||
|
busy,
|
||||||
|
baseDir,
|
||||||
|
highlight,
|
||||||
|
regenerate,
|
||||||
|
}: {
|
||||||
|
m: Message;
|
||||||
|
results: Map<string, ToolResult>;
|
||||||
|
busy: boolean;
|
||||||
|
baseDir: string;
|
||||||
|
highlight?: string;
|
||||||
|
regenerate?: { sessionId: string; model: string };
|
||||||
|
}) {
|
||||||
|
const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
|
||||||
|
if (m.role === "assistant") {
|
||||||
|
return (
|
||||||
|
<div className={cls} data-mid={m.id}>
|
||||||
|
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
|
||||||
|
{regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const notes = (m.parts.filter((p) => p.type === "text") as { text: string }[]).filter((p) => isHarnessNote(p.text));
|
||||||
|
if (!notes.length) return null;
|
||||||
|
return (
|
||||||
|
<div className={cls} data-mid={m.id}>
|
||||||
|
{notes.map((p, i) => (
|
||||||
|
<HarnessMessage key={i} text={p.text} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A user message or a summary of the history before it. */
|
||||||
|
const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight }: { m: Message; canEdit: boolean; onRewind: (m: Message) => void; highlight?: string }) {
|
||||||
|
const t = useT();
|
||||||
|
const [summaryOpen, setSummaryOpen] = useState(false);
|
||||||
|
const summary = m.parts.find((p) => p.type === "summary");
|
||||||
|
if (summary && summary.type === "summary") {
|
||||||
|
return (
|
||||||
|
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
|
||||||
|
<summary>
|
||||||
|
{summary.auto
|
||||||
|
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
||||||
|
: summary.tokens
|
||||||
|
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
||||||
|
: t("chat.compacted")}
|
||||||
|
</summary>
|
||||||
|
{summaryOpen && <Markdown text={summary.text} />}
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||||||
|
const background = allTexts.filter((p) => isHarnessNote(p.text));
|
||||||
|
const texts = allTexts.filter((p) => !isHarnessNote(p.text));
|
||||||
|
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
||||||
|
return (
|
||||||
|
<div className={`msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}>
|
||||||
|
{background.map((p, i) => (
|
||||||
|
<HarnessMessage key={i} text={p.text} />
|
||||||
|
))}
|
||||||
|
<div className="msg-user">
|
||||||
|
{canEdit && (
|
||||||
|
<div className="msg-actions">
|
||||||
|
<button className="small ghost" onClick={() => onRewind(m)} title={t("chat.rewind")}>
|
||||||
|
<Icon name="edit" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<UserText text={texts.map((p) => p.text).join("\n\n")} />
|
||||||
|
{imgs.length > 0 && (
|
||||||
|
<div className="user-files">
|
||||||
|
{imgs.map((p, i) => (
|
||||||
|
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
|
||||||
|
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
|
||||||
|
</FileFigure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Older messages: loaded automatically when scrolled to the top; the history before a
|
||||||
|
* summary only on request. Keeps the scroll position while the list grows upwards.
|
||||||
|
*/
|
||||||
|
function OlderLoader({ sessionId, afterSummary, scrollRef }: { sessionId: string; afterSummary?: boolean; scrollRef: RefObject<HTMLDivElement | null> }) {
|
||||||
|
const t = useT();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const sentinel = useRef<HTMLDivElement>(null);
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (loading || !el) return;
|
||||||
|
setLoading(true);
|
||||||
|
const before = { height: el.scrollHeight, top: el.scrollTop };
|
||||||
|
try {
|
||||||
|
// Scrolling stops at a summary; its button loads the history before it.
|
||||||
|
await loadOlder(sessionId, !afterSummary);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.scrollTop = before.top + (el.scrollHeight - before.height);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [loading, sessionId, scrollRef, afterSummary]);
|
||||||
|
useEffect(() => {
|
||||||
|
const node = sentinel.current;
|
||||||
|
if (!node || afterSummary || typeof IntersectionObserver === "undefined") return;
|
||||||
|
const io = new IntersectionObserver((e) => e[0]?.isIntersecting && void load(), { root: scrollRef.current, rootMargin: "300px 0px 0px 0px" });
|
||||||
|
io.observe(node);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [afterSummary, load, scrollRef]);
|
||||||
|
return (
|
||||||
|
<div ref={sentinel} className="older-loader">
|
||||||
|
{loading ? (
|
||||||
|
<Spinner small />
|
||||||
|
) : (
|
||||||
|
<button className="small ghost" onClick={() => void load()}>
|
||||||
|
<Icon name="arrow-up" /> {t(afterSummary ? "chat.showBeforeSummary" : "chat.loadOlder")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) {
|
export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const session = useStore((s) => s.sessionById[sessionId]);
|
const session = useStore((s) => s.sessionById[sessionId]);
|
||||||
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
|
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
|
||||||
|
const win = useStore((s) => s.windows[sessionId]);
|
||||||
|
// Message to show (search hit, media): scrolled into view and marked.
|
||||||
|
const [highlight, setHighlight] = useState<string>();
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [gallery, setGallery] = useState(false);
|
||||||
const draft = useStore((s) => s.drafts[sessionId]);
|
const draft = useStore((s) => s.drafts[sessionId]);
|
||||||
const busy = useStore((s) => !!s.busy[sessionId]);
|
const busy = useStore((s) => !!s.busy[sessionId]);
|
||||||
const errors = useStore((s) => s.errors[sessionId]);
|
const errors = useStore((s) => s.errors[sessionId]);
|
||||||
@@ -226,11 +517,36 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
return m;
|
return m;
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
// Keep scrolled to the bottom while the user hasn't scrolled up.
|
// Keep scrolled to the bottom while the user hasn't scrolled up (only when the
|
||||||
|
// content changes – reading the height forces a layout).
|
||||||
|
const permCount = permissions.length + questions.length;
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||||
});
|
}, [messages, draft, busy, waiting, compacting, permCount, errors]);
|
||||||
|
|
||||||
|
// Stable callbacks for the (memoised) message list.
|
||||||
|
const rewindRef = useRef<(m: Message) => void>(() => undefined);
|
||||||
|
const onRewind = useCallback((m: Message) => rewindRef.current(m), []);
|
||||||
|
const showLatest = useCallback(async () => {
|
||||||
|
setHighlight(undefined);
|
||||||
|
await loadSession(sessionId);
|
||||||
|
follow(true);
|
||||||
|
}, [sessionId]);
|
||||||
|
/** Load the part of the chat around a message and scroll to it. */
|
||||||
|
const jumpTo = useCallback(
|
||||||
|
async (messageId: string) => {
|
||||||
|
follow(false);
|
||||||
|
if (!getState().messages[sessionId]?.some((m) => m.id === messageId)) await loadAround(sessionId, messageId);
|
||||||
|
setHighlight(messageId);
|
||||||
|
},
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlight) return;
|
||||||
|
const h = setTimeout(() => document.querySelector(`[data-mid="${highlight}"]`)?.scrollIntoView({ block: "center" }), 50);
|
||||||
|
return () => clearTimeout(h);
|
||||||
|
}, [highlight, messages]);
|
||||||
// … also when the pane gets smaller (on-screen keyboard opens) without a render.
|
// … also when the pane gets smaller (on-screen keyboard opens) without a render.
|
||||||
const hasSession = !!session;
|
const hasSession = !!session;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -280,9 +596,10 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const lastAssistantIdx = messages.reduce((acc, m, i) => (m.role === "assistant" ? i : acc), -1);
|
|
||||||
const [rewinding, setRewinding] = useState<Message>();
|
const [rewinding, setRewinding] = useState<Message>();
|
||||||
const rewind = (m: Message) => setRewinding(m);
|
const rewind = (m: Message) => setRewinding(m);
|
||||||
|
rewindRef.current = rewind;
|
||||||
const rewindDone = async () => {
|
const rewindDone = async () => {
|
||||||
const m = rewinding!;
|
const m = rewinding!;
|
||||||
setRewinding(undefined);
|
setRewinding(undefined);
|
||||||
@@ -371,6 +688,9 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
>
|
>
|
||||||
<Icon name="terminal" />
|
<Icon name="terminal" />
|
||||||
</button>
|
</button>
|
||||||
|
<button className={`small ghost ${searching ? "active" : ""}`} onClick={() => setSearching(!searching)} title={t("chat.search")} aria-label={t("chat.search")}>
|
||||||
|
<Icon name="search" />
|
||||||
|
</button>
|
||||||
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
|
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
|
||||||
<Icon name="folder" />
|
<Icon name="folder" />
|
||||||
</button>
|
</button>
|
||||||
@@ -394,6 +714,16 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
>
|
>
|
||||||
<Icon name="edit" /> {t("instructions.title")}
|
<Icon name="edit" /> {t("instructions.title")}
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
|
||||||
|
setGallery(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="image" /> {t("media.title")}
|
||||||
|
</a>
|
||||||
{telegramPaired && !session.parent_id && (
|
{telegramPaired && !session.parent_id && (
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
@@ -447,6 +777,17 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{gallery && (
|
||||||
|
<MediaGallery
|
||||||
|
sessionId={sessionId}
|
||||||
|
onClose={() => setGallery(false)}
|
||||||
|
onJump={(id) => {
|
||||||
|
setGallery(false);
|
||||||
|
void jumpTo(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{searching && <ChatSearch sessionId={sessionId} onPick={(id) => void jumpTo(id)} onClose={() => setSearching(false)} />}
|
||||||
<ChatTabBar state={tabs} />
|
<ChatTabBar state={tabs} />
|
||||||
<ChatTabPanes state={tabs} />
|
<ChatTabPanes state={tabs} />
|
||||||
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
|
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
|
||||||
@@ -472,71 +813,24 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
<div className="chat">
|
<div className="chat">
|
||||||
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
|
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
|
||||||
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
|
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
|
||||||
{messages.map((m, idx) => {
|
{win?.older && <OlderLoader sessionId={sessionId} afterSummary={messages[0]?.parts.some((p) => p.type === "summary")} scrollRef={scrollRef} />}
|
||||||
if (m.role === "assistant") {
|
<MessageList
|
||||||
const lastAnswer = idx === lastAssistantIdx && !busy && !readOnly && !session.parent_id;
|
messages={messages}
|
||||||
return (
|
results={results}
|
||||||
<div key={m.id} className="msg">
|
busy={busy}
|
||||||
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={session.cwd} />
|
canEdit={!busy && !readOnly}
|
||||||
{lastAnswer && <RegenerateButton sessionId={sessionId} messageId={m.id} model={session.model} />}
|
canRegenerate={!busy && !readOnly && !session.parent_id && !win?.newer}
|
||||||
</div>
|
sessionId={sessionId}
|
||||||
);
|
model={session.model}
|
||||||
}
|
baseDir={session.cwd}
|
||||||
const summary = m.parts.find((p) => p.type === "summary");
|
onRewind={onRewind}
|
||||||
if (summary && summary.type === "summary") {
|
highlight={highlight}
|
||||||
return (
|
/>
|
||||||
<details key={m.id} className="summary-divider">
|
{win?.newer && (
|
||||||
<summary>
|
<button className="back-to-latest" onClick={() => void showLatest()}>
|
||||||
{summary.auto
|
<Icon name="arrow-down" /> {t("chat.backToLatest")}
|
||||||
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
</button>
|
||||||
: summary.tokens
|
)}
|
||||||
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
|
||||||
: t("chat.compacted")}
|
|
||||||
</summary>
|
|
||||||
<Markdown text={summary.text} />
|
|
||||||
</details>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
|
||||||
const background = allTexts.filter((p) => isHarnessNote(p.text));
|
|
||||||
const texts = allTexts.filter((p) => !isHarnessNote(p.text));
|
|
||||||
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
|
||||||
if (texts.length === 0 && imgs.length === 0) {
|
|
||||||
return background.length ? (
|
|
||||||
<div key={m.id} className="msg">
|
|
||||||
{background.map((p, i) => (
|
|
||||||
<HarnessMessage key={i} text={p.text} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div key={m.id} className="msg">
|
|
||||||
{background.map((p, i) => (
|
|
||||||
<HarnessMessage key={i} text={p.text} />
|
|
||||||
))}
|
|
||||||
<div className="msg-user">
|
|
||||||
{!busy && !readOnly && (
|
|
||||||
<div className="msg-actions">
|
|
||||||
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
|
|
||||||
<Icon name="edit" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<UserText text={texts.map((p) => p.text).join("\n\n")} />
|
|
||||||
{imgs.length > 0 && (
|
|
||||||
<div className="user-files">
|
|
||||||
{imgs.map((p, i) => (
|
|
||||||
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
|
|
||||||
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
|
|
||||||
</FileFigure>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{draft && (
|
{draft && (
|
||||||
<div className="msg">
|
<div className="msg">
|
||||||
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
||||||
|
|||||||
@@ -125,6 +125,12 @@ const P: Record<string, ReactNode> = {
|
|||||||
<path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1" />
|
<path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
search: (
|
||||||
|
<>
|
||||||
|
<circle cx="11" cy="11" r="6.5" />
|
||||||
|
<path d="m16 16 4.5 4.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
eye: (
|
eye: (
|
||||||
<>
|
<>
|
||||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
|
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
|
||||||
|
import { useState } from "react";
|
||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
@@ -68,6 +69,8 @@ export function TodoList({ todos }: { todos: Todo[] }) {
|
|||||||
|
|
||||||
export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; result?: ToolResult; running: boolean; baseDir?: string }) {
|
export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; result?: ToolResult; running: boolean; baseDir?: string }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
// The body (diff, output) is only built while the card is open – long chats have thousands.
|
||||||
|
const [toggled, setToggled] = useState<boolean | null>(null);
|
||||||
const progress = useStore((s) => s.progress[call.id]);
|
const progress = useStore((s) => s.progress[call.id]);
|
||||||
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
|
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
|
||||||
const statusText = result ? <Icon name={result.is_error ? "x-mark" : "check"} /> : running ? "…" : "–";
|
const statusText = result ? <Icon name={result.is_error ? "x-mark" : "check"} /> : running ? "…" : "–";
|
||||||
@@ -119,9 +122,13 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
|||||||
const openByDefault =
|
const openByDefault =
|
||||||
(status === "err" && !!result) ||
|
(status === "err" && !!result) ||
|
||||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image"));
|
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image"));
|
||||||
|
const open = toggled ?? openByDefault;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details className={`tool ${status === "run" ? "running" : ""}`} open={openByDefault}>
|
<details className={`tool ${status === "run" ? "running" : ""}`} open={open} onToggle={(e) => {
|
||||||
|
const now = (e.currentTarget as HTMLDetailsElement).open;
|
||||||
|
if (now !== open) setToggled(now);
|
||||||
|
}}>
|
||||||
<summary>
|
<summary>
|
||||||
<span className="tool-name">{call.name}</span>
|
<span className="tool-name">{call.name}</span>
|
||||||
<span className="tool-title">{toolTitle(call.name, call.input)}</span>
|
<span className="tool-title">{toolTitle(call.name, call.input)}</span>
|
||||||
@@ -130,6 +137,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
|||||||
{call.name === "task" && call.input?.background && <span className="chip">{t("tool.background")}</span>}
|
{call.name === "task" && call.input?.background && <span className="chip">{t("tool.background")}</span>}
|
||||||
{status === "run" ? <Spinner small /> : <span className={`status ${status}`}>{statusText}</span>}
|
{status === "run" ? <Spinner small /> : <span className={`status ${status}`}>{statusText}</span>}
|
||||||
</summary>
|
</summary>
|
||||||
|
{open && (
|
||||||
<div className="tool-body">
|
<div className="tool-body">
|
||||||
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
|
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
|
||||||
{(call.name === "edit" || call.name === "write") && meta?.diff && <Diff diff={meta.diff} />}
|
{(call.name === "edit" || call.name === "write") && meta?.diff && <Diff diff={meta.diff} />}
|
||||||
@@ -186,6 +194,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -338,6 +338,33 @@ export const de: Messages = {
|
|||||||
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:",
|
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:",
|
||||||
"agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten",
|
"agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten",
|
||||||
"agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.",
|
"agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.",
|
||||||
|
"chat.workSteps": "{n} Schritte",
|
||||||
|
"chat.workFiles": "{n} Dateien",
|
||||||
|
"chat.loadOlder": "Ältere Nachrichten laden",
|
||||||
|
"chat.showBeforeSummary": "Verlauf vor der Zusammenfassung anzeigen",
|
||||||
|
"chat.backToLatest": "Zu den neuesten Nachrichten",
|
||||||
|
"chat.search": "Im Chat suchen",
|
||||||
|
"chat.searchPlaceholder": "Nachrichten, Tool-Ausgaben … durchsuchen",
|
||||||
|
"chat.searchNone": "Keine Treffer",
|
||||||
|
"chat.searchHits": "{n} Treffer",
|
||||||
|
"chat.you": "Du",
|
||||||
|
"chat.agent": "Agent",
|
||||||
|
"media.title": "Medien",
|
||||||
|
"media.none": "Noch keine Bilder, Videos oder Audiodateien in diesem Chat.",
|
||||||
|
"media.filter.all": "Alle",
|
||||||
|
"media.filter.image": "Bilder",
|
||||||
|
"media.filter.video": "Videos",
|
||||||
|
"media.filter.audio": "Audio",
|
||||||
|
"media.source.generated": "generiert",
|
||||||
|
"media.source.tool": "Tool-Ausgabe",
|
||||||
|
"media.source.written": "vom Agenten geschrieben",
|
||||||
|
"media.source.answer": "in einer Antwort",
|
||||||
|
"media.source.upload": "dein Anhang",
|
||||||
|
"media.showInChat": "Im Chat zeigen",
|
||||||
|
"media.open": "Öffnen",
|
||||||
|
"media.prev": "Vorheriges",
|
||||||
|
"media.next": "Nächstes",
|
||||||
|
"media.back": "Zurück zur Übersicht",
|
||||||
"chat.reviewRound": "Review {round}: Änderungen nötig",
|
"chat.reviewRound": "Review {round}: Änderungen nötig",
|
||||||
"agents.refine": "Review-Schleife",
|
"agents.refine": "Review-Schleife",
|
||||||
"agents.refineRounds": "Review-Runden pro Aufgabe",
|
"agents.refineRounds": "Review-Runden pro Aufgabe",
|
||||||
|
|||||||
@@ -337,6 +337,33 @@ export const en = {
|
|||||||
"chat.todoReminder": "Back to work – {n} open todos:",
|
"chat.todoReminder": "Back to work – {n} open todos:",
|
||||||
"agents.refineAfterTodos": "Review every completed todo and work until the list is done",
|
"agents.refineAfterTodos": "Review every completed todo and work until the list is done",
|
||||||
"agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.",
|
"agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.",
|
||||||
|
"chat.workSteps": "{n} steps",
|
||||||
|
"chat.workFiles": "{n} files",
|
||||||
|
"chat.loadOlder": "Load older messages",
|
||||||
|
"chat.showBeforeSummary": "Show the history before the summary",
|
||||||
|
"chat.backToLatest": "Back to the latest messages",
|
||||||
|
"chat.search": "Search in this chat",
|
||||||
|
"chat.searchPlaceholder": "Search messages, tool output …",
|
||||||
|
"chat.searchNone": "No matches",
|
||||||
|
"chat.searchHits": "{n} matches",
|
||||||
|
"chat.you": "You",
|
||||||
|
"chat.agent": "Agent",
|
||||||
|
"media.title": "Media",
|
||||||
|
"media.none": "No images, videos or audio in this chat yet.",
|
||||||
|
"media.filter.all": "All",
|
||||||
|
"media.filter.image": "Images",
|
||||||
|
"media.filter.video": "Videos",
|
||||||
|
"media.filter.audio": "Audio",
|
||||||
|
"media.source.generated": "generated",
|
||||||
|
"media.source.tool": "tool output",
|
||||||
|
"media.source.written": "written by the agent",
|
||||||
|
"media.source.answer": "in an answer",
|
||||||
|
"media.source.upload": "your attachment",
|
||||||
|
"media.showInChat": "Show in chat",
|
||||||
|
"media.open": "Open",
|
||||||
|
"media.prev": "Previous",
|
||||||
|
"media.next": "Next",
|
||||||
|
"media.back": "Back to the overview",
|
||||||
"chat.reviewRound": "Review {round}: changes requested",
|
"chat.reviewRound": "Review {round}: changes requested",
|
||||||
"agents.refine": "Review loop",
|
"agents.refine": "Review loop",
|
||||||
"agents.refineRounds": "Review rounds per task",
|
"agents.refineRounds": "Review rounds per task",
|
||||||
|
|||||||
+39
-2
@@ -32,6 +32,8 @@ export interface State {
|
|||||||
/** Includes subagent sessions loaded on demand. */
|
/** Includes subagent sessions loaded on demand. */
|
||||||
sessionById: Record<string, Session>;
|
sessionById: Record<string, Session>;
|
||||||
messages: Record<string, Message[]>;
|
messages: Record<string, Message[]>;
|
||||||
|
/** Long chats are loaded in parts: are there older / newer messages than the loaded ones? */
|
||||||
|
windows: Record<string, { older: boolean; newer: boolean } | undefined>;
|
||||||
/** Sessions the current user may only read (shared with them). */
|
/** Sessions the current user may only read (shared with them). */
|
||||||
readOnly: Record<string, boolean>;
|
readOnly: Record<string, boolean>;
|
||||||
drafts: Record<string, Draft | undefined>;
|
drafts: Record<string, Draft | undefined>;
|
||||||
@@ -58,6 +60,7 @@ let state: State = {
|
|||||||
sessions: [],
|
sessions: [],
|
||||||
sessionById: {},
|
sessionById: {},
|
||||||
messages: {},
|
messages: {},
|
||||||
|
windows: {},
|
||||||
readOnly: {},
|
readOnly: {},
|
||||||
drafts: {},
|
drafts: {},
|
||||||
busy: {},
|
busy: {},
|
||||||
@@ -130,7 +133,8 @@ export function applyEvent(ev: ServerEvent) {
|
|||||||
setState((s) => {
|
setState((s) => {
|
||||||
const list = s.messages[ev.session_id];
|
const list = s.messages[ev.session_id];
|
||||||
const drafts = s.drafts[ev.session_id]?.id === ev.message.id ? { ...s.drafts, [ev.session_id]: undefined } : s.drafts;
|
const drafts = s.drafts[ev.session_id]?.id === ev.message.id ? { ...s.drafts, [ev.session_id]: undefined } : s.drafts;
|
||||||
if (!list) return { drafts };
|
// Viewing an older part of the chat: the new message comes with "back to the end".
|
||||||
|
if (!list || s.windows[ev.session_id]?.newer) return { drafts };
|
||||||
if (list.some((m) => m.id === ev.message.id)) return { drafts };
|
if (list.some((m) => m.id === ev.message.id)) return { drafts };
|
||||||
return { messages: { ...s.messages, [ev.session_id]: [...list, ev.message] }, drafts };
|
return { messages: { ...s.messages, [ev.session_id]: [...list, ev.message] }, drafts };
|
||||||
});
|
});
|
||||||
@@ -231,9 +235,17 @@ export async function loadSessions() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Messages loaded when a chat opens (from the last summary on, at most this many). */
|
||||||
|
export const PAGE = 200;
|
||||||
|
|
||||||
|
type Page = { messages: Message[]; has_older: boolean; has_newer: boolean };
|
||||||
|
|
||||||
export async function loadSession(id: string) {
|
export async function loadSession(id: string) {
|
||||||
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any }>(`/sessions/${id}`);
|
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any; has_older?: boolean; has_newer?: boolean }>(
|
||||||
|
`/sessions/${id}?limit=${PAGE}`,
|
||||||
|
);
|
||||||
setState((s) => ({
|
setState((s) => ({
|
||||||
|
windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } },
|
||||||
...upsertSession(s, data.session),
|
...upsertSession(s, data.session),
|
||||||
sessionById: { ...s.sessionById, [id]: data.session },
|
sessionById: { ...s.sessionById, [id]: data.session },
|
||||||
messages: { ...s.messages, [id]: data.messages },
|
messages: { ...s.messages, [id]: data.messages },
|
||||||
@@ -247,6 +259,31 @@ export async function loadSession(id: string) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Older messages before the loaded ones (scrolling up). Returns how many came. */
|
||||||
|
export async function loadOlder(id: string, stopAtSummary = true): Promise<number> {
|
||||||
|
const first = getState().messages[id]?.[0];
|
||||||
|
if (!first) return 0;
|
||||||
|
const page = await get<Page>(`/sessions/${id}/messages?before=${encodeURIComponent(first.id)}&limit=${PAGE}${stopAtSummary ? "&stop_at_summary=true" : ""}`);
|
||||||
|
setState((s) => {
|
||||||
|
const cur = s.messages[id] ?? [];
|
||||||
|
if (cur[0]?.id !== first.id) return {}; // changed meanwhile
|
||||||
|
return {
|
||||||
|
messages: { ...s.messages, [id]: [...page.messages, ...cur] },
|
||||||
|
windows: { ...s.windows, [id]: { older: page.has_older, newer: s.windows[id]?.newer ?? false } },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return page.messages.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show the part of the chat around a message (search hit, media). */
|
||||||
|
export async function loadAround(id: string, messageId: string) {
|
||||||
|
const page = await get<Page>(`/sessions/${id}/messages?around=${encodeURIComponent(messageId)}&limit=${PAGE}`);
|
||||||
|
setState((s) => ({
|
||||||
|
messages: { ...s.messages, [id]: page.messages },
|
||||||
|
windows: { ...s.windows, [id]: { older: page.has_older, newer: page.has_newer } },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** After reconnects: reload everything that could have changed. */
|
/** After reconnects: reload everything that could have changed. */
|
||||||
export async function resync() {
|
export async function resync() {
|
||||||
const isAdmin = getState().me?.role === "admin";
|
const isAdmin = getState().me?.role === "admin";
|
||||||
|
|||||||
@@ -4185,3 +4185,216 @@ span.chat-tab > button {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- long chats ------------------------------------------------------------------
|
||||||
|
* Finished turns fold into one line, older messages load on demand, search hits. */
|
||||||
|
.work-block > .work-head {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 2px 0;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.work-block > .work-head:hover {
|
||||||
|
color: var(--fg1);
|
||||||
|
}
|
||||||
|
.work-block.open {
|
||||||
|
border-left: 2px solid var(--bg2);
|
||||||
|
padding-left: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.older-loader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 0 12px;
|
||||||
|
}
|
||||||
|
.back-to-latest {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 10px auto 0;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.msg.highlight,
|
||||||
|
.summary-divider[data-mid].highlight {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.chat-search {
|
||||||
|
border-bottom: 1px solid var(--bg2);
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--bg0);
|
||||||
|
}
|
||||||
|
.chat-search > .row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.chat-search > .row input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.chat-search-hits {
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.chat-search-hits button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.chat-search-hits button:hover {
|
||||||
|
background: var(--bg1);
|
||||||
|
}
|
||||||
|
.chat-search-hits .who {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.chat-search-hits .snippet {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--fg1);
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- media overview of a chat --------------------------------------------------- */
|
||||||
|
.media-gallery {
|
||||||
|
width: min(1100px, calc(100vw - 24px));
|
||||||
|
height: min(820px, calc(100dvh - 24px));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.media-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.media-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.media-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.media-filters .chip-btn {
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.media-filters small {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
.media-grid {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
grid-auto-rows: 150px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.media-tile {
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--bg3);
|
||||||
|
}
|
||||||
|
.media-tile img,
|
||||||
|
.media-tile video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.media-tile .media-play {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
margin: auto;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
color: #fff;
|
||||||
|
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
|
||||||
|
}
|
||||||
|
.media-audio {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg2);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.media-audio .icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.media-viewer {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.media-stage {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.media-stage img,
|
||||||
|
.media-stage video {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.media-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--fg2);
|
||||||
|
}
|
||||||
|
.media-bar .grow {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||||
*/
|
*/
|
||||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||||
|
"202611-00019": {
|
||||||
|
en: ["Long chats open and react much faster: older messages load when you scroll up, finished turns fold into one line", "Search in a chat (magnifier in the top bar) and ⋯ → Media: all images, videos and audio of a chat"],
|
||||||
|
de: ["Lange Chats öffnen und reagieren viel schneller: ältere Nachrichten laden beim Hochscrollen, abgeschlossene Turns sind eingeklappt", "Suche im Chat (Lupe oben) und ⋯ → Medien: alle Bilder, Videos und Audiodateien eines Chats"],
|
||||||
|
},
|
||||||
"202611-00018": {
|
"202611-00018": {
|
||||||
en: [
|
en: [
|
||||||
"Modes with a review: every completed todo is reviewed, and the agent keeps working until the todo list is done – for overnight runs",
|
"Modes with a review: every completed todo is reviewed, and the agent keeps working until the todo list is done – for overnight runs",
|
||||||
|
|||||||
Reference in new issue
Block a user