diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index ebec0a28d..7fb050810 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -27,6 +27,7 @@ use crate::acp::types::{AcpEvent, ConnectionStatus, EventEnvelope}; use crate::db::entities::conversation::ConversationStatus; use crate::db::error::DbError; use crate::db::service::conversation_service; +use crate::logging::throttle::{LagLogThrottle, LAG_LOG_WINDOW}; use crate::models::AgentType; use crate::web::event_bridge::{emit_with_state, EventEmitter}; use tokio::sync::RwLock; @@ -1512,6 +1513,7 @@ pub fn lifecycle_subscriber_task( // connection's first relevant event and torn down after a terminal // event by dropping the sender (worker drains its queue and exits). let mut workers: HashMap>> = HashMap::new(); + let mut lag_throttle = LagLogThrottle::new(LAG_LOG_WINDOW); loop { match rx.recv().await { Ok(envelope_arc) => { @@ -1592,12 +1594,19 @@ pub fn lifecycle_subscriber_task( // Lagged at the bus level. Now that the dispatcher // filters and only blocks on the rare relevant events, // this should only fire under genuine emit-rate spikes - // exceeding the 4096 broadcast capacity. - tracing::warn!( - "[lifecycle][WARN] internal bus lagged, dropped {skipped} events \ - (emit rate exceeded broadcast capacity)" - ); + // exceeding the 4096 broadcast capacity. The metric is the + // authoritative loss record (unthrottled); the log line is + // throttled to at most one per window. metrics.lagged_count.fetch_add(skipped, Ordering::Relaxed); + if let Some(s) = lag_throttle.record(skipped) { + tracing::warn!( + "[lifecycle][WARN] internal bus lagged: dropped {} events across \ + {} occurrence(s) in the last {}s (emit rate exceeded broadcast capacity)", + s.dropped, + s.occurrences, + LAG_LOG_WINDOW.as_secs() + ); + } } Err(broadcast::error::RecvError::Closed) => { tracing::info!("[lifecycle] internal bus closed; dispatcher exiting"); diff --git a/src-tauri/src/automation/engine.rs b/src-tauri/src/automation/engine.rs index d7dd62cd2..7296bb1e3 100644 --- a/src-tauri/src/automation/engine.rs +++ b/src-tauri/src/automation/engine.rs @@ -34,6 +34,7 @@ use crate::commands::folders::{ use crate::db::entities::conversation::{self, ConversationStatus}; use crate::db::service::automation_service; use crate::db::AppDatabase; +use crate::logging::throttle::{LagLogThrottle, LAG_LOG_WINDOW}; use crate::models::{ AgentType, AutomationConfig, AutomationInfo, AutomationRunStatus, IsolationMode, }; @@ -231,6 +232,7 @@ pub async fn run_automation_engine(engine: Arc) { let mut reconcile = delay_interval(RECONCILE_INTERVAL_SECS); let mut schedule = delay_interval(SCHEDULER_INTERVAL_SECS); let mut prune = delay_interval(PRUNE_INTERVAL_SECS); + let mut lag_throttle = LagLogThrottle::new(LAG_LOG_WINDOW); loop { tokio::select! { @@ -238,7 +240,15 @@ pub async fn run_automation_engine(engine: Arc) { Ok(env) => engine.on_event(&env).await, // Dropped events under lag — the reconcile backstop recovers them. Err(RecvError::Lagged(n)) => { - tracing::warn!("[automation] event bus lagged {n}; reconcile will recover"); + if let Some(s) = lag_throttle.record(n) { + tracing::warn!( + "[automation] event bus lagged: dropped {} events across \ + {} occurrence(s) in the last {}s; reconcile will recover", + s.dropped, + s.occurrences, + LAG_LOG_WINDOW.as_secs() + ); + } } Err(RecvError::Closed) => break, }, diff --git a/src-tauri/src/chat_channel/event_subscriber.rs b/src-tauri/src/chat_channel/event_subscriber.rs index 53ed6e8ae..eb901d7e4 100644 --- a/src-tauri/src/chat_channel/event_subscriber.rs +++ b/src-tauri/src/chat_channel/event_subscriber.rs @@ -17,6 +17,7 @@ use crate::acp::types::{AcpEvent, EventEnvelope}; use crate::db::service::{ app_metadata_service, chat_channel_message_log_service, chat_channel_service, }; +use crate::logging::throttle::{LagLogThrottle, LAG_LOG_WINDOW}; /// Minimum interval between pushes for the same event type per channel (debounce). const DEBOUNCE_SECS: u64 = 5; @@ -213,13 +214,22 @@ pub fn spawn_event_subscriber( let mut config = EventConfigCache::new(); // One reqwest client, reused (and cheaply cloned) for every webhook POST. let webhook_client = super::webhook::make_webhook_client(); + let mut lag_throttle = LagLogThrottle::new(LAG_LOG_WINDOW); loop { let envelope_arc = match rx.recv().await { Ok(e) => e, Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - tracing::warn!("[ChatChannel] event subscriber lagged by {n} messages"); metrics.lagged_count.fetch_add(n, Ordering::Relaxed); + if let Some(s) = lag_throttle.record(n) { + tracing::warn!( + "[ChatChannel] event subscriber lagged: dropped {} messages across \ + {} occurrence(s) in the last {}s", + s.dropped, + s.occurrences, + LAG_LOG_WINDOW.as_secs() + ); + } continue; } Err(tokio::sync::broadcast::error::RecvError::Closed) => { diff --git a/src-tauri/src/chat_channel/session_event_subscriber.rs b/src-tauri/src/chat_channel/session_event_subscriber.rs index 8ad4be704..fc94b7f20 100644 --- a/src-tauri/src/chat_channel/session_event_subscriber.rs +++ b/src-tauri/src/chat_channel/session_event_subscriber.rs @@ -19,6 +19,7 @@ use crate::acp::types::{ use crate::db::service::{ app_metadata_service, conversation_service, sender_context_service, thread_binding_service, }; +use crate::logging::throttle::{LagLogThrottle, LAG_LOG_WINDOW}; use super::manager::ChatChannelManager; @@ -41,6 +42,7 @@ pub fn spawn_session_event_subscriber( tokio::spawn(async move { let mut last_heartbeat = Instant::now(); + let mut lag_throttle = LagLogThrottle::new(LAG_LOG_WINDOW); loop { tokio::select! { @@ -48,8 +50,16 @@ pub fn spawn_session_event_subscriber( let envelope_arc = match result { Ok(e) => e, Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - tracing::warn!("[SessionEventSub] lagged {n} events"); metrics.lagged_count.fetch_add(n, Ordering::Relaxed); + if let Some(s) = lag_throttle.record(n) { + tracing::warn!( + "[SessionEventSub] lagged: dropped {} events across \ + {} occurrence(s) in the last {}s", + s.dropped, + s.occurrences, + LAG_LOG_WINDOW.as_secs() + ); + } continue; } Err(_) => break, diff --git a/src-tauri/src/logging/init.rs b/src-tauri/src/logging/init.rs index 68fb8675c..6f12b1cad 100644 --- a/src-tauri/src/logging/init.rs +++ b/src-tauri/src/logging/init.rs @@ -39,12 +39,35 @@ pub struct LogGuard { _guard: Option, } +/// Standing per-target backstops appended to EVERY constructed filter — the +/// default/configured level, a persisted level, AND an explicit `RUST_LOG` / +/// `CODEG_LOG` override. Two entries, both appended last so they win over a +/// broader global level: +/// +/// - `kill_tree=warn` — the `kill_tree` crate emits one DEBUG line per inspected +/// PID (~1500 on macOS, mostly EPERM on SIP-protected processes) on every +/// `kill_tree()` call. Under a global `debug` level that firehose grew a +/// server's log file to 217GB. We never want it: real kill failures are +/// already surfaced at our own call sites via `error!`. Pinned here so even +/// `RUST_LOG=debug` can't re-open it. +/// - `codeg_lib::logging=off` — the logging stack must not log about itself +/// (cross-thread feedback-loop backstop; the layer's thread-local guard +/// handles the same-thread case). +const TARGET_BACKSTOPS: &str = "kill_tree=warn,codeg_lib::logging=off"; + +/// Directive string for an explicit env-override level `s`, with the standing +/// [`TARGET_BACKSTOPS`] appended. Extracted so the backstop application on the +/// env-override path is unit-testable without installing a global subscriber. +fn env_override_directives(s: &str) -> String { + format!("{s},{TARGET_BACKSTOPS}") +} + /// Build the `EnvFilter` for the full settings: the global level followed by -/// each non-empty per-target override (`target=level`), then always excluding -/// the logging module's own target as a cross-thread feedback-loop backstop -/// (the layer's thread-local guard handles the same-thread case). The backstop -/// is appended last so it wins. `parse_lossy` silently drops any malformed -/// target directive — the UI constrains the input to avoid that. +/// each non-empty per-target override (`target=level`), then always appending +/// [`TARGET_BACKSTOPS`] (the `kill_tree` clamp + the logging module's own target +/// off). The backstops are appended last so they win. `parse_lossy` silently +/// drops any malformed target directive — the UI constrains the input to avoid +/// that. pub fn build_env_filter(settings: &LogSettings) -> EnvFilter { let mut directives = settings.level.directive().to_string(); for t in &settings.targets { @@ -56,7 +79,8 @@ pub fn build_env_filter(settings: &LogSettings) -> EnvFilter { directives.push_str(&format!(",{}={}", target, t.level.directive())); } } - directives.push_str(",codeg_lib::logging=off"); + directives.push(','); + directives.push_str(TARGET_BACKSTOPS); EnvFilter::builder().parse_lossy(directives) } @@ -159,7 +183,9 @@ fn build_subscriber( // same `env_level_override` precedence as `env_level_is_set`, so a level // applied here is exactly the one the UI reports as env-locked. let initial_filter = match env_level_override() { - Some(s) => EnvFilter::builder().parse_lossy(format!("{s},codeg_lib::logging=off")), + // Even an explicit env override gets the standing target backstops, so + // `RUST_LOG=debug` still can't re-open the kill_tree per-PID firehose. + Some(s) => EnvFilter::builder().parse_lossy(env_override_directives(&s)), // Phase 1 has no DB yet, so no persisted per-target overrides; just the // default level. Phase 2 (apply_persisted_level) rebuilds with targets. None => build_env_filter(&LogSettings { @@ -331,6 +357,45 @@ mod tests { assert!(!rendered.contains("codeg_lib::logging=trace"), "{rendered}"); } + #[test] + fn build_env_filter_pins_noisy_third_party_targets() { + // Every constructed filter clamps `kill_tree` to warn — its per-PID + // DEBUG firehose once grew a server log to 217GB — and keeps the + // logging module's own target off. + let rendered = build_env_filter(&LogSettings { + level: LogLevel::Info, + targets: Vec::new(), + }) + .to_string(); + assert!( + rendered.contains("kill_tree=warn"), + "kill_tree must be pinned to warn: {rendered}" + ); + assert!( + rendered.contains("codeg_lib::logging=off"), + "logging backstop must remain: {rendered}" + ); + } + + #[test] + fn env_override_appends_backstops_so_debug_cannot_reopen_kill_tree() { + // The explicit RUST_LOG/CODEG_LOG path bypasses build_env_filter, so it + // must apply the same backstops itself — otherwise `RUST_LOG=debug` + // re-opens the kill_tree per-PID firehose. + assert_eq!( + env_override_directives("debug"), + "debug,kill_tree=warn,codeg_lib::logging=off" + ); + // And the parsed filter still carries the clamp under a global debug. + let rendered = EnvFilter::builder() + .parse_lossy(env_override_directives("debug")) + .to_string(); + assert!( + rendered.contains("kill_tree=warn"), + "kill_tree clamp must survive a global debug override: {rendered}" + ); + } + #[test] fn is_valid_target_grammar() { assert!(is_valid_target("codeg_lib::acp")); diff --git a/src-tauri/src/logging/mod.rs b/src-tauri/src/logging/mod.rs index f19aa175a..ec97686e3 100644 --- a/src-tauri/src/logging/mod.rs +++ b/src-tauri/src/logging/mod.rs @@ -15,6 +15,7 @@ pub mod hub; pub mod init; pub mod layer; +pub mod throttle; use serde::{Deserialize, Serialize}; diff --git a/src-tauri/src/logging/throttle.rs b/src-tauri/src/logging/throttle.rs new file mode 100644 index 000000000..41fb2f846 --- /dev/null +++ b/src-tauri/src/logging/throttle.rs @@ -0,0 +1,208 @@ +//! Time-windowed throttle for high-frequency, near-duplicate WARN logs. +//! +//! Several broadcast subscribers (`pet_state_mapper`, `ws`, the chat-channel +//! subscribers, `automation::engine`, `acp::lifecycle`) log a `warn!` whenever +//! their `tokio::sync::broadcast` receiver returns `Lagged(n)`. `Lagged` +//! already coalesces `n` dropped events into ONE `recv()` error, so it can't +//! runaway on its own — but under sustained backpressure (a chronically slow +//! subscriber while a steady event stream flows) these branches still emit a +//! continuous trickle of near-identical lines. The `lagged_count` / +//! `forwarder_lagged_count` metrics remain the authoritative record of loss; +//! the log line is only an operator convenience, so collapsing bursts loses +//! nothing. +//! +//! [`LagLogThrottle`] is a pure state machine (no clock capture in the core, +//! no lock, no alloc). Each subscriber holds one instance as a task-local and +//! feeds every lag occurrence through [`LagLogThrottle::record`]; it returns +//! `Some(LagSummary)` only when a line should actually be written. + +use std::time::{Duration, Instant}; + +/// Shared default window for lag WARN throttling. Leading-edge emit (see +/// [`LagLogThrottle`]) keeps the first lag instantly visible; a 10s window +/// then collapses a burst to at most one line while still surfacing ongoing +/// pressure roughly every 10 seconds. +pub const LAG_LOG_WINDOW: Duration = Duration::from_secs(10); + +/// What a throttled lag WARN line should report: how many lag occurrences were +/// coalesced, and the summed count of dropped events across them, since the +/// previously emitted line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LagSummary { + /// Lag occurrences coalesced into this line (always `>= 1`). + pub occurrences: u64, + /// Sum of dropped-event counts since the last emitted line. + pub dropped: u64, +} + +/// Leading-edge, time-windowed throttle. +/// +/// The first hit emits immediately; further hits within `window` are counted +/// and suppressed; the first hit after the window elapses emits a +/// [`LagSummary`] carrying everything coalesced since the last emitted line. +/// Nothing is dropped silently — the suppressed tally always rides the next +/// emitted line. +#[derive(Debug)] +pub struct LagLogThrottle { + window: Duration, + /// `None` until the first line is emitted, which forces the leading-edge + /// emit regardless of clock state. + last_emit: Option, + pending_occ: u64, + pending_dropped: u64, +} + +impl LagLogThrottle { + /// Build a throttle with the given minimum spacing between emitted lines. + pub const fn new(window: Duration) -> Self { + Self { + window, + last_emit: None, + pending_occ: 0, + pending_dropped: 0, + } + } + + /// Record one lag occurrence dropping `dropped` events, timestamped now. + /// Returns `Some` when the caller should emit a WARN line this instant. + pub fn record(&mut self, dropped: u64) -> Option { + self.record_at(Instant::now(), dropped) + } + + /// Pure core of [`record`](Self::record) with an injected clock, so the + /// windowing logic is unit-testable without sleeping. + pub fn record_at(&mut self, now: Instant, dropped: u64) -> Option { + // Both accumulators saturate: reaching u64::MAX within a window is + // infeasible, but this keeps the two counters consistent and avoids a + // debug-build overflow panic on the occurrence count. + self.pending_occ = self.pending_occ.saturating_add(1); + self.pending_dropped = self.pending_dropped.saturating_add(dropped); + + // `saturating_duration_since` yields zero if the clock appears to move + // backwards, so a backwards jump simply suppresses (never spuriously + // emits and never panics). + let due = match self.last_emit { + None => true, + Some(t) => now.saturating_duration_since(t) >= self.window, + }; + if !due { + return None; + } + + let summary = LagSummary { + occurrences: self.pending_occ, + dropped: self.pending_dropped, + }; + self.pending_occ = 0; + self.pending_dropped = 0; + self.last_emit = Some(now); + Some(summary) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const W: Duration = Duration::from_secs(10); + + #[test] + fn first_hit_emits_immediately() { + let mut t = LagLogThrottle::new(W); + let now = Instant::now(); + assert_eq!( + t.record_at(now, 7), + Some(LagSummary { + occurrences: 1, + dropped: 7, + }), + "the leading edge must surface instantly" + ); + } + + #[test] + fn burst_within_window_is_suppressed_and_accumulated() { + let mut t = LagLogThrottle::new(W); + let t0 = Instant::now(); + // Leading-edge line. + assert!(t.record_at(t0, 3).is_some()); + // Everything else inside the window is swallowed... + assert_eq!(t.record_at(t0 + Duration::from_secs(1), 5), None); + assert_eq!(t.record_at(t0 + Duration::from_secs(2), 2), None); + assert_eq!(t.record_at(t0 + Duration::from_secs(9), 1), None); + // ...and re-surfaces coalesced on the first hit past the window, + // carrying every suppressed occurrence + the one that crossed it. + assert_eq!( + t.record_at(t0 + Duration::from_secs(10), 4), + Some(LagSummary { + occurrences: 4, // the 3 suppressed + this one + dropped: 5 + 2 + 1 + 4, + }) + ); + } + + #[test] + fn window_boundary_is_inclusive() { + let mut t = LagLogThrottle::new(W); + let t0 = Instant::now(); + assert!(t.record_at(t0, 1).is_some()); + // Exactly `window` later counts as due (>=, not >). + assert!(t.record_at(t0 + W, 1).is_some()); + } + + #[test] + fn counters_reset_after_each_emit() { + let mut t = LagLogThrottle::new(W); + let t0 = Instant::now(); + assert_eq!( + t.record_at(t0, 100), + Some(LagSummary { + occurrences: 1, + dropped: 100, + }) + ); + // Second window opens fresh — not cumulative with the first. + assert_eq!( + t.record_at(t0 + Duration::from_secs(11), 2), + Some(LagSummary { + occurrences: 1, + dropped: 2, + }) + ); + } + + #[test] + fn dropped_sum_saturates_without_panicking() { + let mut t = LagLogThrottle::new(W); + let t0 = Instant::now(); + // First hit emits (occ=1) and resets; feed u64::MAX then one more + // within the same window so the accumulator must saturate. + assert!(t.record_at(t0, u64::MAX).is_some()); + assert_eq!(t.record_at(t0 + Duration::from_secs(1), u64::MAX), None); + let summary = t + .record_at(t0 + Duration::from_secs(11), 5) + .expect("post-window hit emits"); + assert_eq!(summary.occurrences, 2); + assert_eq!(summary.dropped, u64::MAX, "sum saturates rather than wrapping"); + } + + #[test] + fn backwards_clock_does_not_emit_or_panic() { + let mut t = LagLogThrottle::new(W); + let t0 = Instant::now() + Duration::from_secs(60); + assert!(t.record_at(t0, 1).is_some()); + // A clock that appears to move backwards saturates to zero elapsed, + // which is < window, so it suppresses (and never panics). + assert_eq!(t.record_at(t0 - Duration::from_secs(30), 1), None); + } + + #[test] + fn instances_are_independent() { + let mut a = LagLogThrottle::new(W); + let mut b = LagLogThrottle::new(W); + let now = Instant::now(); + assert!(a.record_at(now, 1).is_some()); + // b has its own state; its first hit still emits. + assert!(b.record_at(now, 1).is_some()); + } +} diff --git a/src-tauri/src/pet_state_mapper.rs b/src-tauri/src/pet_state_mapper.rs index df334331b..d0d7ed8f6 100644 --- a/src-tauri/src/pet_state_mapper.rs +++ b/src-tauri/src/pet_state_mapper.rs @@ -26,6 +26,7 @@ use tokio::task::JoinHandle; use crate::acp::internal_bus::InternalEventBus; use crate::acp::types::{AcpEvent, ConnectionStatus, EventEnvelope}; use crate::db::entities::conversation::ConversationStatus; +use crate::logging::throttle::{LagLogThrottle, LAG_LOG_WINDOW}; use crate::models::pet::PetState; use crate::web::event_bridge::{emit_event, EventEmitter, WebEvent, WebEventBroadcaster}; @@ -341,6 +342,12 @@ pub fn pet_state_subscriber_task( write_pet_state(&handle, last_state); emit_event(&emitter, "pet://state", last_state); + // Separate throttles for the two lag signals: the internal ACP bus and + // the web side-channel broadcaster overflow independently, so a burst on + // one must not suppress the other's first line. + let mut acp_lag_throttle = LagLogThrottle::new(LAG_LOG_WINDOW); + let mut web_lag_throttle = LagLogThrottle::new(LAG_LOG_WINDOW); + loop { tokio::select! { acp_event = acp_rx.recv() => { @@ -414,11 +421,19 @@ pub fn pet_state_subscriber_task( // snapshot. A persistent lag without follow-up // events would leave the pet stuck on idle even // if connections are still active; surface it on - // the metric so operators can spot it. - tracing::warn!( - "[Pet] internal bus lagged, dropped {skipped} events; resetting to idle" - ); + // the metric so operators can spot it. The metric is + // the authoritative loss record, so it's bumped + // unconditionally; only the log line is throttled. metrics.lagged_count.fetch_add(skipped, Ordering::Relaxed); + if let Some(s) = acp_lag_throttle.record(skipped) { + tracing::warn!( + "[Pet] internal bus lagged: dropped {} events across \ + {} occurrence(s) in the last {}s; resetting to idle", + s.dropped, + s.occurrences, + LAG_LOG_WINDOW.as_secs() + ); + } // Clear the volatile signals (reseeded from the next // StatusChanged batch) but KEEP the delegation-child // classification — a running sub-agent won't re-fire @@ -467,9 +482,15 @@ pub fn pet_state_subscriber_task( // these are fire-and-forget side-channel events // (a lost git-commit ping is benign), so just // log and keep going. No snapshot reset. - tracing::warn!( - "[Pet] web broadcaster lagged, dropped {skipped} non-ACP events" - ); + if let Some(s) = web_lag_throttle.record(skipped) { + tracing::warn!( + "[Pet] web broadcaster lagged: dropped {} non-ACP events \ + across {} occurrence(s) in the last {}s", + s.dropped, + s.occurrences, + LAG_LOG_WINDOW.as_secs() + ); + } } Err(broadcast::error::RecvError::Closed) => { cancel_failed_recovery(&mut clear_task); diff --git a/src-tauri/src/web/ws.rs b/src-tauri/src/web/ws.rs index c88b457f5..3fae93f26 100644 --- a/src-tauri/src/web/ws.rs +++ b/src-tauri/src/web/ws.rs @@ -12,6 +12,7 @@ use tokio::task::JoinHandle; use super::shutdown::ShutdownSignal; use super::ws_attach::{self, ClientMsg, DetachReason, ServerMsg, OUTBOUND_CAPACITY}; use crate::app_state::AppState; +use crate::logging::throttle::{LagLogThrottle, LAG_LOG_WINDOW}; /// One entry per live attach subscription. The `epoch` is the per-WS-session /// monotonic counter assigned at spawn time; it threads through the cleanup @@ -124,6 +125,11 @@ async fn handle_ws_connection( } } + // Throttles the per-connection global-firehose lag WARN so a slow client + // reading behind a steady side-channel stream can't trickle near-duplicate + // lines. Task-local: one instance per WS connection. + let mut lag_throttle = LagLogThrottle::new(LAG_LOG_WINDOW); + loop { tokio::select! { // Server-initiated shutdown: notify any active attach @@ -191,7 +197,15 @@ async fn handle_ws_connection( } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - tracing::warn!("[WS][WARN] global receiver lagged, skipped {n} events"); + if let Some(s) = lag_throttle.record(n) { + tracing::warn!( + "[WS][WARN] global receiver lagged: skipped {} events across \ + {} occurrence(s) in the last {}s", + s.dropped, + s.occurrences, + LAG_LOG_WINDOW.as_secs() + ); + } } Err(tokio::sync::broadcast::error::RecvError::Closed) => { break; diff --git a/src/app/globals.css b/src/app/globals.css index e1598bf7a..2d47f6512 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1123,6 +1123,18 @@ box-shadow: inset 0 0 0 1px var(--border); } +/* 折叠态用户消息底部渐隐:遮罩内容本身而非叠加层,跟 .qa-marquee-viewport / + .browser-tab-label 同技术,天然适配纯色 bg-secondary 或开工作区背景图后的 + 半透明 ws-msg-secondary。不属于下面的 ws- 门控家族,始终生效。 */ +.collapsed-user-message-fade { + -webkit-mask-image: linear-gradient( + to bottom, + #000 calc(100% - 2.5rem), + transparent + ); + mask-image: linear-gradient(to bottom, #000 calc(100% - 2.5rem), transparent); +} + /* ===== 消息内半透明表面 ws-msg-* ===== 会话消息内一切带底色元素读作同一种半透明中性染色(**不磨砂**,用户要真透出背景图)+ 1px var(--border) 边。alpha 固定(--ws-msg-alpha=0.5),刻意不随「面板不透明度」滑块——消息不随 @@ -1231,12 +1243,35 @@ 连续下边框无法在激活 tab 处「开口」(透明子元素盖不住祖先画的线),故平线逐段画在激活 tab 的兄弟区域(预留区/非激活 tab/按钮/拖拽填充),激活 tab 及其祖先不画。拱门轮廓(顶 + 两侧 + 反向圆角脚)与两端 group px-2 补缝都在下方 .browser-tab-item 区块。色 = - var(--border)(纯色,比 detail header 的 border/50 略深,让透出背景图时分隔线更清晰)。 */ + var(--ws-chrome-border)(下方定义的加强色,与状态栏/header 等所有 chrome 分隔线统一)。 */ + +/* ===== 结构性 chrome 分隔线加强色 --ws-chrome-border ===== + 状态栏顶边 / 会话·文件 header 底边 / 移动端标题栏·aux 标签条底边 / 标签条平线 + 激活 tab 拱门—— + 这些持久的 chrome 细分隔线原本用主题 --border(浅色主题=0.922 灰,在浅色磨砂面上几乎看不见;深色 + 主题=白/10%)。开启背景图后(默认遮罩 0.82 把画面veil向 --background),它们常被冲淡看不清(用户 + 反馈「边框很多时候不清楚」)。这里给它们一个 foreground 染色的加强 hairline:浅色主题转深、深色主题 + 转浅,自动在两侧都有对比;alpha 低只描出一条清晰细线。仅 bg-on 生效,关图零回归。单一变量=一处调深浅。 */ +[data-workspace-bg="on"] { + --ws-chrome-border: color-mix(in oklch, var(--foreground) 13%, transparent); +} +/* 老引擎无 color-mix 时回落到主题 --border(仍随主题,至少不崩)。 */ +@supports not (background: color-mix(in oklch, white, transparent)) { + [data-workspace-bg="on"] { + --ws-chrome-border: var(--border); + } +} +/* 组合式 utility(无 base 规则):挂在已带 border-* / border-border* 的 chrome 元素上,开图时用加强色 + 覆盖其 border-color。unlayered 压过 Tailwind 的 layered border-border(与 specificity 无关)。关图无此 + 规则 → 元素保留自带 border 颜色,零回归。这些分隔线无 hover 变色,故 unlayered 覆盖不会误盖交互态。 */ +[data-workspace-bg="on"] .ws-chrome-border { + border-color: var(--ws-chrome-border); +} /* 平线:挂到激活 tab 的兄弟/非祖先区域,逐段 border-bottom 连成一条线(各兄弟 h-full, - 底都落在 strip 底 y=40)。激活 tab 不挂 → 留缺口给拱门。 */ + 底都落在 strip 底 y=40)。激活 tab 不挂 → 留缺口给拱门。色随 --ws-chrome-border(同上加强色, + 与状态栏/header 分隔线统一)。 */ [data-workspace-bg="on"] .ws-strip-line { - border-bottom: 1px solid var(--border); + border-bottom: 1px solid var(--ws-chrome-border); } /* 旧 WebKitGTK 无 color-mix 时降级为不透明(背景图仅在透明内容区透出,不崩)。 */ @@ -2017,6 +2052,33 @@ opacity: 0; } +/* Trailing separator between the last tab and the new-conversation button (the + conversation strip only — see tab-bar.tsx `tab-strip-tail`). Inter-tab + separators sit on each tab's LEFT edge, so the last tab's RIGHT edge, where + the flush-pinned button wrapper begins, has none. Same hairline geometry as + `.browser-tab-item::before` (the wrapper shares the tabs' flex box, so the + `calc(50% - 3px)` nudge centers it on the strip midline identically) and the + same `var(--border)` tone, so it reads as just another inter-tab separator. + Suppressed when the last tab is active or hovered, mirroring how a tab drops + the separators flanking the active/hovered tab. */ +.tab-strip-tail::before { + content: ""; + position: absolute; + left: 0; + top: calc(50% - 3px); + height: 1rem; + width: 1px; + transform: translateY(-50%); + background: var(--border); + opacity: 1; + transition: opacity 150ms ease; + pointer-events: none; +} +.browser-tab-item[data-active="true"] + .tab-strip-tail::before, +.browser-tab-item:hover + .tab-strip-tail::before { + opacity: 0; +} + /* Embedded tab label — no ellipsis: fade the right edge so an overflowing title dissolves instead of hard-cutting with "…". Pairs with `overflow-hidden whitespace-nowrap` on the label (replaces `truncate`). @@ -2187,7 +2249,7 @@ 0.5rem 见方的盒(left/right:-0.5rem, y32→40, box-sizing:border-box),把**对角圆角 + 相邻 两条边框**做成一段干净四分之一凹弧:左脚=右下角圆角+下/右边框,右脚=左下角圆角+下/左边框。 弧顶(tab 边 y32)竖直切线接拱门 ::after 侧边、弧底(gutter 外沿 y40)水平切线接 ws-strip-line - 平线,两端相切→过渡自然。清掉 mask 与填充色,只留描边,色 var(--border) 与拱门同深。 */ + 平线,两端相切→过渡自然。清掉 mask 与填充色,只留描边,色 var(--ws-chrome-border) 与拱门同深。 */ [data-workspace-bg="on"] .browser-tab-item[data-active="true"] .browser-tab-seat::before, @@ -2201,15 +2263,15 @@ [data-workspace-bg="on"] .browser-tab-item[data-active="true"] .browser-tab-seat::before { - border-bottom: 1px solid var(--border); - border-right: 1px solid var(--border); + border-bottom: 1px solid var(--ws-chrome-border); + border-right: 1px solid var(--ws-chrome-border); border-bottom-right-radius: 0.5rem; } [data-workspace-bg="on"] .browser-tab-item[data-active="true"] .browser-tab-seat::after { - border-bottom: 1px solid var(--border); - border-left: 1px solid var(--border); + border-bottom: 1px solid var(--ws-chrome-border); + border-left: 1px solid var(--ws-chrome-border); border-bottom-left-radius: 0.5rem; } @@ -2229,9 +2291,9 @@ left: -1px; right: -1px; bottom: 0.5rem; - border-top: 1px solid var(--border); - border-left: 1px solid var(--border); - border-right: 1px solid var(--border); + border-top: 1px solid var(--ws-chrome-border); + border-left: 1px solid var(--ws-chrome-border); + border-right: 1px solid var(--ws-chrome-border); border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem; pointer-events: none; @@ -2254,7 +2316,7 @@ bottom: 0; height: 1px; width: 0.5rem; - background-color: var(--border); + background-color: var(--ws-chrome-border); pointer-events: none; } [data-workspace-bg="on"] @@ -2291,7 +2353,7 @@ position: absolute; bottom: 0; height: 1px; - background-color: var(--border); + background-color: var(--ws-chrome-border); pointer-events: none; } [data-workspace-bg="on"] @@ -2324,6 +2386,6 @@ left: 0.5rem; right: 0; height: 1px; - background-color: var(--border); + background-color: var(--ws-chrome-border); pointer-events: none; } diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index db5c7d082..0f712eea4 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -337,7 +337,7 @@ function WorkspaceContent({ children }: { children: React.ReactNode }) { )}
{hasConvTabs ? ( - + ) : ( // No tabs → TabBar renders null; keep a drag region so the // empty bar can still move the window. @@ -427,7 +427,7 @@ function WorkspaceContent({ children }: { children: React.ReactNode }) { /> )}
- +
{fileReservesRight && (
{showConversation ? ( + // Mobile mirrors the desktop chrome: no tab strip — the conversation + // detail header (folder › title) renders inside {children}, and tabs + // are navigated from the sidebar (single active conversation at a time).
-
{children}
) : ( + // File view: the shared FileWorkspaceHeader (folder › file breadcrumb) + // replaces the file tab strip, matching the desktop file column.
- +
diff --git a/src/components/ai-elements/link-safety.test.tsx b/src/components/ai-elements/link-safety.test.tsx index a09178330..f1701227d 100644 --- a/src/components/ai-elements/link-safety.test.tsx +++ b/src/components/ai-elements/link-safety.test.tsx @@ -177,6 +177,26 @@ describe("link safety direct opening", () => { expect(window.open).not.toHaveBeenCalled() }) + it("opens a Windows drive link through the real chat path (remark-rewritten /C:/ form)", async () => { + // In chat, remark-file-uri-links rewrites a bare `E:/…` (and file:///E:/…) + // to the sanitize-safe `/E:/…` form BEFORE MarkdownLink sees it. That + // rewritten href must route to the file opener as `E:/…` (leading slash + // stripped), not the browser. + render() + + fireEvent.click(screen.getByRole("button", { name: "Trigger link" })) + + await waitFor(() => { + expect(mocks.openFilePreview).toHaveBeenCalledWith( + "E:/Desktop/docs/G.docx", + { + line: undefined, + } + ) + }) + expect(window.open).not.toHaveBeenCalled() + }) + it("passes ~ paths through for home expansion by the opener", async () => { render() diff --git a/src/components/ai-elements/message-windows-file-link.test.tsx b/src/components/ai-elements/message-windows-file-link.test.tsx new file mode 100644 index 000000000..9601744da --- /dev/null +++ b/src/components/ai-elements/message-windows-file-link.test.tsx @@ -0,0 +1,240 @@ +import { fireEvent, render, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +// End-to-end guard for the "Windows local file link renders as [blocked]" bug +// (issue #362). Exercises the REAL Streamdown pipeline (no streamdown mock) so +// the assertions cover actual rehype `sanitize` + `harden` behavior — the layer +// that read `E:` in `E:/…` as a URL protocol, stripped the href, and let harden +// replace the link with " [blocked]". Only the leaf dependencies of the +// real link-safety hook are stubbed, so the click path (badge → link-safety → +// `openFilePreview`) is genuinely exercised too. +const mocks = vi.hoisted(() => ({ + openFilePreview: vi.fn(), + openUrl: vi.fn(), + toastError: vi.fn(), + isDesktop: vi.fn(() => false), + getActiveRemoteConnectionId: vi.fn(() => null), +})) + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock("sonner", () => ({ + toast: { error: mocks.toastError }, +})) + +vi.mock("@/lib/platform", () => ({ + openUrl: mocks.openUrl, +})) + +vi.mock("@/lib/transport", () => ({ + isDesktop: mocks.isDesktop, + getActiveRemoteConnectionId: mocks.getActiveRemoteConnectionId, +})) + +vi.mock("@/contexts/active-folder-context", () => ({ + useActiveFolder: () => ({ activeFolder: { path: "/repo" } }), +})) + +vi.mock("@/contexts/workspace-context", () => ({ + useWorkspaceActions: () => ({ openFilePreview: mocks.openFilePreview }), +})) + +import { MessageResponse } from "./message" + +function fileBadgeButton(container: HTMLElement): HTMLButtonElement { + const button = container.querySelector( + "button[data-resource-kind='file']" + ) + if (!button) throw new Error("expected a clickable file badge") + return button +} + +describe("MessageResponse — Windows local file links (issue #362, real Streamdown)", () => { + beforeEach(() => { + mocks.openFilePreview.mockReset() + mocks.openFilePreview.mockResolvedValue(undefined) + mocks.openUrl.mockReset() + mocks.openUrl.mockResolvedValue(undefined) + mocks.toastError.mockReset() + mocks.isDesktop.mockReset() + mocks.isDesktop.mockReturnValue(false) + mocks.getActiveRemoteConnectionId.mockReset() + mocks.getActiveRemoteConnectionId.mockReturnValue(null) + vi.spyOn(window, "open").mockReturnValue(null) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("renders a bare Windows drive-path link as a file badge, not '[blocked]'", async () => { + const { container } = render( + {"[Gd.docx](E:/Desktop/docs/Gd.docx)"} + ) + + await waitFor(() => { + expect(fileBadgeButton(container)).toBeTruthy() + }) + expect(container.textContent).toContain("Gd.docx") + expect(container.textContent).not.toContain("[blocked]") + + fireEvent.click(fileBadgeButton(container)) + await waitFor(() => { + expect(mocks.openFilePreview).toHaveBeenCalledWith( + "E:/Desktop/docs/Gd.docx", + { + line: undefined, + } + ) + }) + expect(window.open).not.toHaveBeenCalled() + }) + + it("renders a file:///E:/… link as a file badge, not '[blocked]'", async () => { + const { container } = render( + + {"[Gd.docx](file:///E:/Desktop/docs/Gd.docx)"} + + ) + + await waitFor(() => { + expect(fileBadgeButton(container)).toBeTruthy() + }) + expect(container.textContent).not.toContain("[blocked]") + + fireEvent.click(fileBadgeButton(container)) + await waitFor(() => { + expect(mocks.openFilePreview).toHaveBeenCalledWith( + "E:/Desktop/docs/Gd.docx", + { + line: undefined, + } + ) + }) + }) + + it("handles a Chinese Windows path", async () => { + const { container } = render( + {"[手册](E:/桌面/使用手册/G手册.docx)"} + ) + + await waitFor(() => { + expect(fileBadgeButton(container)).toBeTruthy() + }) + expect(container.textContent).not.toContain("[blocked]") + + fireEvent.click(fileBadgeButton(container)) + await waitFor(() => { + expect(mocks.openFilePreview).toHaveBeenCalledWith( + "E:/桌面/使用手册/G手册.docx", + { line: undefined } + ) + }) + }) + + it("handles a URL-encoded Windows path with spaces", async () => { + const { container } = render( + + {"[手册](E:/My%20Docs/%E6%89%8B%E5%86%8C.docx)"} + + ) + + await waitFor(() => { + expect(fileBadgeButton(container)).toBeTruthy() + }) + expect(container.textContent).not.toContain("[blocked]") + + fireEvent.click(fileBadgeButton(container)) + await waitFor(() => { + expect(mocks.openFilePreview).toHaveBeenCalledWith( + "E:/My Docs/手册.docx", + { + line: undefined, + } + ) + }) + }) + + it("keeps blocking javascript:/data:/vbscript: links", async () => { + for (const href of [ + "javascript:alert(1)", + "data:text/html,", + "vbscript:msgbox(1)", + ]) { + const { container, unmount } = render( + {`[click](${href})`} + ) + await waitFor(() => { + expect(container.textContent).toContain("[blocked]") + }) + // No openable file/web affordance was minted for a dangerous scheme. + expect(container.querySelector("button[data-resource-kind]")).toBeNull() + unmount() + } + }) + + it("does not regress POSIX file links", async () => { + const { container } = render( + {"[app](file:///repo/src/app.ts)"} + ) + + await waitFor(() => { + expect(fileBadgeButton(container)).toBeTruthy() + }) + expect(container.textContent).not.toContain("[blocked]") + + fireEvent.click(fileBadgeButton(container)) + await waitFor(() => { + expect(mocks.openFilePreview).toHaveBeenCalledWith("/repo/src/app.ts", { + line: undefined, + }) + }) + }) + + it("documents the known UNC end-to-end limitation (out of scope for #362)", async () => { + // remark rewrites file://server/share/doc.md to the backslash UNC form + // `\\server\share\doc.md` (the correct LOCAL signal for link-safety). But + // rehype-harden re-parses hrefs with `new URL` and only retries relatives + // starting with `/` `./` `../`, so a backslash href fails to parse and is + // blocked — while a forward-slash `//server/share` would be collapsed to a + // hostless `/share/…` (losing the server). UNC therefore can't be expressed + // as a harden-surviving relative URL without inventing a scheme, which #362 + // forbids. This test pins the CURRENT behavior so a future UNC fix updates + // it deliberately. The remark layer + the click routing of the backslash + // form stay covered by their own unit tests (no regression there). + const { container } = render( + {"[doc](file://server/share/doc.md)"} + ) + + await waitFor(() => { + expect(container.textContent).toContain("[blocked]") + }) + expect(container.querySelector("button[data-resource-kind]")).toBeNull() + expect(mocks.openFilePreview).not.toHaveBeenCalled() + }) + + it("does not regress https links (routed to the browser, not the file opener)", async () => { + const { container } = render( + {"[docs](https://example.com)"} + ) + + await waitFor(() => { + expect(container.querySelector("[data-streamdown='link']")).not.toBeNull() + }) + expect(container.textContent).not.toContain("[blocked]") + + fireEvent.click(container.querySelector("[data-streamdown='link']")!) + await waitFor(() => { + // Streamdown normalizes the href through `new URL`, adding the trailing + // slash; the click still routes to the browser (never the file opener). + expect(window.open).toHaveBeenCalledWith( + "https://example.com/", + "_blank", + "noreferrer" + ) + }) + expect(mocks.openFilePreview).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/ai-elements/remark-file-uri-links.test.ts b/src/components/ai-elements/remark-file-uri-links.test.ts index 6647f4456..fd8e993eb 100644 --- a/src/components/ai-elements/remark-file-uri-links.test.ts +++ b/src/components/ai-elements/remark-file-uri-links.test.ts @@ -42,8 +42,35 @@ describe("remarkRewriteFileUriLinks", () => { expect(rewrite("file:///Users/a/b.ts")).toBe("/Users/a/b.ts") }) - it("strips the leading slash before a Windows drive letter", () => { - expect(rewrite("file:///C:/x/y.ts")).toBe("C:/x/y.ts") + it("keeps the leading slash before a Windows drive letter (sanitize-safe)", () => { + // A bare `C:/…` would make rehype-sanitize read `C:` as a URL protocol and + // strip the href (→ harden's "[blocked]"); `/C:/…` keeps `C:` out of + // protocol position. Downstream link-safety strips the slash before opening. + expect(rewrite("file:///C:/x/y.ts")).toBe("/C:/x/y.ts") + }) + + it("prefixes a slash onto a bare Windows drive path (forward slashes)", () => { + expect(rewrite("E:/Desktop/docs/G.docx")).toBe("/E:/Desktop/docs/G.docx") + }) + + it("prefixes a slash onto a bare Windows drive path (backslashes)", () => { + expect(rewrite("C:\\Users\\a\\b.docx")).toBe("/C:\\Users\\a\\b.docx") + }) + + it("prefixes a slash onto a Chinese/encoded bare Windows drive path", () => { + expect(rewrite("E:/桌面/使用手册/G手册.docx")).toBe( + "/E:/桌面/使用手册/G手册.docx" + ) + expect(rewrite("E:/My%20Docs/%E6%89%8B%E5%86%8C.docx")).toBe( + "/E:/My%20Docs/%E6%89%8B%E5%86%8C.docx" + ) + }) + + it("leaves a bare relative path untouched (not a drive path)", () => { + // `C:` needs a following slash to be a drive path; `src/main.rs` and a + // schemeless relative path stay as-is (not openable — existing behavior). + expect(rewrite("src/main.rs")).toBe("src/main.rs") + expect(rewrite("notes.md")).toBe("notes.md") }) it("emits a UNC file:// URI as a backslash UNC path (unambiguously local)", () => { diff --git a/src/components/ai-elements/remark-file-uri-links.ts b/src/components/ai-elements/remark-file-uri-links.ts index 3ccae2d34..f97dca3f2 100644 --- a/src/components/ai-elements/remark-file-uri-links.ts +++ b/src/components/ai-elements/remark-file-uri-links.ts @@ -1,9 +1,18 @@ -// rehype-harden hard-codes `file:` in its blocked-protocol list and replaces -// such links with `… [blocked]`. Rewriting `file://` hrefs in -// the mdast layer (before remark-rehype) sidesteps the block while keeping -// the link clickable through the existing link-safety + open-file-dialog -// flow. Image syntax is intentionally left untouched: harden's -// "[Image blocked: …]" placeholder is more useful than a broken . +// Local-file markdown links are otherwise rendered as `… [blocked]`. Two +// distinct sanitize/harden rules cause this, both sidestepped here in the mdast +// layer (before remark-rehype) while keeping the link clickable through the +// existing link-safety + open-file-dialog flow: +// +// 1. `file://` hrefs — rehype-harden hard-codes `file:` in its blocked- +// protocol list. Rewritten to a bare local path (POSIX `/…`, `/C:/…` for +// Windows drives, or a `\\server\share` UNC form). +// 2. Bare Windows drive paths (`C:/…`, `C:\…`) — rehype-sanitize reads the +// leading `C:` as a URL protocol and strips the href, after which harden +// blocks the now-hrefless ``. Rewritten to `/C:/…` so `C:` is no longer +// in protocol position (see {@link windowsDrivePathToSafe}). +// +// Image syntax is intentionally left untouched: harden's "[Image blocked: …]" +// placeholder is more useful than a broken . type MdastNodeLike = { type: string @@ -32,11 +41,35 @@ function fileUriToLocalPath(uri: string): string | null { const body = `${parsed.host}${parsed.pathname}`.replace(/\//g, "\\") return `\\\\${body}${parsed.search}${parsed.hash}` } - let path = parsed.pathname - if (/^\/[a-zA-Z]:[\\/]/.test(path)) path = path.slice(1) - // Keep URL-encoded form so `%23` / `%3F` don't collide with fragment/query - // boundaries when the click handler later splits on `#` / `?`. - return `${path}${parsed.search}${parsed.hash}` + // Keep the pathname verbatim, INCLUDING the leading slash before a Windows + // drive letter (`/C:/…`). Stripping it to a bare `C:/…` makes the downstream + // rehype-sanitize step read `C:` as a URL protocol and drop the href, after + // which rehype-harden replaces the link with a `… [blocked]` span. The + // leading slash is stripped back off before the file is opened + // (link-safety's `stripLeadingSlashOnWindows`). POSIX paths already start + // with a slash, so they are unaffected. URL-encoded form is preserved so + // `%23` / `%3F` don't collide with fragment/query boundaries when the click + // handler later splits on `#` / `?`. + return `${parsed.pathname}${parsed.search}${parsed.hash}` +} + +// A bare Windows drive-letter path (`C:/…` or `C:\…`, no `file://` scheme) hits +// the same wall as the rewritten `file://` drive path above: rehype-sanitize +// parses the leading `C:` as a URL protocol and strips the href → harden emits +// `… [blocked]`. Prefixing a single slash — `/C:/…` — pushes the colon past the +// first `/`, so sanitize sees a schemeless, path-absolute URL and keeps it. +// Downstream (`classifyResourceKind`, link-safety's `stripLeadingSlashOnWindows`, +// `normalizeAbsPath`) already strips that leading slash back off before opening, +// so the opener still receives `C:/…`. This adds no new allowed protocol. +const WINDOWS_DRIVE_PATH = /^[a-zA-Z]:[\\/]/ + +function windowsDrivePathToSafe(url: string): string | null { + return WINDOWS_DRIVE_PATH.test(url) ? `/${url}` : null +} + +/** Rewrite a `file://` URI or a bare Windows drive path to a sanitize-safe form. */ +function rewriteLocalFileUrl(url: string): string | null { + return fileUriToLocalPath(url) ?? windowsDrivePathToSafe(url) } function walk(node: MdastNodeLike, fn: (n: MdastNodeLike) => void): void { @@ -67,7 +100,7 @@ export function remarkRewriteFileUriLinks() { walk(tree, (node) => { if (typeof node.url !== "string") return if (node.type === "link") { - const rewritten = fileUriToLocalPath(node.url) + const rewritten = rewriteLocalFileUrl(node.url) if (rewritten != null) node.url = rewritten return } @@ -77,7 +110,7 @@ export function remarkRewriteFileUriLinks() { ? node.identifier.toLowerCase() : "" if (imageRefIds.has(id)) return - const rewritten = fileUriToLocalPath(node.url) + const rewritten = rewriteLocalFileUrl(node.url) if (rewritten != null) node.url = rewritten } }) diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index cfcbdacad..9daa9c3f1 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -124,9 +124,13 @@ export const ChatInput = memo(function ChatInput({ const isPrompting = status === "prompting" 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. 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 dfc7bac2e..aaa321a13 100644 --- a/src/components/chat/conversation-context-bar.test.tsx +++ b/src/components/chat/conversation-context-bar.test.tsx @@ -2,7 +2,7 @@ import { render, screen, cleanup } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { ConversationFolderBranchPicker } from "./conversation-context-bar" +import { ConversationHeaderFolderPicker } from "./conversation-context-bar" import type { FolderDetail } from "@/lib/types" import { resetAppWorkspaceStore, @@ -10,13 +10,10 @@ import { } from "@/stores/app-workspace-store" // --------------------------------------------------------------------------- -// Mocks. The picker reads three contexts/hooks and one api call; everything -// else (cmdk tree building, branch-tree expansion) is pure and runs for real. +// Mocks. The header folder picker reads the tab store + tab actions and renders +// the shared FolderPicker (cmdk); the folder-display helpers run for real. // --------------------------------------------------------------------------- -const switchToBranch = vi.fn().mockResolvedValue(undefined) -const gitCheckout = vi.fn().mockResolvedValue(undefined) -const gitListAllBranches = vi.fn() const openNewConversationTab = vi.fn() vi.mock("next-intl", () => ({ @@ -27,17 +24,6 @@ vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() }, })) -vi.mock("@/hooks/use-switch-to-branch", () => ({ - useSwitchToBranch: () => switchToBranch, -})) - -vi.mock("@/lib/api", () => ({ - gitListAllBranches: (path: string) => gitListAllBranches(path), - // Present only so the "never bare-checkout" assertion has a spy to read; the - // component must not reach for it any more. - gitCheckout: (path: string, branch: string) => gitCheckout(path, branch), -})) - // Tab state, mutated per test before render. Workspace state (folders / // branches) is seeded into the real zustand store in beforeEach. let tabs: Array<{ @@ -85,15 +71,7 @@ const repo = mkFolder({ }) beforeEach(() => { - switchToBranch.mockClear() - gitCheckout.mockClear() openNewConversationTab.mockClear() - gitListAllBranches.mockReset() - gitListAllBranches.mockResolvedValue({ - local: ["main", "feat-x"], - remote: [], - worktree_branches: ["feat-x"], - }) resetAppWorkspaceStore() useAppWorkspaceStore.setState({ folders: [repo], @@ -104,64 +82,55 @@ beforeEach(() => { afterEach(() => cleanup()) -async function openBranchPickerAndSelect(branchName: string) { - const user = userEvent.setup() - // Two triggers render (folder + branch); the branch one is labelled by the - // current branch. - await user.click(screen.getByRole("button", { name: /main/ })) - const item = await screen.findByText(branchName) - await user.click(item) -} - -describe("ConversationFolderBranchPicker — branch checkout", () => { - it("routes an EXISTING conversation through switchToBranch (not a bare checkout)", async () => { - tabs = [{ id: "tab-1", folderId: 1, conversationId: 42 }] - activeTabId = "tab-1" - - render() - await openBranchPickerAndSelect("feat-x") - - expect(switchToBranch).toHaveBeenCalledTimes(1) - expect(switchToBranch).toHaveBeenCalledWith({ - activeFolder: repo, - branchName: "feat-x", - currentBranch: "main", - isRemote: false, +// 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. +describe("ConversationHeaderFolderPicker", () => { + it("switches folders for a draft via openNewConversationTab", async () => { + const other = mkFolder({ id: 2, name: "other-repo", path: "/repo/other" }) + useAppWorkspaceStore.setState({ + folders: [repo, other], + allFolders: [repo, other], }) - // The whole point of the fix: existing conversations must never run a bare - // in-place `git checkout` (which fails for a worktree branch or hijacks a - // worktree onto a free one). - expect(gitCheckout).not.toHaveBeenCalled() - }) - - it("routes a DRAFT conversation through switchToBranch too (unchanged)", async () => { tabs = [{ id: "tab-draft", folderId: 1, conversationId: null }] activeTabId = "tab-draft" - render() - await openBranchPickerAndSelect("feat-x") + const user = userEvent.setup() + render() + // Draft → editable trigger showing the current folder name. + await user.click(screen.getByRole("button", { name: /repo/ })) + await user.click(await screen.findByText("other-repo")) - expect(switchToBranch).toHaveBeenCalledWith({ - activeFolder: repo, - branchName: "feat-x", - currentBranch: "main", - isRemote: false, + expect(openNewConversationTab).toHaveBeenCalledWith(2, "/repo/other", { + inheritFromActive: true, }) - expect(gitCheckout).not.toHaveBeenCalled() }) - it("does not fire a checkout when the picked branch is the current one", async () => { + it("renders a static (non-switchable) chip for an existing conversation", async () => { + const other = mkFolder({ id: 2, name: "other-repo", path: "/repo/other" }) + useAppWorkspaceStore.setState({ + folders: [repo, other], + allFolders: [repo, other], + }) tabs = [{ id: "tab-1", folderId: 1, conversationId: 42 }] activeTabId = "tab-1" - render() const user = userEvent.setup() - await user.click(screen.getByRole("button", { name: /main/ })) - // Click the already-current branch inside the open popover. - const items = await screen.findAllByText("main") - await user.click(items[items.length - 1]) + render() + await user.click(screen.getByRole("button", { name: /repo/ })) + // Non-editable: clicking opens no folder list, so the other repo is + // unreachable and no switch fires. + expect(screen.queryByText("other-repo")).toBeNull() + expect(openNewConversationTab).not.toHaveBeenCalled() + }) + + it("shows the chat-mode label for a folderless chat tab", () => { + tabs = [ + { id: "tab-chat", folderId: 999, conversationId: null, isChat: true }, + ] + activeTabId = "tab-chat" - expect(switchToBranch).not.toHaveBeenCalled() - expect(gitCheckout).not.toHaveBeenCalled() + render() + expect(screen.getByRole("button", { name: /chatModeLabel/ })).toBeTruthy() }) }) diff --git a/src/components/chat/conversation-context-bar.tsx b/src/components/chat/conversation-context-bar.tsx index 02ffb55b6..f55c28025 100644 --- a/src/components/chat/conversation-context-bar.tsx +++ b/src/components/chat/conversation-context-bar.tsx @@ -1,38 +1,12 @@ "use client" -import { - Fragment, - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react" +import { memo, useEffect, useMemo, useRef, useState } from "react" import { useTranslations } from "next-intl" import { toast } from "sonner" -import { - Check, - ChevronDown, - ChevronRight, - Folder, - GitBranch, - Loader2, - MessageSquare, -} from "lucide-react" +import { Check, ChevronDown, Folder, MessageSquare } from "lucide-react" import type { OverlayScrollbarsComponentRef } from "overlayscrollbars-react" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useTabActions, useTabStore } from "@/contexts/tab-context" -import { gitListAllBranches } from "@/lib/api" -import { - buildBranchTree, - buildRemoteBranchSections, - expandedKeysForBranch, - localBranchItems, - type BranchTreeNode, -} from "@/lib/branch-tree" -import { useBranchTreeExpansion } from "@/hooks/use-branch-tree-expansion" -import type { GitBranchList } from "@/lib/types" import { Button } from "@/components/ui/button" import { Popover, @@ -53,10 +27,11 @@ import { cn } from "@/lib/utils" import { excludeChatFolders, filterTopLevelFolders, + formatFolderLabelWithAlias, resolveFolderDisplayName, resolvePickerSelectedFolderId, } from "@/lib/folder-display" -import { useSwitchToBranch } from "@/hooks/use-switch-to-branch" +import { FolderAliasLabel } from "@/components/conversations/folder-alias-label" interface ConversationContextBarProps { extraContent?: React.ReactNode @@ -124,26 +99,27 @@ export const ConversationContextBar = memo(function ConversationContextBar({ ConversationContextBar.displayName = "ConversationContextBar" // ============================================================================ -// ConversationFolderBranchPicker — folder + branch buttons rendered below the -// message input. +// ConversationHeaderFolderPicker — the folder selector on its own. Rendered in +// the desktop conversation header (replacing the old folder-name breadcrumb). +// Self-contained: resolves its own tab/folder from `tabId` (or the active tab) +// so the mount site only passes `tabId`. The branch selector no longer sits +// beside it here — on desktop it lives in the bottom status bar. // ============================================================================ -interface ConversationFolderBranchPickerProps { +interface ConversationHeaderFolderPickerProps { tabId?: string | null } -export const ConversationFolderBranchPicker = memo( - function ConversationFolderBranchPicker({ +export const ConversationHeaderFolderPicker = memo( + function ConversationHeaderFolderPicker({ tabId, - }: ConversationFolderBranchPickerProps) { + }: ConversationHeaderFolderPickerProps) { 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 branches = useAppWorkspaceStore((s) => s.branches) - const switchToBranch = useSwitchToBranch() const ownTab = useMemo(() => { const lookupId = tabId ?? activeTabId @@ -158,152 +134,99 @@ export const ConversationFolderBranchPicker = memo( [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). + // Only top-level repos are switchable here; worktree folders are reached + // via the branch picker, and hidden chat folders are a per-conversation + // implementation detail, so both are excluded from the list. 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. + // Chat mode: a draft flagged `isChat` (no folder yet) or a conversation + // bound to a hidden chat folder. The chip still shows so the user can + // switch back to a real folder while drafting. const isChatMode = ownTab.isChat === true || ownFolder?.kind === "chat" if (!ownFolder && !isChatMode) return null const isNewConversation = ownTab.conversationId == null - const currentBranch = + // 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 + // `ownFolder` (the worktree) unchanged. + const pickerSelectedId = + isChatMode || !ownFolder ? -1 : resolvePickerSelectedFolderId(ownFolder) + const displayFolder = isChatMode || !ownFolder ? null - : (branches.get(ownFolder.id) ?? ownFolder.git_branch ?? null) - const showBranchPicker = currentBranch != 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. + : (allFolders.find((f) => f.id === pickerSelectedId) ?? ownFolder) 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) + const displayFolderAlias = displayFolder?.alias ?? null + // The full `alias [ name ]` also goes in the tooltip for the truncated case. + const titleFolderName = displayFolder + ? formatFolderLabelWithAlias(displayFolder) + : displayFolderName 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")) - } - }} - /> - - {showBranchPicker && ownFolder && ( - { - // Both draft and existing conversations route through the shared - // switch logic (mirroring the top-bar branch dropdown). It never - // mutates the live conversation: a branch checked out in another - // worktree navigates to that folder (opening a fresh draft there - // via the singleton), and a free branch checks out in place only - // when the active folder is already the root tree. A bare - // in-place `git checkout` would instead fail (branch already - // checked out elsewhere) or hijack a worktree onto another branch. - await switchToBranch({ - activeFolder: ownFolder, - branchName, - currentBranch, - isRemote, - }) - }} - /> - )} - + { + 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 existing-draft branch reuses ownTab + // via the singleton invariant. `inheritFromActive: true` keeps the + // user's current agent when the target folder has no pinned default. + openNewConversationTab(target.id, target.path, { + inheritFromActive: true, + }) + toast.success(t("toasts.folderChanged", { name: target.name })) + } catch (err) { + console.error( + "[ConversationHeaderFolderPicker] 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( + "[ConversationHeaderFolderPicker] switch to chat mode failed:", + err + ) + toast.error(t("toasts.openFolderFailed")) + } + }} + /> ) } ) -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 - // Chat-mode drafts have no resolvable folder yet, but the picker row must - // still show so the folder chip (and the "no-folder mode" item) are reachable. - return Boolean(ownTab && (ownFolder || ownTab.isChat)) -} +ConversationHeaderFolderPicker.displayName = "ConversationHeaderFolderPicker" // ============================================================================ // FolderPicker // ============================================================================ interface FolderPickerProps { - folders: { id: number; name: string; path: string }[] + folders: { id: number; name: string; path: string; alias?: string | null }[] currentFolderId: number currentFolderName: string title: string @@ -317,6 +240,14 @@ interface FolderPickerProps { isChatMode: boolean /** Select folderless chat mode. */ onSelectChatMode: () => void + /** Trigger appearance. `"chip"` (default) = folder icon + name + chevron, the + * compact below-input row (mobile). `"header"` = bare text sized to the + * conversation title, alias-aware, themed while editable — the desktop + * conversation-header breadcrumb. */ + variant?: "chip" | "header" + /** Folder alias for the `"header"` variant's `alias [ name ]` label (rendered + * via {@link FolderAliasLabel}). Ignored by the chip variant. */ + alias?: string | null } const FolderPicker = memo(function FolderPicker({ @@ -331,27 +262,58 @@ const FolderPicker = memo(function FolderPicker({ labelChatMode, isChatMode, onSelectChatMode, + variant = "chip", + alias = null, }: FolderPickerProps) { const [open, setOpen] = useState(false) - const trigger = ( - - ) + // Header variant: no icons, `alias [ name ]` inline (matching the sidebar + // folder header), sized to the neighbouring conversation title. A new/editable + // conversation renders in the theme color to advertise that it's switchable; + // a bound one is a static foreground chip that reads as a breadcrumb crumb. + const headerLabel = + alias && alias.trim() ? ( + + ) : ( + currentFolderName + ) + + const trigger = + variant === "header" ? ( + + ) : ( + + ) if (!editable) { return trigger @@ -369,7 +331,9 @@ const FolderPicker = memo(function FolderPicker({ {folders.map((f) => ( { setOpen(false) void onSelect(f.id) @@ -377,7 +341,13 @@ const FolderPicker = memo(function FolderPicker({ >
- {f.name} + + + {f.path} @@ -419,284 +389,3 @@ const FolderPicker = memo(function FolderPicker({ ) }) - -// ============================================================================ -// BranchPicker -// ============================================================================ - -interface BranchPickerProps { - folderId: number - folderPath: string - currentBranch: string | null - title: string - onCheckout: (branchName: string, isRemote: boolean) => Promise -} - -const BranchPicker = memo(function BranchPicker({ - folderId, - folderPath, - currentBranch, - title, - onCheckout, -}: BranchPickerProps) { - const t = useTranslations("Folder.conversationContextBar") - const tBd = useTranslations("Folder.branchDropdown") - const [open, setOpen] = useState(false) - const [branchList, setBranchList] = useState(null) - const [loading, setLoading] = useState(false) - - const loadBranches = useCallback(async () => { - setLoading(true) - try { - const list = await gitListAllBranches(folderPath) - setBranchList(list) - } catch (err) { - console.error("[BranchPicker] list failed:", err) - setBranchList({ local: [], remote: [], worktree_branches: [] }) - } finally { - setLoading(false) - } - }, [folderPath]) - - useEffect(() => { - if (open) void loadBranches() - }, [open, loadBranches]) - - // Tree mode (browse) when the search box is empty; flat list (cmdk filters) - // when the user types — cmdk unmounts filtered items, so collapsed branches - // would otherwise be unsearchable. The query is controlled, so it must be - // cleared explicitly (an uncontrolled input used to reset when the popover - // content unmounted). - const [query, setQuery] = useState("") - - // Clear the search on EVERY close, regardless of path. `onOpenChange` doesn't - // fire when a leaf's `onSelect` closes the popover via `setOpen(false)` - // directly, so reset off the `open` transition (render-time, not an effect) - // — covers select, Escape, outside-click, and trigger-toggle alike. - const [prevOpen, setPrevOpen] = useState(open) - if (open !== prevOpen) { - setPrevOpen(open) - if (!open) setQuery("") - } - - // Reset branches cache + search when folder changes. - useEffect(() => { - setBranchList(null) - setQuery("") - }, [folderId]) - const isSearching = query.trim().length > 0 - - const localNodes = useMemo( - () => buildBranchTree(localBranchItems(branchList?.local ?? []), "local"), - [branchList] - ) - const remoteSections = useMemo( - () => buildRemoteBranchSections(branchList?.remote ?? []), - [branchList] - ) - - // Auto-expand the prefix groups leading to the current (local) branch. - const seedKeys = useMemo( - () => - currentBranch ? expandedKeysForBranch(localNodes, currentBranch) : [], - [currentBranch, localNodes] - ) - const { isExpanded, toggle } = useBranchTreeExpansion(open, seedKeys) - - const indentStyle = (depth: number) => ({ - paddingLeft: `${0.5 + depth * 0.75}rem`, - }) - - // Recursively render the prefix tree as cmdk items. Group headers toggle - // expansion (and never close the popover); leaves check out. - const renderTreeItems = ( - nodes: BranchTreeNode[], - depth: number, - isRemote: boolean - ): React.ReactNode[] => - nodes.flatMap((node) => { - if (node.type === "group") { - const groupOpen = isExpanded(node.key) - const header = ( - toggle(node.key)} - style={indentStyle(depth)} - > - - {node.label} - - {node.count} - - - ) - return groupOpen - ? [header, ...renderTreeItems(node.children, depth + 1, isRemote)] - : [header] - } - const localName = isRemote - ? node.fullName.replace(/^[^/]+\//, "") - : node.fullName - const selected = localName === currentBranch - return [ - { - setOpen(false) - if (localName !== currentBranch) - void onCheckout(localName, isRemote) - }} - style={indentStyle(depth)} - > - - - {node.label} - - {selected && } - , - ] - }) - - return ( - - - - - - - - - {loading ? ( -
- -
- ) : ( - <> - {t("noBranches")} - {branchList && branchList.local.length > 0 && ( - - {isSearching - ? branchList.local.map((b) => ( - { - setOpen(false) - if (b !== currentBranch) void onCheckout(b, false) - }} - > - - {b} - {b === currentBranch && ( - - )} - - )) - : renderTreeItems(localNodes, 0, false)} - - )} - {branchList && branchList.remote.length > 0 && ( - - {isSearching - ? branchList.remote.map((b) => { - const localName = b.replace(/^[^/]+\//, "") - return ( - { - setOpen(false) - if (localName !== currentBranch) - void onCheckout(localName, true) - }} - > - - - {b} - - {localName === currentBranch && ( - - )} - - ) - }) - : remoteSections.map((section) => - section.remoteName == null ? ( - - {renderTreeItems(section.nodes, 0, true)} - - ) : ( - - toggle(section.key)} - style={indentStyle(0)} - > - - - {section.remoteName} - - - {section.count} - - - {isExpanded(section.key) && - renderTreeItems(section.nodes, 1, true)} - - ) - )} - - )} - - )} -
-
-
-
- ) -}) diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index 186262549..ad00e5d50 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -72,8 +72,6 @@ vi.mock("@/components/chat/conversation-context-bar", () => ({ }: { extraContent?: React.ReactNode }) =>
{extraContent}
, - ConversationFolderBranchPicker: () => null, - useConversationFolderBranchPickerVisible: () => false, })) vi.mock("@/lib/platform", () => ({ isDesktop: () => false, diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 2f7dc2e4f..1668f5b87 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -112,11 +112,7 @@ import { type AttachFileToSessionDetail, type AppendTextToSessionDetail, } from "@/lib/session-attachment-events" -import { - ConversationContextBar, - ConversationFolderBranchPicker, - useConversationFolderBranchPickerVisible, -} from "@/components/chat/conversation-context-bar" +import { ConversationContextBar } from "@/components/chat/conversation-context-bar" import { InlineModeSelector } from "@/components/chat/mode-selector" import { InlineSessionConfigSelector } from "@/components/chat/session-config-selector" import { ModelOptionPicker } from "@/components/chat/model-option-picker" @@ -970,9 +966,6 @@ export function MessageInput({ const hasAnySelector = showConfigLoading || hasConfigOptions || showModeLoading || showModeSelector const hasInlineSelectors = hasConfigOptions || showModeSelector - const hasFolderBranchPicker = - useConversationFolderBranchPickerVisible(attachmentTabId) - const folderBranchPickerAttached = hasFolderBranchPicker const imageAttachments = useMemo( () => attachments.filter( @@ -2996,16 +2989,10 @@ export function MessageInput({
)} -
+ {/* 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. */} +
{/* Disabled in non-secure web (no async clipboard read) so the native context menu — whose Paste still works over the editor text — is @@ -3022,18 +3009,17 @@ 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. - "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 + // Focus still swaps to `border-ring` below. `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. Off (no image) it's the plain background, - // unchanged. - 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", + // 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", // 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 @@ -3041,9 +3027,7 @@ export function MessageInput({ // A lone/non-tiled session (showActiveFlow=false) and inactive // tiles show the plain default border. showActiveFlow && "codeg-composer-flow", - !folderBranchPickerAttached && - showDragActive && - "ring-1 ring-primary/40", + showDragActive && "ring-1 ring-primary/40", className )} > @@ -3586,21 +3570,6 @@ 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`). -
- -
- )}
({ vi.mock("./session-details-dialog", () => ({ SessionDetailsDialog: () => null, })) +// The header now embeds the folder picker (self-contained, store-driven); stub +// it so these tests exercise only the header's own menu/dialog logic. +vi.mock("@/components/chat/conversation-context-bar", () => ({ + ConversationHeaderFolderPicker: () => null, +})) import { ConversationDetailHeader } from "./conversation-detail-header" @@ -70,8 +75,6 @@ const A: Props = { runtimeConversationId: null, folderId: 1, folderPath: "/a", - folderName: "folder-a", - folderAlias: null, title: "conv-a", status: "in_progress", } @@ -79,7 +82,6 @@ const B: Props = { ...A, tabId: "tab-b", conversationId: 2, - folderName: "folder-b", title: "conv-b", } diff --git a/src/components/conversations/conversation-detail-header.tsx b/src/components/conversations/conversation-detail-header.tsx index 8a7c2b7ee..782c1caa9 100644 --- a/src/components/conversations/conversation-detail-header.tsx +++ b/src/components/conversations/conversation-detail-header.tsx @@ -21,8 +21,7 @@ import { updateConversationTitle, } from "@/lib/api" import { formatConversationTitle } from "@/lib/conversation-title" -import { formatFolderLabelWithAlias } from "@/lib/folder-display" -import { FolderAliasLabel } from "@/components/conversations/folder-alias-label" +import { ConversationHeaderFolderPicker } from "@/components/chat/conversation-context-bar" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useTabActions } from "@/contexts/tab-context" import { useConversationLocate } from "@/contexts/conversation-locate-context" @@ -75,11 +74,6 @@ interface ConversationDetailHeaderProps { runtimeConversationId: number | null folderId: number folderPath: string | undefined - /** Owning folder's raw name, shown as a breadcrumb left of the title. */ - folderName: string | null - /** Owning folder's user-set alias, or null. When set the breadcrumb renders - * `alias [ name ]` via {@link FolderAliasLabel}. */ - folderAlias: string | null title: string status: ConversationStatus | undefined } @@ -104,8 +98,6 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ runtimeConversationId, folderId, folderPath, - folderName, - folderAlias, title, status, }: ConversationDetailHeaderProps) { @@ -253,27 +245,13 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ // so the whole top of the column reveals the canvas.
- {folderName && ( - <> - - - - - - )} + {/* Folder selector — replaces the old folder-name breadcrumb. Switches + folders for a draft; a static chip for a bound conversation. */} + + {displayTitle} diff --git a/src/components/conversations/conversation-detail-panel-layout.test.ts b/src/components/conversations/conversation-detail-panel-layout.test.ts index 831bc5254..514c197d6 100644 --- a/src/components/conversations/conversation-detail-panel-layout.test.ts +++ b/src/components/conversations/conversation-detail-panel-layout.test.ts @@ -57,67 +57,46 @@ describe("ConversationDetailPanel new conversation layout", () => { expect(welcomeHeroSource).not.toContain("bg-gradient-to-r") }) - it("uses the shared attached folder branch picker treatment for all chat inputs", () => { + 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. expect(source).not.toContain("attachFolderBranchPickerToInput") expect(conversationShellSource).not.toContain( "attachFolderBranchPickerToInput" ) expect(messageInputSource).not.toContain("attachFolderBranchPickerToInput") - expect(messageInputSource).toContain( - "const folderBranchPickerAttached = hasFolderBranchPicker" + expect(messageInputSource).not.toContain("hasFolderBranchPicker") + expect(messageInputSource).not.toContain("folderBranchPickerAttached") + expect(messageInputSource).not.toContain("ConversationFolderBranchPicker") + expect(messageInputSource).not.toContain( + "useConversationFolderBranchPickerVisible" ) expect(messageInputSource).not.toContain("rounded-b-none") - const pickerStart = messageInputSource.indexOf( - "{hasFolderBranchPicker && (" - ) - const pickerEnd = messageInputSource.indexOf( - " { expect(conversationShellSource).toContain( 'className="mx-auto w-full max-w-3xl"' ) - // Ordinary (active) chat input keeps its own px-4 gutter to align with the - // sibling cards in conversation-shell; only the welcome input drops it via - // `flush` (the welcome column already provides the px-4). - expect(chatInputSource).toContain('cn("pt-0 pb-1", !flush && "px-4")') + // 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. + expect(chatInputSource).toContain( + 'cn("pt-0", flush ? "pb-1" : "px-4 pb-4")' + ) expect(chatInputSource).toContain( 'cn(tall ? "min-h-30" : "min-h-24", "max-h-60")' ) diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 0b07f32a4..505973f8d 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -115,7 +115,6 @@ import { ExportTooLongError, } from "@/lib/export-conversation" import { useExportLabels } from "@/lib/use-export-labels" -import { useIsMobile } from "@/hooks/use-mobile" import { resolveActiveSessionDetails } from "./active-session-details" import { ConversationDetailHeader } from "./conversation-detail-header" import { SessionDetailsDialog } from "./session-details-dialog" @@ -1671,9 +1670,6 @@ export function ConversationDetailPanel() { const tabs = useTabStore((s) => s.tabs) const activeTabId = useTabStore((s) => s.activeTabId) const isTileMode = useTabStore((s) => s.isTileMode) - // The per-tile conversation header is desktop-only; the mobile shell keeps its - // own tab bar + menu, so the tile renders the view alone there. - const isMobile = useIsMobile() const { openNewConversationTab, closeTab, switchTab, onPreviewTabReplaced } = useTabActions() const newConversation = useMemo(() => { @@ -2029,9 +2025,10 @@ export function ConversationDetailPanel() { ) }) - // A single header (desktop only) sits fixed above the horizontally-scrolling - // tile row, so it never scrolls on the x-axis when conversations are tiled. - // It reflects the ACTIVE conversation (title + owning folder). + // A single header sits fixed above the horizontally-scrolling tile row, so it + // never scrolls on the x-axis when conversations are tiled. It reflects the + // ACTIVE conversation (title + owning folder). On mobile there's no tile row — + // it's simply the sole conversation's header. const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null const activeTabFolder = activeTab ? allFolders.find((f) => f.id === activeTab.folderId) @@ -2040,15 +2037,13 @@ export function ConversationDetailPanel() { return ( <>
- {!isMobile && activeTab && ( + {activeTab && ( diff --git a/src/components/files/file-workspace-tab-bar.tsx b/src/components/files/file-workspace-tab-bar.tsx index 9364cb548..ef145ac44 100644 --- a/src/components/files/file-workspace-tab-bar.tsx +++ b/src/components/files/file-workspace-tab-bar.tsx @@ -2,19 +2,8 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { Reorder } from "motion/react" -import { - Code, - Eye, - ExternalLink, - FileText, - GitCompare, - Maximize2, - Minimize2, - X, -} from "lucide-react" +import { FileText, GitCompare, Maximize2, Minimize2, X } from "lucide-react" import { useTranslations } from "next-intl" -import { openPath } from "@/lib/platform" -import { isHtmlPreviewable } from "@/lib/language-detect" import { useWorkspaceActions, useWorkspaceFileTabs, @@ -22,10 +11,7 @@ import { } from "@/contexts/workspace-context" import type { FileWorkspaceTab } from "@/contexts/workspace-context" import { useIsCoarsePointer } from "@/hooks/use-is-coarse-pointer" -import { useIsMobile } from "@/hooks/use-mobile" import { useLongPressDrag } from "@/hooks/use-long-press-drag" -import { useShortcutSettings } from "@/hooks/use-shortcut-settings" -import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" import { cn, handleMiddleClickClose } from "@/lib/utils" import { ContextMenu, @@ -35,40 +21,27 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu" -export function FileWorkspaceTabBar({ - embedded = false, -}: { - embedded?: boolean -} = {}) { +// Rendered only inside the desktop file-column title strip (embedded). The old +// standalone mobile variant is gone — mobile shows the FileWorkspaceHeader +// (folder › file breadcrumb) instead and opens files from the file tree. +export function FileWorkspaceTabBar() { const t = useTranslations("Folder.fileWorkspace") - const { mode, activePane, filesMaximized } = useWorkspaceView() - const { fileTabs, activeFileTabId, previewFileTabIds } = - useWorkspaceFileTabs() + const { mode, filesMaximized } = useWorkspaceView() + const { fileTabs, activeFileTabId } = useWorkspaceFileTabs() const { switchFileTab, closeFileTab, closeOtherFileTabs, closeAllFileTabs, reorderFileTabs, - toggleFileTabPreview, toggleFilesMaximized, } = useWorkspaceActions() - const { shortcuts } = useShortcutSettings() const scrollRef = useRef(null) const isCoarsePointer = useIsCoarsePointer() - const isMobile = useIsMobile() - const [isHovered, setIsHovered] = useState(false) const [touchSortingTabId, setTouchSortingTabId] = useState( null ) - const handleWheel = useCallback((e: React.WheelEvent) => { - if (e.deltaY !== 0 && scrollRef.current) { - e.preventDefault() - scrollRef.current.scrollLeft += e.deltaY - } - }, []) - useEffect(() => { if (!activeFileTabId || !scrollRef.current) return const el = scrollRef.current.querySelector( @@ -77,40 +50,6 @@ export function FileWorkspaceTabBar({ el?.scrollIntoView({ block: "nearest", inline: "nearest" }) }, [activeFileTabId]) - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - // While maximized only the files pane is interactive, so route shortcuts - // here regardless of the user's last-clicked pane. - const shouldHandleShortcut = - mode === "fusion" && (activePane === "files" || filesMaximized) - if (!shouldHandleShortcut) return - if (matchShortcutEvent(event, shortcuts.close_all_file_tabs)) { - event.preventDefault() - closeAllFileTabs() - return - } - if (!matchShortcutEvent(event, shortcuts.close_current_tab)) return - - if (!activeFileTabId) return - event.preventDefault() - closeFileTab(activeFileTabId) - } - - window.addEventListener("keydown", onKeyDown) - return () => { - window.removeEventListener("keydown", onKeyDown) - } - }, [ - activeFileTabId, - closeAllFileTabs, - closeFileTab, - mode, - activePane, - filesMaximized, - shortcuts.close_all_file_tabs, - shortcuts.close_current_tab, - ]) - const handleReorder = useCallback( (nextTabs: FileWorkspaceTab[]) => { if (isCoarsePointer && !touchSortingTabId) return @@ -124,45 +63,14 @@ export function FileWorkspaceTabBar({ [] ) - const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId) const activeFileIndex = fileTabs.findIndex( (tab) => tab.id === activeFileTabId ) - const canPreview = - activeTab?.kind === "file" && - (activeTab.language === "markdown" || isHtmlPreviewable(activeTab.path)) - const canOpenInBrowser = - activeTab?.kind === "file" && isHtmlPreviewable(activeTab.path) - const isPreviewActive = - canPreview && activeFileTabId - ? previewFileTabIds.has(activeFileTabId) - : false - // Embedded in the title bar: fill its height and let the bar own the bottom - // border. Standalone (mobile panel row): keep the h-10 row + border. - const rowHeight = embedded ? "h-full" : "h-10" - const rowBorder = embedded ? "" : "border-b border-border" - - if (fileTabs.length === 0) { - // In the title bar an empty file workspace shows nothing (only the - // conversation tabs remain); the standalone panel row keeps its label. - if (embedded) return null - return ( -
- {t("files")} -
- ) - } + if (fileTabs.length === 0) return null return ( -
+
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - className={cn( - "pt-1.5 px-1.5 min-w-0 flex items-stretch", - // Standalone row fills its container so the trailing action buttons - // sit flush right; embedded sizes to content so the wrapper's drag - // spacer claims the leftover row. - !embedded && "flex-1", - rowHeight, - rowBorder, - // Embedded: no scrollbar — tabs shrink browser-style and sit flush - // (`gap-0`) so their hairline separators read as dividers (see - // FileWorkspaceTabItem `embedded`); no bottom padding so they reach - // the strip's bottom and the active (white) tab merges into the file - // detail header below. Standalone: horizontal scroll with a hover - // scrollbar + the original inter-tab gap (mobile panel row). - embedded - ? "gap-0 overflow-hidden px-2" - : [ - "gap-1.5 overflow-x-scroll", - isHovered - ? [ - "pb-0.5", - "[&::-webkit-scrollbar]:h-1", - "[&::-webkit-scrollbar-track]:bg-transparent", - "[&::-webkit-scrollbar-thumb]:rounded-full", - "[&::-webkit-scrollbar-thumb]:bg-border", - ] - : ["pb-1.5", "[&::-webkit-scrollbar]:h-0"], - ] - )} + // Tabs shrink browser-style and sit flush (`gap-0`) so their hairline + // separators read as dividers (see FileWorkspaceTabItem); no scrollbar + // (`overflow-hidden` still scrolls programmatically) and no bottom padding + // so they reach the strip's bottom and the active (white) tab merges into + // the file detail header below. + className="pt-1.5 px-2 min-w-0 flex h-full items-stretch gap-0 overflow-hidden" > {fileTabs.map((tab, index) => ( ))} - {/* Title-bar trailing area (desktop title bar): a drag spacer fills the - leftover panel width (window-drag region) and, in fusion, a - maximize/restore button sits flush right (it used to live in the file - detail header). Wrapped in one `flex-1` box so the workspace-bg bottom - hairline (ws-strip-line) runs unbroken under both — the short - `self-center h-7` button can't carry the line itself. NO `min-w-0`: the - wrapper's min-content (the spacer's `min-w-10` + the shrink-0 button) is - its floor, so under many-tab overflow the group shrinks to reserve them - instead of the wrapper collapsing to 0 and clipping. Off (no bg image): - ws-strip-line is inert. */} - {embedded && ( -
- {/* Drag spacer, floored at `min-w-10` (40px) instead of `min-w-0`: even - when many tabs overflow and squeeze this region, a grabbable - window-drag gap always remains between the last tab and the - maximize button, so the tabs never butt against the button and the - packed title bar stays draggable. */} -
- {mode === "fusion" && ( - - )} -
- )} - {/* Trailing file-action buttons render only in the standalone (mobile - panel) row. In the desktop title bar (embedded) they live in the file - detail header instead (FileWorkspaceHeader). */} - {!embedded && canPreview && activeFileTabId && ( - - )} - {!embedded && canOpenInBrowser && activeTab?.path && ( - - )} - {!embedded && !isMobile && mode === "fusion" && ( - - )} + {/* Trailing area: a drag spacer fills the leftover panel width (window-drag + region) and, in fusion, a maximize/restore button sits flush right (it + used to live in the file detail header). Wrapped in one `flex-1` box so + the workspace-bg bottom hairline (ws-strip-line) runs unbroken under + both. NO `min-w-0`: the wrapper's min-content (the spacer's `min-w-10` + + the shrink-0 button) is its floor, so under many-tab overflow the group + shrinks to reserve them instead of the wrapper collapsing to 0. */} +
+ {/* Drag spacer, floored at `min-w-10` (40px): even when many tabs overflow + and squeeze this region, a grabbable window-drag gap always remains + between the last tab and the maximize button. */} +
+ {mode === "fusion" && ( + + )} +
) } diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index 8f7350bf6..6e7b50653 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -21,6 +21,7 @@ import { useActiveFolder } from "@/contexts/active-folder-context" import { useAuxPanelContext } from "@/contexts/aux-panel-context" import { useTabStore } from "@/contexts/tab-context" import { useTerminalContext } from "@/contexts/terminal-context" +import { useIsMobile } from "@/hooks/use-mobile" import { useWorkspaceActions, useWorkspaceFileTabs, @@ -1088,7 +1089,12 @@ function RenderNode({ export function FileTreeTab() { const t = useTranslations("Folder.fileTreeTab") const tCommon = useTranslations("Folder.common") - const { pendingRevealPath, consumePendingRevealPath } = useAuxPanelContext() + const { + pendingRevealPath, + consumePendingRevealPath, + setOpen: setAuxOpen, + } = useAuxPanelContext() + const isMobile = useIsMobile() // Defer the folder so a cross-folder conversation-tab switch commits first and // this tab's heavy tree rebuild (remount, applyLazyTreeOverrides, per-row // ContextMenus) runs in a non-blocking transition a frame later instead of @@ -1664,8 +1670,11 @@ export function FileTreeTab() { treeContainerRef.current?.focus({ preventScroll: true }) if (!filePathSet.has(path)) return void openFilePreview(path) + // On mobile the file tree lives in a Sheet overlay — close it so the + // opened file is visible in the main pane. + if (isMobile) setAuxOpen(false) }, - [filePathSet, openFilePreview] + [filePathSet, openFilePreview, isMobile, setAuxOpen] ) // ─── File-tree keyboard navigation (IDEA-style project tree) ─── diff --git a/src/components/layout/aux-panel-session-details-tab.test.tsx b/src/components/layout/aux-panel-session-details-tab.test.tsx index bba63afb0..70a540a13 100644 --- a/src/components/layout/aux-panel-session-details-tab.test.tsx +++ b/src/components/layout/aux-panel-session-details-tab.test.tsx @@ -5,8 +5,10 @@ import { beforeEach, describe, expect, it, vi, type Mock } from "vitest" import type { DbConversationSummary } from "@/lib/types" import enMessages from "@/i18n/messages/en.json" -// Heavy, context-hungry children relocated from the title bar — stub them so -// this test exercises only the tab's own layout/gating, not their internals. +// Regression guard: the branch selector + command launcher were REMOVED from +// this tab — they live in the bottom status bar now, on every platform. Stub +// them so that if either is ever re-added here, its test id surfaces and the +// "no actions bar" assertions below fail. vi.mock("./branch-dropdown", () => ({ BranchDropdown: () =>
, })) @@ -20,16 +22,6 @@ vi.mock("@/components/agent-icon", () => ({ AgentIcon: () => null })) vi.mock("@/lib/api", () => ({ getFolderConversation: vi.fn() })) vi.mock("@/contexts/aux-panel-context", () => ({ useAuxPanelContext: vi.fn() })) -vi.mock("@/contexts/active-folder-context", () => ({ - useActiveFolder: vi.fn(), -})) -vi.mock("@/hooks/use-is-active-chat-mode", () => ({ - useIsActiveChatMode: vi.fn(), -})) -// The tab now reads viewport size to size its header (desktop h-10 vs the -// mobile Sheet's original py-2); the real hook calls `window.matchMedia`, which -// jsdom lacks, so mock it. -vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: vi.fn() })) vi.mock("@/contexts/tab-context", () => ({ useTabStore: vi.fn() })) vi.mock("@/stores/conversation-runtime-store", () => ({ useConversationRuntimeStore: vi.fn(), @@ -40,17 +32,11 @@ vi.mock("@/stores/app-workspace-store", () => ({ import { SessionDetailsTab } from "./aux-panel-session-details-tab" import { useAuxPanelContext } from "@/contexts/aux-panel-context" -import { useActiveFolder } from "@/contexts/active-folder-context" -import { useIsActiveChatMode } from "@/hooks/use-is-active-chat-mode" -import { useIsMobile } from "@/hooks/use-mobile" import { useTabStore } from "@/contexts/tab-context" import { useConversationRuntimeStore } from "@/stores/conversation-runtime-store" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" const mockAux = useAuxPanelContext as unknown as Mock -const mockFolder = useActiveFolder as unknown as Mock -const mockChat = useIsActiveChatMode as unknown as Mock -const mockMobile = useIsMobile as unknown as Mock const mockTabs = useTabStore as unknown as Mock const mockRuntime = useConversationRuntimeStore as unknown as Mock const mockWorkspace = useAppWorkspaceStore as unknown as Mock @@ -89,16 +75,8 @@ function summary( } } -function setupScene(opts: { - activeFolderId: number | null - isChatMode: boolean - hasActiveConversation: boolean - isMobile?: boolean -}) { +function setupScene(opts: { hasActiveConversation: boolean }) { mockAux.mockReturnValue({ isOpen: true, activeTab: "session_details" }) - mockFolder.mockReturnValue({ activeFolderId: opts.activeFolderId }) - mockChat.mockReturnValue(opts.isChatMode) - mockMobile.mockReturnValue(opts.isMobile ?? false) const tabState: TabSlice = { tabs: opts.hasActiveConversation ? [{ id: 1, conversationId: 7 }] : [], @@ -126,65 +104,22 @@ describe("SessionDetailsTab", () => { vi.clearAllMocks() }) - it("renders the active session's details and the folder actions bar", () => { - setupScene({ - activeFolderId: 1, - isChatMode: false, - hasActiveConversation: true, - }) - const { getByText, getByTestId } = renderTab() - expect(getByText("My session")).toBeTruthy() - expect(getByText("Claude Code")).toBeTruthy() - expect(getByTestId("branch-dropdown")).toBeTruthy() - expect(getByTestId("command-dropdown")).toBeTruthy() - }) - - it("hides the folder actions bar in chat mode but still shows details", () => { - setupScene({ - activeFolderId: 1, - isChatMode: true, - hasActiveConversation: true, - }) + it("renders the active session's details with no folder-actions bar", () => { + // Branch + command moved to the bottom status bar on every platform, so the + // tab shows the details alone — no branch/command controls here. + setupScene({ hasActiveConversation: true }) const { getByText, queryByTestId } = renderTab() expect(getByText("My session")).toBeTruthy() + expect(getByText("Claude Code")).toBeTruthy() expect(queryByTestId("branch-dropdown")).toBeNull() expect(queryByTestId("command-dropdown")).toBeNull() }) it("shows the empty state when there is no active session", () => { - setupScene({ - activeFolderId: null, - isChatMode: false, - hasActiveConversation: false, - }) + setupScene({ hasActiveConversation: false }) const { getByText, queryByTestId } = renderTab() expect(getByText("No active session")).toBeTruthy() expect(queryByTestId("branch-dropdown")).toBeNull() - }) - - it("sizes the actions bar h-10 on desktop but keeps the mobile Sheet's py-2", () => { - // Desktop: the bar matches the conversation/file detail headers (h-10). - setupScene({ - activeFolderId: 1, - isChatMode: false, - hasActiveConversation: true, - isMobile: false, - }) - const desktop = renderTab() - const desktopBar = desktop.getByTestId("branch-dropdown").parentElement - expect(desktopBar?.className).toContain("h-10") - expect(desktopBar?.className).not.toContain("py-2") - desktop.unmount() - - // Mobile (Sheet): unchanged from before — the original py-2, no fixed height. - setupScene({ - activeFolderId: 1, - isChatMode: false, - hasActiveConversation: true, - isMobile: true, - }) - const mobileBar = renderTab().getByTestId("branch-dropdown").parentElement - expect(mobileBar?.className).toContain("py-2") - expect(mobileBar?.className).not.toContain("h-10") + expect(queryByTestId("command-dropdown")).toBeNull() }) }) diff --git a/src/components/layout/aux-panel-session-details-tab.tsx b/src/components/layout/aux-panel-session-details-tab.tsx index 2a400ad03..1335f014a 100644 --- a/src/components/layout/aux-panel-session-details-tab.tsx +++ b/src/components/layout/aux-panel-session-details-tab.tsx @@ -10,13 +10,7 @@ import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { resolveActiveSessionDetails } from "@/components/conversations/active-session-details" import { SessionDetailsContent } from "@/components/conversations/session-details-content" import { ScrollArea } from "@/components/ui/scroll-area" -import { useActiveFolder } from "@/contexts/active-folder-context" -import { useIsActiveChatMode } from "@/hooks/use-is-active-chat-mode" -import { useIsMobile } from "@/hooks/use-mobile" import { useAuxPanelContext } from "@/contexts/aux-panel-context" -import { cn } from "@/lib/utils" -import { BranchDropdown } from "./branch-dropdown" -import { CommandDropdown } from "./command-dropdown" // Stable empty-turns reference so the `useShallow` slice below stays // reference-equal when there's no active session — otherwise a fresh `[]` each @@ -26,9 +20,9 @@ const EMPTY_TURNS: MessageTurn[] = [] /** * The aux-panel "Session Details" tab. Shows the active conversation's metadata - * and token usage (via the shared `SessionDetailsContent`), with a folder-scoped - * actions bar hosting the branch selector + command launcher relocated here from - * the top title bar. + * and token usage (via the shared `SessionDetailsContent`). The branch selector + * + command launcher that used to sit atop this tab now live in the bottom + * status bar on both platforms, so the tab shows the details alone. * * Details are resolved from live runtime state exactly the way the conversation * detail panel does it (`resolveActiveSessionDetails`), so no network fetch is @@ -37,9 +31,6 @@ const EMPTY_TURNS: MessageTurn[] = [] export function SessionDetailsTab() { const t = useTranslations("Folder.sessionDetails") const { isOpen, activeTab } = useAuxPanelContext() - const { activeFolderId } = useActiveFolder() - const isChatMode = useIsActiveChatMode() - const isMobile = useIsMobile() const tabs = useTabStore((s) => s.tabs) const activeTabId = useTabStore((s) => s.activeTabId) @@ -84,27 +75,8 @@ export function SessionDetailsTab() { conversations ) - // Branch + command are folder-scoped and both self-hide in chat mode / without - // a folder, so only surface the actions bar for a real folder workspace to - // avoid an empty bordered row. - const showFolderActions = activeFolderId != null && !isChatMode - return (
- {showFolderActions && ( -
- - -
- )} {summary ? (
diff --git a/src/components/layout/aux-panel.tsx b/src/components/layout/aux-panel.tsx index 8af9c9686..e45f5936b 100644 --- a/src/components/layout/aux-panel.tsx +++ b/src/components/layout/aux-panel.tsx @@ -288,7 +288,7 @@ export function AuxPanel() { // Mobile (Sheet): unchanged — full-width underline tabs + a divider. {renderTabTriggers(false)} {/* Trailing drag region lets the empty part of the tab row move diff --git a/src/components/layout/branch-dropdown.tsx b/src/components/layout/branch-dropdown.tsx index d3abbcdc7..0f8acb903 100644 --- a/src/components/layout/branch-dropdown.tsx +++ b/src/components/layout/branch-dropdown.tsx @@ -11,9 +11,10 @@ import { import { ArchiveRestore, Archive, - ArrowDownToLine, - ChevronDown, ChevronRight, + CloudDownload, + CloudSync, + CloudUpload, FolderGit2, FolderOpen, GitBranch, @@ -24,9 +25,7 @@ import { GitPullRequestArrow, Globe, Loader2, - RefreshCw, Trash2, - Upload, } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" @@ -135,7 +134,15 @@ interface GitPushSucceededEventPayload { upstream_set: boolean } -export function BranchDropdown() { +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 +} + +export function BranchDropdown({ + showFolderName = true, +}: BranchDropdownProps = {}) { const t = useTranslations("Folder.branchDropdown") const tCommon = useTranslations("Folder.common") const { activeFolder } = useActiveFolder() @@ -364,6 +371,29 @@ 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). + function handlePull() { + setDropdownOpen(false) + void runGitTask( + t("tasks.pullCode"), + () => + withCredentialRetry((creds) => gitPull(folderPath, creds), { + folderPath, + }), + (result) => { + if (result.conflict?.has_conflicts) { + setConflictInfo(result.conflict) + return false + } + if (result.updated_files === 0) { + return t("toasts.allFilesUpToDate") + } + return t("toasts.updatedFiles", { count: result.updated_files }) + } + ) + } + async function handleNewBranch() { const name = newBranchName.trim() if (!name) return @@ -692,18 +722,41 @@ export function BranchDropdown() { // 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) { return ( - @@ -723,260 +776,277 @@ export function BranchDropdown() { return ( <> - - - - - - - - runGitTask( - t("tasks.pullCode"), - () => - withCredentialRetry((creds) => gitPull(folderPath, creds), { - folderPath, - }), - (result) => { - if (result.conflict?.has_conflicts) { - setConflictInfo(result.conflict) - return false - } - if (result.updated_files === 0) { - return t("toasts.allFilesUpToDate") - } - return t("toasts.updatedFiles", { - count: result.updated_files, - }) - } - ) +
+ + + + + + + + + {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("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 ? ( -
- -
- ) : ( - + }} + > + + {t("openCommitWindow")} + { - e.preventDefault() - toggle(localSectionKey) + disabled={loading} + onSelect={() => { + if (!folderId) return + setDropdownOpen(false) + openPushWindow(folderId).catch((err) => { + const title = t("toasts.openPushWindowFailed") + const msg = toErrorMessage(err) + pushAlert("error", title, msg) + toast.error(title, { description: msg }) + }) }} > - - {t("localBranches", { count: branchList.local.length })} + + {t("pushCode")} - {isExpanded(localSectionKey) && - (branchList.local.length === 0 ? ( - - {t("noLocalBranches")} - - ) : ( - renderDropdownTree(localNodes, 0, false) - ))} - + + + { - e.preventDefault() - toggle(remoteSectionKey) + disabled={loading} + onSelect={() => { + setNewBranchName("") + setNewBranchOpen(true) }} > - - {t("remoteBranches", { count: branchList.remote.length })} + + {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")} - {isExpanded(remoteSectionKey) && - (branchList.remote.length === 0 ? ( - - {t("noRemoteBranches")} - - ) : ( - remoteSections.map((section) => - section.remoteName == null ? ( - - {renderDropdownTree(section.nodes, 0, true)} - - ) : ( - - { - e.preventDefault() - toggle(section.key) - }} - style={{ - paddingLeft: branchRowPaddingLeft("dropdown", 0), - }} - > - - - {section.remoteName} - - - {section.count} - - - {isExpanded(section.key) && - renderDropdownTree(section.nodes, 1, true)} - + + + {branchLoading ? ( +
+ +
+ ) : ( + + { + e.preventDefault() + toggle(localSectionKey) + }} + > + + {t("localBranches", { count: branchList.local.length })} + + {isExpanded(localSectionKey) && + (branchList.local.length === 0 ? ( + + {t("noLocalBranches")} + + ) : ( + renderDropdownTree(localNodes, 0, false) + ))} + + { + e.preventDefault() + toggle(remoteSectionKey) + }} + > + + {t("remoteBranches", { count: branchList.remote.length })} + + {isExpanded(remoteSectionKey) && + (branchList.remote.length === 0 ? ( + + {t("noRemoteBranches")} + + ) : ( + remoteSections.map((section) => + section.remoteName == null ? ( + + {renderDropdownTree(section.nodes, 0, true)} + + ) : ( + + { + e.preventDefault() + toggle(section.key) + }} + style={{ + paddingLeft: branchRowPaddingLeft("dropdown", 0), + }} + > + + + {section.remoteName} + + + {section.count} + + + {isExpanded(section.key) && + renderDropdownTree(section.nodes, 1, true)} + + ) ) - ) - ))} - - )} -
-
+ ))} + + )} + + + {/* Quick-pull button — the right half of the split, mirroring the + command block. Its own hover highlights just this icon; the shared + container tints the whole pill and the divider brightens. */} + {isStatusBar && ( + <> + + + + )} +
setManageOpen(true)} disabled={bootstrapping} > @@ -255,14 +256,23 @@ export function CommandDropdown() { {bootstrapping ? t("loading") : t("addCommand")} ) : ( - // Has commands → split button: [name ▼] [run/stop] -
+ // Has commands → one cohesive control: [name ▼ | run/stop]. The whole + // pill highlights as a SINGLE unit on hover (background overlay on the + // group container, not on each half), so the two affordances read as one + // command block rather than two adjacent buttons. `bg-foreground/10` is a + // translucent overlay on purpose: the status-bar surface is already + // `--muted`, so a `bg-muted`/`bg-accent` hover would be invisible against + // it in light mode. +
- + {commands.map((cmd) => ( @@ -285,27 +295,34 @@ export function CommandDropdown() { - +
)} diff --git a/src/components/layout/folder-title-bar.tsx b/src/components/layout/folder-title-bar.tsx index 850001eb5..3d87ce500 100644 --- a/src/components/layout/folder-title-bar.tsx +++ b/src/components/layout/folder-title-bar.tsx @@ -2,48 +2,57 @@ import { useCallback } from "react" import { - EllipsisVertical, Menu, PanelRight, Settings, + SquarePen, SquareTerminal, } from "lucide-react" import { useTranslations } from "next-intl" import { openSettingsWindow } from "@/lib/api" +import { isDesktop } from "@/lib/platform" import { useActiveFolder } from "@/contexts/active-folder-context" import { useIsActiveChatMode } from "@/hooks/use-is-active-chat-mode" +import { usePlatform } from "@/hooks/use-platform" import { Button } from "@/components/ui/button" import { useSidebarContext } from "@/contexts/sidebar-context" import { useAuxPanelContext } from "@/contexts/aux-panel-context" import { useTerminalContext } from "@/contexts/terminal-context" -import { AppTitleBar } from "./app-title-bar" -import { NewFolderDropdown } from "./new-folder-dropdown" -import { RemoteWorkspaceDropdown } from "./remote-workspace-dropdown" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" +import { useTabActions } from "@/contexts/tab-context" +import { useWorkbenchRoute } from "@/contexts/workbench-route-context" +import { MAC_TRAFFIC_LIGHT_INSET } from "@/lib/window-chrome" +import { cn } from "@/lib/utils" +import { WindowControls } from "./window-controls" /** - * Mobile-only workspace title bar. + * Mobile-only workspace title bar (`h-10`, matching the desktop column strip). * * On desktop the full-width title bar was removed: its buttons were relocated - * into per-column edge clusters (`LeftEdgeChrome` / `RightEdgeChrome`) so the - * four columns' divider lines run unbroken from the top, and its global - * shortcuts + search/directory dialogs moved to `WorkspaceChromeController`. - * This component is mounted only on the mobile path (`FolderLayoutShell`), where - * the sidebar / aux / terminal are `Sheet` overlays that still need a compact - * bar to summon them. + * into fixed corner overlays (`LeftEdgeChrome` / `RightEdgeChrome`) and its + * global shortcuts + dialogs moved to `WorkspaceChromeController`. This bar is + * mounted only on the mobile path (`FolderLayoutShell`), where the sidebar / aux + * / terminal are `Sheet` overlays that need a compact bar to summon them. + * + * It mirrors the desktop chrome directly (rather than via `AppTitleBar`): the + * left holds the sidebar toggle + a new-conversation shortcut; the right holds + * the same terminal / aux / settings cluster as `RightEdgeChrome` (active + * `bg-accent`, same disabled predicates). The empty middle is a full-height + * `data-tauri-drag-region` filler so the window drags by it — plus a macOS + * traffic-light inset and the Windows/Linux caption buttons (`WindowControls` + * self-nulls elsewhere), exactly like the desktop edges. */ export function FolderTitleBar() { const tTitleBar = useTranslations("Folder.folderTitleBar") - const { toggle } = useSidebarContext() - const { toggle: toggleAuxPanel } = useAuxPanelContext() - const { toggle: toggleTerminal } = useTerminalContext() + const tCard = useTranslations("Folder.conversationCard") + const { isOpen: sidebarOpen, toggle } = useSidebarContext() + const { isOpen: auxPanelOpen, toggle: toggleAuxPanel } = useAuxPanelContext() + const { isOpen: terminalOpen, toggle: toggleTerminal } = useTerminalContext() const { activeFolder } = useActiveFolder() const isChatMode = useIsActiveChatMode() + const { openNewConversationTab, openChatModeTab } = useTabActions() + const { openConversations } = useWorkbenchRoute() + const { isMac } = usePlatform() + const showMacInset = isMac && isDesktop() const handleOpenSettings = useCallback(() => { openSettingsWindow().catch((err) => { @@ -51,57 +60,92 @@ export function FolderTitleBar() { }) }, []) + // Mirror the sidebar's "New chat": return to the conversation workspace, then + // start a new conversation in the active folder — or folderless chat mode when + // there's none, so this entry point is never a dead end. + const handleNewConversation = useCallback(() => { + openConversations() + if (!activeFolder) { + openChatModeTab() + return + } + openNewConversationTab(activeFolder.id, activeFolder.path) + }, [activeFolder, openChatModeTab, openNewConversationTab, openConversations]) + return ( - - - - -
- } - right={ -
- {/* Search lives in the left sidebar's fixed actions region; the ⌘K - shortcut + SearchCommandDialog live in WorkspaceChromeController. */} - - - - - - {/* The aux panel hosts the Session Details tab, so it's reachable - in chat mode too. */} - - - {tTitleBar("toggleAuxPanel")} - - toggleTerminal()} - disabled={!activeFolder} - > - - {tTitleBar("toggleTerminal")} - - - - {tTitleBar("openSettings")} - - - -
- } - /> +
+ {/* macOS traffic-light inset — a window-drag region so the left cluster + clears the OS-drawn lights. */} + {showMacInset && ( +
+ )} + {/* Left cluster: sidebar toggle + new conversation. */} +
+ + +
+ {/* Empty middle is a full-height window-drag region. */} +
+ {/* Right cluster: terminal + aux + settings — the same controls the + desktop RightEdgeChrome shows, now as direct buttons (no ⋯ menu). */} +
+ + + +
+ {/* Windows/Linux caption buttons; self-nulls on macOS / web. */} + +
) } diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 3866705c2..75a0560f3 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -188,6 +188,10 @@ export function Sidebar() { }, [allExpanded]) const handleNewConversation = useCallback(() => { + // On mobile the sidebar is a Sheet overlay — close it so the new + // conversation is visible (mirrors tapping a conversation card, which the + // list wrapper already closes on). + if (isMobile) toggle() // Starting a conversation always returns to the conversation workspace (in // case a route like Automations was taking over the content region). openConversations() @@ -199,7 +203,14 @@ export function Sidebar() { return } openNewConversationTab(activeFolder.id, activeFolder.path) - }, [activeFolder, openChatModeTab, openNewConversationTab, openConversations]) + }, [ + activeFolder, + openChatModeTab, + openNewConversationTab, + openConversations, + isMobile, + toggle, + ]) if (!isOpen) return null @@ -209,14 +220,17 @@ export function Sidebar() { className={cn( "flex h-10 shrink-0 items-center gap-2 pr-2", // Desktop: the fixed left window-chrome overlay (reserved below) owns - // the top-left, so drop the header's own left padding. A subtle bottom - // divider (border-border/50) matches the conversation detail header's - // bottom border so the two align as one bar across the app's top edge. - // Mobile (Sheet): keep the original title padding + a full-strength - // divider — mobile is unchanged. + // the top-left, so drop the header's own left padding. Off-image the + // divider is border-border/50, matching the conversation / file detail + // headers. But the sidebar sits on a FROSTED surface (ws-surface-sidebar) + // while those headers sit on the transparent canvas: with a workspace + // background image on, a border-border/50 hairline washes out against the + // frosted shade, so it takes the boosted `ws-chrome-border` (like the + // frosted status bar) to stay legible. Mobile (Sheet): keep the original + // title padding + a full-strength divider — mobile is unchanged. isMobile ? "border-b border-border pl-4" - : "border-b border-border/50 pl-0" + : "border-b border-border/50 ws-chrome-border pl-0" )} > {isMobile ? ( diff --git a/src/components/layout/status-bar-session-info.tsx b/src/components/layout/status-bar-session-info.tsx deleted file mode 100644 index a37aeac61..000000000 --- a/src/components/layout/status-bar-session-info.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client" - -import { useMemo } from "react" -import { GitBranch } from "lucide-react" -import { useTabStore } from "@/contexts/tab-context" -import { useAppWorkspaceStore } from "@/stores/app-workspace-store" - -export function StatusBarSessionInfo() { - const tabs = useTabStore((s) => s.tabs) - const activeTabId = useTabStore((s) => s.activeTabId) - - const activeTab = useMemo( - () => tabs.find((t) => t.id === activeTabId) ?? null, - [tabs, activeTabId] - ) - - // Selecting the matching summary (not the whole list) keeps this component - // inert to unrelated conversation updates: `find` returns the same object - // reference until this conversation itself changes. - const summary = useAppWorkspaceStore((s) => { - if (!activeTab || activeTab.kind !== "conversation") return null - return ( - s.conversations.find( - (c) => - c.id === activeTab.conversationId && - c.agent_type === activeTab.agentType - ) ?? null - ) - }) - - if (!summary) return null - - return ( -
- {summary.git_branch && ( - - - {summary.git_branch} - - )} -
- ) -} diff --git a/src/components/layout/status-bar.tsx b/src/components/layout/status-bar.tsx index 3622b4eb5..916c31014 100644 --- a/src/components/layout/status-bar.tsx +++ b/src/components/layout/status-bar.tsx @@ -1,24 +1,31 @@ "use client" import { StatusBarStats } from "@/components/layout/status-bar-stats" -import { StatusBarSessionInfo } from "@/components/layout/status-bar-session-info" import { StatusBarTasks } from "@/components/layout/status-bar-tasks" 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" 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. return ( -
- +
+
+ +
- - + +
@@ -26,14 +33,20 @@ export function StatusBar() { } return ( -
-
+
+
+ {/* 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. */} +
- + {/* Command launcher (moved from the aux "session details" tab), taking + the slot the old static branch label (StatusBarSessionInfo) held. */} + diff --git a/src/components/layout/workspace-chrome-controller-source.test.ts b/src/components/layout/workspace-chrome-controller-source.test.ts new file mode 100644 index 000000000..79b53e664 --- /dev/null +++ b/src/components/layout/workspace-chrome-controller-source.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +const controllerSource = readFileSync( + resolve( + process.cwd(), + "src/components/layout/workspace-chrome-controller.tsx" + ), + "utf8" +) + +const tabBarSource = readFileSync( + resolve(process.cwd(), "src/components/tabs/tab-bar.tsx"), + "utf8" +) + +const fileTabBarSource = readFileSync( + resolve(process.cwd(), "src/components/files/file-workspace-tab-bar.tsx"), + "utf8" +) + +describe("tab close/navigation shortcuts live in the always-mounted controller", () => { + // Regression guard. mod+w / mod+tab / mod+shift+tab / close-all-file-tabs + // used to be registered by a `window` keydown listener INSIDE the visible tab + // strips (TabBar, FileWorkspaceTabBar). The mobile workspace no longer mounts + // those strips (it shows the folder-title header instead), and the file strip + // is desktop-only anyway — so listeners living there would silently die below + // the 768px breakpoint. Worse, with the listener gone, mod+w falls through to + // the native OS default and closes the whole Tauri/browser window instead of + // the active tab. The shortcuts must therefore live in the headless, + // always-mounted WorkspaceChromeController (mounted on BOTH platforms). + it("registers the tab shortcuts in WorkspaceChromeController", () => { + expect(controllerSource).toMatch(/shortcuts\.next_tab/) + expect(controllerSource).toMatch(/shortcuts\.prev_tab/) + expect(controllerSource).toMatch(/shortcuts\.close_current_tab/) + expect(controllerSource).toMatch(/shortcuts\.close_all_file_tabs/) + // ...and actually drives the tab / file-tab actions. The e.preventDefault() + // calls next to these are what stop mod+w reaching the window-close default. + expect(controllerSource).toMatch(/switchTab\(/) + expect(controllerSource).toMatch(/closeTab\(/) + expect(controllerSource).toMatch(/closeFileTab\(/) + expect(controllerSource).toMatch(/closeAllFileTabs\(/) + }) + + it("removes the keydown shortcut listeners from both tab strips", () => { + // The strips are conditionally mounted (desktop only; the file strip only + // when a file tab is open), so they must not own any global shortcut. + expect(tabBarSource).not.toContain('addEventListener("keydown"') + expect(tabBarSource).not.toContain("matchShortcutEvent") + expect(fileTabBarSource).not.toContain('addEventListener("keydown"') + expect(fileTabBarSource).not.toContain("matchShortcutEvent") + }) +}) diff --git a/src/components/layout/workspace-chrome-controller.tsx b/src/components/layout/workspace-chrome-controller.tsx index 0cbc585b0..98664a7ab 100644 --- a/src/components/layout/workspace-chrome-controller.tsx +++ b/src/components/layout/workspace-chrome-controller.tsx @@ -10,7 +10,12 @@ import { getActiveRemoteConnectionId } from "@/lib/transport" import { useSidebarContext } from "@/contexts/sidebar-context" import { useAuxPanelContext } from "@/contexts/aux-panel-context" import { useTerminalContext } from "@/contexts/terminal-context" -import { useTabActions } from "@/contexts/tab-context" +import { useTabActions, useTabStore } from "@/contexts/tab-context" +import { + useWorkspaceActions, + useWorkspaceFileTabs, + useWorkspaceView, +} from "@/contexts/workspace-context" import { useWorkbenchRoute } from "@/contexts/workbench-route-context" import { useSearchDialog } from "@/contexts/search-dialog-context" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" @@ -33,7 +38,15 @@ export function WorkspaceChromeController() { const { toggle } = useSidebarContext() const { toggle: toggleAuxPanel } = useAuxPanelContext() const { toggle: toggleTerminal } = useTerminalContext() - const { openNewConversationTab } = useTabActions() + const { openNewConversationTab, switchTab, closeTab } = useTabActions() + const tabs = useTabStore((s) => s.tabs) + const activeTabId = useTabStore((s) => s.activeTabId) + // Tab-close/navigation shortcuts used to live in the visible tab strips. + // Mobile no longer mounts those strips, so this always-mounted controller now + // owns them too (see the keydown handler below). + const { mode, activePane, filesMaximized } = useWorkspaceView() + const { activeFileTabId } = useWorkspaceFileTabs() + const { closeFileTab, closeAllFileTabs } = useWorkspaceActions() const { openConversations } = useWorkbenchRoute() const { shortcuts } = useShortcutSettings() // Search open-state is shared (see search-dialog-context): the trigger lives @@ -111,6 +124,51 @@ export function WorkspaceChromeController() { if (matchShortcutEvent(e, shortcuts.open_settings)) { e.preventDefault() handleOpenSettings() + return + } + + // Tab navigation + close. These once lived in the visible tab strips, + // which mobile no longer mounts; owning them here keeps mod+w / mod+tab / + // mod+shift+tab working at every width — and, crucially, keeps + // preventDefault firing so mod+w never falls through to closing the OS + // window. Routing mirrors the old split: conversation pane vs files pane. + const conversationPaneActive = + mode === "conversation" || + (mode === "fusion" && activePane === "conversation" && !filesMaximized) + const filesPaneActive = + mode === "fusion" && (activePane === "files" || filesMaximized) + + const isNextTab = matchShortcutEvent(e, shortcuts.next_tab) + const isPrevTab = matchShortcutEvent(e, shortcuts.prev_tab) + if (isNextTab || isPrevTab) { + if (!conversationPaneActive) return + if (tabs.length < 2 || !activeTabId) return + const currentIndex = tabs.findIndex((tab) => tab.id === activeTabId) + if (currentIndex === -1) return + e.preventDefault() + const offset = isNextTab ? 1 : -1 + const nextIndex = (currentIndex + offset + tabs.length) % tabs.length + switchTab(tabs[nextIndex].id) + return + } + + if (matchShortcutEvent(e, shortcuts.close_all_file_tabs)) { + if (!filesPaneActive) return + e.preventDefault() + closeAllFileTabs() + return + } + + if (matchShortcutEvent(e, shortcuts.close_current_tab)) { + if (conversationPaneActive) { + if (!activeTabId) return + e.preventDefault() + closeTab(activeTabId) + } else if (filesPaneActive) { + if (!activeFileTabId) return + e.preventDefault() + closeFileTab(activeFileTabId) + } } } document.addEventListener("keydown", handleKeyDown) @@ -127,6 +185,16 @@ export function WorkspaceChromeController() { toggleAuxPanel, toggleTerminal, isChatMode, + tabs, + activeTabId, + switchTab, + closeTab, + mode, + activePane, + filesMaximized, + activeFileTabId, + closeFileTab, + closeAllFileTabs, ]) return ( diff --git a/src/components/message/collapsible-user-message.test.tsx b/src/components/message/collapsible-user-message.test.tsx new file mode 100644 index 000000000..b7266e935 --- /dev/null +++ b/src/components/message/collapsible-user-message.test.tsx @@ -0,0 +1,114 @@ +import { type ReactElement } from "react" +import { fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, describe, expect, it } from "vitest" + +import { CollapsibleUserMessage } from "./collapsible-user-message" +import enMessages from "@/i18n/messages/en.json" +import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter" + +function renderWithIntl(ui: ReactElement) { + return render( + + {ui} + + ) +} + +const TEXT_PART: AdaptedContentPart[] = [{ type: "text", text: "hello world" }] + +// jsdom does no layout: scrollHeight/clientHeight (defined on Element, per +// Element-impl.js) both default to 0, which already reads as "not +// overflowing" for the short-content case below. The overflow cases patch +// both onto Element.prototype *before* rendering, so the component's +// synchronous mount-time measurement — it doesn't wait on a ResizeObserver +// callback, since the global jsdom stub in test-setup.ts never invokes one — +// picks up the mocked heights on its first read. +function mockScrollMetrics(scrollHeight: number, clientHeight: number) { + const scrollHeightDescriptor = Object.getOwnPropertyDescriptor( + Element.prototype, + "scrollHeight" + ) + const clientHeightDescriptor = Object.getOwnPropertyDescriptor( + Element.prototype, + "clientHeight" + ) + Object.defineProperty(Element.prototype, "scrollHeight", { + configurable: true, + get: () => scrollHeight, + }) + Object.defineProperty(Element.prototype, "clientHeight", { + configurable: true, + get: () => clientHeight, + }) + return () => { + if (scrollHeightDescriptor) { + Object.defineProperty( + Element.prototype, + "scrollHeight", + scrollHeightDescriptor + ) + } + if (clientHeightDescriptor) { + Object.defineProperty( + Element.prototype, + "clientHeight", + clientHeightDescriptor + ) + } + } +} + +describe("CollapsibleUserMessage", () => { + let restoreMetrics: (() => void) | null = null + + afterEach(() => { + restoreMetrics?.() + restoreMetrics = null + }) + + it("renders short content with no toggle", () => { + renderWithIntl() + + expect(screen.getByText("hello world")).toBeInTheDocument() + expect( + screen.queryByTestId("collapsible-user-message-toggle") + ).not.toBeInTheDocument() + expect( + screen.getByTestId("collapsible-user-message-content") + ).not.toHaveClass("collapsed-user-message-fade") + }) + + it("shows a Show more toggle when content overflows the collapsed height", () => { + restoreMetrics = mockScrollMetrics(600, 240) + + renderWithIntl() + + const content = screen.getByTestId("collapsible-user-message-content") + const toggle = screen.getByTestId("collapsible-user-message-toggle") + expect(toggle).toHaveAttribute("aria-expanded", "false") + expect(toggle).toHaveTextContent("Show more") + expect(toggle).toHaveAttribute("aria-controls", content.id) + expect(content).toHaveClass("max-h-60", "collapsed-user-message-fade") + }) + + it("expands to Show less on click and removes the clamp", () => { + restoreMetrics = mockScrollMetrics(600, 240) + + renderWithIntl() + + fireEvent.click(screen.getByTestId("collapsible-user-message-toggle")) + + const toggle = screen.getByTestId("collapsible-user-message-toggle") + expect(toggle).toHaveAttribute("aria-expanded", "true") + expect(toggle).toHaveTextContent("Show less") + const content = screen.getByTestId("collapsible-user-message-content") + expect(content).not.toHaveClass("max-h-60") + expect(content).not.toHaveClass("collapsed-user-message-fade") + + // Clicking again re-collapses. + fireEvent.click(toggle) + expect(toggle).toHaveAttribute("aria-expanded", "false") + expect(toggle).toHaveTextContent("Show more") + }) +}) diff --git a/src/components/message/collapsible-user-message.tsx b/src/components/message/collapsible-user-message.tsx new file mode 100644 index 000000000..28a73a380 --- /dev/null +++ b/src/components/message/collapsible-user-message.tsx @@ -0,0 +1,86 @@ +"use client" + +import { memo, useEffect, useId, useRef, useState } from "react" +import { useTranslations } from "next-intl" +import { ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" +import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter" + +import { ContentPartsRenderer } from "./content-parts-renderer" + +/** + * Caps a user message's rendered height (mirrors Codex desktop) and reveals a + * "Show more"/"Show less" toggle once it's actually clipped. Assistant + * messages are out of scope by design — this is only ever used from the + * `group.role === "user"` branch in `HistoricalMessageGroup`, so `role` isn't + * a prop here. + */ +export const CollapsibleUserMessage = memo(function CollapsibleUserMessage({ + parts, +}: { + parts: AdaptedContentPart[] +}) { + const t = useTranslations("Folder.chat.messageList") + const contentRef = useRef(null) + const [isOverflowing, setIsOverflowing] = useState(false) + const [expanded, setExpanded] = useState(false) + const contentId = useId() + + useEffect(() => { + // Nothing useful to redetect once expanded: the clamp class below is + // removed, so clientHeight === scrollHeight trivially and this would + // misreport `false`, dropping the "Show less" toggle. Freeze the last + // known value instead. + if (expanded) return + const el = contentRef.current + if (!el) return + const measure = () => { + // Both reads are on this same, currently `max-h-60`-clamped node: no + // numeric threshold duplicated from CSS. clientHeight is capped by the + // class below; scrollHeight always reports the untruncated height. + setIsOverflowing(el.scrollHeight > el.clientHeight + 1) + } + measure() // Synchronous initial read — doesn't depend on the + // ResizeObserver callback firing (the jsdom test stub never invokes it). + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => observer.disconnect() + }, [parts, expanded]) + + const clipped = !expanded + + return ( + <> +
+ +
+ {isOverflowing && ( + + )} + + ) +}) diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 2e1095718..aa373af29 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -6,6 +6,7 @@ import { useConversationRuntimeStore, } from "@/stores/conversation-runtime-store" import { ContentPartsRenderer } from "./content-parts-renderer" +import { CollapsibleUserMessage } from "./collapsible-user-message" import { createMessageTurnAdapter, groupGoalRuns, @@ -519,7 +520,7 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
- +
) : ( diff --git a/src/components/tabs/tab-bar.tsx b/src/components/tabs/tab-bar.tsx index f159baadc..311e032da 100644 --- a/src/components/tabs/tab-bar.tsx +++ b/src/components/tabs/tab-bar.tsx @@ -8,15 +8,14 @@ import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useActiveFolder } from "@/contexts/active-folder-context" import { useTabActions, useTabStore } from "@/contexts/tab-context" import type { TabItem as TabItemData } from "@/contexts/tab-context" -import { useWorkspaceView } from "@/contexts/workspace-context" import { useWorkbenchRoute } from "@/contexts/workbench-route-context" import { useIsCoarsePointer } from "@/hooks/use-is-coarse-pointer" -import { useShortcutSettings } from "@/hooks/use-shortcut-settings" -import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" import { TabItem } from "./tab-item" -import { cn } from "@/lib/utils" -export function TabBar({ embedded = false }: { embedded?: boolean } = {}) { +// Rendered only inside the desktop conversation-column title strip (embedded). +// The old standalone mobile variant is gone — mobile shows the conversation +// detail header instead and navigates tabs from the sidebar. +export function TabBar() { const t = useTranslations("Folder.conversationCard") const tabs = useTabStore((s) => s.tabs) const activeTabId = useTabStore((s) => s.activeTabId) @@ -36,7 +35,6 @@ export function TabBar({ embedded = false }: { embedded?: boolean } = {}) { const branches = useAppWorkspaceStore((s) => s.branches) const { activeFolder } = useActiveFolder() const { openConversations } = useWorkbenchRoute() - const { mode, activePane, filesMaximized } = useWorkspaceView() // New-conversation affordance at the end of the tab strip. Mirrors the // sidebar's "New chat": return to the conversation workspace, then open a @@ -57,71 +55,18 @@ export function TabBar({ embedded = false }: { embedded?: boolean } = {}) { return map }, [allFolders]) - const { shortcuts } = useShortcutSettings() const scrollRef = useRef(null) const isCoarsePointer = useIsCoarsePointer() - const [isHovered, setIsHovered] = useState(false) const [touchSortingTabId, setTouchSortingTabId] = useState( null ) - const handleWheel = useCallback((e: React.WheelEvent) => { - if (e.deltaY !== 0 && scrollRef.current) { - e.preventDefault() - scrollRef.current.scrollLeft += e.deltaY - } - }, []) - useEffect(() => { if (!activeTabId || !scrollRef.current) return const el = scrollRef.current.querySelector(`[data-tab-id="${activeTabId}"]`) el?.scrollIntoView({ block: "nearest", inline: "nearest" }) }, [activeTabId]) - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - const shouldHandleShortcut = - mode === "conversation" || - (mode === "fusion" && activePane === "conversation" && !filesMaximized) - if (!shouldHandleShortcut) return - const isNextTab = matchShortcutEvent(event, shortcuts.next_tab) - const isPrevTab = matchShortcutEvent(event, shortcuts.prev_tab) - if (isNextTab || isPrevTab) { - if (tabs.length < 2 || !activeTabId) return - const currentIndex = tabs.findIndex((tab) => tab.id === activeTabId) - if (currentIndex === -1) return - - event.preventDefault() - const offset = isNextTab ? 1 : -1 - const nextIndex = (currentIndex + offset + tabs.length) % tabs.length - switchTab(tabs[nextIndex].id) - return - } - - if (!matchShortcutEvent(event, shortcuts.close_current_tab)) return - if (!activeTabId) return - - event.preventDefault() - closeTab(activeTabId) - } - - window.addEventListener("keydown", onKeyDown) - return () => { - window.removeEventListener("keydown", onKeyDown) - } - }, [ - activePane, - activeTabId, - closeTab, - filesMaximized, - mode, - shortcuts.close_current_tab, - shortcuts.next_tab, - shortcuts.prev_tab, - switchTab, - tabs, - ]) - const handleReorder = useCallback( (nextTabs: TabItemData[]) => { if (isCoarsePointer && !touchSortingTabId) return @@ -144,7 +89,7 @@ export function TabBar({ embedded = false }: { embedded?: boolean } = {}) { // doesn't leave a stray line poking out from under it (globals.css). const lastTabActive = activeIndex >= 0 && activeIndex === tabs.length - 1 - const group = ( + return ( setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - className={cn( - // Horizontal padding is set per-branch below (not shared here), because - // the two modes need different gutters. - "pt-1.5 flex items-stretch", - // Embedded in the title bar: fill its height, no scrollbar — the tabs - // shrink browser-style to share the row (see TabItem `embedded`), sit - // flush (`gap-0`) so their hairline separators read as dividers, and the - // strip owns no bottom border. No bottom padding, so the tabs reach the - // strip's bottom edge and the active (white) one merges into the detail - // header below. It fills the row (`flex-1`) and hosts the trailing - // new-conversation button + drag spacer as its own last children, so the - // tabs, button, and spacer all size in ONE flex line: the tabs keep their - // equal `basis-48` width until the row fills, then shrink together, and the - // button always hugs the last tab. (A nested content-sized group instead - // let the engine resolve its `flex-basis:auto` to min-content — starving - // the tabs to their label width and detaching the button from them.) - // `pl-2` only (NOT `px-2`): the first tab keeps its 0.5rem left gutter for - // the first-child seam-patch, but there is NO right padding — so the trailing - // wrapper's `ws-strip-line` reaches the group's right edge and the bottom - // hairline stays continuous into the right reserve. (A right `px-2` left an - // 8px hole in the hairline there, since the wrapper — not a tab — is the - // group's last child and gets no last-child seam-patch.) - // Standalone (mobile panel row): keep the h-10 row + border + horizontal - // scroll with a hover scrollbar and the original inter-tab gap. - embedded - ? "h-full min-w-0 flex-1 gap-0 overflow-hidden pl-2" - : [ - "h-10 gap-1.5 px-1.5 border-b border-border overflow-x-scroll", - isHovered - ? [ - "pb-0.5", - "[&::-webkit-scrollbar]:h-1", - "[&::-webkit-scrollbar-track]:bg-transparent", - "[&::-webkit-scrollbar-thumb]:rounded-full", - "[&::-webkit-scrollbar-thumb]:bg-border", - ] - : ["pb-1.5", "[&::-webkit-scrollbar]:h-0"], - ] - )} + // Fills the title-bar strip and shrinks browser-style to share the row (see + // TabItem): flush (`gap-0`) so hairline separators read as dividers, no + // scrollbar (`overflow-hidden` still scrolls programmatically), and no + // bottom border so the active (white) tab merges into the detail header + // below. It hosts the trailing new-conversation button + drag spacer as its + // own last children so the tabs, button, and spacer size in ONE flex line: + // the tabs keep their equal `basis-48` width until the row fills, then + // shrink together, and the button always hugs the last tab. `pl-2` only + // (NOT `px-2`): the first tab keeps its left gutter for the first-child + // seam-patch, but there's NO right padding so the trailing wrapper's + // `ws-strip-line` reaches the group's right edge and the bottom hairline + // stays continuous into the right reserve. + className="pt-1.5 flex h-full min-w-0 flex-1 items-stretch gap-0 overflow-hidden pl-2" > {tabs.map((tab, index) => { const folderInfo = folderIndex.get(tab.folderId) @@ -216,7 +130,7 @@ export function TabBar({ embedded = false }: { embedded?: boolean } = {}) { tab={tab} isActive={tab.id === activeTabId} isTileMode={isTileMode} - embedded={embedded} + embedded adjacentActive={adjacentActive} folderName={folderInfo?.name ?? null} folderBranch={branches.get(tab.folderId) ?? null} @@ -233,57 +147,57 @@ export function TabBar({ embedded = false }: { embedded?: boolean } = {}) { /> ) })} - {/* Title-bar strip only: the new-conversation button + drag spacer are the - Reorder.Group's own trailing children, so they share the tabs' flex line - — the button hugs the last tab and the spacer fills the leftover row as a - window-drag region. They are not Reorder.Items, so dragging a tab only - ever permutes the tabs (verified: reordering is unaffected). Wrapped in - one `flex-1` `ws-strip-line` box so the workspace-bg bottom hairline runs - unbroken under both — the short `self-center h-7` button can't carry the - line itself. NO `min-w-0`: its min-content (the shrink-0 button + the - spacer's `min-w-10`) is its floor, so under many-tab overflow the tabs - shrink to reserve it instead of it collapsing to 0 and clipping the - button. Off (no bg image): ws-strip-line is inert. */} - {embedded && ( -
+ - {/* Drag spacer, floored at `min-w-10` (40px) instead of `min-w-0`: even - when many tabs overflow and squeeze this region, a grabbable - window-drag gap always remains to the RIGHT of the new-conversation - button, so the button never reaches the strip's right edge and the - packed title bar stays draggable. */} -
-
- )} + + + {/* Drag spacer, floored at `min-w-10` (40px) instead of `min-w-0`: even + when many tabs overflow and squeeze this region, a grabbable + window-drag gap always remains to the RIGHT of the new-conversation + button, so the button never reaches the strip's right edge and the + packed title bar stays draggable. */} +
+
) - - return group } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index a968120fa..d00a6cefc 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "كتابة التخزين المؤقت", "duration": "المدة", "completedAt": "وقت الإنجاز", - "jumpToPreviousUserMessage": "الانتقال إلى رسالة المستخدم" + "jumpToPreviousUserMessage": "الانتقال إلى رسالة المستخدم", + "showMore": "عرض المزيد", + "showLess": "طي" }, "liveTurnStats": { "thinking": "جارٍ التفكير...", @@ -2786,7 +2788,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "وضع بدون مجلد", + "chatModeLabel": "وضع الدردشة", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index bd637bfeb..e926ed601 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "Cache-Schreiben", "duration": "Dauer", "completedAt": "Abgeschlossen um", - "jumpToPreviousUserMessage": "Zur Benutzernachricht springen" + "jumpToPreviousUserMessage": "Zur Benutzernachricht springen", + "showMore": "Mehr anzeigen", + "showLess": "Weniger anzeigen" }, "liveTurnStats": { "thinking": "Denkt nach...", @@ -2786,7 +2788,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "Ohne-Ordner-Modus", + "chatModeLabel": "Chat-Modus", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 501648bb2..359b54268 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "Cache write", "duration": "Duration", "completedAt": "Completed at", - "jumpToPreviousUserMessage": "Jump to user message" + "jumpToPreviousUserMessage": "Jump to user message", + "showMore": "Show more", + "showLess": "Show less" }, "liveTurnStats": { "thinking": "Thinking...", @@ -2786,7 +2788,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "No-folder mode", + "chatModeLabel": "Chat mode", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 0b2a582b1..56eddbce5 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "Escritura de caché", "duration": "Duración", "completedAt": "Completado a las", - "jumpToPreviousUserMessage": "Ir al mensaje del usuario" + "jumpToPreviousUserMessage": "Ir al mensaje del usuario", + "showMore": "Mostrar más", + "showLess": "Mostrar menos" }, "liveTurnStats": { "thinking": "Pensando...", @@ -2786,7 +2788,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "Modo sin carpeta", + "chatModeLabel": "Modo de chat", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index bda8f4747..5e888e7d6 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "Écriture du cache", "duration": "Durée", "completedAt": "Terminé à", - "jumpToPreviousUserMessage": "Aller au message utilisateur" + "jumpToPreviousUserMessage": "Aller au message utilisateur", + "showMore": "Afficher plus", + "showLess": "Afficher moins" }, "liveTurnStats": { "thinking": "Réflexion...", @@ -2786,7 +2788,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "Mode sans dossier", + "chatModeLabel": "Mode chat", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 67ce8f71a..d4e820fb0 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "キャッシュ書込", "duration": "所要時間", "completedAt": "完了時刻", - "jumpToPreviousUserMessage": "前のユーザーメッセージへ" + "jumpToPreviousUserMessage": "前のユーザーメッセージへ", + "showMore": "もっと見る", + "showLess": "折りたたむ" }, "liveTurnStats": { "thinking": "考え中...", @@ -2786,7 +2788,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "フォルダーなしモード", + "chatModeLabel": "チャットモード", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 55ea3abc7..120eed32e 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "캐시 쓰기", "duration": "소요 시간", "completedAt": "완료 시각", - "jumpToPreviousUserMessage": "이전 사용자 메시지로 이동" + "jumpToPreviousUserMessage": "이전 사용자 메시지로 이동", + "showMore": "더보기", + "showLess": "접기" }, "liveTurnStats": { "thinking": "생각 중...", @@ -2786,7 +2788,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "폴더 없음 모드", + "chatModeLabel": "채팅 모드", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index e83807984..20380762b 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "Escrita de cache", "duration": "Duração", "completedAt": "Concluído às", - "jumpToPreviousUserMessage": "Ir para a mensagem do usuário" + "jumpToPreviousUserMessage": "Ir para a mensagem do usuário", + "showMore": "Mostrar mais", + "showLess": "Mostrar menos" }, "liveTurnStats": { "thinking": "Pensando...", @@ -2786,7 +2788,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "Modo sem pasta", + "chatModeLabel": "Modo de chat", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 909927e91..341b37d93 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "缓存写入", "duration": "耗时", "completedAt": "完成时间", - "jumpToPreviousUserMessage": "跳转到上一条用户消息" + "jumpToPreviousUserMessage": "跳转到上一条用户消息", + "showMore": "展开", + "showLess": "收起" }, "liveTurnStats": { "thinking": "思考中...", @@ -2786,7 +2788,7 @@ "noFolders": "暂无文件夹", "noBranches": "暂无分支", "noBranch": "(无分支)", - "chatModeLabel": "无文件夹模式", + "chatModeLabel": "聊天模式", "commit": "提交", "push": "推送", "merge": "合并", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index a269244ac..53bee8190 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2474,7 +2474,9 @@ "tokenCacheWrite": "快取寫入", "duration": "耗時", "completedAt": "完成時間", - "jumpToPreviousUserMessage": "跳轉到上一條使用者訊息" + "jumpToPreviousUserMessage": "跳轉到上一條使用者訊息", + "showMore": "展開", + "showLess": "收合" }, "liveTurnStats": { "thinking": "思考中...", @@ -2786,7 +2788,7 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", - "chatModeLabel": "無資料夾模式", + "chatModeLabel": "聊天模式", "commit": "Commit", "push": "Push", "merge": "Merge", diff --git a/src/lib/resource-kind.test.ts b/src/lib/resource-kind.test.ts index 6d29eda48..c0c21379b 100644 --- a/src/lib/resource-kind.test.ts +++ b/src/lib/resource-kind.test.ts @@ -13,6 +13,10 @@ describe("classifyResourceKind", () => { ["C:\\Users\\a\\notes.txt", "file"], ["C:/Users/a/notes.txt", "file"], ["d:\\repo\\src\\main.rs", "file"], + // Sanitize-safe drive form emitted by remark-file-uri-links (leading slash + // survives rehype-sanitize; downstream strips it before opening). + ["/C:/Users/a/notes.txt", "file"], + ["/E:/桌面/手册.docx", "file"], // Backslash UNC (the form remark-file-uri-links emits for a UNC // file:// link) — local file, distinct from forward-slash // (web). ["\\\\server\\share\\doc.md", "file"],