mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
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:
parent
2274369383
commit
8a782358bc
3 changed files with 52 additions and 15 deletions
|
|
@ -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<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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<string, (args: unknown) => Promise<unknown>> = {}
|
||||
let invokeCounts: Record<string, number> = {}
|
||||
let ipcListeners: Record<string, Array<(payload: unknown) => 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' } })
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue