feat(opencode): add scoped model defaults UI
This commit is contained in:
parent
98405b9040
commit
384446e83c
10 changed files with 1297 additions and 217 deletions
|
|
@ -172,11 +172,23 @@ export interface RuntimeProviderManagementViewDto {
|
|||
runtime: RuntimeProviderManagementRuntimeDto;
|
||||
providers: readonly RuntimeProviderConnectionDto[];
|
||||
configuredModels?: readonly RuntimeProviderModelDto[];
|
||||
projectPath?: string | null;
|
||||
projectDefaultModel?: string | null;
|
||||
allProjectsDefaultModel?: string | null;
|
||||
defaultModelSource?: RuntimeProviderDefaultModelSourceDto | null;
|
||||
defaultModel: string | null;
|
||||
fallbackModel: string | null;
|
||||
diagnostics: readonly string[];
|
||||
}
|
||||
|
||||
export type RuntimeProviderDefaultModelSourceDto =
|
||||
| 'project'
|
||||
| 'all_projects'
|
||||
| 'opencode_config'
|
||||
| 'fallback';
|
||||
|
||||
export type RuntimeProviderDefaultScopeDto = 'project' | 'all_projects';
|
||||
|
||||
export type RuntimeProviderManagementErrorCodeDto =
|
||||
| 'unsupported-runtime'
|
||||
| 'unsupported-action'
|
||||
|
|
@ -361,5 +373,6 @@ export interface RuntimeProviderManagementSetDefaultModelInput {
|
|||
providerId: string;
|
||||
modelId: string;
|
||||
probe?: boolean;
|
||||
scope?: RuntimeProviderDefaultScopeDto;
|
||||
projectPath?: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -638,6 +638,8 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
|
|||
input.providerId,
|
||||
'--model',
|
||||
input.modelId,
|
||||
'--scope',
|
||||
input.scope === 'all_projects' ? 'all-projects' : 'project',
|
||||
'--probe',
|
||||
'--compact',
|
||||
'--json',
|
||||
|
|
|
|||
|
|
@ -1,28 +1,87 @@
|
|||
import { type JSX, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
loadProjectPathProjects,
|
||||
type ProjectPathProject,
|
||||
} from '@renderer/components/team/dialogs/projectPathProjects';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { useRuntimeProviderManagement } from './hooks/useRuntimeProviderManagement';
|
||||
import { RuntimeProviderManagementPanelView } from './ui/RuntimeProviderManagementPanelView';
|
||||
|
||||
import type { RuntimeProviderManagementRuntimeId } from '@features/runtime-provider-management/contracts';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
interface RuntimeProviderManagementPanelProps {
|
||||
readonly runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
readonly open: boolean;
|
||||
readonly projectPath?: string | null;
|
||||
readonly initialProviderId?: string | null;
|
||||
readonly initialProviderAction?: 'connect' | 'select' | null;
|
||||
readonly disabled?: boolean;
|
||||
readonly onProviderChanged?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function RuntimeProviderManagementPanel({
|
||||
export const RuntimeProviderManagementPanel = ({
|
||||
runtimeId,
|
||||
open,
|
||||
projectPath = null,
|
||||
initialProviderId = null,
|
||||
initialProviderAction = null,
|
||||
disabled = false,
|
||||
onProviderChanged,
|
||||
}: RuntimeProviderManagementPanelProps): JSX.Element {
|
||||
}: RuntimeProviderManagementPanelProps): JSX.Element => {
|
||||
const repositoryGroups = useStore(useShallow((state) => state.repositoryGroups));
|
||||
const initialProjectPath = useMemo(() => projectPath?.trim() || null, [projectPath]);
|
||||
const [activeProjectPath, setActiveProjectPath] = useState<string | null>(initialProjectPath);
|
||||
const [projectContextProjects, setProjectContextProjects] = useState<ProjectPathProject[]>([]);
|
||||
const [projectContextLoading, setProjectContextLoading] = useState(false);
|
||||
const [projectContextError, setProjectContextError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setActiveProjectPath(initialProjectPath);
|
||||
}, [initialProjectPath, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setProjectContextLoading(true);
|
||||
setProjectContextError(null);
|
||||
void loadProjectPathProjects({
|
||||
defaultProjectPath: activeProjectPath ?? initialProjectPath,
|
||||
repositoryGroups,
|
||||
})
|
||||
.then((projects) => {
|
||||
if (cancelled) return;
|
||||
setProjectContextProjects(projects);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
setProjectContextError(
|
||||
error instanceof Error ? error.message : 'Failed to load project contexts'
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setProjectContextLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeProjectPath, initialProjectPath, open, repositoryGroups]);
|
||||
|
||||
const [state, actions] = useRuntimeProviderManagement({
|
||||
runtimeId,
|
||||
enabled: open,
|
||||
projectPath,
|
||||
projectPath: activeProjectPath,
|
||||
initialProviderId,
|
||||
initialProviderAction,
|
||||
onProviderChanged,
|
||||
});
|
||||
|
||||
|
|
@ -31,7 +90,11 @@ export function RuntimeProviderManagementPanel({
|
|||
state={state}
|
||||
actions={actions}
|
||||
disabled={disabled}
|
||||
projectPath={projectPath}
|
||||
projectPath={activeProjectPath}
|
||||
projectContextProjects={projectContextProjects}
|
||||
projectContextLoading={projectContextLoading}
|
||||
projectContextError={projectContextError}
|
||||
onProjectContextChange={setActiveProjectPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
|
||||
import type {
|
||||
RuntimeProviderConnectionDto,
|
||||
RuntimeProviderDefaultScopeDto,
|
||||
RuntimeProviderDirectoryEntryDto,
|
||||
RuntimeProviderDirectoryFilterDto,
|
||||
RuntimeProviderManagementRuntimeId,
|
||||
|
|
@ -23,6 +24,8 @@ interface UseRuntimeProviderManagementOptions {
|
|||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
enabled: boolean;
|
||||
projectPath?: string | null;
|
||||
initialProviderId?: string | null;
|
||||
initialProviderAction?: 'connect' | 'select' | null;
|
||||
onProviderChanged?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +33,11 @@ export type RuntimeProviderModelPickerMode = 'use' | 'runtime-default';
|
|||
|
||||
const DEFAULT_DIRECTORY_FILTER: RuntimeProviderDirectoryFilterDto = 'all';
|
||||
|
||||
interface ProjectContextSnapshot {
|
||||
path: string | null;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementState {
|
||||
view: RuntimeProviderManagementViewDto | null;
|
||||
providers: readonly RuntimeProviderConnectionDto[];
|
||||
|
|
@ -87,7 +95,11 @@ export interface RuntimeProviderManagementActions {
|
|||
selectModel: (modelId: string) => void;
|
||||
useModelForNewTeams: (modelId: string) => void;
|
||||
testModel: (providerId: string, modelId: string) => Promise<void>;
|
||||
setDefaultModel: (providerId: string, modelId: string) => Promise<void>;
|
||||
setDefaultModel: (
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
scope?: RuntimeProviderDefaultScopeDto
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
function replaceProvider(
|
||||
|
|
@ -123,6 +135,10 @@ function withUiTimeout<T>(promise: Promise<T>, message: string, timeoutMs = 70_0
|
|||
});
|
||||
}
|
||||
|
||||
function normalizeProjectContextPath(projectPath: string | null | undefined): string | null {
|
||||
return projectPath?.trim() || null;
|
||||
}
|
||||
|
||||
function buildFailedModelTestResult(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
|
|
@ -239,11 +255,35 @@ export function useRuntimeProviderManagement(
|
|||
const [savingProviderId, setSavingProviderId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const viewLoadRequestSeq = useRef(0);
|
||||
const directoryRequestSeq = useRef(0);
|
||||
const setupFormRequestSeq = useRef(0);
|
||||
const modelLoadRequestSeq = useRef(0);
|
||||
const modelProbeGenerationRef = useRef(0);
|
||||
const activeModelPickerProviderRef = useRef<string | null>(null);
|
||||
const appliedInitialProviderRef = useRef<string | null>(null);
|
||||
const currentProjectPath = normalizeProjectContextPath(options.projectPath);
|
||||
const projectContextRef = useRef<ProjectContextSnapshot>({
|
||||
path: currentProjectPath,
|
||||
generation: 0,
|
||||
});
|
||||
if (projectContextRef.current.path !== currentProjectPath) {
|
||||
projectContextRef.current = {
|
||||
path: currentProjectPath,
|
||||
generation: projectContextRef.current.generation + 1,
|
||||
};
|
||||
}
|
||||
|
||||
const getProjectContextSnapshot = useCallback(
|
||||
(): ProjectContextSnapshot => projectContextRef.current,
|
||||
[]
|
||||
);
|
||||
const isProjectContextCurrent = useCallback(
|
||||
(snapshot: ProjectContextSnapshot): boolean =>
|
||||
projectContextRef.current.path === snapshot.path &&
|
||||
projectContextRef.current.generation === snapshot.generation,
|
||||
[]
|
||||
);
|
||||
|
||||
const openModelPickerState = useCallback(
|
||||
(providerId: string, mode: RuntimeProviderModelPickerMode): void => {
|
||||
|
|
@ -278,11 +318,44 @@ export function useRuntimeProviderManagement(
|
|||
setTestingModelIds([]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
directoryRequestSeq.current += 1;
|
||||
setupFormRequestSeq.current += 1;
|
||||
modelLoadRequestSeq.current += 1;
|
||||
modelProbeGenerationRef.current += 1;
|
||||
setDirectoryEntries([]);
|
||||
setDirectoryTotalCount(null);
|
||||
setDirectoryNextCursor(null);
|
||||
setDirectoryError(null);
|
||||
setDirectorySelectedProviderId(null);
|
||||
setDirectoryLoaded(false);
|
||||
setSetupForm(null);
|
||||
setSetupFormLoading(false);
|
||||
setSetupFormError(null);
|
||||
setSetupSubmitError(null);
|
||||
setActiveFormProviderId(null);
|
||||
setApiKeyValue('');
|
||||
setSetupMetadata({});
|
||||
setModels([]);
|
||||
setModelsLoading(false);
|
||||
setModelsError(null);
|
||||
setSelectedModelId(null);
|
||||
setTestingModelIds([]);
|
||||
setSavingDefaultModelId(null);
|
||||
setModelResults({});
|
||||
setSuccessMessage(null);
|
||||
}, [currentProjectPath]);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (input: { silent?: boolean } = {}): Promise<void> => {
|
||||
if (!options.enabled) {
|
||||
return;
|
||||
}
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
const requestSeq = viewLoadRequestSeq.current + 1;
|
||||
viewLoadRequestSeq.current = requestSeq;
|
||||
const requestIsCurrent = (): boolean =>
|
||||
viewLoadRequestSeq.current === requestSeq && isProjectContextCurrent(projectContext);
|
||||
const silent = input.silent === true;
|
||||
if (!silent) {
|
||||
setLoading(true);
|
||||
|
|
@ -291,8 +364,11 @@ export function useRuntimeProviderManagement(
|
|||
try {
|
||||
const response = await api.runtimeProviderManagement.loadView({
|
||||
runtimeId: options.runtimeId,
|
||||
projectPath: options.projectPath ?? null,
|
||||
projectPath: projectContext.path,
|
||||
});
|
||||
if (!requestIsCurrent()) {
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
if (!silent) {
|
||||
setView(null);
|
||||
|
|
@ -309,17 +385,20 @@ export function useRuntimeProviderManagement(
|
|||
return selectInitialProviderId(nextView);
|
||||
});
|
||||
} catch (loadError) {
|
||||
if (!requestIsCurrent()) {
|
||||
return;
|
||||
}
|
||||
if (!silent) {
|
||||
setView(null);
|
||||
}
|
||||
setError(loadError instanceof Error ? loadError.message : 'Failed to load providers');
|
||||
} finally {
|
||||
if (!silent) {
|
||||
if (!silent && requestIsCurrent()) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[options.enabled, options.projectPath, options.runtimeId]
|
||||
[getProjectContextSnapshot, isProjectContextCurrent, options.enabled, options.runtimeId]
|
||||
);
|
||||
|
||||
const loadDirectoryPage = useCallback(
|
||||
|
|
@ -341,8 +420,11 @@ export function useRuntimeProviderManagement(
|
|||
const query = input.query ?? directoryQuery;
|
||||
const filter = input.filter ?? DEFAULT_DIRECTORY_FILTER;
|
||||
const cursor = input.cursor ?? null;
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
const requestSeq = directoryRequestSeq.current + 1;
|
||||
directoryRequestSeq.current = requestSeq;
|
||||
const requestIsCurrent = (): boolean =>
|
||||
directoryRequestSeq.current === requestSeq && isProjectContextCurrent(projectContext);
|
||||
|
||||
if (append) {
|
||||
setDirectoryRefreshing(true);
|
||||
|
|
@ -356,14 +438,14 @@ export function useRuntimeProviderManagement(
|
|||
try {
|
||||
const response = await api.runtimeProviderManagement.loadProviderDirectory({
|
||||
runtimeId: options.runtimeId,
|
||||
projectPath: options.projectPath ?? null,
|
||||
projectPath: projectContext.path,
|
||||
query: query.trim() || null,
|
||||
filter,
|
||||
limit: 50,
|
||||
cursor,
|
||||
refresh: refreshDirectoryData,
|
||||
});
|
||||
if (directoryRequestSeq.current !== requestSeq) {
|
||||
if (!requestIsCurrent()) {
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
|
|
@ -388,23 +470,32 @@ export function useRuntimeProviderManagement(
|
|||
append ? [...current, ...directory.entries] : directory.entries
|
||||
);
|
||||
} catch (loadError) {
|
||||
if (directoryRequestSeq.current === requestSeq) {
|
||||
if (requestIsCurrent()) {
|
||||
setDirectoryError(
|
||||
loadError instanceof Error ? loadError.message : 'Failed to load provider directory'
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (directoryRequestSeq.current === requestSeq) {
|
||||
if (requestIsCurrent()) {
|
||||
setDirectoryLoading(false);
|
||||
setDirectoryRefreshing(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[directoryQuery, directorySupported, options.enabled, options.projectPath, options.runtimeId]
|
||||
[
|
||||
directoryQuery,
|
||||
directorySupported,
|
||||
getProjectContextSnapshot,
|
||||
isProjectContextCurrent,
|
||||
options.enabled,
|
||||
options.runtimeId,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled) {
|
||||
viewLoadRequestSeq.current += 1;
|
||||
appliedInitialProviderRef.current = null;
|
||||
setProviderQuery('');
|
||||
setDirectoryLoading(false);
|
||||
setDirectoryRefreshing(false);
|
||||
|
|
@ -426,7 +517,7 @@ export function useRuntimeProviderManagement(
|
|||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, [closeModelPickerState, options.enabled, refresh]);
|
||||
}, [closeModelPickerState, currentProjectPath, options.enabled, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled || !directorySupported) {
|
||||
|
|
@ -458,9 +549,11 @@ export function useRuntimeProviderManagement(
|
|||
const requestSeq = modelLoadRequestSeq.current + 1;
|
||||
modelLoadRequestSeq.current = requestSeq;
|
||||
const providerId = modelPickerProviderId;
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
const requestIsCurrent = (): boolean =>
|
||||
modelLoadRequestSeq.current === requestSeq &&
|
||||
activeModelPickerProviderRef.current === providerId;
|
||||
activeModelPickerProviderRef.current === providerId &&
|
||||
isProjectContextCurrent(projectContext);
|
||||
let cancelled = false;
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
|
|
@ -468,7 +561,7 @@ export function useRuntimeProviderManagement(
|
|||
api.runtimeProviderManagement.loadModels({
|
||||
runtimeId: options.runtimeId,
|
||||
providerId,
|
||||
projectPath: options.projectPath ?? null,
|
||||
projectPath: projectContext.path,
|
||||
query: modelQuery.trim() || null,
|
||||
limit: 250,
|
||||
}),
|
||||
|
|
@ -511,7 +604,14 @@ export function useRuntimeProviderManagement(
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [modelPickerProviderId, modelQuery, options.enabled, options.projectPath, options.runtimeId]);
|
||||
}, [
|
||||
getProjectContextSnapshot,
|
||||
isProjectContextCurrent,
|
||||
modelPickerProviderId,
|
||||
modelQuery,
|
||||
options.enabled,
|
||||
options.runtimeId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled || activeFormProviderId) {
|
||||
|
|
@ -620,19 +720,22 @@ export function useRuntimeProviderManagement(
|
|||
setSetupFormLoading(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
const requestSeq = setupFormRequestSeq.current + 1;
|
||||
setupFormRequestSeq.current = requestSeq;
|
||||
const requestIsCurrent = (): boolean =>
|
||||
setupFormRequestSeq.current === requestSeq && isProjectContextCurrent(projectContext);
|
||||
|
||||
void withUiTimeout(
|
||||
api.runtimeProviderManagement.loadSetupForm({
|
||||
runtimeId: options.runtimeId,
|
||||
providerId,
|
||||
projectPath: options.projectPath ?? null,
|
||||
projectPath: projectContext.path,
|
||||
}),
|
||||
'Provider setup form load timed out'
|
||||
)
|
||||
.then((response) => {
|
||||
if (setupFormRequestSeq.current !== requestSeq) {
|
||||
if (!requestIsCurrent()) {
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
|
|
@ -645,7 +748,7 @@ export function useRuntimeProviderManagement(
|
|||
}
|
||||
})
|
||||
.catch((setupError) => {
|
||||
if (setupFormRequestSeq.current !== requestSeq) {
|
||||
if (!requestIsCurrent()) {
|
||||
return;
|
||||
}
|
||||
setSetupFormError(
|
||||
|
|
@ -653,12 +756,12 @@ export function useRuntimeProviderManagement(
|
|||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (setupFormRequestSeq.current === requestSeq) {
|
||||
if (requestIsCurrent()) {
|
||||
setSetupFormLoading(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
[closeModelPickerState, options.projectPath, options.runtimeId]
|
||||
[closeModelPickerState, getProjectContextSnapshot, isProjectContextCurrent, options.runtimeId]
|
||||
);
|
||||
|
||||
const updateProviderQuery = useCallback(
|
||||
|
|
@ -720,6 +823,7 @@ export function useRuntimeProviderManagement(
|
|||
setError(null);
|
||||
setSetupSubmitError(null);
|
||||
setSuccessMessage(null);
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
try {
|
||||
const response = await withUiTimeout(
|
||||
api.runtimeProviderManagement.connectProvider({
|
||||
|
|
@ -728,10 +832,13 @@ export function useRuntimeProviderManagement(
|
|||
method: setupForm.method,
|
||||
apiKey: apiKey || null,
|
||||
metadata: setupMetadata,
|
||||
projectPath: options.projectPath ?? null,
|
||||
projectPath: projectContext.path,
|
||||
}),
|
||||
'Provider connect timed out'
|
||||
);
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
setSetupSubmitError(response.error.message);
|
||||
return;
|
||||
|
|
@ -748,24 +855,45 @@ export function useRuntimeProviderManagement(
|
|||
setSetupSubmitError(null);
|
||||
try {
|
||||
await options.onProviderChanged?.();
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
refresh({ silent: true }),
|
||||
loadDirectoryPage({ refresh: true, cursor: null }),
|
||||
]);
|
||||
} catch (refreshError) {
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
|
||||
);
|
||||
}
|
||||
} catch (connectError) {
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
setSetupSubmitError(
|
||||
connectError instanceof Error ? connectError.message : 'Failed to connect provider'
|
||||
);
|
||||
} finally {
|
||||
setSavingProviderId(null);
|
||||
if (isProjectContextCurrent(projectContext)) {
|
||||
setSavingProviderId(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[apiKeyValue, loadDirectoryPage, options, refresh, setupForm, setupFormError, setupMetadata]
|
||||
[
|
||||
apiKeyValue,
|
||||
getProjectContextSnapshot,
|
||||
isProjectContextCurrent,
|
||||
loadDirectoryPage,
|
||||
options,
|
||||
refresh,
|
||||
setupForm,
|
||||
setupFormError,
|
||||
setupMetadata,
|
||||
]
|
||||
);
|
||||
|
||||
const forgetProvider = useCallback(
|
||||
|
|
@ -773,15 +901,19 @@ export function useRuntimeProviderManagement(
|
|||
setSavingProviderId(providerId);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
try {
|
||||
const response = await withUiTimeout(
|
||||
api.runtimeProviderManagement.forgetCredential({
|
||||
runtimeId: options.runtimeId,
|
||||
providerId,
|
||||
projectPath: options.projectPath ?? null,
|
||||
projectPath: projectContext.path,
|
||||
}),
|
||||
'Provider forget timed out'
|
||||
);
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
setError(response.error.message);
|
||||
return;
|
||||
|
|
@ -792,25 +924,39 @@ export function useRuntimeProviderManagement(
|
|||
const success = formatCredentialRemovedMessage(response.provider ?? null);
|
||||
try {
|
||||
await options.onProviderChanged?.();
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
refresh({ silent: true }),
|
||||
loadDirectoryPage({ refresh: true, cursor: null }),
|
||||
]);
|
||||
} catch (refreshError) {
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
|
||||
);
|
||||
}
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
setSuccessMessage(success);
|
||||
} catch (forgetError) {
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
forgetError instanceof Error ? forgetError.message : 'Failed to forget credential'
|
||||
);
|
||||
} finally {
|
||||
setSavingProviderId(null);
|
||||
if (isProjectContextCurrent(projectContext)) {
|
||||
setSavingProviderId(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[loadDirectoryPage, options, refresh]
|
||||
[getProjectContextSnapshot, isProjectContextCurrent, loadDirectoryPage, options, refresh]
|
||||
);
|
||||
|
||||
const openModelPicker = useCallback(
|
||||
|
|
@ -839,9 +985,11 @@ export function useRuntimeProviderManagement(
|
|||
async (providerId: string, modelId: string): Promise<void> => {
|
||||
const probeGeneration = modelProbeGenerationRef.current;
|
||||
const activeProviderAtStart = activeModelPickerProviderRef.current;
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
const shouldRecordProbeResult = (): boolean =>
|
||||
modelProbeGenerationRef.current === probeGeneration &&
|
||||
(activeProviderAtStart === null || activeModelPickerProviderRef.current === providerId);
|
||||
(activeProviderAtStart === null || activeModelPickerProviderRef.current === providerId) &&
|
||||
isProjectContextCurrent(projectContext);
|
||||
setTestingModelIds((current) =>
|
||||
current.includes(modelId) ? current : [...current, modelId]
|
||||
);
|
||||
|
|
@ -853,7 +1001,7 @@ export function useRuntimeProviderManagement(
|
|||
runtimeId: options.runtimeId,
|
||||
providerId,
|
||||
modelId,
|
||||
projectPath: options.projectPath ?? null,
|
||||
projectPath: projectContext.path,
|
||||
}),
|
||||
'Model test timed out',
|
||||
100_000
|
||||
|
|
@ -900,17 +1048,24 @@ export function useRuntimeProviderManagement(
|
|||
setView((current) => applyModelTestResultToView(current, result));
|
||||
}
|
||||
} finally {
|
||||
setTestingModelIds((current) => current.filter((entry) => entry !== modelId));
|
||||
if (shouldRecordProbeResult()) {
|
||||
setTestingModelIds((current) => current.filter((entry) => entry !== modelId));
|
||||
}
|
||||
}
|
||||
},
|
||||
[options.projectPath, options.runtimeId]
|
||||
[getProjectContextSnapshot, isProjectContextCurrent, options.runtimeId]
|
||||
);
|
||||
|
||||
const setDefaultModel = useCallback(
|
||||
async (providerId: string, modelId: string): Promise<void> => {
|
||||
async (
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
scope: RuntimeProviderDefaultScopeDto = 'project'
|
||||
): Promise<void> => {
|
||||
setSavingDefaultModelId(modelId);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
const projectContext = getProjectContextSnapshot();
|
||||
try {
|
||||
const response = await withUiTimeout(
|
||||
api.runtimeProviderManagement.setDefaultModel({
|
||||
|
|
@ -918,11 +1073,15 @@ export function useRuntimeProviderManagement(
|
|||
providerId,
|
||||
modelId,
|
||||
probe: true,
|
||||
projectPath: options.projectPath ?? null,
|
||||
scope,
|
||||
projectPath: projectContext.path,
|
||||
}),
|
||||
'Set default model timed out',
|
||||
100_000
|
||||
);
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
setError(response.error.message);
|
||||
return;
|
||||
|
|
@ -938,33 +1097,46 @@ export function useRuntimeProviderManagement(
|
|||
if (response.view) {
|
||||
setView(applyModelTestResultToView(response.view, proofResult));
|
||||
}
|
||||
const effectiveDefaultModelId = response.view?.defaultModel ?? modelId;
|
||||
setModelResults((current) => ({
|
||||
...current,
|
||||
[modelId]: proofResult,
|
||||
}));
|
||||
setSelectedModelId(modelId);
|
||||
setSelectedModelId(effectiveDefaultModelId);
|
||||
setModels((current) =>
|
||||
current.map((model) =>
|
||||
applyModelTestResultToModel(
|
||||
{
|
||||
...model,
|
||||
default: model.modelId === modelId,
|
||||
default: model.modelId === effectiveDefaultModelId,
|
||||
},
|
||||
proofResult
|
||||
)
|
||||
)
|
||||
);
|
||||
setSuccessMessage(`OpenCode default set to ${modelId}`);
|
||||
setSuccessMessage(
|
||||
scope === 'all_projects'
|
||||
? `All-projects OpenCode default set to ${modelId}`
|
||||
: `Project OpenCode default set to ${modelId}`
|
||||
);
|
||||
await options.onProviderChanged?.();
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
} catch (defaultError) {
|
||||
if (!isProjectContextCurrent(projectContext)) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
defaultError instanceof Error ? defaultError.message : 'Failed to set OpenCode default'
|
||||
);
|
||||
} finally {
|
||||
setSavingDefaultModelId(null);
|
||||
if (isProjectContextCurrent(projectContext)) {
|
||||
setSavingDefaultModelId(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[options]
|
||||
[getProjectContextSnapshot, isProjectContextCurrent, options]
|
||||
);
|
||||
|
||||
const selectProvider = useCallback(
|
||||
|
|
@ -984,6 +1156,40 @@ export function useRuntimeProviderManagement(
|
|||
[closeModelPickerState]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialProviderId = options.initialProviderId?.trim();
|
||||
if (!initialProviderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialAction = options.initialProviderAction ?? 'select';
|
||||
const initialKey = `${initialProviderId}:${initialAction}`;
|
||||
if (appliedInitialProviderRef.current === initialKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
appliedInitialProviderRef.current = initialKey;
|
||||
updateProviderQuery(initialProviderId);
|
||||
|
||||
if (initialAction === 'connect') {
|
||||
startConnect(initialProviderId);
|
||||
return;
|
||||
}
|
||||
|
||||
selectProvider(initialProviderId);
|
||||
}, [
|
||||
options.enabled,
|
||||
options.initialProviderAction,
|
||||
options.initialProviderId,
|
||||
selectProvider,
|
||||
startConnect,
|
||||
updateProviderQuery,
|
||||
]);
|
||||
|
||||
const state = useMemo<RuntimeProviderManagementState>(
|
||||
() => ({
|
||||
view,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
import {
|
||||
compareOpenCodeTeamModelRecommendations,
|
||||
getOpenCodeTeamModelRecommendation,
|
||||
|
|
@ -43,11 +44,13 @@ import type {
|
|||
} from '../hooks/useRuntimeProviderManagement';
|
||||
import type {
|
||||
RuntimeProviderConnectionDto,
|
||||
RuntimeProviderDefaultScopeDto,
|
||||
RuntimeProviderDirectoryEntryDto,
|
||||
RuntimeProviderModelDto,
|
||||
RuntimeProviderModelTestResultDto,
|
||||
RuntimeProviderSetupPromptDto,
|
||||
} from '@features/runtime-provider-management/contracts';
|
||||
import type { ProjectPathProject } from '@renderer/components/team/dialogs/projectPathProjects';
|
||||
import type { CSSProperties, JSX, KeyboardEvent } from 'react';
|
||||
|
||||
interface RuntimeProviderManagementPanelViewProps {
|
||||
|
|
@ -55,6 +58,10 @@ interface RuntimeProviderManagementPanelViewProps {
|
|||
readonly actions: RuntimeProviderManagementActions;
|
||||
readonly disabled: boolean;
|
||||
readonly projectPath?: string | null;
|
||||
readonly projectContextProjects?: readonly ProjectPathProject[];
|
||||
readonly projectContextLoading?: boolean;
|
||||
readonly projectContextError?: string | null;
|
||||
readonly onProjectContextChange?: (projectPath: string | null) => void;
|
||||
}
|
||||
|
||||
interface ProviderActionsProps {
|
||||
|
|
@ -72,9 +79,14 @@ interface ProviderRowProps {
|
|||
readonly formOpen: boolean;
|
||||
readonly busy: boolean;
|
||||
readonly disabled: boolean;
|
||||
readonly hasProjectContext: boolean;
|
||||
readonly actions: RuntimeProviderManagementActions;
|
||||
}
|
||||
|
||||
type OpenCodeSettingsSection = 'models' | 'providers';
|
||||
|
||||
const NO_PROJECT_CONTEXT_VALUE = '__runtime-provider-no-project-context__';
|
||||
|
||||
function getDirectoryAction(
|
||||
provider: RuntimeProviderDirectoryEntryDto,
|
||||
actionId: RuntimeProviderConnectionDto['actions'][number]['id']
|
||||
|
|
@ -116,6 +128,38 @@ function formatOpenCodeProviderCount(count: number): string {
|
|||
return `${count} OpenCode provider${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function getProjectContextName(projectPath: string | null | undefined): string | null {
|
||||
const trimmed = projectPath?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const normalized = trimmed.replace(/[\\/]+$/, '');
|
||||
const name = normalized.split(/[\\/]/).pop()?.trim();
|
||||
return name || normalized;
|
||||
}
|
||||
|
||||
function getDefaultScopeDescription(scope: RuntimeProviderDefaultScopeDto): string {
|
||||
return scope === 'all_projects'
|
||||
? 'Used by project contexts without their own OpenCode default. Local models are tested per project.'
|
||||
: 'Applies only to the selected project context.';
|
||||
}
|
||||
|
||||
function getDefaultScopeButtonLabel(scope: RuntimeProviderDefaultScopeDto): string {
|
||||
return scope === 'all_projects' ? 'Set all-projects default' : 'Set project default';
|
||||
}
|
||||
|
||||
function isDefaultForScope(
|
||||
model: RuntimeProviderModelDto,
|
||||
state: RuntimeProviderManagementState,
|
||||
scope: RuntimeProviderDefaultScopeDto
|
||||
): boolean {
|
||||
const scopedDefault =
|
||||
scope === 'all_projects'
|
||||
? state.view?.allProjectsDefaultModel
|
||||
: state.view?.projectDefaultModel;
|
||||
return scopedDefault === model.modelId;
|
||||
}
|
||||
|
||||
function directoryEntryMatchesQuery(
|
||||
provider: RuntimeProviderDirectoryEntryDto,
|
||||
query: string
|
||||
|
|
@ -442,11 +486,15 @@ function RuntimeSummary({
|
|||
<div
|
||||
className="mt-1 truncate text-[11px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
title={projectPath ?? undefined}
|
||||
title={
|
||||
projectPath
|
||||
? `Current project context: ${projectPath}`
|
||||
: 'No project context selected; using the fallback OpenCode management context.'
|
||||
}
|
||||
>
|
||||
{projectPath
|
||||
? `Managing selected project profile: ${projectPath}`
|
||||
: 'Managing fallback OpenCode profile. Select a project to manage launch credentials for that project.'}
|
||||
? `Project context: ${getProjectContextName(projectPath) ?? 'current project'}`
|
||||
: 'No project context selected'}
|
||||
</div>
|
||||
{state.loading ? (
|
||||
<div
|
||||
|
|
@ -713,6 +761,7 @@ function ProviderRow({
|
|||
formOpen,
|
||||
busy,
|
||||
disabled,
|
||||
hasProjectContext,
|
||||
actions,
|
||||
}: ProviderRowProps): JSX.Element {
|
||||
const connect = getProviderAction(provider, 'connect');
|
||||
|
|
@ -827,6 +876,7 @@ function ProviderRow({
|
|||
actions={actions}
|
||||
provider={provider}
|
||||
disabled={disabled || busy}
|
||||
hasProjectContext={hasProjectContext}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -840,6 +890,7 @@ function DirectoryProviderRow({
|
|||
formOpen,
|
||||
disabled,
|
||||
busy,
|
||||
hasProjectContext,
|
||||
actions,
|
||||
}: {
|
||||
readonly provider: RuntimeProviderDirectoryEntryDto;
|
||||
|
|
@ -848,6 +899,7 @@ function DirectoryProviderRow({
|
|||
readonly formOpen: boolean;
|
||||
readonly disabled: boolean;
|
||||
readonly busy: boolean;
|
||||
readonly hasProjectContext: boolean;
|
||||
readonly actions: RuntimeProviderManagementActions;
|
||||
}): JSX.Element {
|
||||
const connect = getDirectoryAction(provider, 'connect');
|
||||
|
|
@ -1003,6 +1055,7 @@ function DirectoryProviderRow({
|
|||
actions={actions}
|
||||
provider={directoryEntryToProviderConnection(provider)}
|
||||
disabled={disabled || busy}
|
||||
hasProjectContext={hasProjectContext}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -1082,7 +1135,7 @@ function ModelBadges({
|
|||
{usedForNewTeams ? (
|
||||
<Badge className="bg-sky-400/15 px-1.5 py-0 text-[10px] text-sky-100">
|
||||
<Star className="mr-1 size-3" />
|
||||
Used for new teams
|
||||
Used in team picker
|
||||
</Badge>
|
||||
) : null}
|
||||
{model.free ? (
|
||||
|
|
@ -1140,10 +1193,6 @@ function canUseOpenCodeModelRoute(model: RuntimeProviderModelDto): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function canSetOpenCodeDefaultModelRoute(model: RuntimeProviderModelDto): boolean {
|
||||
return canUseOpenCodeModelRoute(model) && !model.default;
|
||||
}
|
||||
|
||||
function getOpenCodeRouteUnavailableTitle(model: RuntimeProviderModelDto): string | undefined {
|
||||
if (isUnknownOpenCodeModelRoute(model)) {
|
||||
return 'This model is the current OpenCode default, but it is not available in the live catalog yet.';
|
||||
|
|
@ -1183,6 +1232,7 @@ function ModelRow({
|
|||
model,
|
||||
selected,
|
||||
disabled,
|
||||
hasProjectContext,
|
||||
testing,
|
||||
result,
|
||||
actions,
|
||||
|
|
@ -1191,6 +1241,7 @@ function ModelRow({
|
|||
readonly model: RuntimeProviderModelDto;
|
||||
readonly selected: boolean;
|
||||
readonly disabled: boolean;
|
||||
readonly hasProjectContext: boolean;
|
||||
readonly testing: boolean;
|
||||
readonly result: RuntimeProviderModelTestResultDto | undefined;
|
||||
readonly actions: RuntimeProviderManagementActions;
|
||||
|
|
@ -1254,9 +1305,13 @@ function ModelRow({
|
|||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 min-w-20 justify-center"
|
||||
disabled={disabled || testing}
|
||||
disabled={disabled || !hasProjectContext || testing}
|
||||
title={
|
||||
hasProjectContext ? undefined : 'Select a project context before testing models.'
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!hasProjectContext) return;
|
||||
void actions.testModel(provider.providerId, model.modelId);
|
||||
}}
|
||||
>
|
||||
|
|
@ -1274,14 +1329,139 @@ function ModelRow({
|
|||
);
|
||||
}
|
||||
|
||||
function OpenCodeModelScopeControls({
|
||||
defaultScope,
|
||||
onDefaultScopeChange,
|
||||
projectPath,
|
||||
projects,
|
||||
loading,
|
||||
error,
|
||||
onProjectContextChange,
|
||||
}: {
|
||||
readonly defaultScope: RuntimeProviderDefaultScopeDto;
|
||||
readonly onDefaultScopeChange: (scope: RuntimeProviderDefaultScopeDto) => void;
|
||||
readonly projectPath: string | null | undefined;
|
||||
readonly projects: readonly ProjectPathProject[];
|
||||
readonly loading: boolean;
|
||||
readonly error: string | null;
|
||||
readonly onProjectContextChange?: (projectPath: string | null) => void;
|
||||
}): JSX.Element {
|
||||
const selectedValue = projectPath?.trim() || NO_PROJECT_CONTEXT_VALUE;
|
||||
const projectOptions = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const options = projects.filter((project) => {
|
||||
const normalized = project.path.trim();
|
||||
if (!normalized || seen.has(normalized) || project.filesystemState === 'deleted') {
|
||||
return false;
|
||||
}
|
||||
seen.add(normalized);
|
||||
return true;
|
||||
});
|
||||
const currentPath = projectPath?.trim();
|
||||
if (currentPath && !seen.has(currentPath)) {
|
||||
options.unshift({
|
||||
id: currentPath,
|
||||
path: currentPath,
|
||||
name: getProjectContextName(currentPath) ?? currentPath,
|
||||
sessions: [],
|
||||
totalSessions: 0,
|
||||
createdAt: 0,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [projectPath, projects]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border p-3"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--color-text)]">OpenCode model scope</div>
|
||||
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
|
||||
{getDefaultScopeDescription(defaultScope)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex shrink-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{(['project', 'all_projects'] as const).map((scope) => (
|
||||
<button
|
||||
key={scope}
|
||||
type="button"
|
||||
className={`rounded-[3px] px-3 py-1 text-xs font-medium transition-colors ${
|
||||
defaultScope === scope
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
onClick={() => onDefaultScopeChange(scope)}
|
||||
>
|
||||
{scope === 'all_projects' ? 'All projects' : 'This project'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
|
||||
<div className="min-w-0">
|
||||
<Label className="text-xs text-[var(--color-text-secondary)]">Project context</Label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
value={selectedValue}
|
||||
disabled={loading || !onProjectContextChange}
|
||||
onValueChange={(value) => {
|
||||
onProjectContextChange?.(value === NO_PROJECT_CONTEXT_VALUE ? null : value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue
|
||||
placeholder={loading ? 'Loading project contexts...' : 'Select project context'}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_PROJECT_CONTEXT_VALUE}>Select project context</SelectItem>
|
||||
{projectOptions.map((project) => (
|
||||
<SelectItem key={project.path} value={project.path}>
|
||||
{project.name || getProjectContextName(project.path) || project.path}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="min-w-0 truncate text-[11px] text-[var(--color-text-muted)] md:max-w-[280px]"
|
||||
title={projectPath?.trim() || undefined}
|
||||
>
|
||||
{projectPath
|
||||
? `Current context: ${getProjectContextName(projectPath) ?? projectPath}`
|
||||
: 'Select a project before testing or saving OpenCode defaults.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-2 rounded-md border border-red-400/25 bg-red-400/10 px-2 py-1.5 text-xs text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfiguredOpenCodeModelsPanel({
|
||||
state,
|
||||
actions,
|
||||
disabled,
|
||||
defaultScope,
|
||||
hasProjectContext,
|
||||
}: {
|
||||
readonly state: RuntimeProviderManagementState;
|
||||
readonly actions: RuntimeProviderManagementActions;
|
||||
readonly disabled: boolean;
|
||||
readonly defaultScope: RuntimeProviderDefaultScopeDto;
|
||||
readonly hasProjectContext: boolean;
|
||||
}): JSX.Element | null {
|
||||
const models = state.view?.configuredModels ?? [];
|
||||
if (models.length === 0) {
|
||||
|
|
@ -1299,10 +1479,11 @@ function ConfiguredOpenCodeModelsPanel({
|
|||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--color-text)]">
|
||||
Configured OpenCode models
|
||||
Launchable OpenCode models
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-muted)]">
|
||||
Ready model routes from OpenCode config, built-in free models, and the current default.
|
||||
Routes you can test or use in the team picker: local config, free built-in models, and
|
||||
current default.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1314,10 +1495,19 @@ function ConfiguredOpenCodeModelsPanel({
|
|||
const savingDefault = state.savingDefaultModelId === model.modelId;
|
||||
const result = state.modelResults[model.modelId];
|
||||
const unavailableTitle = getOpenCodeRouteUnavailableTitle(model);
|
||||
const canTest = !disabled && !testing && canTestOpenCodeModelRoute(model);
|
||||
const contextRequiredTitle = hasProjectContext
|
||||
? undefined
|
||||
: 'Select a project context before testing or saving OpenCode defaults.';
|
||||
const alreadyDefaultForScope = isDefaultForScope(model, state, defaultScope);
|
||||
const canTest =
|
||||
!disabled && hasProjectContext && !testing && canTestOpenCodeModelRoute(model);
|
||||
const canUse = !disabled && canUseOpenCodeModelRoute(model);
|
||||
const canSetDefault =
|
||||
!disabled && !savingDefault && canSetOpenCodeDefaultModelRoute(model);
|
||||
!disabled &&
|
||||
hasProjectContext &&
|
||||
!savingDefault &&
|
||||
!alreadyDefaultForScope &&
|
||||
canUseOpenCodeModelRoute(model);
|
||||
return (
|
||||
<div
|
||||
key={model.modelId}
|
||||
|
|
@ -1349,7 +1539,7 @@ function ConfiguredOpenCodeModelsPanel({
|
|||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={!canTest}
|
||||
title={canTest ? undefined : unavailableTitle}
|
||||
title={canTest ? undefined : (contextRequiredTitle ?? unavailableTitle)}
|
||||
onClick={() => {
|
||||
if (!canTest) return;
|
||||
void actions.testModel(model.providerId, model.modelId);
|
||||
|
|
@ -1374,7 +1564,7 @@ function ConfiguredOpenCodeModelsPanel({
|
|||
actions.useModelForNewTeams(model.modelId);
|
||||
}}
|
||||
>
|
||||
Use for new teams
|
||||
Use in team picker
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -1385,17 +1575,18 @@ function ConfiguredOpenCodeModelsPanel({
|
|||
title={
|
||||
canSetDefault
|
||||
? undefined
|
||||
: model.default
|
||||
? 'This is already the OpenCode default.'
|
||||
: unavailableTitle
|
||||
: (contextRequiredTitle ??
|
||||
(alreadyDefaultForScope
|
||||
? 'This is already the selected OpenCode default.'
|
||||
: unavailableTitle))
|
||||
}
|
||||
onClick={() => {
|
||||
if (!canSetDefault) return;
|
||||
void actions.setDefaultModel(model.providerId, model.modelId);
|
||||
void actions.setDefaultModel(model.providerId, model.modelId, defaultScope);
|
||||
}}
|
||||
>
|
||||
{savingDefault ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
|
||||
Set OpenCode default
|
||||
{getDefaultScopeButtonLabel(defaultScope)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1413,11 +1604,13 @@ function ProviderModelList({
|
|||
actions,
|
||||
provider,
|
||||
disabled,
|
||||
hasProjectContext,
|
||||
}: {
|
||||
readonly state: RuntimeProviderManagementState;
|
||||
readonly actions: RuntimeProviderManagementActions;
|
||||
readonly provider: RuntimeProviderConnectionDto;
|
||||
readonly disabled: boolean;
|
||||
readonly hasProjectContext: boolean;
|
||||
}): JSX.Element {
|
||||
const pickerOpen = state.modelPickerProviderId === provider.providerId;
|
||||
const [recommendedOnly, setRecommendedOnly] = useState(false);
|
||||
|
|
@ -1513,6 +1706,7 @@ function ProviderModelList({
|
|||
model={model}
|
||||
selected={state.selectedModelId === model.modelId}
|
||||
disabled={disabled}
|
||||
hasProjectContext={hasProjectContext}
|
||||
testing={state.testingModelIds.includes(model.modelId)}
|
||||
result={state.modelResults[model.modelId]}
|
||||
actions={actions}
|
||||
|
|
@ -1529,7 +1723,13 @@ export function RuntimeProviderManagementPanelView({
|
|||
actions,
|
||||
disabled,
|
||||
projectPath = null,
|
||||
projectContextProjects = [],
|
||||
projectContextLoading = false,
|
||||
projectContextError = null,
|
||||
onProjectContextChange,
|
||||
}: RuntimeProviderManagementPanelViewProps): JSX.Element {
|
||||
const [selectedSection, setSelectedSection] = useState<OpenCodeSettingsSection | null>(null);
|
||||
const [defaultScope, setDefaultScope] = useState<RuntimeProviderDefaultScopeDto>('project');
|
||||
const providerQuery = state.providerQuery.trim().toLowerCase();
|
||||
const filteredProviders = providerQuery
|
||||
? state.providers.filter((provider) =>
|
||||
|
|
@ -1558,6 +1758,9 @@ export function RuntimeProviderManagementPanelView({
|
|||
: state.directorySupported
|
||||
? 'OpenCode provider catalog'
|
||||
: 'OpenCode providers';
|
||||
const launchableModelCount = state.view?.configuredModels?.length ?? 0;
|
||||
const activeSection = selectedSection ?? (launchableModelCount > 0 ? 'models' : 'providers');
|
||||
const hasProjectContext = Boolean(projectPath?.trim());
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -1596,156 +1799,215 @@ export function RuntimeProviderManagementPanelView({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<ConfiguredOpenCodeModelsPanel state={state} actions={actions} disabled={disabled} />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--color-text)]">Providers</div>
|
||||
<div className="text-xs text-[var(--color-text-muted)]">
|
||||
{providerCountLabel}. Connected and recommended providers are shown first.
|
||||
</div>
|
||||
<Tabs
|
||||
value={activeSection}
|
||||
onValueChange={(value) => setSelectedSection(value as OpenCodeSettingsSection)}
|
||||
>
|
||||
<div className="border-b border-white/10">
|
||||
<TabsList className="gap-1 rounded-b-none">
|
||||
<TabsTrigger
|
||||
value="models"
|
||||
className="rounded-b-none data-[state=active]:bg-[var(--color-surface)]"
|
||||
>
|
||||
Models
|
||||
{launchableModelCount > 0 ? (
|
||||
<span className="ml-2 rounded-full bg-white/10 px-1.5 py-0 text-[10px]">
|
||||
{launchableModelCount}
|
||||
</span>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="providers"
|
||||
className="rounded-b-none data-[state=active]:bg-[var(--color-surface)]"
|
||||
>
|
||||
Providers
|
||||
{state.directoryTotalCount !== null ? (
|
||||
<span className="ml-2 rounded-full bg-white/10 px-1.5 py-0 text-[10px]">
|
||||
{state.directoryTotalCount}
|
||||
</span>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
{state.directorySupported ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={disabled || state.directoryLoading || state.directoryRefreshing}
|
||||
onClick={() => void actions.refreshDirectory()}
|
||||
>
|
||||
{state.directoryRefreshing ? (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcw className="mr-1 size-3.5" />
|
||||
)}
|
||||
Refresh catalog
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{state.providers.length > 0 || state.directorySupported ? (
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-text-muted)]" />
|
||||
<Input
|
||||
data-testid="runtime-provider-search"
|
||||
value={state.providerQuery}
|
||||
disabled={disabled || state.loading}
|
||||
onChange={(event) => actions.setProviderQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && state.providerQuery.trim().length >= 2) {
|
||||
actions.searchAllProviders(state.providerQuery.trim());
|
||||
}
|
||||
}}
|
||||
placeholder="Search providers"
|
||||
className="h-9 pr-3 text-sm"
|
||||
style={{ paddingLeft: 40 }}
|
||||
<TabsContent value="models" className="mt-3 space-y-3">
|
||||
<OpenCodeModelScopeControls
|
||||
defaultScope={defaultScope}
|
||||
onDefaultScopeChange={setDefaultScope}
|
||||
projectPath={projectPath}
|
||||
projects={projectContextProjects}
|
||||
loading={projectContextLoading}
|
||||
error={projectContextError}
|
||||
onProjectContextChange={onProjectContextChange}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<ConfiguredOpenCodeModelsPanel
|
||||
state={state}
|
||||
actions={actions}
|
||||
disabled={disabled}
|
||||
defaultScope={defaultScope}
|
||||
hasProjectContext={hasProjectContext}
|
||||
/>
|
||||
{launchableModelCount === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-white/10 p-4 text-sm text-[var(--color-text-muted)]">
|
||||
No launchable OpenCode model routes were reported yet. Configure a local route in
|
||||
OpenCode or use the Providers tab to inspect catalog providers.
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
{state.directoryError ? (
|
||||
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
|
||||
{state.directoryError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-[min(52vh,640px)] space-y-2 overflow-y-auto pr-1">
|
||||
{useDirectoryRows ? (
|
||||
<>
|
||||
{state.directoryLoading && state.directoryEntries.length === 0 ? (
|
||||
<RuntimeProviderLoadingPlaceholder />
|
||||
) : null}
|
||||
{visibleDirectoryRows.map((provider) => (
|
||||
<DirectoryProviderRow
|
||||
key={provider.providerId}
|
||||
provider={provider}
|
||||
state={state}
|
||||
active={provider.providerId === state.selectedProviderId}
|
||||
formOpen={state.activeFormProviderId === provider.providerId}
|
||||
busy={state.savingProviderId === provider.providerId}
|
||||
disabled={disabled || state.directoryLoading}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
{state.directoryNextCursor ? (
|
||||
<div className="flex justify-center py-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || state.directoryRefreshing}
|
||||
onClick={() => void actions.loadMoreDirectory()}
|
||||
>
|
||||
{state.directoryRefreshing ? (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Load more providers
|
||||
</Button>
|
||||
<TabsContent value="providers" className="mt-3 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--color-text)]">Providers</div>
|
||||
<div className="text-xs text-[var(--color-text-muted)]">
|
||||
{providerCountLabel}. Connected and recommended providers are shown first.
|
||||
</div>
|
||||
</div>
|
||||
{state.directorySupported ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={disabled || state.directoryLoading || state.directoryRefreshing}
|
||||
onClick={() => void actions.refreshDirectory()}
|
||||
>
|
||||
{state.directoryRefreshing ? (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcw className="mr-1 size-3.5" />
|
||||
)}
|
||||
Refresh catalog
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{state.loading && state.providers.length === 0 ? (
|
||||
<RuntimeProviderLoadingPlaceholder />
|
||||
) : null}
|
||||
{filteredProviders.map((provider) => (
|
||||
<ProviderRow
|
||||
key={provider.providerId}
|
||||
provider={provider}
|
||||
state={state}
|
||||
active={provider.providerId === state.selectedProviderId}
|
||||
formOpen={state.activeFormProviderId === provider.providerId}
|
||||
busy={state.savingProviderId === provider.providerId}
|
||||
</div>
|
||||
|
||||
{state.providers.length > 0 || state.directorySupported ? (
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-text-muted)]" />
|
||||
<Input
|
||||
data-testid="runtime-provider-search"
|
||||
value={state.providerQuery}
|
||||
disabled={disabled || state.loading}
|
||||
actions={actions}
|
||||
onChange={(event) => actions.setProviderQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && state.providerQuery.trim().length >= 2) {
|
||||
actions.searchAllProviders(state.providerQuery.trim());
|
||||
}
|
||||
}}
|
||||
placeholder="Search providers"
|
||||
className="h-9 pr-3 text-sm"
|
||||
style={{ paddingLeft: 40 }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{useDirectoryRows &&
|
||||
!state.directoryLoading &&
|
||||
visibleDirectoryRows.length === 0 &&
|
||||
!state.directoryError ? (
|
||||
<div
|
||||
className="rounded-lg border p-3 text-sm"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
No providers match that search.
|
||||
</div>
|
||||
) : null}
|
||||
{state.directoryError ? (
|
||||
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
|
||||
{state.directoryError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!useDirectoryRows &&
|
||||
!state.loading &&
|
||||
state.providers.length > 0 &&
|
||||
filteredProviders.length === 0 ? (
|
||||
<div
|
||||
className="rounded-lg border p-3 text-sm"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
No providers match that search.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-[min(52vh,640px)] space-y-2 overflow-y-auto pr-1">
|
||||
{useDirectoryRows ? (
|
||||
<>
|
||||
{state.directoryLoading && state.directoryEntries.length === 0 ? (
|
||||
<RuntimeProviderLoadingPlaceholder />
|
||||
) : null}
|
||||
{visibleDirectoryRows.map((provider) => (
|
||||
<DirectoryProviderRow
|
||||
key={provider.providerId}
|
||||
provider={provider}
|
||||
state={state}
|
||||
active={provider.providerId === state.selectedProviderId}
|
||||
formOpen={state.activeFormProviderId === provider.providerId}
|
||||
busy={state.savingProviderId === provider.providerId}
|
||||
disabled={disabled || state.directoryLoading}
|
||||
hasProjectContext={hasProjectContext}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
{state.directoryNextCursor ? (
|
||||
<div className="flex justify-center py-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || state.directoryRefreshing}
|
||||
onClick={() => void actions.loadMoreDirectory()}
|
||||
>
|
||||
{state.directoryRefreshing ? (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Load more providers
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{state.loading && state.providers.length === 0 ? (
|
||||
<RuntimeProviderLoadingPlaceholder />
|
||||
) : null}
|
||||
{filteredProviders.map((provider) => (
|
||||
<ProviderRow
|
||||
key={provider.providerId}
|
||||
provider={provider}
|
||||
state={state}
|
||||
active={provider.providerId === state.selectedProviderId}
|
||||
formOpen={state.activeFormProviderId === provider.providerId}
|
||||
busy={state.savingProviderId === provider.providerId}
|
||||
disabled={disabled || state.loading}
|
||||
hasProjectContext={hasProjectContext}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!useDirectoryRows && !state.loading && state.providers.length === 0 ? (
|
||||
<div
|
||||
className="rounded-lg border p-3 text-sm"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
No OpenCode providers reported by the managed runtime.
|
||||
</div>
|
||||
) : null}
|
||||
{useDirectoryRows &&
|
||||
!state.directoryLoading &&
|
||||
visibleDirectoryRows.length === 0 &&
|
||||
!state.directoryError ? (
|
||||
<div
|
||||
className="rounded-lg border p-3 text-sm"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
No providers match that search.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!useDirectoryRows &&
|
||||
!state.loading &&
|
||||
state.providers.length > 0 &&
|
||||
filteredProviders.length === 0 ? (
|
||||
<div
|
||||
className="rounded-lg border p-3 text-sm"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
No providers match that search.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!useDirectoryRows && !state.loading && state.providers.length === 0 ? (
|
||||
<div
|
||||
className="rounded-lg border p-3 text-sm"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
No OpenCode providers reported by the managed runtime.
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ interface Props {
|
|||
readonly onOpenChange: (open: boolean) => void;
|
||||
readonly providers: CliProviderStatus[];
|
||||
readonly initialProviderId: CliProviderId;
|
||||
readonly initialRuntimeProviderId?: string | null;
|
||||
readonly initialRuntimeProviderAction?: 'connect' | 'select' | null;
|
||||
readonly projectPath?: string | null;
|
||||
readonly providerStatusLoading?: Partial<Record<CliProviderId, boolean>>;
|
||||
readonly disabled?: boolean;
|
||||
|
|
@ -314,6 +316,24 @@ function getProviderUsageLabel(provider: CliProviderStatus): string {
|
|||
: provider.statusMessage || 'Not connected';
|
||||
}
|
||||
|
||||
function getCompactOpenCodeProviderDetailMessage(detailMessage?: string | null): string | null {
|
||||
const trimmed = detailMessage?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstInternalDetailIndex = [' - auth ', ' - behavior ', ' - managed ']
|
||||
.map((marker) => trimmed.indexOf(marker))
|
||||
.filter((index) => index >= 0)
|
||||
.sort((left, right) => left - right)[0];
|
||||
|
||||
const compact =
|
||||
typeof firstInternalDetailIndex === 'number'
|
||||
? trimmed.slice(0, firstInternalDetailIndex).trim()
|
||||
: trimmed;
|
||||
return compact || null;
|
||||
}
|
||||
|
||||
function getCodexAccountPanelHint(
|
||||
provider: CliProviderStatus | null,
|
||||
configuredAuthMode: CliProviderAuthMode | undefined
|
||||
|
|
@ -593,6 +613,8 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
onOpenChange,
|
||||
providers,
|
||||
initialProviderId,
|
||||
initialRuntimeProviderId = null,
|
||||
initialRuntimeProviderAction = null,
|
||||
projectPath = null,
|
||||
providerStatusLoading = {},
|
||||
disabled = false,
|
||||
|
|
@ -887,6 +909,14 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
}
|
||||
}
|
||||
const showSelectedProviderSummary = Boolean(selectedProvider) && !connectionManagedRuntime;
|
||||
const selectedProviderDetailMessage =
|
||||
selectedProvider?.providerId === 'opencode'
|
||||
? getCompactOpenCodeProviderDetailMessage(selectedProvider.detailMessage)
|
||||
: (selectedProvider?.detailMessage ?? null);
|
||||
const selectedProviderDiagnostics =
|
||||
selectedProvider?.providerId === 'opencode'
|
||||
? []
|
||||
: (selectedProvider?.externalRuntimeDiagnostics ?? []);
|
||||
|
||||
const connectionProgressMessage = useMemo(() => {
|
||||
if (!connectionLoading || !selectedProvider) {
|
||||
|
|
@ -1208,18 +1238,17 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedProvider.detailMessage ? (
|
||||
{selectedProviderDetailMessage ? (
|
||||
<div className="mt-2 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{selectedProvider.detailMessage}
|
||||
{selectedProviderDetailMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedProvider.externalRuntimeDiagnostics &&
|
||||
selectedProvider.externalRuntimeDiagnostics.length > 0 ? (
|
||||
{selectedProviderDiagnostics.length > 0 ? (
|
||||
<div
|
||||
className="mt-2 space-y-1 text-[11px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{selectedProvider.externalRuntimeDiagnostics.slice(0, 3).map((diagnostic) => (
|
||||
{selectedProviderDiagnostics.slice(0, 3).map((diagnostic) => (
|
||||
<div key={diagnostic.id}>
|
||||
{diagnostic.label}:{' '}
|
||||
{diagnostic.statusMessage ?? (diagnostic.detected ? 'detected' : 'missing')}
|
||||
|
|
@ -1237,6 +1266,8 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
runtimeId="opencode"
|
||||
open={open}
|
||||
projectPath={projectPath}
|
||||
initialProviderId={initialRuntimeProviderId}
|
||||
initialProviderAction={initialRuntimeProviderAction}
|
||||
disabled={disabled || selectedProviderLoading}
|
||||
onProviderChanged={() => onRefreshProvider?.('opencode')}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const buildProviderAwareCliEnvMock = vi.fn();
|
||||
const resolveBinaryMock = vi.fn();
|
||||
const execCliMock = vi.fn();
|
||||
|
|
@ -248,6 +249,51 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
|
|||
expect(JSON.stringify(execCliMock.mock.calls[0])).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('passes all-projects default scope to the runtime CLI', async () => {
|
||||
execCliMock.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
view: {
|
||||
runtimeId: 'opencode',
|
||||
title: 'OpenCode',
|
||||
runtime: {
|
||||
state: 'ready',
|
||||
cliPath: '/opt/homebrew/bin/opencode',
|
||||
version: '1.0.0',
|
||||
managedProfile: 'active',
|
||||
localAuth: 'synced',
|
||||
},
|
||||
providers: [],
|
||||
configuredModels: [],
|
||||
projectPath: '/Users/test/project',
|
||||
projectDefaultModel: null,
|
||||
allProjectsDefaultModel: 'openrouter/qwen/qwen3-coder',
|
||||
defaultModelSource: 'all_projects',
|
||||
defaultModel: 'openrouter/qwen/qwen3-coder',
|
||||
fallbackModel: null,
|
||||
diagnostics: [],
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
const client = new AgentTeamsRuntimeProviderManagementCliClient();
|
||||
await client.setDefaultModel({
|
||||
runtimeId: 'opencode',
|
||||
providerId: 'openrouter',
|
||||
modelId: 'openrouter/qwen/qwen3-coder',
|
||||
scope: 'all_projects',
|
||||
projectPath: '/Users/test/project',
|
||||
});
|
||||
|
||||
expect(execCliMock).toHaveBeenCalledWith(
|
||||
'/repo/cli-dev',
|
||||
expect.arrayContaining(['--scope', 'all-projects']),
|
||||
expect.objectContaining({ cwd: '/Users/test/project' })
|
||||
);
|
||||
});
|
||||
|
||||
it('loads provider setup forms through the CLI contract', async () => {
|
||||
execCliMock.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CliProviderStatus } from '@shared/types';
|
||||
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
||||
import type { CliProviderStatus } from '@shared/types';
|
||||
|
||||
interface StoreState {
|
||||
appConfig: {
|
||||
|
|
@ -1588,6 +1589,9 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
|||
expect(panel?.getAttribute('data-open')).toBe('true');
|
||||
expect(panel?.getAttribute('data-project-path')).toBe('/tmp/project-a');
|
||||
expect(host.textContent).toContain('Runtime provider management: opencode');
|
||||
expect(host.textContent).toContain('version 1.4.0 - live resolved-fin');
|
||||
expect(host.textContent).not.toContain('managed teammate agent');
|
||||
expect(host.textContent).not.toContain('behavior abc123');
|
||||
expect(host.textContent).not.toContain('Desktop currently exposes status only.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -156,6 +156,31 @@ describe('RuntimeProviderManagementPanelView', () => {
|
|||
expect(refreshButton?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the project as a compact operation context, not a selected global profile', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: createState(),
|
||||
actions: createActions(),
|
||||
disabled: false,
|
||||
projectPath: '/Users/belief/dev/projects/321',
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Project context: 321');
|
||||
expect(host.textContent).not.toContain('Managing selected project profile');
|
||||
expect(host.textContent).not.toContain('/Users/belief/dev/projects/321');
|
||||
expect(
|
||||
host.querySelector('[title="Current project context: /Users/belief/dev/projects/321"]')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders configured OpenCode model routes with local proof actions', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -188,6 +213,7 @@ describe('RuntimeProviderManagementPanelView', () => {
|
|||
}),
|
||||
actions,
|
||||
disabled: false,
|
||||
projectPath: '/tmp/project',
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
|
@ -196,7 +222,7 @@ describe('RuntimeProviderManagementPanelView', () => {
|
|||
const row = host.querySelector<HTMLElement>(
|
||||
'[data-testid="configured-opencode-model-row-llama.cpp/qwen-test:0.5b"]'
|
||||
);
|
||||
expect(host.textContent).toContain('Configured OpenCode models');
|
||||
expect(host.textContent).toContain('Launchable OpenCode models');
|
||||
expect(row?.textContent).toContain('local');
|
||||
expect(row?.textContent).toContain('configured');
|
||||
expect(row?.textContent).toContain('needs test');
|
||||
|
|
@ -207,25 +233,124 @@ describe('RuntimeProviderManagementPanelView', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
buttons.find((button) => button.textContent?.includes('Use for new teams'))?.click();
|
||||
buttons.find((button) => button.textContent?.includes('Use in team picker'))?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
buttons.find((button) => button.textContent?.includes('Set OpenCode default'))?.click();
|
||||
buttons.find((button) => button.textContent?.includes('Set project default'))?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(actions.testModel).toHaveBeenCalledWith(
|
||||
'llama.cpp',
|
||||
'llama.cpp/qwen-test:0.5b'
|
||||
);
|
||||
expect(actions.testModel).toHaveBeenCalledWith('llama.cpp', 'llama.cpp/qwen-test:0.5b');
|
||||
expect(actions.useModelForNewTeams).toHaveBeenCalledWith('llama.cpp/qwen-test:0.5b');
|
||||
expect(actions.setDefaultModel).toHaveBeenCalledWith(
|
||||
'llama.cpp',
|
||||
'llama.cpp/qwen-test:0.5b'
|
||||
'llama.cpp/qwen-test:0.5b',
|
||||
'project'
|
||||
);
|
||||
});
|
||||
|
||||
it('can set an all-projects OpenCode default from the model scope controls', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const actions = createActions();
|
||||
const configuredModel = {
|
||||
providerId: 'llama.cpp',
|
||||
modelId: 'llama.cpp/qwen-test:0.5b',
|
||||
displayName: 'qwen-test:0.5b',
|
||||
sourceLabel: 'llama.cpp',
|
||||
free: false,
|
||||
default: false,
|
||||
availability: 'available' as const,
|
||||
accessKind: 'verified' as const,
|
||||
routeKind: 'configured_local' as const,
|
||||
proofState: 'verified' as const,
|
||||
requiresExecutionProof: false,
|
||||
accessReason: null,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: createState({
|
||||
view: {
|
||||
...createState().view!,
|
||||
configuredModels: [configuredModel],
|
||||
},
|
||||
}),
|
||||
actions,
|
||||
disabled: false,
|
||||
projectPath: '/tmp/project-a',
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
Array.from(host.querySelectorAll('button'))
|
||||
.find((button) => button.textContent?.includes('All projects'))
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
Array.from(host.querySelectorAll('button'))
|
||||
.find((button) => button.textContent?.includes('Set all-projects default'))
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Used by project contexts without their own OpenCode default');
|
||||
expect(actions.setDefaultModel).toHaveBeenCalledWith(
|
||||
'llama.cpp',
|
||||
'llama.cpp/qwen-test:0.5b',
|
||||
'all_projects'
|
||||
);
|
||||
});
|
||||
|
||||
it('opens launchable routes first when they exist and keeps providers in a separate tab', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const baseState = createState();
|
||||
const configuredModel = {
|
||||
providerId: 'llama.cpp',
|
||||
modelId: 'llama.cpp/qwen-test:0.5b',
|
||||
displayName: 'qwen-test:0.5b',
|
||||
sourceLabel: 'llama.cpp',
|
||||
free: false,
|
||||
default: false,
|
||||
availability: 'untested' as const,
|
||||
accessKind: 'configured_authless' as const,
|
||||
routeKind: 'configured_local' as const,
|
||||
proofState: 'needs_probe' as const,
|
||||
requiresExecutionProof: true,
|
||||
accessReason: 'Execution proof required',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: createState({
|
||||
view: {
|
||||
...baseState.view!,
|
||||
configuredModels: [configuredModel],
|
||||
},
|
||||
providers: baseState.view?.providers ?? [],
|
||||
}),
|
||||
actions: createActions(),
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Launchable OpenCode models');
|
||||
expect(host.textContent).toContain('llama.cpp/qwen-test:0.5b');
|
||||
expect(host.textContent).toContain('Providers');
|
||||
expect(host.querySelector('[data-testid="runtime-provider-row-openrouter"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows unknown OpenCode defaults without enabling launch actions', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -1122,13 +1247,14 @@ describe('RuntimeProviderManagementPanelView', () => {
|
|||
state,
|
||||
actions,
|
||||
disabled: false,
|
||||
projectPath: '/tmp/project',
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('openrouter/openai/gpt-oss-20b:free');
|
||||
expect(host.textContent).toContain('Used for new teams');
|
||||
expect(host.textContent).toContain('Used in team picker');
|
||||
expect(host.textContent).toContain('Model probe passed');
|
||||
expect(host.textContent).toContain('Recommended');
|
||||
expect(host.textContent).toContain('Not recommended');
|
||||
|
|
@ -1139,7 +1265,7 @@ describe('RuntimeProviderManagementPanelView', () => {
|
|||
expect(host.textContent).not.toContain('Set OpenCode default');
|
||||
expect(
|
||||
Array.from(host.querySelectorAll('button')).some(
|
||||
(button) => button.textContent?.trim() === 'Use for new teams'
|
||||
(button) => button.textContent?.trim() === 'Use in team picker'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import type {
|
|||
RuntimeProviderDirectoryEntryDto,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementViewDto,
|
||||
RuntimeProviderManagementViewResponse,
|
||||
} from '../../../../src/features/runtime-provider-management/contracts';
|
||||
import type { ElectronAPI } from '../../../../src/shared/types/api';
|
||||
|
||||
|
|
@ -173,6 +174,245 @@ describe('useRuntimeProviderManagement', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('ignores stale provider views after project context changes', async () => {
|
||||
let resolveProjectA:
|
||||
| ((response: {
|
||||
schemaVersion: 1;
|
||||
runtimeId: 'opencode';
|
||||
view: RuntimeProviderManagementViewDto;
|
||||
}) => void)
|
||||
| null = null;
|
||||
const projectAResponse = new Promise<{
|
||||
schemaVersion: 1;
|
||||
runtimeId: 'opencode';
|
||||
view: RuntimeProviderManagementViewDto;
|
||||
}>((resolve) => {
|
||||
resolveProjectA = resolve;
|
||||
});
|
||||
const loadView = vi.fn((input: { projectPath?: string | null }) => {
|
||||
if (input.projectPath === '/tmp/project-a') {
|
||||
return projectAResponse;
|
||||
}
|
||||
return Promise.resolve({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
view: {
|
||||
...createRuntimeView(),
|
||||
projectPath: '/tmp/project-b',
|
||||
defaultModel: 'opencode/project-b',
|
||||
},
|
||||
});
|
||||
});
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtimeProviderManagement: {
|
||||
loadView,
|
||||
},
|
||||
} as unknown as ElectronAPI,
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-b' }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(state?.view?.projectPath).toBe('/tmp/project-b');
|
||||
|
||||
await act(async () => {
|
||||
resolveProjectA?.({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
view: {
|
||||
...createRuntimeView(),
|
||||
projectPath: '/tmp/project-a',
|
||||
defaultModel: 'opencode/project-a',
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(state?.view?.projectPath).toBe('/tmp/project-b');
|
||||
expect(state?.view?.defaultModel).toBe('opencode/project-b');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('drops stale model probe results after project context changes', async () => {
|
||||
const modelId = 'llama.cpp/qwen-test:0.5b';
|
||||
let resolveProbe: ((value: RuntimeProviderManagementModelTestResponse) => void) | null = null;
|
||||
const loadView = vi.fn((input: { projectPath?: string | null }) =>
|
||||
Promise.resolve({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
view: {
|
||||
...createRuntimeView(),
|
||||
projectPath: input.projectPath ?? null,
|
||||
defaultModel: input.projectPath === '/tmp/project-b' ? 'opencode/project-b' : null,
|
||||
},
|
||||
})
|
||||
);
|
||||
const testModel = vi.fn(
|
||||
() =>
|
||||
new Promise<RuntimeProviderManagementModelTestResponse>((resolve) => {
|
||||
resolveProbe = resolve;
|
||||
})
|
||||
);
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtimeProviderManagement: {
|
||||
loadView,
|
||||
testModel,
|
||||
},
|
||||
} as unknown as ElectronAPI,
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
let probe: Promise<void> | null = null;
|
||||
await act(async () => {
|
||||
probe = actions?.testModel('llama.cpp', modelId) ?? null;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(testModel).toHaveBeenCalledWith({
|
||||
runtimeId: 'opencode',
|
||||
providerId: 'llama.cpp',
|
||||
modelId,
|
||||
projectPath: '/tmp/project-a',
|
||||
});
|
||||
expect(state?.testingModelIds).toEqual([modelId]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-b' }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(state?.view?.projectPath).toBe('/tmp/project-b');
|
||||
expect(state?.testingModelIds).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
resolveProbe?.({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
result: {
|
||||
providerId: 'llama.cpp',
|
||||
modelId,
|
||||
ok: true,
|
||||
availability: 'available',
|
||||
message: 'Stale project A probe passed',
|
||||
diagnostics: [],
|
||||
},
|
||||
});
|
||||
await probe;
|
||||
});
|
||||
|
||||
expect(state?.view?.projectPath).toBe('/tmp/project-b');
|
||||
expect(state?.modelResults[modelId]).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('drops stale set-default responses after project context changes', async () => {
|
||||
const projectAModelId = 'llama.cpp/project-a:0.5b';
|
||||
let resolveSetDefault: ((value: RuntimeProviderManagementViewResponse) => void) | null = null;
|
||||
const loadView = vi.fn((input: { projectPath?: string | null }) =>
|
||||
Promise.resolve({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
view: {
|
||||
...createRuntimeView(),
|
||||
projectPath: input.projectPath ?? null,
|
||||
defaultModel: input.projectPath === '/tmp/project-b' ? 'opencode/project-b' : null,
|
||||
},
|
||||
})
|
||||
);
|
||||
const setDefaultModel = vi.fn(
|
||||
() =>
|
||||
new Promise<RuntimeProviderManagementViewResponse>((resolve) => {
|
||||
resolveSetDefault = resolve;
|
||||
})
|
||||
);
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtimeProviderManagement: {
|
||||
loadView,
|
||||
setDefaultModel,
|
||||
},
|
||||
} as unknown as ElectronAPI,
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
let setDefault: Promise<void> | null = null;
|
||||
await act(async () => {
|
||||
setDefault = actions?.setDefaultModel('llama.cpp', projectAModelId, 'project') ?? null;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(setDefaultModel).toHaveBeenCalledWith({
|
||||
runtimeId: 'opencode',
|
||||
providerId: 'llama.cpp',
|
||||
modelId: projectAModelId,
|
||||
probe: true,
|
||||
scope: 'project',
|
||||
projectPath: '/tmp/project-a',
|
||||
});
|
||||
expect(state?.savingDefaultModelId).toBe(projectAModelId);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-b' }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(state?.view?.projectPath).toBe('/tmp/project-b');
|
||||
expect(state?.savingDefaultModelId).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveSetDefault?.({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
view: {
|
||||
...createRuntimeView(),
|
||||
projectPath: '/tmp/project-a',
|
||||
defaultModel: projectAModelId,
|
||||
},
|
||||
});
|
||||
await setDefault;
|
||||
});
|
||||
|
||||
expect(state?.view?.projectPath).toBe('/tmp/project-b');
|
||||
expect(state?.view?.defaultModel).toBe('opencode/project-b');
|
||||
expect(state?.selectedModelId).toBeNull();
|
||||
expect(state?.successMessage).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes view and catalog after forgetting managed auth while local auth remains', async () => {
|
||||
const localProvider = createOpenAiLocalProvider();
|
||||
const loadView = vi.fn(() =>
|
||||
|
|
@ -1279,6 +1519,7 @@ describe('useRuntimeProviderManagement', () => {
|
|||
|
||||
await act(async () => {
|
||||
await actions?.setDefaultModel('llama.cpp', modelId);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(setDefaultModel).toHaveBeenCalledWith({
|
||||
|
|
@ -1286,6 +1527,7 @@ describe('useRuntimeProviderManagement', () => {
|
|||
providerId: 'llama.cpp',
|
||||
modelId,
|
||||
probe: true,
|
||||
scope: 'project',
|
||||
projectPath: null,
|
||||
});
|
||||
expect(state?.view?.configuredModels?.[0]).toMatchObject({
|
||||
|
|
@ -1302,4 +1544,89 @@ describe('useRuntimeProviderManagement', () => {
|
|||
message: 'Model probe passed',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the effective project default selected when an all-projects default is shadowed', async () => {
|
||||
const allProjectsModelId = 'llama.cpp/qwen-test:0.5b';
|
||||
const projectModelId = 'llama.cpp/project-test:1b';
|
||||
const setDefaultModel = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
view: {
|
||||
...createRuntimeView(),
|
||||
defaultModel: projectModelId,
|
||||
projectDefaultModel: projectModelId,
|
||||
allProjectsDefaultModel: allProjectsModelId,
|
||||
defaultModelSource: 'project',
|
||||
configuredModels: [
|
||||
{
|
||||
providerId: 'llama.cpp',
|
||||
modelId: allProjectsModelId,
|
||||
displayName: 'qwen-test:0.5b',
|
||||
sourceLabel: 'llama.cpp',
|
||||
free: false,
|
||||
default: false,
|
||||
availability: 'untested',
|
||||
accessKind: 'configured_authless',
|
||||
routeKind: 'configured_local',
|
||||
proofState: 'needs_probe',
|
||||
requiresExecutionProof: true,
|
||||
accessReason: 'Execution proof required',
|
||||
},
|
||||
{
|
||||
providerId: 'llama.cpp',
|
||||
modelId: projectModelId,
|
||||
displayName: 'project-test:1b',
|
||||
sourceLabel: 'llama.cpp',
|
||||
free: false,
|
||||
default: true,
|
||||
availability: 'available',
|
||||
accessKind: 'verified',
|
||||
routeKind: 'configured_local',
|
||||
proofState: 'verified',
|
||||
requiresExecutionProof: false,
|
||||
accessReason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtimeProviderManagement: {
|
||||
setDefaultModel,
|
||||
},
|
||||
} as unknown as ElectronAPI,
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await actions?.setDefaultModel('llama.cpp', allProjectsModelId, 'all_projects');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(state?.selectedModelId).toBe(projectModelId);
|
||||
expect(state?.view?.defaultModel).toBe(projectModelId);
|
||||
expect(state?.view?.defaultModelSource).toBe('project');
|
||||
expect(
|
||||
state?.view?.configuredModels?.find((model) => model.modelId === allProjectsModelId)
|
||||
).toMatchObject({
|
||||
default: false,
|
||||
availability: 'available',
|
||||
accessKind: 'verified',
|
||||
proofState: 'verified',
|
||||
});
|
||||
expect(
|
||||
state?.view?.configuredModels?.find((model) => model.modelId === projectModelId)
|
||||
).toMatchObject({
|
||||
default: true,
|
||||
accessKind: 'verified',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue