From d7d920979516a700ae0ef9df6b79e739fadeb634 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:40:01 -0700 Subject: [PATCH] fix(desktop): derive the loud scope note from the shared settings-scope store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite of the original component-local `editingNonDefault` computation onto the existing scope architecture (owner's ask: "use the same existing architecture" as the Capabilities/toolset scope handling). - store/settings-scope.ts gains `$settingsScopeEditsNonDefault`, a computed over `$settingsScopeProfile` × `$profiles`, so "the settings pages are editing a non-default profile" is a store fact any surface can subscribe to, not a per-component recomputation. - profile-scope.tsx reads `$settingsScopeProfile` for the selected chip (instead of re-deriving `override ?? active`) and the new selector for the loud/quiet note. User-visible outcome is unchanged: accented note for any non-default target, override or not; quiet note for an explicit override onto the default; nothing when following the active default. - Review: an unloaded roster (no `is_default` entry yet) used to suppress the note exactly at the landing moment where a bot may already be the active profile. The selector now assumes the root profile's canonical key as the default, so an unknown default fails loud, not quiet. The four-cell truth table is documented at the JSX conditional. - settings-scope.test.ts pins the selector across active-profile, override and unloaded-roster inputs; the component tests from the original PR are kept as-is and still pass. --- .../src/app/settings/profile-scope.tsx | 27 ++++++------ apps/desktop/src/store/settings-scope.test.ts | 42 +++++++++++++++++-- apps/desktop/src/store/settings-scope.ts | 16 ++++++- 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/app/settings/profile-scope.tsx b/apps/desktop/src/app/settings/profile-scope.tsx index 451d3943eb..4e5c8667b1 100644 --- a/apps/desktop/src/app/settings/profile-scope.tsx +++ b/apps/desktop/src/app/settings/profile-scope.tsx @@ -3,8 +3,13 @@ import { useEffect } from 'react' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' -import { $activeGatewayProfile, $profiles, normalizeProfileKey, refreshProfiles } from '@/store/profile' -import { $settingsScopeOverride, setSettingsScope } from '@/store/settings-scope' +import { $profiles, normalizeProfileKey, refreshProfiles } from '@/store/profile' +import { + $settingsScopeEditsNonDefault, + $settingsScopeOverride, + $settingsScopeProfile, + setSettingsScope +} from '@/store/settings-scope' // The same chip affordance the Gateway page uses for its per-profile // connection overrides (gateway-settings ScopeChip). That one stays local to @@ -37,7 +42,8 @@ export function SettingsProfileScope({ className }: { className?: string }) { const { t } = useI18n() const scope = t.settings.profileScope const override = useStore($settingsScopeOverride) - const active = useStore($activeGatewayProfile) + const selected = useStore($settingsScopeProfile) + const editingNonDefault = useStore($settingsScopeEditsNonDefault) const profiles = useStore($profiles) // Refresh lazily so a profile created elsewhere shows up; the cached list @@ -50,10 +56,6 @@ export function SettingsProfileScope({ className }: { className?: string }) { return null } - const selected = normalizeProfileKey(override ?? active) - const defaultProfile = profiles.find(profile => profile.is_default) - const editingNonDefault = defaultProfile ? selected !== normalizeProfileKey(defaultProfile.name) : false - return (
@@ -69,12 +71,11 @@ export function SettingsProfileScope({ className }: { className?: string }) { /> ))}
- {/* The scope follows the app's ACTIVE profile when no override is set — - which, after opening any Bot Mode chat, is the BOT's profile. Users - reasonably assume Settings edit their main config, so an edit landing - in profiles//config.yaml with only a faint chip tint as the tell - is a silent misdirect (the #89190/#89162 report class). Always state - the target when it isn't the default profile, and make it loud. */} + {/* Note truth table (override × non-default target, per the store's + $settingsScopeEditsNonDefault): non-default target → loud accented + note whether or not an override is set (the bot-active misdirect); + explicit override onto the default → quiet tertiary note; following + the active DEFAULT profile → no note. */} {override !== null || editingNonDefault ? (

({ @@ -16,14 +18,20 @@ vi.mock('@/hermes', () => ({ vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() })) vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() })) -const { $activeGatewayProfile } = await import('./profile') +const { $activeGatewayProfile, $profiles } = await import('./profile') -const { $settingsRequestProfile, $settingsScopeOverride, $settingsScopeProfile, setSettingsScope } = - await import('./settings-scope') +const { + $settingsRequestProfile, + $settingsScopeEditsNonDefault, + $settingsScopeOverride, + $settingsScopeProfile, + setSettingsScope +} = await import('./settings-scope') beforeEach(() => { $activeGatewayProfile.set('default') $settingsScopeOverride.set(null) + $profiles.set([]) }) describe('settings scope store', () => { @@ -72,6 +80,34 @@ describe('settings scope store', () => { expect($settingsRequestProfile.get()).toBeUndefined() }) + it('flags a non-default edit target whether it comes from the active profile or an override', () => { + const roster = [ + { is_default: true, name: 'default' }, + { is_default: false, name: 'scout' } + ] as unknown as ProfileInfo[] + $profiles.set(roster) + + // Following the active DEFAULT profile → editing the default. + expect($settingsScopeEditsNonDefault.get()).toBe(false) + + // A Bot Mode chat made the bot the active profile; no override is set, + // yet the settings pages now edit profiles/scout/config.yaml. + $activeGatewayProfile.set('scout') + expect($settingsScopeEditsNonDefault.get()).toBe(true) + + // Explicit override back onto the default → editing the default again. + setSettingsScope('default') + expect($settingsScopeEditsNonDefault.get()).toBe(false) + }) + + it('treats an unloaded roster as "default = the root profile", so an unknown default fails loud', () => { + $activeGatewayProfile.set('scout') + expect($settingsScopeEditsNonDefault.get()).toBe(true) + + $activeGatewayProfile.set('default') + expect($settingsScopeEditsNonDefault.get()).toBe(false) + }) + it('drops the override on an app-wide profile switch', () => { setSettingsScope('research') expect($settingsScopeOverride.get()).toBe('research') diff --git a/apps/desktop/src/store/settings-scope.ts b/apps/desktop/src/store/settings-scope.ts index 115707b8d5..fe9f8f99a7 100644 --- a/apps/desktop/src/store/settings-scope.ts +++ b/apps/desktop/src/store/settings-scope.ts @@ -1,6 +1,6 @@ import { atom, computed } from 'nanostores' -import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' // ── Shared settings "Applies to" scope ────────────────────────────────────── // One selection shared by every config-backed settings page (Model, Workspace, @@ -16,6 +16,20 @@ export const $settingsScopeProfile = computed([$settingsScopeOverride, $activeGa normalizeProfileKey(override ?? active) ) +// Whether the settings pages are editing a profile OTHER than the default +// one. The scope follows the app's active profile when no override is set — +// which, after opening any Bot Mode chat, is the BOT's profile — so an edit +// can land in profiles//config.yaml while the user believes they are +// editing their main config (#89190/#89162 class). Surfaces render this +// loudly. Until the roster has loaded (no is_default entry yet) the root +// profile's canonical key is assumed, so an unknown default fails loud, not +// quiet. +export const $settingsScopeEditsNonDefault = computed([$settingsScopeProfile, $profiles], (selected, profiles) => { + const defaultProfile = profiles.find(profile => profile.is_default) + + return selected !== normalizeProfileKey(defaultProfile?.name) +}) + // ── Request-scope form (THE value to hand to API helpers) ────────────────── // The store contract and the API contract disagree about `null`: // - here, `null` means "follow the app's active profile" (no override);