fix(desktop): derive the loud scope note from the shared settings-scope store

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.
This commit is contained in:
teknium1
2026-09-14 18:40:01 -07:00
committed by Teknium
parent b047b5db4c
commit d7d9209795
3 changed files with 68 additions and 17 deletions

View File

@@ -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 (
<div className={cn('grid gap-2', className)}>
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
@@ -69,12 +71,11 @@ export function SettingsProfileScope({ className }: { className?: string }) {
/>
))}
</div>
{/* 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/<bot>/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 ? (
<p
className={cn(

View File

@@ -1,6 +1,8 @@
import { atom } from 'nanostores'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProfileInfo } from '@/types/hermes'
// Keep store/profile's side-effecting imports inert — same seam as
// store/profile.test.ts.
vi.mock('@/store/gateway', () => ({
@@ -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')

View File

@@ -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/<bot>/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);