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:
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user