diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index c7dcee95..f7a78c7f 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -35,6 +35,7 @@ import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus import { useProviderModels } from "@/hooks/use-provider-models" import { useChatGPT } from "@/hooks/useChatGPT" import { ModelSelector, type ModelRef } from "@/components/model-selector" +import { useModels } from "@/hooks/use-models" type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "advanced" | "help" @@ -2657,7 +2658,12 @@ export function SettingsDialog({ children, defaultTab = "account", open: control const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) - const [rowboatConnected, setRowboatConnected] = useState(false) + // Sign-in state comes from the shared model store (single source of + // truth), which refetches on the oauth:didConnect broadcast — emitted on + // BOTH connect and disconnect (disconnectProvider sends success:false). + // A dialog-open-time snapshot here went stale when the user signed out + // with the dialog open, leaving the Models tab on the signed-in section. + const { isRowboatConnected: rowboatConnected } = useModels() // Reset to the requested default tab each time the dialog is opened useEffect(() => { @@ -2667,17 +2673,6 @@ export function SettingsDialog({ children, defaultTab = "account", open: control } }, [open, defaultTab]) - // Check if user is signed in to Rowboat - useEffect(() => { - if (!open) return - window.ipc.invoke('oauth:getState', null).then((result) => { - const connected = result.config?.rowboat?.connected ?? false - setRowboatConnected(connected) - }).catch(() => { - setRowboatConnected(false) - }) - }, [open]) - // Hybrid mode: the Models tab is shown in both modes — signed-in users can // pick gateway models AND bring their own providers/models alongside. const visibleTabs = tabs diff --git a/apps/x/apps/renderer/src/hooks/use-models.test.tsx b/apps/x/apps/renderer/src/hooks/use-models.test.tsx index ec87d092..a1819c68 100644 --- a/apps/x/apps/renderer/src/hooks/use-models.test.tsx +++ b/apps/x/apps/renderer/src/hooks/use-models.test.tsx @@ -4,12 +4,19 @@ import { __resetModelsForTests, useModels } from './use-models' // The hook wires a module-level store to window.ipc, so the tests stub the // preload surface: `invoke` routes by channel through a per-test handler map -// and counts calls per channel to observe the store's fetch dedupe. +// and counts calls per channel to observe the store's fetch dedupe; `on` +// captures listeners so tests can fire main-process broadcasts. let handlers: Record Promise> = {} let invokeCounts: Record = {} +let ipcListeners: Record void>> = {} ;(window as unknown as { ipc: unknown }).ipc = { - on: () => () => undefined, + on: (channel: string, handler: (payload: unknown) => void) => { + ;(ipcListeners[channel] ??= []).push(handler) + return () => { + ipcListeners[channel] = (ipcListeners[channel] ?? []).filter((h) => h !== handler) + } + }, invoke: (channel: string, args: unknown) => { invokeCounts[channel] = (invokeCounts[channel] ?? 0) + 1 const handler = handlers[channel] @@ -34,6 +41,7 @@ beforeEach(() => { __resetModelsForTests() handlers = {} invokeCounts = {} + ipcListeners = {} }) describe('useModels', () => { @@ -72,6 +80,38 @@ describe('useModels', () => { expect(invokeCounts['models:list']).toBe(1) }) + it('sign-out via the oauth:didConnect broadcast flips isRowboatConnected and drops the rowboat group', async () => { + // Signed in: gateway catalog present. + handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: true } } }) + handlers['llm:getDefaultModel'] = async () => ({ provider: 'rowboat', model: 'claude-opus-4-8' }) + handlers['models:list'] = async () => ({ + providers: [{ id: 'rowboat', name: 'Rowboat', models: [{ id: 'claude-opus-4-8' }] }], + }) + handlers['workspace:readFile'] = async () => ({ + data: JSON.stringify({ provider: { flavor: 'openai' }, model: 'gpt-5.4', providers: {} }), + }) + + const { result } = renderHook(() => useModels()) + await waitFor(() => expect(result.current.isRowboatConnected).toBe(true)) + expect(result.current.groups).toEqual([{ kind: 'catalog', flavor: 'rowboat', models: ['claude-opus-4-8'] }]) + + // Sign out: main broadcasts oauth:didConnect with success:false + // (disconnectProvider's emitOAuthEvent) — same channel as connect. + handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } }) + handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' }) + handlers['models:list'] = async () => ({ + providers: [{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4' }] }], + }) + act(() => { + for (const listener of ipcListeners['oauth:didConnect'] ?? []) { + listener({ provider: 'rowboat', success: false }) + } + }) + + await waitFor(() => expect(result.current.isRowboatConnected).toBe(false)) + expect(result.current.groups.some((g) => g.flavor === 'rowboat')).toBe(false) + }) + it('refetches on models-config-changed and updates every consumer', async () => { serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } }) diff --git a/apps/x/apps/renderer/src/hooks/use-models.ts b/apps/x/apps/renderer/src/hooks/use-models.ts index 3faa8594..a525d714 100644 --- a/apps/x/apps/renderer/src/hooks/use-models.ts +++ b/apps/x/apps/renderer/src/hooks/use-models.ts @@ -208,7 +208,9 @@ function wireGlobalEvents(): void { window.addEventListener('models-config-changed', refreshModels) wiredCleanups = [ () => window.removeEventListener('models-config-changed', refreshModels), - // Rowboat sign-in swaps the whole hybrid list. + // Rowboat sign-in/out swaps the whole hybrid list. Despite the name, + // main broadcasts this channel on every OAuth state change — including + // disconnect (disconnectProvider emits { provider, success: false }). window.ipc.on('oauth:didConnect', refreshModels), // ChatGPT subscription models appear/disappear with the ChatGPT session. window.ipc.on('chatgpt:statusChanged', refreshModels),