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;
|
runtime: RuntimeProviderManagementRuntimeDto;
|
||||||
providers: readonly RuntimeProviderConnectionDto[];
|
providers: readonly RuntimeProviderConnectionDto[];
|
||||||
configuredModels?: readonly RuntimeProviderModelDto[];
|
configuredModels?: readonly RuntimeProviderModelDto[];
|
||||||
|
projectPath?: string | null;
|
||||||
|
projectDefaultModel?: string | null;
|
||||||
|
allProjectsDefaultModel?: string | null;
|
||||||
|
defaultModelSource?: RuntimeProviderDefaultModelSourceDto | null;
|
||||||
defaultModel: string | null;
|
defaultModel: string | null;
|
||||||
fallbackModel: string | null;
|
fallbackModel: string | null;
|
||||||
diagnostics: readonly string[];
|
diagnostics: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RuntimeProviderDefaultModelSourceDto =
|
||||||
|
| 'project'
|
||||||
|
| 'all_projects'
|
||||||
|
| 'opencode_config'
|
||||||
|
| 'fallback';
|
||||||
|
|
||||||
|
export type RuntimeProviderDefaultScopeDto = 'project' | 'all_projects';
|
||||||
|
|
||||||
export type RuntimeProviderManagementErrorCodeDto =
|
export type RuntimeProviderManagementErrorCodeDto =
|
||||||
| 'unsupported-runtime'
|
| 'unsupported-runtime'
|
||||||
| 'unsupported-action'
|
| 'unsupported-action'
|
||||||
|
|
@ -361,5 +373,6 @@ export interface RuntimeProviderManagementSetDefaultModelInput {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
modelId: string;
|
modelId: string;
|
||||||
probe?: boolean;
|
probe?: boolean;
|
||||||
|
scope?: RuntimeProviderDefaultScopeDto;
|
||||||
projectPath?: string | null;
|
projectPath?: string | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -638,6 +638,8 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
|
||||||
input.providerId,
|
input.providerId,
|
||||||
'--model',
|
'--model',
|
||||||
input.modelId,
|
input.modelId,
|
||||||
|
'--scope',
|
||||||
|
input.scope === 'all_projects' ? 'all-projects' : 'project',
|
||||||
'--probe',
|
'--probe',
|
||||||
'--compact',
|
'--compact',
|
||||||
'--json',
|
'--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 { useRuntimeProviderManagement } from './hooks/useRuntimeProviderManagement';
|
||||||
import { RuntimeProviderManagementPanelView } from './ui/RuntimeProviderManagementPanelView';
|
import { RuntimeProviderManagementPanelView } from './ui/RuntimeProviderManagementPanelView';
|
||||||
|
|
||||||
import type { RuntimeProviderManagementRuntimeId } from '@features/runtime-provider-management/contracts';
|
import type { RuntimeProviderManagementRuntimeId } from '@features/runtime-provider-management/contracts';
|
||||||
import type { JSX } from 'react';
|
|
||||||
|
|
||||||
interface RuntimeProviderManagementPanelProps {
|
interface RuntimeProviderManagementPanelProps {
|
||||||
readonly runtimeId: RuntimeProviderManagementRuntimeId;
|
readonly runtimeId: RuntimeProviderManagementRuntimeId;
|
||||||
readonly open: boolean;
|
readonly open: boolean;
|
||||||
readonly projectPath?: string | null;
|
readonly projectPath?: string | null;
|
||||||
|
readonly initialProviderId?: string | null;
|
||||||
|
readonly initialProviderAction?: 'connect' | 'select' | null;
|
||||||
readonly disabled?: boolean;
|
readonly disabled?: boolean;
|
||||||
readonly onProviderChanged?: () => Promise<void> | void;
|
readonly onProviderChanged?: () => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RuntimeProviderManagementPanel({
|
export const RuntimeProviderManagementPanel = ({
|
||||||
runtimeId,
|
runtimeId,
|
||||||
open,
|
open,
|
||||||
projectPath = null,
|
projectPath = null,
|
||||||
|
initialProviderId = null,
|
||||||
|
initialProviderAction = null,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onProviderChanged,
|
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({
|
const [state, actions] = useRuntimeProviderManagement({
|
||||||
runtimeId,
|
runtimeId,
|
||||||
enabled: open,
|
enabled: open,
|
||||||
projectPath,
|
projectPath: activeProjectPath,
|
||||||
|
initialProviderId,
|
||||||
|
initialProviderAction,
|
||||||
onProviderChanged,
|
onProviderChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -31,7 +90,11 @@ export function RuntimeProviderManagementPanel({
|
||||||
state={state}
|
state={state}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
projectPath={projectPath}
|
projectPath={activeProjectPath}
|
||||||
|
projectContextProjects={projectContextProjects}
|
||||||
|
projectContextLoading={projectContextLoading}
|
||||||
|
projectContextError={projectContextError}
|
||||||
|
onProjectContextChange={setActiveProjectPath}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
RuntimeProviderConnectionDto,
|
RuntimeProviderConnectionDto,
|
||||||
|
RuntimeProviderDefaultScopeDto,
|
||||||
RuntimeProviderDirectoryEntryDto,
|
RuntimeProviderDirectoryEntryDto,
|
||||||
RuntimeProviderDirectoryFilterDto,
|
RuntimeProviderDirectoryFilterDto,
|
||||||
RuntimeProviderManagementRuntimeId,
|
RuntimeProviderManagementRuntimeId,
|
||||||
|
|
@ -23,6 +24,8 @@ interface UseRuntimeProviderManagementOptions {
|
||||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
projectPath?: string | null;
|
projectPath?: string | null;
|
||||||
|
initialProviderId?: string | null;
|
||||||
|
initialProviderAction?: 'connect' | 'select' | null;
|
||||||
onProviderChanged?: () => Promise<void> | void;
|
onProviderChanged?: () => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,6 +33,11 @@ export type RuntimeProviderModelPickerMode = 'use' | 'runtime-default';
|
||||||
|
|
||||||
const DEFAULT_DIRECTORY_FILTER: RuntimeProviderDirectoryFilterDto = 'all';
|
const DEFAULT_DIRECTORY_FILTER: RuntimeProviderDirectoryFilterDto = 'all';
|
||||||
|
|
||||||
|
interface ProjectContextSnapshot {
|
||||||
|
path: string | null;
|
||||||
|
generation: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RuntimeProviderManagementState {
|
export interface RuntimeProviderManagementState {
|
||||||
view: RuntimeProviderManagementViewDto | null;
|
view: RuntimeProviderManagementViewDto | null;
|
||||||
providers: readonly RuntimeProviderConnectionDto[];
|
providers: readonly RuntimeProviderConnectionDto[];
|
||||||
|
|
@ -87,7 +95,11 @@ export interface RuntimeProviderManagementActions {
|
||||||
selectModel: (modelId: string) => void;
|
selectModel: (modelId: string) => void;
|
||||||
useModelForNewTeams: (modelId: string) => void;
|
useModelForNewTeams: (modelId: string) => void;
|
||||||
testModel: (providerId: string, modelId: string) => Promise<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(
|
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(
|
function buildFailedModelTestResult(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
|
|
@ -239,11 +255,35 @@ export function useRuntimeProviderManagement(
|
||||||
const [savingProviderId, setSavingProviderId] = useState<string | null>(null);
|
const [savingProviderId, setSavingProviderId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
const viewLoadRequestSeq = useRef(0);
|
||||||
const directoryRequestSeq = useRef(0);
|
const directoryRequestSeq = useRef(0);
|
||||||
const setupFormRequestSeq = useRef(0);
|
const setupFormRequestSeq = useRef(0);
|
||||||
const modelLoadRequestSeq = useRef(0);
|
const modelLoadRequestSeq = useRef(0);
|
||||||
const modelProbeGenerationRef = useRef(0);
|
const modelProbeGenerationRef = useRef(0);
|
||||||
const activeModelPickerProviderRef = useRef<string | null>(null);
|
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(
|
const openModelPickerState = useCallback(
|
||||||
(providerId: string, mode: RuntimeProviderModelPickerMode): void => {
|
(providerId: string, mode: RuntimeProviderModelPickerMode): void => {
|
||||||
|
|
@ -278,11 +318,44 @@ export function useRuntimeProviderManagement(
|
||||||
setTestingModelIds([]);
|
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(
|
const refresh = useCallback(
|
||||||
async (input: { silent?: boolean } = {}): Promise<void> => {
|
async (input: { silent?: boolean } = {}): Promise<void> => {
|
||||||
if (!options.enabled) {
|
if (!options.enabled) {
|
||||||
return;
|
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;
|
const silent = input.silent === true;
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -291,8 +364,11 @@ export function useRuntimeProviderManagement(
|
||||||
try {
|
try {
|
||||||
const response = await api.runtimeProviderManagement.loadView({
|
const response = await api.runtimeProviderManagement.loadView({
|
||||||
runtimeId: options.runtimeId,
|
runtimeId: options.runtimeId,
|
||||||
projectPath: options.projectPath ?? null,
|
projectPath: projectContext.path,
|
||||||
});
|
});
|
||||||
|
if (!requestIsCurrent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setView(null);
|
setView(null);
|
||||||
|
|
@ -309,17 +385,20 @@ export function useRuntimeProviderManagement(
|
||||||
return selectInitialProviderId(nextView);
|
return selectInitialProviderId(nextView);
|
||||||
});
|
});
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
|
if (!requestIsCurrent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setView(null);
|
setView(null);
|
||||||
}
|
}
|
||||||
setError(loadError instanceof Error ? loadError.message : 'Failed to load providers');
|
setError(loadError instanceof Error ? loadError.message : 'Failed to load providers');
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) {
|
if (!silent && requestIsCurrent()) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[options.enabled, options.projectPath, options.runtimeId]
|
[getProjectContextSnapshot, isProjectContextCurrent, options.enabled, options.runtimeId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadDirectoryPage = useCallback(
|
const loadDirectoryPage = useCallback(
|
||||||
|
|
@ -341,8 +420,11 @@ export function useRuntimeProviderManagement(
|
||||||
const query = input.query ?? directoryQuery;
|
const query = input.query ?? directoryQuery;
|
||||||
const filter = input.filter ?? DEFAULT_DIRECTORY_FILTER;
|
const filter = input.filter ?? DEFAULT_DIRECTORY_FILTER;
|
||||||
const cursor = input.cursor ?? null;
|
const cursor = input.cursor ?? null;
|
||||||
|
const projectContext = getProjectContextSnapshot();
|
||||||
const requestSeq = directoryRequestSeq.current + 1;
|
const requestSeq = directoryRequestSeq.current + 1;
|
||||||
directoryRequestSeq.current = requestSeq;
|
directoryRequestSeq.current = requestSeq;
|
||||||
|
const requestIsCurrent = (): boolean =>
|
||||||
|
directoryRequestSeq.current === requestSeq && isProjectContextCurrent(projectContext);
|
||||||
|
|
||||||
if (append) {
|
if (append) {
|
||||||
setDirectoryRefreshing(true);
|
setDirectoryRefreshing(true);
|
||||||
|
|
@ -356,14 +438,14 @@ export function useRuntimeProviderManagement(
|
||||||
try {
|
try {
|
||||||
const response = await api.runtimeProviderManagement.loadProviderDirectory({
|
const response = await api.runtimeProviderManagement.loadProviderDirectory({
|
||||||
runtimeId: options.runtimeId,
|
runtimeId: options.runtimeId,
|
||||||
projectPath: options.projectPath ?? null,
|
projectPath: projectContext.path,
|
||||||
query: query.trim() || null,
|
query: query.trim() || null,
|
||||||
filter,
|
filter,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
cursor,
|
cursor,
|
||||||
refresh: refreshDirectoryData,
|
refresh: refreshDirectoryData,
|
||||||
});
|
});
|
||||||
if (directoryRequestSeq.current !== requestSeq) {
|
if (!requestIsCurrent()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
|
|
@ -388,23 +470,32 @@ export function useRuntimeProviderManagement(
|
||||||
append ? [...current, ...directory.entries] : directory.entries
|
append ? [...current, ...directory.entries] : directory.entries
|
||||||
);
|
);
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (directoryRequestSeq.current === requestSeq) {
|
if (requestIsCurrent()) {
|
||||||
setDirectoryError(
|
setDirectoryError(
|
||||||
loadError instanceof Error ? loadError.message : 'Failed to load provider directory'
|
loadError instanceof Error ? loadError.message : 'Failed to load provider directory'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (directoryRequestSeq.current === requestSeq) {
|
if (requestIsCurrent()) {
|
||||||
setDirectoryLoading(false);
|
setDirectoryLoading(false);
|
||||||
setDirectoryRefreshing(false);
|
setDirectoryRefreshing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[directoryQuery, directorySupported, options.enabled, options.projectPath, options.runtimeId]
|
[
|
||||||
|
directoryQuery,
|
||||||
|
directorySupported,
|
||||||
|
getProjectContextSnapshot,
|
||||||
|
isProjectContextCurrent,
|
||||||
|
options.enabled,
|
||||||
|
options.runtimeId,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!options.enabled) {
|
if (!options.enabled) {
|
||||||
|
viewLoadRequestSeq.current += 1;
|
||||||
|
appliedInitialProviderRef.current = null;
|
||||||
setProviderQuery('');
|
setProviderQuery('');
|
||||||
setDirectoryLoading(false);
|
setDirectoryLoading(false);
|
||||||
setDirectoryRefreshing(false);
|
setDirectoryRefreshing(false);
|
||||||
|
|
@ -426,7 +517,7 @@ export function useRuntimeProviderManagement(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [closeModelPickerState, options.enabled, refresh]);
|
}, [closeModelPickerState, currentProjectPath, options.enabled, refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!options.enabled || !directorySupported) {
|
if (!options.enabled || !directorySupported) {
|
||||||
|
|
@ -458,9 +549,11 @@ export function useRuntimeProviderManagement(
|
||||||
const requestSeq = modelLoadRequestSeq.current + 1;
|
const requestSeq = modelLoadRequestSeq.current + 1;
|
||||||
modelLoadRequestSeq.current = requestSeq;
|
modelLoadRequestSeq.current = requestSeq;
|
||||||
const providerId = modelPickerProviderId;
|
const providerId = modelPickerProviderId;
|
||||||
|
const projectContext = getProjectContextSnapshot();
|
||||||
const requestIsCurrent = (): boolean =>
|
const requestIsCurrent = (): boolean =>
|
||||||
modelLoadRequestSeq.current === requestSeq &&
|
modelLoadRequestSeq.current === requestSeq &&
|
||||||
activeModelPickerProviderRef.current === providerId;
|
activeModelPickerProviderRef.current === providerId &&
|
||||||
|
isProjectContextCurrent(projectContext);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setModelsLoading(true);
|
setModelsLoading(true);
|
||||||
setModelsError(null);
|
setModelsError(null);
|
||||||
|
|
@ -468,7 +561,7 @@ export function useRuntimeProviderManagement(
|
||||||
api.runtimeProviderManagement.loadModels({
|
api.runtimeProviderManagement.loadModels({
|
||||||
runtimeId: options.runtimeId,
|
runtimeId: options.runtimeId,
|
||||||
providerId,
|
providerId,
|
||||||
projectPath: options.projectPath ?? null,
|
projectPath: projectContext.path,
|
||||||
query: modelQuery.trim() || null,
|
query: modelQuery.trim() || null,
|
||||||
limit: 250,
|
limit: 250,
|
||||||
}),
|
}),
|
||||||
|
|
@ -511,7 +604,14 @@ export function useRuntimeProviderManagement(
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [modelPickerProviderId, modelQuery, options.enabled, options.projectPath, options.runtimeId]);
|
}, [
|
||||||
|
getProjectContextSnapshot,
|
||||||
|
isProjectContextCurrent,
|
||||||
|
modelPickerProviderId,
|
||||||
|
modelQuery,
|
||||||
|
options.enabled,
|
||||||
|
options.runtimeId,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!options.enabled || activeFormProviderId) {
|
if (!options.enabled || activeFormProviderId) {
|
||||||
|
|
@ -620,19 +720,22 @@ export function useRuntimeProviderManagement(
|
||||||
setSetupFormLoading(true);
|
setSetupFormLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccessMessage(null);
|
setSuccessMessage(null);
|
||||||
|
const projectContext = getProjectContextSnapshot();
|
||||||
const requestSeq = setupFormRequestSeq.current + 1;
|
const requestSeq = setupFormRequestSeq.current + 1;
|
||||||
setupFormRequestSeq.current = requestSeq;
|
setupFormRequestSeq.current = requestSeq;
|
||||||
|
const requestIsCurrent = (): boolean =>
|
||||||
|
setupFormRequestSeq.current === requestSeq && isProjectContextCurrent(projectContext);
|
||||||
|
|
||||||
void withUiTimeout(
|
void withUiTimeout(
|
||||||
api.runtimeProviderManagement.loadSetupForm({
|
api.runtimeProviderManagement.loadSetupForm({
|
||||||
runtimeId: options.runtimeId,
|
runtimeId: options.runtimeId,
|
||||||
providerId,
|
providerId,
|
||||||
projectPath: options.projectPath ?? null,
|
projectPath: projectContext.path,
|
||||||
}),
|
}),
|
||||||
'Provider setup form load timed out'
|
'Provider setup form load timed out'
|
||||||
)
|
)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (setupFormRequestSeq.current !== requestSeq) {
|
if (!requestIsCurrent()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
|
|
@ -645,7 +748,7 @@ export function useRuntimeProviderManagement(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((setupError) => {
|
.catch((setupError) => {
|
||||||
if (setupFormRequestSeq.current !== requestSeq) {
|
if (!requestIsCurrent()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSetupFormError(
|
setSetupFormError(
|
||||||
|
|
@ -653,12 +756,12 @@ export function useRuntimeProviderManagement(
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (setupFormRequestSeq.current === requestSeq) {
|
if (requestIsCurrent()) {
|
||||||
setSetupFormLoading(false);
|
setSetupFormLoading(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[closeModelPickerState, options.projectPath, options.runtimeId]
|
[closeModelPickerState, getProjectContextSnapshot, isProjectContextCurrent, options.runtimeId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateProviderQuery = useCallback(
|
const updateProviderQuery = useCallback(
|
||||||
|
|
@ -720,6 +823,7 @@ export function useRuntimeProviderManagement(
|
||||||
setError(null);
|
setError(null);
|
||||||
setSetupSubmitError(null);
|
setSetupSubmitError(null);
|
||||||
setSuccessMessage(null);
|
setSuccessMessage(null);
|
||||||
|
const projectContext = getProjectContextSnapshot();
|
||||||
try {
|
try {
|
||||||
const response = await withUiTimeout(
|
const response = await withUiTimeout(
|
||||||
api.runtimeProviderManagement.connectProvider({
|
api.runtimeProviderManagement.connectProvider({
|
||||||
|
|
@ -728,10 +832,13 @@ export function useRuntimeProviderManagement(
|
||||||
method: setupForm.method,
|
method: setupForm.method,
|
||||||
apiKey: apiKey || null,
|
apiKey: apiKey || null,
|
||||||
metadata: setupMetadata,
|
metadata: setupMetadata,
|
||||||
projectPath: options.projectPath ?? null,
|
projectPath: projectContext.path,
|
||||||
}),
|
}),
|
||||||
'Provider connect timed out'
|
'Provider connect timed out'
|
||||||
);
|
);
|
||||||
|
if (!isProjectContextCurrent(projectContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setSetupSubmitError(response.error.message);
|
setSetupSubmitError(response.error.message);
|
||||||
return;
|
return;
|
||||||
|
|
@ -748,24 +855,45 @@ export function useRuntimeProviderManagement(
|
||||||
setSetupSubmitError(null);
|
setSetupSubmitError(null);
|
||||||
try {
|
try {
|
||||||
await options.onProviderChanged?.();
|
await options.onProviderChanged?.();
|
||||||
|
if (!isProjectContextCurrent(projectContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
refresh({ silent: true }),
|
refresh({ silent: true }),
|
||||||
loadDirectoryPage({ refresh: true, cursor: null }),
|
loadDirectoryPage({ refresh: true, cursor: null }),
|
||||||
]);
|
]);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
|
if (!isProjectContextCurrent(projectContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(
|
setError(
|
||||||
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
|
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (connectError) {
|
} catch (connectError) {
|
||||||
|
if (!isProjectContextCurrent(projectContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSetupSubmitError(
|
setSetupSubmitError(
|
||||||
connectError instanceof Error ? connectError.message : 'Failed to connect provider'
|
connectError instanceof Error ? connectError.message : 'Failed to connect provider'
|
||||||
);
|
);
|
||||||
} finally {
|
} 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(
|
const forgetProvider = useCallback(
|
||||||
|
|
@ -773,15 +901,19 @@ export function useRuntimeProviderManagement(
|
||||||
setSavingProviderId(providerId);
|
setSavingProviderId(providerId);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccessMessage(null);
|
setSuccessMessage(null);
|
||||||
|
const projectContext = getProjectContextSnapshot();
|
||||||
try {
|
try {
|
||||||
const response = await withUiTimeout(
|
const response = await withUiTimeout(
|
||||||
api.runtimeProviderManagement.forgetCredential({
|
api.runtimeProviderManagement.forgetCredential({
|
||||||
runtimeId: options.runtimeId,
|
runtimeId: options.runtimeId,
|
||||||
providerId,
|
providerId,
|
||||||
projectPath: options.projectPath ?? null,
|
projectPath: projectContext.path,
|
||||||
}),
|
}),
|
||||||
'Provider forget timed out'
|
'Provider forget timed out'
|
||||||
);
|
);
|
||||||
|
if (!isProjectContextCurrent(projectContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.error.message);
|
setError(response.error.message);
|
||||||
return;
|
return;
|
||||||
|
|
@ -792,25 +924,39 @@ export function useRuntimeProviderManagement(
|
||||||
const success = formatCredentialRemovedMessage(response.provider ?? null);
|
const success = formatCredentialRemovedMessage(response.provider ?? null);
|
||||||
try {
|
try {
|
||||||
await options.onProviderChanged?.();
|
await options.onProviderChanged?.();
|
||||||
|
if (!isProjectContextCurrent(projectContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
refresh({ silent: true }),
|
refresh({ silent: true }),
|
||||||
loadDirectoryPage({ refresh: true, cursor: null }),
|
loadDirectoryPage({ refresh: true, cursor: null }),
|
||||||
]);
|
]);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
|
if (!isProjectContextCurrent(projectContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(
|
setError(
|
||||||
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
|
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!isProjectContextCurrent(projectContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSuccessMessage(success);
|
setSuccessMessage(success);
|
||||||
} catch (forgetError) {
|
} catch (forgetError) {
|
||||||
|
if (!isProjectContextCurrent(projectContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(
|
setError(
|
||||||
forgetError instanceof Error ? forgetError.message : 'Failed to forget credential'
|
forgetError instanceof Error ? forgetError.message : 'Failed to forget credential'
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingProviderId(null);
|
if (isProjectContextCurrent(projectContext)) {
|
||||||
|
setSavingProviderId(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadDirectoryPage, options, refresh]
|
[getProjectContextSnapshot, isProjectContextCurrent, loadDirectoryPage, options, refresh]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openModelPicker = useCallback(
|
const openModelPicker = useCallback(
|
||||||
|
|
@ -839,9 +985,11 @@ export function useRuntimeProviderManagement(
|
||||||
async (providerId: string, modelId: string): Promise<void> => {
|
async (providerId: string, modelId: string): Promise<void> => {
|
||||||
const probeGeneration = modelProbeGenerationRef.current;
|
const probeGeneration = modelProbeGenerationRef.current;
|
||||||
const activeProviderAtStart = activeModelPickerProviderRef.current;
|
const activeProviderAtStart = activeModelPickerProviderRef.current;
|
||||||
|
const projectContext = getProjectContextSnapshot();
|
||||||
const shouldRecordProbeResult = (): boolean =>
|
const shouldRecordProbeResult = (): boolean =>
|
||||||
modelProbeGenerationRef.current === probeGeneration &&
|
modelProbeGenerationRef.current === probeGeneration &&
|
||||||
(activeProviderAtStart === null || activeModelPickerProviderRef.current === providerId);
|
(activeProviderAtStart === null || activeModelPickerProviderRef.current === providerId) &&
|
||||||
|
isProjectContextCurrent(projectContext);
|
||||||
setTestingModelIds((current) =>
|
setTestingModelIds((current) =>
|
||||||
current.includes(modelId) ? current : [...current, modelId]
|
current.includes(modelId) ? current : [...current, modelId]
|
||||||
);
|
);
|
||||||
|
|
@ -853,7 +1001,7 @@ export function useRuntimeProviderManagement(
|
||||||
runtimeId: options.runtimeId,
|
runtimeId: options.runtimeId,
|
||||||
providerId,
|
providerId,
|
||||||
modelId,
|
modelId,
|
||||||
projectPath: options.projectPath ?? null,
|
projectPath: projectContext.path,
|
||||||
}),
|
}),
|
||||||
'Model test timed out',
|
'Model test timed out',
|
||||||
100_000
|
100_000
|
||||||
|
|
@ -900,17 +1048,24 @@ export function useRuntimeProviderManagement(
|
||||||
setView((current) => applyModelTestResultToView(current, result));
|
setView((current) => applyModelTestResultToView(current, result));
|
||||||
}
|
}
|
||||||
} finally {
|
} 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(
|
const setDefaultModel = useCallback(
|
||||||
async (providerId: string, modelId: string): Promise<void> => {
|
async (
|
||||||
|
providerId: string,
|
||||||
|
modelId: string,
|
||||||
|
scope: RuntimeProviderDefaultScopeDto = 'project'
|
||||||
|
): Promise<void> => {
|
||||||
setSavingDefaultModelId(modelId);
|
setSavingDefaultModelId(modelId);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccessMessage(null);
|
setSuccessMessage(null);
|
||||||
|
const projectContext = getProjectContextSnapshot();
|
||||||
try {
|
try {
|
||||||
const response = await withUiTimeout(
|
const response = await withUiTimeout(
|
||||||
api.runtimeProviderManagement.setDefaultModel({
|
api.runtimeProviderManagement.setDefaultModel({
|
||||||
|
|
@ -918,11 +1073,15 @@ export function useRuntimeProviderManagement(
|
||||||
providerId,
|
providerId,
|
||||||
modelId,
|
modelId,
|
||||||
probe: true,
|
probe: true,
|
||||||
projectPath: options.projectPath ?? null,
|
scope,
|
||||||
|
projectPath: projectContext.path,
|
||||||
}),
|
}),
|
||||||
'Set default model timed out',
|
'Set default model timed out',
|
||||||
100_000
|
100_000
|
||||||
);
|
);
|
||||||
|
if (!isProjectContextCurrent(projectContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.error.message);
|
setError(response.error.message);
|
||||||
return;
|
return;
|
||||||
|
|
@ -938,33 +1097,46 @@ export function useRuntimeProviderManagement(
|
||||||
if (response.view) {
|
if (response.view) {
|
||||||
setView(applyModelTestResultToView(response.view, proofResult));
|
setView(applyModelTestResultToView(response.view, proofResult));
|
||||||
}
|
}
|
||||||
|
const effectiveDefaultModelId = response.view?.defaultModel ?? modelId;
|
||||||
setModelResults((current) => ({
|
setModelResults((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[modelId]: proofResult,
|
[modelId]: proofResult,
|
||||||
}));
|
}));
|
||||||
setSelectedModelId(modelId);
|
setSelectedModelId(effectiveDefaultModelId);
|
||||||
setModels((current) =>
|
setModels((current) =>
|
||||||
current.map((model) =>
|
current.map((model) =>
|
||||||
applyModelTestResultToModel(
|
applyModelTestResultToModel(
|
||||||
{
|
{
|
||||||
...model,
|
...model,
|
||||||
default: model.modelId === modelId,
|
default: model.modelId === effectiveDefaultModelId,
|
||||||
},
|
},
|
||||||
proofResult
|
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?.();
|
await options.onProviderChanged?.();
|
||||||
|
if (!isProjectContextCurrent(projectContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (defaultError) {
|
} catch (defaultError) {
|
||||||
|
if (!isProjectContextCurrent(projectContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(
|
setError(
|
||||||
defaultError instanceof Error ? defaultError.message : 'Failed to set OpenCode default'
|
defaultError instanceof Error ? defaultError.message : 'Failed to set OpenCode default'
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingDefaultModelId(null);
|
if (isProjectContextCurrent(projectContext)) {
|
||||||
|
setSavingDefaultModelId(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[options]
|
[getProjectContextSnapshot, isProjectContextCurrent, options]
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectProvider = useCallback(
|
const selectProvider = useCallback(
|
||||||
|
|
@ -984,6 +1156,40 @@ export function useRuntimeProviderManagement(
|
||||||
[closeModelPickerState]
|
[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>(
|
const state = useMemo<RuntimeProviderManagementState>(
|
||||||
() => ({
|
() => ({
|
||||||
view,
|
view,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||||
import {
|
import {
|
||||||
compareOpenCodeTeamModelRecommendations,
|
compareOpenCodeTeamModelRecommendations,
|
||||||
getOpenCodeTeamModelRecommendation,
|
getOpenCodeTeamModelRecommendation,
|
||||||
|
|
@ -43,11 +44,13 @@ import type {
|
||||||
} from '../hooks/useRuntimeProviderManagement';
|
} from '../hooks/useRuntimeProviderManagement';
|
||||||
import type {
|
import type {
|
||||||
RuntimeProviderConnectionDto,
|
RuntimeProviderConnectionDto,
|
||||||
|
RuntimeProviderDefaultScopeDto,
|
||||||
RuntimeProviderDirectoryEntryDto,
|
RuntimeProviderDirectoryEntryDto,
|
||||||
RuntimeProviderModelDto,
|
RuntimeProviderModelDto,
|
||||||
RuntimeProviderModelTestResultDto,
|
RuntimeProviderModelTestResultDto,
|
||||||
RuntimeProviderSetupPromptDto,
|
RuntimeProviderSetupPromptDto,
|
||||||
} from '@features/runtime-provider-management/contracts';
|
} from '@features/runtime-provider-management/contracts';
|
||||||
|
import type { ProjectPathProject } from '@renderer/components/team/dialogs/projectPathProjects';
|
||||||
import type { CSSProperties, JSX, KeyboardEvent } from 'react';
|
import type { CSSProperties, JSX, KeyboardEvent } from 'react';
|
||||||
|
|
||||||
interface RuntimeProviderManagementPanelViewProps {
|
interface RuntimeProviderManagementPanelViewProps {
|
||||||
|
|
@ -55,6 +58,10 @@ interface RuntimeProviderManagementPanelViewProps {
|
||||||
readonly actions: RuntimeProviderManagementActions;
|
readonly actions: RuntimeProviderManagementActions;
|
||||||
readonly disabled: boolean;
|
readonly disabled: boolean;
|
||||||
readonly projectPath?: string | null;
|
readonly projectPath?: string | null;
|
||||||
|
readonly projectContextProjects?: readonly ProjectPathProject[];
|
||||||
|
readonly projectContextLoading?: boolean;
|
||||||
|
readonly projectContextError?: string | null;
|
||||||
|
readonly onProjectContextChange?: (projectPath: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProviderActionsProps {
|
interface ProviderActionsProps {
|
||||||
|
|
@ -72,9 +79,14 @@ interface ProviderRowProps {
|
||||||
readonly formOpen: boolean;
|
readonly formOpen: boolean;
|
||||||
readonly busy: boolean;
|
readonly busy: boolean;
|
||||||
readonly disabled: boolean;
|
readonly disabled: boolean;
|
||||||
|
readonly hasProjectContext: boolean;
|
||||||
readonly actions: RuntimeProviderManagementActions;
|
readonly actions: RuntimeProviderManagementActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenCodeSettingsSection = 'models' | 'providers';
|
||||||
|
|
||||||
|
const NO_PROJECT_CONTEXT_VALUE = '__runtime-provider-no-project-context__';
|
||||||
|
|
||||||
function getDirectoryAction(
|
function getDirectoryAction(
|
||||||
provider: RuntimeProviderDirectoryEntryDto,
|
provider: RuntimeProviderDirectoryEntryDto,
|
||||||
actionId: RuntimeProviderConnectionDto['actions'][number]['id']
|
actionId: RuntimeProviderConnectionDto['actions'][number]['id']
|
||||||
|
|
@ -116,6 +128,38 @@ function formatOpenCodeProviderCount(count: number): string {
|
||||||
return `${count} OpenCode provider${count === 1 ? '' : 's'}`;
|
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(
|
function directoryEntryMatchesQuery(
|
||||||
provider: RuntimeProviderDirectoryEntryDto,
|
provider: RuntimeProviderDirectoryEntryDto,
|
||||||
query: string
|
query: string
|
||||||
|
|
@ -442,11 +486,15 @@ function RuntimeSummary({
|
||||||
<div
|
<div
|
||||||
className="mt-1 truncate text-[11px]"
|
className="mt-1 truncate text-[11px]"
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
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
|
{projectPath
|
||||||
? `Managing selected project profile: ${projectPath}`
|
? `Project context: ${getProjectContextName(projectPath) ?? 'current project'}`
|
||||||
: 'Managing fallback OpenCode profile. Select a project to manage launch credentials for that project.'}
|
: 'No project context selected'}
|
||||||
</div>
|
</div>
|
||||||
{state.loading ? (
|
{state.loading ? (
|
||||||
<div
|
<div
|
||||||
|
|
@ -713,6 +761,7 @@ function ProviderRow({
|
||||||
formOpen,
|
formOpen,
|
||||||
busy,
|
busy,
|
||||||
disabled,
|
disabled,
|
||||||
|
hasProjectContext,
|
||||||
actions,
|
actions,
|
||||||
}: ProviderRowProps): JSX.Element {
|
}: ProviderRowProps): JSX.Element {
|
||||||
const connect = getProviderAction(provider, 'connect');
|
const connect = getProviderAction(provider, 'connect');
|
||||||
|
|
@ -827,6 +876,7 @@ function ProviderRow({
|
||||||
actions={actions}
|
actions={actions}
|
||||||
provider={provider}
|
provider={provider}
|
||||||
disabled={disabled || busy}
|
disabled={disabled || busy}
|
||||||
|
hasProjectContext={hasProjectContext}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -840,6 +890,7 @@ function DirectoryProviderRow({
|
||||||
formOpen,
|
formOpen,
|
||||||
disabled,
|
disabled,
|
||||||
busy,
|
busy,
|
||||||
|
hasProjectContext,
|
||||||
actions,
|
actions,
|
||||||
}: {
|
}: {
|
||||||
readonly provider: RuntimeProviderDirectoryEntryDto;
|
readonly provider: RuntimeProviderDirectoryEntryDto;
|
||||||
|
|
@ -848,6 +899,7 @@ function DirectoryProviderRow({
|
||||||
readonly formOpen: boolean;
|
readonly formOpen: boolean;
|
||||||
readonly disabled: boolean;
|
readonly disabled: boolean;
|
||||||
readonly busy: boolean;
|
readonly busy: boolean;
|
||||||
|
readonly hasProjectContext: boolean;
|
||||||
readonly actions: RuntimeProviderManagementActions;
|
readonly actions: RuntimeProviderManagementActions;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const connect = getDirectoryAction(provider, 'connect');
|
const connect = getDirectoryAction(provider, 'connect');
|
||||||
|
|
@ -1003,6 +1055,7 @@ function DirectoryProviderRow({
|
||||||
actions={actions}
|
actions={actions}
|
||||||
provider={directoryEntryToProviderConnection(provider)}
|
provider={directoryEntryToProviderConnection(provider)}
|
||||||
disabled={disabled || busy}
|
disabled={disabled || busy}
|
||||||
|
hasProjectContext={hasProjectContext}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1082,7 +1135,7 @@ function ModelBadges({
|
||||||
{usedForNewTeams ? (
|
{usedForNewTeams ? (
|
||||||
<Badge className="bg-sky-400/15 px-1.5 py-0 text-[10px] text-sky-100">
|
<Badge className="bg-sky-400/15 px-1.5 py-0 text-[10px] text-sky-100">
|
||||||
<Star className="mr-1 size-3" />
|
<Star className="mr-1 size-3" />
|
||||||
Used for new teams
|
Used in team picker
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{model.free ? (
|
{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 {
|
function getOpenCodeRouteUnavailableTitle(model: RuntimeProviderModelDto): string | undefined {
|
||||||
if (isUnknownOpenCodeModelRoute(model)) {
|
if (isUnknownOpenCodeModelRoute(model)) {
|
||||||
return 'This model is the current OpenCode default, but it is not available in the live catalog yet.';
|
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,
|
model,
|
||||||
selected,
|
selected,
|
||||||
disabled,
|
disabled,
|
||||||
|
hasProjectContext,
|
||||||
testing,
|
testing,
|
||||||
result,
|
result,
|
||||||
actions,
|
actions,
|
||||||
|
|
@ -1191,6 +1241,7 @@ function ModelRow({
|
||||||
readonly model: RuntimeProviderModelDto;
|
readonly model: RuntimeProviderModelDto;
|
||||||
readonly selected: boolean;
|
readonly selected: boolean;
|
||||||
readonly disabled: boolean;
|
readonly disabled: boolean;
|
||||||
|
readonly hasProjectContext: boolean;
|
||||||
readonly testing: boolean;
|
readonly testing: boolean;
|
||||||
readonly result: RuntimeProviderModelTestResultDto | undefined;
|
readonly result: RuntimeProviderModelTestResultDto | undefined;
|
||||||
readonly actions: RuntimeProviderManagementActions;
|
readonly actions: RuntimeProviderManagementActions;
|
||||||
|
|
@ -1254,9 +1305,13 @@ function ModelRow({
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 min-w-20 justify-center"
|
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) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
if (!hasProjectContext) return;
|
||||||
void actions.testModel(provider.providerId, model.modelId);
|
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({
|
function ConfiguredOpenCodeModelsPanel({
|
||||||
state,
|
state,
|
||||||
actions,
|
actions,
|
||||||
disabled,
|
disabled,
|
||||||
|
defaultScope,
|
||||||
|
hasProjectContext,
|
||||||
}: {
|
}: {
|
||||||
readonly state: RuntimeProviderManagementState;
|
readonly state: RuntimeProviderManagementState;
|
||||||
readonly actions: RuntimeProviderManagementActions;
|
readonly actions: RuntimeProviderManagementActions;
|
||||||
readonly disabled: boolean;
|
readonly disabled: boolean;
|
||||||
|
readonly defaultScope: RuntimeProviderDefaultScopeDto;
|
||||||
|
readonly hasProjectContext: boolean;
|
||||||
}): JSX.Element | null {
|
}): JSX.Element | null {
|
||||||
const models = state.view?.configuredModels ?? [];
|
const models = state.view?.configuredModels ?? [];
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
|
|
@ -1299,10 +1479,11 @@ function ConfiguredOpenCodeModelsPanel({
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium text-[var(--color-text)]">
|
<div className="text-sm font-medium text-[var(--color-text)]">
|
||||||
Configured OpenCode models
|
Launchable OpenCode models
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-[var(--color-text-muted)]">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1314,10 +1495,19 @@ function ConfiguredOpenCodeModelsPanel({
|
||||||
const savingDefault = state.savingDefaultModelId === model.modelId;
|
const savingDefault = state.savingDefaultModelId === model.modelId;
|
||||||
const result = state.modelResults[model.modelId];
|
const result = state.modelResults[model.modelId];
|
||||||
const unavailableTitle = getOpenCodeRouteUnavailableTitle(model);
|
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 canUse = !disabled && canUseOpenCodeModelRoute(model);
|
||||||
const canSetDefault =
|
const canSetDefault =
|
||||||
!disabled && !savingDefault && canSetOpenCodeDefaultModelRoute(model);
|
!disabled &&
|
||||||
|
hasProjectContext &&
|
||||||
|
!savingDefault &&
|
||||||
|
!alreadyDefaultForScope &&
|
||||||
|
canUseOpenCodeModelRoute(model);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={model.modelId}
|
key={model.modelId}
|
||||||
|
|
@ -1349,7 +1539,7 @@ function ConfiguredOpenCodeModelsPanel({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8"
|
className="h-8"
|
||||||
disabled={!canTest}
|
disabled={!canTest}
|
||||||
title={canTest ? undefined : unavailableTitle}
|
title={canTest ? undefined : (contextRequiredTitle ?? unavailableTitle)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canTest) return;
|
if (!canTest) return;
|
||||||
void actions.testModel(model.providerId, model.modelId);
|
void actions.testModel(model.providerId, model.modelId);
|
||||||
|
|
@ -1374,7 +1564,7 @@ function ConfiguredOpenCodeModelsPanel({
|
||||||
actions.useModelForNewTeams(model.modelId);
|
actions.useModelForNewTeams(model.modelId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Use for new teams
|
Use in team picker
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1385,17 +1575,18 @@ function ConfiguredOpenCodeModelsPanel({
|
||||||
title={
|
title={
|
||||||
canSetDefault
|
canSetDefault
|
||||||
? undefined
|
? undefined
|
||||||
: model.default
|
: (contextRequiredTitle ??
|
||||||
? 'This is already the OpenCode default.'
|
(alreadyDefaultForScope
|
||||||
: unavailableTitle
|
? 'This is already the selected OpenCode default.'
|
||||||
|
: unavailableTitle))
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canSetDefault) return;
|
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}
|
{savingDefault ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
|
||||||
Set OpenCode default
|
{getDefaultScopeButtonLabel(defaultScope)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1413,11 +1604,13 @@ function ProviderModelList({
|
||||||
actions,
|
actions,
|
||||||
provider,
|
provider,
|
||||||
disabled,
|
disabled,
|
||||||
|
hasProjectContext,
|
||||||
}: {
|
}: {
|
||||||
readonly state: RuntimeProviderManagementState;
|
readonly state: RuntimeProviderManagementState;
|
||||||
readonly actions: RuntimeProviderManagementActions;
|
readonly actions: RuntimeProviderManagementActions;
|
||||||
readonly provider: RuntimeProviderConnectionDto;
|
readonly provider: RuntimeProviderConnectionDto;
|
||||||
readonly disabled: boolean;
|
readonly disabled: boolean;
|
||||||
|
readonly hasProjectContext: boolean;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const pickerOpen = state.modelPickerProviderId === provider.providerId;
|
const pickerOpen = state.modelPickerProviderId === provider.providerId;
|
||||||
const [recommendedOnly, setRecommendedOnly] = useState(false);
|
const [recommendedOnly, setRecommendedOnly] = useState(false);
|
||||||
|
|
@ -1513,6 +1706,7 @@ function ProviderModelList({
|
||||||
model={model}
|
model={model}
|
||||||
selected={state.selectedModelId === model.modelId}
|
selected={state.selectedModelId === model.modelId}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
hasProjectContext={hasProjectContext}
|
||||||
testing={state.testingModelIds.includes(model.modelId)}
|
testing={state.testingModelIds.includes(model.modelId)}
|
||||||
result={state.modelResults[model.modelId]}
|
result={state.modelResults[model.modelId]}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
|
|
@ -1529,7 +1723,13 @@ export function RuntimeProviderManagementPanelView({
|
||||||
actions,
|
actions,
|
||||||
disabled,
|
disabled,
|
||||||
projectPath = null,
|
projectPath = null,
|
||||||
|
projectContextProjects = [],
|
||||||
|
projectContextLoading = false,
|
||||||
|
projectContextError = null,
|
||||||
|
onProjectContextChange,
|
||||||
}: RuntimeProviderManagementPanelViewProps): JSX.Element {
|
}: RuntimeProviderManagementPanelViewProps): JSX.Element {
|
||||||
|
const [selectedSection, setSelectedSection] = useState<OpenCodeSettingsSection | null>(null);
|
||||||
|
const [defaultScope, setDefaultScope] = useState<RuntimeProviderDefaultScopeDto>('project');
|
||||||
const providerQuery = state.providerQuery.trim().toLowerCase();
|
const providerQuery = state.providerQuery.trim().toLowerCase();
|
||||||
const filteredProviders = providerQuery
|
const filteredProviders = providerQuery
|
||||||
? state.providers.filter((provider) =>
|
? state.providers.filter((provider) =>
|
||||||
|
|
@ -1558,6 +1758,9 @@ export function RuntimeProviderManagementPanelView({
|
||||||
: state.directorySupported
|
: state.directorySupported
|
||||||
? 'OpenCode provider catalog'
|
? 'OpenCode provider catalog'
|
||||||
: 'OpenCode providers';
|
: 'OpenCode providers';
|
||||||
|
const launchableModelCount = state.view?.configuredModels?.length ?? 0;
|
||||||
|
const activeSection = selectedSection ?? (launchableModelCount > 0 ? 'models' : 'providers');
|
||||||
|
const hasProjectContext = Boolean(projectPath?.trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -1596,156 +1799,215 @@ export function RuntimeProviderManagementPanelView({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ConfiguredOpenCodeModelsPanel state={state} actions={actions} disabled={disabled} />
|
<Tabs
|
||||||
|
value={activeSection}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
onValueChange={(value) => setSelectedSection(value as OpenCodeSettingsSection)}
|
||||||
<div className="min-w-0">
|
>
|
||||||
<div className="text-sm font-medium text-[var(--color-text)]">Providers</div>
|
<div className="border-b border-white/10">
|
||||||
<div className="text-xs text-[var(--color-text-muted)]">
|
<TabsList className="gap-1 rounded-b-none">
|
||||||
{providerCountLabel}. Connected and recommended providers are shown first.
|
<TabsTrigger
|
||||||
</div>
|
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>
|
</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 ? (
|
<TabsContent value="models" className="mt-3 space-y-3">
|
||||||
<div className="relative">
|
<OpenCodeModelScopeControls
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-text-muted)]" />
|
defaultScope={defaultScope}
|
||||||
<Input
|
onDefaultScopeChange={setDefaultScope}
|
||||||
data-testid="runtime-provider-search"
|
projectPath={projectPath}
|
||||||
value={state.providerQuery}
|
projects={projectContextProjects}
|
||||||
disabled={disabled || state.loading}
|
loading={projectContextLoading}
|
||||||
onChange={(event) => actions.setProviderQuery(event.target.value)}
|
error={projectContextError}
|
||||||
onKeyDown={(event) => {
|
onProjectContextChange={onProjectContextChange}
|
||||||
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>
|
<ConfiguredOpenCodeModelsPanel
|
||||||
) : null}
|
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 ? (
|
<TabsContent value="providers" className="mt-3 space-y-3">
|
||||||
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
{state.directoryError}
|
<div className="min-w-0">
|
||||||
</div>
|
<div className="text-sm font-medium text-[var(--color-text)]">Providers</div>
|
||||||
) : null}
|
<div className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
{providerCountLabel}. Connected and recommended providers are shown first.
|
||||||
<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>
|
|
||||||
</div>
|
</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}
|
) : null}
|
||||||
</>
|
</div>
|
||||||
) : (
|
|
||||||
<>
|
{state.providers.length > 0 || state.directorySupported ? (
|
||||||
{state.loading && state.providers.length === 0 ? (
|
<div className="relative">
|
||||||
<RuntimeProviderLoadingPlaceholder />
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-text-muted)]" />
|
||||||
) : null}
|
<Input
|
||||||
{filteredProviders.map((provider) => (
|
data-testid="runtime-provider-search"
|
||||||
<ProviderRow
|
value={state.providerQuery}
|
||||||
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}
|
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>
|
||||||
</>
|
) : null}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{useDirectoryRows &&
|
{state.directoryError ? (
|
||||||
!state.directoryLoading &&
|
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
|
||||||
visibleDirectoryRows.length === 0 &&
|
{state.directoryError}
|
||||||
!state.directoryError ? (
|
</div>
|
||||||
<div
|
) : null}
|
||||||
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 &&
|
<div className="max-h-[min(52vh,640px)] space-y-2 overflow-y-auto pr-1">
|
||||||
!state.loading &&
|
{useDirectoryRows ? (
|
||||||
state.providers.length > 0 &&
|
<>
|
||||||
filteredProviders.length === 0 ? (
|
{state.directoryLoading && state.directoryEntries.length === 0 ? (
|
||||||
<div
|
<RuntimeProviderLoadingPlaceholder />
|
||||||
className="rounded-lg border p-3 text-sm"
|
) : null}
|
||||||
style={{
|
{visibleDirectoryRows.map((provider) => (
|
||||||
borderColor: 'var(--color-border-subtle)',
|
<DirectoryProviderRow
|
||||||
color: 'var(--color-text-secondary)',
|
key={provider.providerId}
|
||||||
}}
|
provider={provider}
|
||||||
>
|
state={state}
|
||||||
No providers match that search.
|
active={provider.providerId === state.selectedProviderId}
|
||||||
</div>
|
formOpen={state.activeFormProviderId === provider.providerId}
|
||||||
) : null}
|
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 ? (
|
{useDirectoryRows &&
|
||||||
<div
|
!state.directoryLoading &&
|
||||||
className="rounded-lg border p-3 text-sm"
|
visibleDirectoryRows.length === 0 &&
|
||||||
style={{
|
!state.directoryError ? (
|
||||||
borderColor: 'var(--color-border-subtle)',
|
<div
|
||||||
color: 'var(--color-text-secondary)',
|
className="rounded-lg border p-3 text-sm"
|
||||||
}}
|
style={{
|
||||||
>
|
borderColor: 'var(--color-border-subtle)',
|
||||||
No OpenCode providers reported by the managed runtime.
|
color: 'var(--color-text-secondary)',
|
||||||
</div>
|
}}
|
||||||
) : null}
|
>
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ interface Props {
|
||||||
readonly onOpenChange: (open: boolean) => void;
|
readonly onOpenChange: (open: boolean) => void;
|
||||||
readonly providers: CliProviderStatus[];
|
readonly providers: CliProviderStatus[];
|
||||||
readonly initialProviderId: CliProviderId;
|
readonly initialProviderId: CliProviderId;
|
||||||
|
readonly initialRuntimeProviderId?: string | null;
|
||||||
|
readonly initialRuntimeProviderAction?: 'connect' | 'select' | null;
|
||||||
readonly projectPath?: string | null;
|
readonly projectPath?: string | null;
|
||||||
readonly providerStatusLoading?: Partial<Record<CliProviderId, boolean>>;
|
readonly providerStatusLoading?: Partial<Record<CliProviderId, boolean>>;
|
||||||
readonly disabled?: boolean;
|
readonly disabled?: boolean;
|
||||||
|
|
@ -314,6 +316,24 @@ function getProviderUsageLabel(provider: CliProviderStatus): string {
|
||||||
: provider.statusMessage || 'Not connected';
|
: 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(
|
function getCodexAccountPanelHint(
|
||||||
provider: CliProviderStatus | null,
|
provider: CliProviderStatus | null,
|
||||||
configuredAuthMode: CliProviderAuthMode | undefined
|
configuredAuthMode: CliProviderAuthMode | undefined
|
||||||
|
|
@ -593,6 +613,8 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
providers,
|
providers,
|
||||||
initialProviderId,
|
initialProviderId,
|
||||||
|
initialRuntimeProviderId = null,
|
||||||
|
initialRuntimeProviderAction = null,
|
||||||
projectPath = null,
|
projectPath = null,
|
||||||
providerStatusLoading = {},
|
providerStatusLoading = {},
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
|
@ -887,6 +909,14 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const showSelectedProviderSummary = Boolean(selectedProvider) && !connectionManagedRuntime;
|
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(() => {
|
const connectionProgressMessage = useMemo(() => {
|
||||||
if (!connectionLoading || !selectedProvider) {
|
if (!connectionLoading || !selectedProvider) {
|
||||||
|
|
@ -1208,18 +1238,17 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{selectedProvider.detailMessage ? (
|
{selectedProviderDetailMessage ? (
|
||||||
<div className="mt-2 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
<div className="mt-2 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
{selectedProvider.detailMessage}
|
{selectedProviderDetailMessage}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{selectedProvider.externalRuntimeDiagnostics &&
|
{selectedProviderDiagnostics.length > 0 ? (
|
||||||
selectedProvider.externalRuntimeDiagnostics.length > 0 ? (
|
|
||||||
<div
|
<div
|
||||||
className="mt-2 space-y-1 text-[11px]"
|
className="mt-2 space-y-1 text-[11px]"
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
>
|
>
|
||||||
{selectedProvider.externalRuntimeDiagnostics.slice(0, 3).map((diagnostic) => (
|
{selectedProviderDiagnostics.slice(0, 3).map((diagnostic) => (
|
||||||
<div key={diagnostic.id}>
|
<div key={diagnostic.id}>
|
||||||
{diagnostic.label}:{' '}
|
{diagnostic.label}:{' '}
|
||||||
{diagnostic.statusMessage ?? (diagnostic.detected ? 'detected' : 'missing')}
|
{diagnostic.statusMessage ?? (diagnostic.detected ? 'detected' : 'missing')}
|
||||||
|
|
@ -1237,6 +1266,8 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
runtimeId="opencode"
|
runtimeId="opencode"
|
||||||
open={open}
|
open={open}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
|
initialProviderId={initialRuntimeProviderId}
|
||||||
|
initialProviderAction={initialRuntimeProviderAction}
|
||||||
disabled={disabled || selectedProviderLoading}
|
disabled={disabled || selectedProviderLoading}
|
||||||
onProviderChanged={() => onRefreshProvider?.('opencode')}
|
onProviderChanged={() => onRefreshProvider?.('opencode')}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const buildProviderAwareCliEnvMock = vi.fn();
|
const buildProviderAwareCliEnvMock = vi.fn();
|
||||||
const resolveBinaryMock = vi.fn();
|
const resolveBinaryMock = vi.fn();
|
||||||
const execCliMock = vi.fn();
|
const execCliMock = vi.fn();
|
||||||
|
|
@ -248,6 +249,51 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
|
||||||
expect(JSON.stringify(execCliMock.mock.calls[0])).not.toContain('undefined');
|
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 () => {
|
it('loads provider setup forms through the CLI contract', async () => {
|
||||||
execCliMock.mockResolvedValue({
|
execCliMock.mockResolvedValue({
|
||||||
stdout: JSON.stringify({
|
stdout: JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import React, { act } from 'react';
|
import React, { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
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 { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
||||||
|
import type { CliProviderStatus } from '@shared/types';
|
||||||
|
|
||||||
interface StoreState {
|
interface StoreState {
|
||||||
appConfig: {
|
appConfig: {
|
||||||
|
|
@ -1588,6 +1589,9 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
||||||
expect(panel?.getAttribute('data-open')).toBe('true');
|
expect(panel?.getAttribute('data-open')).toBe('true');
|
||||||
expect(panel?.getAttribute('data-project-path')).toBe('/tmp/project-a');
|
expect(panel?.getAttribute('data-project-path')).toBe('/tmp/project-a');
|
||||||
expect(host.textContent).toContain('Runtime provider management: opencode');
|
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.');
|
expect(host.textContent).not.toContain('Desktop currently exposes status only.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,31 @@ describe('RuntimeProviderManagementPanelView', () => {
|
||||||
expect(refreshButton?.disabled).toBe(true);
|
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 () => {
|
it('renders configured OpenCode model routes with local proof actions', async () => {
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
|
|
@ -188,6 +213,7 @@ describe('RuntimeProviderManagementPanelView', () => {
|
||||||
}),
|
}),
|
||||||
actions,
|
actions,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
projectPath: '/tmp/project',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
@ -196,7 +222,7 @@ describe('RuntimeProviderManagementPanelView', () => {
|
||||||
const row = host.querySelector<HTMLElement>(
|
const row = host.querySelector<HTMLElement>(
|
||||||
'[data-testid="configured-opencode-model-row-llama.cpp/qwen-test:0.5b"]'
|
'[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('local');
|
||||||
expect(row?.textContent).toContain('configured');
|
expect(row?.textContent).toContain('configured');
|
||||||
expect(row?.textContent).toContain('needs test');
|
expect(row?.textContent).toContain('needs test');
|
||||||
|
|
@ -207,25 +233,124 @@ describe('RuntimeProviderManagementPanelView', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
await act(async () => {
|
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 Promise.resolve();
|
||||||
});
|
});
|
||||||
await act(async () => {
|
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();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(actions.testModel).toHaveBeenCalledWith(
|
expect(actions.testModel).toHaveBeenCalledWith('llama.cpp', 'llama.cpp/qwen-test:0.5b');
|
||||||
'llama.cpp',
|
|
||||||
'llama.cpp/qwen-test:0.5b'
|
|
||||||
);
|
|
||||||
expect(actions.useModelForNewTeams).toHaveBeenCalledWith('llama.cpp/qwen-test:0.5b');
|
expect(actions.useModelForNewTeams).toHaveBeenCalledWith('llama.cpp/qwen-test:0.5b');
|
||||||
expect(actions.setDefaultModel).toHaveBeenCalledWith(
|
expect(actions.setDefaultModel).toHaveBeenCalledWith(
|
||||||
'llama.cpp',
|
'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 () => {
|
it('shows unknown OpenCode defaults without enabling launch actions', async () => {
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
|
|
@ -1122,13 +1247,14 @@ describe('RuntimeProviderManagementPanelView', () => {
|
||||||
state,
|
state,
|
||||||
actions,
|
actions,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
projectPath: '/tmp/project',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(host.textContent).toContain('openrouter/openai/gpt-oss-20b:free');
|
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('Model probe passed');
|
||||||
expect(host.textContent).toContain('Recommended');
|
expect(host.textContent).toContain('Recommended');
|
||||||
expect(host.textContent).toContain('Not recommended');
|
expect(host.textContent).toContain('Not recommended');
|
||||||
|
|
@ -1139,7 +1265,7 @@ describe('RuntimeProviderManagementPanelView', () => {
|
||||||
expect(host.textContent).not.toContain('Set OpenCode default');
|
expect(host.textContent).not.toContain('Set OpenCode default');
|
||||||
expect(
|
expect(
|
||||||
Array.from(host.querySelectorAll('button')).some(
|
Array.from(host.querySelectorAll('button')).some(
|
||||||
(button) => button.textContent?.trim() === 'Use for new teams'
|
(button) => button.textContent?.trim() === 'Use in team picker'
|
||||||
)
|
)
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import type {
|
||||||
RuntimeProviderDirectoryEntryDto,
|
RuntimeProviderDirectoryEntryDto,
|
||||||
RuntimeProviderManagementModelTestResponse,
|
RuntimeProviderManagementModelTestResponse,
|
||||||
RuntimeProviderManagementViewDto,
|
RuntimeProviderManagementViewDto,
|
||||||
|
RuntimeProviderManagementViewResponse,
|
||||||
} from '../../../../src/features/runtime-provider-management/contracts';
|
} from '../../../../src/features/runtime-provider-management/contracts';
|
||||||
import type { ElectronAPI } from '../../../../src/shared/types/api';
|
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 () => {
|
it('refreshes view and catalog after forgetting managed auth while local auth remains', async () => {
|
||||||
const localProvider = createOpenAiLocalProvider();
|
const localProvider = createOpenAiLocalProvider();
|
||||||
const loadView = vi.fn(() =>
|
const loadView = vi.fn(() =>
|
||||||
|
|
@ -1279,6 +1519,7 @@ describe('useRuntimeProviderManagement', () => {
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await actions?.setDefaultModel('llama.cpp', modelId);
|
await actions?.setDefaultModel('llama.cpp', modelId);
|
||||||
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(setDefaultModel).toHaveBeenCalledWith({
|
expect(setDefaultModel).toHaveBeenCalledWith({
|
||||||
|
|
@ -1286,6 +1527,7 @@ describe('useRuntimeProviderManagement', () => {
|
||||||
providerId: 'llama.cpp',
|
providerId: 'llama.cpp',
|
||||||
modelId,
|
modelId,
|
||||||
probe: true,
|
probe: true,
|
||||||
|
scope: 'project',
|
||||||
projectPath: null,
|
projectPath: null,
|
||||||
});
|
});
|
||||||
expect(state?.view?.configuredModels?.[0]).toMatchObject({
|
expect(state?.view?.configuredModels?.[0]).toMatchObject({
|
||||||
|
|
@ -1302,4 +1544,89 @@ describe('useRuntimeProviderManagement', () => {
|
||||||
message: 'Model probe passed',
|
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