From b0000a10d9bd3a4b5493d4e3848d6de1f7ff66f9 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:36:20 +0530 Subject: [PATCH] feat(x): rowboat config IPC + model recommendations plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/x/apps/main/src/ipc.ts | 15 ++-- .../src/components/billing-error-dialog.tsx | 19 +----- .../components/settings/account-settings.tsx | 11 +-- .../src/components/sidebar-content.tsx | 9 +-- .../src/hooks/use-rowboat-config.test.tsx | 68 +++++++++++++++++++ .../renderer/src/hooks/use-rowboat-config.ts | 67 ++++++++++++++++++ .../src/hooks/useMeetingTranscription.ts | 12 +++- .../renderer/src/hooks/useRowboatAccount.ts | 11 ++- .../x/apps/renderer/src/hooks/useVoiceMode.ts | 13 +++- .../core/src/models/initial-selection.test.ts | 32 +++++++++ .../core/src/models/initial-selection.ts | 34 ++++++++++ apps/x/packages/shared/src/ipc.ts | 9 ++- apps/x/packages/shared/src/rowboat-account.ts | 9 +++ 13 files changed, 257 insertions(+), 52 deletions(-) create mode 100644 apps/x/apps/renderer/src/hooks/use-rowboat-config.test.tsx create mode 100644 apps/x/apps/renderer/src/hooks/use-rowboat-config.ts create mode 100644 apps/x/packages/core/src/models/initial-selection.test.ts create mode 100644 apps/x/packages/core/src/models/initial-selection.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index a4f1f9d9..0a1e4956 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -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('granolaConfigRepo'); const config = await repo.getConfig(); diff --git a/apps/x/apps/renderer/src/components/billing-error-dialog.tsx b/apps/x/apps/renderer/src/components/billing-error-dialog.tsx index e0bf2521..da26f38e 100644 --- a/apps/x/apps/renderer/src/components/billing-error-dialog.tsx +++ b/apps/x/apps/renderer/src/components/billing-error-dialog.tsx @@ -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(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) diff --git a/apps/x/apps/renderer/src/components/settings/account-settings.tsx b/apps/x/apps/renderer/src/components/settings/account-settings.tsx index 9833d50b..be6a40f9 100644 --- a/apps/x/apps/renderer/src/components/settings/account-settings.tsx +++ b/apps/x/apps/renderer/src/components/settings/account-settings.tsx @@ -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(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') { diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index c873b1bc..85182e0d 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -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(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) { diff --git a/apps/x/apps/renderer/src/hooks/use-rowboat-config.test.tsx b/apps/x/apps/renderer/src/hooks/use-rowboat-config.test.tsx new file mode 100644 index 00000000..f06abf4f --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/use-rowboat-config.test.tsx @@ -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 Promise> = {} +let invokeCounts: Record = {} + +;(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() + }) +}) diff --git a/apps/x/apps/renderer/src/hooks/use-rowboat-config.ts b/apps/x/apps/renderer/src/hooks/use-rowboat-config.ts new file mode 100644 index 00000000..c9f76139 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/use-rowboat-config.ts @@ -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 + +// 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 | 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 { + 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() +} diff --git a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts index 4e5a73d5..3511a639 100644 --- a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts +++ b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts @@ -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 { diff --git a/apps/x/apps/renderer/src/hooks/useRowboatAccount.ts b/apps/x/apps/renderer/src/hooks/useRowboatAccount.ts index 1cab2414..c0f7985f 100644 --- a/apps/x/apps/renderer/src/hooks/useRowboatAccount.ts +++ b/apps/x/apps/renderer/src/hooks/useRowboatAccount.ts @@ -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 | 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, }; diff --git a/apps/x/apps/renderer/src/hooks/useVoiceMode.ts b/apps/x/apps/renderer/src/hooks/useVoiceMode.ts index d8c49098..2c5d6c45 100644 --- a/apps/x/apps/renderer/src/hooks/useVoiceMode.ts +++ b/apps/x/apps/renderer/src/hooks/useVoiceMode.ts @@ -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) { diff --git a/apps/x/packages/core/src/models/initial-selection.test.ts b/apps/x/packages/core/src/models/initial-selection.test.ts new file mode 100644 index 00000000..44ca5fc0 --- /dev/null +++ b/apps/x/packages/core/src/models/initial-selection.test.ts @@ -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(); + }); +}); diff --git a/apps/x/packages/core/src/models/initial-selection.ts b/apps/x/packages/core/src/models/initial-selection.ts new file mode 100644 index 00000000..de6ad1d0 --- /dev/null +++ b/apps/x/packages/core/src/models/initial-selection.ts @@ -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 | undefined, +): string | null { + const recommended = recommendations?.[flavor]; + if (recommended && availableModelIds.includes(recommended)) { + return recommended; + } + return availableModelIds[0] ?? null; +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index c3489a92..b22c3270 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -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(), diff --git a/apps/x/packages/shared/src/rowboat-account.ts b/apps/x/packages/shared/src/rowboat-account.ts index efa484e3..526adf30 100644 --- a/apps/x/packages/shared/src/rowboat-account.ts +++ b/apps/x/packages/shared/src/rowboat-account.ts @@ -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(), });