Merge remote-tracking branch 'origin/main' into dev
# Conflicts: # nix/package.nix # packages/app/src/components/icons/opencode-icon.tsx # packages/app/src/components/provider-icons.ts # packages/app/src/panels/agent-panel.tsx # packages/cli/src/commands/provider/ls.ts # packages/server/src/server/agent/provider-manifest.ts # packages/server/src/server/agent/provider-registry.ts # packages/server/src/server/agent/providers/claude-agent.test.ts # packages/server/src/server/agent/providers/claude-agent.ts # packages/server/src/server/persistence-hooks.ts # packages/server/src/server/session.ts
This commit is contained in:
@@ -8,7 +8,12 @@ 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 { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
buildFavoriteModelKey,
|
||||
mergeProviderPreferences,
|
||||
toggleFavoriteModel,
|
||||
useFormPreferences,
|
||||
} from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -25,6 +30,7 @@ import type {
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
getModeVisuals,
|
||||
type AgentModeColorTier,
|
||||
type AgentModeIcon,
|
||||
@@ -42,6 +48,10 @@ type StatusOption = {
|
||||
|
||||
type StatusSelector = "provider" | "mode" | "model" | "thinking";
|
||||
|
||||
const PROVIDER_DEFINITION_MAP = new Map(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
|
||||
type ControlledAgentStatusBarProps = {
|
||||
provider: string;
|
||||
providerOptions?: StatusOption[];
|
||||
@@ -58,6 +68,11 @@ type ControlledAgentStatusBarProps = {
|
||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||
disabled?: boolean;
|
||||
isModelLoading?: boolean;
|
||||
providerDefinitions?: AgentProviderDefinition[];
|
||||
allProviderModels?: Map<string, AgentModelDefinition[]>;
|
||||
canSelectModelProvider?: (providerId: string) => boolean;
|
||||
favoriteKeys?: Set<string>;
|
||||
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
||||
};
|
||||
|
||||
export interface DraftAgentStatusBarProps {
|
||||
@@ -142,6 +157,11 @@ function ControlledStatusBar({
|
||||
onSelectThinkingOption,
|
||||
disabled = false,
|
||||
isModelLoading = false,
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
canSelectModelProvider,
|
||||
favoriteKeys = new Set<string>(),
|
||||
onToggleFavoriteModel,
|
||||
}: ControlledAgentStatusBarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isWeb = Platform.OS === "web";
|
||||
@@ -205,6 +225,26 @@ function ControlledStatusBar({
|
||||
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||
[modelOptions],
|
||||
);
|
||||
const fallbackAllProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
if (!modelOptions || modelOptions.length === 0) {
|
||||
return map;
|
||||
}
|
||||
|
||||
map.set(
|
||||
provider,
|
||||
modelOptions.map((option) => ({
|
||||
provider: provider as AgentProvider,
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
})),
|
||||
);
|
||||
return map;
|
||||
}, [modelOptions, provider]);
|
||||
const effectiveProviderDefinitions = providerDefinitions ??
|
||||
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
|
||||
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
|
||||
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
|
||||
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
||||
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||
[thinkingOptions],
|
||||
@@ -289,49 +329,36 @@ function ControlledStatusBar({
|
||||
) : null}
|
||||
|
||||
{canSelectModel ? (
|
||||
<>
|
||||
<Tooltip
|
||||
key={`model-${openSelector === "model" ? "open" : "closed"}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={modelAnchorRef}
|
||||
collapsable={false}
|
||||
<Tooltip
|
||||
key={`model-${displayModel}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<View>
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={effectiveProviderDefinitions}
|
||||
allProviderModels={effectiveAllProviderModels}
|
||||
selectedProvider={provider}
|
||||
selectedModel={selectedModelId ?? ""}
|
||||
canSelectProvider={canSelectProviderInModelMenu}
|
||||
onSelect={(selectedProviderId, modelId) => {
|
||||
if (selectedProviderId === provider) {
|
||||
onSelectModel?.(modelId);
|
||||
}
|
||||
}}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
isLoading={isModelLoading}
|
||||
disabled={modelDisabled}
|
||||
onPress={() => handleSelectorPress("model")}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === "model") && styles.modeBadgePressed,
|
||||
modelDisabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-model-selector"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={comboboxModelOptions}
|
||||
value={selectedModelId ?? ""}
|
||||
onSelect={(id) => onSelectModel?.(id)}
|
||||
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === "model"}
|
||||
onOpenChange={handleOpenChange("model")}
|
||||
anchorRef={modelAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
</>
|
||||
/>
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||
@@ -454,73 +481,38 @@ function ControlledStatusBar({
|
||||
stackBehavior="replace"
|
||||
testID="agent-preferences-sheet"
|
||||
>
|
||||
{providerOptions && providerOptions.length > 0 ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu
|
||||
open={openSelector === "provider"}
|
||||
onOpenChange={handleOpenChange("provider")}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled || !canSelectProvider}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
(disabled || !canSelectProvider) && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent provider"
|
||||
testID="agent-preferences-provider"
|
||||
>
|
||||
<Text style={styles.sheetSelectText}>{displayProvider}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{providerOptions.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
key={provider.id}
|
||||
selected={provider.id === selectedProviderId}
|
||||
onSelect={() => onSelectProvider?.(provider.id)}
|
||||
>
|
||||
{provider.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{canSelectModel ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu
|
||||
open={openSelector === "model"}
|
||||
onOpenChange={handleOpenChange("model")}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={modelDisabled}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
modelDisabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<Text style={styles.sheetSelectText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{(modelOptions ?? []).map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
selected={model.id === selectedModelId}
|
||||
onSelect={() => onSelectModel?.(model.id)}
|
||||
>
|
||||
{model.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={effectiveProviderDefinitions}
|
||||
allProviderModels={effectiveAllProviderModels}
|
||||
selectedProvider={provider}
|
||||
selectedModel={selectedModelId ?? ""}
|
||||
canSelectProvider={canSelectProviderInModelMenu}
|
||||
onSelect={(selectedProviderId, modelId) => {
|
||||
if (selectedProviderId !== provider) {
|
||||
onSelectProvider?.(selectedProviderId);
|
||||
}
|
||||
onSelectModel?.(modelId);
|
||||
}}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
isLoading={isModelLoading}
|
||||
disabled={modelDisabled}
|
||||
renderTrigger={({ selectedModelLabel }) => (
|
||||
<View
|
||||
style={[
|
||||
styles.sheetSelect,
|
||||
modelDisabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
pointerEvents="none"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -650,6 +642,35 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const agentProviderDefinitions = useMemo(() => {
|
||||
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 ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const agentProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
if (agent?.provider && agentProviderModelQuery.data) {
|
||||
map.set(agent.provider, agentProviderModelQuery.data);
|
||||
}
|
||||
return map;
|
||||
}, [agent?.provider, agentProviderModelQuery.data]);
|
||||
|
||||
const models = modelsQuery.data ?? null;
|
||||
|
||||
const displayMode =
|
||||
@@ -674,6 +695,10 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
const modelOptions = useMemo<StatusOption[]>(() => {
|
||||
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
||||
}, [models]);
|
||||
const favoriteKeys = useMemo(
|
||||
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
|
||||
[preferences.favoriteModels],
|
||||
);
|
||||
|
||||
const thinkingOptions = useMemo<StatusOption[]>(() => {
|
||||
return (modelSelection.thinkingOptions ?? []).map((option) => ({
|
||||
@@ -693,6 +718,8 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
|
||||
}
|
||||
selectedModeId={agent.currentModeId ?? undefined}
|
||||
providerDefinitions={agentProviderDefinitions}
|
||||
allProviderModels={agentProviderModels}
|
||||
onSelectMode={(modeId) => {
|
||||
if (!client) {
|
||||
return;
|
||||
@@ -722,6 +749,12 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
||||
});
|
||||
}}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavoriteModel={(provider, modelId) => {
|
||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||
console.warn("[AgentStatusBar] toggle favorite model failed", error);
|
||||
});
|
||||
}}
|
||||
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
|
||||
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
||||
onSelectThinkingOption={(thinkingOptionId) => {
|
||||
@@ -775,6 +808,7 @@ export function DraftAgentStatusBar({
|
||||
disabled = false,
|
||||
}: DraftAgentStatusBarProps) {
|
||||
const isWeb = Platform.OS === "web";
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
|
||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||
if (modeOptions.length === 0) {
|
||||
@@ -789,6 +823,10 @@ export function DraftAgentStatusBar({
|
||||
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
|
||||
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
|
||||
}, [thinkingOptions]);
|
||||
const favoriteKeys = useMemo(
|
||||
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
|
||||
[preferences.favoriteModels],
|
||||
);
|
||||
|
||||
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
||||
const effectiveSelectedThinkingOption =
|
||||
@@ -803,6 +841,12 @@ export function DraftAgentStatusBar({
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={onSelectProviderAndModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={(provider, modelId) => {
|
||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||
});
|
||||
}}
|
||||
isLoading={isAllModelsLoading}
|
||||
disabled={disabled}
|
||||
/>
|
||||
@@ -820,29 +864,29 @@ export function DraftAgentStatusBar({
|
||||
);
|
||||
}
|
||||
|
||||
const providerOptions = providerDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
const modelOptions: StatusOption[] = models.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
}));
|
||||
|
||||
const modelOptions: StatusOption[] = [];
|
||||
for (const model of models) {
|
||||
modelOptions.push({ id: model.id, label: model.label });
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerOptions={providerOptions}
|
||||
selectedProviderId={selectedProvider}
|
||||
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={selectedModel}
|
||||
onSelectModel={onSelectModel}
|
||||
isModelLoading={isModelLoading}
|
||||
onSelectModel={(modelId) => onSelectModel(modelId)}
|
||||
isModelLoading={isAllModelsLoading}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavoriteModel={(provider, modelId) => {
|
||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||
});
|
||||
}}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
buildModelRows,
|
||||
buildSelectedTriggerLabel,
|
||||
matchesSearch,
|
||||
resolveProviderLabel,
|
||||
} from "./combined-model-selector.utils";
|
||||
|
||||
describe("combined model selector helpers", () => {
|
||||
const providerDefinitions = [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
description: "Claude provider",
|
||||
defaultModeId: "default",
|
||||
modes: [],
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description: "Codex provider",
|
||||
defaultModeId: "auto",
|
||||
modes: [],
|
||||
},
|
||||
];
|
||||
|
||||
const claudeModels: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "sonnet-4.6",
|
||||
label: "Sonnet 4.6",
|
||||
},
|
||||
];
|
||||
|
||||
const codexModels: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
},
|
||||
];
|
||||
|
||||
it("keeps enough data to search by model and provider name", async () => {
|
||||
const rows = buildModelRows(providerDefinitions, new Map([
|
||||
["claude", claudeModels],
|
||||
["codex", codexModels],
|
||||
]));
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ providerLabel: "Claude", modelLabel: "Sonnet 4.6", modelId: "sonnet-4.6" }),
|
||||
expect.objectContaining({ providerLabel: "Codex", modelLabel: "GPT-5.4", modelId: "gpt-5.4" }),
|
||||
]);
|
||||
|
||||
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
|
||||
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
|
||||
});
|
||||
|
||||
it("builds an explicit trigger label for the selected provider and model", () => {
|
||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||
expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("Codex: GPT-5.4");
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,39 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, Text, Pressable, Platform } from "react-native";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
type GestureResponderEvent,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ArrowLeft, Check, ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Search,
|
||||
Star,
|
||||
} from "lucide-react-native";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { Combobox, ComboboxItem, SearchInput } 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 {
|
||||
buildModelRows,
|
||||
buildSelectedTriggerLabel,
|
||||
matchesSearch,
|
||||
resolveProviderLabel,
|
||||
type SelectorModelRow,
|
||||
} from "./combined-model-selector.utils";
|
||||
|
||||
const INLINE_MODEL_THRESHOLD = 8;
|
||||
const INLINE_MODEL_THRESHOLD = Number.POSITIVE_INFINITY;
|
||||
|
||||
type DrillDownView = { provider: string };
|
||||
|
||||
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
||||
if (!models || models.length === 0) {
|
||||
return "Select model";
|
||||
}
|
||||
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
|
||||
}
|
||||
type SelectorView =
|
||||
| { kind: "all" }
|
||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||
|
||||
interface CombinedModelSelectorProps {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
@@ -25,9 +42,408 @@ interface CombinedModelSelectorProps {
|
||||
selectedModel: string;
|
||||
onSelect: (provider: AgentProvider, modelId: string) => void;
|
||||
isLoading: boolean;
|
||||
canSelectProvider?: (provider: string) => boolean;
|
||||
favoriteKeys?: Set<string>;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
renderTrigger?: (input: {
|
||||
selectedModelLabel: string;
|
||||
onPress: () => void;
|
||||
disabled: boolean;
|
||||
isOpen: boolean;
|
||||
}) => React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectorContentProps {
|
||||
view: SelectorView;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
favoriteKeys: Set<string>;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
canSelectProvider: (provider: string) => boolean;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
||||
if (!models || models.length === 0) {
|
||||
return "Select model";
|
||||
}
|
||||
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
|
||||
}
|
||||
|
||||
function normalizeSearchQuery(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function partitionRows(
|
||||
rows: SelectorModelRow[],
|
||||
favoriteKeys: Set<string>,
|
||||
): { favoriteRows: SelectorModelRow[]; regularRows: SelectorModelRow[] } {
|
||||
const favoriteRows: SelectorModelRow[] = [];
|
||||
const regularRows: SelectorModelRow[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (favoriteKeys.has(row.favoriteKey)) {
|
||||
favoriteRows.push(row);
|
||||
continue;
|
||||
}
|
||||
regularRows.push(row);
|
||||
}
|
||||
|
||||
return { favoriteRows, regularRows };
|
||||
}
|
||||
|
||||
function groupRowsByProvider(
|
||||
rows: SelectorModelRow[],
|
||||
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
|
||||
const grouped = new Map<string, { providerId: string; providerLabel: string; rows: SelectorModelRow[] }>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = grouped.get(row.provider);
|
||||
if (existing) {
|
||||
existing.rows.push(row);
|
||||
continue;
|
||||
}
|
||||
|
||||
grouped.set(row.provider, {
|
||||
providerId: row.provider,
|
||||
providerLabel: row.providerLabel,
|
||||
rows: [row],
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(grouped.values());
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
row,
|
||||
isSelected,
|
||||
isFavorite,
|
||||
disabled = false,
|
||||
onPress,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
row: SelectorModelRow;
|
||||
isSelected: boolean;
|
||||
isFavorite: boolean;
|
||||
disabled?: boolean;
|
||||
onPress: () => void;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProviderIcon = getProviderIcon(row.provider);
|
||||
const isWeb = Platform.OS === "web";
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
onToggleFavorite?.(row.provider, row.modelId);
|
||||
},
|
||||
[onToggleFavorite, row.modelId, row.provider],
|
||||
);
|
||||
|
||||
const item = (
|
||||
<ComboboxItem
|
||||
label={row.modelLabel}
|
||||
selected={isSelected}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
leadingSlot={<ProviderIcon size={14} color={theme.colors.foregroundMuted} />}
|
||||
trailingSlot={
|
||||
onToggleFavorite && !disabled ? (
|
||||
<Pressable
|
||||
onPress={handleToggleFavorite}
|
||||
hitSlop={8}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.favoriteButton,
|
||||
hovered && styles.favoriteButtonHovered,
|
||||
pressed && styles.favoriteButtonPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
|
||||
testID={`favorite-model-${row.provider}-${row.modelId}`}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Star
|
||||
size={16}
|
||||
color={
|
||||
isFavorite
|
||||
? theme.colors.palette.amber[500]
|
||||
: hovered
|
||||
? theme.colors.foregroundMuted
|
||||
: theme.colors.border
|
||||
}
|
||||
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
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({
|
||||
favoriteRows,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
favoriteKeys,
|
||||
onSelect,
|
||||
canSelectProvider,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
favoriteRows: SelectorModelRow[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
favoriteKeys: Set<string>;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
canSelectProvider: (provider: string) => boolean;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (favoriteRows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={styles.sectionHeadingText}>Favorites</Text>
|
||||
</View>
|
||||
{favoriteRows.map((row) => (
|
||||
<ModelRow
|
||||
key={row.favoriteKey}
|
||||
row={row}
|
||||
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
||||
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
||||
disabled={!canSelectProvider(row.provider)}
|
||||
onPress={() => onSelect(row.provider, row.modelId)}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
/>
|
||||
))}
|
||||
<View style={styles.separator} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupedProviderRows({
|
||||
providerDefinitions,
|
||||
groupedRows,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
favoriteKeys,
|
||||
onSelect,
|
||||
canSelectProvider,
|
||||
onToggleFavorite,
|
||||
onDrillDown,
|
||||
}: {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
favoriteKeys: Set<string>;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
canSelectProvider: (provider: string) => boolean;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<View>
|
||||
{groupedRows.map((group, index) => {
|
||||
const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId);
|
||||
const ProvIcon = getProviderIcon(group.providerId);
|
||||
const isInline = group.rows.length <= INLINE_MODEL_THRESHOLD;
|
||||
|
||||
return (
|
||||
<View key={group.providerId}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
{isInline ? (
|
||||
<>
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={styles.sectionHeadingText}>
|
||||
{providerDefinition?.label ?? group.providerLabel}
|
||||
</Text>
|
||||
</View>
|
||||
{group.rows.map((row) => (
|
||||
<ModelRow
|
||||
key={row.favoriteKey}
|
||||
row={row}
|
||||
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
||||
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
||||
disabled={!canSelectProvider(row.provider)}
|
||||
onPress={() => onSelect(row.provider, row.modelId)}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => onDrillDown(group.providerId, group.providerLabel)}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.drillDownRow,
|
||||
hovered && styles.drillDownRowHovered,
|
||||
pressed && styles.drillDownRowPressed,
|
||||
]}
|
||||
>
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
<Text style={styles.drillDownCount}>{group.rows.length}</Text>
|
||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectorContent({
|
||||
view,
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
favoriteKeys,
|
||||
onSelect,
|
||||
canSelectProvider,
|
||||
onToggleFavorite,
|
||||
onDrillDown,
|
||||
onBack,
|
||||
}: SelectorContentProps) {
|
||||
const allRows = useMemo(
|
||||
() => buildModelRows(providerDefinitions, allProviderModels),
|
||||
[allProviderModels, providerDefinitions],
|
||||
);
|
||||
|
||||
const scopedRows = useMemo(() => {
|
||||
if (view.kind === "provider") {
|
||||
return allRows.filter((row) => row.provider === view.providerId);
|
||||
}
|
||||
return allRows;
|
||||
}, [allRows, view]);
|
||||
|
||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() => scopedRows.filter((row) => matchesSearch(row, normalizedQuery)),
|
||||
[normalizedQuery, scopedRows],
|
||||
);
|
||||
|
||||
const { favoriteRows, regularRows } = useMemo(
|
||||
() => partitionRows(visibleRows, favoriteKeys),
|
||||
[favoriteKeys, visibleRows],
|
||||
);
|
||||
|
||||
const groupedRegularRows = useMemo(() => groupRowsByProvider(regularRows), [regularRows]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
{view.kind === "provider" ? (
|
||||
<ProviderBackButton providerId={view.providerId} providerLabel={view.providerLabel} onBack={onBack} />
|
||||
) : null}
|
||||
|
||||
<SearchInput
|
||||
placeholder={view.kind === "provider" ? "Search models..." : "Search models or providers..."}
|
||||
value={searchQuery}
|
||||
onChangeText={onSearchChange}
|
||||
autoFocus={Platform.OS === "web"}
|
||||
/>
|
||||
|
||||
<FavoritesSection
|
||||
favoriteRows={favoriteRows}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onSelect={onSelect}
|
||||
canSelectProvider={canSelectProvider}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
/>
|
||||
|
||||
{groupedRegularRows.length > 0 ? (
|
||||
<GroupedProviderRows
|
||||
providerDefinitions={providerDefinitions}
|
||||
groupedRows={groupedRegularRows}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onSelect={onSelect}
|
||||
canSelectProvider={canSelectProvider}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDrillDown={onDrillDown}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{favoriteRows.length === 0 && groupedRegularRows.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Search size={16} color="#777" />
|
||||
<Text style={styles.emptyStateText}>No models match your search</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderBackButton({
|
||||
providerId,
|
||||
providerLabel,
|
||||
onBack,
|
||||
}: {
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProviderIcon = getProviderIcon(providerId);
|
||||
|
||||
if (!onBack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.backButton,
|
||||
hovered && styles.backButtonHovered,
|
||||
pressed && styles.backButtonPressed,
|
||||
]}
|
||||
>
|
||||
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
|
||||
<ProviderIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function CombinedModelSelector({
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
@@ -35,48 +451,80 @@ export function CombinedModelSelector({
|
||||
selectedModel,
|
||||
onSelect,
|
||||
isLoading,
|
||||
canSelectProvider = () => true,
|
||||
favoriteKeys = new Set<string>(),
|
||||
onToggleFavorite,
|
||||
renderTrigger,
|
||||
disabled = false,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isWeb = Platform.OS === "web";
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [view, setView] = useState<"groups" | DrillDownView>("groups");
|
||||
const [isContentReady, setIsContentReady] = useState(isWeb);
|
||||
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (open) {
|
||||
const models = allProviderModels.get(selectedProvider);
|
||||
if (models && models.length > INLINE_MODEL_THRESHOLD) {
|
||||
setView({ provider: selectedProvider });
|
||||
}
|
||||
} else {
|
||||
setView("groups");
|
||||
setView({ kind: "all" });
|
||||
if (!open) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
},
|
||||
[allProviderModels, selectedProvider],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(provider: string, modelId: string) => {
|
||||
onSelect(provider as AgentProvider, modelId);
|
||||
setIsOpen(false);
|
||||
setView("groups");
|
||||
setView({ kind: "all" });
|
||||
setSearchQuery("");
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||
const selectedProviderLabel = useMemo(
|
||||
() => resolveProviderLabel(providerDefinitions, selectedProvider),
|
||||
[providerDefinitions, selectedProvider],
|
||||
);
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
const models = allProviderModels.get(selectedProvider);
|
||||
if (!models) return isLoading ? "Loading..." : "Select model";
|
||||
const model = models.find((m) => m.id === selectedModel);
|
||||
if (!models) {
|
||||
return isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
const model = models.find((entry) => entry.id === selectedModel);
|
||||
return model?.label ?? resolveDefaultModelLabel(models);
|
||||
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
|
||||
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
||||
|
||||
const triggerLabel = useMemo(() => {
|
||||
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
|
||||
return selectedModelLabel;
|
||||
}
|
||||
|
||||
return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel);
|
||||
}, [selectedModelLabel, selectedProviderLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
setIsContentReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
setIsContentReady(true);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [isOpen, isWeb]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -90,14 +538,26 @@ export function CombinedModelSelector({
|
||||
hovered && styles.triggerHovered,
|
||||
(pressed || isOpen) && styles.triggerPressed,
|
||||
disabled && styles.triggerDisabled,
|
||||
renderTrigger ? styles.customTriggerWrapper : null,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select model (${selectedModelLabel})`}
|
||||
testID="combined-model-selector"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.triggerText}>{selectedModelLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
{renderTrigger ? (
|
||||
renderTrigger({
|
||||
selectedModelLabel: triggerLabel,
|
||||
onPress: () => handleOpenChange(!isOpen),
|
||||
disabled,
|
||||
isOpen,
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.triggerText}>{triggerLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={[]}
|
||||
@@ -105,184 +565,46 @@ export function CombinedModelSelector({
|
||||
onSelect={() => {}}
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
stackBehavior="push"
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="top-start"
|
||||
title="Select model"
|
||||
>
|
||||
{view === "groups" ? (
|
||||
<GroupsView
|
||||
{isContentReady ? (
|
||||
<SelectorContent
|
||||
view={view}
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={handleSelect}
|
||||
onDrillDown={(provider) => {
|
||||
setView({ provider });
|
||||
setSearchQuery("");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DrillDownModelView
|
||||
provider={view.provider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
models={allProviderModels.get(view.provider) ?? []}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onSelect={handleSelect}
|
||||
onBack={() => {
|
||||
setView("groups");
|
||||
setSearchQuery("");
|
||||
canSelectProvider={canSelectProvider}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDrillDown={(providerId, providerLabel) => {
|
||||
setView({ kind: "provider", providerId, providerLabel });
|
||||
}}
|
||||
onBack={
|
||||
view.kind === "provider"
|
||||
? () => {
|
||||
setView({ kind: "all" });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.sheetLoadingState}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sheetLoadingText}>Loading model selector…</Text>
|
||||
</View>
|
||||
)}
|
||||
</Combobox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupsView({
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
onSelect,
|
||||
onDrillDown,
|
||||
}: {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
onDrillDown: (provider: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<View>
|
||||
{providerDefinitions.map((def, index) => {
|
||||
const models = allProviderModels.get(def.id) ?? [];
|
||||
const isInline = models.length <= INLINE_MODEL_THRESHOLD;
|
||||
const ProvIcon = getProviderIcon(def.id);
|
||||
|
||||
return (
|
||||
<View key={def.id}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
|
||||
{isInline ? (
|
||||
<>
|
||||
<View style={styles.sectionHeading}>
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sectionHeadingText}>{def.label}</Text>
|
||||
</View>
|
||||
{models.map((model) => (
|
||||
<ComboboxItem
|
||||
key={model.id}
|
||||
label={model.label}
|
||||
selected={model.id === selectedModel && def.id === selectedProvider}
|
||||
onPress={() => onSelect(def.id, model.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => onDrillDown(def.id)}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.drillDownRow,
|
||||
hovered && styles.drillDownRowHovered,
|
||||
pressed && styles.drillDownRowPressed,
|
||||
]}
|
||||
>
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownText}>{def.label}</Text>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
<Text style={styles.drillDownCount}>{models.length}</Text>
|
||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DrillDownModelView({
|
||||
provider,
|
||||
providerDefinitions,
|
||||
models,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onSelect,
|
||||
onBack,
|
||||
}: {
|
||||
provider: string;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
models: AgentModelDefinition[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProvIcon = getProviderIcon(provider);
|
||||
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider;
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!searchQuery.trim()) return models;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return models.filter(
|
||||
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q),
|
||||
);
|
||||
}, [models, searchQuery]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.backButton,
|
||||
hovered && styles.backButtonHovered,
|
||||
pressed && styles.backButtonPressed,
|
||||
]}
|
||||
>
|
||||
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
||||
</Pressable>
|
||||
|
||||
<SearchInput
|
||||
placeholder="Search models..."
|
||||
value={searchQuery}
|
||||
onChangeText={onSearchChange}
|
||||
autoFocus={Platform.OS === "web"}
|
||||
/>
|
||||
|
||||
{filteredModels.map((model) => (
|
||||
<ComboboxItem
|
||||
key={model.id}
|
||||
label={model.label}
|
||||
description={model.description}
|
||||
selected={model.id === selectedModel && provider === selectedProvider}
|
||||
onPress={() => onSelect(provider, model.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredModels.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyStateText}>No models match your search</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
height: 28,
|
||||
@@ -307,6 +629,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
customTriggerWrapper: {
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: 0,
|
||||
height: "auto",
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
@@ -374,9 +701,37 @@ const styles = StyleSheet.create((theme) => ({
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
favoriteButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
favoriteButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
favoriteButtonPressed: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
sheetLoadingState: {
|
||||
minHeight: 160,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sheetLoadingText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||
|
||||
export type SelectorModelRow = FavoriteModelRow;
|
||||
|
||||
export function resolveProviderLabel(
|
||||
providerDefinitions: AgentProviderDefinition[],
|
||||
providerId: string,
|
||||
): string {
|
||||
return providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId;
|
||||
}
|
||||
|
||||
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
|
||||
return modelLabel;
|
||||
}
|
||||
|
||||
export function buildModelRows(
|
||||
providerDefinitions: AgentProviderDefinition[],
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>,
|
||||
): SelectorModelRow[] {
|
||||
const providerLabelMap = new Map(providerDefinitions.map((definition) => [definition.id, definition.label]));
|
||||
const rows: SelectorModelRow[] = [];
|
||||
|
||||
for (const definition of providerDefinitions) {
|
||||
const providerLabel = providerLabelMap.get(definition.id) ?? definition.label;
|
||||
for (const model of allProviderModels.get(definition.id) ?? []) {
|
||||
rows.push({
|
||||
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
|
||||
provider: definition.id,
|
||||
providerLabel,
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
description: model.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
|
||||
value.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface CopilotIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function CopilotIcon({ size = 16, color = "currentColor" }: CopilotIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 512 416" fill={color}>
|
||||
<Path
|
||||
d="M181.33 266.143c0-11.497 9.32-20.818 20.818-20.818 11.498 0 20.819 9.321 20.819 20.818v38.373c0 11.497-9.321 20.818-20.819 20.818-11.497 0-20.818-9.32-20.818-20.818v-38.373zM308.807 245.325c-11.477 0-20.798 9.321-20.798 20.818v38.373c0 11.497 9.32 20.818 20.798 20.818 11.497 0 20.818-9.32 20.818-20.818v-38.373c0-11.497-9.32-20.818-20.818-20.818z"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
<Path d="M512.002 246.393v57.384c-.02 7.411-3.696 14.638-9.67 19.011C431.767 374.444 344.695 416 256 416c-98.138 0-196.379-56.542-246.33-93.21-5.975-4.374-9.65-11.6-9.671-19.012v-57.384a35.347 35.347 0 016.857-20.922l15.583-21.085c8.336-11.312 20.757-14.31 33.98-14.31 4.988-56.953 16.794-97.604 45.024-127.354C155.194 5.77 226.56 0 256 0c29.441 0 100.807 5.77 154.557 62.722 28.19 29.75 40.036 70.401 45.025 127.354 13.263 0 25.602 2.936 33.958 14.31l15.583 21.127c4.476 6.077 6.878 13.345 6.878 20.88zm-97.666-26.075c-.677-13.058-11.292-18.19-22.338-21.824-11.64 7.309-25.848 10.183-39.46 10.183-14.454 0-41.432-3.47-63.872-25.869-5.667-5.625-9.527-14.454-12.155-24.247a212.902 212.902 0 00-20.469-1.088c-6.098 0-13.099.349-20.551 1.088-2.628 9.793-6.509 18.622-12.155 24.247-22.4 22.4-49.418 25.87-63.872 25.87-13.612 0-27.86-2.855-39.501-10.184-11.005 3.613-21.558 8.828-22.277 21.824-1.17 24.555-1.272 49.11-1.375 73.645-.041 12.318-.082 24.658-.288 36.976.062 7.166 4.374 13.818 10.882 16.774 52.97 24.124 103.045 36.278 149.137 36.278 46.01 0 96.085-12.154 149.014-36.278 6.508-2.956 10.84-9.608 10.881-16.774.637-36.832.124-73.809-1.642-110.62h.041zM107.521 168.97c8.643 8.623 24.966 14.392 42.56 14.392 13.448 0 39.03-2.874 60.156-24.329 9.28-8.951 15.05-31.35 14.413-54.079-.657-18.231-5.769-33.28-13.448-39.665-8.315-7.371-27.203-10.574-48.33-8.644-22.399 2.238-41.267 9.588-50.875 19.833-20.798 22.728-16.323 80.317-4.476 92.492zm130.556-56.008c.637 3.51.965 7.35 1.273 11.517 0 2.875 0 5.77-.308 8.952 6.406-.636 11.847-.636 16.959-.636s10.553 0 16.959.636c-.329-3.182-.329-6.077-.329-8.952.329-4.167.657-8.007 1.294-11.517-6.735-.637-12.812-.965-17.924-.965s-11.21.328-17.924.965zm49.275-8.008c-.637 22.728 5.133 45.128 14.413 54.08 21.105 21.454 46.708 24.328 60.155 24.328 17.596 0 33.918-5.769 42.561-14.392 11.847-12.175 16.322-69.764-4.476-92.492-9.608-10.245-28.476-17.595-50.875-19.833-21.127-1.93-40.015 1.273-48.33 8.644-7.679 6.385-12.791 21.434-13.448 39.665z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ interface OpenCodeIconProps {
|
||||
export function OpenCodeIcon({ size = 16, color = "currentColor" }: OpenCodeIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="96 64 288 384" fill={color}>
|
||||
<Path d="M320 224V352H192V224H320Z" opacity="0.4" />
|
||||
<Path d="M320 224V352H192V224H320Z" opacity={0.4} />
|
||||
<Path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
|
||||
@@ -4,14 +4,17 @@ import { AmpIcon } from "@/components/icons/amp-icon";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { GeminiIcon } from "@/components/icons/gemini-icon";
|
||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
"claude-acp": ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
gemini: GeminiIcon as unknown as typeof Bot,
|
||||
amp: AmpIcon as unknown as typeof Bot,
|
||||
aider: AiderIcon as unknown as typeof Bot,
|
||||
copilot: CopilotIcon as unknown as typeof Bot,
|
||||
opencode: OpenCodeIcon as unknown as typeof Bot,
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { type GestureType } from "react-native-gesture-handler";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
Archive,
|
||||
CircleAlert,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
@@ -73,7 +74,7 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
@@ -105,6 +106,10 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||
const EMPTY_WORKSPACES = new Map();
|
||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
||||
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
|
||||
open: "Open",
|
||||
merged: "Merged",
|
||||
@@ -243,6 +248,14 @@ function WorkspaceStatusIndicator({
|
||||
);
|
||||
}
|
||||
|
||||
if (bucket === "needs_input") {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const KindIcon =
|
||||
workspaceKind === "checkout"
|
||||
? Monitor
|
||||
@@ -252,6 +265,13 @@ function WorkspaceStatusIndicator({
|
||||
if (!KindIcon) return null;
|
||||
|
||||
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
|
||||
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
|
||||
? EMPHASIZED_STATUS_DOT_SIZE
|
||||
: DEFAULT_STATUS_DOT_SIZE;
|
||||
const statusDotOffset =
|
||||
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
|
||||
? EMPHASIZED_STATUS_DOT_OFFSET
|
||||
: DEFAULT_STATUS_DOT_OFFSET;
|
||||
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
@@ -263,6 +283,10 @@ function WorkspaceStatusIndicator({
|
||||
{
|
||||
backgroundColor: dotColor,
|
||||
borderColor: theme.colors.surface0,
|
||||
width: statusDotSize,
|
||||
height: statusDotSize,
|
||||
right: statusDotOffset,
|
||||
bottom: statusDotOffset,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -332,11 +356,26 @@ function ProjectLeadingVisual({
|
||||
);
|
||||
}
|
||||
|
||||
if (activeWorkspace.statusBucket === "needs_input") {
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const dotColor = getStatusDotColor({
|
||||
theme,
|
||||
bucket: activeWorkspace.statusBucket,
|
||||
showDoneAsInactive: false,
|
||||
});
|
||||
const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket)
|
||||
? EMPHASIZED_STATUS_DOT_SIZE
|
||||
: DEFAULT_STATUS_DOT_SIZE;
|
||||
const statusDotOffset =
|
||||
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
|
||||
? EMPHASIZED_STATUS_DOT_OFFSET
|
||||
: DEFAULT_STATUS_DOT_OFFSET;
|
||||
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
@@ -348,6 +387,10 @@ function ProjectLeadingVisual({
|
||||
{
|
||||
backgroundColor: dotColor,
|
||||
borderColor: theme.colors.surface0,
|
||||
width: statusDotSize,
|
||||
height: statusDotSize,
|
||||
right: statusDotOffset,
|
||||
bottom: statusDotOffset,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -2212,10 +2255,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
statusDotOverlay: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 7,
|
||||
height: 7,
|
||||
right: DEFAULT_STATUS_DOT_OFFSET,
|
||||
bottom: DEFAULT_STATUS_DOT_OFFSET,
|
||||
width: DEFAULT_STATUS_DOT_SIZE,
|
||||
height: DEFAULT_STATUS_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface ComboboxProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
enableDismissOnClose?: boolean;
|
||||
stackBehavior?: "push" | "switch" | "replace";
|
||||
desktopPlacement?: "top-start" | "bottom-start";
|
||||
/**
|
||||
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
||||
@@ -145,8 +146,10 @@ export interface ComboboxItemProps {
|
||||
description?: string;
|
||||
kind?: "directory" | "file";
|
||||
leadingSlot?: ReactNode;
|
||||
trailingSlot?: ReactNode;
|
||||
selected?: boolean;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
@@ -156,8 +159,10 @@ export function ComboboxItem({
|
||||
description,
|
||||
kind,
|
||||
leadingSlot,
|
||||
trailingSlot,
|
||||
selected,
|
||||
active,
|
||||
disabled,
|
||||
onPress,
|
||||
testID,
|
||||
}: ComboboxItemProps): ReactElement {
|
||||
@@ -178,12 +183,14 @@ export function ComboboxItem({
|
||||
return (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.comboboxItem,
|
||||
hovered && styles.comboboxItemHovered,
|
||||
pressed && styles.comboboxItemPressed,
|
||||
active && styles.comboboxItemActive,
|
||||
disabled && styles.comboboxItemDisabled,
|
||||
]}
|
||||
>
|
||||
{leadingContent}
|
||||
@@ -197,9 +204,12 @@ export function ComboboxItem({
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{selected ? (
|
||||
<View style={styles.comboboxItemTrailingSlot}>
|
||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||
{selected || trailingSlot ? (
|
||||
<View style={styles.comboboxItemTrailingContainer}>
|
||||
<View style={styles.comboboxItemTrailingSlot}>
|
||||
{selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null}
|
||||
</View>
|
||||
{trailingSlot}
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
@@ -233,6 +243,7 @@ export function Combobox({
|
||||
open,
|
||||
onOpenChange,
|
||||
enableDismissOnClose,
|
||||
stackBehavior,
|
||||
desktopPlacement = "top-start",
|
||||
desktopPreventInitialFlash = true,
|
||||
anchorRef,
|
||||
@@ -642,6 +653,7 @@ export function Combobox({
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
enableDismissOnClose={enableDismissOnClose}
|
||||
stackBehavior={stackBehavior}
|
||||
backgroundComponent={ComboboxSheetBackground}
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
@@ -777,10 +789,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
comboboxItemActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
comboboxItemDisabled: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
comboboxItemTrailingSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
comboboxItemTrailingContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
marginLeft: "auto",
|
||||
},
|
||||
comboboxItemContent: {
|
||||
|
||||
Reference in New Issue
Block a user