From b850406a552b26206e14f350d552759a3cbb8a44 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 22 Jul 2026 11:48:57 +0800 Subject: [PATCH 01/10] fix(acp): sync composer install status after local version detection The composer reads installed_version from the agent_setting row via a cached list, so an agent whose version is discovered only by the Settings live probe kept showing "not installed". acp_detect_agent_local_version_core now emits app://acp-agents-updated when the probe changes the stored version, so useAcpAgents() reloads and the input box reflects the real state in both desktop and server modes. The event fires only on an actual change to avoid redundant reloads during the settings preflight sweep. --- src-tauri/src/commands/acp.rs | 36 +++++++++++++++++++++++++------ src-tauri/src/web/handlers/acp.rs | 8 ++++--- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 8a230c349..b8b8b9cc1 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -8168,12 +8168,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 +8210,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 +8226,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/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)) } From 650f205f159520913e7c040304a1e4d5ef03d1ce Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 22 Jul 2026 12:30:56 +0800 Subject: [PATCH 02/10] fix(acp): retain non-UTF-8 output in agent and plugin install logs Agent (npm) and plugin (bun) install streams were read as strict UTF-8 and stopped at the first non-UTF-8 byte, so localized OS error text (e.g. GBK on Chinese Windows) truncated the live log and left the captured stderr empty, surfacing install failures with no error detail. Read the streams UTF-8-lossily via a shared helper so the real output is preserved. --- src-tauri/src/acp/opencode_plugins.rs | 25 +++--- src-tauri/src/commands/acp.rs | 39 ++++++---- src-tauri/src/commands/office_tools.rs | 102 +----------------------- src-tauri/src/process.rs | 104 +++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 129 deletions(-) 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..069e02955 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 } }); 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/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()); + } +} From 90c6421ea984bdf20bdc950923846f2f7795963b Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 22 Jul 2026 13:10:48 +0800 Subject: [PATCH 03/10] fix(sidebar): keep the Folders section visible with an empty hint when no folder is open --- .../sidebar-conversation-grouping.test.ts | 52 +++++++++-- .../sidebar-conversation-grouping.ts | 92 ++++++++++++------- .../sidebar-conversation-list.tsx | 11 +++ src/i18n/messages/ar.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + 13 files changed, 125 insertions(+), 40 deletions(-) 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({ ) } + 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 ( +
+ {t("noFolders")} +
+ ) + } 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/i18n/messages/ar.json b/src/i18n/messages/ar.json index d00a6cefc..24d50629b 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1372,6 +1372,7 @@ "sectionFolders": "المجلدات", "sectionChats": "محادثة", "noChats": "لا توجد محادثات", + "noFolders": "لا توجد مجلدات مفتوحة", "newChatAction": "محادثة جديدة", "automations": "الأتمتة", "loadingSubsessions": "جارٍ تحميل المحادثات الفرعية…" diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index e926ed601..e3463a9b7 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…" diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 359b54268..88857eb7b 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…" diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 56eddbce5..f9b199adf 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…" diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 5e888e7d6..fa464fb86 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…" diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index d4e820fb0..ebe7d3245 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1372,6 +1372,7 @@ "sectionFolders": "フォルダ", "sectionChats": "チャット", "noChats": "チャットがありません", + "noFolders": "開いているフォルダがありません", "newChatAction": "新しいチャット", "automations": "オートメーション", "loadingSubsessions": "サブ会話を読み込み中…" diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 120eed32e..6ea385246 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1372,6 +1372,7 @@ "sectionFolders": "폴더", "sectionChats": "채팅", "noChats": "채팅 없음", + "noFolders": "열린 폴더 없음", "newChatAction": "새 채팅", "automations": "자동화", "loadingSubsessions": "하위 대화 로드 중…" diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 20380762b..712183047 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…" diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 341b37d93..ff55ca95e 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": "正在加载子会话…" diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 53bee8190..0c4d4529c 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": "正在載入子會話…" From 1ddf751b96e132bd7f7016837d4ed1203ea935a2 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 22 Jul 2026 14:47:10 +0800 Subject: [PATCH 04/10] fix(chat): drop id-less in-flight tool cards that stacked during streaming While a delegation-status or background-task poll, or an ask_user_question call, is still in flight, its live tool_call carries no arguments yet (Claude Code sends an arg-less initial tool_call and fills the real input only on a later update). Each such block rendered as an anonymous "running" / "awaiting your answer" row, and repeated re-polls of the same task stacked into visible duplicates. The delegation and background-task row builders now skip an in-flight poll that has no task id and nothing to show, and AskQuestionResultCard renders nothing until the question text resolves (the live answer is driven by the pinned card). Entries reappear the moment an id or result arrives; the settled transcript is unaffected. --- .../message/ask-question-result-card.test.tsx | 12 ++++ .../message/ask-question-result-card.tsx | 25 +++++--- .../message/delegation-status-card.test.tsx | 23 +++++++- .../delegation-status-group-card.test.tsx | 41 +++++++++++++ src/lib/background-task.test.ts | 57 +++++++++++++++++++ src/lib/background-task.ts | 12 ++++ src/lib/delegation-status.ts | 20 +++++++ 7 files changed, 180 insertions(+), 10 deletions(-) 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( + + ) + 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 ? ( -
- {questions.map((q, i) => ( - - {q.header || q.question} - - ))} -
- ) : undefined +
+ {questions.map((q, i) => ( + + {q.header || q.question} + + ))} +
) } 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( - + ) 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( + + ) + 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( { 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( + + ) + 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( + + ) + 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( { 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 = `success +completed +0 +ok` + 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/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) { From b28a7e76c9f88cc555a25d72e6faf8b4473dafba Mon Sep 17 00:00:00 2001 From: xupeng Date: Wed, 22 Jul 2026 15:51:05 +0800 Subject: [PATCH 05/10] feat(chat): fuzzy subsequence match for slash commands Replace substring-only filtering so inputs like /bmadhelp and /bmhp can match hyphenated names (e.g. bmad-help), with ranked exact/prefix/ substring/subsequence scoring shared across chat and automation menus. --- .../automations/composer-invocations.tsx | 19 ++-- src/components/chat/message-input.tsx | 48 ++++----- src/lib/fuzzy-text-match.test.ts | 91 +++++++++++++++++ src/lib/fuzzy-text-match.ts | 98 +++++++++++++++++++ 4 files changed, 215 insertions(+), 41 deletions(-) create mode 100644 src/lib/fuzzy-text-match.test.ts create mode 100644 src/lib/fuzzy-text-match.ts 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/message-input.tsx b/src/components/chat/message-input.tsx index 1668f5b87..a2c800728 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -142,6 +142,7 @@ import { loadMessageInputDraftV2, saveMessageInputDraftV2, } from "@/lib/message-input-draft" +import { rankByTextMatch } from "@/lib/fuzzy-text-match" import { RichComposer, type RichComposerHandle, @@ -1007,20 +1008,16 @@ export function MessageInput({ const [slashDropdownOpen, setSlashDropdownOpen] = useState(false) const [slashDropdownSearch, setSlashDropdownSearch] = useState("") const slashDropdownInputRef = useRef(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 +1036,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 diff --git a/src/lib/fuzzy-text-match.test.ts b/src/lib/fuzzy-text-match.test.ts new file mode 100644 index 000000000..a0eb6787d --- /dev/null +++ b/src/lib/fuzzy-text-match.test.ts @@ -0,0 +1,91 @@ +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 above subsequence", () => { + const items = [ + { name: "bh" }, + { name: "bmad-help" }, + { name: "batch" }, + ] + const ranked = rankByTextMatch("bh", items, (i) => i.name) + expect(ranked[0].name).toBe("bh") + }) + + 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( + 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) +} From 4519d1de6868872571945a52522a7fc65b7dd690 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 22 Jul 2026 20:42:33 +0800 Subject: [PATCH 06/10] feat(git): move the branch selector below the composer and rebuild its dropdown Relocate the rich branch selector out of the status bar into the below-composer folder/branch row, mounted per conversation tile so tiled views keep every tile's branch chip. Rebuild its dropdown as a searchable, virtualized flat list that combines the git operations and the local/remote branch tree: type-ahead filtering, prefix-grouped branches (groups collapsed by default), and per-branch actions (switch/merge/rebase/delete) in a right-side flyout. --- src/components/chat/chat-input.tsx | 11 +- .../chat/conversation-context-bar.test.tsx | 19 +- .../chat/conversation-context-bar.tsx | 168 +++- src/components/chat/message-input.test.tsx | 5 + src/components/chat/message-input.tsx | 66 +- .../conversation-detail-header.tsx | 7 +- .../conversation-detail-panel-layout.test.ts | 67 +- src/components/layout/branch-dropdown.tsx | 717 +++++------------ .../layout/branch-selector-list.tsx | 726 ++++++++++++++++++ .../layout/branch-selector-placement.test.ts | 41 + src/components/layout/status-bar.tsx | 20 +- src/i18n/messages/ar.json | 6 +- src/i18n/messages/de.json | 6 +- src/i18n/messages/en.json | 6 +- src/i18n/messages/es.json | 6 +- src/i18n/messages/fr.json | 6 +- src/i18n/messages/ja.json | 6 +- src/i18n/messages/ko.json | 6 +- src/i18n/messages/pt.json | 6 +- src/i18n/messages/zh-CN.json | 6 +- src/i18n/messages/zh-TW.json | 6 +- src/lib/branch-selector-rows.test.ts | 304 ++++++++ src/lib/branch-selector-rows.ts | 342 +++++++++ src/lib/branch-tree.test.ts | 39 + src/lib/branch-tree.ts | 20 + 25 files changed, 2002 insertions(+), 610 deletions(-) create mode 100644 src/components/layout/branch-selector-list.tsx create mode 100644 src/components/layout/branch-selector-placement.test.ts create mode 100644 src/lib/branch-selector-rows.test.ts create mode 100644 src/lib/branch-selector-rows.ts 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/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() - // 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 ( { 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 ( + <> + { + 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. */} + + + ) + } +) + +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" )} > - {headerLabel} + {/* 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). */} + {headerLabel} ) : (
)} - {/* 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. */} -
+ {/* 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`). */} +
{/* 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 +3026,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 +3048,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 +3593,21 @@ export function MessageInput({ + {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`). +
+ +
+ )}
- + {/* 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. */} + {displayTitle}
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( + " { @@ -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/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( + () => [ + { 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 ( -
- - {label} - {t("current")} -
- ) + // 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 ( - - - - {label} - - - { - if (isRemote) { - void handleCheckoutRemote(b) - } else { - void handleCheckout(b) - } - }} - > - - {t("switchToBranch")} - - { - setDropdownOpen(false) - setConfirmAction({ type: "merge", branchName: b }) - }} - > - - {t("mergeBranchIntoCurrent", { - branchName: b, - currentBranch: branch ?? "-", - })} - - { - setDropdownOpen(false) - setConfirmAction({ type: "rebase", branchName: b }) - }} - > - - {t("rebaseCurrentToBranch", { - currentBranch: branch ?? "-", - branchName: b, - })} - - {!isTrackingCurrent && ( - <> - - { - setDropdownOpen(false) - setConfirmAction({ - type: isRemote ? "deleteRemote" : "delete", - branchName: b, - }) - }} - > - - {t("deleteBranch")} - - - )} - - - ) } - // 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 [ - { - e.preventDefault() - toggle(node.key) - }} - style={{ paddingLeft: branchRowPaddingLeft("dropdown", depth) }} - > - - {node.label} - - {node.count} - - , - ...(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 ( - - + + - - - + + + + ) } return ( <> -
- - - - - - - - - {t("pullCode")} - - - runGitTask(t("tasks.fetchInfo"), () => - withCredentialRetry( - (creds) => gitFetch(folderPath, creds), - { - folderPath, - } - ) - ) - } - > - - {t("fetchRemoteBranches")} - - - - - { - 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 }) - }) - }} - > - - {t("openCommitWindow")} - - { - 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 }) - }) - }} - > - - {t("pushCode")} - - - - - { - setNewBranchName("") - setNewBranchOpen(true) - }} - > - - {t("newBranch")} - - - - {t("newWorktree")} - - - - - { - setDropdownOpen(false) - setStashDialogOpen(true) - }} - > - - {t("stashChanges")} - - { - if (!folderId) return - openStashWindow(folderId).catch((err) => { - const msg = toErrorMessage(err) - pushAlert("error", t("stashPop"), msg) - }) - }} - > - - {t("stashPop")} - - - - - { - setDropdownOpen(false) - setManageRemotesOpen(true) - }} - > - - {t("manageRemotes")} - - - - {branchLoading ? ( -
- -
+ {/* Single chip: the branch name + chevron opens the searchable popup (pull + lives inside it now). Matches the sibling folder chip's ghost xs feel. */} + + + - - )} -
+ + {branch ?? head?.branch ?? head?.short_sha ?? t("noBranch")} + + + + + {/* No `overflow-hidden`: the list's inner shell clips to the rounding so + the right-side action bubble can overflow past this edge. */} + + + + + /** 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 = { + pull: CloudDownload, + fetch: CloudSync, + commit: GitCommitHorizontal, + push: CloudUpload, + newBranch: GitBranchPlus, + newWorktree: FolderGit2, + stash: Archive, + stashPop: ArchiveRestore, + manageRemotes: Globe, + init: GitBranch, +} + +const ACTION_ICONS: Record = { + 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>( + () => new Map() + ) + const [activeIndex, setActiveIndex] = useState(0) + const [bubble, setBubble] = useState(null) + const [bubbleActiveIndex, setBubbleActiveIndex] = useState(0) + + const rootRef = useRef(null) + const inputRef = useRef(null) + const virtualizerRef = useRef(null) + const viewportRef = useRef(null) + const [viewportEl, setViewportEl] = useState(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() + 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, + 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) { + // 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 ( +
+ ) + } + if (row.kind === "empty") { + return ( +
+ {row.scope === "local" ? t("noLocalBranches") : t("noRemoteBranches")} +
+ ) + } + + 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 ( + + ) + } + + 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 ( + + ) + } + + // 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 ( + + ) + } + + return ( +
+ {/* 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). */} +
+ {/* 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). */} +
+ + 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" + /> +
+ + {showSpinner ? ( +
+ +
+ ) : isEmpty ? ( +
+ {t("noMatches")} +
+ ) : ( +
+ +
+ {viewportEl ? ( + + {rows.map((row, flatIndex) => renderRow(row, flatIndex))} + + ) : null} +
+
+
+ )} +
+ + {bubble ? ( +
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 ( + + ) + })} +
+ ) : null} +
+ ) +} 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( + "" + ) + expect(contextBarSource).not.toContain("isActiveTab && { + // 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 ( -
-
- -
+
@@ -34,12 +30,10 @@ export function StatusBar() { return (
+ {/* The branch selector moved to the below-composer folder/branch row; the + left side now carries just the workspace stats. */}
- {/* 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. */} -
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 24d50629b..dd54d376a 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1738,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 e3463a9b7..d80a5b69f 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1738,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 88857eb7b..783c55221 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1738,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 f9b199adf..55ac967f5 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1738,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 fa464fb86..0b71da52e 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1738,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 ebe7d3245..fbd1be4ce 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1738,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 6ea385246..68db8fb40 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1738,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 712183047..ff331c024 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1738,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 ff55ca95e..6c11d56f6 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1738,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 0c4d4529c..31bf100f2 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1738,7 +1738,11 @@ "confirmApply": "將貯藏 {ref} 套用到工作目錄?", "cancel": "取消" }, - "deleteBranch": "刪除分支" + "deleteBranch": "刪除分支", + "searchPlaceholder": "搜尋分支與操作", + "searchAriaLabel": "搜尋分支與操作", + "branchListLabel": "分支與操作", + "noMatches": "無相符項目" }, "commitDialog": { "toasts": { 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 { + 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 + /** Group/section keys the user has collapsed (default-expanded = absent). */ + collapsed: Set + /** 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 + collapsed: Set +} + +/** Strip a remote leaf's `/` 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 From 3df850aa4073190f96d6662b9bdaea775454d017 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 22 Jul 2026 21:16:07 +0800 Subject: [PATCH 07/10] test(chat): fix prettier lint and lock fuzzy-match ranking invariants The exact/prefix/subsequence ordering test collapsed onto one line under prettier, failing CI; rewrite it to assert the full tier order (adding the missing prefix case) and add coverage for the primary-over-secondary guarantee, stable-sort tie ordering, and empty-primary handling. --- src/lib/fuzzy-text-match.test.ts | 47 ++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src/lib/fuzzy-text-match.test.ts b/src/lib/fuzzy-text-match.test.ts index a0eb6787d..98ac39908 100644 --- a/src/lib/fuzzy-text-match.test.ts +++ b/src/lib/fuzzy-text-match.test.ts @@ -58,14 +58,51 @@ describe("rankByTextMatch", () => { expect(short.map((c) => c.name)).toContain("bmad-help") }) - it("ranks exact/prefix above subsequence", () => { + it("ranks exact > prefix > subsequence (shorter wins ties)", () => { const items = [ - { name: "bh" }, - { name: "bmad-help" }, - { name: "batch" }, + { 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[0].name).toBe("bh") + 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", () => { From 3b62da6fad5cb854df5da3317defb5733220cf71 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 22 Jul 2026 22:01:10 +0800 Subject: [PATCH 08/10] feat(chat): open reply changed-file items on click like new files Changed-file rows in a reply's artifacts now render as cards that open the file in the workspace tabs on click, with a reveal-in-folder side button, mirroring the New files cards. The inline diff is dropped and removed files render as static cards. The section keeps its own neutral/destructive accent instead of the new-files green. --- src/components/message/reply-artifacts.tsx | 221 +++++++++++---------- 1 file changed, 119 insertions(+), 102 deletions(-) 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(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({ {changedOpen && ( -
    - {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 + +
    +
    + {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 ( -
  • - + + {t("remove")} + +
  • + ) + } + + return ( +
    + + + + + + {t("openInEditor")} + + - {isOpen && - (file.diff ? ( - - ) : ( -

    - {t("noDiffDataAvailable", { filePath: displayPath })} -

    - ))} - - ) - })} -
+ {isLocalDesktop() && ( + + + + + + {t("revealInFolder")} + + + )} +
+ ) + })} +
+
+ )}
)} From c87bbf439fa1819564ecbea8de43c6ee0b2f5450 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 22 Jul 2026 22:09:15 +0800 Subject: [PATCH 09/10] fix(kimi): base context-window usage on the latest step, not the per-turn sum Kimi emits one usage record per step, and each step's cache-read count re-reads the same growing context prefix. The context-window usage figure summed every step, so a multi-step turn counted the cached prefix repeatedly and reported far more than the model's window, clamping the gauge to a false 100%. It now reads occupancy from the latest step's input side (input plus cache-creation and cache-read tokens) to reflect the current context, while the cumulative token-usage totals stay unchanged. --- src-tauri/src/parsers/kimi_code.rs | 107 +++++++++++++++++- .../parsers_snapshot__kimi_code_detail.snap | 4 +- 2 files changed, 105 insertions(+), 6 deletions(-) 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/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 } } From f16da99b865a9d6b8bae5ede964f9e9c54c475cf Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 22 Jul 2026 22:35:08 +0800 Subject: [PATCH 10/10] fix(chat): paste a copied URL as its address, not the page title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message composer is plain text with no link mark, so ProseMirror's default paste dropped the href and kept the anchor text from a browser's clipboard HTML — a URL copied from the address bar pasted as the page title. Paste now prefers the clipboard's text/plain whenever the HTML is an external fragment, while content copied within the editor (tagged with data-pm-slice) still round-trips through ProseMirror so paragraphs, hard breaks, and reference badges are preserved exactly. --- .../chat/composer/plain-text-content.test.ts | 71 ++++++++++++++++ .../chat/composer/plain-text-content.ts | 53 ++++++++++++ .../chat/composer/rich-composer.test.tsx | 80 +++++++++++++++++++ .../chat/composer/rich-composer.tsx | 30 ++++++- 4 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 src/components/chat/composer/plain-text-content.test.ts 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 }) => {