diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index a70cfedd8e37..b7caae291fb1 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -2,6 +2,7 @@ import { act, cleanup, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { $desktopBoot } from '@/store/boot' +import { $activeGatewayProfile } from '@/store/profile' import { $gatewayState } from '@/store/session' import { takeGatewaySurvivor } from './gateway-hmr-survivor' @@ -30,6 +31,7 @@ class FakeWebSocket { // errors (a dead remote). Mirrors a VPS going away after the first connect. static mode: 'open' | 'fail' = 'open' static instances: FakeWebSocket[] = [] + static eventOnOpen: null | { payload?: unknown; type: string } = null readyState = 0 private listeners: Record> = {} @@ -43,6 +45,16 @@ class FakeWebSocket { if (willOpen) { this.readyState = FakeWebSocket.OPEN this.emit('open', {}) + + if (FakeWebSocket.eventOnOpen) { + this.emit('message', { + data: JSON.stringify({ + jsonrpc: '2.0', + method: 'event', + params: FakeWebSocket.eventOnOpen + }) + }) + } } else { this.readyState = FakeWebSocket.CLOSED this.emit('error', {}) @@ -76,11 +88,11 @@ class FakeWebSocket { } } -function fakeDesktop() { +function fakeDesktop(profile = 'default') { const conn = { authMode: 'token' as const, baseUrl: 'https://vps.example.com', - profile: 'default', + profile, token: 't', wsUrl: 'wss://vps.example.com/api/ws?token=t' } @@ -109,17 +121,22 @@ function fakeDesktop() { onPowerResume: vi.fn(() => () => undefined), onWindowStateChanged: vi.fn(() => () => undefined), touchBackend: vi.fn(async () => undefined), - profile: { get: vi.fn(async () => ({ profile: 'default' })) } + profile: { get: vi.fn(async () => ({ profile })) } } } function Harness({ beforeConnectionSwitch = () => undefined, + handleGatewayEvent = () => undefined, refreshSessions -}: { beforeConnectionSwitch?: () => void; refreshSessions?: () => Promise } = {}) { +}: { + beforeConnectionSwitch?: () => void + handleGatewayEvent?: (event: { profile?: string; type: string }) => void + refreshSessions?: () => Promise +} = {}) { useGatewayBoot({ beforeConnectionSwitch, - handleGatewayEvent: () => undefined, + handleGatewayEvent, onConnectionReady: () => undefined, onGatewayReady: () => undefined, refreshHermesConfig: async () => undefined, @@ -146,10 +163,12 @@ beforeEach(() => { vi.useFakeTimers() FakeWebSocket.mode = 'open' FakeWebSocket.instances = [] + FakeWebSocket.eventOnOpen = null connectionApplied = null ;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket ;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop() $gatewayState.set('idle') + $activeGatewayProfile.set('default') $desktopBoot.set({ error: null, fakeMode: false, @@ -180,6 +199,7 @@ afterEach(() => { vi.useRealTimers() ;(globalThis as { WebSocket: unknown }).WebSocket = originalWebSocket delete (window as { hermesDesktop?: unknown }).hermesDesktop + $activeGatewayProfile.set('default') }) // Let pending microtasks (awaits) AND the queued 0ms socket open/error fire. @@ -199,6 +219,33 @@ async function advanceBackoff() { } describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => { + it('tags an immediate primary gateway.ready event with the persisted profile', async () => { + const handleGatewayEvent = vi.fn() + FakeWebSocket.eventOnOpen = { type: 'gateway.ready' } + ;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop('work') + + render() + await flushAsync() + + expect(handleGatewayEvent).toHaveBeenCalledWith(expect.objectContaining({ profile: 'work', type: 'gateway.ready' })) + }) + + it('reconnects the primary backend by its owner while a secondary profile is active', async () => { + const desktop = fakeDesktop() + + ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop + + render() + await flushAsync() + expect($gatewayState.get()).toBe('open') + + $activeGatewayProfile.set('work') + act(() => FakeWebSocket.instances[0].drop()) + await advanceBackoff() + + expect(desktop.getConnection).toHaveBeenLastCalledWith('default') + }) + it('INITIAL boot against a dead VPS: getConnection hangs (waitForHermes) → app sits in the connecting combo, then fails', async () => { // The report's actual path: a fresh launch pointed at an unreachable VPS. // startHermes()'s remote branch awaits waitForHermes() for 45s before it diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index f02ee69f5e6d..8798f06d899e 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -114,6 +114,10 @@ export function useGatewayBoot({ let reconnecting = false let reconnectTimer: ReturnType | null = null let reconnectAttempt = 0 + // The profile owned by the primary socket. Keep this separate from the + // foreground profile because the user can view a secondary while primary + // events are still arriving in the background. + let primaryProfile = normalizeProfileKey($activeGatewayProfile.get()) // Surface "sign in again" once per disconnect episode, not on every backoff // tick — a stale OAuth ticket fails every attempt and would otherwise stack // identical error toasts (and their haptics). Reset on the next clean open. @@ -150,7 +154,7 @@ export function useGatewayBoot({ // "Starting Hermes…". The probe is a no-op for a healthy or local backend. await desktop.revalidateConnection?.().catch(() => undefined) - const conn = await desktop.getConnection($activeGatewayProfile.get()) + const conn = await desktop.getConnection(primaryProfile) if (cancelled) { return @@ -239,11 +243,14 @@ export function useGatewayBoot({ try { const pref = await desktop.profile?.get?.() const profileKey = (pref?.profile ?? '').trim() || 'default' + primaryProfile = profileKey $activeGatewayProfile.set(profileKey) setPrimaryGateway(gateway, profileKey) void ensureGatewayForProfile(profileKey) } catch { + primaryProfile = 'default' $activeGatewayProfile.set('default') + setPrimaryGateway(gateway, 'default') } } @@ -285,6 +292,9 @@ export function useGatewayBoot({ } publish(conn) + // Establish the socket's profile before connect(): gateway.ready can + // arrive immediately after the WS opens, before connect() resolves. + await adoptPrimaryProfile() const wsUrl = await resolveGatewayWsUrl(desktop, conn) await gateway.connect(wsUrl) @@ -292,9 +302,6 @@ export function useGatewayBoot({ return } - // Same shape as boot(): profile first (session scope depends on it), - // then the independent fetches concurrently. - await adoptPrimaryProfile() await Promise.all([ seedDefaultCwd(), callbacksRef.current.refreshHermesConfig().catch(() => undefined), @@ -360,7 +367,8 @@ export function useGatewayBoot({ const gateway = adoptedFromHmr ? survivor!.gateway : new HermesGateway() callbacksRef.current.onGatewayReady(gateway) - setPrimaryGateway(gateway, survivor?.profile ?? normalizeProfileKey($activeGatewayProfile.get())) + primaryProfile = normalizeProfileKey(survivor?.profile ?? primaryProfile) + setPrimaryGateway(gateway, primaryProfile) // Secondary (background-profile) sockets funnel into the same handler. configureGatewayRegistry({ onEvent: event => callbacksRef.current.handleGatewayEvent(event) }) @@ -390,10 +398,8 @@ export function useGatewayBoot({ } }) - const sourceProfile = normalizeProfileKey($activeGatewayProfile.get()) - const offEvent = gateway.onEvent(event => - callbacksRef.current.handleGatewayEvent({ ...event, profile: sourceProfile }) + callbacksRef.current.handleGatewayEvent({ ...event, profile: primaryProfile }) ) // Wake signals: power resume (macOS/Windows), network coming back, and the @@ -479,6 +485,9 @@ export function useGatewayBoot({ progress: 95 }) publish(conn) + // Profile first: the backend may emit gateway.ready in the same turn as + // the open event, so its source tag must be established pre-connect. + await adoptPrimaryProfile() // Mint a fresh WS URL right before connecting. For OAuth gateways the // ticket is single-use with a short TTL, so the ticket baked into // conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it rather than @@ -491,13 +500,6 @@ export function useGatewayBoot({ return } - // Profile adoption must land first: refreshSessions scopes its fetch by - // $profileScope ← $activeGatewayProfile. The remaining three fetches - // (cwd seed, config, sessions) are independent REST calls — running - // them serially added their sum to time-to-populated-sidebar when only - // the max is needed. - await adoptPrimaryProfile() - setDesktopBootStep({ phase: 'renderer.config', message: translateNow('boot.steps.loadingSettings'), diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index dfdbdff87b64..7fcdc1ef3f2c 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -63,7 +63,7 @@ import { reportInstallMethodWarning } from '@/store/updates' import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events' // Leaf import (not the `@/themes` barrel) to avoid pulling the ThemeProvider // module graph into the gateway event hot path. -import { ingestBackendSkin } from '@/themes/backend-sync' +import { ingestBackendSkin, ingestGatewayReadySkin } from '@/themes/backend-sync' import type { RpcEvent } from '@/types/hermes' import type { ClientSessionState } from '../../../types' @@ -279,23 +279,26 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { } if (event.type === 'gateway.ready') { - // Seed the active skin into the desktop theme registry without applying, - // so a fresh connect never overrides the user's persisted desktop theme. - ingestBackendSkin((payload as { skin?: HermesSkin } | undefined)?.skin, { apply: false }) + const activeProfile = normalizeProfileKey($activeGatewayProfile.get()) + const sourceProfile = normalizeProfileKey(event.profile ?? activeProfile) + + // Establish the source profile's first-use preference even when this is + // a prewarmed/background socket. The queued apply persists to that + // profile but ThemeProvider paints only when it is the foreground. + ingestGatewayReadySkin((payload as { skin?: HermesSkin } | undefined)?.skin, sourceProfile) + // Backends with the change watcher broadcast pet/cron/sessions change // events; consumers demote their legacy polls to slow backstops. setChangeEventsAvailable(Boolean((payload as { change_events?: boolean } | undefined)?.change_events)) return } else if (event.type === 'skin.changed') { - // A runtime skin switch (Hermes activating an authored skin, or `/skin` - // on another surface). Only the active profile's change repaints. - const fromActiveProfile = - !event.profile || normalizeProfileKey(event.profile) === normalizeProfileKey($activeGatewayProfile.get()) - - if (fromActiveProfile) { - ingestBackendSkin(payload as HermesSkin | undefined, { apply: true }) - } + // Keep every connected profile current. ThemeProvider persists the + // source profile but repaints only when that profile is foreground. + ingestBackendSkin(payload as HermesSkin | undefined, { + apply: true, + profile: normalizeProfileKey(event.profile ?? $activeGatewayProfile.get()) + }) return } else if (event.type === 'pet.changed' || event.type === 'cron.changed' || event.type === 'sessions.changed') { diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/skin-ready-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/skin-ready-event.test.tsx new file mode 100644 index 000000000000..2c0b0dcda3c1 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/skin-ready-event.test.tsx @@ -0,0 +1,94 @@ +import { QueryClient } from '@tanstack/react-query' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { useEffect, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ClientSessionState } from '@/app/types' +import { createClientSessionState } from '@/lib/chat-runtime' +import { $activeGatewayProfile } from '@/store/profile' +import { $pendingSkinApplies, __resetBackendSkinSync } from '@/themes/backend-sync' +import type { RpcEvent } from '@/types/hermes' + +import { useMessageStream } from './index' + +let handleEvent: ((event: RpcEvent) => void) | null = null + +function Harness() { + const activeSessionIdRef = useRef(null) + const sessionStateByRuntimeIdRef = useRef(new Map()) + const queryClientRef = useRef(new QueryClient()) + + const stream = useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession: vi.fn(async () => undefined), + queryClient: queryClientRef.current, + refreshHermesConfig: vi.fn(async () => undefined), + refreshSessions: vi.fn(async () => undefined), + sessionStateByRuntimeIdRef, + updateSessionState: (sessionId, updater) => { + const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState() + const next = updater(current) + sessionStateByRuntimeIdRef.current.set(sessionId, next) + + return next + } + }) + + useEffect(() => { + handleEvent = stream.handleGatewayEvent + }, [stream.handleGatewayEvent]) + + return null +} + +describe('gateway.ready skin adoption', () => { + beforeEach(() => { + handleEvent = null + window.localStorage.clear() + __resetBackendSkinSync() + $activeGatewayProfile.set('default') + }) + + afterEach(() => { + cleanup() + $activeGatewayProfile.set('default') + }) + + it('queues first-use adoption when a prewarmed profile connects in the background', async () => { + render() + await waitFor(() => expect(handleEvent).not.toBeNull()) + + act(() => + handleEvent!({ + payload: { + skin: { + colors: { background: '#101020', ui_accent: '#ff33aa', ui_text: '#eeeeee' }, + name: 'neon' + } + }, + profile: 'work', + type: 'gateway.ready' + }) + ) + + expect($pendingSkinApplies.get()).toEqual([{ name: 'neon', profile: 'work' }]) + }) + + it('queues a runtime skin change for its background source profile', async () => { + render() + await waitFor(() => expect(handleEvent).not.toBeNull()) + + act(() => + handleEvent!({ + payload: { + colors: { background: '#202010', ui_accent: '#33ffaa', ui_text: '#eeeeee' }, + name: 'forest' + }, + profile: 'work', + type: 'skin.changed' + }) + ) + + expect($pendingSkinApplies.get()).toEqual([{ name: 'forest', profile: 'work' }]) + }) +}) diff --git a/apps/desktop/src/themes/backend-sync.test.ts b/apps/desktop/src/themes/backend-sync.test.ts index 373333123a0c..7a6bde1a4062 100644 --- a/apps/desktop/src/themes/backend-sync.test.ts +++ b/apps/desktop/src/themes/backend-sync.test.ts @@ -1,77 +1,93 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { $backendThemes, $pendingSkinApply, __resetBackendSkinSync, ingestBackendSkin } from './backend-sync' +import { + $backendThemes, + $pendingSkinApplies, + __resetBackendSkinSync, + activateBackendSkinProfile, + ingestBackendSkin, + ingestGatewayReadySkin +} from './backend-sync' +import { PROFILE_SKINS_STORAGE_KEY, SKIN_STORAGE_KEY } from './skin-preference' const skin = (name: string) => ({ name, colors: { background: '#101020', ui_accent: '#ff33aa', banner_text: '#eeeeee' } }) +const profileSkin = (name: string, foreground: string) => ({ + name, + colors: { background: '#101020', ui_accent: '#ff33aa', ui_text: foreground } +}) + describe('ingestBackendSkin', () => { - beforeEach(() => __resetBackendSkinSync()) + beforeEach(() => { + window.localStorage.clear() + __resetBackendSkinSync() + }) it('registers a converted skin without applying when apply=false', () => { ingestBackendSkin(skin('neon'), { apply: false }) expect($backendThemes.get().neon?.name).toBe('neon') - expect($pendingSkinApply.get()).toBeNull() + expect($pendingSkinApplies.get()).toEqual([]) }) it('applies a new skin name once', () => { ingestBackendSkin(skin('neon'), { apply: true }) - expect($pendingSkinApply.get()).toBe('neon') + expect($pendingSkinApplies.get()).toEqual([{ name: 'neon', profile: 'default' }]) }) it('does not re-apply the same skin name', () => { ingestBackendSkin(skin('neon'), { apply: true }) - $pendingSkinApply.set(null) + $pendingSkinApplies.set([]) ingestBackendSkin(skin('neon'), { apply: true }) - expect($pendingSkinApply.get()).toBeNull() + expect($pendingSkinApplies.get()).toEqual([]) }) it('applies again when the skin name changes', () => { ingestBackendSkin(skin('neon'), { apply: true }) - $pendingSkinApply.set(null) + $pendingSkinApplies.set([]) ingestBackendSkin(skin('forest'), { apply: true }) - expect($pendingSkinApply.get()).toBe('forest') + expect($pendingSkinApplies.get()).toEqual([{ name: 'forest', profile: 'default' }]) }) it('seed does not paint, but a later same-name skin.changed applies (missed-activation recovery)', () => { // Connect while display.skin is already neon: seed records the baseline // without painting (never stomp the persisted desktop theme on connect). ingestBackendSkin(skin('neon'), { apply: false }) // gateway.ready seed - expect($pendingSkinApply.get()).toBeNull() + expect($pendingSkinApplies.get()).toEqual([]) // The activation event was missed (skin set while disconnected / backend // restarted). Hermes re-affirms it — `hermes config set display.skin neon` // or a `hermes skin set` recolor. That explicit event must repaint even // though the name matches the seed. ingestBackendSkin(skin('neon'), { apply: true }) - expect($pendingSkinApply.get()).toBe('neon') + expect($pendingSkinApplies.get()).toEqual([{ name: 'neon', profile: 'default' }]) // Once applied, a repeat same-name event is a no-op again... - $pendingSkinApply.set(null) + $pendingSkinApplies.set([]) ingestBackendSkin(skin('neon'), { apply: true }) - expect($pendingSkinApply.get()).toBeNull() + expect($pendingSkinApplies.get()).toEqual([]) // ...and a genuine switch still applies. ingestBackendSkin(skin('forest'), { apply: true }) // Hermes authored a new skin - expect($pendingSkinApply.get()).toBe('forest') + expect($pendingSkinApplies.get()).toEqual([{ name: 'forest', profile: 'default' }]) }) it('a reconnect re-seed after a real apply does not downgrade the applied baseline', () => { ingestBackendSkin(skin('neon'), { apply: true }) // applied for real - $pendingSkinApply.set(null) + $pendingSkinApplies.set([]) ingestBackendSkin(skin('neon'), { apply: false }) // reconnect: gateway.ready re-seed ingestBackendSkin(skin('neon'), { apply: true }) // repeat event (e.g. in-place recolor) // Already painted once — the repeat must not re-apply (protects a manual // desktop-side theme switch from being snapped back after a reconnect). - expect($pendingSkinApply.get()).toBeNull() + expect($pendingSkinApplies.get()).toEqual([]) }) it('never registers default in the backend store (desktop keeps its own palette)', () => { @@ -83,27 +99,113 @@ describe('ingestBackendSkin', () => { it('does not apply default on the connect-time seed', () => { ingestBackendSkin(skin('default'), { apply: false }) - expect($pendingSkinApply.get()).toBeNull() + expect($pendingSkinApplies.get()).toEqual([]) }) it('applies a runtime switch back to default (repaints the desktop to its own default)', () => { ingestBackendSkin(skin('neon'), { apply: false }) // gateway.ready seed on some skin ingestBackendSkin(skin('default'), { apply: true }) // Hermes switched back to default - expect($pendingSkinApply.get()).toBe('default') + expect($pendingSkinApplies.get()).toEqual([{ name: 'default', profile: 'default' }]) }) it('does not shadow a built-in name but can still apply it', () => { ingestBackendSkin(skin('mono'), { apply: true }) expect($backendThemes.get().mono).toBeUndefined() - expect($pendingSkinApply.get()).toBe('mono') + expect($pendingSkinApplies.get()).toEqual([{ name: 'mono', profile: 'default' }]) }) it('ignores empty payloads', () => { ingestBackendSkin(undefined, { apply: true }) ingestBackendSkin({ name: '' }, { apply: true }) - expect($pendingSkinApply.get()).toBeNull() + expect($pendingSkinApplies.get()).toEqual([]) + }) + + it('isolates same-name palettes and apply baselines between profiles', () => { + activateBackendSkinProfile('work') + ingestBackendSkin(profileSkin('shared', '#aaaaaa'), { apply: true, profile: 'work' }) + + expect($backendThemes.get().shared?.colors.foreground).toBe('#aaaaaa') + + $pendingSkinApplies.set([]) + ingestBackendSkin(profileSkin('shared', '#bbbbbb'), { apply: false, profile: 'personal' }) + + // A background profile cannot replace the foreground profile's palette or + // downgrade its "already applied" baseline. + expect($backendThemes.get().shared?.colors.foreground).toBe('#aaaaaa') + ingestBackendSkin(profileSkin('shared', '#aaaaaa'), { apply: true, profile: 'work' }) + expect($pendingSkinApplies.get()).toEqual([]) + + activateBackendSkinProfile('personal') + expect($backendThemes.get().shared?.colors.foreground).toBe('#bbbbbb') + + activateBackendSkinProfile('work') + expect($backendThemes.get().shared?.colors.foreground).toBe('#aaaaaa') + }) + + it('coalesces rapid applies per profile without dropping other profiles', () => { + ingestBackendSkin(skin('neon'), { apply: true, profile: 'work' }) + ingestBackendSkin(skin('forest'), { apply: true, profile: 'personal' }) + ingestBackendSkin(skin('mono'), { apply: true, profile: 'work' }) + + expect($pendingSkinApplies.get()).toEqual([ + { name: 'forest', profile: 'personal' }, + { name: 'mono', profile: 'work' } + ]) + }) +}) + +describe('ingestGatewayReadySkin', () => { + beforeEach(() => { + window.localStorage.clear() + __resetBackendSkinSync() + }) + + it('adopts display.skin when Desktop has no persisted choice', () => { + ingestGatewayReadySkin(skin('neon'), 'default') + + expect($backendThemes.get().neon?.name).toBe('neon') + expect($pendingSkinApplies.get()).toEqual([{ name: 'neon', profile: 'default' }]) + }) + + it('keeps backend default as no palette opinion', () => { + ingestGatewayReadySkin(skin('default'), 'default') + + expect($pendingSkinApplies.get()).toEqual([]) + }) + + it('preserves a persisted default-profile choice', () => { + window.localStorage.setItem(SKIN_STORAGE_KEY, 'mono') + + ingestGatewayReadySkin(skin('neon'), 'default') + + expect($backendThemes.get().neon?.name).toBe('neon') + expect($pendingSkinApplies.get()).toEqual([]) + }) + + it('preserves a named profile choice', () => { + window.localStorage.setItem(PROFILE_SKINS_STORAGE_KEY, JSON.stringify({ work: 'mono' })) + + ingestGatewayReadySkin(skin('neon'), 'work') + + expect($pendingSkinApplies.get()).toEqual([]) + }) + + it('preserves the global choice inherited by an unassigned named profile', () => { + window.localStorage.setItem(SKIN_STORAGE_KEY, 'slate') + + ingestGatewayReadySkin(skin('neon'), 'work') + + expect($pendingSkinApplies.get()).toEqual([]) + }) + + it('does not let another profile assignment block first-use adoption', () => { + window.localStorage.setItem(PROFILE_SKINS_STORAGE_KEY, JSON.stringify({ personal: 'mono' })) + + ingestGatewayReadySkin(skin('neon'), 'work') + + expect($pendingSkinApplies.get()).toEqual([{ name: 'neon', profile: 'work' }]) }) }) diff --git a/apps/desktop/src/themes/backend-sync.ts b/apps/desktop/src/themes/backend-sync.ts index 9a988730f10f..54e47a7ade8e 100644 --- a/apps/desktop/src/themes/backend-sync.ts +++ b/apps/desktop/src/themes/backend-sync.ts @@ -8,12 +8,13 @@ * 1. Registers the converted theme in `$backendThemes` so it appears wherever a * built-in does — Appearance, Cmd-K, `/skin` — with no per-surface wiring * (`listAllThemes` merges this store). - * 2. When asked to apply (an explicit change), requests the switch via - * `$pendingSkinApply`, which the ThemeProvider drains through `setTheme`. + * 2. When asked to apply (a first-use backend choice or an explicit runtime + * change), queues the switch in `$pendingSkinApplies`, which the + * ThemeProvider drains through `setTheme`. * - * `gateway.ready` seeds the baseline WITHOUT applying, so a fresh connect never - * stomps the user's persisted desktop theme; only a genuine name change (Hermes - * authoring/activating a skin from a prompt, or `/skin` elsewhere) repaints. + * `gateway.ready` adopts a concrete backend skin only when Desktop has no + * persisted choice for the active profile. Otherwise it only seeds the + * registry, so reconnects never stomp the user's Desktop preference. */ import type { HermesSkin } from '@hermes/shared/skin' @@ -21,36 +22,55 @@ import { atom } from 'nanostores' import { BUILTIN_THEMES } from './presets' import { skinToDesktopTheme } from './skin' +import { hasStoredSkinPreference } from './skin-preference' import type { DesktopTheme } from './types' /** Skins pushed by the backend, keyed by name. Merged by `listAllThemes`. */ export const $backendThemes = atom>({}) -/** One-shot skin name the ThemeProvider should switch to (it clears this). */ -export const $pendingSkinApply = atom(null) +export interface PendingSkinApply { + name: string + profile: string +} + +/** Profile-scoped switches the ThemeProvider should drain. */ +export const $pendingSkinApplies = atom([]) + +// Background gateways remain live, so both their theme registries and apply +// guards must remain profile-scoped. Only the active profile's themes are +// published through $backendThemes; the rest stay cached until activation. +const themesByProfile = new Map>() +const lastSyncedByProfile = new Map() +let activeThemeProfile = 'default' + +const normalizeProfile = (profile: string | null | undefined): string => (profile ?? '').trim() || 'default' -// Last skin name synced from the backend + whether it was ever APPLIED (vs -// merely seeded at connect). Once applied, only a name change applies again — -// no re-apply on repeat events, no snap-back after a manual desktop switch. -// A `skin.changed` matching a seed-only baseline still applies: the seed -// records without painting, so if the activation event was missed (backend -// restart / disconnected), an explicit re-affirm must repaint, not no-op. -let lastSynced: { applied: boolean; name: string } | null = null +export function activateBackendSkinProfile(profile: string): void { + const key = normalizeProfile(profile) + activeThemeProfile = key + $backendThemes.set(themesByProfile.get(key) ?? {}) +} /** Test-only: reset the module's apply guard + registry between cases. */ export function __resetBackendSkinSync(): void { - lastSynced = null + themesByProfile.clear() + lastSyncedByProfile.clear() + activeThemeProfile = 'default' $backendThemes.set({}) - $pendingSkinApply.set(null) + $pendingSkinApplies.set([]) } /** - * Fold a resolved skin into the desktop. `apply: false` (connect-time seed) only - * records the baseline; `apply: true` (runtime change / poll) repaints on a name - * change. Built-in names keep the desktop's own palette but can still be applied. + * Fold a resolved skin into the desktop. `apply: false` only records the + * baseline; `apply: true` repaints on a name change. Built-in names keep the + * desktop's own palette but can still be applied. */ -export function ingestBackendSkin(skin: HermesSkin | undefined | null, { apply }: { apply: boolean }): void { +export function ingestBackendSkin( + skin: HermesSkin | undefined | null, + { apply, profile = 'default' }: { apply: boolean; profile?: string } +): void { const name = (skin && typeof skin === 'object' ? (skin.name ?? '') : '').trim() + const profileKey = normalizeProfile(profile) if (!name) { return @@ -70,25 +90,51 @@ export function ingestBackendSkin(skin: HermesSkin | undefined | null, { apply } return } - const current = $backendThemes.get() + const current = themesByProfile.get(profileKey) ?? {} if (JSON.stringify(current[name]) !== JSON.stringify(theme)) { - $backendThemes.set({ ...current, [name]: theme }) + const next = { ...current, [name]: theme } + themesByProfile.set(profileKey, next) + + if (profileKey === activeThemeProfile) { + $backendThemes.set(next) + } } } if (!apply) { // Connect-time seed: record without painting. A reconnect re-seed keeps an // earlier real apply's flag so repeat events can't override a manual switch. + const lastSynced = lastSyncedByProfile.get(profileKey) + if (lastSynced?.name !== name || !lastSynced.applied) { - lastSynced = { applied: false, name } + lastSyncedByProfile.set(profileKey, { applied: false, name }) } return } + const lastSynced = lastSyncedByProfile.get(profileKey) + if (name !== lastSynced?.name || !lastSynced.applied) { - lastSynced = { applied: true, name } - $pendingSkinApply.set(name) + lastSyncedByProfile.set(profileKey, { applied: true, name }) + + // Keep the latest command for each profile without dropping commands from + // other profiles whose gateway events arrived in the same React turn. + const pending = $pendingSkinApplies.get().filter(item => item.profile !== profileKey) + $pendingSkinApplies.set([...pending, { name, profile: profileKey }]) } } + +/** + * Register the active backend skin at connect time and adopt it only when + * Desktop has no persisted appearance choice for the active profile. + */ +export function ingestGatewayReadySkin(skin: HermesSkin | undefined | null, profile: string): void { + const name = (skin && typeof skin === 'object' ? (skin.name ?? '') : '').trim() + + ingestBackendSkin(skin, { + apply: name !== 'default' && !hasStoredSkinPreference(profile), + profile + }) +} diff --git a/apps/desktop/src/themes/context.test.tsx b/apps/desktop/src/themes/context.test.tsx index 860bacfab084..2be7dcb37f16 100644 --- a/apps/desktop/src/themes/context.test.tsx +++ b/apps/desktop/src/themes/context.test.tsx @@ -1,8 +1,12 @@ import { act, cleanup, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { __resetBackendSkinSync, ingestBackendSkin } from './backend-sync' +import { $activeGatewayProfile } from '@/store/profile' + +import { __resetBackendSkinSync, activateBackendSkinProfile, ingestBackendSkin } from './backend-sync' import { ThemeProvider } from './context' +import { DEFAULT_SKIN_NAME } from './presets' +import { PROFILE_SKINS_STORAGE_KEY } from './skin-preference' // The live-authoring loop: Hermes writes/edits one skin file and every surface // repaints. An in-place edit keeps the NAME — only the palette moves. @@ -17,9 +21,13 @@ describe('ThemeProvider ← backend skin sync', () => { beforeEach(() => { window.localStorage.clear() __resetBackendSkinSync() + $activeGatewayProfile.set('default') }) - afterEach(cleanup) + afterEach(() => { + cleanup() + $activeGatewayProfile.set('default') + }) it('applies an activated backend skin', () => { render( @@ -67,4 +75,50 @@ describe('ThemeProvider ← backend skin sync', () => { ) expect(cssVar('--theme-foreground')).toBe('#ff9f0a') }) + + it('drains rapid applies to each source profile after the foreground switches', () => { + activateBackendSkinProfile('work') + ingestBackendSkin(bloomberg('#ff9f0a'), { apply: true, profile: 'work' }) + ingestBackendSkin({ name: 'mono', colors: {} }, { apply: true, profile: 'personal' }) + $activeGatewayProfile.set('personal') + + render( + +
+ + ) + + const stored = JSON.parse(window.localStorage.getItem(PROFILE_SKINS_STORAGE_KEY) ?? '{}') as Record + + expect(stored.work).toBe('bloomberg') + expect(stored.personal).toBe('mono') + expect(cssVar('--theme-foreground')).not.toBe('#ff9f0a') + expect(window.document.documentElement.dataset.hermesTheme).toBe('mono') + + act(() => $activeGatewayProfile.set('work')) + + expect(cssVar('--theme-foreground')).toBe('#ff9f0a') + }) + + it('routes a queued reset to default without repainting the new foreground profile', () => { + window.localStorage.setItem(PROFILE_SKINS_STORAGE_KEY, JSON.stringify({ work: 'mono' })) + ingestBackendSkin({ name: 'default', colors: {} }, { apply: true, profile: 'work' }) + $activeGatewayProfile.set('personal') + + render( + +
+ + ) + + const stored = JSON.parse(window.localStorage.getItem(PROFILE_SKINS_STORAGE_KEY) ?? '{}') as Record + + expect(stored.work).toBe(DEFAULT_SKIN_NAME) + expect(stored.personal).toBeUndefined() + expect(window.document.documentElement.dataset.hermesTheme).toBe(DEFAULT_SKIN_NAME) + + act(() => $activeGatewayProfile.set('work')) + + expect(window.document.documentElement.dataset.hermesTheme).toBe(DEFAULT_SKIN_NAME) + }) }) diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index e618534c3107..e87e621027bd 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -17,20 +17,14 @@ import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query' import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage' import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' -import { $backendThemes, $pendingSkinApply } from './backend-sync' +import { $backendThemes, $pendingSkinApplies, activateBackendSkinProfile } from './backend-sync' import { hexToRgb, mix, readableOn } from './color' import { BUILTIN_THEME_LIST, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets' +import { PROFILE_SKINS_STORAGE_KEY, SKIN_STORAGE_KEY } from './skin-preference' import type { DesktopTheme, DesktopThemeColors } from './types' import { $userThemes, listAllThemes, resolveTheme } from './user-themes' -// Legacy global skin (pre per-profile themes). Still the inheritance fallback -// for any profile without its own assignment, so single-profile users and old -// installs are unaffected. -const SKIN_KEY = 'hermes-desktop-theme-v2' const MODE_KEY = 'hermes-desktop-mode-v1' -// Per-profile skin + light/dark mode assignments: { [profileKey]: value }. A -// profile inherits the global default until it's given its own appearance. -const PROFILE_SKINS_KEY = 'hermes-desktop-profile-themes-v1' const PROFILE_MODES_KEY = 'hermes-desktop-profile-modes-v1' // Last active profile, recorded so the boot-time paint can pick that profile's // theme before the gateway reports which profile actually launched. @@ -66,7 +60,7 @@ const profilePref = (record: string, legacy: string, normalize } }) -export const skinPref = profilePref(PROFILE_SKINS_KEY, SKIN_KEY, normalizeSkin) +export const skinPref = profilePref(PROFILE_SKINS_STORAGE_KEY, SKIN_STORAGE_KEY, normalizeSkin) export const modePref = profilePref(PROFILE_MODES_KEY, MODE_KEY, normalizeMode) // Last active profile — lets the boot paint pick its appearance before the @@ -346,6 +340,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) { // Follow profile switches: paint the profile's assigned skin + mode and // remember it for the next boot's first paint. useEffect(() => { + activateBackendSkinProfile(profileKey) rememberActiveProfileKey(profileKey) setThemeNameState(skinPref.resolve(profileKey)) setModeState(modePref.resolve(profileKey)) @@ -387,17 +382,32 @@ export function ThemeProvider({ children }: { children: ReactNode }) { modePref.assign(liveProfile(), next) }, []) - // Drain a backend-driven skin switch (Hermes authoring/activating a skin from a - // prompt, or `/skin` on another surface). setTheme persists it per profile, so - // the choice sticks like any manual pick. - const pendingSkin = useStore($pendingSkinApply) + // Drain a backend-driven skin switch (first-use display.skin adoption, Hermes + // authoring/activating a skin from a prompt, or `/skin` on another surface). + // setTheme persists it per profile, so the choice sticks like any manual pick. + const pendingSkins = useStore($pendingSkinApplies) useEffect(() => { - if (pendingSkin) { - setTheme(pendingSkin) - $pendingSkinApply.set(null) + if (pendingSkins.length > 0) { + const activeProfile = liveProfile() + + for (const pendingSkin of pendingSkins) { + const targetProfile = normalizeProfileKey(pendingSkin.profile) + const next = RETIRED_SKINS.has(pendingSkin.name) ? DEFAULT_SKIN_NAME : pendingSkin.name + + // Persist to each event's source profile even if the foreground + // switched before this effect drained. Only repaint the profile that is + // still active, preventing queued background choices from leaking. + skinPref.assign(targetProfile, next) + + if (targetProfile === activeProfile) { + setThemeNameState(next) + } + } + + $pendingSkinApplies.set([]) } - }, [pendingSkin, setTheme]) + }, [pendingSkins]) // The light/dark toggle (Shift+X by default) is owned by the keybind runtime // (`appearance.toggleMode`) so it shows up in the hotkey map and is rebindable. diff --git a/apps/desktop/src/themes/skin-preference.ts b/apps/desktop/src/themes/skin-preference.ts new file mode 100644 index 000000000000..a9046830cde1 --- /dev/null +++ b/apps/desktop/src/themes/skin-preference.ts @@ -0,0 +1,22 @@ +import { storedString, storedStringRecord } from '@/lib/storage' + +// Legacy global skin (and the default profile's slot). +export const SKIN_STORAGE_KEY = 'hermes-desktop-theme-v2' +// Per-profile skin assignments. Named profiles inherit the global slot until +// they receive their own explicit appearance. +export const PROFILE_SKINS_STORAGE_KEY = 'hermes-desktop-profile-themes-v1' + +/** + * Whether Desktop already owns a persisted skin choice for this profile. + * + * Check raw presence rather than theme resolution: a backend-authored skin is + * not registered until `gateway.ready`, so resolving its stored name during + * boot would incorrectly classify a real user choice as absent. + */ +export function hasStoredSkinPreference(profile: string): boolean { + if (storedString(SKIN_STORAGE_KEY) !== null) { + return true + } + + return profile !== 'default' && Object.hasOwn(storedStringRecord(PROFILE_SKINS_STORAGE_KEY), profile) +}