From c63240b18c301c0c71ffb7d5438c2170bb466bdf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 6 Apr 2026 23:37:22 +0700 Subject: [PATCH] feat(app): unified provider snapshot, resilient model selector, and UX polish - Model selector is no longer disabled while providers load; opens immediately and streams available providers as they arrive - Selecting a non-default provider on mobile now works correctly - Provider icon added to model trigger in mobile preferences sheet - Thinking icon (brain) added to thinking trigger in mobile preferences - Model descriptions for OpenCode/Pi shown inline next to model name instead of in a tooltip - Running agents and draft screen share a single provider/model cache, eliminating duplicate network requests - Provider data is prefetched when entering a workspace so the model selector is instant on first open --- .../app/src/components/agent-status-bar.tsx | 66 ++++++++----- .../components/combined-model-selector.tsx | 30 ++---- packages/app/src/components/ui/combobox.tsx | 11 ++- .../app/src/hooks/use-agent-form-state.ts | 98 +++---------------- .../app/src/hooks/use-providers-snapshot.ts | 84 ++++++++++++++++ .../screens/workspace/workspace-screen.tsx | 11 +++ 6 files changed, 165 insertions(+), 135 deletions(-) create mode 100644 packages/app/src/hooks/use-providers-snapshot.ts diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index b79c6402f..ee7a63397 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -17,6 +17,7 @@ import { getProviderIcon } from "@/components/provider-icons"; import { CombinedModelSelector } from "@/components/combined-model-selector"; import { useQuery } from "@tanstack/react-query"; import { useSessionStore } from "@/stores/session-store"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { buildFavoriteModelKey, mergeProviderPreferences, @@ -266,7 +267,7 @@ function ControlledStatusBar({ return null; } - const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0; + const modelDisabled = disabled; const SEARCH_THRESHOLD = 6; @@ -673,6 +674,7 @@ function ControlledStatusBar({ pointerEvents="none" testID="agent-preferences-model" > + {selectedModelLabel} @@ -698,6 +700,7 @@ function ControlledStatusBar({ accessibilityLabel="Select thinking option" testID="agent-preferences-thinking" > + {displayThinking} @@ -869,9 +872,17 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat ); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); - const modelsQuery = useQuery({ + const { + entries: snapshotEntries, + isLoading: snapshotIsLoading, + isFetching: snapshotIsFetching, + supportsSnapshot, + } = useProvidersSnapshot(serverId); + + // COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support + const legacyModelsQuery = useQuery({ queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"], - enabled: Boolean(client && agent?.provider), + enabled: Boolean(!supportsSnapshot && client && agent?.provider), staleTime: 5 * 60 * 1000, queryFn: async () => { if (!client || !agent) { @@ -885,36 +896,39 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat }, }); + const snapshotModels = useMemo(() => { + if (!supportsSnapshot || !snapshotEntries || !agent?.provider) { + return null; + } + const entry = snapshotEntries.find((e) => e.provider === agent.provider); + return entry?.models ?? null; + }, [supportsSnapshot, snapshotEntries, agent?.provider]); + + const models = supportsSnapshot ? snapshotModels : (legacyModelsQuery.data ?? null); + const agentProviderDefinitions = useMemo(() => { + if (supportsSnapshot && snapshotEntries) { + return AGENT_PROVIDER_DEFINITIONS.filter((d) => + snapshotEntries.some((e) => e.provider === d.id), + ); + } const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider); return definition ? [definition] : []; - }, [agent?.provider]); - - const agentProviderModelQuery = useQuery({ - queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""], - enabled: Boolean(client && agent?.cwd && agent?.provider), - staleTime: 5 * 60 * 1000, - queryFn: async () => { - if (!client || !agent) { - throw new Error("Daemon client unavailable"); - } - const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd }); - if (payload.error) { - throw new Error(payload.error); - } - return payload.models ?? []; - }, - }); + }, [agent?.provider, supportsSnapshot, snapshotEntries]); const agentProviderModels = useMemo(() => { const map = new Map(); - if (agent?.provider && agentProviderModelQuery.data) { - map.set(agent.provider, agentProviderModelQuery.data); + if (supportsSnapshot && snapshotEntries) { + for (const entry of snapshotEntries) { + if (entry.models) { + map.set(entry.provider, entry.models); + } + } + } else if (agent?.provider && legacyModelsQuery.data) { + map.set(agent.provider, legacyModelsQuery.data); } return map; - }, [agent?.provider, agentProviderModelQuery.data]); - - const models = modelsQuery.data ?? null; + }, [supportsSnapshot, snapshotEntries, agent?.provider, legacyModelsQuery.data]); const displayMode = availableModes.find((mode) => mode.id === agent?.currentModeId)?.label || @@ -1034,7 +1048,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat console.warn("[AgentStatusBar] setAgentFeature failed", error); }); }} - isModelLoading={isProviderModelsQueryLoading(modelsQuery)} + isModelLoading={supportsSnapshot ? (snapshotIsLoading || snapshotIsFetching) : isProviderModelsQueryLoading(legacyModelsQuery)} onDropdownClose={onDropdownClose} disabled={!client} /> diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index a8aaa051d..ffb0ef6df 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -25,7 +25,6 @@ import type { AgentProviderDefinition } from "@server/server/agent/provider-mani const IS_WEB = Platform.OS === "web"; import { Combobox, ComboboxItem } from "@/components/ui/combobox"; -import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { getProviderIcon } from "@/components/provider-icons"; import type { FavoriteModelRow } from "@/hooks/use-form-preferences"; import { @@ -36,6 +35,9 @@ import { type SelectorModelRow, } from "./combined-model-selector.utils"; +// TODO: this should be configured per provider in the provider manifest +const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]); + type SelectorView = | { kind: "all" } | { kind: "provider"; providerId: string; providerLabel: string }; @@ -161,7 +163,6 @@ function ModelRow({ }) { const { theme } = useUnistyles(); const ProviderIcon = getProviderIcon(row.provider); - const isWeb = Platform.OS === "web"; const handleToggleFavorite = useCallback( (event: GestureResponderEvent) => { @@ -171,9 +172,13 @@ function ModelRow({ [onToggleFavorite, row.modelId, row.provider], ); - const item = ( + const showDescription = + row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider); + + return ( ); - - if (!isWeb || !row.description) { - return item; - } - - return ( - - - {item} - - - {row.description} - - - ); } function FavoritesSection({ @@ -839,10 +829,6 @@ const styles = StyleSheet.create((theme) => ({ favoriteButtonPressed: { backgroundColor: theme.colors.surface1, }, - tooltipText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.xs, - }, sheetLoadingState: { minHeight: 160, justifyContent: "center", diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index ab4ec98f8..9d709cbd7 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -203,12 +203,12 @@ export function ComboboxItem({ ]} > {leadingContent} - + {label} {description ? ( - + {description} ) : null} @@ -847,6 +847,11 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, flexShrink: 1, }, + comboboxItemContentInline: { + flexDirection: "row", + alignItems: "baseline", + gap: theme.spacing[2], + }, comboboxItemLeadingSlot: { width: 16, alignItems: "center", @@ -857,9 +862,9 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, }, comboboxItemDescription: { - marginTop: 2, fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, + flexShrink: 1, }, emptyText: { paddingHorizontal: theme.spacing[3], diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 812e3fd0a..5d5865714 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueries } from "@tanstack/react-query"; import { AGENT_PROVIDER_DEFINITIONS, type AgentProviderDefinition, @@ -12,7 +12,7 @@ import type { } from "@server/server/agent/agent-sdk-types"; import { useHosts } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useSessionForServer } from "./use-session-directory"; +import { useProvidersSnapshot } from "./use-providers-snapshot"; import { useFormPreferences, mergeProviderPreferences, @@ -322,10 +322,6 @@ function combineInitialValues( return initialValues; } -function providersSnapshotQueryKey(serverId: string | null, cwd: string | undefined) { - return ["providersSnapshot", serverId, cwd ?? ""] as const; -} - export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult { const { initialServerId = null, @@ -343,7 +339,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg } = useFormPreferences(); const daemons = useHosts(); - const queryClient = useQueryClient(); // Build a set of valid server IDs for preference validation const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]); @@ -379,10 +374,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg // Session state for provider model listing const client = useHostRuntimeClient(formState.serverId ?? ""); const isConnected = useHostRuntimeIsConnected(formState.serverId ?? ""); - const supportsProvidersSnapshot = useSessionForServer( - formState.serverId, - (session) => session?.serverInfo?.features?.providersSnapshot === true, - ); + const { + entries: snapshotEntries, + isLoading: snapshotIsLoading, + isFetching: snapshotIsFetching, + supportsSnapshot: supportsProvidersSnapshot, + refresh: refreshSnapshot, + } = useProvidersSnapshot(formState.serverId); const [debouncedCwd, setDebouncedCwd] = useState(undefined); useEffect(() => { @@ -391,69 +389,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const timer = setTimeout(() => setDebouncedCwd(next), 180); return () => clearTimeout(timer); }, [formState.workingDir]); - - const snapshotQueryKey = useMemo( - () => providersSnapshotQueryKey(formState.serverId, debouncedCwd), - [debouncedCwd, formState.serverId], - ); - - const providersSnapshotQuery = useQuery({ - queryKey: snapshotQueryKey, - enabled: Boolean( - supportsProvidersSnapshot && - isVisible && - isTargetDaemonReady && - formState.serverId && - client && - isConnected, - ), - staleTime: 60 * 1000, - queryFn: async () => { - if (!client) { - throw new Error("Host is not connected"); - } - return client.getProvidersSnapshot({ cwd: debouncedCwd }); - }, - }); - - useEffect(() => { - if ( - !supportsProvidersSnapshot || - !client || - !isConnected || - !isVisible || - !isTargetDaemonReady || - !formState.serverId - ) { - return; - } - - return client.on("providers_snapshot_update", (message) => { - if (message.type !== "providers_snapshot_update") { - return; - } - if (message.payload.cwd !== undefined && message.payload.cwd !== debouncedCwd) { - return; - } - queryClient.setQueryData(snapshotQueryKey, { - entries: message.payload.entries, - generatedAt: message.payload.generatedAt, - requestId: "providers_snapshot_update", - }); - }); - }, [ - client, - debouncedCwd, - formState.serverId, - isConnected, - isTargetDaemonReady, - isVisible, - queryClient, - snapshotQueryKey, - supportsProvidersSnapshot, - ]); - - const snapshotEntries = providersSnapshotQuery.data?.entries ?? undefined; const allProviderEntries = useMemo( () => (supportsProvidersSnapshot ? snapshotEntries ?? [] : undefined), [snapshotEntries, supportsProvidersSnapshot], @@ -649,7 +584,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ? snapshotSelectedProviderModes : legacySelectedProviderModes; const isAllModelsLoading = supportsProvidersSnapshot - ? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching + ? snapshotIsLoading || snapshotIsFetching : allProviderModelQueries.some((q) => q.isLoading); // Combine initialValues with initialServerId for resolution @@ -873,14 +808,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const refreshProviderModels = useCallback(() => { if (supportsProvidersSnapshot) { - if (!client) { - return; - } - void client.refreshProvidersSnapshot({ cwd: debouncedCwd }); + refreshSnapshot(); return; } void legacySelectedProviderModelsQuery.refetch(); - }, [client, debouncedCwd, legacySelectedProviderModelsQuery, supportsProvidersSnapshot]); + }, [legacySelectedProviderModelsQuery, refreshSnapshot, supportsProvidersSnapshot]); const persistFormPreferences = useCallback(async () => { const resolvedModel = resolveEffectiveModel(availableModels, formState.model); @@ -917,13 +849,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const resolvedModelId = effectiveModel?.id ?? formState.model; const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; const isModelLoading = supportsProvidersSnapshot - ? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching + ? snapshotIsLoading || snapshotIsFetching : legacySelectedProviderModelsQuery.isLoading || legacySelectedProviderModelsQuery.isFetching; const modelError = supportsProvidersSnapshot - ? providersSnapshotQuery.error instanceof Error - ? providersSnapshotQuery.error.message - : null + ? null : legacySelectedProviderModelsQuery.error instanceof Error ? legacySelectedProviderModelsQuery.error.message : null; diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts new file mode 100644 index 000000000..943791f8a --- /dev/null +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types"; +import type { DaemonClient } from "@server/client/daemon-client"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useSessionForServer } from "./use-session-directory"; +import { queryClient as singletonQueryClient } from "@/query/query-client"; + +export function providersSnapshotQueryKey(serverId: string | null) { + return ["providersSnapshot", serverId] as const; +} + +interface UseProvidersSnapshotResult { + entries: ProviderSnapshotEntry[] | undefined; + isLoading: boolean; + isFetching: boolean; + supportsSnapshot: boolean; + refresh: () => void; +} + +export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult { + const queryClient = useQueryClient(); + const client = useHostRuntimeClient(serverId ?? ""); + const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + const supportsSnapshot = useSessionForServer( + serverId, + (session) => session?.serverInfo?.features?.providersSnapshot === true, + ); + + const queryKey = useMemo(() => providersSnapshotQueryKey(serverId), [serverId]); + + const snapshotQuery = useQuery({ + queryKey, + enabled: Boolean(supportsSnapshot && serverId && client && isConnected), + staleTime: 60_000, + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + return client.getProvidersSnapshot(); + }, + }); + + useEffect(() => { + if (!supportsSnapshot || !client || !isConnected || !serverId) { + return; + } + + return client.on("providers_snapshot_update", (message) => { + if (message.type !== "providers_snapshot_update") { + return; + } + queryClient.setQueryData(queryKey, { + entries: message.payload.entries, + generatedAt: message.payload.generatedAt, + requestId: "providers_snapshot_update", + }); + }); + }, [client, isConnected, serverId, queryClient, queryKey, supportsSnapshot]); + + const refresh = useCallback(() => { + if (!client) { + return; + } + void client.refreshProvidersSnapshot(); + }, [client]); + + return { + entries: snapshotQuery.data?.entries ?? undefined, + isLoading: snapshotQuery.isLoading, + isFetching: snapshotQuery.isFetching, + supportsSnapshot, + refresh, + }; +} + +export function prefetchProvidersSnapshot(serverId: string, client: DaemonClient): void { + const queryKey = providersSnapshotQueryKey(serverId); + void singletonQueryClient.prefetchQuery({ + queryKey, + staleTime: 60_000, + queryFn: () => client.getProvidersSnapshot(), + }); +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d4555c4ab..dbeb7f930 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -70,6 +70,7 @@ import { workspaceTabTargetsEqual, } from "@/utils/workspace-tab-identity"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import { checkoutStatusQueryKey, @@ -608,6 +609,16 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); + useEffect(() => { + if (!client || !isConnected || !normalizedServerId) { + return; + } + const session = useSessionStore.getState().sessions[normalizedServerId]; + if (session?.serverInfo?.features?.providersSnapshot) { + prefetchProvidersSnapshot(normalizedServerId, client); + } + }, [client, isConnected, normalizedServerId]); + const workspaceAgentVisibility = useStoreWithEqualityFn( useSessionStore, (state) =>