feat(x): rowboat config IPC + model recommendations plumbing

- RowboatApiConfig gains optional modelRecommendations (one model per
  provider FLAVOR, native id format) matching the merged backend payload;
  optional so older API deployments and failed fetches never break parsing
- new rowboat:getConfig IPC serves the unauthenticated /v1/config bootstrap
  independent of sign-in (signed-out BYOK users need recommendations when
  connecting a provider); account:getRowboat is back to pure account state
  (signedIn/accessToken) — config is no longer a property of the account
- renderer useRowboatConfig() hook: one shared store for all config
  consumers (appUrl dialogs/sidebar/settings), plus imperative
  fetchRowboatConfig() for event-time consumers (voice, meeting) that must
  await the value when starting a session
- selectInitialModel(flavor, models, recommendations): pure spec-ordered
  initial pick (recommendation if listed, else first, else null) — runs
  only when a provider is first connected, never over a saved choice

Inert plumbing: nothing calls selectInitialModel yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-24 13:36:20 +05:30
parent 5729e21d77
commit b0000a10d9
13 changed files with 257 additions and 52 deletions

View file

@ -1327,18 +1327,21 @@ export function setupIpcHandlers() {
'account:getRowboat': async () => {
const signedIn = await isSignedIn();
if (!signedIn) {
return { signedIn: false, accessToken: null, config: null };
return { signedIn: false, accessToken: null };
}
const config = await getRowboatConfig();
try {
const accessToken = await getAccessToken();
return { signedIn: true, accessToken, config };
return { signedIn: true, accessToken };
} catch {
return { signedIn: true, accessToken: null, config };
return { signedIn: true, accessToken: null };
}
},
// Unauthenticated /v1/config bootstrap, independent of sign-in (signed-out
// BYOK users need its model recommendations when connecting a provider).
// getRowboatConfig caches once per app run; best-effort null on failure.
'rowboat:getConfig': async () => {
return await getRowboatConfig().catch(() => null);
},
'granola:getConfig': async () => {
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
const config = await repo.getConfig();

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"
import { useEffect } from "react"
import {
Dialog,
DialogContent,
@ -10,12 +10,7 @@ import {
import { Button } from "@/components/ui/button"
import type { BillingErrorMatch } from "@/lib/billing-error"
import * as analytics from "@/lib/analytics"
interface BillingRowboatAccount {
config?: {
appUrl?: string | null
} | null
}
import { useRowboatConfig } from "@/hooks/use-rowboat-config"
interface BillingErrorDialogProps {
open: boolean
@ -24,15 +19,7 @@ interface BillingErrorDialogProps {
}
export function BillingErrorDialog({ open, match, onOpenChange }: BillingErrorDialogProps) {
const [appUrl, setAppUrl] = useState<string | null>(null)
useEffect(() => {
if (!open) return
window.ipc
.invoke('account:getRowboat', null)
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
.catch(() => {})
}, [open])
const appUrl = useRowboatConfig()?.appUrl ?? null
useEffect(() => {
if (open && match) analytics.billingErrorShown(match.kind)

View file

@ -16,6 +16,7 @@ import {
} from "@/components/ui/alert-dialog"
import { Separator } from "@/components/ui/separator"
import { useBilling } from "@/hooks/useBilling"
import { useRowboatConfig } from "@/hooks/use-rowboat-config"
import { CreditRewards } from "@/components/settings/credit-rewards"
import { toast } from "sonner"
import { getBillingPlanData, type BillingUsageBucket } from "@x/shared/dist/billing.js"
@ -56,7 +57,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
const [connectionLoading, setConnectionLoading] = useState(true)
const [disconnecting, setDisconnecting] = useState(false)
const [connecting, setConnecting] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null)
const appUrl = useRowboatConfig()?.appUrl ?? null
const { billing, isLoading: billingLoading, refresh: refreshBilling } = useBilling(isRowboatConnected)
const currentPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
const hasPaidSubscription = currentPlan?.category === 'starter' || currentPlan?.category === 'pro'
@ -80,14 +81,6 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
}
}, [dialogOpen, checkConnection])
useEffect(() => {
if (isRowboatConnected) {
window.ipc.invoke('account:getRowboat', null)
.then((account) => setAppUrl(account.config?.appUrl ?? null))
.catch(() => {})
}
}, [isRowboatConnected])
useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
if (event.provider === 'rowboat') {

View file

@ -87,6 +87,7 @@ import { SidebarCreditRewards } from "@/components/sidebar-credit-rewards"
import { MascotFaceIcon } from "@/components/talking-head"
import { extractConferenceLink } from "@/lib/calendar-event"
import { useBilling } from "@/hooks/useBilling"
import { useRowboatConfig } from "@/hooks/use-rowboat-config"
import { toast } from "@/lib/toast"
import { getBillingPlanData } from "@x/shared/dist/billing.js"
import { ServiceEvent } from "@x/shared/src/service-events.js"
@ -477,7 +478,7 @@ export function SidebarContentPanel({
const outOfCreditsRef = useRef(false)
const creditPopoverAutoShownRef = useRef(false)
const [loggingIn, setLoggingIn] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null)
const appUrl = useRowboatConfig()?.appUrl ?? null
const { billing, refresh: refreshBilling } = useBilling(isRowboatConnected)
const currentBillingPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
@ -734,12 +735,6 @@ export function SidebarContentPanel({
setShowOauthAlert(true)
}
}
if (connected && mounted) {
try {
const account = await window.ipc.invoke('account:getRowboat', null)
if (mounted) setAppUrl(account.config?.appUrl ?? null)
} catch { /* ignore */ }
}
} catch (error) {
console.error('Failed to fetch OAuth state:', error)
if (mounted) {

View file

@ -0,0 +1,68 @@
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { __resetRowboatConfigForTests, fetchRowboatConfig, useRowboatConfig } from './use-rowboat-config'
// Same preload stub pattern as use-models.test.tsx: invoke routes by channel
// through a per-test handler map, with per-channel call counts to observe the
// store's fetch dedupe.
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
let invokeCounts: Record<string, number> = {}
;(window as unknown as { ipc: unknown }).ipc = {
on: () => () => undefined,
invoke: (channel: string, args: unknown) => {
invokeCounts[channel] = (invokeCounts[channel] ?? 0) + 1
const handler = handlers[channel]
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
},
}
const CONFIG = {
appUrl: 'https://app.example.com',
websocketApiUrl: 'https://ws.example.com',
supabaseUrl: 'https://supabase.example.com',
billing: { plans: [] },
modelRecommendations: { openai: 'gpt-5.4' },
}
beforeEach(() => {
__resetRowboatConfigForTests()
handlers = {}
invokeCounts = {}
})
describe('useRowboatConfig', () => {
it('serves every consumer from one shared fetch', async () => {
handlers['rowboat:getConfig'] = async () => CONFIG
const first = renderHook(() => useRowboatConfig())
const second = renderHook(() => useRowboatConfig())
await waitFor(() => expect(first.result.current?.appUrl).toBe('https://app.example.com'))
await waitFor(() => expect(second.result.current?.modelRecommendations).toEqual({ openai: 'gpt-5.4' }))
expect(invokeCounts['rowboat:getConfig']).toBe(1)
// Imperative reads share the same cache — no extra IPC.
await expect(fetchRowboatConfig()).resolves.toEqual(CONFIG)
expect(invokeCounts['rowboat:getConfig']).toBe(1)
})
it('retries after an unreachable API instead of caching null forever', async () => {
handlers['rowboat:getConfig'] = async () => null
await expect(fetchRowboatConfig()).resolves.toBeNull()
handlers['rowboat:getConfig'] = async () => CONFIG
await expect(fetchRowboatConfig()).resolves.toEqual(CONFIG)
expect(invokeCounts['rowboat:getConfig']).toBe(2)
})
it('returns null while loading and to consumers when the fetch rejects', async () => {
handlers['rowboat:getConfig'] = async () => {
throw new Error('ipc down')
}
const { result } = renderHook(() => useRowboatConfig())
expect(result.current).toBeNull()
await expect(fetchRowboatConfig()).resolves.toBeNull()
expect(result.current).toBeNull()
})
})

View file

@ -0,0 +1,67 @@
import { z } from 'zod'
import { useSyncExternalStore } from 'react'
import { RowboatApiConfig } from '@x/shared/dist/rowboat-account.js'
export type RowboatConfig = z.infer<typeof RowboatApiConfig>
// The single renderer-side reader of the /v1/config bootstrap (service URLs,
// billing catalog, model recommendations) via rowboat:getConfig. The config
// is static for the lifetime of the app run (main caches the fetch), so one
// module-level store serves every consumer: the first subscriber triggers
// the fetch, everyone shares the result, and a failed fetch retries on the
// next consumer instead of caching null forever.
let cached: RowboatConfig | null = null
let pending: Promise<RowboatConfig | null> | null = null
const subscribers = new Set<() => void>()
/**
* Imperative access for non-hook call paths (e.g. useRowboatAccount's
* refresh) same shared cache as the hook.
*/
export function fetchRowboatConfig(): Promise<RowboatConfig | null> {
if (cached) return Promise.resolve(cached)
if (!pending) {
pending = window.ipc
.invoke('rowboat:getConfig', null)
.then((config) => {
if (config) {
cached = config
for (const notify of subscribers) notify()
} else {
// Main returned null (API unreachable) — leave the cache empty so
// a later consumer retries.
pending = null
}
return config
})
.catch(() => {
pending = null
return null
})
}
return pending
}
function subscribe(onStoreChange: () => void): () => void {
subscribers.add(onStoreChange)
void fetchRowboatConfig()
return () => {
subscribers.delete(onStoreChange)
}
}
function getSnapshot(): RowboatConfig | null {
return cached
}
/** The Rowboat bootstrap config, or null while loading / when unreachable. */
export function useRowboatConfig(): RowboatConfig | null {
return useSyncExternalStore(subscribe, getSnapshot)
}
// Test-only: drop the shared cache so each test starts cold.
export function __resetRowboatConfigForTests(): void {
cached = null
pending = null
subscribers.clear()
}

View file

@ -3,6 +3,7 @@ import { toast } from 'sonner';
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
import { finalizeDeepgramStream } from '@/lib/deepgram-finalize';
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
import { fetchRowboatConfig } from '@/hooks/use-rowboat-config';
export type MeetingTranscriptionState = 'idle' | 'connecting' | 'recording' | 'stopping';
@ -258,14 +259,19 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
detectHeadphones(),
// 2. Set up Deepgram WebSocket (account refresh + connect + wait for open)
(async () => {
const account = await refreshRowboatAccount();
// Token from account refresh; websocket URL from the
// sign-in-independent bootstrap config store.
const [account, rowboatConfig] = await Promise.all([
refreshRowboatAccount(),
fetchRowboatConfig(),
]);
let ws: WebSocket;
if (
account?.signedIn &&
account.accessToken &&
account.config?.websocketApiUrl
rowboatConfig?.websocketApiUrl
) {
const listenUrl = buildDeepgramListenUrl(account.config.websocketApiUrl, DEEPGRAM_PARAMS);
const listenUrl = buildDeepgramListenUrl(rowboatConfig.websocketApiUrl, DEEPGRAM_PARAMS);
console.log('[meeting] Using Rowboat WebSocket');
ws = new WebSocket(listenUrl, ['bearer', account.accessToken]);
} else {

View file

@ -1,12 +1,12 @@
import { z } from 'zod';
import { useCallback, useEffect, useState } from 'react';
import { RowboatApiConfig } from '@x/shared/dist/rowboat-account.js';
// Account state only — sign-in status and the access token. The bootstrap
// config (/v1/config: service URLs, billing catalog, model recommendations)
// is deliberately NOT part of this snapshot: it's unauthenticated and
// sign-in independent, so consumers read it from use-rowboat-config instead.
interface RowboatAccountState {
signedIn: boolean;
accessToken: string | null;
config: z.infer<typeof RowboatApiConfig> | null;
}
export type RowboatAccountSnapshot = RowboatAccountState;
@ -14,7 +14,6 @@ export type RowboatAccountSnapshot = RowboatAccountState;
const DEFAULT_STATE: RowboatAccountState = {
signedIn: false,
accessToken: null,
config: null,
};
export function useRowboatAccount() {
@ -28,7 +27,6 @@ export function useRowboatAccount() {
const next: RowboatAccountSnapshot = {
signedIn: result.signedIn,
accessToken: result.accessToken,
config: result.config,
};
setState(next);
return next;
@ -58,7 +56,6 @@ export function useRowboatAccount() {
return {
signedIn: state.signedIn,
accessToken: state.accessToken,
config: state.config,
isLoading,
refresh,
};

View file

@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from 'react';
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
import { finalizeDeepgramStream } from '@/lib/deepgram-finalize';
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
import { fetchRowboatConfig } from '@/hooks/use-rowboat-config';
import posthog from 'posthog-js';
import * as analytics from '@/lib/analytics';
@ -91,13 +92,19 @@ export function useVoiceMode() {
// Refresh cached auth details (called on warmup, not on mic click)
const refreshAuth = useCallback(async () => {
const account = await refreshRowboatAccount();
// Auth (account) and the websocket URL (bootstrap config) are
// separate concerns: the token comes from account refresh, the URL
// from the sign-in-independent config store.
const [account, rowboatConfig] = await Promise.all([
refreshRowboatAccount(),
fetchRowboatConfig(),
]);
if (
account?.signedIn &&
account.accessToken &&
account.config?.websocketApiUrl
rowboatConfig?.websocketApiUrl
) {
cachedAuth = { type: 'rowboat', url: account.config.websocketApiUrl, token: account.accessToken };
cachedAuth = { type: 'rowboat', url: rowboatConfig.websocketApiUrl, token: account.accessToken };
} else {
const config = await window.ipc.invoke('voice:getConfig', null);
if (config?.deepgram) {

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { selectInitialModel } from './initial-selection.js';
describe('selectInitialModel', () => {
const recommendations = {
openai: 'gpt-5.4',
openrouter: 'anthropic/claude-opus-4.8',
};
it('picks the recommended model when the provider lists it', () => {
expect(selectInitialModel('openai', ['gpt-4.1', 'gpt-5.4', 'gpt-5.4-mini'], recommendations))
.toBe('gpt-5.4');
});
it('falls back to the first listed model when the recommendation is not in the list', () => {
expect(selectInitialModel('openai', ['gpt-4.1', 'gpt-4o'], recommendations))
.toBe('gpt-4.1');
});
it('falls back to the first listed model for flavors with no recommendation', () => {
expect(selectInitialModel('ollama', ['llama3', 'qwen3'], recommendations))
.toBe('llama3');
});
it('falls back to the first listed model when no recommendations map is available', () => {
expect(selectInitialModel('openai', ['gpt-4.1'], undefined)).toBe('gpt-4.1');
});
it('returns null when the provider listed nothing', () => {
expect(selectInitialModel('openai', [], recommendations)).toBeNull();
});
});

View file

@ -0,0 +1,34 @@
/**
* Initial model selection for a provider being connected for the first time.
*
* Implements the selection order from the provider/model-selection spec:
* 1. If Rowboat's recommended model for this flavor appears in the
* provider's available list, pick it.
* 2. Otherwise pick the first model the provider returned.
* 3. With no list at all, return null the caller offers retry or manual
* entry.
*
* This runs ONLY when a provider is first connected and has no saved
* selection. It must never run over an existing choice: after initial setup
* the saved model configuration is the source of truth, and changes to the
* recommendations or to the provider's list order must not silently replace
* what the user picked.
*
* Pure function by design: the caller supplies the provider's available
* models (from the unified catalog / a live probe) and the recommendations
* map (from /v1/config via rowboat:getConfig, keyed by provider FLAVOR in
* each provider's native id format). Recommendations are best-effort an
* absent map, an unknown flavor, or a recommendation the provider doesn't
* serve all degrade to "first available model".
*/
export function selectInitialModel(
flavor: string,
availableModelIds: string[],
recommendations: Record<string, string> | undefined,
): string | null {
const recommended = recommendations?.[flavor];
if (recommended && availableModelIds.includes(recommended)) {
return recommended;
}
return availableModelIds[0] ?? null;
}

View file

@ -790,9 +790,16 @@ const ipcSchemas = {
res: z.object({
signedIn: z.boolean(),
accessToken: z.string().nullable(),
config: RowboatApiConfig.nullable(),
}),
},
// The unauthenticated /v1/config bootstrap (service URLs, billing catalog,
// model recommendations). Independent of sign-in state — main caches the
// fetch once per app run; null when the API is unreachable. Renderer
// consumers go through the useRowboatConfig() hook.
'rowboat:getConfig': {
req: z.null(),
res: RowboatApiConfig.nullable(),
},
'oauth:didConnect': {
req: z.object({
provider: z.string(),

View file

@ -11,4 +11,13 @@ export const RowboatApiConfig = z.object({
// app keeps working against API deployments that predate it — the rewards
// UI just stays empty until the backend serves the catalog
creditActivations: z.array(CreditActivationCatalogEntrySchema).optional(),
// One recommended model id per provider FLAVOR (e.g. { openai: "gpt-5.4",
// openrouter: "anthropic/claude-opus-4.8" }), in each provider's native id
// format. A hint for the INITIAL selection when a provider is first
// connected — never a catalog, and never applied over a saved choice
// (see core/models/initial-selection.ts). Local/custom flavors are
// intentionally absent: the API can't know which models exist in a user's
// environment. Optional so older API deployments and failed fetches never
// break parsing — recommendations are best-effort by design.
modelRecommendations: z.record(z.string(), z.string()).optional(),
});