From 8a782358bc32f27a0e4405c0368155dd67ce76cd Mon Sep 17 00:00:00 2001 From: PRAKHAR PANDEY Date: Wed, 22 Jul 2026 14:05:07 +0530 Subject: [PATCH] fix(x): settings Models tab tracks Rowboat sign-out live (#775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signing out with the dialog open left the Models tab on the stale signed-in section: the dialog snapshotted oauth:getState once per open into local state and nothing updated it. Main DOES broadcast sign-out — disconnectProvider emits { provider, success: false } on the oauth:didConnect channel — and the useModels store already refetches on that channel, so the fix is to derive the dialog's rowboatConnected from useModels().isRowboatConnected (single source of truth). The section now swaps live on sign-out AND sign-in; the composer's rowboat group already followed the store. Documents the channel's fires-on-disconnect-too behavior at the subscription site and covers the sign-out flip in the store tests. Co-authored-by: Claude Fable 5 --- .../src/components/settings-dialog.tsx | 19 +++----- .../renderer/src/hooks/use-models.test.tsx | 44 ++++++++++++++++++- apps/x/apps/renderer/src/hooks/use-models.ts | 4 +- 3 files changed, 52 insertions(+), 15 deletions(-) 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),