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..483286ed2 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 { @@ -181,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 @@ -820,6 +836,7 @@ pub enum SettingsPatch { SkipDeleteConfirmation(bool), AutoOpenTaskPanel(bool), LiveCommandPanelDisabled(bool), + SidebarProviderMarks(bool), ProviderUpdateChecksDisabled(bool), InactiveFrameThrottleDisabled(bool), AbortOnModelFallback(bool), @@ -918,6 +935,10 @@ pub struct Settings { /// legacy settings keep the feature enabled. #[serde(default)] pub live_command_panel_disabled: bool, + /// Whether sidebar thread rows show their provider's mark. Off by + /// default and absent in legacy files. + #[serde(default)] + 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)] @@ -1039,6 +1060,7 @@ impl Default for Settings { skip_delete_confirmation: false, auto_open_task_panel: false, live_command_panel_disabled: false, + sidebar_provider_marks: false, provider_update_checks_disabled: false, inactive_frame_throttle_disabled: false, abort_on_model_fallback: true, @@ -1082,6 +1104,9 @@ impl Settings { SettingsPatch::LiveCommandPanelDisabled(value) => { self.live_command_panel_disabled = value; } + SettingsPatch::SidebarProviderMarks(value) => { + self.sidebar_provider_marks = value; + } SettingsPatch::ProviderUpdateChecksDisabled(value) => { self.provider_update_checks_disabled = value; } @@ -1287,6 +1312,33 @@ impl Settings { } } + /// The `0xRRGGBB` color for a provider color key (see + /// [`SessionMeta::provider_color_key`](crate::project::SessionMeta::provider_color_key)). + /// 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; + } + 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 +1402,110 @@ mod tests { ); } + #[test] + fn sidebar_provider_marks_default_off_and_patch_on() { + let legacy: Settings = serde_json::from_str(r#"{"theme_mode":"system"}"#).unwrap(); + assert!(!legacy.sidebar_provider_marks); + let mut settings = Settings::default(); + settings.apply(SettingsPatch::SidebarProviderMarks(true)); + assert!(settings.sidebar_provider_marks); + } + + #[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 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/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index 41d7f71e3..26ab977b4 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, + sidebar_provider_marks: 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.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/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/settings_page.rs b/crates/ui/src/settings_page.rs index d3b23ed3c..f02435707 100644 --- a/crates/ui/src/settings_page.rs +++ b/crates/ui/src/settings_page.rs @@ -1220,9 +1220,26 @@ 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_marks_reset = self.reset_action( + "reset-sidebar-provider-marks", + settings.sidebar_provider_marks, + cx, + |this, _, 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( + "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, + WorkspaceStore::set_sidebar_provider_marks, + ), 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..fcf5bde72 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}; +/// 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.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.; + /// 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")] @@ -50,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 @@ -2127,6 +2138,47 @@ 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 marks 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_INSET); + let id = meta.id.clone(); + Some( + div() + .absolute() + .top_0() + .bottom_0() + .right(px(right_padding)) + .flex() + .items_center() + .child( + div() + .size(size) + .debug_selector(move || format!("sidebar-provider-mark-{id}")) + .child( + glyph + .size(size) + .text_color(color.opacity(PROVIDER_MARK_ALPHA)), + ), + ), + ) + } + fn thread_clickable_row( &self, base: gpui::Div, @@ -2407,12 +2459,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() + .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, THREAD_ROW_PADDING_X, cx), + |row, mark| row.relative().child(mark), + ) .when_some( Self::thread_status_badge(&state, working, cx), |row, badge| row.child(badge), @@ -2577,6 +2634,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( @@ -2591,10 +2653,23 @@ 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.))) - .px_2() - .rounded(px(6.)); + .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(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, 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); @@ -3306,6 +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, + crate::material::COMPACT_PAGE_INSET, + cx, + ); let row = crate::material::list_row(cached.row_id.clone(), cached.label.clone(), cx) .debug_selector({ @@ -3315,6 +3396,8 @@ impl SessionsSidebar { .when(state.is_child, |row| { 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().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), @@ -4411,6 +4494,133 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[gpui::test] + 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-mark-{}", + 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())); + // (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); + 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, + "thread rows never rendered" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + }; + + 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 once enabled"); + assert!( + 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_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); + 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_active(cx, rows[0]), + "the active row keeps the neutral selected surface" + ); + + set_marks(cx, false); + let rows = wait_for_rows(cx); + for (_, mark_selector) in selectors { + assert!( + cx.debug_bounds(mark_selector).is_none(), + "with marks off again no mark is drawn" + ); + } + assert!(painted_active(cx, rows[0])); + 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..0c0371c1f 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_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 718b4315e..05e4e3a8d 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 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.sidebar_provider_marks.then(|| { + self.settings_replica + .provider_color(&meta.provider_color_key()) + }) + } + pub fn archived_groups(&self) -> Vec { let archived: Vec<_> = self .index_replica @@ -1959,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 0b98eb55c..fab08df51 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -613,7 +613,20 @@ 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 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 + right-aligned inside the row's own horizontal padding — nothing overhangs + 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 + 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 @@ -1054,8 +1067,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 +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 040c67f19..f7a2b0d79 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." + 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 c85e15166..8e9caa8a5 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -280,6 +280,9 @@ settings: live_command_panel: title: "实时命令面板" description: "智能体工作时自动展开最新命令的输出。" + sidebar_provider_marks: + title: "提供方标识" + description: "在侧边栏每个对话行的右侧以提供方颜色显示其 logo。" provider_updates: title: "提供方更新检查" description: "启动时检查 Tcode 和提供方 CLI 的新版本,并从 t3code 刷新 Claude Code 模型列表。"