Add hooks, @file mentions, session search/fork/export, follow-scroll
- Hooks ([[hooks]]): pre_tool, post_tool, user_prompt, stop; sandboxed, exit 2 blocks - @file mentions with autocomplete; mentioned files/folders are attached to the prompt - Session full-text search in the sidebar, fork into a copy, export as Markdown/JSON - Chat follows the newest message until the user scrolls up; jump-to-latest button - SVG activity spark and thinking icon instead of Unicode/emoji glyphs - File search ranks name matches first and returns relative paths Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec1b9b4925
commit
013695722a
17 files changed
+911
-41
No files matched your search
@@ -317,6 +317,29 @@ enabled = true
|
|||||||
# root_markers = ["build.zig"]
|
# root_markers = ["build.zig"]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Hooks – shell commands on agent events
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# event: pre_tool | post_tool | user_prompt | stop
|
||||||
|
# matcher: tool name pattern for tool events ("*", "bash", "edit|write", "mcp__*")
|
||||||
|
# The hook gets the event as JSON on stdin (session_id, cwd, tool, input,
|
||||||
|
# result, prompt, last_message) and runs in the user's sandbox in the session
|
||||||
|
# directory. Exit 0: stdout is added as context (user_prompt, post_tool).
|
||||||
|
# Exit 2: block – pre_tool rejects the call, user_prompt drops the prompt,
|
||||||
|
# post_tool sends stderr to the agent as feedback, stop makes the agent
|
||||||
|
# continue with stderr as instruction (max. 3 times per turn).
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# [[hooks]]
|
||||||
|
# event = "post_tool"
|
||||||
|
# matcher = "edit|write"
|
||||||
|
# command = 'f=$(jq -r .input.path); case "$f" in *.py) ruff format -q "$f";; esac; exit 0'
|
||||||
|
#
|
||||||
|
# [[hooks]]
|
||||||
|
# event = "pre_tool"
|
||||||
|
# matcher = "bash"
|
||||||
|
# command = 'jq -r .input.command | grep -q "git push" && { echo "No pushes from agents" >&2; exit 2; }; exit 0'
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Sandbox
|
# Sandbox
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/agents", get(agents))
|
.route("/agents", get(agents))
|
||||||
.route("/commands", get(commands))
|
.route("/commands", get(commands))
|
||||||
.route("/sessions", get(list_sessions).post(create_session))
|
.route("/sessions", get(list_sessions).post(create_session))
|
||||||
|
.route("/sessions/search", get(search_sessions))
|
||||||
|
.route("/sessions/{id}/fork", post(fork_session))
|
||||||
|
.route("/sessions/{id}/export", get(export_session))
|
||||||
.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))
|
||||||
@@ -367,6 +370,89 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SearchQ {
|
||||||
|
q: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search_sessions(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<SearchQ>) -> ApiResult {
|
||||||
|
if q.q.trim().len() < 2 {
|
||||||
|
return Ok(Json(json!([])));
|
||||||
|
}
|
||||||
|
let filter = if user.is_admin() { None } else { Some(user.id.as_str()) };
|
||||||
|
let hits: Vec<Value> = app
|
||||||
|
.store
|
||||||
|
.search_sessions(filter, q.q.trim())?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(s, snippet)| json!({"id": s.id, "title": s.title, "cwd": s.cwd, "agent": s.agent, "updated_at": s.updated_at, "snippet": snippet}))
|
||||||
|
.collect();
|
||||||
|
Ok(Json(json!(hits)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct ForkBody {
|
||||||
|
/// Last message to keep (inclusive); all messages when missing.
|
||||||
|
message_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a session (up to a message) into a new one to try another path.
|
||||||
|
async fn fork_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, body: Option<Json<ForkBody>>) -> ApiResult {
|
||||||
|
let src = writable(&app, &user, &id)?;
|
||||||
|
let body = body.map(|b| b.0).unwrap_or_default();
|
||||||
|
let messages = app.store.messages(&id)?;
|
||||||
|
let end = match &body.message_id {
|
||||||
|
Some(mid) => messages.iter().position(|m| &m.id == mid).ok_or_else(|| not_found("message"))? + 1,
|
||||||
|
None => messages.len(),
|
||||||
|
};
|
||||||
|
let now = now_ms();
|
||||||
|
let title = if src.title.is_empty() { "Fork".to_string() } else { format!("{} (fork)", src.title) };
|
||||||
|
let fork = Session {
|
||||||
|
id: new_id(),
|
||||||
|
title,
|
||||||
|
parent_id: None,
|
||||||
|
owner_id: Some(user.id.clone()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
last_usage: None,
|
||||||
|
..src.clone()
|
||||||
|
};
|
||||||
|
let fork = app.save_session(fork)?;
|
||||||
|
for m in &messages[..end] {
|
||||||
|
let mut m = m.clone();
|
||||||
|
m.id = new_id();
|
||||||
|
app.store.add_message(&fork.id, &m)?;
|
||||||
|
}
|
||||||
|
app.audit(Some(&user), "session.fork", &format!("{id} -> {}", fork.id), "");
|
||||||
|
Ok(Json(json!(fork)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ExportQ {
|
||||||
|
#[serde(default)]
|
||||||
|
format: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download a session as Markdown (default) or JSON.
|
||||||
|
async fn export_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ExportQ>) -> Result<axum::response::Response, ApiError> {
|
||||||
|
let s = readable(&app, &user, &id)?;
|
||||||
|
let messages = app.store.messages(&id)?;
|
||||||
|
let base: String = s.title.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect::<String>().trim_matches('_').to_string();
|
||||||
|
let base = if base.is_empty() { format!("session-{}", &id[..8.min(id.len())]) } else { base };
|
||||||
|
let (body, mime, ext) = if q.format == "json" {
|
||||||
|
(serde_json::to_string_pretty(&json!({"session": s, "messages": messages}))?, "application/json", "json")
|
||||||
|
} else {
|
||||||
|
(crate::export::markdown(&s, &messages), "text/markdown; charset=utf-8", "md")
|
||||||
|
};
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(axum::http::header::CONTENT_TYPE, mime.to_string()),
|
||||||
|
(axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{base}.{ext}\"")),
|
||||||
|
],
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
let s = readable(&app, &user, &id)?;
|
let s = readable(&app, &user, &id)?;
|
||||||
let messages = app.store.messages(&id)?;
|
let messages = app.store.messages(&id)?;
|
||||||
|
|||||||
@@ -24,6 +24,31 @@ pub struct Config {
|
|||||||
pub sandbox: SandboxConfig,
|
pub sandbox: SandboxConfig,
|
||||||
pub search: SearchConfig,
|
pub search: SearchConfig,
|
||||||
pub lsp: LspConfig,
|
pub lsp: LspConfig,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub hooks: Vec<HookConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shell command run on an agent event (see hooks.rs).
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct HookConfig {
|
||||||
|
/// pre_tool, post_tool, user_prompt or stop.
|
||||||
|
pub event: String,
|
||||||
|
/// Tool name pattern for tool events ("*", "bash", "edit|write", "mcp__*").
|
||||||
|
#[serde(default = "star")]
|
||||||
|
pub matcher: String,
|
||||||
|
pub command: String,
|
||||||
|
#[serde(default = "sixty")]
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn star() -> String {
|
||||||
|
"*".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sixty() -> u64 {
|
||||||
|
60
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Language servers for diagnostics after edits.
|
/// Language servers for diagnostics after edits.
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//! Session export as Markdown.
|
||||||
|
|
||||||
|
use crate::types::{Message, Part, Role, Session};
|
||||||
|
|
||||||
|
fn fence(s: &str) -> String {
|
||||||
|
// A fence longer than any backtick run inside the content.
|
||||||
|
let mut longest = 0;
|
||||||
|
let mut run = 0;
|
||||||
|
for c in s.chars() {
|
||||||
|
if c == '`' {
|
||||||
|
run += 1;
|
||||||
|
longest = longest.max(run);
|
||||||
|
} else {
|
||||||
|
run = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"`".repeat((longest + 1).max(3))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clip(s: &str, max: usize) -> String {
|
||||||
|
if s.len() <= max {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
let mut end = max;
|
||||||
|
while !s.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
format!("{}\n… ({} more bytes)", &s[..end], s.len() - end)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn time(ms: i64) -> String {
|
||||||
|
chrono::DateTime::from_timestamp_millis(ms).map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn markdown(s: &Session, messages: &[Message]) -> String {
|
||||||
|
let title = if s.title.is_empty() { "Untitled session" } else { s.title.as_str() };
|
||||||
|
let mut out = format!(
|
||||||
|
"# {title}\n\n- Directory: `{}`\n- Model: `{}`\n- Mode: {}\n- Created: {}\n- Exported from CodeCraft\n\n---\n\n",
|
||||||
|
s.cwd,
|
||||||
|
s.model,
|
||||||
|
s.agent,
|
||||||
|
time(s.created_at)
|
||||||
|
);
|
||||||
|
for m in messages {
|
||||||
|
let is_tool_results = m.parts.iter().all(|p| matches!(p, Part::ToolResult { .. }));
|
||||||
|
if !is_tool_results {
|
||||||
|
let who = match m.role {
|
||||||
|
Role::User => "User".to_string(),
|
||||||
|
_ => format!("Assistant{}", m.model.as_deref().map(|x| format!(" ({x})")).unwrap_or_default()),
|
||||||
|
};
|
||||||
|
out.push_str(&format!("## {who} · {}\n\n", time(m.created_at)));
|
||||||
|
}
|
||||||
|
for p in &m.parts {
|
||||||
|
match p {
|
||||||
|
Part::Text { text } => out.push_str(&format!("{}\n\n", text.trim())),
|
||||||
|
Part::Summary { text } => out.push_str(&format!("<details><summary>Summary of the earlier conversation</summary>\n\n{}\n\n</details>\n\n", text.trim())),
|
||||||
|
Part::Reasoning { text, .. } if !text.trim().is_empty() => {
|
||||||
|
out.push_str(&format!("<details><summary>Thinking</summary>\n\n{}\n\n</details>\n\n", text.trim()))
|
||||||
|
}
|
||||||
|
Part::Reasoning { .. } => {}
|
||||||
|
Part::Image { image } => out.push_str(&format!("*[image: {}]*\n\n", image.media_type)),
|
||||||
|
Part::ToolCall { name, input, .. } => {
|
||||||
|
let body = match name.as_str() {
|
||||||
|
"bash" => input["command"].as_str().unwrap_or("").to_string(),
|
||||||
|
_ => serde_json::to_string_pretty(input).unwrap_or_default(),
|
||||||
|
};
|
||||||
|
let body = clip(&body, 4000);
|
||||||
|
let f = fence(&body);
|
||||||
|
out.push_str(&format!("**Tool `{name}`**\n\n{f}{}\n{body}\n{f}\n\n", if name == "bash" { "sh" } else { "json" }));
|
||||||
|
}
|
||||||
|
Part::ToolResult { name, content, is_error, .. } => {
|
||||||
|
let body = clip(content.trim(), 4000);
|
||||||
|
let f = fence(&body);
|
||||||
|
out.push_str(&format!(
|
||||||
|
"<details><summary>{} of <code>{name}</code></summary>\n\n{f}\n{body}\n{f}\n\n</details>\n\n",
|
||||||
|
if *is_error { "Error" } else { "Result" }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
+19
-7
@@ -399,20 +399,32 @@ pub async fn search(Extension(user): Extension<User>, State(app): State<AppState
|
|||||||
return Ok(Json(json!([])));
|
return Ok(Json(json!([])));
|
||||||
}
|
}
|
||||||
let hits = tokio::task::spawn_blocking(move || {
|
let hits = tokio::task::spawn_blocking(move || {
|
||||||
let mut out = Vec::new();
|
// Matches on the name or the relative path; name prefix matches first.
|
||||||
|
let mut found: Vec<(u8, usize, Value)> = Vec::new();
|
||||||
for e in ignore::WalkBuilder::new(&root).hidden(true).build().flatten() {
|
for e in ignore::WalkBuilder::new(&root).hidden(true).build().flatten() {
|
||||||
if e.depth() == 0 {
|
if e.depth() == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let name = e.file_name().to_string_lossy().to_lowercase();
|
let name = e.file_name().to_string_lossy().to_lowercase();
|
||||||
if name.contains(&needle) {
|
let rel = e.path().strip_prefix(&root).unwrap_or(e.path()).to_string_lossy().to_string();
|
||||||
out.push(json!({"path": e.path(), "is_dir": e.file_type().is_some_and(|t| t.is_dir())}));
|
let rel_l = rel.to_lowercase();
|
||||||
if out.len() >= 200 {
|
let rank = if name.starts_with(&needle) {
|
||||||
break;
|
0
|
||||||
}
|
} else if name.contains(&needle) {
|
||||||
|
1
|
||||||
|
} else if rel_l.contains(&needle) {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let is_dir = e.file_type().is_some_and(|t| t.is_dir());
|
||||||
|
found.push((rank, rel.len(), json!({"path": e.path(), "rel": rel, "is_dir": is_dir})));
|
||||||
|
if found.len() >= 2000 {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
found.sort_by_key(|(r, l, _)| (*r, *l));
|
||||||
|
found.into_iter().take(200).map(|(_, _, v)| v).collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!(hits)))
|
Ok(Json(json!(hits)))
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
//! Hooks: shell commands run on agent events (configured by admins).
|
||||||
|
//!
|
||||||
|
//! Each hook gets the event as JSON on stdin and runs in the session owner's
|
||||||
|
//! sandbox in the session directory.
|
||||||
|
//! - exit 0: success; stdout is added as context (user_prompt, post_tool)
|
||||||
|
//! - exit 2: block; stderr (or stdout) is the reason
|
||||||
|
//! (pre_tool: the call is rejected, user_prompt: the prompt is dropped,
|
||||||
|
//! stop: the agent continues with the reason as new instruction)
|
||||||
|
//! - other exit codes: logged and ignored
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::sandbox::SandboxSpec;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
const MAX_OUTPUT: usize = 10_000;
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct Outcome {
|
||||||
|
/// Reason when a hook blocked (exit code 2).
|
||||||
|
pub blocked: Option<String>,
|
||||||
|
/// Collected stdout of successful hooks.
|
||||||
|
pub context: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches(pattern: &str, tool: &str) -> bool {
|
||||||
|
if pattern.is_empty() || pattern == "*" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
pattern.split('|').any(|p| {
|
||||||
|
let p = p.trim();
|
||||||
|
globset::Glob::new(p).map(|g| g.compile_matcher().is_match(tool)).unwrap_or(p == tool)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clip(s: &str) -> String {
|
||||||
|
let s = s.trim();
|
||||||
|
if s.len() <= MAX_OUTPUT {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
let mut end = MAX_OUTPUT;
|
||||||
|
while !s.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
format!("{}…", &s[..end])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run all hooks of `event` (for tool events: matching `tool`).
|
||||||
|
pub async fn run(app: &App, event: &str, tool: Option<&str>, payload: Value, cwd: &Path, sandbox: Option<&SandboxSpec>) -> Outcome {
|
||||||
|
let hooks: Vec<_> = app
|
||||||
|
.config()
|
||||||
|
.hooks
|
||||||
|
.iter()
|
||||||
|
.filter(|h| h.enabled && h.event == event && tool.is_none_or(|t| matches(&h.matcher, t)))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let mut out = Outcome::default();
|
||||||
|
if hooks.is_empty() {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let mut payload = payload;
|
||||||
|
payload["event"] = Value::String(event.into());
|
||||||
|
let input = serde_json::to_vec(&payload).unwrap_or_default();
|
||||||
|
for h in hooks {
|
||||||
|
let script = format!(
|
||||||
|
"export CODECRAFT_HOOK_EVENT={event} CODECRAFT_HOOK_TOOL='{}'\n{}",
|
||||||
|
tool.unwrap_or("").replace('\'', ""),
|
||||||
|
h.command
|
||||||
|
);
|
||||||
|
let mut cmd = match sandbox {
|
||||||
|
Some(spec) => {
|
||||||
|
if !crate::sandbox::available() {
|
||||||
|
tracing::warn!("hook skipped: sandbox unavailable");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
crate::sandbox::command(spec, cwd, "bash", &["-c", &script])
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut c = tokio::process::Command::new("bash");
|
||||||
|
c.arg("-c").arg(&script).current_dir(cwd).env("CODECRAFT", "1");
|
||||||
|
c
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true);
|
||||||
|
let Ok(mut child) = cmd.spawn() else {
|
||||||
|
tracing::warn!("hook could not be started: {}", h.command);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
let data = input.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = stdin.write_all(&data).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let res = tokio::time::timeout(Duration::from_secs(h.timeout_secs.max(1)), child.wait_with_output()).await;
|
||||||
|
let Ok(Ok(o)) = res else {
|
||||||
|
tracing::warn!("hook timed out or failed: {}", h.command);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||||
|
match o.status.code() {
|
||||||
|
Some(0) => {
|
||||||
|
if !stdout.trim().is_empty() {
|
||||||
|
out.context.push_str(&clip(&stdout));
|
||||||
|
out.context.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(2) => {
|
||||||
|
let reason = if stderr.trim().is_empty() { stdout.to_string() } else { stderr.to_string() };
|
||||||
|
out.blocked = Some(if reason.trim().is_empty() { format!("blocked by hook `{}`", h.command) } else { clip(&reason) });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
code => tracing::warn!("hook `{}` exited with {code:?}: {}", h.command, stderr.trim()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::matches;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matcher() {
|
||||||
|
assert!(matches("*", "bash"));
|
||||||
|
assert!(matches("", "edit"));
|
||||||
|
assert!(matches("edit|write", "write"));
|
||||||
|
assert!(!matches("edit|write", "bash"));
|
||||||
|
assert!(matches("mcp__*", "mcp__github__create_issue"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,13 @@ mod auth;
|
|||||||
mod budget;
|
mod budget;
|
||||||
mod config;
|
mod config;
|
||||||
mod docs;
|
mod docs;
|
||||||
|
mod export;
|
||||||
mod files;
|
mod files;
|
||||||
mod git;
|
mod git;
|
||||||
|
mod hooks;
|
||||||
mod images;
|
mod images;
|
||||||
mod lsp;
|
mod lsp;
|
||||||
|
mod mentions;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod permission;
|
mod permission;
|
||||||
mod prompts;
|
mod prompts;
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//! `@path` mentions in user prompts: the mentioned files (or folder listings)
|
||||||
|
//! are attached to the prompt, read with the regular read/list tools.
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tools::{Tool, ToolCtx};
|
||||||
|
use crate::types::Part;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
const MAX_MENTIONS: usize = 10;
|
||||||
|
const MAX_CHARS: usize = 60_000;
|
||||||
|
|
||||||
|
/// `@token`s at the start of the text or after whitespace (not e-mail addresses).
|
||||||
|
pub fn find(text: &str) -> Vec<String> {
|
||||||
|
let re = regex::Regex::new(r"(?:^|\s)@([\w./~+\-]+)").unwrap();
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
for c in re.captures_iter(text) {
|
||||||
|
let token = c[1].trim_end_matches(['.', ',', ';', ':', '!', '?', ')']).to_string();
|
||||||
|
if !token.is_empty() && !out.contains(&token) {
|
||||||
|
out.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parts to append to a user prompt for its mentions.
|
||||||
|
pub async fn expand(app: &Arc<App>, session_id: &str, text: &str) -> Vec<Part> {
|
||||||
|
let tokens = find(text);
|
||||||
|
if tokens.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let Ok(session) = app.session(session_id) else { return vec![] };
|
||||||
|
let cwd = PathBuf::from(&session.cwd);
|
||||||
|
let ctx = ToolCtx {
|
||||||
|
app: app.clone(),
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
call_id: String::new(),
|
||||||
|
cwd: cwd.clone(),
|
||||||
|
cancel: CancellationToken::new(),
|
||||||
|
agent: session.agent.clone(),
|
||||||
|
roots: app.user_roots(session.owner_id.as_deref()),
|
||||||
|
sandbox: crate::sandbox::spec_for(app, session.owner_id.as_deref()),
|
||||||
|
clean_room: session.clean_room(app.config().craft.clean_room),
|
||||||
|
};
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for token in tokens.into_iter().take(MAX_MENTIONS) {
|
||||||
|
let Ok(path) = ctx.resolve_read(&token) else { continue };
|
||||||
|
if !path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let res = if path.is_dir() {
|
||||||
|
crate::tools::fs::List.run(json!({"path": token}), &ctx).await
|
||||||
|
} else {
|
||||||
|
crate::tools::fs::Read.run(json!({"path": token}), &ctx).await
|
||||||
|
};
|
||||||
|
let Ok(out) = res else { continue };
|
||||||
|
let mut content = out.content;
|
||||||
|
if content.len() > MAX_CHARS {
|
||||||
|
let mut end = MAX_CHARS;
|
||||||
|
while !content.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
content.truncate(end);
|
||||||
|
content.push_str("\n[… truncated – read the rest with the read tool]");
|
||||||
|
}
|
||||||
|
let kind = if path.is_dir() { "folder" } else { "file" };
|
||||||
|
parts.push(Part::Text { text: format!("<mentioned-{kind} path=\"{token}\">\n{content}\n</mentioned-{kind}>") });
|
||||||
|
parts.extend(out.images.into_iter().map(|image| Part::Image { image }));
|
||||||
|
}
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn finds_mentions() {
|
||||||
|
assert_eq!(super::find("look at @src/main.rs, and @README.md."), vec!["src/main.rs", "README.md"]);
|
||||||
|
assert!(super::find("mail me at a@b.de").is_empty());
|
||||||
|
assert_eq!(super::find("@docs/ please"), vec!["docs/"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-1
@@ -11,7 +11,7 @@ use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms};
|
|||||||
use anyhow::{Result, anyhow, bail};
|
use anyhow::{Result, anyhow, bail};
|
||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
@@ -70,9 +70,26 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true });
|
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true });
|
||||||
|
let source = source.to_string();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let res = async {
|
let res = async {
|
||||||
|
let mut parts = parts;
|
||||||
|
if source == "user" {
|
||||||
|
let session = app.session(&session_id)?;
|
||||||
|
let sandbox = crate::sandbox::spec_for(&app, session.owner_id.as_deref());
|
||||||
|
let payload = serde_json::json!({"session_id": session_id, "cwd": session.cwd, "prompt": text_only});
|
||||||
|
let o = crate::hooks::run(&app, "user_prompt", None, payload, Path::new(&session.cwd), sandbox.as_ref()).await;
|
||||||
|
if let Some(reason) = o.blocked {
|
||||||
|
anyhow::bail!("Prompt blocked by a hook: {}", reason.trim());
|
||||||
|
}
|
||||||
|
if !o.context.trim().is_empty() {
|
||||||
|
parts.push(Part::Text { text: format!("<hook-context event=\"user_prompt\">\n{}\n</hook-context>", o.context.trim()) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if source == "user" {
|
||||||
|
parts.extend(crate::mentions::expand(&app, &session_id, &text_only).await);
|
||||||
|
}
|
||||||
let msg = Message::new(Role::User, parts);
|
let msg = Message::new(Role::User, parts);
|
||||||
app.store.add_message(&session_id, &msg)?;
|
app.store.add_message(&session_id, &msg)?;
|
||||||
app.emit(Event::MessageAdded { session_id: session_id.clone(), message: msg });
|
app.emit(Event::MessageAdded { session_id: session_id.clone(), message: msg });
|
||||||
@@ -236,6 +253,7 @@ fn run_session_boxed(
|
|||||||
/// Run the loop for a session until the model stops. Returns the final text.
|
/// Run the loop for a session until the model stops. Returns the final text.
|
||||||
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
|
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
|
||||||
let mut final_text = String::new();
|
let mut final_text = String::new();
|
||||||
|
let mut stop_hooks = 0;
|
||||||
for _ in 0..MAX_STEPS {
|
for _ in 0..MAX_STEPS {
|
||||||
if cancel.is_cancelled() {
|
if cancel.is_cancelled() {
|
||||||
break;
|
break;
|
||||||
@@ -303,6 +321,16 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if calls.is_empty() && depth == 0 && stop_hooks < 3 && matches!(step.stop_reason.as_str(), "stop" | "end_turn" | "") {
|
||||||
|
let payload = serde_json::json!({"session_id": session_id, "cwd": session.cwd, "last_message": final_text});
|
||||||
|
let sandbox = crate::sandbox::spec_for(&app, session.owner_id.as_deref());
|
||||||
|
let o = crate::hooks::run(&app, "stop", None, payload, &cwd, sandbox.as_ref()).await;
|
||||||
|
if let Some(reason) = o.blocked {
|
||||||
|
stop_hooks += 1;
|
||||||
|
save_user_parts(&app, session_id, vec![Part::Text { text: format!("<hook-feedback event=\"stop\">\n{}\n</hook-feedback>", reason.trim()) }])?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
if calls.is_empty() {
|
if calls.is_empty() {
|
||||||
match step.stop_reason.as_str() {
|
match step.stop_reason.as_str() {
|
||||||
"max_tokens" | "length" => app.emit(Event::Error {
|
"max_tokens" | "length" => app.emit(Event::Error {
|
||||||
@@ -453,6 +481,20 @@ async fn execute_call(
|
|||||||
Decision::Allow => {}
|
Decision::Allow => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hooks see the tool input (cloned only when hooks are configured).
|
||||||
|
let hook_input = if app.config().hooks.is_empty() { Value::Null } else { input.clone() };
|
||||||
|
let hook_payload = |extra: Option<(&str, Value)>| {
|
||||||
|
let mut v = serde_json::json!({"session_id": session.id, "cwd": session.cwd, "tool": name, "input": hook_input});
|
||||||
|
if let Some((k, x)) = extra {
|
||||||
|
v[k] = x;
|
||||||
|
}
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let pre = crate::hooks::run(app, "pre_tool", Some(name), hook_payload(None), &ctx.cwd, ctx.sandbox.as_ref()).await;
|
||||||
|
if let Some(reason) = pre.blocked {
|
||||||
|
return tool_result(call_id, call_name, format!("Blocked by a hook: {}", reason.trim()), true, None);
|
||||||
|
}
|
||||||
|
|
||||||
// Checkpoint: remember files before the agent changes them (rewind can restore them).
|
// Checkpoint: remember files before the agent changes them (rewind can restore them).
|
||||||
if tool.is_edit() {
|
if tool.is_edit() {
|
||||||
if let Some(path) = input["path"].as_str().and_then(|p| ctx.resolve(p).ok()) {
|
if let Some(path) = input["path"].as_str().and_then(|p| ctx.resolve(p).ok()) {
|
||||||
@@ -482,6 +524,19 @@ async fn execute_call(
|
|||||||
}
|
}
|
||||||
other => other,
|
other => other,
|
||||||
};
|
};
|
||||||
|
let res = match res {
|
||||||
|
Ok(mut out) => {
|
||||||
|
let result = serde_json::json!({"content": out.content.chars().take(20_000).collect::<String>(), "is_error": out.is_error});
|
||||||
|
let post = crate::hooks::run(app, "post_tool", Some(name), hook_payload(Some(("result", result))), &ctx.cwd, ctx.sandbox.as_ref()).await;
|
||||||
|
if let Some(reason) = post.blocked {
|
||||||
|
out.content.push_str(&format!("\n\n<hook-feedback event=\"post_tool\">\n{}\n</hook-feedback>", reason.trim()));
|
||||||
|
} else if !post.context.trim().is_empty() {
|
||||||
|
out.content.push_str(&format!("\n\n<hook-context event=\"post_tool\">\n{}\n</hook-context>", post.context.trim()));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
e => e,
|
||||||
|
};
|
||||||
match res {
|
match res {
|
||||||
Ok(out) => Part::ToolResult {
|
Ok(out) => Part::ToolResult {
|
||||||
call_id: call_id.into(),
|
call_id: call_id.into(),
|
||||||
|
|||||||
+52
-1
@@ -1,6 +1,6 @@
|
|||||||
//! SQLite persistence for sessions and messages.
|
//! SQLite persistence for sessions and messages.
|
||||||
|
|
||||||
use crate::types::{Message, Session, now_ms};
|
use crate::types::{Message, Part, 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;
|
||||||
@@ -178,6 +178,57 @@ impl Store {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full-text search over titles and message texts of the sessions a user
|
||||||
|
/// can see (None = all). Returns (session, snippet) pairs, newest first.
|
||||||
|
pub fn search_sessions(&self, user: Option<&str>, q: &str) -> Result<Vec<(Session, 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 s.data,
|
||||||
|
(SELECT m.data FROM messages m WHERE m.session_id = s.id AND m.data LIKE ?2 ESCAPE '\\' ORDER BY m.seq DESC LIMIT 1)
|
||||||
|
FROM sessions s
|
||||||
|
WHERE s.parent_id IS NULL
|
||||||
|
AND (?1 IS NULL OR s.owner_id = ?1 OR s.id IN (SELECT session_id FROM session_shares WHERE user_id = ?1))
|
||||||
|
AND (s.data LIKE ?2 ESCAPE '\\' OR EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id AND m.data LIKE ?2 ESCAPE '\\'))
|
||||||
|
ORDER BY s.updated_at DESC LIMIT 50",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(rusqlite::params![user, like], |r| Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?)))?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if snippet.is_empty() && !session.title.to_lowercase().contains(&needle) {
|
||||||
|
continue; // matched only JSON structure (keys, ids)
|
||||||
|
}
|
||||||
|
out.push((session, snippet));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -19,16 +19,37 @@ export function Spinner({ small }: { small?: boolean }) {
|
|||||||
return <span className={`spinner ${small ? "small" : ""}`} aria-hidden="true" />;
|
return <span className={`spinner ${small ? "small" : ""}`} aria-hidden="true" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STARS = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
|
/** Animated spark (SVG, looks the same on every system): rays light up in turn while it turns. */
|
||||||
|
export function Star() {
|
||||||
|
const rays = Array.from({ length: 8 }, (_, i) => i);
|
||||||
|
return (
|
||||||
|
<svg className="star" viewBox="-12 -12 24 24" aria-hidden="true">
|
||||||
|
<g className="star-spin">
|
||||||
|
{rays.map((i) => (
|
||||||
|
<line
|
||||||
|
key={i}
|
||||||
|
x1="0"
|
||||||
|
y1="-4"
|
||||||
|
x2="0"
|
||||||
|
y2={i % 2 ? "-8.5" : "-10.5"}
|
||||||
|
transform={`rotate(${i * 45})`}
|
||||||
|
style={{ animationDelay: `${(i * 0.9) / 8 - 0.9}s` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Rotating star like a terminal agent's spinner. */
|
/** Thought bubble icon for model reasoning (SVG instead of an emoji). */
|
||||||
function Star() {
|
export function ThinkIcon() {
|
||||||
const [i, setI] = useState(0);
|
return (
|
||||||
useEffect(() => {
|
<svg className="think-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
const h = setInterval(() => setI((x) => (x + 1) % STARS.length), 120);
|
<path d="M7 15.5a4.5 4.5 0 0 1-.6-8.96A5.5 5.5 0 0 1 16.6 5.2 4.25 4.25 0 0 1 18 13.5a4 4 0 0 1-4 2Z" />
|
||||||
return () => clearInterval(h);
|
<circle cx="7.5" cy="19" r="1.6" />
|
||||||
}, []);
|
<circle cx="4" cy="21.6" r="1" />
|
||||||
return <span className="star" aria-hidden="true">{STARS[i]}</span>;
|
</svg>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useNow(active: boolean) {
|
function useNow(active: boolean) {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { del, get, patch, post } from "../api";
|
|||||||
import { useModels } from "../hooks";
|
import { useModels } from "../hooks";
|
||||||
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT } from "../i18n";
|
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { dismissError, loadSession, useStore } from "../store";
|
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
||||||
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
|
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
|
||||||
import { ActivityLine, Dots, WorkBar } from "./Activity";
|
import { ActivityLine, Dots, ThinkIcon, WorkBar } from "./Activity";
|
||||||
import { useReasoningMode } from "../prefs";
|
import { useReasoningMode } from "../prefs";
|
||||||
import { Composer } from "./Composer";
|
import { Composer } from "./Composer";
|
||||||
import { ShareDialog } from "./ShareDialog";
|
import { ShareDialog } from "./ShareDialog";
|
||||||
@@ -45,7 +45,7 @@ function Reasoning({ text, live }: { text: string; live: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<div className={`reasoning ${live ? "live" : ""}`}>
|
<div className={`reasoning ${live ? "live" : ""}`}>
|
||||||
<button className="reasoning-head" onClick={() => setToggled(!open)} aria-expanded={open}>
|
<button className="reasoning-head" onClick={() => setToggled(!open)} aria-expanded={open}>
|
||||||
{live ? <Dots /> : <span aria-hidden="true">💭</span>}
|
{live ? <Dots /> : <ThinkIcon />}
|
||||||
<span>{live ? t("chat.reasoningLive") : t("chat.reasoningDone", { n: words })}</span>
|
<span>{live ? t("chat.reasoningLive") : t("chat.reasoningDone", { n: words })}</span>
|
||||||
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
|
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -128,7 +128,22 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
const [showStats, setShowStats] = useState(false);
|
const [showStats, setShowStats] = useState(false);
|
||||||
const [editingTitle, setEditingTitle] = useState(false);
|
const [editingTitle, setEditingTitle] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Follow the newest message until the user scrolls up; the ↓ button resumes.
|
||||||
const stick = useRef(true);
|
const stick = useRef(true);
|
||||||
|
const [following, setFollowing] = useState(true);
|
||||||
|
const lastScroll = useRef({ top: 0, height: 0 });
|
||||||
|
const follow = (on: boolean) => {
|
||||||
|
stick.current = on;
|
||||||
|
setFollowing(on);
|
||||||
|
};
|
||||||
|
const jumpToLatest = () => {
|
||||||
|
follow(true);
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Every chat starts following.
|
||||||
|
useEffect(() => follow(true), [sessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSession(sessionId).catch((e) => setLoadError(e.message));
|
loadSession(sessionId).catch((e) => setLoadError(e.message));
|
||||||
@@ -165,6 +180,17 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const forkSession = async () => {
|
||||||
|
try {
|
||||||
|
const fork = await post<Session>(`/sessions/${sessionId}/fork`, {});
|
||||||
|
await loadSessions();
|
||||||
|
toast(t("chat.forked"));
|
||||||
|
navigate(`/s/${fork.id}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const send = async (raw: string, images: ImageData[]) => {
|
const send = async (raw: string, images: ImageData[]) => {
|
||||||
const txt = raw.trim();
|
const txt = raw.trim();
|
||||||
if (txt === "/new") {
|
if (txt === "/new") {
|
||||||
@@ -174,7 +200,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
try {
|
try {
|
||||||
await post(`/sessions/${sessionId}/prompt`, { text: txt, images });
|
await post(`/sessions/${sessionId}/prompt`, { text: txt, images });
|
||||||
setText("");
|
setText("");
|
||||||
stick.current = true;
|
follow(true);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(e.message);
|
toast(e.message);
|
||||||
}
|
}
|
||||||
@@ -262,6 +288,24 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
⇄
|
⇄
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!session.parent_id && (
|
||||||
|
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
|
||||||
|
🔀
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<details className="menu-pop">
|
||||||
|
<summary className="small ghost" title={t("chat.export")}>
|
||||||
|
💾
|
||||||
|
</summary>
|
||||||
|
<div className="menu-pop-body">
|
||||||
|
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
|
||||||
|
{t("chat.exportMd")}
|
||||||
|
</a>
|
||||||
|
<a href={`/api/sessions/${sessionId}/export?format=json`} download>
|
||||||
|
{t("chat.exportJson")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
|
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
|
||||||
⇊
|
⇊
|
||||||
</button>
|
</button>
|
||||||
@@ -277,7 +321,16 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
const last = lastScroll.current;
|
||||||
|
const fromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
|
// Scrolling up (not content shrinking) stops following …
|
||||||
|
if (el.scrollTop < last.top - 2 && el.scrollHeight >= last.height && stick.current) follow(false);
|
||||||
|
// … scrolling back to the very end resumes it.
|
||||||
|
else if (fromBottom < 4 && !stick.current && el.scrollTop > last.top) follow(true);
|
||||||
|
lastScroll.current = { top: el.scrollTop, height: el.scrollHeight };
|
||||||
|
}}
|
||||||
|
onWheel={(e) => {
|
||||||
|
if (e.deltaY < 0 && stick.current) follow(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="chat">
|
<div className="chat">
|
||||||
@@ -382,6 +435,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!following && (
|
||||||
|
<div className="jump-latest">
|
||||||
|
<button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
||||||
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export function Composer({
|
|||||||
const [files, setFiles] = useState<{ name: string; path: string; size: number }[]>([]);
|
const [files, setFiles] = useState<{ name: string; path: string; size: number }[]>([]);
|
||||||
const [uploading, setUploading] = useState(0);
|
const [uploading, setUploading] = useState(0);
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
|
// @file mentions: token before the caret and matching files.
|
||||||
|
const [mention, setMention] = useState<{ start: number; end: number; q: string }>();
|
||||||
|
const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]);
|
||||||
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
|
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
|
||||||
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
|
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
|
||||||
|
|
||||||
@@ -69,15 +72,57 @@ export function Composer({
|
|||||||
el.style.height = `${Math.min(el.scrollHeight, window.innerHeight * 0.4)}px`;
|
el.style.height = `${Math.min(el.scrollHeight, window.innerHeight * 0.4)}px`;
|
||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
const suggestions = useMemo(() => {
|
const updateMention = (value: string, caret: number) => {
|
||||||
|
const m = value.slice(0, caret).match(/(?:^|\s)@([\w./~+\-]*)$/);
|
||||||
|
setMention(m ? { start: caret - m[1].length - 1, end: caret, q: m[1] } : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mention || mention.q.length < 1) {
|
||||||
|
setMentionHits([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fetch(`/api/files/search?path=${encodeURIComponent(session.cwd)}&q=${encodeURIComponent(mention.q)}`, { credentials: "same-origin" })
|
||||||
|
.then((r) => (r.ok ? r.json() : []))
|
||||||
|
.then((hits: { rel: string; is_dir: boolean }[]) => alive && setMentionHits(hits.slice(0, 12)))
|
||||||
|
.catch(() => {});
|
||||||
|
}, 120);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [mention?.q, session.cwd]);
|
||||||
|
|
||||||
|
const suggestions = useMemo((): { key: string; label: string; description: string; apply: () => void }[] => {
|
||||||
|
if (mention && mentionHits.length) {
|
||||||
|
return mentionHits.map((h) => ({
|
||||||
|
key: h.rel,
|
||||||
|
label: `@${h.rel}${h.is_dir ? "/" : ""}`,
|
||||||
|
description: h.is_dir ? t("composer.folder") : "",
|
||||||
|
apply: () => {
|
||||||
|
const insert = `@${h.rel}${h.is_dir ? "/" : ""} `;
|
||||||
|
const next = text.slice(0, mention.start) + insert + text.slice(mention.end);
|
||||||
|
setText(next);
|
||||||
|
setMention(undefined);
|
||||||
|
setMentionHits([]);
|
||||||
|
const caret = mention.start + insert.length;
|
||||||
|
requestAnimationFrame(() => ref.current?.setSelectionRange(caret, caret));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
const m = text.match(/^\/(\S*)$/);
|
const m = text.match(/^\/(\S*)$/);
|
||||||
if (!m) return [];
|
if (!m) return [];
|
||||||
const all: Suggestion[] = [
|
const all: Suggestion[] = [
|
||||||
...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })),
|
...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })),
|
||||||
...(cmdData?.commands ?? []),
|
...(cmdData?.commands ?? []),
|
||||||
];
|
];
|
||||||
return all.filter((c) => c.name.startsWith(m[1])).slice(0, 12);
|
return all
|
||||||
}, [text, cmdData, t]);
|
.filter((c) => c.name.startsWith(m[1]))
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((c) => ({ key: c.name, label: `/${c.name}`, description: c.description, apply: () => setText(`/${c.name} `) }));
|
||||||
|
}, [text, cmdData, t, mention, mentionHits]);
|
||||||
|
|
||||||
const send = () => {
|
const send = () => {
|
||||||
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
|
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
|
||||||
@@ -97,7 +142,13 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setText(`/${suggestions[Math.min(active, suggestions.length - 1)].name} `);
|
suggestions[Math.min(active, suggestions.length - 1)].apply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setMention(undefined);
|
||||||
|
setMentionHits([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,17 +209,17 @@ export function Composer({
|
|||||||
<div className="suggest" role="listbox">
|
<div className="suggest" role="listbox">
|
||||||
{suggestions.map((s, i) => (
|
{suggestions.map((s, i) => (
|
||||||
<div
|
<div
|
||||||
key={s.name}
|
key={s.key}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === active}
|
aria-selected={i === active}
|
||||||
className={i === active ? "active" : ""}
|
className={i === active ? "active" : ""}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setText(`/${s.name} `);
|
s.apply();
|
||||||
ref.current?.focus();
|
ref.current?.focus();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="name">/{s.name}</span>
|
<span className="name">{s.label}</span>
|
||||||
<span className="desc">{s.description}</span>
|
<span className="desc">{s.description}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -208,7 +259,9 @@ export function Composer({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setText(e.target.value);
|
setText(e.target.value);
|
||||||
setActive(0);
|
setActive(0);
|
||||||
|
updateMention(e.target.value, e.target.selectionStart ?? e.target.value.length);
|
||||||
}}
|
}}
|
||||||
|
onClick={(e) => updateMention(e.currentTarget.value, e.currentTarget.selectionStart ?? 0)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onPaste={(e) => {
|
onPaste={(e) => {
|
||||||
const pasted = [...e.clipboardData.files];
|
const pasted = [...e.clipboardData.files];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||||||
import { useMemo, useSyncExternalStore } from "react";
|
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||||
import { post } from "../api";
|
import { get, post } from "../api";
|
||||||
import { fmtRelative, useT, type Key } from "../i18n";
|
import { fmtRelative, useT, type Key } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
@@ -27,6 +27,26 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
|||||||
const permissions = useStore((s) => s.permissions);
|
const permissions = useStore((s) => s.permissions);
|
||||||
const questions = useStore((s) => s.questions);
|
const questions = useStore((s) => s.questions);
|
||||||
const me = useStore((s) => s.me);
|
const me = useStore((s) => s.me);
|
||||||
|
// Full-text search over titles and messages.
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [hits, setHits] = useState<{ id: string; title: string; cwd: string; agent: string; updated_at: number; snippet: string }[]>();
|
||||||
|
useEffect(() => {
|
||||||
|
const q = query.trim();
|
||||||
|
if (q.length < 2) {
|
||||||
|
setHits(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
get<typeof hits>(`/sessions/search?q=${encodeURIComponent(q)}`)
|
||||||
|
.then((h) => alive && setHits(h ?? []))
|
||||||
|
.catch(() => alive && setHits([]));
|
||||||
|
}, 250);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
const attention = useMemo(() => {
|
const attention = useMemo(() => {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
@@ -60,9 +80,36 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
|||||||
+ {t("sidebar.newSession")}
|
+ {t("sidebar.newSession")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="session-search">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
placeholder={t("sidebar.search")}
|
||||||
|
aria-label={t("sidebar.search")}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Escape" && setQuery("")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<nav className="session-list">
|
<nav className="session-list">
|
||||||
{sessions.length === 0 && <div className="empty">{t("sidebar.noSessions")}</div>}
|
{hits && (
|
||||||
{groups.map((g) => (
|
<>
|
||||||
|
<div className="session-group">{t("sidebar.searchResults", { n: hits.length })}</div>
|
||||||
|
{hits.length === 0 && <div className="empty">{t("sidebar.noResults")}</div>}
|
||||||
|
{hits.map((h) => (
|
||||||
|
<button key={h.id} className={`session-item ${h.id === activeId ? "active" : ""}`} onClick={() => navigate(`/s/${h.id}`)}>
|
||||||
|
<div className="title">
|
||||||
|
<span>{h.title || t("session.untitled")}</span>
|
||||||
|
</div>
|
||||||
|
{h.snippet && <div className="sub snippet">{h.snippet}</div>}
|
||||||
|
<div className="sub">
|
||||||
|
{basename(h.cwd)} · {h.agent} · {fmtRelative(h.updated_at)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!hits && sessions.length === 0 && <div className="empty">{t("sidebar.noSessions")}</div>}
|
||||||
|
{!hits && groups.map((g) => (
|
||||||
<div key={g.key}>
|
<div key={g.key}>
|
||||||
<div className="session-group">{t(g.key)}</div>
|
<div className="session-group">{t(g.key)}</div>
|
||||||
{g.items.map((s) => (
|
{g.items.map((s) => (
|
||||||
|
|||||||
+11
-1
@@ -123,6 +123,7 @@ export const de: Messages = {
|
|||||||
"git.commit": "Commit",
|
"git.commit": "Commit",
|
||||||
"git.noCommits": "Noch keine Commits.",
|
"git.noCommits": "Noch keine Commits.",
|
||||||
"git.noDiff": "Keine Unterschiede.",
|
"git.noDiff": "Keine Unterschiede.",
|
||||||
|
"composer.folder": "Ordner",
|
||||||
"composer.attachedFiles": "Angehängte Dateien (im Projekt, mit deinen Tools lesen):",
|
"composer.attachedFiles": "Angehängte Dateien (im Projekt, mit deinen Tools lesen):",
|
||||||
"composer.uploading": "Lade {n} hoch…",
|
"composer.uploading": "Lade {n} hoch…",
|
||||||
"usage.title": "Modelle und Verbrauch dieser Session",
|
"usage.title": "Modelle und Verbrauch dieser Session",
|
||||||
@@ -234,6 +235,15 @@ export const de: Messages = {
|
|||||||
"settings.reasoningHidden": "Aus",
|
"settings.reasoningHidden": "Aus",
|
||||||
"settings.reasoningHint": "Die Überlegungen des Modells (sofern Modell und Provider sie liefern).",
|
"settings.reasoningHint": "Die Überlegungen des Modells (sofern Modell und Provider sie liefern).",
|
||||||
"settings.reasoningLive": "Beim Denken offen, danach einklappen",
|
"settings.reasoningLive": "Beim Denken offen, danach einklappen",
|
||||||
|
"chat.jumpLatest": "Zur neuesten Nachricht",
|
||||||
|
"sidebar.search": "Chats durchsuchen…",
|
||||||
|
"sidebar.searchResults": "Treffer ({n})",
|
||||||
|
"sidebar.noResults": "Nichts gefunden",
|
||||||
|
"chat.fork": "Forken: in einer Kopie dieses Chats weitermachen",
|
||||||
|
"chat.forked": "Geforkt – du bist jetzt in der Kopie",
|
||||||
|
"chat.export": "Exportieren",
|
||||||
|
"chat.exportMd": "Markdown (.md)",
|
||||||
|
"chat.exportJson": "JSON (.json)",
|
||||||
"chat.empty": "Schreib eine Nachricht, um zu starten.",
|
"chat.empty": "Schreib eine Nachricht, um zu starten.",
|
||||||
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
|
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
|
||||||
"chat.context": "Kontext",
|
"chat.context": "Kontext",
|
||||||
@@ -244,7 +254,7 @@ export const de: Messages = {
|
|||||||
"chat.delete": "Session löschen",
|
"chat.delete": "Session löschen",
|
||||||
"chat.deleteConfirm": "Diese Session inklusive Subagenten löschen? Die Statistik bleibt erhalten.",
|
"chat.deleteConfirm": "Diese Session inklusive Subagenten löschen? Die Statistik bleibt erhalten.",
|
||||||
|
|
||||||
"composer.placeholder": "Nachricht… (/ für Befehle, Dateien anhängen oder einfügen)",
|
"composer.placeholder": "Nachricht… (/ für Befehle, @ für Dateien, Dateien anhängen oder einfügen)",
|
||||||
"composer.placeholderBusy": "Nachricht wird eingereiht und beim nächsten Schritt gesendet…",
|
"composer.placeholderBusy": "Nachricht wird eingereiht und beim nächsten Schritt gesendet…",
|
||||||
"composer.send": "Senden",
|
"composer.send": "Senden",
|
||||||
"composer.queue": "Einreihen",
|
"composer.queue": "Einreihen",
|
||||||
|
|||||||
+11
-1
@@ -122,6 +122,7 @@ export const en = {
|
|||||||
"git.commit": "Commit",
|
"git.commit": "Commit",
|
||||||
"git.noCommits": "No commits yet.",
|
"git.noCommits": "No commits yet.",
|
||||||
"git.noDiff": "No differences.",
|
"git.noDiff": "No differences.",
|
||||||
|
"composer.folder": "folder",
|
||||||
"composer.attachedFiles": "Attached files (in the project, read them with your tools):",
|
"composer.attachedFiles": "Attached files (in the project, read them with your tools):",
|
||||||
"composer.uploading": "Uploading {n}…",
|
"composer.uploading": "Uploading {n}…",
|
||||||
"usage.title": "Models and usage in this session",
|
"usage.title": "Models and usage in this session",
|
||||||
@@ -233,6 +234,15 @@ export const en = {
|
|||||||
"settings.reasoningHidden": "Off",
|
"settings.reasoningHidden": "Off",
|
||||||
"settings.reasoningHint": "Thinking of the model (if the model and provider return it).",
|
"settings.reasoningHint": "Thinking of the model (if the model and provider return it).",
|
||||||
"settings.reasoningLive": "Open while thinking, then collapse",
|
"settings.reasoningLive": "Open while thinking, then collapse",
|
||||||
|
"chat.jumpLatest": "Jump to the latest message",
|
||||||
|
"sidebar.search": "Search chats…",
|
||||||
|
"sidebar.searchResults": "Results ({n})",
|
||||||
|
"sidebar.noResults": "Nothing found",
|
||||||
|
"chat.fork": "Fork: continue in a copy of this chat",
|
||||||
|
"chat.forked": "Forked – you are now in the copy",
|
||||||
|
"chat.export": "Export",
|
||||||
|
"chat.exportMd": "Markdown (.md)",
|
||||||
|
"chat.exportJson": "JSON (.json)",
|
||||||
"chat.empty": "Send a message to start.",
|
"chat.empty": "Send a message to start.",
|
||||||
"chat.compacted": "Conversation compacted – summary",
|
"chat.compacted": "Conversation compacted – summary",
|
||||||
"chat.context": "Context",
|
"chat.context": "Context",
|
||||||
@@ -243,7 +253,7 @@ export const en = {
|
|||||||
"chat.delete": "Delete session",
|
"chat.delete": "Delete session",
|
||||||
"chat.deleteConfirm": "Delete this session including its subagents? Statistics are kept.",
|
"chat.deleteConfirm": "Delete this session including its subagents? Statistics are kept.",
|
||||||
|
|
||||||
"composer.placeholder": "Message… (/ for commands, attach or paste files)",
|
"composer.placeholder": "Message… (/ for commands, @ for files, attach or paste files)",
|
||||||
"composer.placeholderBusy": "Message is queued and sent at the next step…",
|
"composer.placeholderBusy": "Message is queued and sent at the next step…",
|
||||||
"composer.send": "Send",
|
"composer.send": "Send",
|
||||||
"composer.queue": "Queue",
|
"composer.queue": "Queue",
|
||||||
|
|||||||
+118
-5
@@ -1362,6 +1362,8 @@ a.button:hover {
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.dots i,
|
.dots i,
|
||||||
.spinner,
|
.spinner,
|
||||||
|
.star line,
|
||||||
|
.star-spin,
|
||||||
.busy-ring,
|
.busy-ring,
|
||||||
.tool.running,
|
.tool.running,
|
||||||
.streaming .markdown > :last-child::after,
|
.streaming .markdown > :last-child::after,
|
||||||
@@ -1964,11 +1966,43 @@ table.grid.sheet th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.star {
|
.star {
|
||||||
color: var(--accent2);
|
width: 16px;
|
||||||
font-size: 15px;
|
height: 16px;
|
||||||
width: 1em;
|
flex-shrink: 0;
|
||||||
display: inline-block;
|
vertical-align: -3px;
|
||||||
text-align: center;
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.star line {
|
||||||
|
stroke: var(--accent2);
|
||||||
|
stroke-width: 2.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
opacity: 0.25;
|
||||||
|
animation: star-ray 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
.star-spin {
|
||||||
|
transform-origin: 0 0;
|
||||||
|
animation: spin 6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes star-ray {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.think-icon {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--fg3);
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.think-icon circle {
|
||||||
|
fill: var(--fg3);
|
||||||
|
stroke: none;
|
||||||
}
|
}
|
||||||
.activity-text {
|
.activity-text {
|
||||||
color: var(--accent2);
|
color: var(--accent2);
|
||||||
@@ -2204,3 +2238,82 @@ button.danger-solid {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- jump to latest message ---------------------------------------------------- */
|
||||||
|
.jump-latest {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 14px;
|
||||||
|
height: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
.jump-latest button {
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateY(-100%);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- session search -------------------------------------------------------------- */
|
||||||
|
.session-search {
|
||||||
|
padding: 0 12px 8px;
|
||||||
|
}
|
||||||
|
.session-search input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.session-item .snippet {
|
||||||
|
white-space: normal;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--fg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- small popup menu (export) ---------------------------------------------- */
|
||||||
|
.menu-pop {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.menu-pop > summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.menu-pop > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.menu-pop > summary:hover {
|
||||||
|
background: var(--bg2);
|
||||||
|
}
|
||||||
|
.menu-pop-body {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--bg3);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.menu-pop-body a {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--fg1);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.menu-pop-body a:hover {
|
||||||
|
background: var(--bg2);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user