Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -27,6 +27,8 @@ export const ACP_PROVIDER_ICON_SVGS = {
|
|||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 466.73 532.09">\n <path fill="currentColor" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>\n</svg>\n',
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 466.73 532.09">\n <path fill="currentColor" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>\n</svg>\n',
|
||||||
deepagents:
|
deepagents:
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 128 128">\n <path fill="currentColor" d="M40.1024 85.0722C47.6207 77.5537 51.8469 67.3453 51.8469 56.7136C51.8469 46.0818 47.617 35.8734 40.1024 28.355L11.7446 0C4.22995 7.5185 0 17.7269 0 28.3586C0 38.9903 4.22995 49.1987 11.7446 56.7172L40.0987 85.0722H40.1024Z"/>\n <path fill="currentColor" d="M99.4385 87.698C91.9239 80.1832 81.7121 75.9531 71.0844 75.9531C60.4566 75.9531 50.2448 80.1832 42.7266 87.698L71.0844 116.057C78.599 123.571 88.8107 127.802 99.4421 127.802C110.074 127.802 120.282 123.571 127.8 116.057L99.4421 87.698H99.4385Z"/>\n <path fill="currentColor" d="M11.8146 115.987C19.3329 123.502 29.541 127.732 40.1724 127.732V87.6289H0.0664062C0.0700559 98.2606 4.29635 108.469 11.8146 115.987Z"/>\n <path fill="currentColor" d="M110.387 45.7684C102.869 38.2535 92.6608 34.0198 82.0258 34.0234C71.3943 34.0234 61.1863 38.2535 53.668 45.772L82.0258 74.1306L110.387 45.7684Z"/>\n</svg>\n',
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 128 128">\n <path fill="currentColor" d="M40.1024 85.0722C47.6207 77.5537 51.8469 67.3453 51.8469 56.7136C51.8469 46.0818 47.617 35.8734 40.1024 28.355L11.7446 0C4.22995 7.5185 0 17.7269 0 28.3586C0 38.9903 4.22995 49.1987 11.7446 56.7172L40.0987 85.0722H40.1024Z"/>\n <path fill="currentColor" d="M99.4385 87.698C91.9239 80.1832 81.7121 75.9531 71.0844 75.9531C60.4566 75.9531 50.2448 80.1832 42.7266 87.698L71.0844 116.057C78.599 123.571 88.8107 127.802 99.4421 127.802C110.074 127.802 120.282 123.571 127.8 116.057L99.4421 87.698H99.4385Z"/>\n <path fill="currentColor" d="M11.8146 115.987C19.3329 123.502 29.541 127.732 40.1724 127.732V87.6289H0.0664062C0.0700559 98.2606 4.29635 108.469 11.8146 115.987Z"/>\n <path fill="currentColor" d="M110.387 45.7684C102.869 38.2535 92.6608 34.0198 82.0258 34.0234C71.3943 34.0234 61.1863 38.2535 53.668 45.772L82.0258 74.1306L110.387 45.7684Z"/>\n</svg>\n',
|
||||||
|
"deepseek-tui":
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">\n <rect width="32" height="32" fill="currentColor" opacity="0.18"/>\n <text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-family="\'Noto Serif SC\', serif" font-weight="700" font-size="20" fill="currentColor">深</text>\n <rect x="0" y="29" width="32" height="3" fill="currentColor"/>\n</svg>\n',
|
||||||
dimcode:
|
dimcode:
|
||||||
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M3.12109 11.0078H1.99902V5.49316H3.12109V11.0078ZM3.7041 5.49316C4.91979 5.49316 5.31142 5.57449 5.80762 5.7373C6.50208 5.97365 6.85299 6.40958 6.86133 7.04492V9.45605C6.86125 10.207 6.37767 10.6797 5.41016 10.874C4.95546 10.9633 4.69632 11.0078 3.7041 11.0078V10.6064C4.72131 10.6064 4.95994 10.5507 5.34863 10.4404C5.91072 10.2671 6.19576 9.93907 6.2041 9.45605V7.04492C6.20402 6.4883 5.83211 6.13886 5.08789 5.99707C4.74057 5.93405 4.58897 5.89978 3.7041 5.89453V5.49316ZM9.16797 5.49316V11.0078H8.0459V5.49316H9.16797ZM14 6.79297V11.0078H12.8779V8.0791L13.8877 6.79297H14ZM14 5.49316V5.7373L11.3594 8.97852H10.7852L9.74219 6.94531V6.07812H9.86719L11.0723 8.33203L13.4258 5.49316H14Z" fill="currentColor"/>\n</svg>\n',
|
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M3.12109 11.0078H1.99902V5.49316H3.12109V11.0078ZM3.7041 5.49316C4.91979 5.49316 5.31142 5.57449 5.80762 5.7373C6.50208 5.97365 6.85299 6.40958 6.86133 7.04492V9.45605C6.86125 10.207 6.37767 10.6797 5.41016 10.874C4.95546 10.9633 4.69632 11.0078 3.7041 11.0078V10.6064C4.72131 10.6064 4.95994 10.5507 5.34863 10.4404C5.91072 10.2671 6.19576 9.93907 6.2041 9.45605V7.04492C6.20402 6.4883 5.83211 6.13886 5.08789 5.99707C4.74057 5.93405 4.58897 5.89978 3.7041 5.89453V5.49316ZM9.16797 5.49316V11.0078H8.0459V5.49316H9.16797ZM14 6.79297V11.0078H12.8779V8.0791L13.8877 6.79297H14ZM14 5.49316V5.7373L11.3594 8.97852H10.7852L9.74219 6.94531V6.07812H9.86719L11.0723 8.33203L13.4258 5.49316H14Z" fill="currentColor"/>\n</svg>\n',
|
||||||
dirac:
|
dirac:
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" fill="currentColor" opacity="0.18"/>
|
||||||
|
<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-family="'Noto Serif SC', serif" font-weight="700" font-size="20" fill="currentColor">深</text>
|
||||||
|
<rect x="0" y="29" width="32" height="3" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 397 B |
@@ -32,6 +32,10 @@ import {
|
|||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||||
|
import {
|
||||||
|
buildModelSelectorProviders,
|
||||||
|
type ModelSelectorProvider,
|
||||||
|
} from "@/components/combined-model-selector.utils";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||||
@@ -93,8 +97,7 @@ interface ControlledAgentStatusBarProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isModelLoading?: boolean;
|
isModelLoading?: boolean;
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
allProviderModels?: Map<string, AgentModelDefinition[]>;
|
modelSelectorProviders?: ModelSelectorProvider[];
|
||||||
canSelectModelProvider?: (providerId: string) => boolean;
|
|
||||||
favoriteKeys?: Set<string>;
|
favoriteKeys?: Set<string>;
|
||||||
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
||||||
features?: AgentFeature[];
|
features?: AgentFeature[];
|
||||||
@@ -114,7 +117,7 @@ export interface DraftAgentStatusBarProps {
|
|||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
onSelectModel: (modelId: string) => void;
|
onSelectModel: (modelId: string) => void;
|
||||||
isModelLoading: boolean;
|
isModelLoading: boolean;
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
modelSelectorProviders: ModelSelectorProvider[];
|
||||||
isAllModelsLoading: boolean;
|
isAllModelsLoading: boolean;
|
||||||
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
|
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
|
||||||
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||||
@@ -184,10 +187,6 @@ const MODE_ICONS = {
|
|||||||
ShieldQuestionMark,
|
ShieldQuestionMark,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function alwaysTrue() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDisplayModel(
|
function resolveDisplayModel(
|
||||||
isModelLoading: boolean,
|
isModelLoading: boolean,
|
||||||
modelOptions: StatusOption[] | undefined,
|
modelOptions: StatusOption[] | undefined,
|
||||||
@@ -235,23 +234,26 @@ function toComboboxOptions(options: StatusOption[] | undefined): ComboboxOption[
|
|||||||
return (options ?? []).map((o) => ({ id: o.id, label: o.label }));
|
return (options ?? []).map((o) => ({ id: o.id, label: o.label }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFallbackAllProviderModels(
|
function buildFallbackModelSelectorProviders(
|
||||||
provider: string,
|
provider: string,
|
||||||
modelOptions: StatusOption[] | undefined,
|
modelOptions: StatusOption[] | undefined,
|
||||||
): Map<string, AgentModelDefinition[]> {
|
): ModelSelectorProvider[] {
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
|
||||||
if (!modelOptions || modelOptions.length === 0) {
|
if (!modelOptions || modelOptions.length === 0) {
|
||||||
return map;
|
return [];
|
||||||
}
|
}
|
||||||
map.set(
|
return [
|
||||||
provider,
|
{
|
||||||
modelOptions.map((option) => ({
|
id: provider,
|
||||||
provider: provider,
|
label: provider,
|
||||||
id: option.id,
|
rows: modelOptions.map((option) => ({
|
||||||
label: option.label,
|
favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
|
||||||
})),
|
provider,
|
||||||
);
|
providerLabel: provider,
|
||||||
return map;
|
modelId: option.id,
|
||||||
|
modelLabel: option.label,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeBadgePressableStyle(
|
function makeBadgePressableStyle(
|
||||||
@@ -459,8 +461,7 @@ function ControlledStatusBar({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
isModelLoading = false,
|
isModelLoading = false,
|
||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
allProviderModels,
|
modelSelectorProviders,
|
||||||
canSelectModelProvider,
|
|
||||||
favoriteKeys = new Set<string>(),
|
favoriteKeys = new Set<string>(),
|
||||||
onToggleFavoriteModel,
|
onToggleFavoriteModel,
|
||||||
features,
|
features,
|
||||||
@@ -521,13 +522,11 @@ function ControlledStatusBar({
|
|||||||
() => toComboboxOptions(modeOptions),
|
() => toComboboxOptions(modeOptions),
|
||||||
[modeOptions],
|
[modeOptions],
|
||||||
);
|
);
|
||||||
const fallbackAllProviderModels = useMemo(
|
const fallbackModelSelectorProviders = useMemo(
|
||||||
() => buildFallbackAllProviderModels(provider, modelOptions),
|
() => buildFallbackModelSelectorProviders(provider, modelOptions),
|
||||||
[modelOptions, provider],
|
[modelOptions, provider],
|
||||||
);
|
);
|
||||||
const effectiveProviderDefinitions = providerDefinitions;
|
const effectiveModelSelectorProviders = modelSelectorProviders ?? fallbackModelSelectorProviders;
|
||||||
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
|
|
||||||
const canSelectProviderInModelMenu = canSelectModelProvider ?? alwaysTrue;
|
|
||||||
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
||||||
() => toComboboxOptions(thinkingOptions),
|
() => toComboboxOptions(thinkingOptions),
|
||||||
[thinkingOptions],
|
[thinkingOptions],
|
||||||
@@ -701,10 +700,8 @@ function ControlledStatusBar({
|
|||||||
canSelectMode={canSelectMode}
|
canSelectMode={canSelectMode}
|
||||||
canSelectModel={canSelectModel}
|
canSelectModel={canSelectModel}
|
||||||
canSelectThinking={canSelectThinking}
|
canSelectThinking={canSelectThinking}
|
||||||
canSelectProviderInModelMenu={canSelectProviderInModelMenu}
|
modelSelectorProviders={effectiveModelSelectorProviders}
|
||||||
modelDisabled={modelDisabled}
|
modelDisabled={modelDisabled}
|
||||||
effectiveProviderDefinitions={effectiveProviderDefinitions}
|
|
||||||
effectiveAllProviderModels={effectiveAllProviderModels}
|
|
||||||
comboboxProviderOptions={comboboxProviderOptions}
|
comboboxProviderOptions={comboboxProviderOptions}
|
||||||
comboboxModeOptions={comboboxModeOptions}
|
comboboxModeOptions={comboboxModeOptions}
|
||||||
comboboxThinkingOptions={comboboxThinkingOptions}
|
comboboxThinkingOptions={comboboxThinkingOptions}
|
||||||
@@ -751,10 +748,8 @@ function ControlledStatusBar({
|
|||||||
canSelectMode={canSelectMode}
|
canSelectMode={canSelectMode}
|
||||||
canSelectModel={canSelectModel}
|
canSelectModel={canSelectModel}
|
||||||
canSelectThinking={canSelectThinking}
|
canSelectThinking={canSelectThinking}
|
||||||
canSelectProviderInModelMenu={canSelectProviderInModelMenu}
|
modelSelectorProviders={effectiveModelSelectorProviders}
|
||||||
modelDisabled={modelDisabled}
|
modelDisabled={modelDisabled}
|
||||||
effectiveProviderDefinitions={effectiveProviderDefinitions}
|
|
||||||
effectiveAllProviderModels={effectiveAllProviderModels}
|
|
||||||
comboboxModeOptions={comboboxModeOptions}
|
comboboxModeOptions={comboboxModeOptions}
|
||||||
comboboxThinkingOptions={comboboxThinkingOptions}
|
comboboxThinkingOptions={comboboxThinkingOptions}
|
||||||
ModeIconComponent={ModeIconComponent}
|
ModeIconComponent={ModeIconComponent}
|
||||||
@@ -799,10 +794,8 @@ interface DesktopStatusBarContentProps {
|
|||||||
canSelectMode: boolean;
|
canSelectMode: boolean;
|
||||||
canSelectModel: boolean;
|
canSelectModel: boolean;
|
||||||
canSelectThinking: boolean;
|
canSelectThinking: boolean;
|
||||||
canSelectProviderInModelMenu: (providerId: string) => boolean;
|
modelSelectorProviders: ModelSelectorProvider[];
|
||||||
modelDisabled: boolean;
|
modelDisabled: boolean;
|
||||||
effectiveProviderDefinitions: AgentProviderDefinition[];
|
|
||||||
effectiveAllProviderModels: Map<string, AgentModelDefinition[]>;
|
|
||||||
comboboxProviderOptions: ComboboxOption[];
|
comboboxProviderOptions: ComboboxOption[];
|
||||||
comboboxModeOptions: ComboboxOption[];
|
comboboxModeOptions: ComboboxOption[];
|
||||||
comboboxThinkingOptions: ComboboxOption[];
|
comboboxThinkingOptions: ComboboxOption[];
|
||||||
@@ -868,10 +861,8 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
|
|||||||
canSelectMode,
|
canSelectMode,
|
||||||
canSelectModel,
|
canSelectModel,
|
||||||
canSelectThinking,
|
canSelectThinking,
|
||||||
canSelectProviderInModelMenu,
|
modelSelectorProviders,
|
||||||
modelDisabled,
|
modelDisabled,
|
||||||
effectiveProviderDefinitions,
|
|
||||||
effectiveAllProviderModels,
|
|
||||||
comboboxProviderOptions,
|
comboboxProviderOptions,
|
||||||
comboboxModeOptions,
|
comboboxModeOptions,
|
||||||
comboboxThinkingOptions,
|
comboboxThinkingOptions,
|
||||||
@@ -942,11 +933,9 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
|
|||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
<View>
|
<View>
|
||||||
<CombinedModelSelector
|
<CombinedModelSelector
|
||||||
providerDefinitions={effectiveProviderDefinitions}
|
providers={modelSelectorProviders}
|
||||||
allProviderModels={effectiveAllProviderModels}
|
|
||||||
selectedProvider={provider}
|
selectedProvider={provider}
|
||||||
selectedModel={selectedModelId ?? ""}
|
selectedModel={selectedModelId ?? ""}
|
||||||
canSelectProvider={canSelectProviderInModelMenu}
|
|
||||||
onSelect={handleDesktopModelSelect}
|
onSelect={handleDesktopModelSelect}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
onToggleFavorite={onToggleFavoriteModel}
|
onToggleFavorite={onToggleFavoriteModel}
|
||||||
@@ -1069,10 +1058,8 @@ interface SheetStatusBarContentProps {
|
|||||||
canSelectMode: boolean;
|
canSelectMode: boolean;
|
||||||
canSelectModel: boolean;
|
canSelectModel: boolean;
|
||||||
canSelectThinking: boolean;
|
canSelectThinking: boolean;
|
||||||
canSelectProviderInModelMenu: (providerId: string) => boolean;
|
modelSelectorProviders: ModelSelectorProvider[];
|
||||||
modelDisabled: boolean;
|
modelDisabled: boolean;
|
||||||
effectiveProviderDefinitions: AgentProviderDefinition[];
|
|
||||||
effectiveAllProviderModels: Map<string, AgentModelDefinition[]>;
|
|
||||||
comboboxModeOptions: ComboboxOption[];
|
comboboxModeOptions: ComboboxOption[];
|
||||||
comboboxThinkingOptions: ComboboxOption[];
|
comboboxThinkingOptions: ComboboxOption[];
|
||||||
ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null;
|
ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null;
|
||||||
@@ -1118,10 +1105,8 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) {
|
|||||||
canSelectMode,
|
canSelectMode,
|
||||||
canSelectModel,
|
canSelectModel,
|
||||||
canSelectThinking,
|
canSelectThinking,
|
||||||
canSelectProviderInModelMenu,
|
modelSelectorProviders,
|
||||||
modelDisabled,
|
modelDisabled,
|
||||||
effectiveProviderDefinitions,
|
|
||||||
effectiveAllProviderModels,
|
|
||||||
comboboxModeOptions,
|
comboboxModeOptions,
|
||||||
comboboxThinkingOptions,
|
comboboxThinkingOptions,
|
||||||
ModeIconComponent,
|
ModeIconComponent,
|
||||||
@@ -1214,11 +1199,9 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) {
|
|||||||
<>
|
<>
|
||||||
{canSelectModel ? (
|
{canSelectModel ? (
|
||||||
<CombinedModelSelector
|
<CombinedModelSelector
|
||||||
providerDefinitions={effectiveProviderDefinitions}
|
providers={modelSelectorProviders}
|
||||||
allProviderModels={effectiveAllProviderModels}
|
|
||||||
selectedProvider={provider}
|
selectedProvider={provider}
|
||||||
selectedModel={selectedModelId ?? ""}
|
selectedModel={selectedModelId ?? ""}
|
||||||
canSelectProvider={canSelectProviderInModelMenu}
|
|
||||||
onSelect={handleSheetModelSelect}
|
onSelect={handleSheetModelSelect}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
onToggleFavorite={onToggleFavoriteModel}
|
onToggleFavorite={onToggleFavoriteModel}
|
||||||
@@ -1678,6 +1661,10 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
() => buildAgentProviderModels(agent?.provider, models),
|
() => buildAgentProviderModels(agent?.provider, models),
|
||||||
[agent?.provider, models],
|
[agent?.provider, models],
|
||||||
);
|
);
|
||||||
|
const agentModelSelectorProviders = useMemo(
|
||||||
|
() => buildModelSelectorProviders(agentProviderDefinitions, agentProviderModels),
|
||||||
|
[agentProviderDefinitions, agentProviderModels],
|
||||||
|
);
|
||||||
|
|
||||||
const displayMode = resolveAgentDisplayMode(availableModes, agent?.currentModeId);
|
const displayMode = resolveAgentDisplayMode(availableModes, agent?.currentModeId);
|
||||||
|
|
||||||
@@ -1841,7 +1828,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
modeOptions={fallbackModeOptions}
|
modeOptions={fallbackModeOptions}
|
||||||
selectedModeId={agent.currentModeId ?? undefined}
|
selectedModeId={agent.currentModeId ?? undefined}
|
||||||
providerDefinitions={agentProviderDefinitions}
|
providerDefinitions={agentProviderDefinitions}
|
||||||
allProviderModels={agentProviderModels}
|
modelSelectorProviders={agentModelSelectorProviders}
|
||||||
onSelectMode={handleSelectMode}
|
onSelectMode={handleSelectMode}
|
||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
selectedModelId={modelSelection.activeModelId ?? undefined}
|
selectedModelId={modelSelection.activeModelId ?? undefined}
|
||||||
@@ -1872,7 +1859,7 @@ export function DraftAgentStatusBar({
|
|||||||
selectedModel,
|
selectedModel,
|
||||||
onSelectModel,
|
onSelectModel,
|
||||||
isModelLoading: _isModelLoading,
|
isModelLoading: _isModelLoading,
|
||||||
allProviderModels,
|
modelSelectorProviders,
|
||||||
isAllModelsLoading,
|
isAllModelsLoading,
|
||||||
onSelectProviderAndModel,
|
onSelectProviderAndModel,
|
||||||
thinkingOptions,
|
thinkingOptions,
|
||||||
@@ -1937,8 +1924,7 @@ export function DraftAgentStatusBar({
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<CombinedModelSelector
|
<CombinedModelSelector
|
||||||
providerDefinitions={providerDefinitions}
|
providers={modelSelectorProviders}
|
||||||
allProviderModels={allProviderModels}
|
|
||||||
selectedProvider={selectedProvider ?? ""}
|
selectedProvider={selectedProvider ?? ""}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onSelect={onSelectProviderAndModel}
|
onSelect={onSelectProviderAndModel}
|
||||||
@@ -1973,7 +1959,7 @@ export function DraftAgentStatusBar({
|
|||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
provider={selectedProvider ?? ""}
|
provider={selectedProvider ?? ""}
|
||||||
providerDefinitions={providerDefinitions}
|
providerDefinitions={providerDefinitions}
|
||||||
allProviderModels={allProviderModels}
|
modelSelectorProviders={modelSelectorProviders}
|
||||||
modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
|
modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
|
||||||
selectedModeId={effectiveSelectedMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
onSelectMode={onSelectMode}
|
onSelectMode={onSelectMode}
|
||||||
|
|||||||
@@ -1,71 +1,136 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
import type {
|
||||||
|
AgentModelDefinition,
|
||||||
|
ProviderSnapshotEntry,
|
||||||
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import {
|
import {
|
||||||
buildModelRows,
|
buildModelSelectorProviders,
|
||||||
|
buildSelectableModelSelectorProviders,
|
||||||
buildSelectedTriggerLabel,
|
buildSelectedTriggerLabel,
|
||||||
filterAndRankModelRows,
|
filterAndRankModelRows,
|
||||||
matchesSearch,
|
matchesSearch,
|
||||||
resolveProviderLabel,
|
|
||||||
} from "./combined-model-selector.utils";
|
} from "./combined-model-selector.utils";
|
||||||
|
|
||||||
describe("combined model selector helpers", () => {
|
describe("combined model selector data", () => {
|
||||||
const providerDefinitions = [
|
const codexModel: AgentModelDefinition = {
|
||||||
{
|
provider: "codex",
|
||||||
id: "claude",
|
id: "gpt-5.4",
|
||||||
label: "Claude",
|
label: "GPT-5.4",
|
||||||
description: "Claude provider",
|
};
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "codex",
|
|
||||||
label: "Codex",
|
|
||||||
description: "Codex provider",
|
|
||||||
defaultModeId: "auto",
|
|
||||||
modes: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const claudeModels: AgentModelDefinition[] = [
|
function snapshotEntry(
|
||||||
{
|
overrides: Partial<ProviderSnapshotEntry> & Pick<ProviderSnapshotEntry, "provider">,
|
||||||
provider: "claude",
|
): ProviderSnapshotEntry {
|
||||||
id: "sonnet-4.6",
|
return {
|
||||||
label: "Sonnet 4.6",
|
...overrides,
|
||||||
},
|
provider: overrides.provider,
|
||||||
];
|
status: overrides.status ?? "ready",
|
||||||
|
enabled: overrides.enabled ?? true,
|
||||||
|
label: overrides.label ?? overrides.provider,
|
||||||
|
description: overrides.description ?? `${overrides.provider} provider`,
|
||||||
|
defaultModeId: overrides.defaultModeId ?? "default",
|
||||||
|
modes: overrides.modes ?? [],
|
||||||
|
models: overrides.models ?? [codexModel],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const codexModels: AgentModelDefinition[] = [
|
it("builds selector providers from ready enabled snapshot entries", () => {
|
||||||
{
|
expect(
|
||||||
provider: "codex",
|
buildSelectableModelSelectorProviders([
|
||||||
id: "gpt-5.4",
|
snapshotEntry({
|
||||||
label: "GPT-5.4",
|
provider: "codex",
|
||||||
},
|
label: "Codex",
|
||||||
];
|
models: [codexModel],
|
||||||
|
}),
|
||||||
it("keeps enough data to search by model and provider name", async () => {
|
|
||||||
const rows = buildModelRows(
|
|
||||||
providerDefinitions,
|
|
||||||
new Map([
|
|
||||||
["claude", claudeModels],
|
|
||||||
["codex", codexModels],
|
|
||||||
]),
|
]),
|
||||||
);
|
).toEqual([
|
||||||
|
{
|
||||||
expect(rows).toEqual([
|
id: "codex",
|
||||||
expect.objectContaining({
|
label: "Codex",
|
||||||
providerLabel: "Claude",
|
rows: [
|
||||||
modelLabel: "Sonnet 4.6",
|
{
|
||||||
modelId: "sonnet-4.6",
|
favoriteKey: "codex:gpt-5.4",
|
||||||
}),
|
provider: "codex",
|
||||||
expect.objectContaining({
|
providerLabel: "Codex",
|
||||||
providerLabel: "Codex",
|
modelId: "gpt-5.4",
|
||||||
modelLabel: "GPT-5.4",
|
modelLabel: "GPT-5.4",
|
||||||
modelId: "gpt-5.4",
|
description: undefined,
|
||||||
}),
|
isDefault: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
expect(matchesSearch(rows[0], "claude")).toBe(true);
|
it("keeps ready enabled providers with no models as model-less providers", () => {
|
||||||
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
|
expect(
|
||||||
|
buildSelectableModelSelectorProviders([
|
||||||
|
snapshotEntry({
|
||||||
|
provider: "deepseek-tui",
|
||||||
|
label: "DeepSeek TUI",
|
||||||
|
models: [],
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
id: "deepseek-tui",
|
||||||
|
label: "DeepSeek TUI",
|
||||||
|
rows: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes disabled providers from selector data", () => {
|
||||||
|
expect(
|
||||||
|
buildSelectableModelSelectorProviders([
|
||||||
|
snapshotEntry({
|
||||||
|
provider: "deepseek-tui",
|
||||||
|
label: "DeepSeek TUI",
|
||||||
|
enabled: false,
|
||||||
|
models: [],
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes providers that are not ready", () => {
|
||||||
|
expect(
|
||||||
|
buildSelectableModelSelectorProviders([
|
||||||
|
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
|
||||||
|
snapshotEntry({ provider: "error-provider", status: "error", models: [] }),
|
||||||
|
snapshotEntry({ provider: "unavailable-provider", status: "unavailable", models: [] }),
|
||||||
|
]),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds selector providers from an already-curated provider list", () => {
|
||||||
|
const providerDefinitions: AgentProviderDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "codex",
|
||||||
|
label: "Codex",
|
||||||
|
description: "Codex provider",
|
||||||
|
defaultModeId: "auto",
|
||||||
|
modes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildModelSelectorProviders(providerDefinitions, new Map([["codex", [codexModel]]])),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
id: "codex",
|
||||||
|
label: "Codex",
|
||||||
|
rows: [
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: "codex",
|
||||||
|
providerLabel: "Codex",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
modelLabel: "GPT-5.4",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches across label, provider, and description with multi-token fuzzy search", () => {
|
it("matches across label, provider, and description with multi-token fuzzy search", () => {
|
||||||
@@ -113,7 +178,6 @@ describe("combined model selector helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the selected trigger label model-only", () => {
|
it("keeps the selected trigger label model-only", () => {
|
||||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
|
||||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
||||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
|
||||||
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
const IS_WEB = platformIsWeb;
|
const IS_WEB = platformIsWeb;
|
||||||
|
|
||||||
@@ -46,10 +45,9 @@ function drillDownRowStyle({
|
|||||||
}
|
}
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import {
|
import {
|
||||||
buildModelRows,
|
|
||||||
buildSelectedTriggerLabel,
|
buildSelectedTriggerLabel,
|
||||||
filterAndRankModelRows,
|
filterAndRankModelRows,
|
||||||
resolveProviderLabel,
|
type ModelSelectorProvider,
|
||||||
type SelectorModelRow,
|
type SelectorModelRow,
|
||||||
} from "./combined-model-selector.utils";
|
} from "./combined-model-selector.utils";
|
||||||
|
|
||||||
@@ -61,13 +59,11 @@ type SelectorView =
|
|||||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||||
|
|
||||||
interface CombinedModelSelectorProps {
|
interface CombinedModelSelectorProps {
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providers: ModelSelectorProvider[];
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
|
||||||
selectedProvider: string;
|
selectedProvider: string;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
onSelect: (provider: AgentProvider, modelId: string) => void;
|
onSelect: (provider: AgentProvider, modelId: string) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
canSelectProvider?: (provider: string) => boolean;
|
|
||||||
favoriteKeys?: Set<string>;
|
favoriteKeys?: Set<string>;
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
renderTrigger?: (input: {
|
renderTrigger?: (input: {
|
||||||
@@ -83,23 +79,21 @@ interface CombinedModelSelectorProps {
|
|||||||
|
|
||||||
interface SelectorContentProps {
|
interface SelectorContentProps {
|
||||||
view: SelectorView;
|
view: SelectorView;
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providers: ModelSelectorProvider[];
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
|
||||||
selectedProvider: string;
|
selectedProvider: string;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
favoriteKeys: Set<string>;
|
favoriteKeys: Set<string>;
|
||||||
onSelect: (provider: string, modelId: string) => void;
|
onSelect: (provider: string, modelId: string) => void;
|
||||||
canSelectProvider: (provider: string) => boolean;
|
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
function resolveDefaultModelLabel(rows: SelectorModelRow[] | undefined): string {
|
||||||
if (!models || models.length === 0) {
|
if (!rows || rows.length === 0) {
|
||||||
return "Select model";
|
return "Select model";
|
||||||
}
|
}
|
||||||
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
|
return (rows.find((row) => row.isDefault) ?? rows[0])?.modelLabel ?? "Select model";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSearchQuery(value: string): string {
|
function normalizeSearchQuery(value: string): string {
|
||||||
@@ -122,36 +116,10 @@ function sortFavoritesFirst(
|
|||||||
return [...favorites, ...rest];
|
return [...favorites, ...rest];
|
||||||
}
|
}
|
||||||
|
|
||||||
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({
|
function ModelRow({
|
||||||
row,
|
row,
|
||||||
isSelected,
|
isSelected,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
disabled = false,
|
|
||||||
elevated = false,
|
elevated = false,
|
||||||
onPress,
|
onPress,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
@@ -159,7 +127,6 @@ function ModelRow({
|
|||||||
row: SelectorModelRow;
|
row: SelectorModelRow;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
elevated?: boolean;
|
elevated?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
@@ -181,7 +148,7 @@ function ModelRow({
|
|||||||
);
|
);
|
||||||
const trailingSlot = useMemo(
|
const trailingSlot = useMemo(
|
||||||
() =>
|
() =>
|
||||||
onToggleFavorite && !disabled ? (
|
onToggleFavorite ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleToggleFavorite}
|
onPress={handleToggleFavorite}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
@@ -207,7 +174,6 @@ function ModelRow({
|
|||||||
) : null,
|
) : null,
|
||||||
[
|
[
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
disabled,
|
|
||||||
handleToggleFavorite,
|
handleToggleFavorite,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
row.provider,
|
row.provider,
|
||||||
@@ -225,7 +191,6 @@ function ModelRow({
|
|||||||
label={row.modelLabel}
|
label={row.modelLabel}
|
||||||
description={showDescription ? row.description : undefined}
|
description={showDescription ? row.description : undefined}
|
||||||
selected={isSelected}
|
selected={isSelected}
|
||||||
disabled={disabled}
|
|
||||||
elevated={elevated}
|
elevated={elevated}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
leadingSlot={leadingSlot}
|
leadingSlot={leadingSlot}
|
||||||
@@ -238,7 +203,6 @@ interface SelectableModelRowProps {
|
|||||||
row: SelectorModelRow;
|
row: SelectorModelRow;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
elevated?: boolean;
|
elevated?: boolean;
|
||||||
onSelect: (provider: string, modelId: string) => void;
|
onSelect: (provider: string, modelId: string) => void;
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
@@ -248,7 +212,6 @@ function SelectableModelRow({
|
|||||||
row,
|
row,
|
||||||
isSelected,
|
isSelected,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
disabled,
|
|
||||||
elevated,
|
elevated,
|
||||||
onSelect,
|
onSelect,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
@@ -261,7 +224,6 @@ function SelectableModelRow({
|
|||||||
row={row}
|
row={row}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
isFavorite={isFavorite}
|
isFavorite={isFavorite}
|
||||||
disabled={disabled}
|
|
||||||
elevated={elevated}
|
elevated={elevated}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
@@ -275,7 +237,6 @@ function FavoritesSection({
|
|||||||
selectedModel,
|
selectedModel,
|
||||||
favoriteKeys,
|
favoriteKeys,
|
||||||
onSelect,
|
onSelect,
|
||||||
canSelectProvider,
|
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
}: {
|
}: {
|
||||||
favoriteRows: SelectorModelRow[];
|
favoriteRows: SelectorModelRow[];
|
||||||
@@ -283,11 +244,8 @@ function FavoritesSection({
|
|||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
favoriteKeys: Set<string>;
|
favoriteKeys: Set<string>;
|
||||||
onSelect: (provider: string, modelId: string) => void;
|
onSelect: (provider: string, modelId: string) => void;
|
||||||
canSelectProvider: (provider: string) => boolean;
|
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { theme: _theme } = useUnistyles();
|
|
||||||
|
|
||||||
if (favoriteRows.length === 0) {
|
if (favoriteRows.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -303,7 +261,6 @@ function FavoritesSection({
|
|||||||
row={row}
|
row={row}
|
||||||
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
||||||
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
||||||
disabled={!canSelectProvider(row.provider)}
|
|
||||||
elevated
|
elevated
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
@@ -317,52 +274,67 @@ interface GroupProviderButtonProps {
|
|||||||
providerId: string;
|
providerId: string;
|
||||||
providerLabel: string;
|
providerLabel: string;
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
|
hasNoModels: boolean;
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
|
onSelectDefault: (providerId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupProviderButton({
|
function GroupProviderButton({
|
||||||
providerId,
|
providerId,
|
||||||
providerLabel,
|
providerLabel,
|
||||||
rowCount,
|
rowCount,
|
||||||
|
hasNoModels,
|
||||||
onDrillDown,
|
onDrillDown,
|
||||||
|
onSelectDefault,
|
||||||
}: GroupProviderButtonProps) {
|
}: GroupProviderButtonProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const ProvIcon = getProviderIcon(providerId);
|
const ProvIcon = getProviderIcon(providerId);
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
|
if (hasNoModels) {
|
||||||
|
onSelectDefault(providerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
onDrillDown(providerId, providerLabel);
|
onDrillDown(providerId, providerLabel);
|
||||||
}, [onDrillDown, providerId, providerLabel]);
|
}, [hasNoModels, onDrillDown, onSelectDefault, providerId, providerLabel]);
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
||||||
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
||||||
<View style={styles.drillDownTrailing}>
|
<View style={styles.drillDownTrailing}>
|
||||||
<Text style={styles.drillDownCount}>
|
<Text style={styles.drillDownCount}>
|
||||||
{rowCount} {rowCount === 1 ? "model" : "models"}
|
{hasNoModels ? "Default" : `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
||||||
</Text>
|
</Text>
|
||||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
{hasNoModels ? null : (
|
||||||
|
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupedProviderRows({
|
function GroupedProviderRows({
|
||||||
groupedRows,
|
providers,
|
||||||
onDrillDown,
|
onDrillDown,
|
||||||
|
onSelectDefault,
|
||||||
}: {
|
}: {
|
||||||
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
|
providers: ModelSelectorProvider[];
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
|
onSelectDefault: (providerId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{groupedRows.map((group, index) => {
|
{providers.map((provider, index) => {
|
||||||
|
const hasNoModels = provider.rows.length === 0;
|
||||||
return (
|
return (
|
||||||
<View key={group.providerId}>
|
<View key={provider.id}>
|
||||||
{index > 0 ? <View style={styles.separator} /> : null}
|
{index > 0 ? <View style={styles.separator} /> : null}
|
||||||
<GroupProviderButton
|
<GroupProviderButton
|
||||||
providerId={group.providerId}
|
providerId={provider.id}
|
||||||
providerLabel={group.providerLabel}
|
providerLabel={provider.label}
|
||||||
rowCount={group.rows.length}
|
rowCount={provider.rows.length}
|
||||||
|
hasNoModels={hasNoModels}
|
||||||
onDrillDown={onDrillDown}
|
onDrillDown={onDrillDown}
|
||||||
|
onSelectDefault={onSelectDefault}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -371,13 +343,41 @@ function GroupedProviderRows({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DefaultProviderRow({
|
||||||
|
providerId,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
providerId: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: (provider: string, modelId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const ProviderIcon = getProviderIcon(providerId);
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onSelect(providerId, "");
|
||||||
|
}, [onSelect, providerId]);
|
||||||
|
const leadingSlot = useMemo(
|
||||||
|
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||||
|
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComboboxItem
|
||||||
|
label="Default"
|
||||||
|
selected={isSelected}
|
||||||
|
onPress={handlePress}
|
||||||
|
leadingSlot={leadingSlot}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ProviderModelRows({
|
function ProviderModelRows({
|
||||||
rows,
|
rows,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
favoriteKeys,
|
favoriteKeys,
|
||||||
onSelect,
|
onSelect,
|
||||||
canSelectProvider,
|
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
normalizedQuery,
|
normalizedQuery,
|
||||||
}: {
|
}: {
|
||||||
@@ -386,7 +386,6 @@ function ProviderModelRows({
|
|||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
favoriteKeys: Set<string>;
|
favoriteKeys: Set<string>;
|
||||||
onSelect: (provider: string, modelId: string) => void;
|
onSelect: (provider: string, modelId: string) => void;
|
||||||
canSelectProvider: (provider: string) => boolean;
|
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
normalizedQuery: string;
|
normalizedQuery: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -402,12 +401,11 @@ function ProviderModelRows({
|
|||||||
row={item}
|
row={item}
|
||||||
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
|
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
|
||||||
isFavorite={favoriteKeys.has(item.favoriteKey)}
|
isFavorite={favoriteKeys.has(item.favoriteKey)}
|
||||||
disabled={!canSelectProvider(item.provider)}
|
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[canSelectProvider, favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
|
[favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
|
||||||
);
|
);
|
||||||
const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []);
|
const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []);
|
||||||
|
|
||||||
@@ -436,44 +434,45 @@ function ProviderModelRows({
|
|||||||
|
|
||||||
function SelectorContent({
|
function SelectorContent({
|
||||||
view,
|
view,
|
||||||
providerDefinitions,
|
providers,
|
||||||
allProviderModels,
|
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
favoriteKeys,
|
favoriteKeys,
|
||||||
onSelect,
|
onSelect,
|
||||||
canSelectProvider,
|
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onDrillDown,
|
onDrillDown,
|
||||||
}: SelectorContentProps) {
|
}: SelectorContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
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 normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||||
|
const selectedViewProvider = useMemo(
|
||||||
|
() =>
|
||||||
|
view.kind === "provider"
|
||||||
|
? providers.find((provider) => provider.id === view.providerId)
|
||||||
|
: null,
|
||||||
|
[providers, view],
|
||||||
|
);
|
||||||
const visibleRows = useMemo(
|
const visibleRows = useMemo(
|
||||||
() => filterAndRankModelRows(scopedRows, normalizedQuery),
|
() =>
|
||||||
[normalizedQuery, scopedRows],
|
selectedViewProvider
|
||||||
|
? filterAndRankModelRows(selectedViewProvider.rows, normalizedQuery)
|
||||||
|
: [],
|
||||||
|
[normalizedQuery, selectedViewProvider],
|
||||||
);
|
);
|
||||||
|
|
||||||
const favoriteRows = useMemo(
|
const favoriteRows = useMemo(
|
||||||
() => visibleRows.filter((row) => favoriteKeys.has(row.favoriteKey)),
|
() =>
|
||||||
[favoriteKeys, visibleRows],
|
providers.flatMap((provider) =>
|
||||||
|
provider.rows.filter((row) => favoriteKeys.has(row.favoriteKey)),
|
||||||
|
),
|
||||||
|
[favoriteKeys, providers],
|
||||||
);
|
);
|
||||||
|
const handleSelectDefaultProvider = useCallback(
|
||||||
const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]);
|
(providerId: string) => {
|
||||||
const hasResults = favoriteRows.length > 0 || allGroupedRows.length > 0;
|
onSelect(providerId, "");
|
||||||
|
},
|
||||||
|
[onSelect],
|
||||||
|
);
|
||||||
|
const hasResults = favoriteRows.length > 0 || providers.length > 0;
|
||||||
const emptyState = (
|
const emptyState = (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
@@ -482,6 +481,20 @@ function SelectorContent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (view.kind === "provider") {
|
if (view.kind === "provider") {
|
||||||
|
if (!selectedViewProvider) {
|
||||||
|
return emptyState;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedViewProvider.rows.length === 0 && !normalizedQuery) {
|
||||||
|
return (
|
||||||
|
<DefaultProviderRow
|
||||||
|
providerId={view.providerId}
|
||||||
|
isSelected={view.providerId === selectedProvider && !selectedModel}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (visibleRows.length === 0) {
|
if (visibleRows.length === 0) {
|
||||||
return emptyState;
|
return emptyState;
|
||||||
}
|
}
|
||||||
@@ -493,7 +506,6 @@ function SelectorContent({
|
|||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
canSelectProvider={canSelectProvider}
|
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
normalizedQuery={normalizedQuery}
|
normalizedQuery={normalizedQuery}
|
||||||
/>
|
/>
|
||||||
@@ -508,12 +520,15 @@ function SelectorContent({
|
|||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
canSelectProvider={canSelectProvider}
|
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{allGroupedRows.length > 0 ? (
|
{providers.length > 0 ? (
|
||||||
<GroupedProviderRows groupedRows={allGroupedRows} onDrillDown={onDrillDown} />
|
<GroupedProviderRows
|
||||||
|
providers={providers}
|
||||||
|
onDrillDown={onDrillDown}
|
||||||
|
onSelectDefault={handleSelectDefaultProvider}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!hasResults ? emptyState : null}
|
{!hasResults ? emptyState : null}
|
||||||
@@ -522,13 +537,11 @@ function SelectorContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CombinedModelSelector({
|
export function CombinedModelSelector({
|
||||||
providerDefinitions,
|
providers,
|
||||||
allProviderModels,
|
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
onSelect,
|
onSelect,
|
||||||
isLoading,
|
isLoading,
|
||||||
canSelectProvider = () => true,
|
|
||||||
favoriteKeys = new Set<string>(),
|
favoriteKeys = new Set<string>(),
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
renderTrigger,
|
renderTrigger,
|
||||||
@@ -544,26 +557,26 @@ export function CombinedModelSelector({
|
|||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
|
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
|
||||||
|
|
||||||
// Single-provider mode: only one provider with models → skip Level 1 entirely
|
// Single-provider mode: only one provider → skip Level 1 entirely
|
||||||
const singleProviderView = useMemo<SelectorView | null>(() => {
|
const singleProviderView = useMemo<SelectorView | null>(() => {
|
||||||
const providers = Array.from(allProviderModels.keys());
|
|
||||||
if (providers.length !== 1) return null;
|
if (providers.length !== 1) return null;
|
||||||
const providerId = providers[0];
|
const provider = providers[0];
|
||||||
const label = resolveProviderLabel(providerDefinitions, providerId);
|
if (!provider) return null;
|
||||||
return { kind: "provider", providerId, providerLabel: label };
|
return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
|
||||||
}, [allProviderModels, providerDefinitions]);
|
}, [providers]);
|
||||||
|
|
||||||
const computeInitialView = useCallback((): SelectorView => {
|
const computeInitialView = useCallback((): SelectorView => {
|
||||||
if (singleProviderView) return singleProviderView;
|
if (singleProviderView) return singleProviderView;
|
||||||
|
|
||||||
const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`;
|
const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`;
|
||||||
if (selectedProvider && selectedModel && !favoriteKeys.has(selectedFavoriteKey)) {
|
if (selectedProvider && selectedModel && !favoriteKeys.has(selectedFavoriteKey)) {
|
||||||
const label = resolveProviderLabel(providerDefinitions, selectedProvider);
|
const provider = providers.find((entry) => entry.id === selectedProvider);
|
||||||
return { kind: "provider", providerId: selectedProvider, providerLabel: label };
|
if (provider)
|
||||||
|
return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { kind: "all" };
|
return { kind: "all" };
|
||||||
}, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providerDefinitions]);
|
}, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providers]);
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
@@ -598,24 +611,28 @@ export function CombinedModelSelector({
|
|||||||
if (!hasSelectedProvider) {
|
if (!hasSelectedProvider) {
|
||||||
return "Select model";
|
return "Select model";
|
||||||
}
|
}
|
||||||
|
const provider = providers.find((entry) => entry.id === selectedProvider);
|
||||||
|
if (provider?.rows.length === 0) {
|
||||||
|
return "Default";
|
||||||
|
}
|
||||||
return isLoading ? "Loading..." : "Select model";
|
return isLoading ? "Loading..." : "Select model";
|
||||||
}
|
}
|
||||||
const models = allProviderModels.get(selectedProvider);
|
const provider = providers.find((entry) => entry.id === selectedProvider);
|
||||||
if (!models) {
|
if (!provider) {
|
||||||
return isLoading ? "Loading..." : "Select model";
|
return isLoading ? "Loading..." : "Select model";
|
||||||
}
|
}
|
||||||
const model = models.find((entry) => entry.id === selectedModel);
|
const model = provider.rows.find((entry) => entry.modelId === selectedModel);
|
||||||
return model?.label ?? resolveDefaultModelLabel(models);
|
return model?.modelLabel ?? resolveDefaultModelLabel(provider.rows);
|
||||||
}, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
|
}, [hasSelectedProvider, isLoading, providers, selectedModel, selectedProvider]);
|
||||||
|
|
||||||
const desktopFixedHeight = useMemo(() => {
|
const desktopFixedHeight = useMemo(() => {
|
||||||
if (view.kind !== "provider") {
|
if (view.kind !== "provider") {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const models = allProviderModels.get(view.providerId);
|
const modelCount =
|
||||||
const modelCount = models?.length ?? 0;
|
providers.find((provider) => provider.id === view.providerId)?.rows.length ?? 0;
|
||||||
return Math.min(80 + modelCount * 40, 400);
|
return Math.min(80 + modelCount * 40, 400);
|
||||||
}, [allProviderModels, view]);
|
}, [providers, view]);
|
||||||
|
|
||||||
const triggerLabel = useMemo(() => {
|
const triggerLabel = useMemo(() => {
|
||||||
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
|
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
|
||||||
@@ -750,14 +767,12 @@ export function CombinedModelSelector({
|
|||||||
{isContentReady ? (
|
{isContentReady ? (
|
||||||
<SelectorContent
|
<SelectorContent
|
||||||
view={view}
|
view={view}
|
||||||
providerDefinitions={providerDefinitions}
|
providers={providers}
|
||||||
allProviderModels={allProviderModels}
|
|
||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
canSelectProvider={canSelectProvider}
|
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onDrillDown={handleDrillDown}
|
onDrillDown={handleDrillDown}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,47 +1,60 @@
|
|||||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
import type {
|
||||||
|
AgentModelDefinition,
|
||||||
|
ProviderSnapshotEntry,
|
||||||
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||||
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
||||||
|
|
||||||
export type SelectorModelRow = FavoriteModelRow;
|
export type SelectorModelRow = FavoriteModelRow & { isDefault?: boolean };
|
||||||
|
|
||||||
export function resolveProviderLabel(
|
export interface ModelSelectorProvider {
|
||||||
providerDefinitions: AgentProviderDefinition[],
|
id: string;
|
||||||
providerId: string,
|
label: string;
|
||||||
): string {
|
rows: SelectorModelRow[];
|
||||||
return (
|
|
||||||
providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSelectedTriggerLabel(modelLabel: string): string {
|
export function buildSelectedTriggerLabel(modelLabel: string): string {
|
||||||
return modelLabel;
|
return modelLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildModelRows(
|
export function buildModelSelectorProviders(
|
||||||
providerDefinitions: AgentProviderDefinition[],
|
providerDefinitions: AgentProviderDefinition[],
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>,
|
allProviderModels: Map<string, AgentModelDefinition[]>,
|
||||||
): SelectorModelRow[] {
|
): ModelSelectorProvider[] {
|
||||||
const providerLabelMap = new Map(
|
return providerDefinitions.map((definition) => ({
|
||||||
providerDefinitions.map((definition) => [definition.id, definition.label]),
|
id: definition.id,
|
||||||
);
|
label: definition.label,
|
||||||
const rows: SelectorModelRow[] = [];
|
rows: (allProviderModels.get(definition.id) ?? []).map((model) => ({
|
||||||
|
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
|
||||||
|
provider: definition.id,
|
||||||
|
providerLabel: definition.label,
|
||||||
|
modelId: model.id,
|
||||||
|
modelLabel: model.label,
|
||||||
|
description: model.description,
|
||||||
|
isDefault: model.isDefault,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
for (const definition of providerDefinitions) {
|
export function buildSelectableModelSelectorProviders(
|
||||||
const providerLabel = providerLabelMap.get(definition.id) ?? definition.label;
|
entries: ProviderSnapshotEntry[] | undefined,
|
||||||
for (const model of allProviderModels.get(definition.id) ?? []) {
|
): ModelSelectorProvider[] {
|
||||||
rows.push({
|
return (entries ?? [])
|
||||||
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
|
.filter((entry) => entry.enabled && entry.status === "ready")
|
||||||
provider: definition.id,
|
.map((entry) => ({
|
||||||
providerLabel,
|
id: entry.provider,
|
||||||
|
label: entry.label ?? entry.provider,
|
||||||
|
rows: (entry.models ?? []).map((model) => ({
|
||||||
|
favoriteKey: buildFavoriteModelKey({ provider: entry.provider, modelId: model.id }),
|
||||||
|
provider: entry.provider,
|
||||||
|
providerLabel: entry.label ?? entry.provider,
|
||||||
modelId: model.id,
|
modelId: model.id,
|
||||||
modelLabel: model.label,
|
modelLabel: model.label,
|
||||||
description: model.description,
|
description: model.description,
|
||||||
});
|
isDefault: model.isDefault,
|
||||||
}
|
})),
|
||||||
}
|
}));
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
|
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe("resolveStatusControlMode", () => {
|
|||||||
selectedModel: "",
|
selectedModel: "",
|
||||||
onSelectModel: () => undefined,
|
onSelectModel: () => undefined,
|
||||||
isModelLoading: false,
|
isModelLoading: false,
|
||||||
allProviderModels: new Map(),
|
modelSelectorProviders: [],
|
||||||
isAllModelsLoading: false,
|
isAllModelsLoading: false,
|
||||||
onSelectProviderAndModel: () => undefined,
|
onSelectProviderAndModel: () => undefined,
|
||||||
thinkingOptions: [],
|
thinkingOptions: [],
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => ({
|
||||||
|
Bot: function Bot() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
PackagePlus: function PackagePlus() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Bot, PackagePlus } from "lucide-react-native";
|
||||||
|
import { getProviderIcon } from "./provider-icons";
|
||||||
|
|
||||||
|
describe("getProviderIcon", () => {
|
||||||
|
it("keeps built-in provider icons", () => {
|
||||||
|
expect(getProviderIcon("kiro")).toBe(PackagePlus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses vendored ACP catalog icons for catalog provider ids", () => {
|
||||||
|
const icon = getProviderIcon("amp-acp");
|
||||||
|
|
||||||
|
expect(icon).not.toBe(Bot);
|
||||||
|
expect(getProviderIcon("amp-acp")).toBe(icon);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the robot icon for unknown custom providers", () => {
|
||||||
|
expect(getProviderIcon("custom-claude-profile")).toBe(Bot);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,63 @@
|
|||||||
import { Bot, PackagePlus } from "lucide-react-native";
|
import { Bot, PackagePlus } from "lucide-react-native";
|
||||||
|
import { createElement, type ComponentType } from "react";
|
||||||
|
import { SvgXml } from "react-native-svg";
|
||||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||||
import { PiIcon } from "@/components/icons/pi-icon";
|
import { PiIcon } from "@/components/icons/pi-icon";
|
||||||
|
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||||
|
|
||||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
export interface ProviderIconProps {
|
||||||
claude: ClaudeIcon as unknown as typeof Bot,
|
size: number;
|
||||||
codex: CodexIcon as unknown as typeof Bot,
|
color: string;
|
||||||
copilot: CopilotIcon as unknown as typeof Bot,
|
}
|
||||||
|
|
||||||
|
export type ProviderIconComponent = ComponentType<ProviderIconProps>;
|
||||||
|
|
||||||
|
const PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
|
||||||
|
claude: ClaudeIcon as unknown as ProviderIconComponent,
|
||||||
|
codex: CodexIcon as unknown as ProviderIconComponent,
|
||||||
|
copilot: CopilotIcon as unknown as ProviderIconComponent,
|
||||||
kiro: PackagePlus,
|
kiro: PackagePlus,
|
||||||
opencode: OpenCodeIcon as unknown as typeof Bot,
|
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
||||||
pi: PiIcon as unknown as typeof Bot,
|
pi: PiIcon as unknown as ProviderIconComponent,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getProviderIcon(provider: string): typeof Bot {
|
const CATALOG_ICON_SVGS = new Map(
|
||||||
return PROVIDER_ICONS[provider] ?? Bot;
|
ACP_PROVIDER_CATALOG.flatMap((entry) => (entry.iconSvg ? [[entry.id, entry.iconSvg]] : [])),
|
||||||
|
);
|
||||||
|
|
||||||
|
const catalogIconComponents = new Map<string, ProviderIconComponent>();
|
||||||
|
|
||||||
|
function createCatalogIcon(provider: string, iconSvg: string): ProviderIconComponent {
|
||||||
|
const CatalogProviderIcon: ProviderIconComponent = ({ size, color }) =>
|
||||||
|
createElement(SvgXml, {
|
||||||
|
xml: iconSvg,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
color,
|
||||||
|
});
|
||||||
|
CatalogProviderIcon.displayName = `CatalogProviderIcon(${provider})`;
|
||||||
|
return CatalogProviderIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCatalogProviderIcon(provider: string): ProviderIconComponent | undefined {
|
||||||
|
const cached = catalogIconComponents.get(provider);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconSvg = CATALOG_ICON_SVGS.get(provider);
|
||||||
|
if (!iconSvg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = createCatalogIcon(provider, iconSvg);
|
||||||
|
catalogIconComponents.set(provider, icon);
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderIcon(provider: string): ProviderIconComponent {
|
||||||
|
return PROVIDER_ICONS[provider] ?? getCatalogProviderIcon(provider) ?? Bot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,15 @@ const CATALOG_DATA = [
|
|||||||
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
||||||
command: ["npx", "-y", "deepagents-acp@0.1.7"],
|
command: ["npx", "-y", "deepagents-acp@0.1.7"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "deepseek-tui",
|
||||||
|
title: "DeepSeek TUI",
|
||||||
|
description: "Terminal coding agent for DeepSeek V4",
|
||||||
|
version: "0.8.39",
|
||||||
|
iconId: "deepseek-tui",
|
||||||
|
installLink: "https://github.com/Hmbown/DeepSeek-TUI",
|
||||||
|
command: ["deepseek", "serve", "--acp"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "dimcode",
|
id: "dimcode",
|
||||||
title: "DimCode",
|
title: "DimCode",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe("ACP provider catalog", () => {
|
|||||||
it("uses PATH commands for entries that were binary distributions upstream", () => {
|
it("uses PATH commands for entries that were binary distributions upstream", () => {
|
||||||
expect(findProvider("amp-acp").command).toEqual(["amp-acp"]);
|
expect(findProvider("amp-acp").command).toEqual(["amp-acp"]);
|
||||||
expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]);
|
expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]);
|
||||||
|
expect(findProvider("deepseek-tui").command).toEqual(["deepseek", "serve", "--acp"]);
|
||||||
expect(findProvider("goose").command).toEqual(["goose", "acp"]);
|
expect(findProvider("goose").command).toEqual(["goose", "acp"]);
|
||||||
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
|
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
|
||||||
expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]);
|
expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]);
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import type {
|
|||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||||
|
import {
|
||||||
|
buildSelectableModelSelectorProviders,
|
||||||
|
type ModelSelectorProvider,
|
||||||
|
} from "@/components/combined-model-selector.utils";
|
||||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||||
import {
|
import {
|
||||||
useFormPreferences,
|
useFormPreferences,
|
||||||
@@ -63,6 +67,7 @@ export interface UseAgentFormStateResult {
|
|||||||
modeOptions: AgentMode[];
|
modeOptions: AgentMode[];
|
||||||
availableModels: AgentModelDefinition[];
|
availableModels: AgentModelDefinition[];
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||||
|
modelSelectorProviders: ModelSelectorProvider[];
|
||||||
isAllModelsLoading: boolean;
|
isAllModelsLoading: boolean;
|
||||||
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||||
isModelLoading: boolean;
|
isModelLoading: boolean;
|
||||||
@@ -237,6 +242,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
() => buildAllProviderModels(snapshotEntries),
|
() => buildAllProviderModels(snapshotEntries),
|
||||||
[snapshotEntries],
|
[snapshotEntries],
|
||||||
);
|
);
|
||||||
|
const snapshotModelSelectorProviders = useMemo(
|
||||||
|
() => buildSelectableModelSelectorProviders(snapshotEntries),
|
||||||
|
[snapshotEntries],
|
||||||
|
);
|
||||||
const snapshotSelectedEntry = useMemo(
|
const snapshotSelectedEntry = useMemo(
|
||||||
() =>
|
() =>
|
||||||
formState.provider
|
formState.provider
|
||||||
@@ -255,6 +264,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
||||||
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
|
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
|
||||||
const allProviderModels = snapshotAllProviderModels;
|
const allProviderModels = snapshotAllProviderModels;
|
||||||
|
const modelSelectorProviders = snapshotModelSelectorProviders;
|
||||||
const availableModels = snapshotSelectedProviderModels;
|
const availableModels = snapshotSelectedProviderModels;
|
||||||
const modeOptions = snapshotSelectedProviderModes;
|
const modeOptions = snapshotSelectedProviderModes;
|
||||||
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
|
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
|
||||||
@@ -516,6 +526,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
modeOptions,
|
modeOptions,
|
||||||
availableModels: availableModels ?? [],
|
availableModels: availableModels ?? [],
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
|
modelSelectorProviders,
|
||||||
isAllModelsLoading,
|
isAllModelsLoading,
|
||||||
availableThinkingOptions,
|
availableThinkingOptions,
|
||||||
isModelLoading,
|
isModelLoading,
|
||||||
@@ -548,6 +559,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
modeOptions,
|
modeOptions,
|
||||||
availableModels,
|
availableModels,
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
|
modelSelectorProviders,
|
||||||
isAllModelsLoading,
|
isAllModelsLoading,
|
||||||
availableThinkingOptions,
|
availableThinkingOptions,
|
||||||
isModelLoading,
|
isModelLoading,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function buildDraftStatusControls(input: {
|
|||||||
selectedModel: formState.selectedModel,
|
selectedModel: formState.selectedModel,
|
||||||
onSelectModel: formState.setModelFromUser,
|
onSelectModel: formState.setModelFromUser,
|
||||||
isModelLoading: formState.isModelLoading,
|
isModelLoading: formState.isModelLoading,
|
||||||
allProviderModels: formState.allProviderModels,
|
modelSelectorProviders: formState.modelSelectorProviders,
|
||||||
isAllModelsLoading: formState.isAllModelsLoading,
|
isAllModelsLoading: formState.isAllModelsLoading,
|
||||||
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
|
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
|
||||||
thinkingOptions: formState.availableThinkingOptions,
|
thinkingOptions: formState.availableThinkingOptions,
|
||||||
|
|||||||
@@ -81,6 +81,22 @@ vi.mock("./use-agent-form-state", () => ({
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
|
modelSelectorProviders: [
|
||||||
|
{
|
||||||
|
id: "codex",
|
||||||
|
label: "Codex",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
favoriteKey: "codex:gpt-5.4",
|
||||||
|
provider: "codex",
|
||||||
|
providerLabel: "Codex",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
modelLabel: "gpt-5.4",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
isAllModelsLoading: false,
|
isAllModelsLoading: false,
|
||||||
availableThinkingOptions: [
|
availableThinkingOptions: [
|
||||||
{ id: "medium", label: "Medium" },
|
{ id: "medium", label: "Medium" },
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export interface WorkspaceDraftAutoSubmitConfig {
|
||||||
|
provider: string;
|
||||||
|
model: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDraftSubmission(input: {
|
||||||
|
text: string;
|
||||||
|
allowsEmptyAutoSubmit: boolean;
|
||||||
|
composerState: {
|
||||||
|
providerDefinitions: unknown[];
|
||||||
|
selectedProvider: string | null;
|
||||||
|
isModelLoading: boolean;
|
||||||
|
effectiveModelId: string | null;
|
||||||
|
availableModels: unknown[];
|
||||||
|
};
|
||||||
|
autoSubmitConfig: WorkspaceDraftAutoSubmitConfig | null;
|
||||||
|
workspaceDirectory: string | null;
|
||||||
|
hasClient: boolean;
|
||||||
|
}): string | null {
|
||||||
|
const {
|
||||||
|
text,
|
||||||
|
allowsEmptyAutoSubmit,
|
||||||
|
composerState,
|
||||||
|
autoSubmitConfig,
|
||||||
|
workspaceDirectory,
|
||||||
|
hasClient,
|
||||||
|
} = input;
|
||||||
|
if (!allowsEmptyAutoSubmit && !text.trim()) {
|
||||||
|
return "Initial prompt is required";
|
||||||
|
}
|
||||||
|
if (composerState.providerDefinitions.length === 0) {
|
||||||
|
return "No available providers on the selected host";
|
||||||
|
}
|
||||||
|
if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
|
||||||
|
return "Select a model";
|
||||||
|
}
|
||||||
|
if (composerState.isModelLoading) {
|
||||||
|
return "Model defaults are still loading";
|
||||||
|
}
|
||||||
|
const hasSelectedModel = Boolean(autoSubmitConfig?.model ?? composerState.effectiveModelId);
|
||||||
|
if (!hasSelectedModel && composerState.availableModels.length > 0) {
|
||||||
|
return "No model is available for the selected provider";
|
||||||
|
}
|
||||||
|
if (!workspaceDirectory) {
|
||||||
|
return "Workspace directory not found";
|
||||||
|
}
|
||||||
|
if (!hasClient) {
|
||||||
|
return "Host is not connected";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { validateDraftSubmission } from "./workspace-draft-agent-tab-core";
|
||||||
|
|
||||||
|
const baseComposerState = {
|
||||||
|
providerDefinitions: [{ id: "deepseek-tui" }],
|
||||||
|
selectedProvider: "deepseek-tui",
|
||||||
|
isModelLoading: false,
|
||||||
|
effectiveModelId: "",
|
||||||
|
availableModels: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function validate(overrides = {}) {
|
||||||
|
return validateDraftSubmission({
|
||||||
|
text: "hello",
|
||||||
|
allowsEmptyAutoSubmit: false,
|
||||||
|
composerState: baseComposerState,
|
||||||
|
autoSubmitConfig: null,
|
||||||
|
workspaceDirectory: "/tmp/project",
|
||||||
|
hasClient: true,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("workspace draft agent model validation", () => {
|
||||||
|
test("allows a ready provider with no models to submit without a selected model", () => {
|
||||||
|
expect(validate({})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps waiting while model defaults are loading", () => {
|
||||||
|
expect(
|
||||||
|
validate({
|
||||||
|
composerState: {
|
||||||
|
...baseComposerState,
|
||||||
|
isModelLoading: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe("Model defaults are still loading");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still requires a selected model when the provider exposes models", () => {
|
||||||
|
expect(
|
||||||
|
validate({
|
||||||
|
composerState: {
|
||||||
|
...baseComposerState,
|
||||||
|
availableModels: [{ id: "deepseek/deepseek-v4-pro" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe("No model is available for the selected provider");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submi
|
|||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||||
|
import { validateDraftSubmission } from "@/screens/workspace/workspace-draft-agent-tab-core";
|
||||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
@@ -73,51 +74,6 @@ function resolveAutoSubmitConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateDraftSubmission(input: {
|
|
||||||
text: string;
|
|
||||||
allowsEmptyAutoSubmit: boolean;
|
|
||||||
composerState: {
|
|
||||||
providerDefinitions: unknown[];
|
|
||||||
selectedProvider: string | null;
|
|
||||||
isModelLoading: boolean;
|
|
||||||
effectiveModelId: string | null;
|
|
||||||
};
|
|
||||||
autoSubmitConfig: AutoSubmitConfig | null;
|
|
||||||
workspaceDirectory: string | null;
|
|
||||||
hasClient: boolean;
|
|
||||||
}): string | null {
|
|
||||||
const {
|
|
||||||
text,
|
|
||||||
allowsEmptyAutoSubmit,
|
|
||||||
composerState,
|
|
||||||
autoSubmitConfig,
|
|
||||||
workspaceDirectory,
|
|
||||||
hasClient,
|
|
||||||
} = input;
|
|
||||||
if (!allowsEmptyAutoSubmit && !text.trim()) {
|
|
||||||
return "Initial prompt is required";
|
|
||||||
}
|
|
||||||
if (composerState.providerDefinitions.length === 0) {
|
|
||||||
return "No available providers on the selected host";
|
|
||||||
}
|
|
||||||
if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
|
|
||||||
return "Select a model";
|
|
||||||
}
|
|
||||||
if (composerState.isModelLoading) {
|
|
||||||
return "Model defaults are still loading";
|
|
||||||
}
|
|
||||||
if (!(autoSubmitConfig?.model ?? composerState.effectiveModelId)) {
|
|
||||||
return "No model is available for the selected provider";
|
|
||||||
}
|
|
||||||
if (!workspaceDirectory) {
|
|
||||||
return "Workspace directory not found";
|
|
||||||
}
|
|
||||||
if (!hasClient) {
|
|
||||||
return "Host is not connected";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDraftModeIdOverride(input: {
|
function resolveDraftModeIdOverride(input: {
|
||||||
autoSubmitConfig: AutoSubmitConfig | null;
|
autoSubmitConfig: AutoSubmitConfig | null;
|
||||||
modeOptionsCount: number;
|
modeOptionsCount: number;
|
||||||
|
|||||||
@@ -362,6 +362,33 @@ describe("createLoggedNdJsonStream", () => {
|
|||||||
consoleError.mockRestore();
|
consoleError.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("normalizes stringified numeric ACP response ids", async () => {
|
||||||
|
const input = new TransformStream<Uint8Array, Uint8Array>();
|
||||||
|
const output = new TransformStream<Uint8Array, Uint8Array>();
|
||||||
|
const logger = {
|
||||||
|
warn: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = createLoggedNdJsonStream(output.writable, input.readable, {
|
||||||
|
logger: asInternals<ReturnType<typeof createTestLogger>>(logger),
|
||||||
|
provider: "deepseek-tui",
|
||||||
|
});
|
||||||
|
const reader = stream.readable.getReader();
|
||||||
|
const writer = input.writable.getWriter();
|
||||||
|
|
||||||
|
await writer.write(
|
||||||
|
new TextEncoder().encode('{"jsonrpc":"2.0","id":"0","result":{"ok":true}}\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = await reader.read();
|
||||||
|
|
||||||
|
expect(parsed.value).toEqual({ jsonrpc: "2.0", id: 0, result: { ok: true } });
|
||||||
|
expect(logger.warn).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await writer.close();
|
||||||
|
reader.releaseLock();
|
||||||
|
});
|
||||||
|
|
||||||
test("does not log terminal control sequences from malformed ACP stdout", async () => {
|
test("does not log terminal control sequences from malformed ACP stdout", async () => {
|
||||||
const input = new TransformStream<Uint8Array, Uint8Array>();
|
const input = new TransformStream<Uint8Array, Uint8Array>();
|
||||||
const output = new TransformStream<Uint8Array, Uint8Array>();
|
const output = new TransformStream<Uint8Array, Uint8Array>();
|
||||||
@@ -704,6 +731,31 @@ describe("ACPAgentSession Zed parity", () => {
|
|||||||
expect(setSessionConfigOption).not.toHaveBeenCalled();
|
expect(setSessionConfigOption).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("does not fail session start when configured model cannot be applied by ACP", async () => {
|
||||||
|
const logger = createTestLogger();
|
||||||
|
const childLogger = { trace: vi.fn(), warn: vi.fn() };
|
||||||
|
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
|
||||||
|
const session = createSessionWithConfig(
|
||||||
|
{ provider: "deepseek-tui", model: "deepseek/v4" },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
const { internals, setSessionConfigOption, unstableSetSessionModel } =
|
||||||
|
prepareConfiguredOverrideSession(session, {
|
||||||
|
currentModel: null,
|
||||||
|
availableModels: null,
|
||||||
|
configOptions: [],
|
||||||
|
connection: { unstable_setSessionModel: undefined },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(internals.applyConfiguredOverrides()).resolves.toBeUndefined();
|
||||||
|
expect(unstableSetSessionModel).not.toHaveBeenCalled();
|
||||||
|
expect(setSessionConfigOption).not.toHaveBeenCalled();
|
||||||
|
expect(childLogger.warn).toHaveBeenCalledWith(
|
||||||
|
{ value: "deepseek/v4" },
|
||||||
|
"deepseek-tui does not expose ACP model selection; using provider default model",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("routes config_option_update and refreshes derived mode, model, and thinking state", async () => {
|
test("routes config_option_update and refreshes derived mode, model, and thinking state", async () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const internals = asInternals<ACPSessionInternals>(session);
|
const internals = asInternals<ACPSessionInternals>(session);
|
||||||
|
|||||||
@@ -180,6 +180,26 @@ function summarizeMalformedACPStdoutError(error: unknown): { type: string; messa
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeACPIncomingMessage(message: AnyMessage): AnyMessage {
|
||||||
|
if (
|
||||||
|
"id" in message &&
|
||||||
|
!("method" in message) &&
|
||||||
|
typeof message.id === "string" &&
|
||||||
|
/^\d+$/.test(message.id)
|
||||||
|
) {
|
||||||
|
const numericId = Number(message.id);
|
||||||
|
if (Number.isSafeInteger(numericId)) {
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
// COMPAT(deepseek-tui-acp-id): added v0.1.78, remove after 2026-11-19
|
||||||
|
// once the ACP SDK accepts stringified numeric response IDs.
|
||||||
|
id: numericId,
|
||||||
|
} as AnyMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
export function createLoggedNdJsonStream(
|
export function createLoggedNdJsonStream(
|
||||||
output: NodeWritableStream,
|
output: NodeWritableStream,
|
||||||
input: NodeReadableStream,
|
input: NodeReadableStream,
|
||||||
@@ -214,7 +234,7 @@ export function createLoggedNdJsonStream(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const message: AnyMessage = JSON.parse(trimmedLine);
|
const message: AnyMessage = JSON.parse(trimmedLine);
|
||||||
controller.enqueue(message);
|
controller.enqueue(normalizeACPIncomingMessage(message));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.logger.warn(
|
options.logger.warn(
|
||||||
{
|
{
|
||||||
@@ -1356,7 +1376,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof this.connection.unstable_setSessionModel !== "function") {
|
if (typeof this.connection.unstable_setSessionModel !== "function") {
|
||||||
throw new Error(`${this.provider} does not expose ACP model selection`);
|
throw new Error(this.modelSelectionUnavailableMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1378,7 +1398,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
|
|
||||||
const modelOption = selection.configOption;
|
const modelOption = selection.configOption;
|
||||||
if (!modelOption) {
|
if (!modelOption) {
|
||||||
throw new Error(`${this.provider} does not expose ACP model selection`);
|
throw new Error(this.modelSelectionUnavailableMessage());
|
||||||
}
|
}
|
||||||
if (!selection.configChoice) {
|
if (!selection.configChoice) {
|
||||||
this.warnInvalidSelection(
|
this.warnInvalidSelection(
|
||||||
@@ -1884,7 +1904,17 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
availableModels: this.availableModels,
|
availableModels: this.availableModels,
|
||||||
configOptions: this.configOptions,
|
configOptions: this.configOptions,
|
||||||
});
|
});
|
||||||
await this.setModelWithSelection({ modelId: configuredModelId, selection });
|
try {
|
||||||
|
await this.setModelWithSelection({ modelId: configuredModelId, selection });
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.isModelSelectionUnavailableError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.logger.warn(
|
||||||
|
{ value: configuredModelId },
|
||||||
|
`${this.provider} does not expose ACP model selection; using provider default model`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
|
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
|
||||||
await this.setThinkingOption(this.config.thinkingOptionId);
|
await this.setThinkingOption(this.config.thinkingOptionId);
|
||||||
@@ -1895,6 +1925,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
this.logger.warn({ value }, message);
|
this.logger.warn({ value }, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private modelSelectionUnavailableMessage(): string {
|
||||||
|
return `${this.provider} does not expose ACP model selection`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isModelSelectionUnavailableError(error: unknown): boolean {
|
||||||
|
return error instanceof Error && error.message === this.modelSelectionUnavailableMessage();
|
||||||
|
}
|
||||||
|
|
||||||
private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] {
|
private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] {
|
||||||
switch (update.sessionUpdate) {
|
switch (update.sessionUpdate) {
|
||||||
case "user_message_chunk": {
|
case "user_message_chunk": {
|
||||||
|
|||||||
Reference in New Issue
Block a user