From 384446e83c3ca1c30d6135fb9f7865a8dc6985a4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 21 May 2026 12:35:41 +0300 Subject: [PATCH] feat(opencode): add scoped model defaults UI --- .../contracts/types.ts | 13 + ...TeamsRuntimeProviderManagementCliClient.ts | 2 + .../RuntimeProviderManagementPanel.tsx | 75 ++- .../hooks/useRuntimeProviderManagement.ts | 276 +++++++-- .../ui/RuntimeProviderManagementPanelView.tsx | 580 +++++++++++++----- .../runtime/ProviderRuntimeSettingsDialog.tsx | 41 +- ...RuntimeProviderManagementCliClient.test.ts | 48 +- .../ProviderRuntimeSettingsDialog.test.ts | 6 +- ...RuntimeProviderManagementPanelView.test.ts | 146 ++++- .../useRuntimeProviderManagement.test.ts | 327 ++++++++++ 10 files changed, 1297 insertions(+), 217 deletions(-) diff --git a/src/features/runtime-provider-management/contracts/types.ts b/src/features/runtime-provider-management/contracts/types.ts index 837845f0..d0e13302 100644 --- a/src/features/runtime-provider-management/contracts/types.ts +++ b/src/features/runtime-provider-management/contracts/types.ts @@ -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; } diff --git a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts index 85f0a39d..f8e579af 100644 --- a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts +++ b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts @@ -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', diff --git a/src/features/runtime-provider-management/renderer/RuntimeProviderManagementPanel.tsx b/src/features/runtime-provider-management/renderer/RuntimeProviderManagementPanel.tsx index bf6a678c..976294e1 100644 --- a/src/features/runtime-provider-management/renderer/RuntimeProviderManagementPanel.tsx +++ b/src/features/runtime-provider-management/renderer/RuntimeProviderManagementPanel.tsx @@ -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; } -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(initialProjectPath); + const [projectContextProjects, setProjectContextProjects] = useState([]); + const [projectContextLoading, setProjectContextLoading] = useState(false); + const [projectContextError, setProjectContextError] = useState(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} /> ); -} +}; diff --git a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts index fe9e36aa..ee60bdd8 100644 --- a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts +++ b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts @@ -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; } @@ -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; - setDefaultModel: (providerId: string, modelId: string) => Promise; + setDefaultModel: ( + providerId: string, + modelId: string, + scope?: RuntimeProviderDefaultScopeDto + ) => Promise; } function replaceProvider( @@ -123,6 +135,10 @@ function withUiTimeout(promise: Promise, 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(null); const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); + const viewLoadRequestSeq = useRef(0); const directoryRequestSeq = useRef(0); const setupFormRequestSeq = useRef(0); const modelLoadRequestSeq = useRef(0); const modelProbeGenerationRef = useRef(0); const activeModelPickerProviderRef = useRef(null); + const appliedInitialProviderRef = useRef(null); + const currentProjectPath = normalizeProjectContextPath(options.projectPath); + const projectContextRef = useRef({ + 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 => { 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 => { 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 => { + async ( + providerId: string, + modelId: string, + scope: RuntimeProviderDefaultScopeDto = 'project' + ): Promise => { 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( () => ({ view, diff --git a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx index fe4d746f..df3745fa 100644 --- a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx +++ b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx @@ -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({
{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'}
{state.loading ? (
) : null}
@@ -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} @@ -1082,7 +1135,7 @@ function ModelBadges({ {usedForNewTeams ? ( - Used for new teams + Used in team picker ) : 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(); + 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 ( +
+
+
+
OpenCode model scope
+
+ {getDefaultScopeDescription(defaultScope)} +
+
+
+ {(['project', 'all_projects'] as const).map((scope) => ( + + ))} +
+
+ +
+
+ +
+ +
+
+
+ {projectPath + ? `Current context: ${getProjectContextName(projectPath) ?? projectPath}` + : 'Select a project before testing or saving OpenCode defaults.'} +
+
+ + {error ? ( +
+ {error} +
+ ) : null} +
+ ); +} + 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({
- Configured OpenCode models + Launchable OpenCode models
- 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.
@@ -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 (
{ 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
@@ -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(null); + const [defaultScope, setDefaultScope] = useState('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 (
@@ -1596,156 +1799,215 @@ export function RuntimeProviderManagementPanelView({
) : null} - - -
-
-
Providers
-
- {providerCountLabel}. Connected and recommended providers are shown first. -
+ setSelectedSection(value as OpenCodeSettingsSection)} + > +
+ + + Models + {launchableModelCount > 0 ? ( + + {launchableModelCount} + + ) : null} + + + Providers + {state.directoryTotalCount !== null ? ( + + {state.directoryTotalCount} + + ) : null} + +
- {state.directorySupported ? ( - - ) : null} -
- {state.providers.length > 0 || state.directorySupported ? ( -
- - 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 }} + + -
- ) : null} + + {launchableModelCount === 0 ? ( +
+ No launchable OpenCode model routes were reported yet. Configure a local route in + OpenCode or use the Providers tab to inspect catalog providers. +
+ ) : null} + - {state.directoryError ? ( -
- {state.directoryError} -
- ) : null} - -
- {useDirectoryRows ? ( - <> - {state.directoryLoading && state.directoryEntries.length === 0 ? ( - - ) : null} - {visibleDirectoryRows.map((provider) => ( - - ))} - {state.directoryNextCursor ? ( -
- + +
+
+
Providers
+
+ {providerCountLabel}. Connected and recommended providers are shown first.
+
+ {state.directorySupported ? ( + ) : null} - - ) : ( - <> - {state.loading && state.providers.length === 0 ? ( - - ) : null} - {filteredProviders.map((provider) => ( - + + {state.providers.length > 0 || state.directorySupported ? ( +
+ + 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 }} /> - ))} - - )} -
+
+ ) : null} - {useDirectoryRows && - !state.directoryLoading && - visibleDirectoryRows.length === 0 && - !state.directoryError ? ( -
- No providers match that search. -
- ) : null} + {state.directoryError ? ( +
+ {state.directoryError} +
+ ) : null} - {!useDirectoryRows && - !state.loading && - state.providers.length > 0 && - filteredProviders.length === 0 ? ( -
- No providers match that search. -
- ) : null} +
+ {useDirectoryRows ? ( + <> + {state.directoryLoading && state.directoryEntries.length === 0 ? ( + + ) : null} + {visibleDirectoryRows.map((provider) => ( + + ))} + {state.directoryNextCursor ? ( +
+ +
+ ) : null} + + ) : ( + <> + {state.loading && state.providers.length === 0 ? ( + + ) : null} + {filteredProviders.map((provider) => ( + + ))} + + )} +
- {!useDirectoryRows && !state.loading && state.providers.length === 0 ? ( -
- No OpenCode providers reported by the managed runtime. -
- ) : null} + {useDirectoryRows && + !state.directoryLoading && + visibleDirectoryRows.length === 0 && + !state.directoryError ? ( +
+ No providers match that search. +
+ ) : null} + + {!useDirectoryRows && + !state.loading && + state.providers.length > 0 && + filteredProviders.length === 0 ? ( +
+ No providers match that search. +
+ ) : null} + + {!useDirectoryRows && !state.loading && state.providers.length === 0 ? ( +
+ No OpenCode providers reported by the managed runtime. +
+ ) : null} +
+
); } diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index 8c5286cb..2eebc58e 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -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>; 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 = ({ ) : null}
- {selectedProvider.detailMessage ? ( + {selectedProviderDetailMessage ? (
- {selectedProvider.detailMessage} + {selectedProviderDetailMessage}
) : null} - {selectedProvider.externalRuntimeDiagnostics && - selectedProvider.externalRuntimeDiagnostics.length > 0 ? ( + {selectedProviderDiagnostics.length > 0 ? (
- {selectedProvider.externalRuntimeDiagnostics.slice(0, 3).map((diagnostic) => ( + {selectedProviderDiagnostics.slice(0, 3).map((diagnostic) => (
{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')} /> diff --git a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts index f9c5a3d8..c8eb6eed 100644 --- a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts +++ b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts @@ -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({ diff --git a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts index c8054518..0e35785e 100644 --- a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts +++ b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts @@ -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.'); }); }); diff --git a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts index d68c3996..ed600272 100644 --- a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts +++ b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts @@ -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( '[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( diff --git a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts index 8012e79a..82a383d2 100644 --- a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts +++ b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts @@ -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((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 | 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((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 | 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', + }); + }); });