fix(x): settings Models tab tracks Rowboat sign-out live (#775)

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 <noreply@anthropic.com>
This commit is contained in:
PRAKHAR PANDEY 2026-07-22 14:05:07 +05:30 committed by GitHub
parent 2274369383
commit 8a782358bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 52 additions and 15 deletions

View file

@ -35,6 +35,7 @@ import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus
import { useProviderModels } from "@/hooks/use-provider-models" import { useProviderModels } from "@/hooks/use-provider-models"
import { useChatGPT } from "@/hooks/useChatGPT" import { useChatGPT } from "@/hooks/useChatGPT"
import { ModelSelector, type ModelRef } from "@/components/model-selector" 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" 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 [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(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 // Reset to the requested default tab each time the dialog is opened
useEffect(() => { useEffect(() => {
@ -2667,17 +2673,6 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
} }
}, [open, defaultTab]) }, [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 // Hybrid mode: the Models tab is shown in both modes — signed-in users can
// pick gateway models AND bring their own providers/models alongside. // pick gateway models AND bring their own providers/models alongside.
const visibleTabs = tabs const visibleTabs = tabs

View file

@ -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 // 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 // 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<string, (args: unknown) => Promise<unknown>> = {} let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
let invokeCounts: Record<string, number> = {} let invokeCounts: Record<string, number> = {}
let ipcListeners: Record<string, Array<(payload: unknown) => void>> = {}
;(window as unknown as { ipc: unknown }).ipc = { ;(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) => { invoke: (channel: string, args: unknown) => {
invokeCounts[channel] = (invokeCounts[channel] ?? 0) + 1 invokeCounts[channel] = (invokeCounts[channel] ?? 0) + 1
const handler = handlers[channel] const handler = handlers[channel]
@ -34,6 +41,7 @@ beforeEach(() => {
__resetModelsForTests() __resetModelsForTests()
handlers = {} handlers = {}
invokeCounts = {} invokeCounts = {}
ipcListeners = {}
}) })
describe('useModels', () => { describe('useModels', () => {
@ -72,6 +80,38 @@ describe('useModels', () => {
expect(invokeCounts['models:list']).toBe(1) 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 () => { it('refetches on models-config-changed and updates every consumer', async () => {
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } }) serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })

View file

@ -208,7 +208,9 @@ function wireGlobalEvents(): void {
window.addEventListener('models-config-changed', refreshModels) window.addEventListener('models-config-changed', refreshModels)
wiredCleanups = [ wiredCleanups = [
() => window.removeEventListener('models-config-changed', refreshModels), () => 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), window.ipc.on('oauth:didConnect', refreshModels),
// ChatGPT subscription models appear/disappear with the ChatGPT session. // ChatGPT subscription models appear/disappear with the ChatGPT session.
window.ipc.on('chatgpt:statusChanged', refreshModels), window.ipc.on('chatgpt:statusChanged', refreshModels),