feat(opencode): add scoped model defaults UI

This commit is contained in:
777genius 2026-05-21 12:35:41 +03:00
parent 98405b9040
commit 384446e83c
10 changed files with 1297 additions and 217 deletions

View file

@ -172,11 +172,23 @@ export interface RuntimeProviderManagementViewDto {
runtime: RuntimeProviderManagementRuntimeDto;
providers: readonly RuntimeProviderConnectionDto[];
configuredModels?: readonly RuntimeProviderModelDto[];
projectPath?: string | null;
projectDefaultModel?: string | null;
allProjectsDefaultModel?: string | null;
defaultModelSource?: RuntimeProviderDefaultModelSourceDto | null;
defaultModel: string | null;
fallbackModel: string | null;
diagnostics: readonly string[];
}
export type RuntimeProviderDefaultModelSourceDto =
| 'project'
| 'all_projects'
| 'opencode_config'
| 'fallback';
export type RuntimeProviderDefaultScopeDto = 'project' | 'all_projects';
export type RuntimeProviderManagementErrorCodeDto =
| 'unsupported-runtime'
| 'unsupported-action'
@ -361,5 +373,6 @@ export interface RuntimeProviderManagementSetDefaultModelInput {
providerId: string;
modelId: string;
probe?: boolean;
scope?: RuntimeProviderDefaultScopeDto;
projectPath?: string | null;
}

View file

@ -638,6 +638,8 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
input.providerId,
'--model',
input.modelId,
'--scope',
input.scope === 'all_projects' ? 'all-projects' : 'project',
'--probe',
'--compact',
'--json',

View file

@ -1,28 +1,87 @@
import { type JSX, useEffect, useMemo, useState } from 'react';
import {
loadProjectPathProjects,
type ProjectPathProject,
} from '@renderer/components/team/dialogs/projectPathProjects';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { useRuntimeProviderManagement } from './hooks/useRuntimeProviderManagement';
import { RuntimeProviderManagementPanelView } from './ui/RuntimeProviderManagementPanelView';
import type { RuntimeProviderManagementRuntimeId } from '@features/runtime-provider-management/contracts';
import type { JSX } from 'react';
interface RuntimeProviderManagementPanelProps {
readonly runtimeId: RuntimeProviderManagementRuntimeId;
readonly open: boolean;
readonly projectPath?: string | null;
readonly initialProviderId?: string | null;
readonly initialProviderAction?: 'connect' | 'select' | null;
readonly disabled?: boolean;
readonly onProviderChanged?: () => Promise<void> | void;
}
export function RuntimeProviderManagementPanel({
export const RuntimeProviderManagementPanel = ({
runtimeId,
open,
projectPath = null,
initialProviderId = null,
initialProviderAction = null,
disabled = false,
onProviderChanged,
}: RuntimeProviderManagementPanelProps): JSX.Element {
}: RuntimeProviderManagementPanelProps): JSX.Element => {
const repositoryGroups = useStore(useShallow((state) => state.repositoryGroups));
const initialProjectPath = useMemo(() => projectPath?.trim() || null, [projectPath]);
const [activeProjectPath, setActiveProjectPath] = useState<string | null>(initialProjectPath);
const [projectContextProjects, setProjectContextProjects] = useState<ProjectPathProject[]>([]);
const [projectContextLoading, setProjectContextLoading] = useState(false);
const [projectContextError, setProjectContextError] = useState<string | null>(null);
useEffect(() => {
if (!open) {
return;
}
setActiveProjectPath(initialProjectPath);
}, [initialProjectPath, open]);
useEffect(() => {
if (!open) {
return;
}
let cancelled = false;
setProjectContextLoading(true);
setProjectContextError(null);
void loadProjectPathProjects({
defaultProjectPath: activeProjectPath ?? initialProjectPath,
repositoryGroups,
})
.then((projects) => {
if (cancelled) return;
setProjectContextProjects(projects);
})
.catch((error) => {
if (cancelled) return;
setProjectContextError(
error instanceof Error ? error.message : 'Failed to load project contexts'
);
})
.finally(() => {
if (!cancelled) {
setProjectContextLoading(false);
}
});
return () => {
cancelled = true;
};
}, [activeProjectPath, initialProjectPath, open, repositoryGroups]);
const [state, actions] = useRuntimeProviderManagement({
runtimeId,
enabled: open,
projectPath,
projectPath: activeProjectPath,
initialProviderId,
initialProviderAction,
onProviderChanged,
});
@ -31,7 +90,11 @@ export function RuntimeProviderManagementPanel({
state={state}
actions={actions}
disabled={disabled}
projectPath={projectPath}
projectPath={activeProjectPath}
projectContextProjects={projectContextProjects}
projectContextLoading={projectContextLoading}
projectContextError={projectContextError}
onProjectContextChange={setActiveProjectPath}
/>
);
}
};

View file

@ -10,6 +10,7 @@ import {
import type {
RuntimeProviderConnectionDto,
RuntimeProviderDefaultScopeDto,
RuntimeProviderDirectoryEntryDto,
RuntimeProviderDirectoryFilterDto,
RuntimeProviderManagementRuntimeId,
@ -23,6 +24,8 @@ interface UseRuntimeProviderManagementOptions {
runtimeId: RuntimeProviderManagementRuntimeId;
enabled: boolean;
projectPath?: string | null;
initialProviderId?: string | null;
initialProviderAction?: 'connect' | 'select' | null;
onProviderChanged?: () => Promise<void> | void;
}
@ -30,6 +33,11 @@ export type RuntimeProviderModelPickerMode = 'use' | 'runtime-default';
const DEFAULT_DIRECTORY_FILTER: RuntimeProviderDirectoryFilterDto = 'all';
interface ProjectContextSnapshot {
path: string | null;
generation: number;
}
export interface RuntimeProviderManagementState {
view: RuntimeProviderManagementViewDto | null;
providers: readonly RuntimeProviderConnectionDto[];
@ -87,7 +95,11 @@ export interface RuntimeProviderManagementActions {
selectModel: (modelId: string) => void;
useModelForNewTeams: (modelId: string) => void;
testModel: (providerId: string, modelId: string) => Promise<void>;
setDefaultModel: (providerId: string, modelId: string) => Promise<void>;
setDefaultModel: (
providerId: string,
modelId: string,
scope?: RuntimeProviderDefaultScopeDto
) => Promise<void>;
}
function replaceProvider(
@ -123,6 +135,10 @@ function withUiTimeout<T>(promise: Promise<T>, message: string, timeoutMs = 70_0
});
}
function normalizeProjectContextPath(projectPath: string | null | undefined): string | null {
return projectPath?.trim() || null;
}
function buildFailedModelTestResult(
providerId: string,
modelId: string,
@ -239,11 +255,35 @@ export function useRuntimeProviderManagement(
const [savingProviderId, setSavingProviderId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const viewLoadRequestSeq = useRef(0);
const directoryRequestSeq = useRef(0);
const setupFormRequestSeq = useRef(0);
const modelLoadRequestSeq = useRef(0);
const modelProbeGenerationRef = useRef(0);
const activeModelPickerProviderRef = useRef<string | null>(null);
const appliedInitialProviderRef = useRef<string | null>(null);
const currentProjectPath = normalizeProjectContextPath(options.projectPath);
const projectContextRef = useRef<ProjectContextSnapshot>({
path: currentProjectPath,
generation: 0,
});
if (projectContextRef.current.path !== currentProjectPath) {
projectContextRef.current = {
path: currentProjectPath,
generation: projectContextRef.current.generation + 1,
};
}
const getProjectContextSnapshot = useCallback(
(): ProjectContextSnapshot => projectContextRef.current,
[]
);
const isProjectContextCurrent = useCallback(
(snapshot: ProjectContextSnapshot): boolean =>
projectContextRef.current.path === snapshot.path &&
projectContextRef.current.generation === snapshot.generation,
[]
);
const openModelPickerState = useCallback(
(providerId: string, mode: RuntimeProviderModelPickerMode): void => {
@ -278,11 +318,44 @@ export function useRuntimeProviderManagement(
setTestingModelIds([]);
}, []);
useEffect(() => {
directoryRequestSeq.current += 1;
setupFormRequestSeq.current += 1;
modelLoadRequestSeq.current += 1;
modelProbeGenerationRef.current += 1;
setDirectoryEntries([]);
setDirectoryTotalCount(null);
setDirectoryNextCursor(null);
setDirectoryError(null);
setDirectorySelectedProviderId(null);
setDirectoryLoaded(false);
setSetupForm(null);
setSetupFormLoading(false);
setSetupFormError(null);
setSetupSubmitError(null);
setActiveFormProviderId(null);
setApiKeyValue('');
setSetupMetadata({});
setModels([]);
setModelsLoading(false);
setModelsError(null);
setSelectedModelId(null);
setTestingModelIds([]);
setSavingDefaultModelId(null);
setModelResults({});
setSuccessMessage(null);
}, [currentProjectPath]);
const refresh = useCallback(
async (input: { silent?: boolean } = {}): Promise<void> => {
if (!options.enabled) {
return;
}
const projectContext = getProjectContextSnapshot();
const requestSeq = viewLoadRequestSeq.current + 1;
viewLoadRequestSeq.current = requestSeq;
const requestIsCurrent = (): boolean =>
viewLoadRequestSeq.current === requestSeq && isProjectContextCurrent(projectContext);
const silent = input.silent === true;
if (!silent) {
setLoading(true);
@ -291,8 +364,11 @@ export function useRuntimeProviderManagement(
try {
const response = await api.runtimeProviderManagement.loadView({
runtimeId: options.runtimeId,
projectPath: options.projectPath ?? null,
projectPath: projectContext.path,
});
if (!requestIsCurrent()) {
return;
}
if (response.error) {
if (!silent) {
setView(null);
@ -309,17 +385,20 @@ export function useRuntimeProviderManagement(
return selectInitialProviderId(nextView);
});
} catch (loadError) {
if (!requestIsCurrent()) {
return;
}
if (!silent) {
setView(null);
}
setError(loadError instanceof Error ? loadError.message : 'Failed to load providers');
} finally {
if (!silent) {
if (!silent && requestIsCurrent()) {
setLoading(false);
}
}
},
[options.enabled, options.projectPath, options.runtimeId]
[getProjectContextSnapshot, isProjectContextCurrent, options.enabled, options.runtimeId]
);
const loadDirectoryPage = useCallback(
@ -341,8 +420,11 @@ export function useRuntimeProviderManagement(
const query = input.query ?? directoryQuery;
const filter = input.filter ?? DEFAULT_DIRECTORY_FILTER;
const cursor = input.cursor ?? null;
const projectContext = getProjectContextSnapshot();
const requestSeq = directoryRequestSeq.current + 1;
directoryRequestSeq.current = requestSeq;
const requestIsCurrent = (): boolean =>
directoryRequestSeq.current === requestSeq && isProjectContextCurrent(projectContext);
if (append) {
setDirectoryRefreshing(true);
@ -356,14 +438,14 @@ export function useRuntimeProviderManagement(
try {
const response = await api.runtimeProviderManagement.loadProviderDirectory({
runtimeId: options.runtimeId,
projectPath: options.projectPath ?? null,
projectPath: projectContext.path,
query: query.trim() || null,
filter,
limit: 50,
cursor,
refresh: refreshDirectoryData,
});
if (directoryRequestSeq.current !== requestSeq) {
if (!requestIsCurrent()) {
return;
}
if (response.error) {
@ -388,23 +470,32 @@ export function useRuntimeProviderManagement(
append ? [...current, ...directory.entries] : directory.entries
);
} catch (loadError) {
if (directoryRequestSeq.current === requestSeq) {
if (requestIsCurrent()) {
setDirectoryError(
loadError instanceof Error ? loadError.message : 'Failed to load provider directory'
);
}
} finally {
if (directoryRequestSeq.current === requestSeq) {
if (requestIsCurrent()) {
setDirectoryLoading(false);
setDirectoryRefreshing(false);
}
}
},
[directoryQuery, directorySupported, options.enabled, options.projectPath, options.runtimeId]
[
directoryQuery,
directorySupported,
getProjectContextSnapshot,
isProjectContextCurrent,
options.enabled,
options.runtimeId,
]
);
useEffect(() => {
if (!options.enabled) {
viewLoadRequestSeq.current += 1;
appliedInitialProviderRef.current = null;
setProviderQuery('');
setDirectoryLoading(false);
setDirectoryRefreshing(false);
@ -426,7 +517,7 @@ export function useRuntimeProviderManagement(
return;
}
void refresh();
}, [closeModelPickerState, options.enabled, refresh]);
}, [closeModelPickerState, currentProjectPath, options.enabled, refresh]);
useEffect(() => {
if (!options.enabled || !directorySupported) {
@ -458,9 +549,11 @@ export function useRuntimeProviderManagement(
const requestSeq = modelLoadRequestSeq.current + 1;
modelLoadRequestSeq.current = requestSeq;
const providerId = modelPickerProviderId;
const projectContext = getProjectContextSnapshot();
const requestIsCurrent = (): boolean =>
modelLoadRequestSeq.current === requestSeq &&
activeModelPickerProviderRef.current === providerId;
activeModelPickerProviderRef.current === providerId &&
isProjectContextCurrent(projectContext);
let cancelled = false;
setModelsLoading(true);
setModelsError(null);
@ -468,7 +561,7 @@ export function useRuntimeProviderManagement(
api.runtimeProviderManagement.loadModels({
runtimeId: options.runtimeId,
providerId,
projectPath: options.projectPath ?? null,
projectPath: projectContext.path,
query: modelQuery.trim() || null,
limit: 250,
}),
@ -511,7 +604,14 @@ export function useRuntimeProviderManagement(
return () => {
cancelled = true;
};
}, [modelPickerProviderId, modelQuery, options.enabled, options.projectPath, options.runtimeId]);
}, [
getProjectContextSnapshot,
isProjectContextCurrent,
modelPickerProviderId,
modelQuery,
options.enabled,
options.runtimeId,
]);
useEffect(() => {
if (!options.enabled || activeFormProviderId) {
@ -620,19 +720,22 @@ export function useRuntimeProviderManagement(
setSetupFormLoading(true);
setError(null);
setSuccessMessage(null);
const projectContext = getProjectContextSnapshot();
const requestSeq = setupFormRequestSeq.current + 1;
setupFormRequestSeq.current = requestSeq;
const requestIsCurrent = (): boolean =>
setupFormRequestSeq.current === requestSeq && isProjectContextCurrent(projectContext);
void withUiTimeout(
api.runtimeProviderManagement.loadSetupForm({
runtimeId: options.runtimeId,
providerId,
projectPath: options.projectPath ?? null,
projectPath: projectContext.path,
}),
'Provider setup form load timed out'
)
.then((response) => {
if (setupFormRequestSeq.current !== requestSeq) {
if (!requestIsCurrent()) {
return;
}
if (response.error) {
@ -645,7 +748,7 @@ export function useRuntimeProviderManagement(
}
})
.catch((setupError) => {
if (setupFormRequestSeq.current !== requestSeq) {
if (!requestIsCurrent()) {
return;
}
setSetupFormError(
@ -653,12 +756,12 @@ export function useRuntimeProviderManagement(
);
})
.finally(() => {
if (setupFormRequestSeq.current === requestSeq) {
if (requestIsCurrent()) {
setSetupFormLoading(false);
}
});
},
[closeModelPickerState, options.projectPath, options.runtimeId]
[closeModelPickerState, getProjectContextSnapshot, isProjectContextCurrent, options.runtimeId]
);
const updateProviderQuery = useCallback(
@ -720,6 +823,7 @@ export function useRuntimeProviderManagement(
setError(null);
setSetupSubmitError(null);
setSuccessMessage(null);
const projectContext = getProjectContextSnapshot();
try {
const response = await withUiTimeout(
api.runtimeProviderManagement.connectProvider({
@ -728,10 +832,13 @@ export function useRuntimeProviderManagement(
method: setupForm.method,
apiKey: apiKey || null,
metadata: setupMetadata,
projectPath: options.projectPath ?? null,
projectPath: projectContext.path,
}),
'Provider connect timed out'
);
if (!isProjectContextCurrent(projectContext)) {
return;
}
if (response.error) {
setSetupSubmitError(response.error.message);
return;
@ -748,24 +855,45 @@ export function useRuntimeProviderManagement(
setSetupSubmitError(null);
try {
await options.onProviderChanged?.();
if (!isProjectContextCurrent(projectContext)) {
return;
}
await Promise.all([
refresh({ silent: true }),
loadDirectoryPage({ refresh: true, cursor: null }),
]);
} catch (refreshError) {
if (!isProjectContextCurrent(projectContext)) {
return;
}
setError(
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
);
}
} catch (connectError) {
if (!isProjectContextCurrent(projectContext)) {
return;
}
setSetupSubmitError(
connectError instanceof Error ? connectError.message : 'Failed to connect provider'
);
} finally {
setSavingProviderId(null);
if (isProjectContextCurrent(projectContext)) {
setSavingProviderId(null);
}
}
},
[apiKeyValue, loadDirectoryPage, options, refresh, setupForm, setupFormError, setupMetadata]
[
apiKeyValue,
getProjectContextSnapshot,
isProjectContextCurrent,
loadDirectoryPage,
options,
refresh,
setupForm,
setupFormError,
setupMetadata,
]
);
const forgetProvider = useCallback(
@ -773,15 +901,19 @@ export function useRuntimeProviderManagement(
setSavingProviderId(providerId);
setError(null);
setSuccessMessage(null);
const projectContext = getProjectContextSnapshot();
try {
const response = await withUiTimeout(
api.runtimeProviderManagement.forgetCredential({
runtimeId: options.runtimeId,
providerId,
projectPath: options.projectPath ?? null,
projectPath: projectContext.path,
}),
'Provider forget timed out'
);
if (!isProjectContextCurrent(projectContext)) {
return;
}
if (response.error) {
setError(response.error.message);
return;
@ -792,25 +924,39 @@ export function useRuntimeProviderManagement(
const success = formatCredentialRemovedMessage(response.provider ?? null);
try {
await options.onProviderChanged?.();
if (!isProjectContextCurrent(projectContext)) {
return;
}
await Promise.all([
refresh({ silent: true }),
loadDirectoryPage({ refresh: true, cursor: null }),
]);
} catch (refreshError) {
if (!isProjectContextCurrent(projectContext)) {
return;
}
setError(
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
);
}
if (!isProjectContextCurrent(projectContext)) {
return;
}
setSuccessMessage(success);
} catch (forgetError) {
if (!isProjectContextCurrent(projectContext)) {
return;
}
setError(
forgetError instanceof Error ? forgetError.message : 'Failed to forget credential'
);
} finally {
setSavingProviderId(null);
if (isProjectContextCurrent(projectContext)) {
setSavingProviderId(null);
}
}
},
[loadDirectoryPage, options, refresh]
[getProjectContextSnapshot, isProjectContextCurrent, loadDirectoryPage, options, refresh]
);
const openModelPicker = useCallback(
@ -839,9 +985,11 @@ export function useRuntimeProviderManagement(
async (providerId: string, modelId: string): Promise<void> => {
const probeGeneration = modelProbeGenerationRef.current;
const activeProviderAtStart = activeModelPickerProviderRef.current;
const projectContext = getProjectContextSnapshot();
const shouldRecordProbeResult = (): boolean =>
modelProbeGenerationRef.current === probeGeneration &&
(activeProviderAtStart === null || activeModelPickerProviderRef.current === providerId);
(activeProviderAtStart === null || activeModelPickerProviderRef.current === providerId) &&
isProjectContextCurrent(projectContext);
setTestingModelIds((current) =>
current.includes(modelId) ? current : [...current, modelId]
);
@ -853,7 +1001,7 @@ export function useRuntimeProviderManagement(
runtimeId: options.runtimeId,
providerId,
modelId,
projectPath: options.projectPath ?? null,
projectPath: projectContext.path,
}),
'Model test timed out',
100_000
@ -900,17 +1048,24 @@ export function useRuntimeProviderManagement(
setView((current) => applyModelTestResultToView(current, result));
}
} finally {
setTestingModelIds((current) => current.filter((entry) => entry !== modelId));
if (shouldRecordProbeResult()) {
setTestingModelIds((current) => current.filter((entry) => entry !== modelId));
}
}
},
[options.projectPath, options.runtimeId]
[getProjectContextSnapshot, isProjectContextCurrent, options.runtimeId]
);
const setDefaultModel = useCallback(
async (providerId: string, modelId: string): Promise<void> => {
async (
providerId: string,
modelId: string,
scope: RuntimeProviderDefaultScopeDto = 'project'
): Promise<void> => {
setSavingDefaultModelId(modelId);
setError(null);
setSuccessMessage(null);
const projectContext = getProjectContextSnapshot();
try {
const response = await withUiTimeout(
api.runtimeProviderManagement.setDefaultModel({
@ -918,11 +1073,15 @@ export function useRuntimeProviderManagement(
providerId,
modelId,
probe: true,
projectPath: options.projectPath ?? null,
scope,
projectPath: projectContext.path,
}),
'Set default model timed out',
100_000
);
if (!isProjectContextCurrent(projectContext)) {
return;
}
if (response.error) {
setError(response.error.message);
return;
@ -938,33 +1097,46 @@ export function useRuntimeProviderManagement(
if (response.view) {
setView(applyModelTestResultToView(response.view, proofResult));
}
const effectiveDefaultModelId = response.view?.defaultModel ?? modelId;
setModelResults((current) => ({
...current,
[modelId]: proofResult,
}));
setSelectedModelId(modelId);
setSelectedModelId(effectiveDefaultModelId);
setModels((current) =>
current.map((model) =>
applyModelTestResultToModel(
{
...model,
default: model.modelId === modelId,
default: model.modelId === effectiveDefaultModelId,
},
proofResult
)
)
);
setSuccessMessage(`OpenCode default set to ${modelId}`);
setSuccessMessage(
scope === 'all_projects'
? `All-projects OpenCode default set to ${modelId}`
: `Project OpenCode default set to ${modelId}`
);
await options.onProviderChanged?.();
if (!isProjectContextCurrent(projectContext)) {
return;
}
} catch (defaultError) {
if (!isProjectContextCurrent(projectContext)) {
return;
}
setError(
defaultError instanceof Error ? defaultError.message : 'Failed to set OpenCode default'
);
} finally {
setSavingDefaultModelId(null);
if (isProjectContextCurrent(projectContext)) {
setSavingDefaultModelId(null);
}
}
},
[options]
[getProjectContextSnapshot, isProjectContextCurrent, options]
);
const selectProvider = useCallback(
@ -984,6 +1156,40 @@ export function useRuntimeProviderManagement(
[closeModelPickerState]
);
useEffect(() => {
if (!options.enabled) {
return;
}
const initialProviderId = options.initialProviderId?.trim();
if (!initialProviderId) {
return;
}
const initialAction = options.initialProviderAction ?? 'select';
const initialKey = `${initialProviderId}:${initialAction}`;
if (appliedInitialProviderRef.current === initialKey) {
return;
}
appliedInitialProviderRef.current = initialKey;
updateProviderQuery(initialProviderId);
if (initialAction === 'connect') {
startConnect(initialProviderId);
return;
}
selectProvider(initialProviderId);
}, [
options.enabled,
options.initialProviderAction,
options.initialProviderId,
selectProvider,
startConnect,
updateProviderQuery,
]);
const state = useMemo<RuntimeProviderManagementState>(
() => ({
view,

View file

@ -12,6 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import {
compareOpenCodeTeamModelRecommendations,
getOpenCodeTeamModelRecommendation,
@ -43,11 +44,13 @@ import type {
} from '../hooks/useRuntimeProviderManagement';
import type {
RuntimeProviderConnectionDto,
RuntimeProviderDefaultScopeDto,
RuntimeProviderDirectoryEntryDto,
RuntimeProviderModelDto,
RuntimeProviderModelTestResultDto,
RuntimeProviderSetupPromptDto,
} from '@features/runtime-provider-management/contracts';
import type { ProjectPathProject } from '@renderer/components/team/dialogs/projectPathProjects';
import type { CSSProperties, JSX, KeyboardEvent } from 'react';
interface RuntimeProviderManagementPanelViewProps {
@ -55,6 +58,10 @@ interface RuntimeProviderManagementPanelViewProps {
readonly actions: RuntimeProviderManagementActions;
readonly disabled: boolean;
readonly projectPath?: string | null;
readonly projectContextProjects?: readonly ProjectPathProject[];
readonly projectContextLoading?: boolean;
readonly projectContextError?: string | null;
readonly onProjectContextChange?: (projectPath: string | null) => void;
}
interface ProviderActionsProps {
@ -72,9 +79,14 @@ interface ProviderRowProps {
readonly formOpen: boolean;
readonly busy: boolean;
readonly disabled: boolean;
readonly hasProjectContext: boolean;
readonly actions: RuntimeProviderManagementActions;
}
type OpenCodeSettingsSection = 'models' | 'providers';
const NO_PROJECT_CONTEXT_VALUE = '__runtime-provider-no-project-context__';
function getDirectoryAction(
provider: RuntimeProviderDirectoryEntryDto,
actionId: RuntimeProviderConnectionDto['actions'][number]['id']
@ -116,6 +128,38 @@ function formatOpenCodeProviderCount(count: number): string {
return `${count} OpenCode provider${count === 1 ? '' : 's'}`;
}
function getProjectContextName(projectPath: string | null | undefined): string | null {
const trimmed = projectPath?.trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.replace(/[\\/]+$/, '');
const name = normalized.split(/[\\/]/).pop()?.trim();
return name || normalized;
}
function getDefaultScopeDescription(scope: RuntimeProviderDefaultScopeDto): string {
return scope === 'all_projects'
? 'Used by project contexts without their own OpenCode default. Local models are tested per project.'
: 'Applies only to the selected project context.';
}
function getDefaultScopeButtonLabel(scope: RuntimeProviderDefaultScopeDto): string {
return scope === 'all_projects' ? 'Set all-projects default' : 'Set project default';
}
function isDefaultForScope(
model: RuntimeProviderModelDto,
state: RuntimeProviderManagementState,
scope: RuntimeProviderDefaultScopeDto
): boolean {
const scopedDefault =
scope === 'all_projects'
? state.view?.allProjectsDefaultModel
: state.view?.projectDefaultModel;
return scopedDefault === model.modelId;
}
function directoryEntryMatchesQuery(
provider: RuntimeProviderDirectoryEntryDto,
query: string
@ -442,11 +486,15 @@ function RuntimeSummary({
<div
className="mt-1 truncate text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
title={projectPath ?? undefined}
title={
projectPath
? `Current project context: ${projectPath}`
: 'No project context selected; using the fallback OpenCode management context.'
}
>
{projectPath
? `Managing selected project profile: ${projectPath}`
: 'Managing fallback OpenCode profile. Select a project to manage launch credentials for that project.'}
? `Project context: ${getProjectContextName(projectPath) ?? 'current project'}`
: 'No project context selected'}
</div>
{state.loading ? (
<div
@ -713,6 +761,7 @@ function ProviderRow({
formOpen,
busy,
disabled,
hasProjectContext,
actions,
}: ProviderRowProps): JSX.Element {
const connect = getProviderAction(provider, 'connect');
@ -827,6 +876,7 @@ function ProviderRow({
actions={actions}
provider={provider}
disabled={disabled || busy}
hasProjectContext={hasProjectContext}
/>
) : null}
</div>
@ -840,6 +890,7 @@ function DirectoryProviderRow({
formOpen,
disabled,
busy,
hasProjectContext,
actions,
}: {
readonly provider: RuntimeProviderDirectoryEntryDto;
@ -848,6 +899,7 @@ function DirectoryProviderRow({
readonly formOpen: boolean;
readonly disabled: boolean;
readonly busy: boolean;
readonly hasProjectContext: boolean;
readonly actions: RuntimeProviderManagementActions;
}): JSX.Element {
const connect = getDirectoryAction(provider, 'connect');
@ -1003,6 +1055,7 @@ function DirectoryProviderRow({
actions={actions}
provider={directoryEntryToProviderConnection(provider)}
disabled={disabled || busy}
hasProjectContext={hasProjectContext}
/>
) : null}
</div>
@ -1082,7 +1135,7 @@ function ModelBadges({
{usedForNewTeams ? (
<Badge className="bg-sky-400/15 px-1.5 py-0 text-[10px] text-sky-100">
<Star className="mr-1 size-3" />
Used for new teams
Used in team picker
</Badge>
) : null}
{model.free ? (
@ -1140,10 +1193,6 @@ function canUseOpenCodeModelRoute(model: RuntimeProviderModelDto): boolean {
);
}
function canSetOpenCodeDefaultModelRoute(model: RuntimeProviderModelDto): boolean {
return canUseOpenCodeModelRoute(model) && !model.default;
}
function getOpenCodeRouteUnavailableTitle(model: RuntimeProviderModelDto): string | undefined {
if (isUnknownOpenCodeModelRoute(model)) {
return 'This model is the current OpenCode default, but it is not available in the live catalog yet.';
@ -1183,6 +1232,7 @@ function ModelRow({
model,
selected,
disabled,
hasProjectContext,
testing,
result,
actions,
@ -1191,6 +1241,7 @@ function ModelRow({
readonly model: RuntimeProviderModelDto;
readonly selected: boolean;
readonly disabled: boolean;
readonly hasProjectContext: boolean;
readonly testing: boolean;
readonly result: RuntimeProviderModelTestResultDto | undefined;
readonly actions: RuntimeProviderManagementActions;
@ -1254,9 +1305,13 @@ function ModelRow({
size="sm"
variant="outline"
className="h-8 min-w-20 justify-center"
disabled={disabled || testing}
disabled={disabled || !hasProjectContext || testing}
title={
hasProjectContext ? undefined : 'Select a project context before testing models.'
}
onClick={(event) => {
event.stopPropagation();
if (!hasProjectContext) return;
void actions.testModel(provider.providerId, model.modelId);
}}
>
@ -1274,14 +1329,139 @@ function ModelRow({
);
}
function OpenCodeModelScopeControls({
defaultScope,
onDefaultScopeChange,
projectPath,
projects,
loading,
error,
onProjectContextChange,
}: {
readonly defaultScope: RuntimeProviderDefaultScopeDto;
readonly onDefaultScopeChange: (scope: RuntimeProviderDefaultScopeDto) => void;
readonly projectPath: string | null | undefined;
readonly projects: readonly ProjectPathProject[];
readonly loading: boolean;
readonly error: string | null;
readonly onProjectContextChange?: (projectPath: string | null) => void;
}): JSX.Element {
const selectedValue = projectPath?.trim() || NO_PROJECT_CONTEXT_VALUE;
const projectOptions = useMemo(() => {
const seen = new Set<string>();
const options = projects.filter((project) => {
const normalized = project.path.trim();
if (!normalized || seen.has(normalized) || project.filesystemState === 'deleted') {
return false;
}
seen.add(normalized);
return true;
});
const currentPath = projectPath?.trim();
if (currentPath && !seen.has(currentPath)) {
options.unshift({
id: currentPath,
path: currentPath,
name: getProjectContextName(currentPath) ?? currentPath,
sessions: [],
totalSessions: 0,
createdAt: 0,
});
}
return options;
}, [projectPath, projects]);
return (
<div
className="rounded-lg border p-3"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255,255,255,0.02)',
}}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text)]">OpenCode model scope</div>
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
{getDefaultScopeDescription(defaultScope)}
</div>
</div>
<div className="inline-flex shrink-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{(['project', 'all_projects'] as const).map((scope) => (
<button
key={scope}
type="button"
className={`rounded-[3px] px-3 py-1 text-xs font-medium transition-colors ${
defaultScope === scope
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => onDefaultScopeChange(scope)}
>
{scope === 'all_projects' ? 'All projects' : 'This project'}
</button>
))}
</div>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
<div className="min-w-0">
<Label className="text-xs text-[var(--color-text-secondary)]">Project context</Label>
<div className="mt-1">
<Select
value={selectedValue}
disabled={loading || !onProjectContextChange}
onValueChange={(value) => {
onProjectContextChange?.(value === NO_PROJECT_CONTEXT_VALUE ? null : value);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue
placeholder={loading ? 'Loading project contexts...' : 'Select project context'}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PROJECT_CONTEXT_VALUE}>Select project context</SelectItem>
{projectOptions.map((project) => (
<SelectItem key={project.path} value={project.path}>
{project.name || getProjectContextName(project.path) || project.path}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div
className="min-w-0 truncate text-[11px] text-[var(--color-text-muted)] md:max-w-[280px]"
title={projectPath?.trim() || undefined}
>
{projectPath
? `Current context: ${getProjectContextName(projectPath) ?? projectPath}`
: 'Select a project before testing or saving OpenCode defaults.'}
</div>
</div>
{error ? (
<div className="mt-2 rounded-md border border-red-400/25 bg-red-400/10 px-2 py-1.5 text-xs text-red-200">
{error}
</div>
) : null}
</div>
);
}
function ConfiguredOpenCodeModelsPanel({
state,
actions,
disabled,
defaultScope,
hasProjectContext,
}: {
readonly state: RuntimeProviderManagementState;
readonly actions: RuntimeProviderManagementActions;
readonly disabled: boolean;
readonly defaultScope: RuntimeProviderDefaultScopeDto;
readonly hasProjectContext: boolean;
}): JSX.Element | null {
const models = state.view?.configuredModels ?? [];
if (models.length === 0) {
@ -1299,10 +1479,11 @@ function ConfiguredOpenCodeModelsPanel({
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text)]">
Configured OpenCode models
Launchable OpenCode models
</div>
<div className="text-xs text-[var(--color-text-muted)]">
Ready model routes from OpenCode config, built-in free models, and the current default.
Routes you can test or use in the team picker: local config, free built-in models, and
current default.
</div>
</div>
</div>
@ -1314,10 +1495,19 @@ function ConfiguredOpenCodeModelsPanel({
const savingDefault = state.savingDefaultModelId === model.modelId;
const result = state.modelResults[model.modelId];
const unavailableTitle = getOpenCodeRouteUnavailableTitle(model);
const canTest = !disabled && !testing && canTestOpenCodeModelRoute(model);
const contextRequiredTitle = hasProjectContext
? undefined
: 'Select a project context before testing or saving OpenCode defaults.';
const alreadyDefaultForScope = isDefaultForScope(model, state, defaultScope);
const canTest =
!disabled && hasProjectContext && !testing && canTestOpenCodeModelRoute(model);
const canUse = !disabled && canUseOpenCodeModelRoute(model);
const canSetDefault =
!disabled && !savingDefault && canSetOpenCodeDefaultModelRoute(model);
!disabled &&
hasProjectContext &&
!savingDefault &&
!alreadyDefaultForScope &&
canUseOpenCodeModelRoute(model);
return (
<div
key={model.modelId}
@ -1349,7 +1539,7 @@ function ConfiguredOpenCodeModelsPanel({
variant="outline"
className="h-8"
disabled={!canTest}
title={canTest ? undefined : unavailableTitle}
title={canTest ? undefined : (contextRequiredTitle ?? unavailableTitle)}
onClick={() => {
if (!canTest) return;
void actions.testModel(model.providerId, model.modelId);
@ -1374,7 +1564,7 @@ function ConfiguredOpenCodeModelsPanel({
actions.useModelForNewTeams(model.modelId);
}}
>
Use for new teams
Use in team picker
</Button>
<Button
type="button"
@ -1385,17 +1575,18 @@ function ConfiguredOpenCodeModelsPanel({
title={
canSetDefault
? undefined
: model.default
? 'This is already the OpenCode default.'
: unavailableTitle
: (contextRequiredTitle ??
(alreadyDefaultForScope
? 'This is already the selected OpenCode default.'
: unavailableTitle))
}
onClick={() => {
if (!canSetDefault) return;
void actions.setDefaultModel(model.providerId, model.modelId);
void actions.setDefaultModel(model.providerId, model.modelId, defaultScope);
}}
>
{savingDefault ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
Set OpenCode default
{getDefaultScopeButtonLabel(defaultScope)}
</Button>
</div>
</div>
@ -1413,11 +1604,13 @@ function ProviderModelList({
actions,
provider,
disabled,
hasProjectContext,
}: {
readonly state: RuntimeProviderManagementState;
readonly actions: RuntimeProviderManagementActions;
readonly provider: RuntimeProviderConnectionDto;
readonly disabled: boolean;
readonly hasProjectContext: boolean;
}): JSX.Element {
const pickerOpen = state.modelPickerProviderId === provider.providerId;
const [recommendedOnly, setRecommendedOnly] = useState(false);
@ -1513,6 +1706,7 @@ function ProviderModelList({
model={model}
selected={state.selectedModelId === model.modelId}
disabled={disabled}
hasProjectContext={hasProjectContext}
testing={state.testingModelIds.includes(model.modelId)}
result={state.modelResults[model.modelId]}
actions={actions}
@ -1529,7 +1723,13 @@ export function RuntimeProviderManagementPanelView({
actions,
disabled,
projectPath = null,
projectContextProjects = [],
projectContextLoading = false,
projectContextError = null,
onProjectContextChange,
}: RuntimeProviderManagementPanelViewProps): JSX.Element {
const [selectedSection, setSelectedSection] = useState<OpenCodeSettingsSection | null>(null);
const [defaultScope, setDefaultScope] = useState<RuntimeProviderDefaultScopeDto>('project');
const providerQuery = state.providerQuery.trim().toLowerCase();
const filteredProviders = providerQuery
? state.providers.filter((provider) =>
@ -1558,6 +1758,9 @@ export function RuntimeProviderManagementPanelView({
: state.directorySupported
? 'OpenCode provider catalog'
: 'OpenCode providers';
const launchableModelCount = state.view?.configuredModels?.length ?? 0;
const activeSection = selectedSection ?? (launchableModelCount > 0 ? 'models' : 'providers');
const hasProjectContext = Boolean(projectPath?.trim());
return (
<div className="space-y-3">
@ -1596,156 +1799,215 @@ export function RuntimeProviderManagementPanelView({
</div>
) : null}
<ConfiguredOpenCodeModelsPanel state={state} actions={actions} disabled={disabled} />
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text)]">Providers</div>
<div className="text-xs text-[var(--color-text-muted)]">
{providerCountLabel}. Connected and recommended providers are shown first.
</div>
<Tabs
value={activeSection}
onValueChange={(value) => setSelectedSection(value as OpenCodeSettingsSection)}
>
<div className="border-b border-white/10">
<TabsList className="gap-1 rounded-b-none">
<TabsTrigger
value="models"
className="rounded-b-none data-[state=active]:bg-[var(--color-surface)]"
>
Models
{launchableModelCount > 0 ? (
<span className="ml-2 rounded-full bg-white/10 px-1.5 py-0 text-[10px]">
{launchableModelCount}
</span>
) : null}
</TabsTrigger>
<TabsTrigger
value="providers"
className="rounded-b-none data-[state=active]:bg-[var(--color-surface)]"
>
Providers
{state.directoryTotalCount !== null ? (
<span className="ml-2 rounded-full bg-white/10 px-1.5 py-0 text-[10px]">
{state.directoryTotalCount}
</span>
) : null}
</TabsTrigger>
</TabsList>
</div>
{state.directorySupported ? (
<Button
type="button"
size="sm"
variant="ghost"
disabled={disabled || state.directoryLoading || state.directoryRefreshing}
onClick={() => void actions.refreshDirectory()}
>
{state.directoryRefreshing ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : (
<RefreshCcw className="mr-1 size-3.5" />
)}
Refresh catalog
</Button>
) : null}
</div>
{state.providers.length > 0 || state.directorySupported ? (
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-text-muted)]" />
<Input
data-testid="runtime-provider-search"
value={state.providerQuery}
disabled={disabled || state.loading}
onChange={(event) => actions.setProviderQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && state.providerQuery.trim().length >= 2) {
actions.searchAllProviders(state.providerQuery.trim());
}
}}
placeholder="Search providers"
className="h-9 pr-3 text-sm"
style={{ paddingLeft: 40 }}
<TabsContent value="models" className="mt-3 space-y-3">
<OpenCodeModelScopeControls
defaultScope={defaultScope}
onDefaultScopeChange={setDefaultScope}
projectPath={projectPath}
projects={projectContextProjects}
loading={projectContextLoading}
error={projectContextError}
onProjectContextChange={onProjectContextChange}
/>
</div>
) : null}
<ConfiguredOpenCodeModelsPanel
state={state}
actions={actions}
disabled={disabled}
defaultScope={defaultScope}
hasProjectContext={hasProjectContext}
/>
{launchableModelCount === 0 ? (
<div className="rounded-lg border border-dashed border-white/10 p-4 text-sm text-[var(--color-text-muted)]">
No launchable OpenCode model routes were reported yet. Configure a local route in
OpenCode or use the Providers tab to inspect catalog providers.
</div>
) : null}
</TabsContent>
{state.directoryError ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
{state.directoryError}
</div>
) : null}
<div className="max-h-[min(52vh,640px)] space-y-2 overflow-y-auto pr-1">
{useDirectoryRows ? (
<>
{state.directoryLoading && state.directoryEntries.length === 0 ? (
<RuntimeProviderLoadingPlaceholder />
) : null}
{visibleDirectoryRows.map((provider) => (
<DirectoryProviderRow
key={provider.providerId}
provider={provider}
state={state}
active={provider.providerId === state.selectedProviderId}
formOpen={state.activeFormProviderId === provider.providerId}
busy={state.savingProviderId === provider.providerId}
disabled={disabled || state.directoryLoading}
actions={actions}
/>
))}
{state.directoryNextCursor ? (
<div className="flex justify-center py-1">
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || state.directoryRefreshing}
onClick={() => void actions.loadMoreDirectory()}
>
{state.directoryRefreshing ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : null}
Load more providers
</Button>
<TabsContent value="providers" className="mt-3 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text)]">Providers</div>
<div className="text-xs text-[var(--color-text-muted)]">
{providerCountLabel}. Connected and recommended providers are shown first.
</div>
</div>
{state.directorySupported ? (
<Button
type="button"
size="sm"
variant="ghost"
disabled={disabled || state.directoryLoading || state.directoryRefreshing}
onClick={() => void actions.refreshDirectory()}
>
{state.directoryRefreshing ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : (
<RefreshCcw className="mr-1 size-3.5" />
)}
Refresh catalog
</Button>
) : null}
</>
) : (
<>
{state.loading && state.providers.length === 0 ? (
<RuntimeProviderLoadingPlaceholder />
) : null}
{filteredProviders.map((provider) => (
<ProviderRow
key={provider.providerId}
provider={provider}
state={state}
active={provider.providerId === state.selectedProviderId}
formOpen={state.activeFormProviderId === provider.providerId}
busy={state.savingProviderId === provider.providerId}
</div>
{state.providers.length > 0 || state.directorySupported ? (
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-text-muted)]" />
<Input
data-testid="runtime-provider-search"
value={state.providerQuery}
disabled={disabled || state.loading}
actions={actions}
onChange={(event) => actions.setProviderQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && state.providerQuery.trim().length >= 2) {
actions.searchAllProviders(state.providerQuery.trim());
}
}}
placeholder="Search providers"
className="h-9 pr-3 text-sm"
style={{ paddingLeft: 40 }}
/>
))}
</>
)}
</div>
</div>
) : null}
{useDirectoryRows &&
!state.directoryLoading &&
visibleDirectoryRows.length === 0 &&
!state.directoryError ? (
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-secondary)',
}}
>
No providers match that search.
</div>
) : null}
{state.directoryError ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
{state.directoryError}
</div>
) : null}
{!useDirectoryRows &&
!state.loading &&
state.providers.length > 0 &&
filteredProviders.length === 0 ? (
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-secondary)',
}}
>
No providers match that search.
</div>
) : null}
<div className="max-h-[min(52vh,640px)] space-y-2 overflow-y-auto pr-1">
{useDirectoryRows ? (
<>
{state.directoryLoading && state.directoryEntries.length === 0 ? (
<RuntimeProviderLoadingPlaceholder />
) : null}
{visibleDirectoryRows.map((provider) => (
<DirectoryProviderRow
key={provider.providerId}
provider={provider}
state={state}
active={provider.providerId === state.selectedProviderId}
formOpen={state.activeFormProviderId === provider.providerId}
busy={state.savingProviderId === provider.providerId}
disabled={disabled || state.directoryLoading}
hasProjectContext={hasProjectContext}
actions={actions}
/>
))}
{state.directoryNextCursor ? (
<div className="flex justify-center py-1">
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || state.directoryRefreshing}
onClick={() => void actions.loadMoreDirectory()}
>
{state.directoryRefreshing ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : null}
Load more providers
</Button>
</div>
) : null}
</>
) : (
<>
{state.loading && state.providers.length === 0 ? (
<RuntimeProviderLoadingPlaceholder />
) : null}
{filteredProviders.map((provider) => (
<ProviderRow
key={provider.providerId}
provider={provider}
state={state}
active={provider.providerId === state.selectedProviderId}
formOpen={state.activeFormProviderId === provider.providerId}
busy={state.savingProviderId === provider.providerId}
disabled={disabled || state.loading}
hasProjectContext={hasProjectContext}
actions={actions}
/>
))}
</>
)}
</div>
{!useDirectoryRows && !state.loading && state.providers.length === 0 ? (
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-secondary)',
}}
>
No OpenCode providers reported by the managed runtime.
</div>
) : null}
{useDirectoryRows &&
!state.directoryLoading &&
visibleDirectoryRows.length === 0 &&
!state.directoryError ? (
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-secondary)',
}}
>
No providers match that search.
</div>
) : null}
{!useDirectoryRows &&
!state.loading &&
state.providers.length > 0 &&
filteredProviders.length === 0 ? (
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-secondary)',
}}
>
No providers match that search.
</div>
) : null}
{!useDirectoryRows && !state.loading && state.providers.length === 0 ? (
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-secondary)',
}}
>
No OpenCode providers reported by the managed runtime.
</div>
) : null}
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -78,6 +78,8 @@ interface Props {
readonly onOpenChange: (open: boolean) => void;
readonly providers: CliProviderStatus[];
readonly initialProviderId: CliProviderId;
readonly initialRuntimeProviderId?: string | null;
readonly initialRuntimeProviderAction?: 'connect' | 'select' | null;
readonly projectPath?: string | null;
readonly providerStatusLoading?: Partial<Record<CliProviderId, boolean>>;
readonly disabled?: boolean;
@ -314,6 +316,24 @@ function getProviderUsageLabel(provider: CliProviderStatus): string {
: provider.statusMessage || 'Not connected';
}
function getCompactOpenCodeProviderDetailMessage(detailMessage?: string | null): string | null {
const trimmed = detailMessage?.trim();
if (!trimmed) {
return null;
}
const firstInternalDetailIndex = [' - auth ', ' - behavior ', ' - managed ']
.map((marker) => trimmed.indexOf(marker))
.filter((index) => index >= 0)
.sort((left, right) => left - right)[0];
const compact =
typeof firstInternalDetailIndex === 'number'
? trimmed.slice(0, firstInternalDetailIndex).trim()
: trimmed;
return compact || null;
}
function getCodexAccountPanelHint(
provider: CliProviderStatus | null,
configuredAuthMode: CliProviderAuthMode | undefined
@ -593,6 +613,8 @@ export const ProviderRuntimeSettingsDialog = ({
onOpenChange,
providers,
initialProviderId,
initialRuntimeProviderId = null,
initialRuntimeProviderAction = null,
projectPath = null,
providerStatusLoading = {},
disabled = false,
@ -887,6 +909,14 @@ export const ProviderRuntimeSettingsDialog = ({
}
}
const showSelectedProviderSummary = Boolean(selectedProvider) && !connectionManagedRuntime;
const selectedProviderDetailMessage =
selectedProvider?.providerId === 'opencode'
? getCompactOpenCodeProviderDetailMessage(selectedProvider.detailMessage)
: (selectedProvider?.detailMessage ?? null);
const selectedProviderDiagnostics =
selectedProvider?.providerId === 'opencode'
? []
: (selectedProvider?.externalRuntimeDiagnostics ?? []);
const connectionProgressMessage = useMemo(() => {
if (!connectionLoading || !selectedProvider) {
@ -1208,18 +1238,17 @@ export const ProviderRuntimeSettingsDialog = ({
</span>
) : null}
</div>
{selectedProvider.detailMessage ? (
{selectedProviderDetailMessage ? (
<div className="mt-2 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
{selectedProvider.detailMessage}
{selectedProviderDetailMessage}
</div>
) : null}
{selectedProvider.externalRuntimeDiagnostics &&
selectedProvider.externalRuntimeDiagnostics.length > 0 ? (
{selectedProviderDiagnostics.length > 0 ? (
<div
className="mt-2 space-y-1 text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
{selectedProvider.externalRuntimeDiagnostics.slice(0, 3).map((diagnostic) => (
{selectedProviderDiagnostics.slice(0, 3).map((diagnostic) => (
<div key={diagnostic.id}>
{diagnostic.label}:{' '}
{diagnostic.statusMessage ?? (diagnostic.detected ? 'detected' : 'missing')}
@ -1237,6 +1266,8 @@ export const ProviderRuntimeSettingsDialog = ({
runtimeId="opencode"
open={open}
projectPath={projectPath}
initialProviderId={initialRuntimeProviderId}
initialProviderAction={initialRuntimeProviderAction}
disabled={disabled || selectedProviderLoading}
onProviderChanged={() => onRefreshProvider?.('opencode')}
/>

View file

@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const buildProviderAwareCliEnvMock = vi.fn();
const resolveBinaryMock = vi.fn();
const execCliMock = vi.fn();
@ -248,6 +249,51 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
expect(JSON.stringify(execCliMock.mock.calls[0])).not.toContain('undefined');
});
it('passes all-projects default scope to the runtime CLI', async () => {
execCliMock.mockResolvedValue({
stdout: JSON.stringify({
schemaVersion: 1,
runtimeId: 'opencode',
view: {
runtimeId: 'opencode',
title: 'OpenCode',
runtime: {
state: 'ready',
cliPath: '/opt/homebrew/bin/opencode',
version: '1.0.0',
managedProfile: 'active',
localAuth: 'synced',
},
providers: [],
configuredModels: [],
projectPath: '/Users/test/project',
projectDefaultModel: null,
allProjectsDefaultModel: 'openrouter/qwen/qwen3-coder',
defaultModelSource: 'all_projects',
defaultModel: 'openrouter/qwen/qwen3-coder',
fallbackModel: null,
diagnostics: [],
},
}),
stderr: '',
});
const client = new AgentTeamsRuntimeProviderManagementCliClient();
await client.setDefaultModel({
runtimeId: 'opencode',
providerId: 'openrouter',
modelId: 'openrouter/qwen/qwen3-coder',
scope: 'all_projects',
projectPath: '/Users/test/project',
});
expect(execCliMock).toHaveBeenCalledWith(
'/repo/cli-dev',
expect.arrayContaining(['--scope', 'all-projects']),
expect.objectContaining({ cwd: '/Users/test/project' })
);
});
it('loads provider setup forms through the CLI contract', async () => {
execCliMock.mockResolvedValue({
stdout: JSON.stringify({

View file

@ -1,9 +1,10 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CliProviderStatus } from '@shared/types';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
import type { CliProviderStatus } from '@shared/types';
interface StoreState {
appConfig: {
@ -1588,6 +1589,9 @@ describe('ProviderRuntimeSettingsDialog', () => {
expect(panel?.getAttribute('data-open')).toBe('true');
expect(panel?.getAttribute('data-project-path')).toBe('/tmp/project-a');
expect(host.textContent).toContain('Runtime provider management: opencode');
expect(host.textContent).toContain('version 1.4.0 - live resolved-fin');
expect(host.textContent).not.toContain('managed teammate agent');
expect(host.textContent).not.toContain('behavior abc123');
expect(host.textContent).not.toContain('Desktop currently exposes status only.');
});
});

View file

@ -156,6 +156,31 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(refreshButton?.disabled).toBe(true);
});
it('shows the project as a compact operation context, not a selected global profile', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState(),
actions: createActions(),
disabled: false,
projectPath: '/Users/belief/dev/projects/321',
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Project context: 321');
expect(host.textContent).not.toContain('Managing selected project profile');
expect(host.textContent).not.toContain('/Users/belief/dev/projects/321');
expect(
host.querySelector('[title="Current project context: /Users/belief/dev/projects/321"]')
).not.toBeNull();
});
it('renders configured OpenCode model routes with local proof actions', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
@ -188,6 +213,7 @@ describe('RuntimeProviderManagementPanelView', () => {
}),
actions,
disabled: false,
projectPath: '/tmp/project',
})
);
await Promise.resolve();
@ -196,7 +222,7 @@ describe('RuntimeProviderManagementPanelView', () => {
const row = host.querySelector<HTMLElement>(
'[data-testid="configured-opencode-model-row-llama.cpp/qwen-test:0.5b"]'
);
expect(host.textContent).toContain('Configured OpenCode models');
expect(host.textContent).toContain('Launchable OpenCode models');
expect(row?.textContent).toContain('local');
expect(row?.textContent).toContain('configured');
expect(row?.textContent).toContain('needs test');
@ -207,25 +233,124 @@ describe('RuntimeProviderManagementPanelView', () => {
await Promise.resolve();
});
await act(async () => {
buttons.find((button) => button.textContent?.includes('Use for new teams'))?.click();
buttons.find((button) => button.textContent?.includes('Use in team picker'))?.click();
await Promise.resolve();
});
await act(async () => {
buttons.find((button) => button.textContent?.includes('Set OpenCode default'))?.click();
buttons.find((button) => button.textContent?.includes('Set project default'))?.click();
await Promise.resolve();
});
expect(actions.testModel).toHaveBeenCalledWith(
'llama.cpp',
'llama.cpp/qwen-test:0.5b'
);
expect(actions.testModel).toHaveBeenCalledWith('llama.cpp', 'llama.cpp/qwen-test:0.5b');
expect(actions.useModelForNewTeams).toHaveBeenCalledWith('llama.cpp/qwen-test:0.5b');
expect(actions.setDefaultModel).toHaveBeenCalledWith(
'llama.cpp',
'llama.cpp/qwen-test:0.5b'
'llama.cpp/qwen-test:0.5b',
'project'
);
});
it('can set an all-projects OpenCode default from the model scope controls', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const actions = createActions();
const configuredModel = {
providerId: 'llama.cpp',
modelId: 'llama.cpp/qwen-test:0.5b',
displayName: 'qwen-test:0.5b',
sourceLabel: 'llama.cpp',
free: false,
default: false,
availability: 'available' as const,
accessKind: 'verified' as const,
routeKind: 'configured_local' as const,
proofState: 'verified' as const,
requiresExecutionProof: false,
accessReason: null,
};
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
view: {
...createState().view!,
configuredModels: [configuredModel],
},
}),
actions,
disabled: false,
projectPath: '/tmp/project-a',
})
);
await Promise.resolve();
});
await act(async () => {
Array.from(host.querySelectorAll('button'))
.find((button) => button.textContent?.includes('All projects'))
?.click();
await Promise.resolve();
});
await act(async () => {
Array.from(host.querySelectorAll('button'))
.find((button) => button.textContent?.includes('Set all-projects default'))
?.click();
await Promise.resolve();
});
expect(host.textContent).toContain('Used by project contexts without their own OpenCode default');
expect(actions.setDefaultModel).toHaveBeenCalledWith(
'llama.cpp',
'llama.cpp/qwen-test:0.5b',
'all_projects'
);
});
it('opens launchable routes first when they exist and keeps providers in a separate tab', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const baseState = createState();
const configuredModel = {
providerId: 'llama.cpp',
modelId: 'llama.cpp/qwen-test:0.5b',
displayName: 'qwen-test:0.5b',
sourceLabel: 'llama.cpp',
free: false,
default: false,
availability: 'untested' as const,
accessKind: 'configured_authless' as const,
routeKind: 'configured_local' as const,
proofState: 'needs_probe' as const,
requiresExecutionProof: true,
accessReason: 'Execution proof required',
};
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
view: {
...baseState.view!,
configuredModels: [configuredModel],
},
providers: baseState.view?.providers ?? [],
}),
actions: createActions(),
disabled: false,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Launchable OpenCode models');
expect(host.textContent).toContain('llama.cpp/qwen-test:0.5b');
expect(host.textContent).toContain('Providers');
expect(host.querySelector('[data-testid="runtime-provider-row-openrouter"]')).toBeNull();
});
it('shows unknown OpenCode defaults without enabling launch actions', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
@ -1122,13 +1247,14 @@ describe('RuntimeProviderManagementPanelView', () => {
state,
actions,
disabled: false,
projectPath: '/tmp/project',
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('openrouter/openai/gpt-oss-20b:free');
expect(host.textContent).toContain('Used for new teams');
expect(host.textContent).toContain('Used in team picker');
expect(host.textContent).toContain('Model probe passed');
expect(host.textContent).toContain('Recommended');
expect(host.textContent).toContain('Not recommended');
@ -1139,7 +1265,7 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(host.textContent).not.toContain('Set OpenCode default');
expect(
Array.from(host.querySelectorAll('button')).some(
(button) => button.textContent?.trim() === 'Use for new teams'
(button) => button.textContent?.trim() === 'Use in team picker'
)
).toBe(false);
expect(

View file

@ -18,6 +18,7 @@ import type {
RuntimeProviderDirectoryEntryDto,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementViewDto,
RuntimeProviderManagementViewResponse,
} from '../../../../src/features/runtime-provider-management/contracts';
import type { ElectronAPI } from '../../../../src/shared/types/api';
@ -173,6 +174,245 @@ describe('useRuntimeProviderManagement', () => {
});
});
it('ignores stale provider views after project context changes', async () => {
let resolveProjectA:
| ((response: {
schemaVersion: 1;
runtimeId: 'opencode';
view: RuntimeProviderManagementViewDto;
}) => void)
| null = null;
const projectAResponse = new Promise<{
schemaVersion: 1;
runtimeId: 'opencode';
view: RuntimeProviderManagementViewDto;
}>((resolve) => {
resolveProjectA = resolve;
});
const loadView = vi.fn((input: { projectPath?: string | null }) => {
if (input.projectPath === '/tmp/project-a') {
return projectAResponse;
}
return Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
view: {
...createRuntimeView(),
projectPath: '/tmp/project-b',
defaultModel: 'opencode/project-b',
},
});
});
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {
runtimeProviderManagement: {
loadView,
},
} as unknown as ElectronAPI,
});
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' }));
await Promise.resolve();
});
await act(async () => {
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-b' }));
await Promise.resolve();
});
expect(state?.view?.projectPath).toBe('/tmp/project-b');
await act(async () => {
resolveProjectA?.({
schemaVersion: 1,
runtimeId: 'opencode',
view: {
...createRuntimeView(),
projectPath: '/tmp/project-a',
defaultModel: 'opencode/project-a',
},
});
await Promise.resolve();
});
expect(state?.view?.projectPath).toBe('/tmp/project-b');
expect(state?.view?.defaultModel).toBe('opencode/project-b');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('drops stale model probe results after project context changes', async () => {
const modelId = 'llama.cpp/qwen-test:0.5b';
let resolveProbe: ((value: RuntimeProviderManagementModelTestResponse) => void) | null = null;
const loadView = vi.fn((input: { projectPath?: string | null }) =>
Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
view: {
...createRuntimeView(),
projectPath: input.projectPath ?? null,
defaultModel: input.projectPath === '/tmp/project-b' ? 'opencode/project-b' : null,
},
})
);
const testModel = vi.fn(
() =>
new Promise<RuntimeProviderManagementModelTestResponse>((resolve) => {
resolveProbe = resolve;
})
);
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {
runtimeProviderManagement: {
loadView,
testModel,
},
} as unknown as ElectronAPI,
});
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' }));
await Promise.resolve();
});
let probe: Promise<void> | null = null;
await act(async () => {
probe = actions?.testModel('llama.cpp', modelId) ?? null;
await Promise.resolve();
});
expect(testModel).toHaveBeenCalledWith({
runtimeId: 'opencode',
providerId: 'llama.cpp',
modelId,
projectPath: '/tmp/project-a',
});
expect(state?.testingModelIds).toEqual([modelId]);
await act(async () => {
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-b' }));
await Promise.resolve();
});
expect(state?.view?.projectPath).toBe('/tmp/project-b');
expect(state?.testingModelIds).toEqual([]);
await act(async () => {
resolveProbe?.({
schemaVersion: 1,
runtimeId: 'opencode',
result: {
providerId: 'llama.cpp',
modelId,
ok: true,
availability: 'available',
message: 'Stale project A probe passed',
diagnostics: [],
},
});
await probe;
});
expect(state?.view?.projectPath).toBe('/tmp/project-b');
expect(state?.modelResults[modelId]).toBeUndefined();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('drops stale set-default responses after project context changes', async () => {
const projectAModelId = 'llama.cpp/project-a:0.5b';
let resolveSetDefault: ((value: RuntimeProviderManagementViewResponse) => void) | null = null;
const loadView = vi.fn((input: { projectPath?: string | null }) =>
Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
view: {
...createRuntimeView(),
projectPath: input.projectPath ?? null,
defaultModel: input.projectPath === '/tmp/project-b' ? 'opencode/project-b' : null,
},
})
);
const setDefaultModel = vi.fn(
() =>
new Promise<RuntimeProviderManagementViewResponse>((resolve) => {
resolveSetDefault = resolve;
})
);
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {
runtimeProviderManagement: {
loadView,
setDefaultModel,
},
} as unknown as ElectronAPI,
});
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' }));
await Promise.resolve();
});
let setDefault: Promise<void> | null = null;
await act(async () => {
setDefault = actions?.setDefaultModel('llama.cpp', projectAModelId, 'project') ?? null;
await Promise.resolve();
});
expect(setDefaultModel).toHaveBeenCalledWith({
runtimeId: 'opencode',
providerId: 'llama.cpp',
modelId: projectAModelId,
probe: true,
scope: 'project',
projectPath: '/tmp/project-a',
});
expect(state?.savingDefaultModelId).toBe(projectAModelId);
await act(async () => {
root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-b' }));
await Promise.resolve();
});
expect(state?.view?.projectPath).toBe('/tmp/project-b');
expect(state?.savingDefaultModelId).toBeNull();
await act(async () => {
resolveSetDefault?.({
schemaVersion: 1,
runtimeId: 'opencode',
view: {
...createRuntimeView(),
projectPath: '/tmp/project-a',
defaultModel: projectAModelId,
},
});
await setDefault;
});
expect(state?.view?.projectPath).toBe('/tmp/project-b');
expect(state?.view?.defaultModel).toBe('opencode/project-b');
expect(state?.selectedModelId).toBeNull();
expect(state?.successMessage).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('refreshes view and catalog after forgetting managed auth while local auth remains', async () => {
const localProvider = createOpenAiLocalProvider();
const loadView = vi.fn(() =>
@ -1279,6 +1519,7 @@ describe('useRuntimeProviderManagement', () => {
await act(async () => {
await actions?.setDefaultModel('llama.cpp', modelId);
await Promise.resolve();
});
expect(setDefaultModel).toHaveBeenCalledWith({
@ -1286,6 +1527,7 @@ describe('useRuntimeProviderManagement', () => {
providerId: 'llama.cpp',
modelId,
probe: true,
scope: 'project',
projectPath: null,
});
expect(state?.view?.configuredModels?.[0]).toMatchObject({
@ -1302,4 +1544,89 @@ describe('useRuntimeProviderManagement', () => {
message: 'Model probe passed',
});
});
it('keeps the effective project default selected when an all-projects default is shadowed', async () => {
const allProjectsModelId = 'llama.cpp/qwen-test:0.5b';
const projectModelId = 'llama.cpp/project-test:1b';
const setDefaultModel = vi.fn(() =>
Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
view: {
...createRuntimeView(),
defaultModel: projectModelId,
projectDefaultModel: projectModelId,
allProjectsDefaultModel: allProjectsModelId,
defaultModelSource: 'project',
configuredModels: [
{
providerId: 'llama.cpp',
modelId: allProjectsModelId,
displayName: 'qwen-test:0.5b',
sourceLabel: 'llama.cpp',
free: false,
default: false,
availability: 'untested',
accessKind: 'configured_authless',
routeKind: 'configured_local',
proofState: 'needs_probe',
requiresExecutionProof: true,
accessReason: 'Execution proof required',
},
{
providerId: 'llama.cpp',
modelId: projectModelId,
displayName: 'project-test:1b',
sourceLabel: 'llama.cpp',
free: false,
default: true,
availability: 'available',
accessKind: 'verified',
routeKind: 'configured_local',
proofState: 'verified',
requiresExecutionProof: false,
accessReason: null,
},
],
},
})
);
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {
runtimeProviderManagement: {
setDefaultModel,
},
} as unknown as ElectronAPI,
});
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
await act(async () => {
await actions?.setDefaultModel('llama.cpp', allProjectsModelId, 'all_projects');
await Promise.resolve();
});
expect(state?.selectedModelId).toBe(projectModelId);
expect(state?.view?.defaultModel).toBe(projectModelId);
expect(state?.view?.defaultModelSource).toBe('project');
expect(
state?.view?.configuredModels?.find((model) => model.modelId === allProjectsModelId)
).toMatchObject({
default: false,
availability: 'available',
accessKind: 'verified',
proofState: 'verified',
});
expect(
state?.view?.configuredModels?.find((model) => model.modelId === projectModelId)
).toMatchObject({
default: true,
accessKind: 'verified',
});
});
});