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
This commit is contained in:
Mohamed Boudra
2026-04-06 23:37:22 +07:00
parent a5aca2312b
commit c63240b18c
6 changed files with 165 additions and 135 deletions
@@ -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"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
@@ -698,6 +700,7 @@ function ControlledStatusBar({
accessibilityLabel="Select thinking option"
testID="agent-preferences-thinking"
>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
@@ -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<string, AgentModelDefinition[]>();
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}
/>
@@ -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 (
<ComboboxItem
label={row.modelLabel}
description={showDescription ? row.description : undefined}
selected={isSelected}
disabled={disabled}
elevated={elevated}
@@ -211,21 +216,6 @@ function ModelRow({
}
/>
);
if (!isWeb || !row.description) {
return item;
}
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<View>{item}</View>
</TooltipTrigger>
<TooltipContent side="right" align="center" offset={4}>
<Text style={styles.tooltipText}>{row.description}</Text>
</TooltipContent>
</Tooltip>
);
}
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",
+8 -3
View File
@@ -203,12 +203,12 @@ export function ComboboxItem({
]}
>
{leadingContent}
<View style={styles.comboboxItemContent}>
<View style={[styles.comboboxItemContent, description && styles.comboboxItemContentInline]}>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
{label}
</Text>
{description ? (
<Text numberOfLines={2} style={styles.comboboxItemDescription}>
<Text numberOfLines={1} style={styles.comboboxItemDescription}>
{description}
</Text>
) : 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],
+14 -84
View File
@@ -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<string | undefined>(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;
@@ -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(),
});
}
@@ -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) =>