diff --git a/src-tauri/src/acp/opencode_plugins.rs b/src-tauri/src/acp/opencode_plugins.rs index 4d04c84af..706629701 100644 --- a/src-tauri/src/acp/opencode_plugins.rs +++ b/src-tauri/src/acp/opencode_plugins.rs @@ -3,7 +3,6 @@ use std::fs; use std::path::{Path, PathBuf}; use serde::Serialize; -use tokio::io::AsyncBufReadExt; use crate::web::event_bridge::{emit_event, EventEmitter}; @@ -507,16 +506,21 @@ pub async fn install_missing_plugins( let emitter_clone = emitter.clone(); let task_id_clone = task_id.clone(); + // `collect_lines_lossy` (not `Lines`/`next_line()`) matters here: bun/npm + // can emit OEM-codepage bytes (e.g. GBK on a zh-CN Windows) for localized + // OS-level error text, which `next_line()` chokes on and silently drops — + // truncating the live install log at the first non-UTF-8 byte. The + // collected return value is unused (the failure message below is built from + // the exit code, not captured stderr), so it is discarded. let stdout_handle = tokio::spawn({ let emitter = emitter_clone.clone(); let task_id = task_id_clone.clone(); async move { if let Some(stdout) = stdout { - let reader = tokio::io::BufReader::new(stdout); - let mut lines = reader.lines(); - while let Ok(Some(line)) = lines.next_line().await { - emit_plugin_event(&emitter, &task_id, PluginInstallEventKind::Log, &line); - } + crate::process::collect_lines_lossy(tokio::io::BufReader::new(stdout), |line| { + emit_plugin_event(&emitter, &task_id, PluginInstallEventKind::Log, line); + }) + .await; } } }); @@ -526,11 +530,10 @@ pub async fn install_missing_plugins( let task_id = task_id_clone; async move { if let Some(stderr) = stderr { - let reader = tokio::io::BufReader::new(stderr); - let mut lines = reader.lines(); - while let Ok(Some(line)) = lines.next_line().await { - emit_plugin_event(&emitter, &task_id, PluginInstallEventKind::Log, &line); - } + crate::process::collect_lines_lossy(tokio::io::BufReader::new(stderr), |line| { + emit_plugin_event(&emitter, &task_id, PluginInstallEventKind::Log, line); + }) + .await; } } }); diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 8a230c349..3a712eaa4 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -677,7 +677,7 @@ async fn run_npm_streaming( task_id: &str, emitter: &EventEmitter, ) -> Result<(bool, String), AcpError> { - use tokio::io::{AsyncBufReadExt, BufReader}; + use tokio::io::BufReader; let mut cmd = crate::process::tokio_command("npm"); for arg in args { @@ -695,16 +695,20 @@ async fn run_npm_streaming( let emitter_clone = emitter.clone(); let task_id_owned = task_id.to_string(); + // `collect_lines_lossy` (not `Lines`/`next_line()`) matters here: npm can + // emit OEM-codepage bytes (e.g. GBK on a zh-CN Windows) for localized + // OS-level error text, which `next_line()` chokes on and silently drops — + // truncating both the live install log and the stderr this function + // returns for the caller's error message. let stdout_handle = tokio::spawn({ let emitter = emitter_clone.clone(); let task_id = task_id_owned.clone(); async move { if let Some(out) = stdout { - let reader = BufReader::new(out); - let mut lines = reader.lines(); - while let Ok(Some(line)) = lines.next_line().await { - emit_agent_install_event(&emitter, &task_id, AgentInstallEventKind::Log, &line); - } + crate::process::collect_lines_lossy(BufReader::new(out), |line| { + emit_agent_install_event(&emitter, &task_id, AgentInstallEventKind::Log, line); + }) + .await; } } }); @@ -713,19 +717,20 @@ async fn run_npm_streaming( let emitter = emitter_clone; let task_id = task_id_owned; async move { - let mut collected = String::new(); - if let Some(err) = stderr { - let reader = BufReader::new(err); - let mut lines = reader.lines(); - while let Ok(Some(line)) = lines.next_line().await { - emit_agent_install_event(&emitter, &task_id, AgentInstallEventKind::Log, &line); - if !collected.is_empty() { - collected.push('\n'); - } - collected.push_str(&line); + match stderr { + Some(err) => { + crate::process::collect_lines_lossy(BufReader::new(err), |line| { + emit_agent_install_event( + &emitter, + &task_id, + AgentInstallEventKind::Log, + line, + ); + }) + .await } + None => String::new(), } - collected } }); @@ -8168,12 +8173,32 @@ pub async fn acp_install_uv_tool( pub(crate) async fn acp_detect_agent_local_version_core( agent_type: AgentType, conn: &sea_orm::DatabaseConnection, + emitter: &EventEmitter, ) -> Result, AcpError> { + // Snapshot the stored version before probing so we can tell whether this + // detection actually moves it. The settings page re-runs this for every + // agent on each open; emitting unconditionally would fan a reload storm out + // to every `useAcpAgents()` consumer, so we only notify on a real change. + let previous = agent_setting_service::get_by_agent_type(conn, agent_type) + .await + .ok() + .flatten() + .and_then(|m| m.installed_version); + let detected = detect_local_version(agent_type).await; if let Some(version) = detected.clone() { let _ = agent_setting_service::set_installed_version(conn, agent_type, Some(version.clone())) .await; + // Heal the composer's install status. The input box reads + // `installed_version` straight from this row and shows "not installed" + // while it's null (`acp_list_agents_core` never probes npm for it). When + // a live probe discovers a version the DB never recorded — an agent + // installed outside codeg, or by a build predating version tracking — + // wake `useAcpAgents()` so the composer stops claiming it's missing. + if previous.as_deref() != Some(version.as_str()) { + emit_acp_agents_updated(emitter, "local_version_detected", Some(agent_type)); + } return Ok(Some(version)); } @@ -8190,15 +8215,15 @@ pub(crate) async fn acp_detect_agent_local_version_core( registry::AgentDistribution::Binary { .. } ) { let _ = agent_setting_service::set_installed_version(conn, agent_type, None).await; + // Mirror the heal in the clearing direction: a binary that vanished from + // disk must flip the composer back to "not installed". + if previous.is_some() { + emit_acp_agents_updated(emitter, "local_version_cleared", Some(agent_type)); + } return Ok(None); } - let fallback = agent_setting_service::get_by_agent_type(conn, agent_type) - .await - .ok() - .flatten() - .and_then(|m| m.installed_version); - Ok(fallback) + Ok(previous) } #[cfg(feature = "tauri-runtime")] @@ -8206,8 +8231,10 @@ pub(crate) async fn acp_detect_agent_local_version_core( pub async fn acp_detect_agent_local_version( agent_type: AgentType, db: State<'_, AppDatabase>, + app: tauri::AppHandle, ) -> Result, AcpError> { - acp_detect_agent_local_version_core(agent_type, &db.conn).await + let emitter = EventEmitter::Tauri(app); + acp_detect_agent_local_version_core(agent_type, &db.conn, &emitter).await } pub(crate) async fn acp_prepare_npx_agent_core( diff --git a/src-tauri/src/commands/office_tools.rs b/src-tauri/src/commands/office_tools.rs index f58dd279c..8ff9239c9 100644 --- a/src-tauri/src/commands/office_tools.rs +++ b/src-tauri/src/commands/office_tools.rs @@ -29,7 +29,7 @@ use crate::commands::experts::{ use crate::app_error::AppCommandError; use crate::commands::folders::resolve_tree_path; use crate::models::agent::AgentType; -use crate::process::tokio_command; +use crate::process::{collect_lines_lossy, tokio_command}; use crate::web::event_bridge::EventEmitter; // ─── Error type ───────────────────────────────────────────────────────── @@ -680,58 +680,6 @@ fn bounded_tail(s: &str, max: usize) -> String { format!("…{}", &s[start..]) } -/// Read `reader` line-by-line as UTF-8-*lossy* text, invoking `on_line` for each -/// line (trailing newline trimmed) and returning the accumulated text. -/// -/// Unlike a `Lines`/`next_line()` loop — which returns `Err(InvalidData)` and so -/// aborts the whole stream on the first non-UTF-8 byte — this preserves a -/// non-UTF-8 line lossily. PowerShell emits OEM-codepage bytes (e.g. GBK on a -/// zh-CN Windows) for non-ASCII installer/error text, so without this a single -/// localized line would truncate both the live log and the failure-diagnostic -/// tail. A genuine read error records a short note and stops — `break`, never -/// `continue`, so a persistent error can't spin. -async fn collect_lines_lossy(mut reader: R, mut on_line: F) -> String -where - R: tokio::io::AsyncBufRead + Unpin, - F: FnMut(&str), -{ - use tokio::io::AsyncBufReadExt; - - let mut buf = Vec::new(); - let mut collected = String::new(); - loop { - buf.clear(); - match reader.read_until(b'\n', &mut buf).await { - Ok(0) => break, // EOF - Ok(_) => { - // Match `Lines` semantics: strip a trailing '\n' then one '\r'. - if buf.last() == Some(&b'\n') { - buf.pop(); - if buf.last() == Some(&b'\r') { - buf.pop(); - } - } - let line = String::from_utf8_lossy(&buf); - on_line(line.as_ref()); - if !collected.is_empty() { - collected.push('\n'); - } - collected.push_str(line.as_ref()); - } - Err(e) => { - let note = format!(""); - on_line(¬e); - if !collected.is_empty() { - collected.push('\n'); - } - collected.push_str(¬e); - break; - } - } - } - collected -} - /// Stream `child`'s stdout+stderr line-by-line as OfficeCLI install Log events, /// bounded by `timeout`. Returns the exit status (`None` on timeout) plus the /// collected stdout/stderr tails for failure diagnostics. @@ -1768,52 +1716,4 @@ mod tests { assert!(tail.chars().skip(1).all(|c| c == 'あ')); } - #[tokio::test] - async fn collect_lines_lossy_preserves_lines_around_invalid_utf8() { - use std::io::Cursor; - // A non-UTF-8 segment (0xFF 0xFE — invalid start bytes, like GBK output - // on a non-English Windows) sits between two valid lines. The old - // `next_line()` loop would abort here and drop "third"; this must not. - let data = b"first\n\xff\xfe garbage\nthird\n".to_vec(); - let mut seen: Vec = Vec::new(); - let collected = - collect_lines_lossy(Cursor::new(data), |l| seen.push(l.to_string())).await; - - assert_eq!(seen.len(), 3, "all three lines emitted: {seen:?}"); - assert_eq!(seen[0], "first"); - assert_eq!(seen[2], "third"); - assert!( - seen[1].contains('\u{fffd}'), - "invalid bytes preserved lossily, not dropped: {:?}", - seen[1] - ); - assert!(collected.contains("first") && collected.contains("third")); - assert!(collected.contains('\u{fffd}')); - } - - #[tokio::test] - async fn collect_lines_lossy_handles_crlf_and_partial_last_line() { - use std::io::Cursor; - // CRLF endings trimmed like `Lines`; a final line with no trailing - // newline is still emitted (then EOF stops the loop). - let data = b"a\r\nb\r\nno-newline".to_vec(); - let mut seen: Vec = Vec::new(); - let collected = - collect_lines_lossy(Cursor::new(data), |l| seen.push(l.to_string())).await; - - assert_eq!(seen, vec!["a", "b", "no-newline"]); - assert_eq!(collected, "a\nb\nno-newline"); - } - - #[tokio::test] - async fn collect_lines_lossy_empty_input_yields_nothing() { - use std::io::Cursor; - let mut seen: Vec = Vec::new(); - let collected = - collect_lines_lossy(Cursor::new(Vec::::new()), |l| seen.push(l.to_string())) - .await; - - assert!(seen.is_empty()); - assert!(collected.is_empty()); - } } diff --git a/src-tauri/src/parsers/kimi_code.rs b/src-tauri/src/parsers/kimi_code.rs index f943bb0e4..f03f0c6b9 100644 --- a/src-tauri/src/parsers/kimi_code.rs +++ b/src-tauri/src/parsers/kimi_code.rs @@ -13,9 +13,9 @@ use crate::models::{ }; use crate::parsers::{ compute_session_stats, folder_name_from_path, infer_context_window_max_tokens, - is_safe_subagent_id, latest_turn_total_usage_tokens, merge_context_window_stats, - relocate_orphaned_tool_results, resolve_patch_line_numbers, structurize_read_tool_output, - title_from_user_text, truncate_str, AgentParser, ParseError, + is_safe_subagent_id, merge_context_window_stats, relocate_orphaned_tool_results, + resolve_patch_line_numbers, structurize_read_tool_output, title_from_user_text, truncate_str, + AgentParser, ParseError, }; /// Resolve Kimi Code's data home, honoring `KIMI_CODE_HOME`, else `~/.kimi-code` @@ -186,7 +186,12 @@ impl KimiCodeParser { resolve_patch_line_numbers(&mut turns, cwd.as_deref()); let model = read_session_log_model(session_dir).or_else(|| parsed.model_alias.clone()); - let used_tokens = latest_turn_total_usage_tokens(&turns); + // Context-window occupancy is the LATEST step's snapshot, never the + // per-turn sum (which re-counts the cached prefix once per step). + let used_tokens = parsed + .last_step_usage + .as_ref() + .and_then(kimi_context_window_used_tokens_from_usage); let max_tokens = infer_context_window_max_tokens(model.as_deref()); let session_stats = merge_context_window_stats(compute_session_stats(&turns), used_tokens, max_tokens); @@ -295,6 +300,13 @@ struct WireParse { /// Number of content-bearing records — used to decide whether the session is /// worth listing at all. content_events: u32, + /// The most recent *single* `usage.record` snapshot — NOT the per-turn sum + /// that lands on the messages' `usage`. Kimi emits one usage record per step, + /// and every step's `inputCacheRead` re-reads the same growing context prefix, + /// so summing a multi-step turn's records over-counts the cached context many + /// times over. The context window's current occupancy is the input side of the + /// latest step alone (see `kimi_context_window_used_tokens_from_usage`). + last_step_usage: Option, } fn main_wire_path(session_dir: &Path) -> PathBuf { @@ -488,6 +500,10 @@ fn parse_wire(path: &Path, agents_dir: Option<&Path>) -> WireParse { } "usage.record" => { if let Some(usage) = usage_from_record(value.get("usage")) { + // Snapshot the latest step for the context-window occupancy + // (see `WireParse::last_step_usage`), then fold it into the + // per-turn sum that feeds the cumulative usage meter. + wp.last_step_usage = Some(usage.clone()); pending_usage = Some(match pending_usage.take() { Some(prev) => add_usage(prev, usage), None => usage, @@ -772,6 +788,24 @@ fn add_usage(a: TurnUsage, b: TurnUsage) -> TurnUsage { } } +/// The context-window *occupancy* implied by a single `usage.record`: the input +/// side of that one request (`inputOther + inputCacheRead + inputCacheCreation`). +/// Output tokens are excluded — they are the model's reply, not context the +/// request occupied — mirroring the Claude parser's occupancy formula. +/// +/// This must be fed the LATEST step's record, never the per-turn sum: Kimi emits +/// one record per step and each step's `inputCacheRead` re-reads the same growing +/// prefix, so summing a multi-step turn's records over-counts the cached context +/// many times over (a 26-step turn reported ~840K "used" against a 262K window — a +/// false 100%). The final step's input side is the true current occupancy. +fn kimi_context_window_used_tokens_from_usage(usage: &TurnUsage) -> Option { + let used = usage + .input_tokens + .saturating_add(usage.cache_creation_input_tokens) + .saturating_add(usage.cache_read_input_tokens); + (used > 0).then_some(used) +} + fn text_message( id: String, role: MessageRole, @@ -1135,6 +1169,71 @@ mod tests { assert_eq!(usage.output_tokens, 37 + 36); assert_eq!(usage.input_tokens, 7962 + 265); assert_eq!(usage.cache_read_input_tokens, 9472 + 17408); + + // Context-window occupancy is the LATEST step's input side (265 + 17408), + // NOT the per-turn sum (8227 + 26880) that the cumulative meter reports — + // summing every step would re-count the cached prefix and inflate the bar. + assert_eq!( + detail + .session_stats + .as_ref() + .and_then(|s| s.context_window_used_tokens), + Some(265 + 17408), + ); + } + + #[test] + fn context_window_used_is_last_step_snapshot_not_turn_sum() { + // Reproduces the reported bug: a single turn whose many tool-use steps + // each re-read the growing context from cache. Summing every step's + // `inputCacheRead` blows past the context window (a false 100%); the true + // occupancy is the LAST step's input side. + let root = unique_root("ctxwindow"); + let sid = "sess-ctx"; + write_session( + &root, + "wd_app_feed", + sid, + &json!({"title":"long single turn"}), + &[ + json!({"type":"turn.prompt","input":[{"type":"text","text":"do a lot of work"}],"origin":{"kind":"user"},"time":1782276649000i64}), + // step 1 + json!({"type":"context.append_loop_event","event":{"type":"tool.call","toolCallId":"t1","name":"Bash","args":{"command":"a"}},"time":1782276650000i64}), + json!({"type":"context.append_loop_event","event":{"type":"tool.result","toolCallId":"t1","result":{"output":"ok"}},"time":1782276650500i64}), + json!({"type":"usage.record","usage":{"inputOther":1000,"output":50,"inputCacheRead":100000,"inputCacheCreation":0},"usageScope":"turn","time":1782276650600i64}), + // step 2 + json!({"type":"context.append_loop_event","event":{"type":"tool.call","toolCallId":"t2","name":"Bash","args":{"command":"b"}},"time":1782276651000i64}), + json!({"type":"context.append_loop_event","event":{"type":"tool.result","toolCallId":"t2","result":{"output":"ok"}},"time":1782276651500i64}), + json!({"type":"usage.record","usage":{"inputOther":500,"output":40,"inputCacheRead":150000,"inputCacheCreation":0},"usageScope":"turn","time":1782276651600i64}), + // step 3 (final) — the snapshot that represents current occupancy + json!({"type":"context.append_loop_event","event":{"type":"content.part","part":{"type":"text","text":"done"}},"time":1782276652000i64}), + json!({"type":"usage.record","usage":{"inputOther":200,"output":30,"inputCacheRead":180000,"inputCacheCreation":0},"usageScope":"turn","time":1782276652100i64}), + ], + ); + + let parser = KimiCodeParser::with_base_dir(root.clone()); + let stats = parser + .get_conversation(sid) + .expect("detail") + .session_stats + .expect("session stats"); + + // Context window = LAST step's input side (200 + 0 + 180000), NOT the sum + // of every step's cache read (100000 + 150000 + 180000 = 430000), which + // would exceed a Kimi context window and clamp to a false 100%. + assert_eq!( + stats.context_window_used_tokens, + Some(200 + 180000), + "context window uses the latest step snapshot, not the per-turn sum" + ); + + // The cumulative usage meter is UNCHANGED: it still sums every step. + let total = stats.total_usage.expect("total usage"); + assert_eq!(total.cache_read_input_tokens, 100000 + 150000 + 180000); + assert_eq!(total.input_tokens, 1000 + 500 + 200); + assert_eq!(total.output_tokens, 50 + 40 + 30); + + std::fs::remove_dir_all(root.parent().unwrap_or(&root)).ok(); } #[test] diff --git a/src-tauri/src/process.rs b/src-tauri/src/process.rs index 9d6a0c943..5b5fc01c7 100644 --- a/src-tauri/src/process.rs +++ b/src-tauri/src/process.rs @@ -515,3 +515,107 @@ pub fn ensure_user_npm_prefix_in_path() { } } } + +/// Read `reader` line-by-line as UTF-8-*lossy* text, invoking `on_line` for each +/// line (trailing newline trimmed) and returning the accumulated text. +/// +/// Unlike a `Lines`/`next_line()` loop — which returns `Err(InvalidData)` and so +/// aborts the whole stream on the first non-UTF-8 byte — this preserves a +/// non-UTF-8 line lossily. PowerShell/npm emit OEM-codepage bytes (e.g. GBK on a +/// zh-CN Windows) for non-ASCII installer/error text, so without this a single +/// localized line would truncate both the live log and the failure-diagnostic +/// tail. A genuine read error records a short note and stops — `break`, never +/// `continue`, so a persistent error can't spin. +pub(crate) async fn collect_lines_lossy(mut reader: R, mut on_line: F) -> String +where + R: tokio::io::AsyncBufRead + Unpin, + F: FnMut(&str), +{ + use tokio::io::AsyncBufReadExt; + + let mut buf = Vec::new(); + let mut collected = String::new(); + loop { + buf.clear(); + match reader.read_until(b'\n', &mut buf).await { + Ok(0) => break, // EOF + Ok(_) => { + // Match `Lines` semantics: strip a trailing '\n' then one '\r'. + if buf.last() == Some(&b'\n') { + buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + } + let line = String::from_utf8_lossy(&buf); + on_line(line.as_ref()); + if !collected.is_empty() { + collected.push('\n'); + } + collected.push_str(line.as_ref()); + } + Err(e) => { + let note = format!(""); + on_line(¬e); + if !collected.is_empty() { + collected.push('\n'); + } + collected.push_str(¬e); + break; + } + } + } + collected +} + +#[cfg(test)] +mod tests { + use super::collect_lines_lossy; + use std::io::Cursor; + + #[tokio::test] + async fn collect_lines_lossy_preserves_lines_around_invalid_utf8() { + // A non-UTF-8 segment (0xFF 0xFE — invalid start bytes, like GBK output + // on a non-English Windows) sits between two valid lines. The old + // `next_line()` loop would abort here and drop "third"; this must not. + let data = b"first\n\xff\xfe garbage\nthird\n".to_vec(); + let mut seen: Vec = Vec::new(); + let collected = + collect_lines_lossy(Cursor::new(data), |l| seen.push(l.to_string())).await; + + assert_eq!(seen.len(), 3, "all three lines emitted: {seen:?}"); + assert_eq!(seen[0], "first"); + assert_eq!(seen[2], "third"); + assert!( + seen[1].contains('\u{fffd}'), + "invalid bytes preserved lossily, not dropped: {:?}", + seen[1] + ); + assert!(collected.contains("first") && collected.contains("third")); + assert!(collected.contains('\u{fffd}')); + } + + #[tokio::test] + async fn collect_lines_lossy_handles_crlf_and_partial_last_line() { + // CRLF endings trimmed like `Lines`; a final line with no trailing + // newline is still emitted (then EOF stops the loop). + let data = b"a\r\nb\r\nno-newline".to_vec(); + let mut seen: Vec = Vec::new(); + let collected = + collect_lines_lossy(Cursor::new(data), |l| seen.push(l.to_string())).await; + + assert_eq!(seen, vec!["a", "b", "no-newline"]); + assert_eq!(collected, "a\nb\nno-newline"); + } + + #[tokio::test] + async fn collect_lines_lossy_empty_input_yields_nothing() { + let mut seen: Vec = Vec::new(); + let collected = + collect_lines_lossy(Cursor::new(Vec::::new()), |l| seen.push(l.to_string())) + .await; + + assert!(seen.is_empty()); + assert!(collected.is_empty()); + } +} diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index d52fea111..dc8f2b0fd 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -863,9 +863,11 @@ pub async fn acp_detect_agent_local_version( Json(params): Json, ) -> Result>, AppCommandError> { let db = &state.db; - let result = acp_commands::acp_detect_agent_local_version_core(params.agent_type, &db.conn) - .await - .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + let emitter = state.emitter.clone(); + let result = + acp_commands::acp_detect_agent_local_version_core(params.agent_type, &db.conn, &emitter) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; Ok(Json(result)) } diff --git a/src-tauri/tests/snapshots/parsers_snapshot__kimi_code_detail.snap b/src-tauri/tests/snapshots/parsers_snapshot__kimi_code_detail.snap index 1cebdddcd..aadd74dcb 100644 --- a/src-tauri/tests/snapshots/parsers_snapshot__kimi_code_detail.snap +++ b/src-tauri/tests/snapshots/parsers_snapshot__kimi_code_detail.snap @@ -88,8 +88,8 @@ expression: detail }, "total_tokens": 4180, "total_duration_ms": 0, - "context_window_used_tokens": 4180, + "context_window_used_tokens": 2060, "context_window_max_tokens": 262144, - "context_window_usage_percent": 1.59454345703125 + "context_window_usage_percent": 0.78582763671875 } } diff --git a/src/components/automations/composer-invocations.tsx b/src/components/automations/composer-invocations.tsx index de6395e2a..8ac3e1d1d 100644 --- a/src/components/automations/composer-invocations.tsx +++ b/src/components/automations/composer-invocations.tsx @@ -16,6 +16,7 @@ import { } from "@/components/chat/composer/invocation-reference" import type { ReferenceAttrs } from "@/components/chat/composer/types" import { useAgentSkills } from "@/hooks/use-agent-skills" +import { rankByTextMatch } from "@/lib/fuzzy-text-match" import { cn } from "@/lib/utils" import type { AgentSkillItem, @@ -103,23 +104,19 @@ export function useComposerInvocations({ const commands = useMemo(() => { if (!open || triggerChar !== "/" || availableCommands.length === 0) return [] - const f = filter.toLowerCase() - return availableCommands.filter((c) => c.name.toLowerCase().includes(f)) + return rankByTextMatch(filter, availableCommands, (c) => c.name) }, [open, triggerChar, availableCommands, filter]) const matchedSkills = useMemo(() => { // Skills autocomplete is Codex-only and triggered by `$`. if (!isCodex || !open || triggerChar !== "$" || skills.length === 0) return [] - const f = filter.toLowerCase() - if (!f) return skills - const nameMatches: AgentSkillItem[] = [] - const idOnlyMatches: AgentSkillItem[] = [] - for (const skill of skills) { - if (skill.name.toLowerCase().includes(f)) nameMatches.push(skill) - else if (skill.id.toLowerCase().includes(f)) idOnlyMatches.push(skill) - } - return [...nameMatches, ...idOnlyMatches] + return rankByTextMatch( + filter, + skills, + (skill) => skill.name, + (skill) => skill.id + ) }, [isCodex, open, triggerChar, skills, filter]) const count = commands.length + matchedSkills.length diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index 9daa9c3f1..f7b198cd9 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -125,12 +125,15 @@ export const ChatInput = memo(function ChatInput({ const isConnecting = status === "connecting" // Active/historical conversations dock the composer at the very bottom of the - // message list, so it gets a bit more bottom breathing room (pb-4) than the - // compact default. The welcome/draft composer (`flush`) keeps its tighter pb-1 - // — it sits in a roomy empty state and supplies its own px-4 gutter. + // message list. The attached folder/branch selector row now sits at the + // composer's bottom edge, so the docked composer keeps only a tight bottom gap + // (pb-1) — matching the row's own `pt-1` top gap, so the selectors read as + // evenly spaced above and below rather than floating over a wide margin. The + // welcome/draft composer (`flush`) uses the same pb-1 but supplies its own + // px-4 gutter. return (
event.stopPropagation()} > {queue && diff --git a/src/components/chat/composer/plain-text-content.test.ts b/src/components/chat/composer/plain-text-content.test.ts new file mode 100644 index 000000000..b2af022cf --- /dev/null +++ b/src/components/chat/composer/plain-text-content.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest" + +import { + decidePastedPlainText, + textToInlineContent, +} from "./plain-text-content" + +describe("decidePastedPlainText", () => { + it("inserts text/plain when the clipboard carries an external HTML fragment", () => { + // What a browser puts on the clipboard when a URL is copied from the address + // bar: the anchor text is the page , not the URL. + const decision = decidePastedPlainText({ + html: '<a href="https://github.com/">GitHub · Change is constant. GitHub keeps you ahead. · GitHub</a>', + text: "https://github.com/", + }) + expect(decision).toEqual(textToInlineContent("https://github.com/")) + // Specifically the URL, never the page title. + expect(decision).toEqual([{ type: "text", text: "https://github.com/" }]) + }) + + it("defers to ProseMirror for a pure text/plain clipboard (no HTML)", () => { + // Nothing can mislead the schema without an HTML flavor, so keep the native + // paste path unchanged. + expect( + decidePastedPlainText({ html: "", text: "https://github.com/" }) + ).toBeNull() + }) + + it("defers to ProseMirror for HTML copied from within the editor (data-pm-slice)", () => { + // ProseMirror's serializeForClipboard tags native copies with data-pm-slice. + // Forcing text/plain here would corrupt structure: two paragraphs come across + // as "one\n\ntwo" (a blank line the composer never had), and a hard break + // comes across as "" (the line break lost). The native HTML round-trip is + // exact, so we must defer. + expect( + decidePastedPlainText({ + html: '<p data-pm-slice="0 0 []">one</p><p>two</p>', + text: "one\n\ntwo", + }) + ).toBeNull() + }) + + it("defers to ProseMirror when the HTML carries our reference badges", () => { + // Defensive fallback: a badge fragment without the slice wrapper must still + // round-trip via the HTML parser rather than collapse to its plain-text token. + expect( + decidePastedPlainText({ + html: '<span data-reference data-ref-type="file" data-ref-id="lib.rs" data-label="lib.rs">lib.rs</span>', + text: "lib.rs", + }) + ).toBeNull() + }) + + it("defers when an external HTML fragment has no text/plain flavor", () => { + expect( + decidePastedPlainText({ html: '<img src="x.png">', text: "" }) + ).toBeNull() + }) + + it("maps newlines in the pasted text to hard breaks", () => { + const decision = decidePastedPlainText({ + html: "<div>one<br>two</div>", + text: "one\ntwo", + }) + expect(decision).toEqual([ + { type: "text", text: "one" }, + { type: "hardBreak" }, + { type: "text", text: "two" }, + ]) + }) +}) diff --git a/src/components/chat/composer/plain-text-content.ts b/src/components/chat/composer/plain-text-content.ts index 52275a198..b977394da 100644 --- a/src/components/chat/composer/plain-text-content.ts +++ b/src/components/chat/composer/plain-text-content.ts @@ -34,3 +34,56 @@ export function textToDoc(text: string): JSONContent { content: [{ type: "paragraph", content: textToInlineContent(text) }], } } + +/** The two clipboard flavors the paste decision looks at. */ +export interface ClipboardTextSnapshot { + /** `text/html` payload (empty string when the clipboard has none). */ + html: string + /** `text/plain` payload (empty string when the clipboard has none). */ + text: string +} + +/** + * Decide how the plain-text composer should paste a clipboard's text. + * + * Returns the inline content to insert (from `text/plain`, `\n` → hardBreak) when + * the clipboard carries an *external* `text/html` fragment, or `null` to let + * ProseMirror handle the paste with its default behavior. + * + * Why this exists: the composer schema has no Link mark (see + * {@link "./editor-config".buildComposerExtensions}). Browsers put a rich + * `<a href="URL">Page Title</a>` fragment on the clipboard when a URL is copied + * from the address bar; ProseMirror's default paste prefers `text/html`, drops + * the href (the mark isn't in the schema), and keeps the anchor **text** — so a + * copied URL pastes as the page's `<title>` instead of the URL. Forcing + * `text/plain` for that *external* fragment fixes it, but must leave these to + * ProseMirror (return `null`): + * - No `text/html` at all — a pure `text/plain` paste is already correct. + * - HTML copied from within a ProseMirror editor (this composer), which + * `serializeForClipboard` tags with a `data-pm-slice` marker. Its native + * round-trip must win: it restores paragraphs, hard breaks, and reference + * badges exactly. Forcing `text/plain` here would corrupt content — the + * clipboard text drops hard breaks (they serialize to `""`) and widens + * paragraph gaps into blank lines (block separator `"\n\n"` vs the composer's + * `"\n"`), so a copied two-line message would paste as one line or gain a + * blank line. + * - HTML carrying our reference badges (`<span data-reference>`) even without a + * slice wrapper — a badge must never downgrade to its plain-text token. + */ +export function decidePastedPlainText( + snapshot: ClipboardTextSnapshot +): JSONContent[] | null { + // Only an HTML payload can mislead the plain-text schema; a text/plain-only + // clipboard already pastes correctly, so defer to ProseMirror. + if (!snapshot.html) return null + // Copied from within a ProseMirror editor: defer so its native HTML round-trip + // restores structure/hard breaks/badges exactly (see the doc comment). + if (snapshot.html.includes("data-pm-slice")) return null + // Defensive: reference badge HTML lacking the slice wrapper still defers so the + // badge round-trips instead of collapsing to its token. + if (snapshot.html.includes("data-reference")) return null + // External rich fragment: insert its plain-text flavor verbatim (never the + // HTML). Nothing sensible to insert when there is no text/plain, so defer. + if (!snapshot.text) return null + return textToInlineContent(snapshot.text) +} diff --git a/src/components/chat/composer/rich-composer.test.tsx b/src/components/chat/composer/rich-composer.test.tsx index e9d688e58..4198fa9b5 100644 --- a/src/components/chat/composer/rich-composer.test.tsx +++ b/src/components/chat/composer/rich-composer.test.tsx @@ -270,3 +270,83 @@ describe("RichComposer paste without formatting (Ctrl/⌘+Shift+V)", () => { expect(event.defaultPrevented).toBe(false) }) }) + +/** Dispatch a `paste` carrying the given clipboard flavors at the editor. */ +function dispatchPaste( + dom: HTMLElement, + flavors: { html?: string; text?: string } +): void { + const html = flavors.html ?? "" + const text = flavors.text ?? "" + const clipboardData = { + getData: (type: string) => + type === "text/html" ? html : type === "text/plain" ? text : "", + setData: () => {}, + clearData: () => {}, + types: [html && "text/html", text && "text/plain"].filter(Boolean), + files: [] as unknown as FileList, + items: [] as unknown, + } + const event = new Event("paste", { bubbles: true, cancelable: true }) + Object.defineProperty(event, "clipboardData", { value: clipboardData }) + act(() => { + dom.dispatchEvent(event) + }) +} + +describe("RichComposer text paste (plain-text schema)", () => { + it("pastes a URL as text/plain, not the browser's title-bearing <a> fragment", async () => { + const { ref } = await mount() + act(() => ref.current?.focus()) + const dom = ref.current?.getEditor()?.view.dom as HTMLElement + // Exactly what a browser writes when a URL is copied from the address bar: + // the anchor text is the page <title>, so the default HTML parse (no Link + // mark) would keep the title. We must insert the URL instead. + dispatchPaste(dom, { + html: '<a href="https://github.com/">GitHub · Change is constant. GitHub keeps you ahead. · GitHub</a>', + text: "https://github.com/", + }) + expect(ref.current?.getText()).toBe("https://github.com/") + }) + + it("preserves structure for content copied from within the editor (data-pm-slice), without blank lines", async () => { + const { ref } = await mount() + act(() => ref.current?.focus()) + const dom = ref.current?.getEditor()?.view.dom as HTMLElement + // A native ProseMirror copy of two paragraphs: HTML tagged with data-pm-slice, + // and text/plain "one\n\ntwo" (block separator is "\n\n"). We must defer to + // the native HTML paste — forcing text/plain would serialize back to + // "one\n\ntwo", introducing a blank line the composer never had. + dispatchPaste(dom, { + html: '<p data-pm-slice="0 0 []">one</p><p>two</p>', + text: "one\n\ntwo", + }) + expect(ref.current?.getText()).toBe("one\ntwo") + }) + + it("reconstructs a reference badge pasted from within the composer", async () => { + const { ref } = await mount() + act(() => ref.current?.focus()) + const dom = ref.current?.getEditor()?.view.dom as HTMLElement + // A real composer copy carries both the slice wrapper and the badge span; + // defer to ProseMirror so the badge round-trips instead of collapsing to its + // plain-text token. + dispatchPaste(dom, { + html: '<p data-pm-slice="0 0 []"><span data-reference data-ref-type="file" data-ref-id="a.ts" data-label="a.ts" data-uri="file:///a.ts">a.ts</span></p>', + text: "a.ts", + }) + expect(JSON.stringify(ref.current?.getJSON())).toContain( + '"type":"reference"' + ) + }) + + it("does not insert text when the host consumes the paste as files", async () => { + const onPasteFiles = vi.fn(() => true) + const { ref } = await mount({ onPasteFiles }) + act(() => ref.current?.focus()) + const dom = ref.current?.getEditor()?.view.dom as HTMLElement + dispatchPaste(dom, { html: "<a href='x'>x</a>", text: "some text" }) + expect(onPasteFiles).toHaveBeenCalledTimes(1) + expect(ref.current?.getText()).toBe("") + }) +}) diff --git a/src/components/chat/composer/rich-composer.tsx b/src/components/chat/composer/rich-composer.tsx index 2bcf4c834..7a82872da 100644 --- a/src/components/chat/composer/rich-composer.tsx +++ b/src/components/chat/composer/rich-composer.tsx @@ -18,7 +18,11 @@ import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" import { cn } from "@/lib/utils" import { buildComposerExtensions } from "./editor-config" -import { textToDoc, textToInlineContent } from "./plain-text-content" +import { + decidePastedPlainText, + textToDoc, + textToInlineContent, +} from "./plain-text-content" import { serializeDocToText } from "./to-prompt-blocks" import { decideComposerKey } from "./submit-key" import type { @@ -355,8 +359,28 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>( } return false }, - handlePaste: (_view, event) => - onPasteFilesRef.current?.(event) === true, + handlePaste: (_view, event) => { + // Images/files first: the host may consume them as attachments + // out-of-band, in which case the editor must not also insert text. + if (onPasteFilesRef.current?.(event) === true) return true + // Plain-text composer: prefer the clipboard's text/plain over an + // external text/html fragment. Copying a URL from a browser address + // bar puts `<a href="URL">Page Title</a>` on the clipboard; the + // default HTML parse drops the href (no Link mark) and keeps the + // title, so the URL would paste as the page title. See + // decidePastedPlainText for what still defers to ProseMirror (pure + // plain text, and our own reference badges). + const editor = editorInstanceRef.current + const clipboard = event.clipboardData + if (!editor || !clipboard) return false + const inline = decidePastedPlainText({ + html: clipboard.getData("text/html"), + text: clipboard.getData("text/plain"), + }) + if (!inline) return false + editor.chain().insertContent(inline).run() + return true + }, handleDrop: (_view, event) => onDropFilesRef.current?.(event) === true, }, onCreate: ({ editor }) => { diff --git a/src/components/chat/conversation-context-bar.test.tsx b/src/components/chat/conversation-context-bar.test.tsx index aaa321a13..2c0b5c84a 100644 --- a/src/components/chat/conversation-context-bar.test.tsx +++ b/src/components/chat/conversation-context-bar.test.tsx @@ -82,11 +82,12 @@ beforeEach(() => { afterEach(() => cleanup()) -// The desktop conversation header renders this folder-only picker in place of -// the old folder-name breadcrumb. `next-intl` is mocked to echo keys, so a -// translated label like the chat-mode item reads back as its key. +// The conversation header renders the owning folder as a STATIC breadcrumb — +// folder (and chat-mode) switching moved to the below-composer picker row, so +// the header never opens a popover, even for a draft. `next-intl` is mocked to +// echo keys, so a translated label like the chat-mode item reads back as its key. describe("ConversationHeaderFolderPicker", () => { - it("switches folders for a draft via openNewConversationTab", async () => { + it("renders a draft's folder name as a static (non-switchable) breadcrumb", async () => { const other = mkFolder({ id: 2, name: "other-repo", path: "/repo/other" }) useAppWorkspaceStore.setState({ folders: [repo, other], @@ -97,13 +98,11 @@ describe("ConversationHeaderFolderPicker", () => { const user = userEvent.setup() render(<ConversationHeaderFolderPicker tabId="tab-draft" />) - // Draft → editable trigger showing the current folder name. + // Even a draft is static now: clicking the label opens no folder list, so + // the other repo is unreachable and no switch fires. await user.click(screen.getByRole("button", { name: /repo/ })) - await user.click(await screen.findByText("other-repo")) - - expect(openNewConversationTab).toHaveBeenCalledWith(2, "/repo/other", { - inheritFromActive: true, - }) + expect(screen.queryByText("other-repo")).toBeNull() + expect(openNewConversationTab).not.toHaveBeenCalled() }) it("renders a static (non-switchable) chip for an existing conversation", async () => { diff --git a/src/components/chat/conversation-context-bar.tsx b/src/components/chat/conversation-context-bar.tsx index f55c28025..ba445d70f 100644 --- a/src/components/chat/conversation-context-bar.tsx +++ b/src/components/chat/conversation-context-bar.tsx @@ -32,6 +32,7 @@ import { resolvePickerSelectedFolderId, } from "@/lib/folder-display" import { FolderAliasLabel } from "@/components/conversations/folder-alias-label" +import { BranchDropdown } from "@/components/layout/branch-dropdown" interface ConversationContextBarProps { extraContent?: React.ReactNode @@ -149,7 +150,6 @@ export const ConversationHeaderFolderPicker = memo( const isChatMode = ownTab.isChat === true || ownFolder?.kind === "chat" if (!ownFolder && !isChatMode) return null - const isNewConversation = ownTab.conversationId == null // Worktree folders surface their parent (root repo) row; resolve that same // folder so the header shows the repo's `alias [ name ]` (matching the // sidebar) rather than the worktree dir's own name. Git/path ops still use @@ -169,6 +169,10 @@ export const ConversationHeaderFolderPicker = memo( ? formatFolderLabelWithAlias(displayFolder) : displayFolderName + // The header folder is a static, un-themed breadcrumb: folder (and chat-mode) + // switching now lives in the below-composer picker row, so even a new + // conversation draft shows a plain label here — never a popover trigger, + // never the theme color. return ( <FolderPicker variant="header" @@ -177,7 +181,7 @@ export const ConversationHeaderFolderPicker = memo( currentFolderName={displayFolderName} alias={displayFolderAlias} title={`${t("folderTitle")}: ${titleFolderName}`} - editable={isNewConversation} + editable={false} onSelect={async (folderId) => { const target = folders.find((f) => f.id === folderId) if (!target) return @@ -221,6 +225,159 @@ export const ConversationHeaderFolderPicker = memo( ConversationHeaderFolderPicker.displayName = "ConversationHeaderFolderPicker" +// ============================================================================ +// ConversationFolderBranchPicker — folder + branch buttons rendered below the +// message input on every platform. Lets the user switch the draft's folder (and +// its git branch) right where they type; the conversation header shows the same +// folder as a static breadcrumb. +// ============================================================================ + +interface ConversationFolderBranchPickerProps { + tabId?: string | null +} + +export const ConversationFolderBranchPicker = memo( + function ConversationFolderBranchPicker({ + tabId, + }: ConversationFolderBranchPickerProps) { + const t = useTranslations("Folder.conversationContextBar") + const tabs = useTabStore((s) => s.tabs) + const activeTabId = useTabStore((s) => s.activeTabId) + const { openNewConversationTab, openChatModeTab } = useTabActions() + const folders = useAppWorkspaceStore((s) => s.folders) + const allFolders = useAppWorkspaceStore((s) => s.allFolders) + + const ownTab = useMemo(() => { + const lookupId = tabId ?? activeTabId + return tabs.find((x) => x.id === lookupId) ?? null + }, [tabs, tabId, activeTabId]) + + const ownFolder = useMemo( + () => + ownTab + ? (allFolders.find((f) => f.id === ownTab.folderId) ?? null) + : null, + [ownTab, allFolders] + ) + + // The folder picker lists only top-level repos — worktree folders + // (`parent_id != null`) are reached through the branch picker, not here, so + // they're hidden to keep this picker a clean repo switcher. Hidden chat + // folders are excluded too (they're a per-conversation implementation + // detail, not a switchable repo). + const topLevelFolders = useMemo( + () => excludeChatFolders(filterTopLevelFolders(folders)), + [folders] + ) + + if (!ownTab) return null + // Chat mode: either a draft flagged `isChat` (no folder yet) or a bound + // conversation whose folder is a hidden chat folder. Show the folder + // chip (so the user can switch back to a real folder while drafting) but + // suppress the branch picker — a folderless chat has no git branch. + const isChatMode = ownTab.isChat === true || ownFolder?.kind === "chat" + if (!ownFolder && !isChatMode) return null + + const isNewConversation = ownTab.conversationId == null + // Worktree folders surface their parent (root repo) name here; the picker's + // own list below keeps real folder names/paths for selection, and every + // git/path operation still uses `ownFolder` (the worktree) unchanged. + const displayFolderName = isChatMode + ? t("chatModeLabel") + : resolveFolderDisplayName(ownFolder!, allFolders) + // When the conversation lives in a worktree, the picker highlights its + // parent repo (the worktree itself isn't listed). Display-only — the tab's + // real folder/working dir is untouched. Chat mode has no real folder, so + // `-1` (no row) is highlighted. + const pickerSelectedId = + isChatMode || !ownFolder ? -1 : resolvePickerSelectedFolderId(ownFolder) + + return ( + <> + <FolderPicker + folders={topLevelFolders} + currentFolderId={pickerSelectedId} + currentFolderName={displayFolderName} + title={`${t("folderTitle")}: ${displayFolderName}`} + editable={isNewConversation} + onSelect={async (folderId) => { + const target = folders.find((f) => f.id === folderId) + if (!target) return + try { + // Route through openNewConversationTab so the target folder's + // saved default agent is applied. The function's existing- + // draft branch reuses ownTab via the singleton invariant and + // runs the disconnect-then-patch dance for folder+agent + // changes. `inheritFromActive: true` preserves the user's + // current agent when the target folder has no pinned default + // — "I'm switching folders, keep my workflow". + openNewConversationTab(target.id, target.path, { + inheritFromActive: true, + }) + toast.success(t("toasts.folderChanged", { name: target.name })) + } catch (err) { + console.error( + "[ConversationFolderBranchPicker] switch folder failed:", + err + ) + toast.error(t("toasts.openFolderFailed")) + } + }} + labelEmpty={t("noFolders")} + labelSearch={t("searchFolder")} + labelChatMode={t("chatModeLabel")} + isChatMode={isChatMode} + onSelectChatMode={() => { + try { + openChatModeTab() + toast.success(t("toasts.switchedToChatMode")) + } catch (err) { + console.error( + "[ConversationFolderBranchPicker] switch to chat mode failed:", + err + ) + toast.error(t("toasts.openFolderFailed")) + } + }} + /> + + {/* Branch selector — the rich BranchDropdown (pull / commit / push / + new branch / worktree / stash / merge / rebase / … + branch tree). + Mounted per tile with the tile's OWN folder so a tiled view keeps + every tile's chip live; self-hides in chat mode and offers an init + option for a non-git folder. */} + <BranchDropdown folder={ownFolder} isChatMode={isChatMode} /> + </> + ) + } +) + +ConversationFolderBranchPicker.displayName = "ConversationFolderBranchPicker" + +/** + * Mirror the visibility check inside `ConversationFolderBranchPicker` so the + * parent can decide whether to render its wrapper row at all. The picker + * itself returns `null` when no tab/folder is resolved (e.g. while folders + * are still loading on first paint), and the parent must avoid rendering an + * otherwise-empty wrapper in that interval. + */ +export function useConversationFolderBranchPickerVisible( + tabId?: string | null +): boolean { + const tabs = useTabStore((s) => s.tabs) + const activeTabId = useTabStore((s) => s.activeTabId) + const allFolders = useAppWorkspaceStore((s) => s.allFolders) + const lookupId = tabId ?? activeTabId + const ownTab = tabs.find((x) => x.id === lookupId) ?? null + const ownFolder = ownTab + ? (allFolders.find((f) => f.id === ownTab.folderId) ?? null) + : null + // The row shows below the composer on every platform. Chat-mode drafts have + // no resolvable folder yet, but the row must still show so the folder chip + // (and the chat-mode item) stay reachable. + return Boolean(ownTab && (ownFolder || ownTab.isChat)) +} + // ============================================================================ // FolderPicker // ============================================================================ @@ -288,13 +445,16 @@ const FolderPicker = memo(function FolderPicker({ type="button" title={title} className={cn( - "flex min-w-0 items-center rounded-sm text-sm outline-none transition-colors", + "flex shrink-0 items-center rounded-sm text-sm outline-none transition-colors", editable ? "cursor-pointer text-primary hover:text-primary/80 focus-visible:ring-[3px] focus-visible:ring-ring/50" : "cursor-default text-muted-foreground" )} > - <span className="max-w-[220px] truncate">{headerLabel}</span> + {/* Full display — the folder crumb never truncates; the neighbouring + conversation title takes the ellipsis when the header runs out of + room (see conversation-detail-header). */} + <span className="whitespace-nowrap">{headerLabel}</span> </button> ) : ( <Button diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index ad00e5d50..ea43aabc9 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -72,6 +72,11 @@ vi.mock("@/components/chat/conversation-context-bar", () => ({ }: { extraContent?: React.ReactNode }) => <div data-testid="ctx-bar">{extraContent}</div>, + // The composer imports these to render the below-input folder/branch row. + // Keep it hidden here (visibility → false) so these tests exercise the bare + // composer without pulling in the picker's tab-store/git dependencies. + ConversationFolderBranchPicker: () => null, + useConversationFolderBranchPickerVisible: () => false, })) vi.mock("@/lib/platform", () => ({ isDesktop: () => false, diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 1668f5b87..96618b558 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -112,7 +112,11 @@ import { type AttachFileToSessionDetail, type AppendTextToSessionDetail, } from "@/lib/session-attachment-events" -import { ConversationContextBar } from "@/components/chat/conversation-context-bar" +import { + ConversationContextBar, + ConversationFolderBranchPicker, + useConversationFolderBranchPickerVisible, +} from "@/components/chat/conversation-context-bar" import { InlineModeSelector } from "@/components/chat/mode-selector" import { InlineSessionConfigSelector } from "@/components/chat/session-config-selector" import { ModelOptionPicker } from "@/components/chat/model-option-picker" @@ -142,6 +146,7 @@ import { loadMessageInputDraftV2, saveMessageInputDraftV2, } from "@/lib/message-input-draft" +import { rankByTextMatch } from "@/lib/fuzzy-text-match" import { RichComposer, type RichComposerHandle, @@ -966,6 +971,9 @@ export function MessageInput({ const hasAnySelector = showConfigLoading || hasConfigOptions || showModeLoading || showModeSelector const hasInlineSelectors = hasConfigOptions || showModeSelector + const hasFolderBranchPicker = + useConversationFolderBranchPickerVisible(attachmentTabId) + const folderBranchPickerAttached = hasFolderBranchPicker const imageAttachments = useMemo( () => attachments.filter( @@ -1007,20 +1015,16 @@ export function MessageInput({ const [slashDropdownOpen, setSlashDropdownOpen] = useState(false) const [slashDropdownSearch, setSlashDropdownSearch] = useState("") const slashDropdownInputRef = useRef<HTMLInputElement>(null) - const filteredSlashDropdownCommands = useMemo(() => { - const q = slashDropdownSearch.toLowerCase().trim() - if (!q) return slashCommands - const nameMatches: typeof slashCommands = [] - const descOnlyMatches: typeof slashCommands = [] - for (const cmd of slashCommands) { - if (cmd.name.toLowerCase().includes(q)) { - nameMatches.push(cmd) - } else if (cmd.description?.toLowerCase().includes(q)) { - descOnlyMatches.push(cmd) - } - } - return [...nameMatches, ...descOnlyMatches] - }, [slashCommands, slashDropdownSearch]) + const filteredSlashDropdownCommands = useMemo( + () => + rankByTextMatch( + slashDropdownSearch, + slashCommands, + (cmd) => cmd.name, + (cmd) => cmd.description + ), + [slashCommands, slashDropdownSearch] + ) const handleSlashDropdownOpenChange = useCallback((open: boolean) => { setSlashDropdownOpen(open) if (!open) setSlashDropdownSearch("") @@ -1039,28 +1043,19 @@ export function MessageInput({ const filteredSlashCommands = useMemo(() => { if (!slashMenuOpen || slashCommands.length === 0) return [] if (slashTriggerChar !== "/") return [] - const filter = slashFilter.toLowerCase() - return slashCommands.filter((cmd) => - cmd.name.toLowerCase().includes(filter) - ) + return rankByTextMatch(slashFilter, slashCommands, (cmd) => cmd.name) }, [slashMenuOpen, slashCommands, slashTriggerChar, slashFilter]) const filteredSlashSkills = useMemo(() => { // Skills autocomplete is Codex-only and triggered by `$`. if (agentType !== "codex") return [] if (!slashMenuOpen || availableSkills.length === 0) return [] if (slashTriggerChar !== "$") return [] - const filter = slashFilter.toLowerCase() - if (!filter) return availableSkills - const nameMatches: typeof availableSkills = [] - const idOnlyMatches: typeof availableSkills = [] - for (const skill of availableSkills) { - if (skill.name.toLowerCase().includes(filter)) { - nameMatches.push(skill) - } else if (skill.id.toLowerCase().includes(filter)) { - idOnlyMatches.push(skill) - } - } - return [...nameMatches, ...idOnlyMatches] + return rankByTextMatch( + slashFilter, + availableSkills, + (skill) => skill.name, + (skill) => skill.id + ) }, [slashMenuOpen, availableSkills, agentType, slashTriggerChar, slashFilter]) const slashAutocompleteCount = filteredSlashCommands.length + filteredSlashSkills.length @@ -2989,10 +2984,20 @@ export function MessageInput({ </div> </div> )} - {/* Layout-neutral group (`display:contents`): it once clipped the attached - mobile folder/branch row, which is retired, so it just wraps the - composer's context menu without affecting layout. */} - <div className="contents"> + {/* When the folder/branch row is attached below the composer, this group + clips both into one rounded box (`overflow-hidden rounded-xl`); the + drag-active ring rides the wrapper so it isn't clipped. Standalone + (no row) it's layout-neutral (`display:contents`). */} + <div + className={cn( + folderBranchPickerAttached + ? "overflow-hidden rounded-xl transition-colors" + : "contents", + folderBranchPickerAttached && + showDragActive && + "ring-1 ring-primary/40" + )} + > <ContextMenu onOpenChange={handleContextMenuOpenChange}> {/* Disabled in non-secure web (no async clipboard read) so the native context menu — whose Paste still works over the editor text — is @@ -3009,17 +3014,21 @@ export function MessageInput({ // the default `border-input`, which is near-invisible at rest and // vanishes over a workspace background image); it adapts per theme // (dark ink in light mode, light ink in dark) and stays legible. - // Focus still swaps to `border-ring` below. `bg-background + // Focus still swaps to `border-ring` below. + "codeg-composer-chrome @container relative flex flex-col rounded-xl border border-foreground/20 bg-transparent transition-colors", + // Standard focus ring — always shown when the composer is + // focused (the plain default input style). `bg-background // ws-transparent-bg`: opaque surface normally, but with a // workspace-bg image the composer goes transparent to reveal the // real image like the rest of the canvas (no frosted treatment) — - // the border stays. The surface lives on the composer itself — - // the old below-composer folder/branch row that used to wrap it - // is gone. - "codeg-composer-chrome @container relative flex flex-col rounded-xl border border-foreground/20 bg-background ws-transparent-bg transition-colors", - // Standard focus ring — always shown when the composer is focused - // (the plain default input style). - "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50", + // the border stays. Off (no image) it's the plain background, + // unchanged. When the folder/branch row is attached below, the + // solid surface + an INSET focus ring live here so the shared + // rounded box (clipped by the wrapper) reads as one control and + // the ring isn't clipped away. + folderBranchPickerAttached + ? "bg-background ws-transparent-bg focus-within:border-ring focus-within:ring-[3px] focus-within:ring-inset focus-within:ring-ring/50" + : "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50", // Active session, tiled across multiple sessions: a gradient // flows around the border to mark which tile is active — but ONLY // while the composer itself is not focused. Focusing it hides the @@ -3027,7 +3036,9 @@ export function MessageInput({ // A lone/non-tiled session (showActiveFlow=false) and inactive // tiles show the plain default border. showActiveFlow && "codeg-composer-flow", - showDragActive && "ring-1 ring-primary/40", + !folderBranchPickerAttached && + showDragActive && + "ring-1 ring-primary/40", className )} > @@ -3570,6 +3581,21 @@ export function MessageInput({ </ContextMenuSub> </ContextMenuContent> </ContextMenu> + {hasFolderBranchPicker && ( + // `pl-2` mirrors the action bar's `px-2` so this row lines up with the + // composer above. Kept on the rem scale (no px literals) so it tracks + // UI zoom; the folder icon then aligns with the centered "+" icon + // because both buttons add the same 1px transparent border (paired + // with the picker buttons' `px-1.5`). + <div + className={cn( + "flex items-center gap-1 pl-2 text-xs text-muted-foreground", + folderBranchPickerAttached ? "rounded-b-xl pt-1 pr-2" : "mt-1.5" + )} + > + <ConversationFolderBranchPicker tabId={attachmentTabId} /> + </div> + )} </div> <ImagePreviewDialog src={ diff --git a/src/components/conversations/conversation-detail-header.tsx b/src/components/conversations/conversation-detail-header.tsx index 782c1caa9..96e86532e 100644 --- a/src/components/conversations/conversation-detail-header.tsx +++ b/src/components/conversations/conversation-detail-header.tsx @@ -252,7 +252,12 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" aria-hidden /> - <span className="truncate text-sm text-foreground/90" title={title}> + {/* min-w-0 flex-1: the title absorbs the remaining width and takes the + ellipsis, so the folder crumb on the left stays fully visible. */} + <span + className="min-w-0 flex-1 truncate text-sm text-foreground/90" + title={title} + > {displayTitle} </span> </div> diff --git a/src/components/conversations/conversation-detail-panel-layout.test.ts b/src/components/conversations/conversation-detail-panel-layout.test.ts index 514c197d6..9b895477a 100644 --- a/src/components/conversations/conversation-detail-panel-layout.test.ts +++ b/src/components/conversations/conversation-detail-panel-layout.test.ts @@ -57,33 +57,57 @@ describe("ConversationDetailPanel new conversation layout", () => { expect(welcomeHeroSource).not.toContain("bg-gradient-to-r") }) - it("has retired the below-composer folder/branch picker on every platform", () => { - // The mobile folder+branch row is gone: the folder picker lives in the - // conversation header and the branch selector in the bottom status bar, so - // the composer no longer renders (or is wrapped by) that row anywhere. + it("uses the shared attached folder branch picker treatment for all chat inputs", () => { expect(source).not.toContain("attachFolderBranchPickerToInput") expect(conversationShellSource).not.toContain( "attachFolderBranchPickerToInput" ) expect(messageInputSource).not.toContain("attachFolderBranchPickerToInput") - expect(messageInputSource).not.toContain("hasFolderBranchPicker") - expect(messageInputSource).not.toContain("folderBranchPickerAttached") - expect(messageInputSource).not.toContain("ConversationFolderBranchPicker") - expect(messageInputSource).not.toContain( - "useConversationFolderBranchPickerVisible" + expect(messageInputSource).toContain( + "const folderBranchPickerAttached = hasFolderBranchPicker" ) expect(messageInputSource).not.toContain("rounded-b-none") - // The rounded border AND the solid surface live in the always-on composer - // base (no longer gated on the removed picker): `bg-background` goes - // transparent to reveal a workspace-bg image via `ws-transparent-bg`, and - // the resting border is `border-foreground/20` (legible over an image). + const pickerStart = messageInputSource.indexOf( + "{hasFolderBranchPicker && (" + ) + const pickerEnd = messageInputSource.indexOf( + "<ImagePreviewDialog", + pickerStart + ) + expect(pickerStart).toBeGreaterThan(-1) + expect(pickerEnd).toBeGreaterThan(pickerStart) + + const pickerWrapper = messageInputSource.slice(pickerStart, pickerEnd) + expect(messageInputSource).toContain( + '"overflow-hidden rounded-xl transition-colors"' + ) + expect(messageInputSource).not.toContain("bg-muted/60") + expect(messageInputSource).toContain(': "contents"') + // The rounded border lives in the always-on base (so the active-session flow + // gradient can overlay a real 1px border without a layout shift); the + // attached folder-branch-picker treatment still adds a solid surface + // (`bg-background`, which goes transparent to reveal a workspace-bg image via + // `ws-transparent-bg` instead of frosting) + the inset focus ring on top. + // The resting border is `border-foreground/20` (a touch darker than the + // near-invisible default `border-input`, and legible over a background image). + expect(messageInputSource).toContain( + "rounded-xl border border-foreground/20 bg-transparent transition-colors" + ) expect(messageInputSource).toContain( - "rounded-xl border border-foreground/20 bg-background ws-transparent-bg transition-colors" + '"bg-background ws-transparent-bg focus-within:border-ring focus-within:ring-[3px] focus-within:ring-inset focus-within:ring-ring/50"' ) - // The inset focus-ring variant only existed for the clipped attached row; - // the standalone composer uses the normal outset ring. - expect(messageInputSource).not.toContain("focus-within:ring-inset") + expect(pickerWrapper).not.toContain("border-t border-input") + expect(pickerWrapper).not.toContain("bg-muted/30") + expect(pickerWrapper).toContain("pt-1") + expect(pickerWrapper).not.toContain("py-1") + expect(pickerWrapper).toContain("rounded-b-xl") + expect(pickerWrapper).toContain("mt-1.5") + expect(pickerWrapper).toContain("pl-2") + expect(pickerWrapper).not.toContain("pl-[") + expect(pickerWrapper).not.toContain("pl-1.5") + expect(pickerWrapper).not.toMatch(/\bborder-b\b/) + expect(pickerWrapper).not.toMatch(/\bborder-x\b/) }) it("keeps ordinary chat input constrained to the message column width", () => { @@ -91,11 +115,12 @@ describe("ConversationDetailPanel new conversation layout", () => { 'className="mx-auto w-full max-w-3xl"' ) // Ordinary (active/historical) chat input keeps its own px-4 gutter to align - // with the sibling cards in conversation-shell AND gets extra bottom room - // (pb-4) since it docks at the very bottom; only the welcome input drops the - // gutter via `flush` (the welcome column already provides px-4) and stays pb-1. + // with the sibling cards in conversation-shell AND a tight bottom gap (pb-1) + // matching the attached folder/branch row's `pt-1` top gap; only the welcome + // input drops the gutter via `flush` (the welcome column already provides + // px-4) and uses the same pb-1. expect(chatInputSource).toContain( - 'cn("pt-0", flush ? "pb-1" : "px-4 pb-4")' + 'cn("pt-0", flush ? "pb-1" : "px-4 pb-1")' ) expect(chatInputSource).toContain( 'cn(tall ? "min-h-30" : "min-h-24", "max-h-60")' diff --git a/src/components/conversations/sidebar-conversation-grouping.test.ts b/src/components/conversations/sidebar-conversation-grouping.test.ts index 7860850cb..a37bb9043 100644 --- a/src/components/conversations/sidebar-conversation-grouping.test.ts +++ b/src/components/conversations/sidebar-conversation-grouping.test.ts @@ -335,8 +335,14 @@ describe("buildRows", () => { ]) }) - it("returns an empty array when there are no folders and nothing pinned", () => { - expect(folderRows([], new Map(), {}, new Map())).toEqual([]) + it("emits the Folders header + empty hint when there are no open folders", () => { + // folderRows trims the trailing Chat section, so this is just the Folders + // portion: the header is always present (a permanent entry point) and an + // expanded empty section shows a single folders-empty hint. + expect(folderRows([], new Map(), {}, new Map())).toEqual([ + foldersHeader(0), + { kind: "folders-empty" }, + ]) }) it("hides every folder row when the Folders section is collapsed", () => { @@ -399,10 +405,12 @@ describe("buildRows", () => { chatConversations: [], chatsExpanded: true, }) - // Pinned section collapsed → header only; the always-present Chat section - // trails (empty → header + hint). + // Pinned section collapsed → header only; the always-present Folders and Chat + // sections trail (both empty → header + hint). expect(rows).toEqual([ { kind: "section", section: "pinned", expanded: false, count: 1 }, + { kind: "section", section: "folders", expanded: true, count: 0 }, + { kind: "folders-empty" }, { kind: "section", section: "chats", expanded: true, count: 0 }, { kind: "chats-empty" }, ]) @@ -554,11 +562,14 @@ describe("buildRows", () => { byFolder: new Map(), folderExpanded: {}, folderTotalCounts: new Map(), - foldersExpanded: true, + // Folders collapsed too, so this test stays focused on the Chat section: + // both sections show only their header (no empty hint) when collapsed. + foldersExpanded: false, chatConversations: [], chatsExpanded: false, }) expect(rows).toEqual([ + { kind: "section", section: "folders", expanded: false, count: 0 }, { kind: "section", section: "chats", expanded: false, count: 0 }, ]) }) @@ -571,15 +582,44 @@ describe("buildRows", () => { byFolder: new Map(), folderExpanded: {}, folderTotalCounts: new Map(), - foldersExpanded: true, + foldersExpanded: false, chatConversations: [conv(1, 99)], chatsExpanded: false, }) expect(rows).toEqual([ + { kind: "section", section: "folders", expanded: false, count: 0 }, { kind: "section", section: "chats", expanded: false, count: 1 }, ]) }) + it("always emits the Folders section, with an empty hint when no folders are open", () => { + // Mirrors the Chat section: with chats present but no open folders, the + // Folders header + a single folders-empty hint still render. (The fully-empty + // initial workspace — no folders AND no conversations — is handled by the + // list's open-folder call-to-action, not buildRows.) + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [], + byFolder: new Map(), + folderExpanded: {}, + folderTotalCounts: new Map(), + foldersExpanded: true, + chatConversations: [conv(1, 99)], + chatsExpanded: true, + }) + const foldersIdx = rows.findIndex( + (r) => r.kind === "section" && r.section === "folders" + ) + expect(rows[foldersIdx]).toEqual({ + kind: "section", + section: "folders", + expanded: true, + count: 0, + }) + expect(rows[foldersIdx + 1]).toEqual({ kind: "folders-empty" }) + }) + // ── Delegation sub-session subtree (recursive expansion) ───────────────── it("recurses into an expanded conversation's cached children at depth+1", () => { diff --git a/src/components/conversations/sidebar-conversation-grouping.ts b/src/components/conversations/sidebar-conversation-grouping.ts index 500628bf4..ec66295aa 100644 --- a/src/components/conversations/sidebar-conversation-grouping.ts +++ b/src/components/conversations/sidebar-conversation-grouping.ts @@ -308,6 +308,17 @@ export interface ChatsEmptyRow { kind: "chats-empty" } +/** + * The single empty-state hint shown under an expanded but empty "Folders" + * section ("No folders open"). Like {@link ChatsEmptyRow} it is folderless — it + * stands in for the whole (empty) folder list rather than one folder — so it + * carries no folder id and renders with a flat (non-rail) indent. Distinct from + * {@link EmptyHintRow}, which is the per-folder "this folder is empty" hint. + */ +export interface FoldersEmptyRow { + kind: "folders-empty" +} + /** * A collapsible section heading. Three exist: "pinned" (above the folders, shown * only when there are pinned conversations), "folders" (wraps the whole folder @@ -342,6 +353,7 @@ export type SidebarRow = | ConversationRow | EmptyHintRow | ChatsEmptyRow + | FoldersEmptyRow | SubsessionLoadingRow const MAX_RENDER_DEPTH = 32 @@ -445,13 +457,18 @@ function pushConversationRow( * that order: * - The "Pinned" section header + its conversations appear only when `pinned` * is non-empty, and its rows only when `pinnedExpanded`. - * - The "Folders" section header appears whenever there are folders; its folder - * rows appear only when `foldersExpanded`. Within it, order follows - * `orderedFolderIds`: a collapsed folder contributes only its header; an - * expanded empty folder contributes header + one empty-hint row; an expanded - * non-empty folder contributes header + its (already sorted) bucket. `byFolder` - * / `folderTotalCounts` exclude pinned conversations (they live in the Pinned - * section), so a folder whose only conversations are pinned reads as empty. + * - The "Folders" section header ALWAYS appears (like "Chat"), so the section + * stays a permanent entry point — its Open-folder / Clone / Import actions stay + * reachable even with nothing open. Its rows appear only when `foldersExpanded`: + * when expanded with no open folders it contributes a single `folders-empty` + * hint row; otherwise its folder rows follow `orderedFolderIds`: a collapsed + * folder contributes only its header; an expanded empty folder contributes + * header + one (per-folder) empty-hint row; an expanded non-empty folder + * contributes header + its (already sorted) bucket. `byFolder` / + * `folderTotalCounts` exclude pinned conversations (they live in the Pinned + * section), so a folder whose only conversations are pinned reads as empty. The + * fully-empty initial workspace (no folders AND no conversations) never reaches + * buildRows — the list renders its dedicated open-folder call-to-action there. * - The "Chat" section header ALWAYS appears (even with zero chat * conversations), so the section is a permanent entry point — its New-chat * affordance and an empty hint stay reachable. When expanded and empty it @@ -527,41 +544,48 @@ export function buildRows(args: { // The Folders and Chat sections sit below the (always-top) Pinned section in // an order the user controls via `sectionOrder`. Each is its own closure so - // the order they emit into `rows` is a one-line swap below — the conditional - // logic inside each (folders gated on count, chats header always present) + // the order they emit into `rows` is a one-line swap below — the row logic + // inside each (both headers always present, each with its own empty hint) // stays intact regardless of position. const pushFolders = () => { - if (orderedFolderIds.length === 0) return + // The Folders section header is always present (a permanent entry point), + // mirroring the Chat section — so a workspace with chats but no open folders + // still shows the "Folders" heading and its "add a folder" actions. The + // fully-empty initial workspace (no folders AND no conversations) never + // reaches buildRows; the list renders a dedicated open-folder CTA there. rows.push({ kind: "section", section: "folders", expanded: foldersExpanded, count: orderedFolderIds.length, }) - if (foldersExpanded) { - for (const folderId of orderedFolderIds) { - rows.push({ kind: "folder", folderId }) - const expanded = folderExpanded[folderId] ?? true - if (!expanded) continue - const convs = byFolder.get(folderId) - if (!convs || convs.length === 0) { - rows.push({ - kind: "empty", - folderId, - totalConversationCount: folderTotalCounts.get(folderId) ?? 0, - }) - continue - } - for (const conv of convs) { - pushConversationRow( - rows, - conv, - 0, - conversationExpanded, - childrenByParent, - childrenLoading - ) - } + if (!foldersExpanded) return + if (orderedFolderIds.length === 0) { + rows.push({ kind: "folders-empty" }) + return + } + for (const folderId of orderedFolderIds) { + rows.push({ kind: "folder", folderId }) + const expanded = folderExpanded[folderId] ?? true + if (!expanded) continue + const convs = byFolder.get(folderId) + if (!convs || convs.length === 0) { + rows.push({ + kind: "empty", + folderId, + totalConversationCount: folderTotalCounts.get(folderId) ?? 0, + }) + continue + } + for (const conv of convs) { + pushConversationRow( + rows, + conv, + 0, + conversationExpanded, + childrenByParent, + childrenLoading + ) } } } diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index dc0d8365d..dce8f1103 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -1994,6 +1994,16 @@ export function SidebarConversationList({ </div> ) } + if (row.kind === "folders-empty") { + // Empty "Folders" section hint — mirrors chats-empty (folderless, no rail, + // aligned with the section header's text inset). The header's own hover + // actions (Open Folder / Clone / Import) are how you add the first folder. + return ( + <div className="px-[0.5rem] py-[0.375rem] text-[0.75rem] text-muted-foreground/70"> + {t("noFolders")} + </div> + ) + } if (row.kind === "subsession-loading") { // Transient spinner at the child indent while children are fetched. The // left inset matches a depth-`row.depth` card's text start: rail axis @@ -2052,6 +2062,7 @@ export function SidebarConversationList({ if (row.kind === "folder") return `folder-${row.folderId}` if (row.kind === "empty") return `empty-${row.folderId}` if (row.kind === "chats-empty") return "chats-empty" + if (row.kind === "folders-empty") return "folders-empty" if (row.kind === "subsession-loading") return `subloading-${row.parentId}` return `conv-${row.conversation.agent_type}-${row.conversation.id}` } diff --git a/src/components/layout/branch-dropdown.tsx b/src/components/layout/branch-dropdown.tsx index d92a53b1c..9add7d52c 100644 --- a/src/components/layout/branch-dropdown.tsx +++ b/src/components/layout/branch-dropdown.tsx @@ -1,45 +1,20 @@ "use client" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { - Fragment, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react" -import { - ArchiveRestore, - Archive, - ChevronRight, - CloudDownload, - CloudSync, - CloudUpload, - FolderGit2, + ChevronDown, FolderOpen, GitBranch, - GitBranchPlus, GitCommitHorizontal, GitFork, - GitMerge, - GitPullRequestArrow, - Globe, - Loader2, - Trash2, } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" import { Dialog, DialogContent, @@ -58,7 +33,6 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" -import { ScrollArea } from "@/components/ui/scroll-area" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -84,24 +58,19 @@ import { ConflictDialog } from "@/components/layout/conflict-dialog" import { StashDialog } from "@/components/layout/stash-dialog" import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog" import { toErrorMessage } from "@/lib/app-error" -import { resolveFolderDisplayName } from "@/lib/folder-display" import { useSwitchToBranch } from "@/hooks/use-switch-to-branch" import { buildBranchTree, buildRemoteBranchSections, - containsBranch, - expandedKeysForBranch, localBranchItems, - sectionKey, - type BranchTreeLeaf, - type BranchTreeNode, } from "@/lib/branch-tree" -import { branchRowPaddingLeft } from "@/components/layout/branch-tree-collapsible" -import { useBranchTreeExpansion } from "@/hooks/use-branch-tree-expansion" -import { cn } from "@/lib/utils" -import type { GitBranchList, GitConflictInfo } from "@/lib/types" -import { useActiveFolder } from "@/contexts/active-folder-context" -import { useIsActiveChatMode } from "@/hooks/use-is-active-chat-mode" +import { BranchSelectorList } from "@/components/layout/branch-selector-list" +import type { + BranchLeafAction, + BranchOperationMeta, +} from "@/lib/branch-selector-rows" +import { useScrollbarSafeDismiss } from "@/hooks/use-scrollbar-safe-dismiss" +import type { FolderDetail, GitBranchList, GitConflictInfo } from "@/lib/types" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useTabActions } from "@/contexts/tab-context" import { useWorkbenchRoute } from "@/contexts/workbench-route-context" @@ -135,19 +104,20 @@ interface GitPushSucceededEventPayload { } interface BranchDropdownProps { - /** Show the owning-folder name before the branch (default). The bottom - * status-bar instance sets this false to render just the branch name. */ - showFolderName?: boolean + /** The row's OWN folder (each conversation tile passes its own), not the + * active one — so a tiled view keeps every tile's branch chip live. */ + folder: FolderDetail | null + /** Whether this tile is folderless "chat mode" (self-hides the chip). */ + isChatMode: boolean } -export function BranchDropdown({ - showFolderName = true, -}: BranchDropdownProps = {}) { +// The branch chip in the below-composer folder/branch row. It's mounted once per +// conversation tile with that tile's own `folder`, and carries per-instance +// machinery (git event subscriptions + dialogs). +export function BranchDropdown({ folder, isChatMode }: BranchDropdownProps) { const t = useTranslations("Folder.branchDropdown") const tCommon = useTranslations("Folder.common") - const { activeFolder } = useActiveFolder() - const isChatMode = useIsActiveChatMode() - const allFolders = useAppWorkspaceStore((s) => s.allFolders) + const activeFolder = folder const refreshFolder = useAppWorkspaceStore((s) => s.refreshFolder) const openWorktreeFolder = useAppWorkspaceStore((s) => s.openWorktreeFolder) const { openNewConversationTab } = useTabActions() @@ -156,6 +126,10 @@ export function BranchDropdown({ const { pushAlert } = useAlertContext() const { withCredentialRetry } = useGitCredential() const switchToBranch = useSwitchToBranch() + // Grabbing the popover's inner scrollbar blurs focus, which WebKit bounces to + // an outside element that Radix reads as a dismiss — keep it open (see hook). + const { contentRef, onPointerDownOutside, onFocusOutside } = + useScrollbarSafeDismiss() const folderPath = activeFolder?.path ?? "" const folderId = activeFolder?.id ?? 0 @@ -208,30 +182,24 @@ export function BranchDropdown({ () => buildRemoteBranchSections(branchList.remote), [branchList.remote] ) - const localSectionKey = sectionKey("local") - const remoteSectionKey = sectionKey("remote") - - // Auto-expand the section + prefix groups leading to the current branch once - // the branch list has loaded. - const seedKeys = useMemo(() => { - if (!branch) return [] - if (containsBranch(localNodes, branch)) { - return [localSectionKey, ...expandedKeysForBranch(localNodes, branch)] - } - for (const section of remoteSections) { - if (containsBranch(section.nodes, branch)) { - const keys = [ - remoteSectionKey, - ...expandedKeysForBranch(section.nodes, branch), - ] - if (section.key) keys.push(section.key) - return keys - } - } - return [] - }, [branch, localNodes, remoteSections, localSectionKey, remoteSectionKey]) - - const { isExpanded, toggle } = useBranchTreeExpansion(dropdownOpen, seedKeys) + // Operations shown as a searchable block at the top of the popup; the list + // resolves each id to an icon and dispatches back through `runOperation`. + // `groupEnd` inserts a separator after that op (non-search) to restore the old + // menu's pull/fetch | commit/push | new | stash | remotes blocking. + const operations = useMemo<BranchOperationMeta[]>( + () => [ + { id: "pull", label: t("pullCode") }, + { id: "fetch", label: t("fetchRemoteBranches"), groupEnd: true }, + { id: "commit", label: t("openCommitWindow") }, + { id: "push", label: t("pushCode"), groupEnd: true }, + { id: "newBranch", label: t("newBranch") }, + { id: "newWorktree", label: t("newWorktree"), groupEnd: true }, + { id: "stash", label: t("stashChanges") }, + { id: "stashPop", label: t("stashPop"), groupEnd: true }, + { id: "manageRemotes", label: t("manageRemotes") }, + ], + [t] + ) const refresh = useCallback(() => { if (folderId) void refreshFolder(folderId) @@ -371,8 +339,7 @@ export function BranchDropdown({ }) } - // Pull, shared by the dropdown menu item and the status-bar quick-pull icon - // button beside the branch name (so both entry points behave identically). + // Pull, invoked by the dropdown's "Pull Code" menu item. function handlePull() { setDropdownOpen(false) void runGitTask( @@ -570,483 +537,167 @@ export function BranchDropdown({ } } - function renderBranchItem( - leaf: BranchTreeLeaf, - isRemote: boolean, - depth: number - ) { - const b = leaf.fullName - const label = leaf.label - const paddingLeft = branchRowPaddingLeft("dropdown", depth) - const isCurrent = b === branch - const isTrackingCurrent = - isRemote && !!branch && b.replace(/^[^/]+\//, "") === branch - const isWorktree = worktreeBranchSet.has( - isRemote ? b.replace(/^[^/]+\//, "") : b - ) - const BranchIcon = isWorktree ? FolderGit2 : GitBranch - - if (isCurrent) { - return ( - <div - key={leaf.key} - className="flex select-none items-center gap-2.5 rounded-xl py-2 pr-3 text-sm opacity-50" - style={{ paddingLeft }} - title={b} - > - <BranchIcon className="h-3.5 w-3.5 shrink-0" /> - <span className="min-w-0 flex-1 truncate">{label}</span> - <span className="shrink-0 pl-2 text-xs">{t("current")}</span> - </div> - ) + // Dispatch a top-of-list operation back to its handler. Every op closes the + // popover (some then open a dialog/window); `handlePull` closes it too. + function runOperation(opId: string) { + setDropdownOpen(false) + switch (opId) { + case "pull": + handlePull() + break + case "fetch": + void runGitTask(t("tasks.fetchInfo"), () => + withCredentialRetry((creds) => gitFetch(folderPath, creds), { + folderPath, + }) + ) + break + case "commit": + if (!folderId) return + openCommitWindow(folderId).catch((err) => { + const title = t("toasts.openCommitWindowFailed") + const msg = toErrorMessage(err) + pushAlert("error", title, msg) + toast.error(title, { description: msg }) + }) + break + case "push": + if (!folderId) return + openPushWindow(folderId).catch((err) => { + const title = t("toasts.openPushWindowFailed") + const msg = toErrorMessage(err) + pushAlert("error", title, msg) + toast.error(title, { description: msg }) + }) + break + case "newBranch": + setNewBranchName("") + setNewBranchOpen(true) + break + case "newWorktree": + handleOpenWorktreeDialog() + break + case "stash": + setStashDialogOpen(true) + break + case "stashPop": + if (!folderId) return + openStashWindow(folderId).catch((err) => { + const msg = toErrorMessage(err) + pushAlert("error", t("stashPop"), msg) + }) + break + case "manageRemotes": + setManageRemotesOpen(true) + break } - - return ( - <DropdownMenuSub key={leaf.key}> - <DropdownMenuSubTrigger - className="hover:bg-accent hover:text-accent-foreground" - style={{ paddingLeft }} - disabled={loading} - title={b} - > - <BranchIcon className="h-3.5 w-3.5 shrink-0" /> - <span className="min-w-0 flex-1 truncate">{label}</span> - </DropdownMenuSubTrigger> - <DropdownMenuSubContent> - <DropdownMenuItem - onSelect={() => { - if (isRemote) { - void handleCheckoutRemote(b) - } else { - void handleCheckout(b) - } - }} - > - <GitBranch className="h-3.5 w-3.5" /> - {t("switchToBranch")} - </DropdownMenuItem> - <DropdownMenuItem - onSelect={() => { - setDropdownOpen(false) - setConfirmAction({ type: "merge", branchName: b }) - }} - > - <GitMerge className="h-3.5 w-3.5" /> - {t("mergeBranchIntoCurrent", { - branchName: b, - currentBranch: branch ?? "-", - })} - </DropdownMenuItem> - <DropdownMenuItem - onSelect={() => { - setDropdownOpen(false) - setConfirmAction({ type: "rebase", branchName: b }) - }} - > - <GitPullRequestArrow className="h-3.5 w-3.5" /> - {t("rebaseCurrentToBranch", { - currentBranch: branch ?? "-", - branchName: b, - })} - </DropdownMenuItem> - {!isTrackingCurrent && ( - <> - <DropdownMenuSeparator /> - <DropdownMenuItem - variant="destructive" - onSelect={() => { - setDropdownOpen(false) - setConfirmAction({ - type: isRemote ? "deleteRemote" : "delete", - branchName: b, - }) - }} - > - <Trash2 className="h-3.5 w-3.5" /> - {t("deleteBranch")} - </DropdownMenuItem> - </> - )} - </DropdownMenuSubContent> - </DropdownMenuSub> - ) } - // Render the prefix tree as a flat sequence of menu items. Group rows are - // DropdownMenuItems (not plain Collapsible buttons) so Radix's roving focus / - // typeahead can reach them by keyboard; `onSelect` preventDefault keeps the - // menu open while toggling. Collapsed children simply aren't rendered. - function renderDropdownTree( - nodes: BranchTreeNode[], - depth: number, + // Dispatch an inline branch action: switch checks out directly (that handler + // closes the popover itself), the rest open the shared confirm dialog. + function runLeafAction( + action: BranchLeafAction, + fullName: string, isRemote: boolean - ): React.ReactNode[] { - return nodes.flatMap((node) => { - if (node.type === "leaf") { - return [renderBranchItem(node, isRemote, depth)] - } - const groupOpen = isExpanded(node.key) - return [ - <DropdownMenuItem - key={node.key} - aria-expanded={groupOpen} - onSelect={(e) => { - e.preventDefault() - toggle(node.key) - }} - style={{ paddingLeft: branchRowPaddingLeft("dropdown", depth) }} - > - <ChevronRight - className={cn( - "h-3.5 w-3.5 shrink-0 transition-transform", - groupOpen && "rotate-90" - )} - /> - <span className="min-w-0 flex-1 truncate">{node.label}</span> - <span className="shrink-0 pl-2 text-xs text-muted-foreground/70"> - {node.count} - </span> - </DropdownMenuItem>, - ...(groupOpen - ? renderDropdownTree(node.children, depth + 1, isRemote) - : []), - ] - }) + ) { + if (action === "switch") { + if (isRemote) void handleCheckoutRemote(fullName) + else void handleCheckout(fullName) + return + } + setDropdownOpen(false) + setConfirmAction({ type: action, branchName: fullName }) } - // Folderless chat conversations have no git branch — hide the top-bar - // selector entirely (covers both the mobile and desktop title-bar instances). + // Folderless chat conversations have no git branch — hide the branch chip + // entirely (the below-composer row still shows the folder chip beside it). if (!activeFolder || isChatMode) return null - // Worktree folders display their parent (root repo) name; paths/ids/git ops - // below still use `activeFolder` (the worktree) unchanged. - const folderName = resolveFolderDisplayName(activeFolder, allFolders) - - // In the compact bottom status bar (showFolderName=false) the branch name is - // the LEFT half of a command-style split control (a quick-pull button sits on - // the right): it inherits the bar's text-xs, goes muted→foreground on its own - // hover, and drops the primary accent. The mobile combo (showFolderName=true, - // `folder | branch`) stays a single text-sm button with a primary-highlighted - // branch so the two segments read as distinct. - const isStatusBar = !showFolderName - const triggerClassName = cn( - "flex min-w-0 items-center outline-none transition-colors cursor-default", - isStatusBar - ? "h-6 gap-1.5 hover:text-foreground" - : "gap-1 text-sm tracking-tight hover:text-foreground/80" - ) - const branchAccentClassName = showFolderName ? "text-primary" : undefined - if (!isRepo) { + // Non-git folder: no branch and nothing to pull, so a single chip (no split + // pull half) opening a one-item popover that offers to init a repo. return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> + <Popover open={dropdownOpen} onOpenChange={handleDropdownOpenChange}> + <PopoverTrigger asChild> <button - className={cn( - triggerClassName, - isStatusBar && "rounded-full px-2 hover:bg-foreground/10" - )} + type="button" + title={t("noBranch")} + className="flex h-6 min-w-0 items-center gap-1.5 rounded-full px-2 text-xs text-muted-foreground outline-none transition-colors hover:bg-foreground/10 hover:text-foreground" > - <GitFork className="h-3 w-3 shrink-0" /> - <span className="max-w-[320px] truncate"> - {showFolderName && ( - <> - {folderName} - <span className="mx-1.5 inline-block h-3 w-px bg-foreground/20 align-middle" /> - </> - )} - <span className={branchAccentClassName}>{t("noBranch")}</span> - </span> + <GitFork className="size-3 shrink-0" /> + <span className="max-w-[160px] truncate">{t("noBranch")}</span> </button> - </DropdownMenuTrigger> - <DropdownMenuContent className="min-w-64" align="start"> - <DropdownMenuItem + </PopoverTrigger> + <PopoverContent side="top" align="start" className="w-64 p-1"> + <button + type="button" disabled={loading} - onSelect={() => - runGitTask(t("tasks.initGitRepo"), () => gitInit(folderPath)) - } + onClick={() => { + setDropdownOpen(false) + void runGitTask(t("tasks.initGitRepo"), () => gitInit(folderPath)) + }} + className="flex w-full select-none items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50" > - <GitBranch className="h-3.5 w-3.5" /> + <GitBranch className="size-3.5 shrink-0" /> {t("initGitRepo")} - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> + </button> + </PopoverContent> + </Popover> ) } return ( <> - <div - className={cn( - isStatusBar - ? "group/branch flex items-center rounded-full transition-colors hover:bg-foreground/10" - : "contents" - )} - > - <DropdownMenu - open={dropdownOpen} - onOpenChange={handleDropdownOpenChange} - > - <DropdownMenuTrigger asChild> - <button - className={cn( - triggerClassName, - isStatusBar && "rounded-l-full pr-1.5 pl-2" - )} - title={ - isDetached - ? t("detachedHead", { sha: head?.short_sha ?? "" }) - : undefined - } - > - {isDetached ? ( - <GitCommitHorizontal className="h-3 w-3 shrink-0" /> - ) : ( - <GitBranch className="h-3 w-3 shrink-0" /> - )} - <span className="max-w-[320px] truncate"> - {showFolderName && ( - <> - {folderName} - <span className="mx-1.5 inline-block h-3 w-px bg-foreground/20 align-middle" /> - </> - )} - <span className={branchAccentClassName}> - {branch ?? head?.branch ?? head?.short_sha ?? t("noBranch")} - </span> - </span> - </button> - </DropdownMenuTrigger> - <DropdownMenuContent className="min-w-64" align="start"> - <DropdownMenuGroup> - <DropdownMenuItem disabled={loading} onSelect={handlePull}> - <CloudDownload className="h-3.5 w-3.5" /> - {t("pullCode")} - </DropdownMenuItem> - <DropdownMenuItem - disabled={loading} - onSelect={() => - runGitTask(t("tasks.fetchInfo"), () => - withCredentialRetry( - (creds) => gitFetch(folderPath, creds), - { - folderPath, - } - ) - ) - } - > - <CloudSync className="h-3.5 w-3.5" /> - {t("fetchRemoteBranches")} - </DropdownMenuItem> - </DropdownMenuGroup> - <DropdownMenuSeparator /> - <DropdownMenuGroup> - <DropdownMenuItem - disabled={loading} - onSelect={() => { - if (!folderId) return - setDropdownOpen(false) - openCommitWindow(folderId).catch((err) => { - const title = t("toasts.openCommitWindowFailed") - const msg = toErrorMessage(err) - pushAlert("error", title, msg) - toast.error(title, { description: msg }) - }) - }} - > - <GitCommitHorizontal className="h-3.5 w-3.5" /> - {t("openCommitWindow")} - </DropdownMenuItem> - <DropdownMenuItem - disabled={loading} - onSelect={() => { - if (!folderId) return - setDropdownOpen(false) - openPushWindow(folderId).catch((err) => { - const title = t("toasts.openPushWindowFailed") - const msg = toErrorMessage(err) - pushAlert("error", title, msg) - toast.error(title, { description: msg }) - }) - }} - > - <CloudUpload className="h-3.5 w-3.5" /> - {t("pushCode")} - </DropdownMenuItem> - </DropdownMenuGroup> - <DropdownMenuSeparator /> - <DropdownMenuGroup> - <DropdownMenuItem - disabled={loading} - onSelect={() => { - setNewBranchName("") - setNewBranchOpen(true) - }} - > - <GitBranchPlus className="h-3.5 w-3.5" /> - {t("newBranch")} - </DropdownMenuItem> - <DropdownMenuItem - disabled={loading} - onSelect={handleOpenWorktreeDialog} - > - <FolderGit2 className="h-3.5 w-3.5" /> - {t("newWorktree")} - </DropdownMenuItem> - </DropdownMenuGroup> - <DropdownMenuSeparator /> - <DropdownMenuGroup> - <DropdownMenuItem - disabled={loading} - onSelect={() => { - setDropdownOpen(false) - setStashDialogOpen(true) - }} - > - <Archive className="h-3.5 w-3.5" /> - {t("stashChanges")} - </DropdownMenuItem> - <DropdownMenuItem - disabled={loading} - onSelect={() => { - if (!folderId) return - openStashWindow(folderId).catch((err) => { - const msg = toErrorMessage(err) - pushAlert("error", t("stashPop"), msg) - }) - }} - > - <ArchiveRestore className="h-3.5 w-3.5" /> - {t("stashPop")} - </DropdownMenuItem> - </DropdownMenuGroup> - <DropdownMenuSeparator /> - <DropdownMenuGroup> - <DropdownMenuItem - disabled={loading} - onSelect={() => { - setDropdownOpen(false) - setManageRemotesOpen(true) - }} - > - <Globe className="h-3.5 w-3.5" /> - {t("manageRemotes")} - </DropdownMenuItem> - </DropdownMenuGroup> - <DropdownMenuSeparator /> - {branchLoading ? ( - <div className="flex items-center justify-center py-3"> - <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> - </div> + {/* Single chip: the branch name + chevron opens the searchable popup (pull + lives inside it now). Matches the sibling folder chip's ghost xs feel. */} + <Popover open={dropdownOpen} onOpenChange={handleDropdownOpenChange}> + <PopoverTrigger asChild> + <Button + variant="ghost" + size="xs" + title={ + isDetached + ? t("detachedHead", { sha: head?.short_sha ?? "" }) + : (branch ?? undefined) + } + className="min-w-0 gap-0.5 px-1.5" + > + {isDetached ? ( + <GitCommitHorizontal className="size-3 shrink-0 text-muted-foreground" /> ) : ( - <ScrollArea className="max-h-64"> - <DropdownMenuItem - aria-expanded={isExpanded(localSectionKey)} - onSelect={(e) => { - e.preventDefault() - toggle(localSectionKey) - }} - > - <ChevronRight - className={cn( - "h-3.5 w-3.5 shrink-0 transition-transform", - isExpanded(localSectionKey) && "rotate-90" - )} - /> - {t("localBranches", { count: branchList.local.length })} - </DropdownMenuItem> - {isExpanded(localSectionKey) && - (branchList.local.length === 0 ? ( - <DropdownMenuItem disabled> - {t("noLocalBranches")} - </DropdownMenuItem> - ) : ( - renderDropdownTree(localNodes, 0, false) - ))} - - <DropdownMenuItem - aria-expanded={isExpanded(remoteSectionKey)} - onSelect={(e) => { - e.preventDefault() - toggle(remoteSectionKey) - }} - > - <ChevronRight - className={cn( - "h-3.5 w-3.5 shrink-0 transition-transform", - isExpanded(remoteSectionKey) && "rotate-90" - )} - /> - {t("remoteBranches", { count: branchList.remote.length })} - </DropdownMenuItem> - {isExpanded(remoteSectionKey) && - (branchList.remote.length === 0 ? ( - <DropdownMenuItem disabled> - {t("noRemoteBranches")} - </DropdownMenuItem> - ) : ( - remoteSections.map((section) => - section.remoteName == null ? ( - <Fragment key="remote-single"> - {renderDropdownTree(section.nodes, 0, true)} - </Fragment> - ) : ( - <Fragment key={section.key}> - <DropdownMenuItem - aria-expanded={isExpanded(section.key)} - onSelect={(e) => { - e.preventDefault() - toggle(section.key) - }} - style={{ - paddingLeft: branchRowPaddingLeft("dropdown", 0), - }} - > - <ChevronRight - className={cn( - "h-3 w-3 shrink-0 transition-transform", - isExpanded(section.key) && "rotate-90" - )} - /> - <span className="min-w-0 flex-1 truncate"> - {section.remoteName} - </span> - <span className="shrink-0 pl-2 text-xs text-muted-foreground/70"> - {section.count} - </span> - </DropdownMenuItem> - {isExpanded(section.key) && - renderDropdownTree(section.nodes, 1, true)} - </Fragment> - ) - ) - ))} - </ScrollArea> + <GitBranch className="size-3 shrink-0 text-muted-foreground" /> )} - </DropdownMenuContent> - </DropdownMenu> - {/* Quick-pull button — the right half of the split, mirroring the - command block. Its own hover highlights just this icon; the shared - container tints the whole pill and the divider brightens. */} - {isStatusBar && ( - <> - <span - aria-hidden - className="h-3 w-px shrink-0 bg-border/70 transition-colors group-hover/branch:bg-foreground/20" - /> - <button - type="button" - onClick={handlePull} - disabled={loading} - title={t("pullCode")} - className="flex h-6 items-center rounded-r-full pr-2 pl-1.5 text-muted-foreground outline-none transition-colors hover:text-foreground disabled:opacity-50 disabled:hover:text-muted-foreground" - > - <CloudDownload className="h-3 w-3" /> - </button> - </> - )} - </div> + <span className="max-w-[160px] truncate"> + {branch ?? head?.branch ?? head?.short_sha ?? t("noBranch")} + </span> + <ChevronDown className="size-3 shrink-0 text-muted-foreground/60" /> + </Button> + </PopoverTrigger> + {/* No `overflow-hidden`: the list's inner shell clips to the rounding so + the right-side action bubble can overflow past this edge. */} + <PopoverContent + ref={contentRef} + side="top" + align="start" + onPointerDownOutside={onPointerDownOutside} + onFocusOutside={onFocusOutside} + className="w-[22rem] max-w-[calc(100vw-1rem)] p-0" + > + <BranchSelectorList + operations={operations} + localNodes={localNodes} + remoteSections={remoteSections} + localCount={branchList.local.length} + remoteCount={branchList.remote.length} + branch={branch} + worktreeBranchSet={worktreeBranchSet} + branchLoading={branchLoading} + loading={loading} + onRunOperation={runOperation} + onLeafAction={runLeafAction} + /> + </PopoverContent> + </Popover> <AlertDialog open={confirmAction !== null} diff --git a/src/components/layout/branch-selector-list.tsx b/src/components/layout/branch-selector-list.tsx new file mode 100644 index 000000000..adc9ce172 --- /dev/null +++ b/src/components/layout/branch-selector-list.tsx @@ -0,0 +1,726 @@ +"use client" + +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react" +import { + Archive, + ArchiveRestore, + ChevronRight, + CloudDownload, + CloudSync, + CloudUpload, + Folder, + FolderGit2, + FolderOpen, + GitBranch, + GitBranchPlus, + GitCommitHorizontal, + GitMerge, + GitPullRequestArrow, + Globe, + Loader2, + Search, + Trash2, + type LucideIcon, +} from "lucide-react" +import { useTranslations } from "next-intl" +import { Virtualizer, type VirtualizerHandle } from "virtua" +import { cn } from "@/lib/utils" +import { ScrollArea } from "@/components/ui/scroll-area" +import { branchRowPaddingLeft } from "@/components/layout/branch-tree-collapsible" +import { + buildBranchRows, + isNavigableRow, + type BranchLeafAction, + type BranchOperationMeta, + type BranchRow, +} from "@/lib/branch-selector-rows" +import { collectGroupKeys } from "@/lib/branch-tree" +import type { BranchTreeNode, RemoteBranchSection } from "@/lib/branch-tree" + +interface BranchSelectorListProps { + /** Operations with pre-translated labels (search matches on these). */ + operations: BranchOperationMeta[] + localNodes: BranchTreeNode[] + remoteSections: RemoteBranchSection[] + localCount: number + remoteCount: number + branch: string | null + worktreeBranchSet: Set<string> + /** First-load fetch in flight and nothing cached yet — show a spinner. */ + branchLoading: boolean + /** A git task is running — disable operation + branch-action rows. */ + loading: boolean + onRunOperation: (opId: string) => void + onLeafAction: ( + action: BranchLeafAction, + fullName: string, + isRemote: boolean + ) => void +} + +// Coarse per-row viewport estimate — only sizes the scroll window; virtua +// measures real rows itself. +const ROW_ESTIMATE_PX = 34 +const MAX_LIST_HEIGHT_PX = 480 +// Approximate width of the right-side action bubble (`w-56`) — used only to +// decide whether to flip it to the left when the popover hugs the screen edge. +const BUBBLE_WIDTH_PX = 224 +// Coarse per-action height + container padding, used to clamp the bubble's top +// so a leaf near the bottom doesn't push its last actions past the list root. +// Slightly over-estimated on purpose so the whole bubble always fits. +const BUBBLE_ITEM_PX = 34 +const BUBBLE_PAD_PX = 8 + +const OP_ICONS: Record<string, LucideIcon> = { + pull: CloudDownload, + fetch: CloudSync, + commit: GitCommitHorizontal, + push: CloudUpload, + newBranch: GitBranchPlus, + newWorktree: FolderGit2, + stash: Archive, + stashPop: ArchiveRestore, + manageRemotes: Globe, + init: GitBranch, +} + +const ACTION_ICONS: Record<BranchLeafAction, LucideIcon> = { + switch: GitBranch, + merge: GitMerge, + rebase: GitPullRequestArrow, + delete: Trash2, + deleteRemote: Trash2, +} + +// The per-branch actions shown in the right-side bubble. Delete is hidden for the +// remote branch the current local branch tracks (deleting it is nonsensical). +function leafActions( + isRemote: boolean, + isTracking: boolean +): BranchLeafAction[] { + const actions: BranchLeafAction[] = ["switch", "merge", "rebase"] + if (!isTracking) actions.push(isRemote ? "deleteRemote" : "delete") + return actions +} + +interface ActionBubble { + leafKey: string + fullName: string + isRemote: boolean + actions: BranchLeafAction[] + /** Nav-cursor index of the owner leaf, so hovering the bubble re-highlights it. */ + ownerNavIndex: number + /** Top offset (px) of the bubble relative to the list root (clamped to fit). */ + top: number + /** Flip to the left of the popover when there's no room on the right. */ + flipLeft: boolean +} + +// Searchable + virtualized branch panel: operations and the local/remote branch +// tree in ONE flat list (modeled on `ModelOptionList` — a plain search box +// driving a virtua `Virtualizer` scrolled by an OverlayScrollbars `ScrollArea`, +// NOT cmdk). Sections default open, prefix groups default collapsed (see the +// `overrides`/`defaultCollapsedGroups` split below); rows toggle either. +// Clicking a leaf opens a right-side action bubble — a plain DOM element inside +// this single Popover layer, never a nested Radix layer (that silently drops +// selection on WKWebView; see session-selectors-panel.tsx). +export function BranchSelectorList({ + operations, + localNodes, + remoteSections, + localCount, + remoteCount, + branch, + worktreeBranchSet, + branchLoading, + loading, + onRunOperation, + onLeafAction, +}: BranchSelectorListProps) { + const t = useTranslations("Folder.branchDropdown") + + const [query, setQuery] = useState("") + // Prefix groups default to COLLAPSED, sections to OPEN. That's modeled as a + // default-collapsed set (every prefix-group key) plus `overrides`, the user's + // explicit toggles (true = force-open, false = force-collapse). The popover + // unmounts on close, so both reset on each open. + const [overrides, setOverrides] = useState<Map<string, boolean>>( + () => new Map() + ) + const [activeIndex, setActiveIndex] = useState(0) + const [bubble, setBubble] = useState<ActionBubble | null>(null) + const [bubbleActiveIndex, setBubbleActiveIndex] = useState(0) + + const rootRef = useRef<HTMLDivElement>(null) + const inputRef = useRef<HTMLInputElement>(null) + const virtualizerRef = useRef<VirtualizerHandle>(null) + const viewportRef = useRef<HTMLElement | null>(null) + const [viewportEl, setViewportEl] = useState<HTMLElement | null>(null) + const handleViewportRef = useCallback((element: HTMLElement | null) => { + viewportRef.current = element + setViewportEl(element) + }, []) + + const baseId = useId() + const listId = `${baseId}-list` + const optionId = useCallback( + (index: number) => `${baseId}-opt-${index}`, + [baseId] + ) + + const closeBubble = useCallback(() => { + setBubble((b) => (b ? null : b)) + }, []) + + // Type-ahead: while the popup is open, typing should filter even if the search + // box doesn't hold focus (Radix focus management, or focus drifting onto a + // branch row after a click on WebKit). A document-level listener catches keys + // wherever focus landed — including body / the PopoverContent wrapper, which a + // handler on an inner div would miss. It no-ops when the input IS focused, so + // the controlled onChange stays the single writer there (no double-insert). + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + const input = inputRef.current + const active = document.activeElement + if (!input || active === input) return + // Never hijack another editable field (e.g. the composer) that happens to + // hold focus while the popup is still open — only re-home keys typed with + // focus on a non-editable element (a branch row, body, the popover shell). + if ( + active instanceof HTMLElement && + (active.tagName === "INPUT" || + active.tagName === "TEXTAREA" || + active.isContentEditable) + ) + return + if (event.isComposing || event.key === "Process") return + const isPrintable = + event.key.length === 1 && + !event.metaKey && + !event.ctrlKey && + !event.altKey + if (isPrintable) { + event.preventDefault() + input.focus() + setQuery((q) => q + event.key) + setActiveIndex(0) + closeBubble() + } else if (event.key === "Backspace") { + event.preventDefault() + input.focus() + setQuery((q) => q.slice(0, -1)) + setActiveIndex(0) + closeBubble() + } + } + document.addEventListener("keydown", onKeyDown) + return () => document.removeEventListener("keydown", onKeyDown) + }, [closeBubble]) + + // Prefix groups start folded: seed the default-collapsed set with every group + // key in the current trees (sections + multi-remote wrappers are keyed outside + // the tree, so they stay open). + const defaultCollapsedGroups = useMemo( + () => + new Set([ + ...collectGroupKeys(localNodes), + ...remoteSections.flatMap((s) => collectGroupKeys(s.nodes)), + ]), + [localNodes, remoteSections] + ) + + // The set fed to buildBranchRows: defaults minus force-open overrides, plus + // force-collapse overrides. + const effectiveCollapsed = useMemo(() => { + const set = new Set(defaultCollapsedGroups) + for (const [key, expanded] of overrides) { + if (expanded) set.delete(key) + else set.add(key) + } + return set + }, [defaultCollapsedGroups, overrides]) + + const toggleCollapse = useCallback( + (key: string) => { + setOverrides((prev) => { + const prevOverride = prev.get(key) + const currentlyCollapsed = + prevOverride !== undefined + ? !prevOverride + : defaultCollapsedGroups.has(key) + const next = new Map(prev) + // New expanded state = the negation of the current one = whether it was + // collapsed. + next.set(key, currentlyCollapsed) + return next + }) + }, + [defaultCollapsedGroups] + ) + + const rows = useMemo( + () => + buildBranchRows({ + operations, + localNodes, + remoteSections, + localCount, + remoteCount, + branch, + worktreeBranchSet, + collapsed: effectiveCollapsed, + query, + }), + [ + operations, + localNodes, + remoteSections, + localCount, + remoteCount, + branch, + worktreeBranchSet, + effectiveCollapsed, + query, + ] + ) + + // Flat indices of rows the keyboard cursor can land on (skips separators + + // empty rows), plus the reverse lookup so each row resolves its cursor slot. + const navigableRowIndices = useMemo( + () => rows.flatMap((row, index) => (isNavigableRow(row) ? [index] : [])), + [rows] + ) + const navigableCount = navigableRowIndices.length + const navigableIndexByRow = useMemo(() => { + const map = new Map<number, number>() + navigableRowIndices.forEach((rowIndex, navIndex) => + map.set(rowIndex, navIndex) + ) + return map + }, [navigableRowIndices]) + + const activeIndexClamped = + navigableCount === 0 ? 0 : Math.min(activeIndex, navigableCount - 1) + + const moveActiveTo = useCallback( + (next: number) => { + if (navigableCount === 0) return + const clamped = Math.max(0, Math.min(navigableCount - 1, next)) + setActiveIndex(clamped) + virtualizerRef.current?.scrollToIndex(navigableRowIndices[clamped], { + align: "nearest", + }) + }, + [navigableCount, navigableRowIndices] + ) + + const leafActionLabel = useCallback( + (action: BranchLeafAction, fullName: string): string => { + switch (action) { + case "switch": + return t("switchToBranch") + case "merge": + return t("mergeBranchIntoCurrent", { + branchName: fullName, + currentBranch: branch ?? "-", + }) + case "rebase": + return t("rebaseCurrentToBranch", { + currentBranch: branch ?? "-", + branchName: fullName, + }) + case "delete": + case "deleteRemote": + return t("deleteBranch") + } + }, + [t, branch] + ) + + // Open (or toggle) the action bubble anchored to a leaf row element. The top + // is clamped so the whole bubble stays inside the list root — a leaf near the + // bottom would otherwise push its last actions past the edge and clip them. + function openLeafBubble( + row: Extract<BranchRow, { kind: "leaf" }>, + el: HTMLElement | null, + navIndex: number + ) { + if (bubble?.leafKey === row.key) { + setBubble(null) + return + } + const root = rootRef.current + if (!el || !root) return + const rowRect = el.getBoundingClientRect() + const rootRect = root.getBoundingClientRect() + const actions = leafActions(row.isRemote, row.isTracking) + const bubbleHeight = actions.length * BUBBLE_ITEM_PX + BUBBLE_PAD_PX + const top = Math.min( + rowRect.top - rootRect.top, + Math.max(0, rootRect.height - bubbleHeight) + ) + setBubble({ + leafKey: row.key, + fullName: row.fullName, + isRemote: row.isRemote, + actions, + ownerNavIndex: navIndex, + top, + flipLeft: rootRect.right + 8 + BUBBLE_WIDTH_PX > window.innerWidth, + }) + setBubbleActiveIndex(0) + // Highlight the owner leaf (covers the keyboard path, where no hover fires). + setActiveIndex(navIndex) + } + + function activateRow( + row: BranchRow, + el: HTMLElement | null, + navIndex: number + ) { + switch (row.kind) { + case "operation": + if (!loading) onRunOperation(row.opId) + break + case "section": + case "group": + closeBubble() + toggleCollapse(row.key) + break + case "leaf": + if (!row.isCurrent) openLeafBubble(row, el, navIndex) + break + default: + break + } + } + + function selectBubbleAction(index: number) { + if (!bubble) return + const action = bubble.actions[index] + if (!action || loading) return + onLeafAction(action, bubble.fullName, bubble.isRemote) + setBubble(null) + } + + function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) { + // Don't steal Enter/arrows while an IME composition is in flight (CJK). + if (event.nativeEvent.isComposing || event.key === "Process") return + + // When the action bubble is open, arrows/Enter drive it; Escape/Left close + // it; any other key closes it and falls through to normal list handling. + if (bubble) { + switch (event.key) { + case "ArrowDown": + event.preventDefault() + setBubbleActiveIndex((i) => + Math.min(bubble.actions.length - 1, i + 1) + ) + return + case "ArrowUp": + event.preventDefault() + setBubbleActiveIndex((i) => Math.max(0, i - 1)) + return + case "Enter": + event.preventDefault() + selectBubbleAction(bubbleActiveIndex) + return + case "Escape": + case "ArrowLeft": + event.preventDefault() + closeBubble() + return + default: + closeBubble() + } + } + + switch (event.key) { + case "ArrowDown": + event.preventDefault() + moveActiveTo(activeIndexClamped + 1) + break + case "ArrowUp": + event.preventDefault() + moveActiveTo(activeIndexClamped - 1) + break + case "Home": + event.preventDefault() + moveActiveTo(0) + break + case "End": + event.preventDefault() + moveActiveTo(navigableCount - 1) + break + case "Enter": { + const rowIndex = navigableRowIndices[activeIndexClamped] + const row = rowIndex != null ? rows[rowIndex] : undefined + if (row) { + event.preventDefault() + activateRow( + row, + document.getElementById(optionId(activeIndexClamped)), + activeIndexClamped + ) + } + break + } + default: + break + } + } + + const listHeight = Math.min( + MAX_LIST_HEIGHT_PX, + Math.max(rows.length, 1) * ROW_ESTIMATE_PX + ) + const activeFlatIndex = navigableRowIndices[activeIndexClamped] + const showSpinner = branchLoading && localCount === 0 && remoteCount === 0 + const isEmpty = !showSpinner && rows.length === 0 + + const renderRow = (row: BranchRow, flatIndex: number) => { + if (row.kind === "separator") { + return ( + <div + key={row.key} + role="presentation" + className="mx-2 my-1 h-px bg-border" + /> + ) + } + if (row.kind === "empty") { + return ( + <div + key={row.key} + role="presentation" + className="py-1.5 pr-3 text-sm text-muted-foreground/70" + style={{ paddingLeft: branchRowPaddingLeft("dropdown", 1) }} + > + {row.scope === "local" ? t("noLocalBranches") : t("noRemoteBranches")} + </div> + ) + } + + const navIndex = navigableIndexByRow.get(flatIndex) ?? 0 + const active = navIndex === activeIndexClamped + const rowClass = cn( + "flex w-full select-none items-center gap-2 rounded-md py-1.5 pr-2 text-left text-sm outline-none transition-colors", + active ? "bg-accent text-accent-foreground" : "hover:bg-accent/60" + ) + + if (row.kind === "operation") { + const Icon = OP_ICONS[row.opId] ?? GitBranch + return ( + <button + key={row.key} + type="button" + role="option" + id={optionId(navIndex)} + aria-selected={active} + disabled={loading} + onMouseMove={() => setActiveIndex(navIndex)} + onClick={(e) => activateRow(row, e.currentTarget, navIndex)} + className={cn( + rowClass, + "disabled:pointer-events-none disabled:opacity-50", + row.destructive && "text-destructive" + )} + style={{ paddingLeft: branchRowPaddingLeft("dropdown", 0) }} + > + <Icon className="size-3.5 shrink-0 opacity-70" /> + <span className="min-w-0 flex-1 truncate">{row.label}</span> + </button> + ) + } + + if (row.kind === "section" || row.kind === "group") { + // Sections anchor the tree at depth 0; groups carry their own depth. + const depth = row.kind === "section" ? 0 : row.depth + const label = + row.kind === "section" + ? row.scope === "local" + ? t("localBranches", { count: row.count }) + : t("remoteBranches", { count: row.count }) + : row.label + return ( + <button + key={row.key} + type="button" + role="option" + id={optionId(navIndex)} + aria-selected={active} + onMouseMove={() => setActiveIndex(navIndex)} + onClick={(e) => activateRow(row, e.currentTarget, navIndex)} + className={rowClass} + style={{ paddingLeft: branchRowPaddingLeft("dropdown", depth) }} + > + {/* Sections use a chevron; prefix groups use a folder icon. Icons run + a touch lighter than the label (opacity, not a fixed muted color, + so they stay legible on the active accent background too). */} + {row.kind === "section" ? ( + <ChevronRight + className={cn( + "size-3.5 shrink-0 opacity-70 transition-transform", + row.expanded && "rotate-90" + )} + /> + ) : row.expanded ? ( + <FolderOpen className="size-3.5 shrink-0 opacity-70" /> + ) : ( + <Folder className="size-3.5 shrink-0 opacity-70" /> + )} + <span className="min-w-0 flex-1 truncate">{label}</span> + {row.kind === "group" && ( + <span className="shrink-0 pl-2 text-xs text-muted-foreground/70"> + {row.count} + </span> + )} + </button> + ) + } + + // leaf. The active background is driven solely by `activeIndex` (which the + // bubble keeps pointed at its owner leaf) — no separate "bubble open" + // highlight, so hovering another row cleanly moves the single highlight. + const LeafIcon = row.isWorktree ? FolderGit2 : GitBranch + return ( + <button + key={row.key} + type="button" + role="option" + id={optionId(navIndex)} + aria-selected={active} + title={row.fullName} + onMouseMove={() => setActiveIndex(navIndex)} + onClick={(e) => activateRow(row, e.currentTarget, navIndex)} + className={cn(rowClass, row.isCurrent && "opacity-50")} + style={{ paddingLeft: branchRowPaddingLeft("dropdown", row.depth) }} + > + <LeafIcon className="size-3.5 shrink-0 opacity-70" /> + <span className="min-w-0 flex-1 truncate">{row.label}</span> + {row.isCurrent ? ( + <span className="shrink-0 pl-2 text-xs text-muted-foreground"> + {t("current")} + </span> + ) : ( + <ChevronRight className="size-3 shrink-0 text-muted-foreground/50" /> + )} + </button> + ) + } + + return ( + <div ref={rootRef} className="relative flex min-w-0 flex-col"> + {/* Inner shell clips content to the popover rounding; the action bubble is + a sibling of it so it can overflow past the popover's right edge (the + PopoverContent is overflow-visible). */} + <div className="flex min-w-0 flex-col overflow-hidden rounded-[inherit]"> + {/* pl-4 (16px) + size-3.5 icon + gap-2 puts the icon and input text at + the same x as the rows below (listbox p-1 + 0.75rem row padding). */} + <div className="flex items-center gap-2 border-b py-2 pl-4 pr-2.5"> + <Search className="size-3.5 shrink-0 text-muted-foreground" /> + <input + ref={inputRef} + type="text" + value={query} + autoFocus + spellCheck={false} + autoComplete="off" + role="combobox" + aria-expanded + aria-controls={listId} + aria-activedescendant={ + navigableCount > 0 ? optionId(activeIndexClamped) : undefined + } + aria-label={t("searchAriaLabel")} + placeholder={t("searchPlaceholder")} + onChange={(event) => { + setQuery(event.target.value) + setActiveIndex(0) + closeBubble() + }} + onKeyDown={handleKeyDown} + className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + </div> + + {showSpinner ? ( + <div className="flex items-center justify-center py-8"> + <Loader2 className="size-4 animate-spin text-muted-foreground" /> + </div> + ) : isEmpty ? ( + <div className="px-3 py-6 text-center text-sm text-muted-foreground"> + {t("noMatches")} + </div> + ) : ( + <div style={{ height: listHeight }}> + <ScrollArea onViewportRef={handleViewportRef} className="h-full"> + <div + role="listbox" + id={listId} + aria-label={t("branchListLabel")} + className="p-1" + > + {viewportEl ? ( + <Virtualizer + ref={virtualizerRef} + scrollRef={viewportRef} + onScroll={closeBubble} + keepMounted={ + activeFlatIndex != null ? [activeFlatIndex] : undefined + } + > + {rows.map((row, flatIndex) => renderRow(row, flatIndex))} + </Virtualizer> + ) : null} + </div> + </ScrollArea> + </div> + )} + </div> + + {bubble ? ( + <div + role="menu" + aria-label={bubble.fullName} + // Hovering the bubble points the single highlight back at its owner + // leaf, so the source branch re-lights while you're over its actions. + onMouseEnter={() => setActiveIndex(bubble.ownerNavIndex)} + // Flush against the popup's edge (no margin) so it reads as attached. + className="absolute z-50 w-56 rounded-xl border bg-popover p-1 shadow-lg" + style={{ + top: bubble.top, + ...(bubble.flipLeft ? { right: "100%" } : { left: "100%" }), + }} + > + {bubble.actions.map((action, index) => { + const ActionIcon = ACTION_ICONS[action] + const destructive = action === "delete" || action === "deleteRemote" + const bubbleActive = index === bubbleActiveIndex + return ( + <button + key={action} + type="button" + role="menuitem" + disabled={loading} + onMouseMove={() => setBubbleActiveIndex(index)} + onClick={() => selectBubbleAction(index)} + className={cn( + "flex w-full select-none items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-sm outline-none transition-colors", + "disabled:pointer-events-none disabled:opacity-50", + bubbleActive && "bg-accent text-accent-foreground", + destructive && "text-destructive" + )} + > + <ActionIcon className="size-3.5 shrink-0 opacity-70" /> + <span className="min-w-0 flex-1 truncate"> + {leafActionLabel(action, bubble.fullName)} + </span> + </button> + ) + })} + </div> + ) : null} + </div> + ) +} diff --git a/src/components/layout/branch-selector-placement.test.ts b/src/components/layout/branch-selector-placement.test.ts new file mode 100644 index 000000000..0deae1cea --- /dev/null +++ b/src/components/layout/branch-selector-placement.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), "utf8") + +const statusBarSource = read("src/components/layout/status-bar.tsx") +const contextBarSource = read( + "src/components/chat/conversation-context-bar.tsx" +) +const branchDropdownSource = read("src/components/layout/branch-dropdown.tsx") + +// The rich git BranchDropdown moved out of the bottom status bar and into the +// below-composer folder/branch row, replacing the basic popover BranchPicker. +describe("branch selector placement", () => { + it("removes the branch selector from the bottom status bar", () => { + expect(statusBarSource).not.toContain("BranchDropdown") + expect(statusBarSource).not.toContain("branch-dropdown") + }) + + it("hosts the rich BranchDropdown in the below-composer row, once per tile", () => { + expect(contextBarSource).toContain( + 'import { BranchDropdown } from "@/components/layout/branch-dropdown"' + ) + // Mounted per tile with the tile's OWN folder (not gated to the active tile) + // so a tiled view keeps every tile's branch chip live. + expect(contextBarSource).toContain( + "<BranchDropdown folder={ownFolder} isChatMode={isChatMode} />" + ) + expect(contextBarSource).not.toContain("isActiveTab && <BranchDropdown") + }) + + it("retires the basic BranchPicker and the status-bar variant", () => { + // The basic popover BranchPicker is gone (replaced by BranchDropdown). + expect(contextBarSource).not.toContain("const BranchPicker") + expect(contextBarSource).not.toContain("interface BranchPickerProps") + // BranchDropdown now renders a single composer-chip trigger — the + // status-bar `showFolderName` split/pill variant is removed. + expect(branchDropdownSource).not.toContain("showFolderName") + expect(branchDropdownSource).not.toContain("isStatusBar") + }) +}) diff --git a/src/components/layout/status-bar.tsx b/src/components/layout/status-bar.tsx index 916c31014..54ef91fc1 100644 --- a/src/components/layout/status-bar.tsx +++ b/src/components/layout/status-bar.tsx @@ -6,7 +6,6 @@ import { StatusBarTokens } from "@/components/layout/status-bar-tokens" import { StatusBarConnection } from "@/components/layout/status-bar-connection" import { StatusBarAlerts } from "@/components/layout/status-bar-alerts" import { StatusBarUpdate } from "@/components/layout/status-bar-update" -import { BranchDropdown } from "@/components/layout/branch-dropdown" import { CommandDropdown } from "@/components/layout/command-dropdown" import { useIsMobile } from "@/hooks/use-mobile" @@ -14,15 +13,12 @@ export function StatusBar() { const isMobile = useIsMobile() if (isMobile) { - // Mobile mirrors the desktop bar: the branch selector on the left, the - // command launcher + context-window circle + alerts on the right. `h-8` - // (matching desktop) gives the h-6 branch/command controls room. Branch and - // command self-hide in chat mode / without a repo. + // Mobile mirrors the desktop bar's right side: the command launcher + + // context-window circle + alerts. `h-8` (matching desktop) gives the h-6 + // command control room. The branch selector now lives in the below-composer + // row, so the bar has nothing on the left and right-aligns its controls. return ( - <div className="h-8 shrink-0 border-t border-border ws-chrome-border ws-surface-muted px-3 flex items-center justify-between text-xs text-muted-foreground"> - <div className="flex min-w-0 items-center gap-3"> - <BranchDropdown showFolderName={false} /> - </div> + <div className="h-8 shrink-0 border-t border-border ws-chrome-border ws-surface-muted px-3 flex items-center justify-end text-xs text-muted-foreground"> <div className="flex items-center gap-3"> <CommandDropdown /> <StatusBarTokens /> @@ -34,12 +30,10 @@ export function StatusBar() { return ( <div className="h-8 shrink-0 border-t border-border ws-chrome-border ws-surface-muted px-4 flex items-center justify-between text-xs text-muted-foreground"> + {/* The branch selector moved to the below-composer folder/branch row; the + left side now carries just the workspace stats. */} <div className="flex items-center gap-3"> <StatusBarStats /> - {/* Branch selector (moved from the aux "session details" tab). Folder - name is hidden — just the branch — since the folder chip now lives in - the conversation header. Self-hides in chat mode / without a repo. */} - <BranchDropdown showFolderName={false} /> </div> <div className="flex items-center gap-4"> <StatusBarUpdate /> diff --git a/src/components/message/ask-question-result-card.test.tsx b/src/components/message/ask-question-result-card.test.tsx index cee949074..6e7939df1 100644 --- a/src/components/message/ask-question-result-card.test.tsx +++ b/src/components/message/ask-question-result-card.test.tsx @@ -235,6 +235,18 @@ describe("AskQuestionResultCard", () => { expect(screen.queryByRole("radio")).toBeNull() }) + it("renders nothing for an in-flight question whose input hasn't streamed in", () => { + // claude-agent-acp's arg-less initial tool_call leaves the input empty, so no + // question parses. The live question is answered via the pinned card, so this + // in-stream placeholder is pure noise — it must not render an anonymous + // "awaiting your answer" card (they stacked into visible duplicates). + const { container } = renderWithIntl( + <AskQuestionResultCard input="{}" state="input-available" /> + ) + expect(container.firstChild).toBeNull() + expect(screen.queryByText(result.awaiting)).not.toBeInTheDocument() + }) + it("matches grok's header-less questions to their empty-header answers", () => { // Grok's native ask carries no `header`; the connection bridge (live) and the // history parser both emit header-less questions + answers with `header: ""`. diff --git a/src/components/message/ask-question-result-card.tsx b/src/components/message/ask-question-result-card.tsx index 610525b08..eb4924deb 100644 --- a/src/components/message/ask-question-result-card.tsx +++ b/src/components/message/ask-question-result-card.tsx @@ -140,17 +140,24 @@ export function AskQuestionResultCard({ } if (isInFlight) { + // A live pending question is answered through the PINNED AskQuestionCard + // (driven by the connection's `pendingAskQuestion`), not this in-stream + // record. Until the question text streams onto the wire, claude-agent-acp's + // arg-less initial `tool_call` leaves `input` empty, so `questions` is []: + // rendering the bare "awaiting your answer" placeholder here only stacks + // anonymous duplicates of the same wait — one per in-flight (or stranded) + // question call. Drop it; the card reappears with its Q&A the moment the + // input or the answer resolves, and the settled transcript is unaffected. + if (questions.length === 0) return null return shell( t("awaiting"), - questions.length > 0 ? ( - <div className="flex flex-wrap gap-1.5"> - {questions.map((q, i) => ( - <Badge key={i} variant="outline" className="text-[10px]"> - {q.header || q.question} - </Badge> - ))} - </div> - ) : undefined + <div className="flex flex-wrap gap-1.5"> + {questions.map((q, i) => ( + <Badge key={i} variant="outline" className="text-[10px]"> + {q.header || q.question} + </Badge> + ))} + </div> ) } diff --git a/src/components/message/delegation-status-card.test.tsx b/src/components/message/delegation-status-card.test.tsx index 742432ad2..4eec94468 100644 --- a/src/components/message/delegation-status-card.test.tsx +++ b/src/components/message/delegation-status-card.test.tsx @@ -69,12 +69,33 @@ describe("DelegationStatusCard", () => { }) it("falls back to a task-less label when the task_id can't be parsed", () => { + // A settled poll that returned a note but no resolvable id still shows the + // task-less label (there is content to surface). renderWithIntl( - <DelegationStatusCard kind="status" state="input-available" /> + <DelegationStatusCard + kind="status" + output="Interim note from the sub-agent." + state="output-available" + /> ) expect(screen.getByText("Waiting for task result")).toBeInTheDocument() }) + it("suppresses a still-in-flight poll whose task_id hasn't streamed in yet", () => { + // claude-agent-acp ships an arg-less initial tool_call (rawInput not yet on + // the wire), so a live in-flight poll carries no identity and nothing to + // show. It must NOT render as an anonymous "waiting for a task's result" + // row (that stacked into visible duplicates); it appears once its id or a + // report resolves. + const { container } = renderWithIntl( + <DelegationStatusCard kind="status" state="input-available" /> + ) + expect(container.firstChild).toBeNull() + expect( + screen.queryByText("Waiting for task result") + ).not.toBeInTheDocument() + }) + it("expands a plain streamed result inline (no child-session button)", () => { renderWithIntl( <DelegationStatusCard diff --git a/src/components/message/delegation-status-group-card.test.tsx b/src/components/message/delegation-status-group-card.test.tsx index c5579b165..9754a8a23 100644 --- a/src/components/message/delegation-status-group-card.test.tsx +++ b/src/components/message/delegation-status-group-card.test.tsx @@ -155,6 +155,47 @@ describe("DelegationStatusGroupCard", () => { expect(container.querySelector(".animate-spin")).toBeInTheDocument() }) + it("suppresses an in-flight poll whose task_ids have not streamed in yet", () => { + // claude-agent-acp ships an arg-less initial tool_call (rawInput `{}`) and + // fills the real `task_ids` only on a later update, so a still-blocking live + // poll has neither an input id nor an output report. It must NOT render as an + // anonymous "waiting for a task's result" row. + const { container } = renderWithIntl( + <DelegationStatusGroupCard + polls={[poll("", { input: "{}", state: "input-available" })]} + /> + ) + expect(container.firstChild).toBeNull() + expect( + screen.queryByText("Waiting for task result") + ).not.toBeInTheDocument() + }) + + it("does not stack repeated id-less in-flight re-polls into duplicate rows", () => { + // The reported bug: N concurrent/re-issued anonymous polls each became a + // separate "waiting for a task's result" row. With one task attributed via + // its returned report and three id-less in-flight checks, only the attributed + // row survives. + renderWithIntl( + <DelegationStatusGroupCard + polls={[ + poll("abc12345", { + output: envelope({ task_id: "abc12345", status: "running" }), + }), + poll("", { input: "{}", state: "input-available" }), + poll("", { input: null, state: "input-available" }), + poll("", { input: "{}", state: "input-streaming" }), + ]} + /> + ) + expect( + screen.getByText("Waiting for task #abc12345 result") + ).toBeInTheDocument() + expect( + screen.queryByText("Waiting for task result") + ).not.toBeInTheDocument() + }) + it("renders one row per task for parallel waits", () => { renderWithIntl( <DelegationStatusGroupCard diff --git a/src/components/message/reply-artifacts.tsx b/src/components/message/reply-artifacts.tsx index 283c39281..3eb34483a 100644 --- a/src/components/message/reply-artifacts.tsx +++ b/src/components/message/reply-artifacts.tsx @@ -15,7 +15,6 @@ import { CommitFileAdditions, CommitFileDeletions, } from "@/components/ai-elements/commit" -import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview" import { Tooltip, TooltipContent, @@ -49,12 +48,14 @@ import { cn } from "@/lib/utils" * reveals it in the OS file manager. Open by default — a freshly written * file is usually the thing you want to jump into. The grid scrolls * within the same bounded max-height as the changed list. - * - "Files changed": modified/removed files as a single-open accordion (only - * one diff expanded at a time). Collapsed by default. Each row expands its - * diff inline within the SAME bordered card (no double border), and the - * list scrolls within a bounded max-height. + * - "Files changed": modified/removed files, each rendered as its own card in + * the same responsive grid as "New files". A modified file's card opens the + * file in the workspace tabs on click (with a reveal-in-folder side button), + * mirroring the "New files" cards but with a neutral (non-green) accent — no + * inline diff. A removed file renders as a static destructive card (nothing + * to open). Collapsed by default; the grid scrolls within a bounded height. * - * Diffs are parsed lazily and ONLY once the reply is persisted + * File changes are parsed lazily and ONLY once the reply is persisted * (`isResponseComplete`), so the streaming hot path never runs diff parsing. */ export const ReplyArtifacts = memo(function ReplyArtifacts({ @@ -69,8 +70,6 @@ export const ReplyArtifacts = memo(function ReplyArtifacts({ const { openFilePreview } = useWorkspaceActions() const [newFilesOpen, setNewFilesOpen] = useState(true) const [changedOpen, setChangedOpen] = useState(false) - // Single-open accordion: the path of the one changed file whose diff is open. - const [openPath, setOpenPath] = useState<string | null>(null) // Guard parsing behind completion so mid-stream renders stay diff-free. const files = useMemo( @@ -78,9 +77,9 @@ export const ReplyArtifacts = memo(function ReplyArtifacts({ [isResponseComplete, sourceTurns] ) - // Split created files (their own cards) from modified/removed files (the - // accordion). Removal wins over creation, so a create+delete in the same - // reply lands in "changed", not "new files". + // Split created files from modified/removed files — each lands in its own + // section ("New files" vs "Files changed"). Removal wins over creation, so a + // create+delete in the same reply lands in "changed", not "new files". const { addedFiles, changedFiles } = useMemo(() => { const addedFiles: FileChangeStat[] = [] const changedFiles: FileChangeStat[] = [] @@ -258,101 +257,119 @@ export const ReplyArtifacts = memo(function ReplyArtifacts({ </button> {changedOpen && ( - <ul className="max-h-80 space-y-1.5 overflow-y-auto border-t border-border p-2"> - {changedFiles.map((file) => { - const displayPath = toFolderRelativePath(file.path, folderPath) - const name = fileNameOf(displayPath) - const dir = - displayPath === name - ? "" - : displayPath.slice(0, displayPath.length - name.length - 1) - const isRemoved = isRemovedFileDiff(file.diff) - const isOpen = openPath === file.path + <TooltipProvider delayDuration={300}> + <div className="@container max-h-80 overflow-y-auto border-t border-border p-2"> + <div className="grid gap-2 @md:grid-cols-2"> + {changedFiles.map((file) => { + const displayPath = toFolderRelativePath( + file.path, + folderPath + ) + const name = fileNameOf(displayPath) + const dir = + displayPath === name + ? "" + : displayPath.slice( + 0, + displayPath.length - name.length - 1 + ) + const isRemoved = isRemovedFileDiff(file.diff) - return ( - <li - key={file.id} - className={cn( - "overflow-hidden rounded-md border transition-colors", - isRemoved ? "border-destructive/30" : "border-border", - isOpen && "bg-muted/20" - )} - > - <button - type="button" - aria-expanded={isOpen} - onClick={() => setOpenPath(isOpen ? null : file.path)} - title={displayPath} - className={cn( - "flex w-full min-w-0 items-center gap-2 px-2 py-1.5 text-left transition-colors", - isRemoved - ? "hover:bg-destructive/10" - : "hover:bg-accent/40", - isOpen && - (isRemoved - ? "border-b border-destructive/30" - : "border-b border-border") - )} - > - <ChevronRight - className={cn( - "h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform", - isOpen && "rotate-90" - )} - /> - <FileIcon - className={cn( - "h-3.5 w-3.5 shrink-0", - isRemoved - ? "text-destructive" - : "text-muted-foreground" - )} - /> - <span className="flex min-w-0 flex-1 items-baseline gap-2"> - <span - className={cn( - "min-w-0 truncate text-xs", - isRemoved ? "text-destructive" : "text-foreground" - )} + // Removed files no longer exist on disk — there is nothing + // to open or reveal, so render a static (non-interactive) + // card that keeps the destructive accent and remove badge. + if (isRemoved) { + return ( + <div + key={file.id} + title={displayPath} + className="flex items-center gap-2 overflow-hidden rounded-md border border-destructive/30 bg-destructive/5 px-2.5 py-2" > - {name} - </span> - {dir && ( - <span className="min-w-0 flex-1 truncate text-[10px] text-muted-foreground"> - {dir} + <FileIcon className="h-4 w-4 shrink-0 text-destructive" /> + <span className="flex min-w-0 flex-1 flex-col"> + <span className="truncate text-xs font-medium text-destructive"> + {name} + </span> + {dir && ( + <span className="truncate text-[10px] text-muted-foreground"> + {dir} + </span> + )} </span> - )} - </span> - {isRemoved ? ( - <span className="inline-flex shrink-0 items-center rounded-md border border-destructive/30 bg-destructive/10 px-1.5 py-0.5 font-mono text-[10px] text-destructive"> - {t("remove")} - </span> - ) : ( - <span className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-[10px] text-foreground"> - <CommitFileAdditions - count={file.additions} - className="text-[10px]" - /> - <CommitFileDeletions - count={file.deletions} - className="text-[10px]" - /> - </span> - )} - </button> + <span className="inline-flex shrink-0 items-center rounded-md border border-destructive/30 bg-destructive/10 px-1.5 py-0.5 font-mono text-[10px] text-destructive"> + {t("remove")} + </span> + </div> + ) + } + + return ( + <div + key={file.id} + className="flex items-stretch overflow-hidden rounded-md border border-border bg-muted/20 transition-colors hover:bg-accent/40" + > + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={() => openInTabs(file)} + title={displayPath} + aria-label={t("openFile", { + filePath: displayPath, + })} + className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 px-2.5 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" + > + <FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" /> + <span className="flex min-w-0 flex-1 flex-col"> + <span className="truncate text-xs font-medium text-foreground"> + {name} + </span> + {dir && ( + <span className="truncate text-[10px] text-muted-foreground"> + {dir} + </span> + )} + </span> + <span className="inline-flex shrink-0 items-center gap-1 font-mono text-[10px]"> + <CommitFileAdditions + count={file.additions} + className="text-[10px]" + /> + <CommitFileDeletions + count={file.deletions} + className="text-[10px]" + /> + </span> + </button> + </TooltipTrigger> + <TooltipContent side="top"> + {t("openInEditor")} + </TooltipContent> + </Tooltip> - {isOpen && - (file.diff ? ( - <UnifiedDiffPreview diffText={file.diff} embedded /> - ) : ( - <p className="px-3 py-2 text-xs text-muted-foreground"> - {t("noDiffDataAvailable", { filePath: displayPath })} - </p> - ))} - </li> - ) - })} - </ul> + {isLocalDesktop() && ( + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={() => revealInFolder(file)} + aria-label={t("revealInFolder")} + className="flex w-9 shrink-0 items-center justify-center border-l border-border text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" + > + <ExternalLink className="h-3.5 w-3.5" /> + </button> + </TooltipTrigger> + <TooltipContent side="top"> + {t("revealInFolder")} + </TooltipContent> + </Tooltip> + )} + </div> + ) + })} + </div> + </div> + </TooltipProvider> )} </div> )} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index d00a6cefc..dd54d376a 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1372,6 +1372,7 @@ "sectionFolders": "المجلدات", "sectionChats": "محادثة", "noChats": "لا توجد محادثات", + "noFolders": "لا توجد مجلدات مفتوحة", "newChatAction": "محادثة جديدة", "automations": "الأتمتة", "loadingSubsessions": "جارٍ تحميل المحادثات الفرعية…" @@ -1737,7 +1738,11 @@ "confirmApply": "تطبيق التخبئة {ref} على دليل العمل؟", "cancel": "إلغاء" }, - "deleteBranch": "حذف الفرع" + "deleteBranch": "حذف الفرع", + "searchPlaceholder": "البحث في الفروع والإجراءات", + "searchAriaLabel": "البحث في الفروع والإجراءات", + "branchListLabel": "الفروع والإجراءات", + "noMatches": "لا توجد نتائج" }, "commitDialog": { "toasts": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index e926ed601..d80a5b69f 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1372,6 +1372,7 @@ "sectionFolders": "Ordner", "sectionChats": "Chat", "noChats": "Keine Chats", + "noFolders": "Keine Ordner geöffnet", "newChatAction": "Neuer Chat", "automations": "Automatisierungen", "loadingSubsessions": "Unterkonversationen werden geladen…" @@ -1737,7 +1738,11 @@ "confirmApply": "Stash {ref} auf das Arbeitsverzeichnis anwenden?", "cancel": "Abbrechen" }, - "deleteBranch": "Branch löschen" + "deleteBranch": "Branch löschen", + "searchPlaceholder": "Branches und Aktionen suchen", + "searchAriaLabel": "Branches und Aktionen suchen", + "branchListLabel": "Branches und Aktionen", + "noMatches": "Keine Treffer" }, "commitDialog": { "toasts": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 359b54268..783c55221 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1372,6 +1372,7 @@ "sectionFolders": "Folders", "sectionChats": "Chat", "noChats": "No chats", + "noFolders": "No folders open", "newChatAction": "New chat", "automations": "Automations", "loadingSubsessions": "Loading sub-conversations…" @@ -1737,7 +1738,11 @@ "confirmApply": "Apply stash {ref} to working directory?", "cancel": "Cancel" }, - "deleteBranch": "Delete branch" + "deleteBranch": "Delete branch", + "searchPlaceholder": "Search branches and actions", + "searchAriaLabel": "Search branches and actions", + "branchListLabel": "Branches and actions", + "noMatches": "No matches" }, "commitDialog": { "toasts": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 56eddbce5..55ac967f5 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1372,6 +1372,7 @@ "sectionFolders": "Carpetas", "sectionChats": "Chat", "noChats": "Sin chats", + "noFolders": "No hay carpetas abiertas", "newChatAction": "Nuevo chat", "automations": "Automatizaciones", "loadingSubsessions": "Cargando subconversaciones…" @@ -1737,7 +1738,11 @@ "confirmApply": "¿Aplicar stash {ref} al directorio de trabajo?", "cancel": "Cancelar" }, - "deleteBranch": "Eliminar rama" + "deleteBranch": "Eliminar rama", + "searchPlaceholder": "Buscar ramas y acciones", + "searchAriaLabel": "Buscar ramas y acciones", + "branchListLabel": "Ramas y acciones", + "noMatches": "Sin coincidencias" }, "commitDialog": { "toasts": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 5e888e7d6..0b71da52e 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1372,6 +1372,7 @@ "sectionFolders": "Dossiers", "sectionChats": "Discussion", "noChats": "Aucune discussion", + "noFolders": "Aucun dossier ouvert", "newChatAction": "Nouvelle discussion", "automations": "Automatisations", "loadingSubsessions": "Chargement des sous-conversations…" @@ -1737,7 +1738,11 @@ "confirmApply": "Appliquer la remise {ref} au répertoire de travail ?", "cancel": "Annuler" }, - "deleteBranch": "Supprimer la branche" + "deleteBranch": "Supprimer la branche", + "searchPlaceholder": "Rechercher des branches et actions", + "searchAriaLabel": "Rechercher des branches et actions", + "branchListLabel": "Branches et actions", + "noMatches": "Aucune correspondance" }, "commitDialog": { "toasts": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index d4e820fb0..fbd1be4ce 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1372,6 +1372,7 @@ "sectionFolders": "フォルダ", "sectionChats": "チャット", "noChats": "チャットがありません", + "noFolders": "開いているフォルダがありません", "newChatAction": "新しいチャット", "automations": "オートメーション", "loadingSubsessions": "サブ会話を読み込み中…" @@ -1737,7 +1738,11 @@ "confirmApply": "スタッシュ {ref} を作業ディレクトリに適用しますか?", "cancel": "キャンセル" }, - "deleteBranch": "ブランチを削除" + "deleteBranch": "ブランチを削除", + "searchPlaceholder": "ブランチと操作を検索", + "searchAriaLabel": "ブランチと操作を検索", + "branchListLabel": "ブランチと操作", + "noMatches": "一致する項目がありません" }, "commitDialog": { "toasts": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 120eed32e..68db8fb40 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1372,6 +1372,7 @@ "sectionFolders": "폴더", "sectionChats": "채팅", "noChats": "채팅 없음", + "noFolders": "열린 폴더 없음", "newChatAction": "새 채팅", "automations": "자동화", "loadingSubsessions": "하위 대화 로드 중…" @@ -1737,7 +1738,11 @@ "confirmApply": "스태시 {ref}을(를) 작업 디렉토리에 적용하시겠습니까?", "cancel": "취소" }, - "deleteBranch": "브랜치 삭제" + "deleteBranch": "브랜치 삭제", + "searchPlaceholder": "브랜치 및 작업 검색", + "searchAriaLabel": "브랜치 및 작업 검색", + "branchListLabel": "브랜치 및 작업", + "noMatches": "일치하는 항목 없음" }, "commitDialog": { "toasts": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 20380762b..ff331c024 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1372,6 +1372,7 @@ "sectionFolders": "Pastas", "sectionChats": "Chat", "noChats": "Sem chats", + "noFolders": "Nenhuma pasta aberta", "newChatAction": "Novo chat", "automations": "Automações", "loadingSubsessions": "Carregando subconversas…" @@ -1737,7 +1738,11 @@ "confirmApply": "Aplicar stash {ref} ao diretório de trabalho?", "cancel": "Cancelar" }, - "deleteBranch": "Excluir branch" + "deleteBranch": "Excluir branch", + "searchPlaceholder": "Pesquisar ramos e ações", + "searchAriaLabel": "Pesquisar ramos e ações", + "branchListLabel": "Ramos e ações", + "noMatches": "Nenhuma correspondência" }, "commitDialog": { "toasts": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 341b37d93..6c11d56f6 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1372,6 +1372,7 @@ "sectionFolders": "文件夹", "sectionChats": "聊天", "noChats": "没有聊天", + "noFolders": "没有打开的文件夹", "newChatAction": "新建聊天", "automations": "自动化", "loadingSubsessions": "正在加载子会话…" @@ -1737,7 +1738,11 @@ "confirmApply": "将贮藏 {ref} 应用到工作目录?", "cancel": "取消" }, - "deleteBranch": "删除分支" + "deleteBranch": "删除分支", + "searchPlaceholder": "搜索分支和操作", + "searchAriaLabel": "搜索分支和操作", + "branchListLabel": "分支和操作", + "noMatches": "无匹配项" }, "commitDialog": { "toasts": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 53bee8190..31bf100f2 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1372,6 +1372,7 @@ "sectionFolders": "資料夾", "sectionChats": "聊天", "noChats": "沒有聊天", + "noFolders": "沒有開啟的資料夾", "newChatAction": "新增聊天", "automations": "自動化", "loadingSubsessions": "正在載入子會話…" @@ -1737,7 +1738,11 @@ "confirmApply": "將貯藏 {ref} 套用到工作目錄?", "cancel": "取消" }, - "deleteBranch": "刪除分支" + "deleteBranch": "刪除分支", + "searchPlaceholder": "搜尋分支與操作", + "searchAriaLabel": "搜尋分支與操作", + "branchListLabel": "分支與操作", + "noMatches": "無相符項目" }, "commitDialog": { "toasts": { diff --git a/src/lib/background-task.test.ts b/src/lib/background-task.test.ts index 7c4bb6a71..90978775c 100644 --- a/src/lib/background-task.test.ts +++ b/src/lib/background-task.test.ts @@ -249,6 +249,63 @@ describe("buildBackgroundTaskRows", () => { expect(rows[0].isInFlight).toBe(true) }) + it("drops an in-flight poll whose task_id has not streamed in yet", () => { + // claude-agent-acp emits an arg-less initial tool_call (rawInput `{}`) and + // fills the real args only on a later update, so a still-blocking live poll + // has no envelope and no input id. It must NOT render as an anonymous row. + const rows = buildBackgroundTaskRows([ + poll({ input: "{}", output: null, state: "input-available" }), + ]) + expect(rows).toHaveLength(0) + }) + + it("does not stack repeated id-less in-flight re-polls into duplicate rows", () => { + // The reported bug: N concurrent/re-issued anonymous polls each became a + // separate "background task running" row. With one real task attributed and + // three id-less in-flight checks, only the attributed row survives. + const rows = buildBackgroundTaskRows([ + poll({ + toolCallId: "done", + output: COMPLETED, + state: "output-available", + }), + poll({ + toolCallId: "x1", + input: "{}", + output: null, + state: "input-available", + }), + poll({ + toolCallId: "x2", + input: null, + output: null, + state: "input-available", + }), + poll({ + toolCallId: "x3", + input: "{}", + output: null, + state: "input-streaming", + }), + ]) + expect(rows).toHaveLength(1) + expect(rows[0].taskId).toBe("bfb5xnq1t") + }) + + it("still shows an id-less poll once it carries an envelope", () => { + // A settled/output-available poll that parsed an envelope (even without a + // resolvable id) is real content and keeps its row. + const noIdEnvelope = `<retrieval_status>success</retrieval_status> +<status>completed</status> +<exit_code>0</exit_code> +<output>ok</output>` + const rows = buildBackgroundTaskRows([ + poll({ input: null, output: noIdEnvelope, state: "output-available" }), + ]) + expect(rows).toHaveLength(1) + expect(rows[0].badge).toBe("completed") + }) + it("derives a stopped badge + command from a TaskStop ack", () => { const rows = buildBackgroundTaskRows([ poll({ diff --git a/src/lib/background-task.ts b/src/lib/background-task.ts index 7ea86c9ff..6017db57c 100644 --- a/src/lib/background-task.ts +++ b/src/lib/background-task.ts @@ -275,6 +275,18 @@ export function buildBackgroundTaskRows( poll.output ?? poll.errorText ?? null ) const taskId = envelope?.taskId ?? inputTaskId(poll.input) ?? null + // Drop an in-flight poll that carries no identity AND no output yet — a live + // `TaskOutput` whose `task_id` hasn't streamed onto the wire. claude-agent-acp + // emits an arg-less initial `tool_call` (rawInput `{}`) and fills the real + // args only on a later update, so a still-blocking poll parses to no envelope + // and no input id. Each such re-poll would otherwise render as its own + // anonymous "background task running" row, stacking identical duplicates of + // the same wait; it folds into its task's row once the id resolves (and the + // settled transcript, whose polls always carry the id, is unaffected). + // Mirrors `buildDelegationTaskRows`. + if (taskId == null && envelope == null && isInFlightState(poll)) { + continue + } const key = taskId ?? `__bg__:${poll.toolCallId}` let entry = byKey.get(key) if (!entry) { diff --git a/src/lib/branch-selector-rows.test.ts b/src/lib/branch-selector-rows.test.ts new file mode 100644 index 000000000..a09b9ed2a --- /dev/null +++ b/src/lib/branch-selector-rows.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from "vitest" + +import { + buildBranchRows, + isNavigableRow, + type BranchOperationMeta, + type BranchRow, + type BuildBranchRowsInput, +} from "@/lib/branch-selector-rows" +import { + buildBranchTree, + buildRemoteBranchSections, + localBranchItems, + sectionKey, + type BranchTreeNode, +} from "@/lib/branch-tree" + +const OPS: BranchOperationMeta[] = [ + { id: "pull", label: "Pull code" }, + { id: "push", label: "Push..." }, +] + +function localTree(names: string[]): BranchTreeNode[] { + return buildBranchTree(localBranchItems(names), "local") +} + +// Compact, readable shape for sequence assertions. +function summarize(row: BranchRow): string { + switch (row.kind) { + case "operation": + return `op:${row.opId}` + case "separator": + return "sep" + case "section": + return `section:${row.scope}(${row.count})${row.expanded ? "+" : "-"}` + case "group": + return `group:${row.label}@${row.depth}${row.expanded ? "+" : "-"}` + case "leaf": + return `leaf:${row.fullName}@${row.depth}${row.isCurrent ? "*" : ""}` + case "empty": + return `empty:${row.scope}` + } +} + +function baseInput( + overrides: Partial<BuildBranchRowsInput> = {} +): BuildBranchRowsInput { + return { + operations: OPS, + localNodes: [], + remoteSections: [], + localCount: 0, + remoteCount: 0, + branch: null, + worktreeBranchSet: new Set(), + collapsed: new Set(), + query: "", + ...overrides, + } +} + +const LOCAL = [ + "main", + "feature/auth/login", + "feature/auth/logout", + "release/1.0", +] + +describe("buildBranchRows — empty query (tree mode)", () => { + it("puts operations first, a separator, then default-expanded sections + flattened tree", () => { + const rows = buildBranchRows( + baseInput({ localNodes: localTree(LOCAL), localCount: 4, remoteCount: 0 }) + ) + expect(rows.map(summarize)).toEqual([ + "op:pull", + "op:push", + "sep", + "section:local(4)+", + "group:feature/auth/@1+", + "leaf:feature/auth/login@2", + "leaf:feature/auth/logout@2", + "leaf:main@1", + "leaf:release/1.0@1", + "section:remote(0)+", + "empty:remote", + ]) + }) + + it("collapsing the local section hides all its children", () => { + const rows = buildBranchRows( + baseInput({ + localNodes: localTree(LOCAL), + localCount: 4, + collapsed: new Set([sectionKey("local")]), + }) + ) + expect(rows.map(summarize)).toEqual([ + "op:pull", + "op:push", + "sep", + "section:local(4)-", + "section:remote(0)+", + "empty:remote", + ]) + }) + + it("collapsing a prefix group hides only that subtree", () => { + const nodes = localTree(LOCAL) + const groupKey = nodes.find((n) => n.type === "group")!.key + const rows = buildBranchRows( + baseInput({ + localNodes: nodes, + localCount: 4, + collapsed: new Set([groupKey]), + }) + ) + expect(rows.map(summarize)).toEqual([ + "op:pull", + "op:push", + "sep", + "section:local(4)+", + "group:feature/auth/@1-", + "leaf:main@1", + "leaf:release/1.0@1", + "section:remote(0)+", + "empty:remote", + ]) + }) +}) + +describe("buildBranchRows — leaf flags (drive the action bubble)", () => { + it("emits no leaf-action rows — actions live in the right-side bubble now", () => { + const rows = buildBranchRows( + baseInput({ localNodes: localTree(LOCAL), localCount: 4, branch: "main" }) + ) + expect(rows.map(summarize)).toContain("leaf:main@1*") + const validKinds = new Set([ + "operation", + "separator", + "section", + "group", + "leaf", + "empty", + ]) + expect(rows.every((r) => validKinds.has(r.kind))).toBe(true) + }) + + it("marks the remote leaf that tracks the current local branch (bubble hides its delete)", () => { + const remoteSections = buildRemoteBranchSections([ + "origin/main", + "origin/dev", + ]) + const rows = buildBranchRows( + baseInput({ remoteSections, remoteCount: 2, branch: "main" }) + ) + const tracking = rows.find( + (r) => r.kind === "leaf" && r.fullName === "origin/main" + ) + const other = rows.find( + (r) => r.kind === "leaf" && r.fullName === "origin/dev" + ) + expect(tracking).toMatchObject({ isTracking: true }) + expect(other).toMatchObject({ isTracking: false }) + }) +}) + +describe("buildBranchRows — operation grouping separators", () => { + it("inserts a separator after each groupEnd op (non-search)", () => { + const ops: BranchOperationMeta[] = [ + { id: "pull", label: "Pull code" }, + { id: "fetch", label: "Fetch", groupEnd: true }, + { id: "commit", label: "Commit" }, + ] + const rows = buildBranchRows( + baseInput({ + operations: ops, + localNodes: localTree(["main"]), + localCount: 1, + }) + ) + // pull, fetch, SEP(groupEnd), commit, SEP(ops↔branches), then branches. + expect(rows.slice(0, 5).map(summarize)).toEqual([ + "op:pull", + "op:fetch", + "sep", + "op:commit", + "sep", + ]) + }) + + it("omits group separators while searching", () => { + const ops: BranchOperationMeta[] = [ + { id: "pull", label: "Pull code" }, + { id: "fetch", label: "Fetch pull", groupEnd: true }, + ] + const rows = buildBranchRows(baseInput({ operations: ops, query: "pull" })) + expect(rows.map(summarize)).toEqual(["op:pull", "op:fetch"]) + }) +}) + +describe("buildBranchRows — search mode", () => { + it("flattens matched leaves under section headers, dropping groups", () => { + const rows = buildBranchRows( + baseInput({ + localNodes: localTree(LOCAL), + localCount: 4, + query: "feature", + }) + ) + // "feature" matches no operation label, so no ops block and no separator. + expect(rows.map(summarize)).toEqual([ + "section:local(2)+", + "leaf:feature/auth/login@1", + "leaf:feature/auth/logout@1", + ]) + }) + + it("filters operations by label and omits the branch side when nothing matches", () => { + const rows = buildBranchRows( + baseInput({ localNodes: localTree(LOCAL), localCount: 4, query: "push" }) + ) + expect(rows.map(summarize)).toEqual(["op:push"]) + }) + + it("keeps the separator when both an operation and branches match", () => { + const rows = buildBranchRows( + baseInput({ localNodes: localTree(LOCAL), localCount: 4, query: "e" }) + ) + // "Pull code" matches "e"; "Push..." does not. "main" has no "e". + expect(rows.map(summarize)).toEqual([ + "op:pull", + "sep", + "section:local(3)+", + "leaf:feature/auth/login@1", + "leaf:feature/auth/logout@1", + "leaf:release/1.0@1", + ]) + }) +}) + +describe("buildBranchRows — multiple remotes", () => { + it("nests each remote as a wrapper group one level deeper", () => { + const remoteSections = buildRemoteBranchSections([ + "origin/main", + "upstream/main", + "origin/dev", + ]) + const rows = buildBranchRows( + baseInput({ operations: [], remoteSections, remoteCount: 3 }) + ) + expect(rows.map(summarize)).toEqual([ + "section:local(0)+", + "empty:local", + "section:remote(3)+", + "group:origin@1+", + "leaf:origin/dev@2", + "leaf:origin/main@2", + "group:upstream@1+", + "leaf:upstream/main@2", + ]) + }) + + it("collapsing a remote wrapper hides just that remote's branches", () => { + const remoteSections = buildRemoteBranchSections([ + "origin/main", + "upstream/main", + ]) + const originKey = remoteSections.find((s) => s.remoteName === "origin")!.key + const rows = buildBranchRows( + baseInput({ + operations: [], + remoteSections, + remoteCount: 2, + collapsed: new Set([originKey]), + }) + ) + expect(rows.map(summarize)).toEqual([ + "section:local(0)+", + "empty:local", + "section:remote(2)+", + "group:origin@1-", + "group:upstream@1+", + "leaf:upstream/main@2", + ]) + }) +}) + +describe("isNavigableRow", () => { + it("skips separators and empty rows, keeps everything else", () => { + expect(isNavigableRow({ kind: "separator", key: "s" })).toBe(false) + expect(isNavigableRow({ kind: "empty", key: "e", scope: "local" })).toBe( + false + ) + expect( + isNavigableRow({ + kind: "operation", + key: "o", + opId: "pull", + label: "Pull", + destructive: false, + }) + ).toBe(true) + }) +}) diff --git a/src/lib/branch-selector-rows.ts b/src/lib/branch-selector-rows.ts new file mode 100644 index 000000000..51ae02f83 --- /dev/null +++ b/src/lib/branch-selector-rows.ts @@ -0,0 +1,342 @@ +/** + * Flat, virtualization-ready row model for the branch selector popup. + * + * The rich branch selector (`BranchDropdown`) renders operations (pull / fetch / + * commit / push / new branch / worktree / stash / manage remotes) AND the full + * local+remote branch tree as ONE searchable, virtualized, flat list — mirroring + * the model picker's `flattenModelGroups` + `ModelOptionList` split. This module + * is the pure half: it flattens the prefix-grouped {@link BranchTreeNode} trees + * (from `@/lib/branch-tree`) plus the operation metadata into a linear + * `BranchRow[]` the renderer maps 1:1 to virtua rows. + * + * Deliberately pure — no React, no callbacks, no icons, no i18n. The renderer + * resolves icons/handlers by `kind`/`opId` and builds every translated string + * (section headers by `scope`+`count`), so all display concerns stay out of + * here and the flattening logic is unit-testable in isolation. + */ + +import { sectionKey } from "@/lib/branch-tree" +import type { + BranchTreeLeaf, + BranchTreeNode, + RemoteBranchSection, +} from "@/lib/branch-tree" + +/** Container-supplied operation, resolved to icon + handler by the renderer. */ +export interface BranchOperationMeta { + id: string + /** Already-translated label — the ONLY string search matches operations on. */ + label: string + destructive?: boolean + /** Emit a separator after this op (non-search) to visually group operations. */ + groupEnd?: boolean +} + +export type BranchLeafAction = + | "switch" + | "merge" + | "rebase" + | "delete" + | "deleteRemote" + +export type BranchRow = + | { + kind: "operation" + key: string + opId: string + label: string + destructive: boolean + } + | { kind: "separator"; key: string } + | { + kind: "section" + key: string + scope: "local" | "remote" + count: number + expanded: boolean + } + | { + kind: "group" + key: string + depth: number + label: string + count: number + expanded: boolean + } + | { + kind: "leaf" + key: string + depth: number + fullName: string + label: string + isRemote: boolean + isCurrent: boolean + isTracking: boolean + isWorktree: boolean + } + | { kind: "empty"; key: string; scope: "local" | "remote" } + +export interface BuildBranchRowsInput { + operations: BranchOperationMeta[] + localNodes: BranchTreeNode[] + remoteSections: RemoteBranchSection[] + /** Total local branch count (for the section header's "(n)"). */ + localCount: number + /** Total remote branch count (for the section header's "(n)"). */ + remoteCount: number + /** Current branch ref (marks the current leaf, suppresses its actions). */ + branch: string | null + /** Branch names checked out in a worktree — leaf gets the worktree icon. */ + worktreeBranchSet: Set<string> + /** Group/section keys the user has collapsed (default-expanded = absent). */ + collapsed: Set<string> + /** Search query; when non-empty the tree flattens to matched leaves. */ + query: string +} + +const localSectionKey = sectionKey("local") +const remoteSectionKey = sectionKey("remote") + +interface LeafContext { + branch: string | null + worktreeBranchSet: Set<string> + collapsed: Set<string> +} + +/** Strip a remote leaf's `<remote>/` prefix (local leaves are returned as-is). */ +function localName(fullName: string, isRemote: boolean): string { + return isRemote ? fullName.replace(/^[^/]+\//, "") : fullName +} + +/** + * Emit a single leaf row. Per-branch actions (switch/merge/rebase/delete) are + * NOT rows — the renderer shows them in a right-side bubble when a leaf is + * clicked (`isTracking` there hides delete for the tracked remote branch). + */ +function emitLeaf( + out: BranchRow[], + leaf: BranchTreeLeaf, + depth: number, + isRemote: boolean, + ctx: LeafContext +): void { + const b = leaf.fullName + const isCurrent = b === ctx.branch + const isTracking = + isRemote && !!ctx.branch && localName(b, true) === ctx.branch + const isWorktree = ctx.worktreeBranchSet.has(localName(b, isRemote)) + + out.push({ + kind: "leaf", + key: leaf.key, + depth, + fullName: b, + label: leaf.label, + isRemote, + isCurrent, + isTracking, + isWorktree, + }) +} + +/** Recursively flatten a prefix tree, descending only expanded groups. */ +function emitTree( + out: BranchRow[], + nodes: BranchTreeNode[], + depth: number, + isRemote: boolean, + ctx: LeafContext +): void { + for (const node of nodes) { + if (node.type === "leaf") { + emitLeaf(out, node, depth, isRemote, ctx) + continue + } + const expanded = !ctx.collapsed.has(node.key) + out.push({ + kind: "group", + key: node.key, + depth, + label: node.label, + count: node.count, + expanded, + }) + if (expanded) emitTree(out, node.children, depth + 1, isRemote, ctx) + } +} + +/** All leaf descendants of `nodes`, in render order. */ +function collectLeaves(nodes: BranchTreeNode[]): BranchTreeLeaf[] { + const leaves: BranchTreeLeaf[] = [] + const walk = (list: BranchTreeNode[]) => { + for (const node of list) { + if (node.type === "leaf") leaves.push(node) + else walk(node.children) + } + } + walk(nodes) + return leaves +} + +function matchesLeaf(leaf: BranchTreeLeaf, q: string): boolean { + return ( + leaf.fullName.toLowerCase().includes(q) || + leaf.label.toLowerCase().includes(q) + ) +} + +/** + * Flatten operations + branch trees into a single linear row list. + * + * - Empty query: operations block → separator → Local section (its prefix tree, + * descending only expanded groups) → Remote section (single-remote strips the + * wrapper; multi-remote nests each remote as a group). Sections default open. + * - Non-empty query: operations whose label matches → separator → matched local + * leaves and matched remote leaves, flat under their section headers (groups + * dropped, collapse state ignored); empty sections omitted. + * + * Indentation depth: operations flat; a section header is depth 0; its children + * are depth 1 (and deeper per nesting). + */ +export function buildBranchRows(input: BuildBranchRowsInput): BranchRow[] { + const { + operations, + localNodes, + remoteSections, + localCount, + remoteCount, + branch, + worktreeBranchSet, + collapsed, + query, + } = input + + const q = query.trim().toLowerCase() + const searching = q.length > 0 + const ctx: LeafContext = { + branch, + worktreeBranchSet, + collapsed, + } + + const rows: BranchRow[] = [] + + // --- Operations ------------------------------------------------------------ + // Grouped by a separator after each `groupEnd` op (non-search only) to mirror + // the old menu's pull/fetch | commit/push | … blocks. + for (const op of operations) { + if (searching && !op.label.toLowerCase().includes(q)) continue + rows.push({ + kind: "operation", + key: `op:${op.id}`, + opId: op.id, + label: op.label, + destructive: op.destructive ?? false, + }) + if (!searching && op.groupEnd) { + rows.push({ kind: "separator", key: `sep:op:${op.id}` }) + } + } + const hasOperations = rows.some((row) => row.kind === "operation") + + // --- Branches -------------------------------------------------------------- + const branchRows: BranchRow[] = [] + + if (searching) { + const localMatches = collectLeaves(localNodes).filter((l) => + matchesLeaf(l, q) + ) + if (localMatches.length > 0) { + branchRows.push({ + kind: "section", + key: localSectionKey, + scope: "local", + count: localMatches.length, + expanded: true, + }) + for (const leaf of localMatches) emitLeaf(branchRows, leaf, 1, false, ctx) + } + + const remoteMatches: BranchTreeLeaf[] = [] + for (const section of remoteSections) { + for (const leaf of collectLeaves(section.nodes)) { + if (matchesLeaf(leaf, q)) remoteMatches.push(leaf) + } + } + if (remoteMatches.length > 0) { + branchRows.push({ + kind: "section", + key: remoteSectionKey, + scope: "remote", + count: remoteMatches.length, + expanded: true, + }) + for (const leaf of remoteMatches) emitLeaf(branchRows, leaf, 1, true, ctx) + } + } else { + // Local section + const localExpanded = !collapsed.has(localSectionKey) + branchRows.push({ + kind: "section", + key: localSectionKey, + scope: "local", + count: localCount, + expanded: localExpanded, + }) + if (localExpanded) { + if (localNodes.length === 0) { + branchRows.push({ kind: "empty", key: "empty:local", scope: "local" }) + } else { + emitTree(branchRows, localNodes, 1, false, ctx) + } + } + + // Remote section + const remoteExpanded = !collapsed.has(remoteSectionKey) + branchRows.push({ + kind: "section", + key: remoteSectionKey, + scope: "remote", + count: remoteCount, + expanded: remoteExpanded, + }) + if (remoteExpanded) { + if (remoteCount === 0) { + branchRows.push({ kind: "empty", key: "empty:remote", scope: "remote" }) + } else { + for (const section of remoteSections) { + if (section.remoteName == null) { + emitTree(branchRows, section.nodes, 1, true, ctx) + continue + } + // Multiple remotes: each remote is a wrapper group toggled by its + // own section key, its branches nested one level deeper. + const wrapperExpanded = !collapsed.has(section.key) + branchRows.push({ + kind: "group", + key: section.key, + depth: 1, + label: section.remoteName, + count: section.count, + expanded: wrapperExpanded, + }) + if (wrapperExpanded) { + emitTree(branchRows, section.nodes, 2, true, ctx) + } + } + } + } + } + + if (hasOperations && branchRows.length > 0) { + rows.push({ kind: "separator", key: "sep:ops-branches" }) + } + rows.push(...branchRows) + + return rows +} + +/** Row kinds the keyboard cursor can land on (skips separators + empty rows). */ +export function isNavigableRow(row: BranchRow): boolean { + return row.kind !== "separator" && row.kind !== "empty" +} diff --git a/src/lib/branch-tree.test.ts b/src/lib/branch-tree.test.ts index 83e22db59..9cf63105e 100644 --- a/src/lib/branch-tree.test.ts +++ b/src/lib/branch-tree.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest" import { buildBranchTree, buildRemoteBranchSections, + collectGroupKeys, containsBranch, expandedKeysForBranch, localBranchItems, @@ -288,3 +289,41 @@ describe("sectionKey", () => { expect(sectionKey("remote:origin")).toBe("s remote:origin") }) }) + +describe("collectGroupKeys", () => { + it("collects every prefix-group key, descending nested groups in render order", () => { + const nodes = buildBranchTree( + localBranchItems([ + "feat/auth/login", + "feat/auth/logout", + "feat/pay", + "main", + ]), + "local" + ) + expect(collectGroupKeys(nodes)).toEqual([ + "g local feat", + "g local feat/auth", + ]) + }) + + it("returns [] when there are no surviving prefix groups", () => { + // Distinct top-level branches + a single collapsed chain — all leaves. + const nodes = buildBranchTree( + localBranchItems(["main", "dev", "a/b/c"]), + "local" + ) + expect(collectGroupKeys(nodes)).toEqual([]) + }) + + it("collects tree-internal groups but not the multi-remote wrapper key", () => { + const [origin] = buildRemoteBranchSections([ + "origin/feat/a", + "origin/feat/b", + "upstream/main", + ]) + // Only the in-tree "feat/" group — the wrapper (section.key) lives outside. + expect(collectGroupKeys(origin.nodes)).toEqual(["g remote:origin feat"]) + expect(collectGroupKeys(origin.nodes)).not.toContain(origin.key) + }) +}) diff --git a/src/lib/branch-tree.ts b/src/lib/branch-tree.ts index 66fed8d05..f46e4a0a0 100644 --- a/src/lib/branch-tree.ts +++ b/src/lib/branch-tree.ts @@ -233,6 +233,26 @@ export function expandedKeysForBranch( return [...path] } +/** + * The expansion keys of every prefix group in `nodes`, in render order. Used to + * seed the branch selector's default-collapsed set so prefix groups start + * folded while their section stays open. Sections and the multi-remote wrapper + * are keyed outside the tree, so they're never included here. + */ +export function collectGroupKeys(nodes: BranchTreeNode[]): string[] { + const keys: string[] = [] + const walk = (current: BranchTreeNode[]) => { + for (const node of current) { + if (node.type === "group") { + keys.push(node.key) + walk(node.children) + } + } + } + walk(nodes) + return keys +} + /** * Bucket remote branches by remote name and build a tree per remote. Mirrors the * existing UX: a single remote strips its prefix with no wrapper level; multiple diff --git a/src/lib/delegation-status.ts b/src/lib/delegation-status.ts index 76eb5b23e..7a55a9770 100644 --- a/src/lib/delegation-status.ts +++ b/src/lib/delegation-status.ts @@ -535,9 +535,29 @@ export function buildDelegationTaskRows( // gets a (spinner) row immediately instead of all-but-the-first vanishing // until the batch resolves. const count = Math.max(reports.length, inputIds.length) + const inFlight = + poll.state === "input-available" || poll.state === "input-streaming" for (let i = 0; i < count; i++) { const report = reports[i] ?? EMPTY_STATUS_REPORT const taskId = report.taskId ?? inputIds[i] ?? null + // Drop an in-flight poll that carries no identity AND nothing to show yet. + // claude-agent-acp ships an arg-less initial `tool_call` (rawInput `{}`) + // and only fills the real `task_ids` on a later update — which, for a long + // `wait_ms` status check, lands near completion — so a poll that is still + // in flight often parses to this empty, id-less report. Each such re-poll + // would otherwise become its own anonymous "waiting for a task's result" + // row, stacking identical duplicates of the same wait. It folds into its + // task's row the moment an id or report arrives, and the settled transcript + // (whose polls always carry the id) is unaffected. A settled id-less report + // — a real interim note or terminal status — still gets its own row below. + if ( + taskId == null && + inFlight && + report.status == null && + (report.text == null || report.text.trim() === "") + ) { + continue + } const key = taskId ?? `__unattributed__:${poll.toolCallId}:${i}` let entry = byKey.get(key) if (!entry) { diff --git a/src/lib/fuzzy-text-match.test.ts b/src/lib/fuzzy-text-match.test.ts new file mode 100644 index 000000000..98ac39908 --- /dev/null +++ b/src/lib/fuzzy-text-match.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest" +import { + rankByTextMatch, + scoreTextMatch, + subsequenceFirstIndex, +} from "@/lib/fuzzy-text-match" + +describe("subsequenceFirstIndex", () => { + it("matches contiguous and gapped subsequences", () => { + expect(subsequenceFirstIndex("bmadhelp", "bmad-help")).toBe(0) + expect(subsequenceFirstIndex("bmhp", "bmad-help")).toBe(0) + expect(subsequenceFirstIndex("help", "bmad-help")).toBe(5) + }) + + it("returns -1 when a character is missing", () => { + expect(subsequenceFirstIndex("bmadx", "bmad-help")).toBe(-1) + }) +}) + +describe("scoreTextMatch tiers", () => { + it("orders exact > prefix > substring > subsequence", () => { + const q = "help" + const exact = scoreTextMatch(q, "help")! + const prefix = scoreTextMatch(q, "help-me")! + const substring = scoreTextMatch(q, "bmad-help")! + const subseq = scoreTextMatch("bmhp", "bmad-help")! + + expect(exact).toBeGreaterThan(prefix) + expect(prefix).toBeGreaterThan(substring) + expect(scoreTextMatch("bmadh", "bmad-help")!).toBeLessThan( + scoreTextMatch("bmad-", "bmad-help")! + ) + // subsequence still scores positive + expect(subseq).toBeGreaterThan(0) + }) + + it("returns null when nothing matches", () => { + expect(scoreTextMatch("zzz", "bmad-help")).toBeNull() + }) +}) + +describe("rankByTextMatch", () => { + const cmds = [ + { name: "bmad-help", description: "Show BMAD help" }, + { name: "bmad-create-story", description: "Create a story" }, + { name: "clear", description: "Clear the chat" }, + ] + + it("returns all items for an empty query", () => { + expect(rankByTextMatch("", cmds, (c) => c.name)).toEqual(cmds) + }) + + it("matches hyphenated names via subsequence (bmadhelp / bmhp)", () => { + const ranked = rankByTextMatch("bmadhelp", cmds, (c) => c.name) + expect(ranked.map((c) => c.name)).toEqual(["bmad-help"]) + + const short = rankByTextMatch("bmhp", cmds, (c) => c.name) + expect(short.map((c) => c.name)).toContain("bmad-help") + }) + + it("ranks exact > prefix > subsequence (shorter wins ties)", () => { + const items = [ + { name: "batch" }, // subsequence b..h + { name: "bh-tool" }, // prefix + { name: "bh" }, // exact + { name: "bmad-help" }, // subsequence, longer than batch + ] + const ranked = rankByTextMatch("bh", items, (i) => i.name) + expect(ranked.map((i) => i.name)).toEqual([ + "bh", + "bh-tool", + "batch", + "bmad-help", + ]) + }) + + it("keeps any primary match above any secondary match", () => { + const items = [ + { name: "zzz", tag: "bh" }, // secondary exact + { name: "batch", tag: "zzz" }, // primary subsequence (b..h) + ] + // Weakest primary (subsequence) still outranks strongest secondary (exact). + const ranked = rankByTextMatch( + "bh", + items, + (i) => i.name, + (i) => i.tag + ) + expect(ranked.map((i) => i.name)).toEqual(["batch", "zzz"]) + }) + + it("preserves input order for equally-scored matches (stable sort)", () => { + const items = [ + { id: 1, name: "abc" }, + { id: 2, name: "abc" }, + { id: 3, name: "abc" }, + ] + const ranked = rankByTextMatch("abc", items, (i) => i.name) + expect(ranked.map((i) => i.id)).toEqual([1, 2, 3]) + }) + + it("skips items with empty primary text without matching", () => { + const items = [{ name: "" }, { name: "bmad-help" }] + const ranked = rankByTextMatch("bmad", items, (i) => i.name) + expect(ranked.map((i) => i.name)).toEqual(["bmad-help"]) + }) + + it("falls back to secondary field below primary hits", () => { + const items = [ + { name: "clear", description: "bmad helpers" }, + { name: "bmad-help", description: "other" }, + ] + const ranked = rankByTextMatch( + "bmad", + items, + (i) => i.name, + (i) => i.description + ) + // name prefix/substring on bmad-help beats description-only on clear + expect(ranked[0].name).toBe("bmad-help") + expect(ranked.map((i) => i.name)).toContain("clear") + }) + + it("is case-insensitive", () => { + const ranked = rankByTextMatch("BMADHELP", cmds, (c) => c.name) + expect(ranked.map((c) => c.name)).toEqual(["bmad-help"]) + }) +}) diff --git a/src/lib/fuzzy-text-match.ts b/src/lib/fuzzy-text-match.ts new file mode 100644 index 000000000..0f535e509 --- /dev/null +++ b/src/lib/fuzzy-text-match.ts @@ -0,0 +1,98 @@ +/** + * Ranked text matcher for slash-command / skill autocomplete. + * No external fuzzy dependency — tiers are exact → prefix → substring → + * subsequence so short inputs like `bmhp` can still hit `bmad-help`. + * + * Same scoring shape as `file-search-match.ts` (tier dominates; earlier + * match position and shorter candidate win within a tier). + */ + +const TIER_BASE = 100_000_000 + +const TIER_EXACT = 6 +const TIER_PREFIX = 5 +const TIER_SUBSTRING = 4 +const TIER_SUBSEQUENCE = 3 + +function tierScore(tier: number, position: number, length: number): number { + return ( + tier * TIER_BASE - Math.min(position, 9_999) * 1_000 - Math.min(length, 999) + ) +} + +/** + * Index of the first matched character if every char of `query` appears in `s` + * in order (a subsequence), else -1. `query` must be non-empty. + */ +export function subsequenceFirstIndex(query: string, s: string): number { + let qi = 0 + let firstIdx = -1 + for (let si = 0; si < s.length && qi < query.length; si++) { + if (s[si] === query[qi]) { + if (qi === 0) firstIdx = si + qi++ + } + } + return qi === query.length ? firstIdx : -1 +} + +/** + * Score one already-lowercased string against an already-lowercased, + * non-empty `query`. Higher is better; `null` means no match. + */ +export function scoreTextMatch(query: string, text: string): number | null { + if (!query) return null + + if (text === query) { + return tierScore(TIER_EXACT, 0, text.length) + } + if (text.startsWith(query)) { + return tierScore(TIER_PREFIX, 0, text.length) + } + const idx = text.indexOf(query) + if (idx !== -1) { + return tierScore(TIER_SUBSTRING, idx, text.length) + } + const sub = subsequenceFirstIndex(query, text) + if (sub !== -1) { + return tierScore(TIER_SUBSEQUENCE, sub, text.length) + } + return null +} + +/** + * Filter + rank `items` by primary text, with optional secondary field fallback + * (e.g. description / skill id). Empty query returns items unchanged (original + * order). Secondary hits always rank below any primary hit. + */ +export function rankByTextMatch<T>( + query: string, + items: readonly T[], + getPrimary: (item: T) => string, + getSecondary?: (item: T) => string | undefined | null +): T[] { + const q = query.trim().toLowerCase() + if (!q) return items.slice() + + // Drop secondary below every primary tier so a weak name subsequence still + // beats a perfect description / id match (mirrors previous name-first lists). + const secondaryOffset = TIER_EXACT * TIER_BASE + + const scored: { item: T; score: number }[] = [] + for (const item of items) { + const primary = getPrimary(item).toLowerCase() + let score = scoreTextMatch(q, primary) + if (score === null && getSecondary) { + const secondary = getSecondary(item)?.toLowerCase() + if (secondary) { + const sec = scoreTextMatch(q, secondary) + if (sec !== null) score = sec - secondaryOffset + } + } + if (score !== null) scored.push({ item, score }) + } + + // ES2019 stable sort keeps original order for equal scores. + scored.sort((a, b) => b.score - a.score) + return scored.map((s) => s.item) +}