From f9cd16b7a790683be837c982455548b2356388bc Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 16 Sep 2026 20:44:27 +0800 Subject: [PATCH 1/6] Tint sidebar thread rows with their provider's color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every provider now has a color: built-in profiles carry a hand-picked brand color and every user-added provider (custom profiles, ACP agents) takes a slot from a fixed palette. Slots come from an FNV-1a hash of the thread's provider color key, with known custom providers walked in sorted order and bumped past collisions, so colors are deterministic across devices without persisted state and configured providers never share a color while the palette has room. With the new default-on "Provider colors" switch (Settings → General → Appearance, stored inverted as `provider_colors_disabled`), wide thread rows use the provider hue at three alpha steps (rest 0.08, hover 0.16, active 0.26) and compact rows take the resting wash under the existing selected surface and semantic washes. Off restores the neutral rows. Owners: `Settings::provider_color` / `crates/core/src/provider_colors.rs` for assignment, `SessionMeta::provider_color_key` for the key, and `WorkspaceStore::provider_color` for the setting-gated read. --- crates/core/src/lib.rs | 1 + crates/core/src/project.rs | 38 ++++++++- crates/core/src/provider_colors.rs | 116 +++++++++++++++++++++++++ crates/core/src/settings.rs | 110 ++++++++++++++++++++++++ crates/runtime/src/app/tests.rs | 2 + crates/ui/src/settings_page.rs | 18 ++++ crates/ui/src/sidebar.rs | 130 ++++++++++++++++++++++++++++- crates/ui/src/store/intents.rs | 3 + crates/ui/src/store/mod.rs | 9 ++ docs/DESIGN.md | 16 +++- locales/en.yml | 3 + locales/zh-CN.yml | 3 + 12 files changed, 442 insertions(+), 7 deletions(-) create mode 100644 crates/core/src/provider_colors.rs diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 1862dd20d..74c4be047 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -5,6 +5,7 @@ pub mod attachments; pub mod git; pub mod permissions; pub mod project; +pub mod provider_colors; pub mod provider_models; pub mod provider_status; pub mod relay; diff --git a/crates/core/src/project.rs b/crates/core/src/project.rs index 7d85aabd9..d66db7365 100644 --- a/crates/core/src/project.rs +++ b/crates/core/src/project.rs @@ -8,7 +8,7 @@ use std::{ use agent::{ApprovalMode, InteractionMode, OptionSelection, ProviderKind, ResumeCursor}; use serde::{Deserialize, Serialize}; -use crate::settings::ProjectSort; +use crate::settings::{ProjectSort, Settings, acp_color_key, provider_key}; /// A project groups sessions (threads) that share a working-directory root. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -140,6 +140,26 @@ pub struct SessionMeta { } impl SessionMeta { + /// The key [`Settings::provider_color`] resolves this thread's color from: + /// a user profile id, an ACP agent (`acp:`), or the built-in + /// [`provider_key`]. A user profile is its own provider to the user even + /// when it drives a built-in protocol, so it never inherits the brand color. + pub fn provider_color_key(&self) -> String { + if let Some(profile_id) = self + .profile_id + .as_deref() + .filter(|id| !Settings::is_builtin_profile_id(id)) + { + return profile_id.to_string(); + } + if self.provider == ProviderKind::Acp + && let Some(agent_id) = self.acp_agent_id.as_deref() + { + return acp_color_key(agent_id); + } + provider_key(self.provider).to_string() + } + #[cfg(feature = "process")] pub fn new(provider: ProviderKind, cwd: PathBuf, model: Option) -> Self { let now = now_secs(); @@ -504,6 +524,22 @@ mod tests { meta } + #[test] + fn provider_color_key_prefers_user_profile_then_acp_agent_then_builtin() { + let mut meta = SessionMeta::new(ProviderKind::ClaudeCode, PathBuf::from("/x"), None); + assert_eq!(meta.provider_color_key(), "claude"); + // The built-in profile spelled out explicitly is still the built-in. + meta.profile_id = Some("claude".into()); + assert_eq!(meta.provider_color_key(), "claude"); + // A third-party endpoint is a different provider to the user. + meta.profile_id = Some("work-claude".into()); + assert_eq!(meta.provider_color_key(), "work-claude"); + + let mut acp = SessionMeta::new(ProviderKind::Acp, PathBuf::from("/x"), None); + acp.acp_agent_id = Some("gemini".into()); + assert_eq!(acp.provider_color_key(), "acp:gemini"); + } + fn candidates( sessions: &[SessionMeta], now: u64, diff --git a/crates/core/src/provider_colors.rs b/crates/core/src/provider_colors.rs new file mode 100644 index 000000000..d2322e6cf --- /dev/null +++ b/crates/core/src/provider_colors.rs @@ -0,0 +1,116 @@ +//! Per-provider colors for the sidebar thread tint. +//! +//! Built-in providers carry their brand color; everything the user adds (a +//! custom profile, an ACP agent) draws from [`PROVIDER_COLOR_PALETTE`]. The +//! palette slot is derived from the color key alone, so two devices sharing the +//! same settings file agree on every color without persisting an assignment. + +use agent::ProviderKind; + +/// Hand-picked brand colors (`0xRRGGBB`) for the built-in provider profiles. +/// ACP is not one provider but many, so it has no color of its own: each agent +/// takes a palette slot under its `acp:` key. +pub fn builtin_provider_color(kind: ProviderKind) -> Option { + match kind { + ProviderKind::ClaudeCode => Some(0xD97757), + ProviderKind::Codex => Some(0x8B5CF6), + ProviderKind::Pi => Some(0x4D9ABF), + ProviderKind::OpenCode => Some(0x22A06B), + ProviderKind::Acp => None, + } +} + +/// Hues for user-added providers (`0xRRGGBB`). They stay clear of the four +/// brand colors above so a custom endpoint never masquerades as a built-in. +pub const PROVIDER_COLOR_PALETTE: [u32; 10] = [ + 0xF59E0B, // amber + 0x84CC16, // lime + 0x14B8A6, // teal + 0x06B6D4, // cyan + 0x3B82F6, // blue + 0x4F46E5, // indigo + 0xD946EF, // fuchsia + 0xEC4899, // pink + 0xF43F5E, // rose + 0x6B7FD7, // slate blue +]; + +/// 64-bit FNV-1a. `DefaultHasher` is not stable across Rust releases, and a +/// color that changed after an update would look like a different provider. +fn fnv1a(key: &str) -> u64 { + key.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) +} + +fn hashed_slot(key: &str) -> usize { + (fnv1a(key) % PROVIDER_COLOR_PALETTE.len() as u64) as usize +} + +/// The palette color for a custom provider key. +/// +/// `known` is every currently configured custom provider key, in sorted order. +/// A known key starts at its hashed slot and bumps forward past slots already +/// taken by keys sorted before it, so configured providers never share a color +/// while the palette has room. A key outside `known` (a thread whose profile +/// was deleted) keeps its plain hashed slot. +pub fn palette_color(key: &str, known: &[&str]) -> u32 { + let palette_len = PROVIDER_COLOR_PALETTE.len(); + let mut taken = [false; PROVIDER_COLOR_PALETTE.len()]; + let mut assigned = 0; + for candidate in known { + let mut slot = hashed_slot(candidate); + if assigned < palette_len { + while taken[slot] { + slot = (slot + 1) % palette_len; + } + taken[slot] = true; + assigned += 1; + } + if *candidate == key { + return PROVIDER_COLOR_PALETTE[slot]; + } + } + PROVIDER_COLOR_PALETTE[hashed_slot(key)] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_keys_get_distinct_colors_while_the_palette_has_room() { + let keys: Vec = (0..PROVIDER_COLOR_PALETTE.len()) + .map(|i| format!("profile-{i}")) + .collect(); + let mut known: Vec<&str> = keys.iter().map(String::as_str).collect(); + known.sort_unstable(); + let mut colors: Vec = known.iter().map(|k| palette_color(k, &known)).collect(); + colors.sort_unstable(); + colors.dedup(); + assert_eq!(colors.len(), PROVIDER_COLOR_PALETTE.len()); + } + + /// Slots are pinned to independently computed FNV-1a values: changing the + /// hash or reordering the palette would recolor every user's threads. + #[test] + fn assignment_is_stable_and_unknown_keys_still_resolve() { + let known = ["acp:gemini", "work-claude"]; + assert_eq!(palette_color("acp:gemini", &known), 0x14B8A6); + assert_eq!(palette_color("work-claude", &known), 0x6B7FD7); + // A deleted profile's threads keep their hashed color rather than none. + assert_eq!(palette_color("deleted-profile", &known), 0xF59E0B); + } + + #[test] + fn overflowing_the_palette_still_yields_a_color() { + let keys: Vec = (0..PROVIDER_COLOR_PALETTE.len() + 3) + .map(|i| format!("p{i}")) + .collect(); + let mut known: Vec<&str> = keys.iter().map(String::as_str).collect(); + known.sort_unstable(); + for key in &known { + assert!(PROVIDER_COLOR_PALETTE.contains(&palette_color(key, &known))); + } + } +} diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 68f2ce663..3311ae30f 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -7,6 +7,7 @@ use agent::{ModelSpec, OptionDescriptor, ProviderKind}; use serde::{Deserialize, Serialize}; use crate::acp::InstalledAcpAgent; +pub use crate::provider_colors::{PROVIDER_COLOR_PALETTE, builtin_provider_color}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -64,6 +65,12 @@ pub fn provider_key(provider: ProviderKind) -> &'static str { } } +/// The provider color key of an ACP agent: agents share `ProviderKind::Acp`, +/// so the registry id is what tells them apart. +pub fn acp_color_key(agent_id: &str) -> String { + format!("acp:{agent_id}") +} + /// The provider's short display name used for card titles and picker labels. pub fn provider_label(provider: ProviderKind) -> &'static str { match provider { @@ -820,6 +827,7 @@ pub enum SettingsPatch { SkipDeleteConfirmation(bool), AutoOpenTaskPanel(bool), LiveCommandPanelDisabled(bool), + ProviderColorsDisabled(bool), ProviderUpdateChecksDisabled(bool), InactiveFrameThrottleDisabled(bool), AbortOnModelFallback(bool), @@ -918,6 +926,10 @@ pub struct Settings { /// legacy settings keep the feature enabled. #[serde(default)] pub live_command_panel_disabled: bool, + /// Whether the sidebar's per-provider thread tint is DISABLED. Stored + /// inverted so absent legacy settings keep the colors on. + #[serde(default)] + pub provider_colors_disabled: bool, /// Whether the on-launch provider version check is DISABLED. Stored inverted /// so it remains enabled for legacy settings files that lack the field. #[serde(default)] @@ -1039,6 +1051,7 @@ impl Default for Settings { skip_delete_confirmation: false, auto_open_task_panel: false, live_command_panel_disabled: false, + provider_colors_disabled: false, provider_update_checks_disabled: false, inactive_frame_throttle_disabled: false, abort_on_model_fallback: true, @@ -1082,6 +1095,9 @@ impl Settings { SettingsPatch::LiveCommandPanelDisabled(value) => { self.live_command_panel_disabled = value; } + SettingsPatch::ProviderColorsDisabled(value) => { + self.provider_colors_disabled = value; + } SettingsPatch::ProviderUpdateChecksDisabled(value) => { self.provider_update_checks_disabled = value; } @@ -1287,6 +1303,26 @@ impl Settings { } } + /// The `0xRRGGBB` color for a provider color key (see + /// [`SessionMeta::provider_color_key`](crate::project::SessionMeta::provider_color_key)). + /// Built-in profiles use their brand color; user profiles and ACP agents + /// take a palette slot that is distinct from every other configured custom + /// provider while the palette has room. + pub fn provider_color(&self, key: &str) -> u32 { + if let Some(color) = Self::builtin_kind_from_id(key).and_then(builtin_provider_color) { + return color; + } + let acp_keys: Vec = self.acp_agents.keys().map(|id| acp_color_key(id)).collect(); + let mut known: Vec<&str> = self + .profiles + .keys() + .map(String::as_str) + .chain(acp_keys.iter().map(String::as_str)) + .collect(); + known.sort_unstable(); + crate::provider_colors::palette_color(key, &known) + } + /// One installed ACP agent, by registry id. pub fn acp_agent(&self, id: &str) -> Option<&InstalledAcpAgent> { self.acp_agents.get(id) @@ -1350,6 +1386,80 @@ mod tests { ); } + #[test] + fn provider_colors_default_on_and_patch_off() { + let legacy: Settings = serde_json::from_str(r#"{"theme_mode":"system"}"#).unwrap(); + assert!(!legacy.provider_colors_disabled); + let mut settings = Settings::default(); + settings.apply(SettingsPatch::ProviderColorsDisabled(true)); + assert!(settings.provider_colors_disabled); + } + + #[test] + fn provider_color_maps_builtins_to_brand_and_custom_providers_to_distinct_palette_slots() { + let mut settings = Settings::default(); + assert_eq!(settings.provider_color("claude"), 0xD97757); + assert_eq!(settings.provider_color("codex"), 0x8B5CF6); + assert_eq!(settings.provider_color("pi"), 0x4D9ABF); + assert_eq!(settings.provider_color("opencode"), 0x22A06B); + + for id in ["work-claude", "proxy-codex", "lab"] { + settings.profiles.insert( + id.into(), + ProviderProfile { + kind: ProviderKind::ClaudeCode, + settings: ProviderSettings::default(), + }, + ); + } + for id in ["gemini", "goose"] { + settings.acp_agents.insert( + id.into(), + InstalledAcpAgent { + id: id.into(), + name: id.into(), + version: String::new(), + icon: None, + launch: agent::AcpLaunch::Npx { + package: id.into(), + args: Vec::new(), + env: Vec::new(), + }, + enabled: true, + env: Vec::new(), + launch_args: None, + }, + ); + } + let keys = [ + "work-claude", + "proxy-codex", + "lab", + "acp:gemini", + "acp:goose", + ]; + let colors: Vec = keys.iter().map(|k| settings.provider_color(k)).collect(); + let mut distinct = colors.clone(); + distinct.sort_unstable(); + distinct.dedup(); + assert_eq!(distinct.len(), keys.len(), "{colors:06X?}"); + for color in &colors { + assert!(PROVIDER_COLOR_PALETTE.contains(color)); + assert!( + ![0xD97757, 0x8B5CF6, 0x4D9ABF, 0x22A06B].contains(color), + "custom providers never take a brand color" + ); + } + // Deterministic across calls, and a deleted profile still has a color. + assert_eq!( + colors, + keys.iter() + .map(|k| settings.provider_color(k)) + .collect::>() + ); + assert!(PROVIDER_COLOR_PALETTE.contains(&settings.provider_color("gone"))); + } + #[test] fn removed_diff_view_mode_key_remains_compatible_with_old_settings() { let settings: Settings = diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index 41d7f71e3..ccaa7959b 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -534,6 +534,7 @@ fn reset_settings_clears_preferences_but_keeps_credentials_installs_and_unknown_ theme_mode: ThemeMode::Dark, language: Some("zh-CN".into()), word_wrap_diffs: true, + provider_colors_disabled: true, auto_archive_max_idle_days: 99, ..Settings::default() }; @@ -591,6 +592,7 @@ fn reset_settings_clears_preferences_but_keeps_credentials_installs_and_unknown_ assert_eq!(reset.theme_mode, ThemeMode::System); assert_eq!(reset.language, None); assert!(!reset.word_wrap_diffs); + assert!(!reset.provider_colors_disabled); assert_eq!(reset.auto_archive_max_idle_days, 7); assert_eq!(reset.browser.home_url, None); diff --git a/crates/ui/src/settings_page.rs b/crates/ui/src/settings_page.rs index d3b23ed3c..ee12bac6a 100644 --- a/crates/ui/src/settings_page.rs +++ b/crates/ui/src/settings_page.rs @@ -1220,9 +1220,27 @@ impl SettingsPage { let language_overridden = store.client_language_override().is_some(); let theme_overridden = store.client_theme_override().is_some(); let device_name_overridden = store.client_device_name_override().is_some(); + let provider_colors_reset = self.reset_action( + "reset-provider-colors", + settings.provider_colors_disabled, + cx, + |this, _, cx| { + this.dispatch_settings(|store| store.set_provider_colors_disabled(false), cx) + }, + ); let appearance = vec![ self.language_row(settings.language.as_deref(), language_overridden, cx), self.theme_row(settings.theme_mode, theme_overridden, cx), + self.toggle_row( + "provider-colors", + crate::tr!("settings.provider_colors.title"), + crate::tr!("settings.provider_colors.description"), + // Stored inverted: checked = enabled. + !settings.provider_colors_disabled, + provider_colors_reset, + cx, + |store, checked| store.set_provider_colors_disabled(!checked), + ), self.device_name_row(device_name_overridden, cx), ]; let delete_confirm_reset = self.reset_action( diff --git a/crates/ui/src/sidebar.rs b/crates/ui/src/sidebar.rs index ad90cb7ba..91e2781b4 100644 --- a/crates/ui/src/sidebar.rs +++ b/crates/ui/src/sidebar.rs @@ -37,6 +37,13 @@ use crate::time::{humanize_ago, now_secs}; use crate::window_drag_area; use crate::window_state::{Destination, Route, WindowState}; +/// Alpha steps of the provider tint on a thread row. One hue per provider, +/// three strengths, so rest, hover and selection stay recognisably the same +/// color on both light and dark canvases. +const PROVIDER_TINT_REST: f32 = 0.08; +const PROVIDER_TINT_HOVER: f32 = 0.16; +const PROVIDER_TINT_ACTIVE: f32 = 0.26; + /// Left padding on the sidebar's top row so branding clears the native macOS /// traffic lights (ending near x=72 on macOS 26); a small inset elsewhere. #[cfg(target_os = "macos")] @@ -2127,6 +2134,15 @@ impl SessionsSidebar { } } + /// The provider color behind `meta`'s row, `None` while Provider colors is + /// off. Callers pick the alpha for their state. + fn provider_tint(&self, meta: &SessionMeta, cx: &App) -> Option { + self.store + .read(cx) + .provider_color(meta) + .map(|rgb| gpui::rgb(rgb).into()) + } + fn thread_clickable_row( &self, base: gpui::Div, @@ -2138,6 +2154,7 @@ impl SessionsSidebar { ) -> gpui::Stateful { let session_id = state.session_id.clone(); let has_direct_children = state.has_direct_children(); + let tint = self.provider_tint(meta, cx); crate::material::accessible_clickable( base, row_id, @@ -2155,9 +2172,13 @@ impl SessionsSidebar { }) .group(state.row_key.clone()) .cursor_pointer() - .when(is_active, |row| row.bg(cx.theme().list_active)) - .when(!is_active, |row| { - row.hover(|row| row.bg(cx.theme().sidebar_accent)) + .map(|row| match tint { + Some(tint) if is_active => row.bg(tint.opacity(PROVIDER_TINT_ACTIVE)), + Some(tint) => row + .bg(tint.opacity(PROVIDER_TINT_REST)) + .hover(move |row| row.bg(tint.opacity(PROVIDER_TINT_HOVER))), + None if is_active => row.bg(cx.theme().list_active), + None => row.hover(|row| row.bg(cx.theme().sidebar_accent)), }) .on_click(cx.listener(move |this, _, _, cx| { let session_id = session_id.clone(); @@ -3306,6 +3327,7 @@ impl SessionsSidebar { let click_id = session_id.clone(); let disclosure_id = session_id.clone(); let unavailable = meta.parent_session_id.is_some() && !state.is_child; + let tint = self.provider_tint(meta, cx); let row = crate::material::list_row(cached.row_id.clone(), cached.label.clone(), cx) .debug_selector({ @@ -3315,6 +3337,9 @@ impl SessionsSidebar { .when(state.is_child, |row| { row.pl(px(crate::material::COMPACT_PAGE_INSET + 16.)) }) + // The provider tint is the resting fill; selection and the semantic + // washes below replace it rather than stack on it. + .when_some(tint, |row, tint| row.bg(tint.opacity(PROVIDER_TINT_REST))) .when( self.store.read(cx).active_session_id().as_deref() == Some(session_id.as_str()), |row| row.bg(cx.theme().list_active).aria_selected(true), @@ -4411,6 +4436,105 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[gpui::test] + fn thread_rows_carry_the_provider_tint_until_the_setting_is_off(cx: &mut TestAppContext) { + cx.update(crate::theme::init); + let root = std::env::temp_dir().join(format!( + "tcode-provider-tint-{}", + tcode_services::store::now_millis() + )); + let host = spawn_host( + SessionStore::open_at(root.clone()).unwrap(), + HostServices::default(), + ) + .unwrap(); + let project = Project::from_root(root.clone()); + let mut codex = session("codex-thread", None); + codex.project_id = Some(project.id.clone()); + let mut claude = session("claude-thread", None); + claude.provider = ProviderKind::ClaudeCode; + claude.project_id = Some(project.id.clone()); + smol::block_on(host.update_state_for_test(move |state, _| { + state.settings.auto_archive_disabled = true; + state.projects = vec![project]; + state.sessions = vec![codex, claude]; + })) + .unwrap(); + let store = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); + let window_state = cx.new(|_| WindowState::new(false)); + let (_, cx) = + cx.add_window_view(|_, cx| SessionsSidebar::new(store.clone(), window_state, cx)); + let cx: &mut VisualTestContext = cx; + cx.simulate_resize(size(px(320.), px(600.))); + store.update(cx, |store, _| store.select_session("codex-thread".into())); + let wait_for_rows = |cx: &mut VisualTestContext| { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + draw(cx); + if let (Some(codex), Some(claude)) = ( + cx.debug_bounds("sidebar-thread-codex-thread"), + cx.debug_bounds("sidebar-thread-claude-thread"), + ) { + return (codex, claude); + } + assert!( + std::time::Instant::now() < deadline, + "thread rows never rendered" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + }; + let painted = + |cx: &mut VisualTestContext, bounds: gpui::Bounds, color: gpui::Hsla| { + cx.update(|window, _| { + window.painted_quads().iter().any(|quad| { + quad.bounds == bounds.scale(window.scale_factor()) + && quad.background == gpui::Background::from(color) + }) + }) + }; + let codex_tint: gpui::Hsla = gpui::rgb(0x8B5CF6).into(); + let claude_tint: gpui::Hsla = gpui::rgb(0xD97757).into(); + + let (codex_row, claude_row) = wait_for_rows(cx); + assert!( + painted(cx, codex_row, codex_tint.opacity(PROVIDER_TINT_ACTIVE)), + "the active row is its provider's color at the selected strength" + ); + assert!( + painted(cx, claude_row, claude_tint.opacity(PROVIDER_TINT_REST)), + "a resting row is its own provider's color at the resting strength" + ); + + store.update(cx, |store, _| store.set_provider_colors_disabled(true)); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !store.read_with(cx, |store, _| store.settings().provider_colors_disabled) { + assert!( + std::time::Instant::now() < deadline, + "setting never replicated" + ); + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let (codex_row, claude_row) = wait_for_rows(cx); + let list_active = cx.update(|_, cx| cx.theme().list_active); + assert!( + painted(cx, codex_row, list_active), + "with colors off the active row is the neutral selected surface" + ); + assert!( + !painted(cx, codex_row, codex_tint.opacity(PROVIDER_TINT_ACTIVE)), + "with colors off no provider tint remains" + ); + assert!(!painted( + cx, + claude_row, + claude_tint.opacity(PROVIDER_TINT_REST) + )); + let _ = std::fs::remove_dir_all(root); + } + #[gpui::test] fn compact_menu_dismissal_preserves_selection_and_clears_row_surface(cx: &mut TestAppContext) { use gpui::{PlatformInput, TouchEvent, TouchId, TouchPhase}; diff --git a/crates/ui/src/store/intents.rs b/crates/ui/src/store/intents.rs index bb3fb5105..4f9321dac 100644 --- a/crates/ui/src/store/intents.rs +++ b/crates/ui/src/store/intents.rs @@ -71,6 +71,9 @@ impl WorkspaceStore { pub fn set_live_command_panel_disabled(&mut self, value: bool) { self.patch_settings(SettingsPatch::LiveCommandPanelDisabled(value)); } + pub fn set_provider_colors_disabled(&mut self, value: bool) { + self.patch_settings(SettingsPatch::ProviderColorsDisabled(value)); + } pub fn set_provider_update_checks_disabled(&mut self, value: bool) { self.patch_settings(SettingsPatch::ProviderUpdateChecksDisabled(value)); } diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index 718b4315e..6be5eb6df 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -1653,6 +1653,15 @@ impl WorkspaceStore { !self.settings_replica.live_command_panel_disabled } + /// The `0xRRGGBB` color tinting `meta`'s sidebar row, `None` while the + /// Provider colors setting is off. + pub fn provider_color(&self, meta: &SessionMeta) -> Option { + (!self.settings_replica.provider_colors_disabled).then(|| { + self.settings_replica + .provider_color(&meta.provider_color_key()) + }) + } + pub fn archived_groups(&self) -> Vec { let archived: Vec<_> = self .index_replica diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 0b98eb55c..5ee63a044 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -613,7 +613,16 @@ in both states. 5. Project groups: rotating chevron + project icon + 13px medium name; hover shows "+" (new thread in project); collapse state persisted. Thread rows: single-line truncated AI-generated title (first-message fallback - while naming) + relative time (muted 11px); hover = accent bg. Inline rename + while naming) + relative time (muted 11px); hover = accent bg. With the + **Provider colors** switch on (the default) every thread row, child rows + included, carries a subtle wash of its provider's color instead: built-in + providers use their brand color and each user profile or ACP agent takes a + stable palette slot (owner: `Settings::provider_color` in `crates/core`). + The wash is the same hue at three strengths — resting (~8%), hover (~16%) + and selected (~26%) — so hover and selection never change geometry or hue. + Compact rows take only the resting wash; the selected surface and the + waiting-for-approval / waiting-for-input semantic washes replace it. With + the switch off, rows are the neutral surfaces described here. Inline rename commits on Enter and cancels on blur or any click outside the input. The thread context menu offers **Regenerate title / 重新生成标题** next to Rename in both layouts. It uses the original request and recent conversation @@ -1054,8 +1063,9 @@ horizontal clipping. A row whose control is a 44pt switch keeps it beside the label at both widths, since a switch never squeezes the text. Both the compact list and wide rail divide Settings into two captioned groups, -in this order: **This device** contains General (appearance, language and device -name) plus Other devices wherever the build can host connections; **‹machine +in this order: **This device** contains General (appearance — theme, the +**Provider colors** switch for the sidebar's per-provider thread tint — language +and device name) plus Other devices wherever the build can host connections; **‹machine name› settings** contains Providers, Usage, Orchestrate, Computer Use, Browser and Archived Threads. The attached host's display name supplies ‹machine name›; a local attachment uses the machine name shown by the hosting panel, and a diff --git a/locales/en.yml b/locales/en.yml index 040c67f19..ab9be1434 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -280,6 +280,9 @@ settings: live_command_panel: title: "Live command panel" description: "Automatically expand the latest command output while the agent is working." + provider_colors: + title: "Provider colors" + description: "Tint each thread in the sidebar with its provider's color." provider_updates: title: "Provider update checks" description: "Check for newer Tcode and provider CLI versions on launch, and refresh the Claude Code model list from t3code." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index c85e15166..25616e770 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -280,6 +280,9 @@ settings: live_command_panel: title: "实时命令面板" description: "智能体工作时自动展开最新命令的输出。" + provider_colors: + title: "按 provider 着色" + description: "在侧边栏中用所属 provider 的颜色为每个对话着色。" provider_updates: title: "提供方更新检查" description: "启动时检查 Tcode 和提供方 CLI 的新版本,并从 t3code 刷新 Claude Code 模型列表。" From d186bfbe0562a85ddc4bb875b02aee30cd1e15c6 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 16 Sep 2026 20:49:07 +0800 Subject: [PATCH 2/6] Let a provider card's accent own its thread tint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ProviderSettings::accent_rgb` is now the one parser of the card's `#rrggbb` accent; `WorkspaceStore::provider_profile_accent` and `Settings::provider_color` both call it, so a user-set accent wins over the brand color and the palette for that profile's rows. ACP keys are not profile ids and keep their palette slot. zh-CN wording follows the locale's 提供方 term for provider. --- crates/core/src/settings.rs | 52 ++++++++++++++++++++++++++++++++++--- crates/ui/src/store/mod.rs | 9 ++----- docs/DESIGN.md | 7 ++--- locales/zh-CN.yml | 4 +-- 4 files changed, 57 insertions(+), 15 deletions(-) diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 3311ae30f..9fd3e5457 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -188,6 +188,15 @@ impl Default for ProviderSettings { } impl ProviderSettings { + /// The card's `accent_color` as `0xRRGGBB`, `None` when unset or not a + /// six-digit hex string. + pub fn accent_rgb(&self) -> Option { + let hex = self.accent_color.as_deref()?.trim().trim_start_matches('#'); + (hex.len() == 6 && hex.chars().all(|ch| ch.is_ascii_hexdigit())) + .then(|| u32::from_str_radix(hex, 16).ok()) + .flatten() + } + /// A native provider's `Launch arguments` field, split on whitespace. pub fn extra_args(&self) -> Vec { self.launch_args @@ -1305,10 +1314,17 @@ impl Settings { /// The `0xRRGGBB` color for a provider color key (see /// [`SessionMeta::provider_color_key`](crate::project::SessionMeta::provider_color_key)). - /// Built-in profiles use their brand color; user profiles and ACP agents - /// take a palette slot that is distinct from every other configured custom - /// provider while the palette has room. + /// A card's own accent wins when set; otherwise built-in profiles use their + /// brand color and user profiles and ACP agents take a palette slot that is + /// distinct from every other configured custom provider while the palette + /// has room. ACP keys are not profile ids, so they never carry an accent. pub fn provider_color(&self, key: &str) -> u32 { + if let Some(accent) = self + .resolved_profile(key) + .and_then(|profile| profile.settings.accent_rgb()) + { + return accent; + } if let Some(color) = Self::builtin_kind_from_id(key).and_then(builtin_provider_color) { return color; } @@ -1460,6 +1476,36 @@ mod tests { assert!(PROVIDER_COLOR_PALETTE.contains(&settings.provider_color("gone"))); } + #[test] + fn card_accent_wins_over_brand_and_palette_when_valid() { + let mut settings = Settings::default(); + settings.profiles.insert( + "work-claude".into(), + ProviderProfile { + kind: ProviderKind::ClaudeCode, + settings: ProviderSettings { + accent_color: Some("#2563eb".into()), + ..ProviderSettings::default() + }, + }, + ); + settings.profiles.insert( + "broken".into(), + ProviderProfile { + kind: ProviderKind::Codex, + settings: ProviderSettings { + accent_color: Some("blue".into()), + ..ProviderSettings::default() + }, + }, + ); + settings.provider_mut(ProviderKind::Codex).accent_color = Some("00FF00".into()); + assert_eq!(settings.provider_color("work-claude"), 0x2563EB); + assert_eq!(settings.provider_color("codex"), 0x00FF00); + assert!(PROVIDER_COLOR_PALETTE.contains(&settings.provider_color("broken"))); + assert_eq!(settings.provider_color("claude"), 0xD97757); + } + #[test] fn removed_diff_view_mode_key_remains_compatible_with_old_settings() { let settings: Settings = diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index 6be5eb6df..2486d23d1 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -1968,15 +1968,10 @@ impl WorkspaceStore { } pub fn provider_profile_accent(&self, profile_id: &str) -> Option { - let raw = self - .settings_replica + self.settings_replica .resolved_profile(profile_id)? .settings - .accent_color?; - let hex = raw.trim().trim_start_matches('#'); - (hex.len() == 6 && hex.chars().all(|ch| ch.is_ascii_hexdigit())) - .then(|| u32::from_str_radix(hex, 16).ok()) - .flatten() + .accent_rgb() } pub fn provider_update_command(&self, provider: agent::ProviderKind) -> Option { diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 5ee63a044..5ea4fb458 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -615,9 +615,10 @@ in both states. Thread rows: single-line truncated AI-generated title (first-message fallback while naming) + relative time (muted 11px); hover = accent bg. With the **Provider colors** switch on (the default) every thread row, child rows - included, carries a subtle wash of its provider's color instead: built-in - providers use their brand color and each user profile or ACP agent takes a - stable palette slot (owner: `Settings::provider_color` in `crates/core`). + included, carries a subtle wash of its provider's color instead: the + provider card's accent when one is set, else the built-in brand color, else + a stable palette slot for each user profile or ACP agent (owner: + `Settings::provider_color` in `crates/core`). The wash is the same hue at three strengths — resting (~8%), hover (~16%) and selected (~26%) — so hover and selection never change geometry or hue. Compact rows take only the resting wash; the selected surface and the diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 25616e770..74db4e7f7 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -281,8 +281,8 @@ settings: title: "实时命令面板" description: "智能体工作时自动展开最新命令的输出。" provider_colors: - title: "按 provider 着色" - description: "在侧边栏中用所属 provider 的颜色为每个对话着色。" + title: "提供方颜色" + description: "在侧边栏中以所属提供方的颜色为每个对话着色。" provider_updates: title: "提供方更新检查" description: "启动时检查 Tcode 和提供方 CLI 的新版本,并从 t3code 刷新 Claude Code 模型列表。" From 9601f75fcdafd58320aaf634529421cbc1743297 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 16 Sep 2026 21:04:33 +0800 Subject: [PATCH 3/6] Replace the provider row tint with a clipped watermark glyph Thread rows return to their neutral rest, hover, selected and semantic surfaces. With Provider colors on, every row shape (grouped, flat parent and child, compact) instead paints its provider's glyph under the content: ~1.25x the row height, vertically centred, hanging 6px past the right edge so the row's clip cuts it, in the provider color at 0.14 alpha. The mark has no handlers; ACP agents use the box glyph the rail and ACP panel already use. The Claude brand color now lives only in core: `provider_glyph` and the favorite star read `builtin_provider_color` instead of a UI constant. --- crates/ui/src/composer/components/pickers.rs | 6 +- crates/ui/src/composer/mod.rs | 2 +- crates/ui/src/material.rs | 4 +- crates/ui/src/provider_card.rs | 24 +-- crates/ui/src/sidebar.rs | 194 ++++++++++++------- docs/DESIGN.md | 25 +-- locales/en.yml | 2 +- locales/zh-CN.yml | 2 +- 8 files changed, 161 insertions(+), 98 deletions(-) diff --git a/crates/ui/src/composer/components/pickers.rs b/crates/ui/src/composer/components/pickers.rs index 30066c151..9f798f6e8 100644 --- a/crates/ui/src/composer/components/pickers.rs +++ b/crates/ui/src/composer/components/pickers.rs @@ -1088,7 +1088,11 @@ fn render_model_row( }) .xsmall() .text_color(if is_fav { - rgb(CLAUDE_BRAND_COLOR).into() + rgb( + tcode_core::settings::builtin_provider_color(ProviderKind::ClaudeCode) + .expect("Claude has a brand color"), + ) + .into() } else { muted }), diff --git a/crates/ui/src/composer/mod.rs b/crates/ui/src/composer/mod.rs index 6fd342776..0d4a00b55 100644 --- a/crates/ui/src/composer/mod.rs +++ b/crates/ui/src/composer/mod.rs @@ -46,7 +46,7 @@ use crate::composer_trigger::{ }; use crate::context_meter; use crate::palette::fuzzy_score; -use crate::provider_card::{CLAUDE_BRAND_COLOR, provider_glyph}; +use crate::provider_card::provider_glyph; use crate::settings::provider_label; use crate::shortcut::format_secondary_shortcut; use crate::store::{TopicKind, WorkspaceStore, observe_store_topics}; diff --git a/crates/ui/src/material.rs b/crates/ui/src/material.rs index 277cd0eac..97f927dff 100644 --- a/crates/ui/src/material.rs +++ b/crates/ui/src/material.rs @@ -24,6 +24,8 @@ pub(crate) const CHAT_CONTENT_MIN_PADDING: f32 = 24.; /// The compact layout's page inset: content is held this far clear of both /// window edges. Cards inside that content inset a further [`CARD_INSET`]. pub(crate) const COMPACT_PAGE_INSET: f32 = 16.; +/// The touch-target height of a [`list_row`]. +pub(crate) const LIST_ROW_MIN_HEIGHT: f32 = 56.; /// Padding inside a card, chip or notice that already sits within a page inset. pub(crate) const CARD_INSET: f32 = 12.; /// The smallest square a finger can reliably hit. @@ -171,7 +173,7 @@ pub fn grouped(rows: Vec, cx: &App) -> Div { pub fn list_row(id: impl Into, label: SharedString, cx: &App) -> Stateful
{ accessible_clickable(gpui_base::h_flex(), id, Role::Button, label, cx) .w_full() - .min_h(px(56.)) + .min_h(px(LIST_ROW_MIN_HEIGHT)) .px(px(COMPACT_PAGE_INSET)) .py(px(8.)) .gap_3() diff --git a/crates/ui/src/provider_card.rs b/crates/ui/src/provider_card.rs index d4f3657f4..42249fc91 100644 --- a/crates/ui/src/provider_card.rs +++ b/crates/ui/src/provider_card.rs @@ -28,9 +28,7 @@ use gpui::{Styled as _, rgb}; use gpui_base::{StyledExt as _, h_flex, v_flex}; use agent::ProviderKind; - -/// Claude's official Clay brand color from Anthropic's media resources. -pub const CLAUDE_BRAND_COLOR: u32 = 0xD97757; +use tcode_core::settings::builtin_provider_color; pub struct ProviderCard { store: Entity, @@ -406,14 +404,16 @@ impl Render for ProviderCard { } /// The provider's glyph (the same asset the composer's picker rail uses). +/// Claude's is pre-tinted with its brand color; the others take the text color. pub fn provider_glyph(provider: ProviderKind) -> Icon { - match provider { - ProviderKind::ClaudeCode => Icon::empty() - .path("icons/claude.svg") - .text_color(rgb(CLAUDE_BRAND_COLOR)), - ProviderKind::Codex => Icon::empty().path("icons/openai.svg"), - ProviderKind::Pi => Icon::empty().path("icons/pi.svg"), - ProviderKind::OpenCode => Icon::empty().path("icons/opencode.svg"), - ProviderKind::Acp => Icon::empty(), - } + let (path, tint) = match provider { + ProviderKind::ClaudeCode => ("icons/claude.svg", builtin_provider_color(provider)), + ProviderKind::Codex => ("icons/openai.svg", None), + ProviderKind::Pi => ("icons/pi.svg", None), + ProviderKind::OpenCode => ("icons/opencode.svg", None), + ProviderKind::Acp => return Icon::empty(), + }; + Icon::empty() + .path(path) + .when_some(tint, |icon, tint| icon.text_color(rgb(tint))) } diff --git a/crates/ui/src/sidebar.rs b/crates/ui/src/sidebar.rs index 91e2781b4..fb76f3141 100644 --- a/crates/ui/src/sidebar.rs +++ b/crates/ui/src/sidebar.rs @@ -37,12 +37,12 @@ use crate::time::{humanize_ago, now_secs}; use crate::window_drag_area; use crate::window_state::{Destination, Route, WindowState}; -/// Alpha steps of the provider tint on a thread row. One hue per provider, -/// three strengths, so rest, hover and selection stay recognisably the same -/// color on both light and dark canvases. -const PROVIDER_TINT_REST: f32 = 0.08; -const PROVIDER_TINT_HOVER: f32 = 0.16; -const PROVIDER_TINT_ACTIVE: f32 = 0.26; +/// The provider mark behind a thread row: its provider's glyph at this alpha, +/// scaled to this multiple of the row height and pushed past the right edge by +/// this much so the row's clip cuts it. +const PROVIDER_MARK_ALPHA: f32 = 0.14; +const PROVIDER_MARK_SCALE: f32 = 1.25; +const PROVIDER_MARK_OVERHANG: f32 = 6.; /// Left padding on the sidebar's top row so branding clears the native macOS /// traffic lights (ending near x=72 on macOS 26); a small inset elsewhere. @@ -57,6 +57,10 @@ const THREADS_COLLAPSED_LIMIT: usize = 6; /// Flat-list row geometry, including the 2px gap reserved below every row. const FLAT_ROOT_ROW_HEIGHT: f32 = 50.; const FLAT_CHILD_ROW_HEIGHT: f32 = 32.; +/// The clickable row inside each flat slot (the slot adds 2px of spacing). +const FLAT_ROOT_ROW_INNER_HEIGHT: f32 = 48.; +const FLAT_CHILD_ROW_INNER_HEIGHT: f32 = 30.; +const GROUPED_ROW_HEIGHT: f32 = 30.; const SETTLED_HEADER_HEIGHT: f32 = 34.; /// A critically damped spring keeps reordering legible without bouncing rows @@ -2134,13 +2138,35 @@ impl SessionsSidebar { } } - /// The provider color behind `meta`'s row, `None` while Provider colors is - /// off. Callers pick the alpha for their state. - fn provider_tint(&self, meta: &SessionMeta, cx: &App) -> Option { - self.store - .read(cx) - .provider_color(meta) - .map(|rgb| gpui::rgb(rgb).into()) + /// The decorative provider mark painted under a thread row's content: + /// `meta`'s protocol glyph in its provider color, vertically centred and + /// hanging off the right edge. `None` while Provider colors is off. It has + /// no handlers, so the row's own hover, click and menu are unaffected. + fn provider_mark(&self, meta: &SessionMeta, row_height: f32, cx: &App) -> Option { + let color: gpui::Hsla = gpui::rgb(self.store.read(cx).provider_color(meta)?).into(); + let glyph = match meta.provider { + // ACP agents share the box glyph the composer rail and ACP panel use. + agent::ProviderKind::Acp => Icon::empty().path("icons/box.svg"), + kind => crate::provider_card::provider_glyph(kind), + }; + let size = px((row_height * PROVIDER_MARK_SCALE).round()); + let id = meta.id.clone(); + Some( + div() + .absolute() + .top_0() + .bottom_0() + .right(px(-PROVIDER_MARK_OVERHANG)) + .w(size) + .flex() + .items_center() + .debug_selector(move || format!("sidebar-provider-mark-{id}")) + .child( + glyph + .size(size) + .text_color(color.opacity(PROVIDER_MARK_ALPHA)), + ), + ) } fn thread_clickable_row( @@ -2154,7 +2180,6 @@ impl SessionsSidebar { ) -> gpui::Stateful { let session_id = state.session_id.clone(); let has_direct_children = state.has_direct_children(); - let tint = self.provider_tint(meta, cx); crate::material::accessible_clickable( base, row_id, @@ -2172,13 +2197,9 @@ impl SessionsSidebar { }) .group(state.row_key.clone()) .cursor_pointer() - .map(|row| match tint { - Some(tint) if is_active => row.bg(tint.opacity(PROVIDER_TINT_ACTIVE)), - Some(tint) => row - .bg(tint.opacity(PROVIDER_TINT_REST)) - .hover(move |row| row.bg(tint.opacity(PROVIDER_TINT_HOVER))), - None if is_active => row.bg(cx.theme().list_active), - None => row.hover(|row| row.bg(cx.theme().sidebar_accent)), + .when(is_active, |row| row.bg(cx.theme().list_active)) + .when(!is_active, |row| { + row.hover(|row| row.bg(cx.theme().sidebar_accent)) }) .on_click(cx.listener(move |this, _, _, cx| { let session_id = session_id.clone(); @@ -2428,12 +2449,17 @@ impl SessionsSidebar { is_active, cx, ) - .h(px(30.)) + .h(px(GROUPED_ROW_HEIGHT)) .items_center() .gap_2() .pl(px(if is_child { 42. } else { 30. })) .pr_2() .rounded(px(6.)) + // First child so the row's content paints over the mark. + .when_some( + self.provider_mark(meta, GROUPED_ROW_HEIGHT, cx), + |row, mark| row.relative().overflow_hidden().child(mark), + ) .when_some( Self::thread_status_badge(&state, working, cx), |row, badge| row.child(badge), @@ -2598,6 +2624,11 @@ impl SessionsSidebar { let children_collapsed = state.children_collapsed; let renaming = state.renaming.is_some(); let status = Self::thread_status_label(&state, working, cx); + let row_height = if is_child { + FLAT_CHILD_ROW_INNER_HEIGHT + } else { + FLAT_ROOT_ROW_INNER_HEIGHT + }; let row = self .thread_clickable_row( @@ -2612,10 +2643,22 @@ impl SessionsSidebar { let id = session_id.clone(); move || format!("sidebar-thread-{id}") }) - .when(is_child, |row| row.h(px(30.)).items_center().ml(px(12.))) - .when(!is_child, |row| row.h(px(48.)).justify_center().gap(px(2.))) + .when(is_child, |row| { + row.h(px(FLAT_CHILD_ROW_INNER_HEIGHT)) + .items_center() + .ml(px(12.)) + }) + .when(!is_child, |row| { + row.h(px(FLAT_ROOT_ROW_INNER_HEIGHT)) + .justify_center() + .gap(px(2.)) + }) .px_2() - .rounded(px(6.)); + .rounded(px(6.)) + // First child so the row's content paints over the mark. + .when_some(self.provider_mark(meta, row_height, cx), |row, mark| { + row.relative().overflow_hidden().child(mark) + }); let row = if is_child { let title_or_input = self.thread_title_or_input(meta, &state, false, cx); @@ -3327,7 +3370,7 @@ impl SessionsSidebar { let click_id = session_id.clone(); let disclosure_id = session_id.clone(); let unavailable = meta.parent_session_id.is_some() && !state.is_child; - let tint = self.provider_tint(meta, cx); + let mark = self.provider_mark(meta, crate::material::LIST_ROW_MIN_HEIGHT, cx); let row = crate::material::list_row(cached.row_id.clone(), cached.label.clone(), cx) .debug_selector({ @@ -3337,9 +3380,10 @@ impl SessionsSidebar { .when(state.is_child, |row| { row.pl(px(crate::material::COMPACT_PAGE_INSET + 16.)) }) - // The provider tint is the resting fill; selection and the semantic - // washes below replace it rather than stack on it. - .when_some(tint, |row, tint| row.bg(tint.opacity(PROVIDER_TINT_REST))) + // First child so the row's content paints over the mark. + .when_some(mark, |row, mark| { + row.relative().overflow_hidden().child(mark) + }) .when( self.store.read(cx).active_session_id().as_deref() == Some(session_id.as_str()), |row| row.bg(cx.theme().list_active).aria_selected(true), @@ -4437,10 +4481,10 @@ mod tests { } #[gpui::test] - fn thread_rows_carry_the_provider_tint_until_the_setting_is_off(cx: &mut TestAppContext) { + fn thread_rows_carry_the_provider_mark_until_the_setting_is_off(cx: &mut TestAppContext) { cx.update(crate::theme::init); let root = std::env::temp_dir().join(format!( - "tcode-provider-tint-{}", + "tcode-provider-mark-{}", tcode_services::store::now_millis() )); let host = spawn_host( @@ -4467,16 +4511,28 @@ mod tests { let cx: &mut VisualTestContext = cx; cx.simulate_resize(size(px(320.), px(600.))); store.update(cx, |store, _| store.select_session("codex-thread".into())); + // (row selector, mark selector); the active Codex row comes first. + let selectors = [ + ( + "sidebar-thread-codex-thread", + "sidebar-provider-mark-codex-thread", + ), + ( + "sidebar-thread-claude-thread", + "sidebar-provider-mark-claude-thread", + ), + ]; let wait_for_rows = |cx: &mut VisualTestContext| { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); loop { store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); draw(cx); - if let (Some(codex), Some(claude)) = ( - cx.debug_bounds("sidebar-thread-codex-thread"), - cx.debug_bounds("sidebar-thread-claude-thread"), - ) { - return (codex, claude); + let rows: Vec<_> = selectors + .iter() + .filter_map(|(row, _)| cx.debug_bounds(row)) + .collect(); + if rows.len() == selectors.len() { + return rows; } assert!( std::time::Instant::now() < deadline, @@ -4485,26 +4541,33 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(10)); } }; - let painted = - |cx: &mut VisualTestContext, bounds: gpui::Bounds, color: gpui::Hsla| { - cx.update(|window, _| { - window.painted_quads().iter().any(|quad| { - quad.bounds == bounds.scale(window.scale_factor()) - && quad.background == gpui::Background::from(color) - }) - }) - }; - let codex_tint: gpui::Hsla = gpui::rgb(0x8B5CF6).into(); - let claude_tint: gpui::Hsla = gpui::rgb(0xD97757).into(); - let (codex_row, claude_row) = wait_for_rows(cx); - assert!( - painted(cx, codex_row, codex_tint.opacity(PROVIDER_TINT_ACTIVE)), - "the active row is its provider's color at the selected strength" - ); + let rows = wait_for_rows(cx); + for ((_, mark_selector), row) in selectors.iter().zip(&rows) { + let mark = cx + .debug_bounds(mark_selector) + .expect("each row carries its provider mark"); + assert!( + mark.top() >= row.top() && mark.bottom() <= row.bottom(), + "mark stays within the row's vertical span: {mark:?} in {row:?}" + ); + assert!( + mark.right() >= row.right(), + "mark hangs off the right edge so the clip cuts it: {mark:?} in {row:?}" + ); + } + let list_active = cx.update(|_, cx| cx.theme().list_active); + let painted_active = |cx: &mut VisualTestContext, bounds: gpui::Bounds| { + cx.update(|window, _| { + window.painted_quads().iter().any(|quad| { + quad.bounds == bounds.scale(window.scale_factor()) + && quad.background == gpui::Background::from(list_active) + }) + }) + }; assert!( - painted(cx, claude_row, claude_tint.opacity(PROVIDER_TINT_REST)), - "a resting row is its own provider's color at the resting strength" + painted_active(cx, rows[0]), + "the active row keeps the neutral selected surface" ); store.update(cx, |store, _| store.set_provider_colors_disabled(true)); @@ -4517,21 +4580,14 @@ mod tests { store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); std::thread::sleep(std::time::Duration::from_millis(10)); } - let (codex_row, claude_row) = wait_for_rows(cx); - let list_active = cx.update(|_, cx| cx.theme().list_active); - assert!( - painted(cx, codex_row, list_active), - "with colors off the active row is the neutral selected surface" - ); - assert!( - !painted(cx, codex_row, codex_tint.opacity(PROVIDER_TINT_ACTIVE)), - "with colors off no provider tint remains" - ); - assert!(!painted( - cx, - claude_row, - claude_tint.opacity(PROVIDER_TINT_REST) - )); + let rows = wait_for_rows(cx); + for (_, mark_selector) in selectors { + assert!( + cx.debug_bounds(mark_selector).is_none(), + "with colors off no mark is drawn" + ); + } + assert!(painted_active(cx, rows[0])); let _ = std::fs::remove_dir_all(root); } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 5ea4fb458..f354961bd 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -614,16 +614,17 @@ in both states. shows "+" (new thread in project); collapse state persisted. Thread rows: single-line truncated AI-generated title (first-message fallback while naming) + relative time (muted 11px); hover = accent bg. With the - **Provider colors** switch on (the default) every thread row, child rows - included, carries a subtle wash of its provider's color instead: the - provider card's accent when one is set, else the built-in brand color, else - a stable palette slot for each user profile or ACP agent (owner: - `Settings::provider_color` in `crates/core`). - The wash is the same hue at three strengths — resting (~8%), hover (~16%) - and selected (~26%) — so hover and selection never change geometry or hue. - Compact rows take only the resting wash; the selected surface and the - waiting-for-approval / waiting-for-input semantic washes replace it. With - the switch off, rows are the neutral surfaces described here. Inline rename + **Provider colors** switch on (the default) every thread row, child and + compact rows included, carries a decorative provider mark painted under + its content: the provider's glyph (the ACP box for agents) at ~1.25× the + row height, vertically centred, anchored to the right edge and pushed + slightly past it so the row's clip cuts it, filled with the provider's + color at low alpha. The color is the provider card's accent when one is + set, else the built-in brand color, else a stable palette slot for each + user profile or ACP agent (owner: `Settings::provider_color` in + `crates/core`). The mark takes no input and changes no geometry; the + row's rest, hover, selected and semantic surfaces are exactly the neutral + ones described here, and with the switch off there is no mark. Inline rename commits on Enter and cancels on blur or any click outside the input. The thread context menu offers **Regenerate title / 重新生成标题** next to Rename in both layouts. It uses the original request and recent conversation @@ -1065,8 +1066,8 @@ label at both widths, since a switch never squeezes the text. Both the compact list and wide rail divide Settings into two captioned groups, in this order: **This device** contains General (appearance — theme, the -**Provider colors** switch for the sidebar's per-provider thread tint — language -and device name) plus Other devices wherever the build can host connections; **‹machine +**Provider colors** switch for the sidebar's provider marks — language and +device name) plus Other devices wherever the build can host connections; **‹machine name› settings** contains Providers, Usage, Orchestrate, Computer Use, Browser and Archived Threads. The attached host's display name supplies ‹machine name›; a local attachment uses the machine name shown by the hosting panel, and a diff --git a/locales/en.yml b/locales/en.yml index ab9be1434..bf36234fc 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -282,7 +282,7 @@ settings: description: "Automatically expand the latest command output while the agent is working." provider_colors: title: "Provider colors" - description: "Tint each thread in the sidebar with its provider's color." + description: "Show each thread's provider mark in its color behind the sidebar row." provider_updates: title: "Provider update checks" description: "Check for newer Tcode and provider CLI versions on launch, and refresh the Claude Code model list from t3code." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 74db4e7f7..fedf3afcd 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -282,7 +282,7 @@ settings: description: "智能体工作时自动展开最新命令的输出。" provider_colors: title: "提供方颜色" - description: "在侧边栏中以所属提供方的颜色为每个对话着色。" + description: "在侧边栏每个对话的背景中以所属提供方的颜色显示其图标。" provider_updates: title: "提供方更新检查" description: "启动时检查 Tcode 和提供方 CLI 的新版本,并从 t3code 刷新 Claude Code 模型列表。" From 37eb9486a3b44dd0e159d2b659ee1ff0287da398 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 16 Sep 2026 21:09:02 +0800 Subject: [PATCH 4/6] Fit the provider mark inside its thread row The glyph is now sized to the row height minus an 8px inset, vertically centred and right-aligned inside the row's own horizontal padding, so nothing overhangs and rows keep their original (unclipped) overflow. Alpha rises to 0.18 to compensate for the smaller mark. --- crates/ui/src/sidebar.rs | 80 ++++++++++++++++++++++++---------------- docs/DESIGN.md | 9 +++-- 2 files changed, 54 insertions(+), 35 deletions(-) diff --git a/crates/ui/src/sidebar.rs b/crates/ui/src/sidebar.rs index fb76f3141..0754837a6 100644 --- a/crates/ui/src/sidebar.rs +++ b/crates/ui/src/sidebar.rs @@ -38,11 +38,11 @@ use crate::window_drag_area; use crate::window_state::{Destination, Route, WindowState}; /// The provider mark behind a thread row: its provider's glyph at this alpha, -/// scaled to this multiple of the row height and pushed past the right edge by -/// this much so the row's clip cuts it. -const PROVIDER_MARK_ALPHA: f32 = 0.14; -const PROVIDER_MARK_SCALE: f32 = 1.25; -const PROVIDER_MARK_OVERHANG: f32 = 6.; +/// sized to the row height minus this vertical inset so it sits inside the row. +const PROVIDER_MARK_ALPHA: f32 = 0.18; +const PROVIDER_MARK_INSET: f32 = 8.; +/// Horizontal padding of the grouped and flat thread rows (`px_2` / `pr_2`). +const THREAD_ROW_PADDING_X: f32 = 8.; /// Left padding on the sidebar's top row so branding clears the native macOS /// traffic lights (ending near x=72 on macOS 26); a small inset elsewhere. @@ -2139,32 +2139,42 @@ impl SessionsSidebar { } /// The decorative provider mark painted under a thread row's content: - /// `meta`'s protocol glyph in its provider color, vertically centred and - /// hanging off the right edge. `None` while Provider colors is off. It has - /// no handlers, so the row's own hover, click and menu are unaffected. - fn provider_mark(&self, meta: &SessionMeta, row_height: f32, cx: &App) -> Option { + /// `meta`'s protocol glyph in its provider color, fitted to the row height + /// minus [`PROVIDER_MARK_INSET`], vertically centred and right-aligned + /// inside the row's `right_padding`. `None` while Provider colors is off. + /// It has no handlers, so the row's own hover, click and menu are unaffected. + fn provider_mark( + &self, + meta: &SessionMeta, + row_height: f32, + right_padding: f32, + cx: &App, + ) -> Option { let color: gpui::Hsla = gpui::rgb(self.store.read(cx).provider_color(meta)?).into(); let glyph = match meta.provider { // ACP agents share the box glyph the composer rail and ACP panel use. agent::ProviderKind::Acp => Icon::empty().path("icons/box.svg"), kind => crate::provider_card::provider_glyph(kind), }; - let size = px((row_height * PROVIDER_MARK_SCALE).round()); + let size = px(row_height - PROVIDER_MARK_INSET); let id = meta.id.clone(); Some( div() .absolute() .top_0() .bottom_0() - .right(px(-PROVIDER_MARK_OVERHANG)) - .w(size) + .right(px(right_padding)) .flex() .items_center() - .debug_selector(move || format!("sidebar-provider-mark-{id}")) .child( - glyph + div() .size(size) - .text_color(color.opacity(PROVIDER_MARK_ALPHA)), + .debug_selector(move || format!("sidebar-provider-mark-{id}")) + .child( + glyph + .size(size) + .text_color(color.opacity(PROVIDER_MARK_ALPHA)), + ), ), ) } @@ -2453,12 +2463,12 @@ impl SessionsSidebar { .items_center() .gap_2() .pl(px(if is_child { 42. } else { 30. })) - .pr_2() + .pr(px(THREAD_ROW_PADDING_X)) .rounded(px(6.)) // First child so the row's content paints over the mark. .when_some( - self.provider_mark(meta, GROUPED_ROW_HEIGHT, cx), - |row, mark| row.relative().overflow_hidden().child(mark), + self.provider_mark(meta, GROUPED_ROW_HEIGHT, THREAD_ROW_PADDING_X, cx), + |row, mark| row.relative().child(mark), ) .when_some( Self::thread_status_badge(&state, working, cx), @@ -2653,12 +2663,13 @@ impl SessionsSidebar { .justify_center() .gap(px(2.)) }) - .px_2() + .px(px(THREAD_ROW_PADDING_X)) .rounded(px(6.)) // First child so the row's content paints over the mark. - .when_some(self.provider_mark(meta, row_height, cx), |row, mark| { - row.relative().overflow_hidden().child(mark) - }); + .when_some( + self.provider_mark(meta, row_height, THREAD_ROW_PADDING_X, cx), + |row, mark| row.relative().child(mark), + ); let row = if is_child { let title_or_input = self.thread_title_or_input(meta, &state, false, cx); @@ -3370,7 +3381,12 @@ impl SessionsSidebar { let click_id = session_id.clone(); let disclosure_id = session_id.clone(); let unavailable = meta.parent_session_id.is_some() && !state.is_child; - let mark = self.provider_mark(meta, crate::material::LIST_ROW_MIN_HEIGHT, cx); + let mark = self.provider_mark( + meta, + crate::material::LIST_ROW_MIN_HEIGHT, + crate::material::COMPACT_PAGE_INSET, + cx, + ); let row = crate::material::list_row(cached.row_id.clone(), cached.label.clone(), cx) .debug_selector({ @@ -3381,9 +3397,7 @@ impl SessionsSidebar { row.pl(px(crate::material::COMPACT_PAGE_INSET + 16.)) }) // First child so the row's content paints over the mark. - .when_some(mark, |row, mark| { - row.relative().overflow_hidden().child(mark) - }) + .when_some(mark, |row, mark| row.relative().child(mark)) .when( self.store.read(cx).active_session_id().as_deref() == Some(session_id.as_str()), |row| row.bg(cx.theme().list_active).aria_selected(true), @@ -4548,12 +4562,16 @@ mod tests { .debug_bounds(mark_selector) .expect("each row carries its provider mark"); assert!( - mark.top() >= row.top() && mark.bottom() <= row.bottom(), - "mark stays within the row's vertical span: {mark:?} in {row:?}" + mark.top() >= row.top() + && mark.bottom() <= row.bottom() + && mark.right() <= row.right() + && mark.left() >= row.left(), + "mark sits entirely inside the row: {mark:?} in {row:?}" ); - assert!( - mark.right() >= row.right(), - "mark hangs off the right edge so the clip cuts it: {mark:?} in {row:?}" + assert_eq!( + mark.size.height, + row.size.height - px(PROVIDER_MARK_INSET), + "mark fits the row height minus the inset" ); } let list_active = cx.update(|_, cx| cx.theme().list_active); diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f354961bd..a2a1b7983 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -616,10 +616,11 @@ in both states. while naming) + relative time (muted 11px); hover = accent bg. With the **Provider colors** switch on (the default) every thread row, child and compact rows included, carries a decorative provider mark painted under - its content: the provider's glyph (the ACP box for agents) at ~1.25× the - row height, vertically centred, anchored to the right edge and pushed - slightly past it so the row's clip cuts it, filled with the provider's - color at low alpha. The color is the provider card's accent when one is + its content: the provider's glyph (the ACP box for agents) in a square box + that fits the row height minus an 8px inset, vertically centred and + right-aligned inside the row's own horizontal padding — nothing overhangs + and the row gains no clipping — filled with the provider's color at low + alpha. The color is the provider card's accent when one is set, else the built-in brand color, else a stable palette slot for each user profile or ACP agent (owner: `Settings::provider_color` in `crates/core`). The mark takes no input and changes no geometry; the From 046575a83f59f7f5c8c76dcadddb333fe284a112 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 16 Sep 2026 21:15:29 +0800 Subject: [PATCH 5/6] Strengthen the provider mark Raise the mark's alpha from 0.18 to 0.32 so the fitted glyph reads on both canvases. --- crates/ui/src/sidebar.rs | 2 +- docs/DESIGN.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/sidebar.rs b/crates/ui/src/sidebar.rs index 0754837a6..1160ffe59 100644 --- a/crates/ui/src/sidebar.rs +++ b/crates/ui/src/sidebar.rs @@ -39,7 +39,7 @@ use crate::window_state::{Destination, Route, WindowState}; /// The provider mark behind a thread row: its provider's glyph at this alpha, /// sized to the row height minus this vertical inset so it sits inside the row. -const PROVIDER_MARK_ALPHA: f32 = 0.18; +const PROVIDER_MARK_ALPHA: f32 = 0.32; const PROVIDER_MARK_INSET: f32 = 8.; /// Horizontal padding of the grouped and flat thread rows (`px_2` / `pr_2`). const THREAD_ROW_PADDING_X: f32 = 8.; diff --git a/docs/DESIGN.md b/docs/DESIGN.md index a2a1b7983..d0fa49f7b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -619,9 +619,10 @@ in both states. its content: the provider's glyph (the ACP box for agents) in a square box that fits the row height minus an 8px inset, vertically centred and right-aligned inside the row's own horizontal padding — nothing overhangs - and the row gains no clipping — filled with the provider's color at low - alpha. The color is the provider card's accent when one is - set, else the built-in brand color, else a stable palette slot for each + and the row gains no clipping — filled with the provider's color at + roughly a third opacity, so it reads without competing with the title. The + color is the provider card's accent when one is set, else the built-in + brand color, else a stable palette slot for each user profile or ACP agent (owner: `Settings::provider_color` in `crates/core`). The mark takes no input and changes no geometry; the row's rest, hover, selected and semantic surfaces are exactly the neutral From f6bb471158242484f441349a5665f66a1cde8591 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 16 Sep 2026 21:23:01 +0800 Subject: [PATCH 6/6] Rename the switch to Provider marks and default it off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `provider_colors_disabled` becomes `sidebar_provider_marks: bool` (`SettingsPatch::SidebarProviderMarks`), absent in legacy files → off. The General row is "Provider marks", non-inverted, with its reset action shown while on. Color assignment (accent → brand → palette) is untouched. --- crates/core/src/settings.rs | 22 +++++++++---------- crates/runtime/src/app/tests.rs | 4 ++-- crates/ui/src/settings_page.rs | 21 +++++++++--------- crates/ui/src/sidebar.rs | 38 ++++++++++++++++++++++----------- crates/ui/src/store/intents.rs | 4 ++-- crates/ui/src/store/mod.rs | 6 +++--- docs/DESIGN.md | 6 +++--- locales/en.yml | 6 +++--- locales/zh-CN.yml | 6 +++--- 9 files changed, 62 insertions(+), 51 deletions(-) diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 9fd3e5457..483286ed2 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -836,7 +836,7 @@ pub enum SettingsPatch { SkipDeleteConfirmation(bool), AutoOpenTaskPanel(bool), LiveCommandPanelDisabled(bool), - ProviderColorsDisabled(bool), + SidebarProviderMarks(bool), ProviderUpdateChecksDisabled(bool), InactiveFrameThrottleDisabled(bool), AbortOnModelFallback(bool), @@ -935,10 +935,10 @@ pub struct Settings { /// legacy settings keep the feature enabled. #[serde(default)] pub live_command_panel_disabled: bool, - /// Whether the sidebar's per-provider thread tint is DISABLED. Stored - /// inverted so absent legacy settings keep the colors on. + /// Whether sidebar thread rows show their provider's mark. Off by + /// default and absent in legacy files. #[serde(default)] - pub provider_colors_disabled: bool, + pub sidebar_provider_marks: bool, /// Whether the on-launch provider version check is DISABLED. Stored inverted /// so it remains enabled for legacy settings files that lack the field. #[serde(default)] @@ -1060,7 +1060,7 @@ impl Default for Settings { skip_delete_confirmation: false, auto_open_task_panel: false, live_command_panel_disabled: false, - provider_colors_disabled: false, + sidebar_provider_marks: false, provider_update_checks_disabled: false, inactive_frame_throttle_disabled: false, abort_on_model_fallback: true, @@ -1104,8 +1104,8 @@ impl Settings { SettingsPatch::LiveCommandPanelDisabled(value) => { self.live_command_panel_disabled = value; } - SettingsPatch::ProviderColorsDisabled(value) => { - self.provider_colors_disabled = value; + SettingsPatch::SidebarProviderMarks(value) => { + self.sidebar_provider_marks = value; } SettingsPatch::ProviderUpdateChecksDisabled(value) => { self.provider_update_checks_disabled = value; @@ -1403,12 +1403,12 @@ mod tests { } #[test] - fn provider_colors_default_on_and_patch_off() { + fn sidebar_provider_marks_default_off_and_patch_on() { let legacy: Settings = serde_json::from_str(r#"{"theme_mode":"system"}"#).unwrap(); - assert!(!legacy.provider_colors_disabled); + assert!(!legacy.sidebar_provider_marks); let mut settings = Settings::default(); - settings.apply(SettingsPatch::ProviderColorsDisabled(true)); - assert!(settings.provider_colors_disabled); + settings.apply(SettingsPatch::SidebarProviderMarks(true)); + assert!(settings.sidebar_provider_marks); } #[test] diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index ccaa7959b..26ab977b4 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -534,7 +534,7 @@ fn reset_settings_clears_preferences_but_keeps_credentials_installs_and_unknown_ theme_mode: ThemeMode::Dark, language: Some("zh-CN".into()), word_wrap_diffs: true, - provider_colors_disabled: true, + sidebar_provider_marks: true, auto_archive_max_idle_days: 99, ..Settings::default() }; @@ -592,7 +592,7 @@ fn reset_settings_clears_preferences_but_keeps_credentials_installs_and_unknown_ assert_eq!(reset.theme_mode, ThemeMode::System); assert_eq!(reset.language, None); assert!(!reset.word_wrap_diffs); - assert!(!reset.provider_colors_disabled); + assert!(!reset.sidebar_provider_marks); assert_eq!(reset.auto_archive_max_idle_days, 7); assert_eq!(reset.browser.home_url, None); diff --git a/crates/ui/src/settings_page.rs b/crates/ui/src/settings_page.rs index ee12bac6a..f02435707 100644 --- a/crates/ui/src/settings_page.rs +++ b/crates/ui/src/settings_page.rs @@ -1220,26 +1220,25 @@ impl SettingsPage { let language_overridden = store.client_language_override().is_some(); let theme_overridden = store.client_theme_override().is_some(); let device_name_overridden = store.client_device_name_override().is_some(); - let provider_colors_reset = self.reset_action( - "reset-provider-colors", - settings.provider_colors_disabled, + let provider_marks_reset = self.reset_action( + "reset-sidebar-provider-marks", + settings.sidebar_provider_marks, cx, |this, _, cx| { - this.dispatch_settings(|store| store.set_provider_colors_disabled(false), cx) + this.dispatch_settings(|store| store.set_sidebar_provider_marks(false), cx) }, ); let appearance = vec![ self.language_row(settings.language.as_deref(), language_overridden, cx), self.theme_row(settings.theme_mode, theme_overridden, cx), self.toggle_row( - "provider-colors", - crate::tr!("settings.provider_colors.title"), - crate::tr!("settings.provider_colors.description"), - // Stored inverted: checked = enabled. - !settings.provider_colors_disabled, - provider_colors_reset, + "sidebar-provider-marks", + crate::tr!("settings.sidebar_provider_marks.title"), + crate::tr!("settings.sidebar_provider_marks.description"), + settings.sidebar_provider_marks, + provider_marks_reset, cx, - |store, checked| store.set_provider_colors_disabled(!checked), + WorkspaceStore::set_sidebar_provider_marks, ), self.device_name_row(device_name_overridden, cx), ]; diff --git a/crates/ui/src/sidebar.rs b/crates/ui/src/sidebar.rs index 1160ffe59..fcf5bde72 100644 --- a/crates/ui/src/sidebar.rs +++ b/crates/ui/src/sidebar.rs @@ -2141,7 +2141,7 @@ impl SessionsSidebar { /// The decorative provider mark painted under a thread row's content: /// `meta`'s protocol glyph in its provider color, fitted to the row height /// minus [`PROVIDER_MARK_INSET`], vertically centred and right-aligned - /// inside the row's `right_padding`. `None` while Provider colors is off. + /// inside the row's `right_padding`. `None` while Provider marks is off. /// It has no handlers, so the row's own hover, click and menu are unaffected. fn provider_mark( &self, @@ -4556,11 +4556,32 @@ mod tests { } }; + let set_marks = |cx: &mut VisualTestContext, enabled: bool| { + store.update(cx, |store, _| store.set_sidebar_provider_marks(enabled)); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while store.read_with(cx, |store, _| store.settings().sidebar_provider_marks) != enabled + { + assert!( + std::time::Instant::now() < deadline, + "setting never replicated" + ); + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + }; + + // Off by default: rows render without a mark. + wait_for_rows(cx); + for (_, mark_selector) in selectors { + assert!(cx.debug_bounds(mark_selector).is_none()); + } + + set_marks(cx, true); let rows = wait_for_rows(cx); for ((_, mark_selector), row) in selectors.iter().zip(&rows) { let mark = cx .debug_bounds(mark_selector) - .expect("each row carries its provider mark"); + .expect("each row carries its provider mark once enabled"); assert!( mark.top() >= row.top() && mark.bottom() <= row.bottom() @@ -4588,21 +4609,12 @@ mod tests { "the active row keeps the neutral selected surface" ); - store.update(cx, |store, _| store.set_provider_colors_disabled(true)); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while !store.read_with(cx, |store, _| store.settings().provider_colors_disabled) { - assert!( - std::time::Instant::now() < deadline, - "setting never replicated" - ); - store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); - std::thread::sleep(std::time::Duration::from_millis(10)); - } + set_marks(cx, false); let rows = wait_for_rows(cx); for (_, mark_selector) in selectors { assert!( cx.debug_bounds(mark_selector).is_none(), - "with colors off no mark is drawn" + "with marks off again no mark is drawn" ); } assert!(painted_active(cx, rows[0])); diff --git a/crates/ui/src/store/intents.rs b/crates/ui/src/store/intents.rs index 4f9321dac..0c0371c1f 100644 --- a/crates/ui/src/store/intents.rs +++ b/crates/ui/src/store/intents.rs @@ -71,8 +71,8 @@ impl WorkspaceStore { pub fn set_live_command_panel_disabled(&mut self, value: bool) { self.patch_settings(SettingsPatch::LiveCommandPanelDisabled(value)); } - pub fn set_provider_colors_disabled(&mut self, value: bool) { - self.patch_settings(SettingsPatch::ProviderColorsDisabled(value)); + pub fn set_sidebar_provider_marks(&mut self, value: bool) { + self.patch_settings(SettingsPatch::SidebarProviderMarks(value)); } pub fn set_provider_update_checks_disabled(&mut self, value: bool) { self.patch_settings(SettingsPatch::ProviderUpdateChecksDisabled(value)); diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index 2486d23d1..05e4e3a8d 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -1653,10 +1653,10 @@ impl WorkspaceStore { !self.settings_replica.live_command_panel_disabled } - /// The `0xRRGGBB` color tinting `meta`'s sidebar row, `None` while the - /// Provider colors setting is off. + /// The `0xRRGGBB` color of `meta`'s sidebar provider mark, `None` while + /// the Provider marks setting is off. pub fn provider_color(&self, meta: &SessionMeta) -> Option { - (!self.settings_replica.provider_colors_disabled).then(|| { + self.settings_replica.sidebar_provider_marks.then(|| { self.settings_replica .provider_color(&meta.provider_color_key()) }) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d0fa49f7b..fab08df51 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -614,7 +614,7 @@ in both states. shows "+" (new thread in project); collapse state persisted. Thread rows: single-line truncated AI-generated title (first-message fallback while naming) + relative time (muted 11px); hover = accent bg. With the - **Provider colors** switch on (the default) every thread row, child and + **Provider marks** switch on (off by default) every thread row, child and compact rows included, carries a decorative provider mark painted under its content: the provider's glyph (the ACP box for agents) in a square box that fits the row height minus an 8px inset, vertically centred and @@ -1068,8 +1068,8 @@ label at both widths, since a switch never squeezes the text. Both the compact list and wide rail divide Settings into two captioned groups, in this order: **This device** contains General (appearance — theme, the -**Provider colors** switch for the sidebar's provider marks — language and -device name) plus Other devices wherever the build can host connections; **‹machine +off-by-default **Provider marks** switch for the sidebar's provider marks — +language and device name) plus Other devices wherever the build can host connections; **‹machine name› settings** contains Providers, Usage, Orchestrate, Computer Use, Browser and Archived Threads. The attached host's display name supplies ‹machine name›; a local attachment uses the machine name shown by the hosting panel, and a diff --git a/locales/en.yml b/locales/en.yml index bf36234fc..f7a2b0d79 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -280,9 +280,9 @@ settings: live_command_panel: title: "Live command panel" description: "Automatically expand the latest command output while the agent is working." - provider_colors: - title: "Provider colors" - description: "Show each thread's provider mark in its color behind the sidebar row." + sidebar_provider_marks: + title: "Provider marks" + description: "Show each thread's provider logo, tinted in the provider's color, at the right edge of its sidebar row." provider_updates: title: "Provider update checks" description: "Check for newer Tcode and provider CLI versions on launch, and refresh the Claude Code model list from t3code." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index fedf3afcd..8e9caa8a5 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -280,9 +280,9 @@ settings: live_command_panel: title: "实时命令面板" description: "智能体工作时自动展开最新命令的输出。" - provider_colors: - title: "提供方颜色" - description: "在侧边栏每个对话的背景中以所属提供方的颜色显示其图标。" + sidebar_provider_marks: + title: "提供方标识" + description: "在侧边栏每个对话行的右侧以提供方颜色显示其 logo。" provider_updates: title: "提供方更新检查" description: "启动时检查 Tcode 和提供方 CLI 的新版本,并从 t3code 刷新 Claude Code 模型列表。"