From b88b2db3658a8c3572ad6b39055de5bee42faf6e Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 16 May 2026 23:23:27 +0300 Subject: [PATCH] fix: harden provider management and updater flows --- AGENTS.md | 9 + docs/RELEASE.md | 63 +- .../hooks/useRuntimeProviderManagement.ts | 437 ++++---- .../ui/RuntimeProviderManagementPanelView.tsx | 247 ++--- .../services/infrastructure/UpdaterService.ts | 57 +- .../infrastructure/updaterReleaseMetadata.ts | 46 +- .../components/team/ClaudeLogsPanel.tsx | 10 +- .../components/team/CliLogsRichView.tsx | 5 +- .../team/useClaudeLogsController.ts | 13 +- .../updaterReleaseMetadata.test.ts | 29 +- .../components/team/ClaudeLogsPanel.test.ts | 50 +- ...RuntimeProviderManagementPanelView.test.ts | 354 ++++++- .../useRuntimeProviderManagement.test.ts | 934 ++++++++++++++++-- 13 files changed, 1714 insertions(+), 540 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c93f5e27..836b2b5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,18 +3,27 @@ This file is a navigation layer for architecture and implementation guidance. Start here: + - Repo overview and commands: [README.md](README.md) - Working instructions and project conventions: [CLAUDE.md](CLAUDE.md) - Hard guardrails: [AGENT_CRITICAL_GUARDRAILS.md](AGENT_CRITICAL_GUARDRAILS.md) - Canonical feature architecture standard: [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md) - Agent team launch/runtime debugging runbook: [docs/team-management/debugging-agent-teams.md](docs/team-management/debugging-agent-teams.md) +GitHub repository disambiguation: + +- For this workspace, the canonical GitHub repository is `777genius/agent-teams-ai`. +- When reviewing or discussing PR `#126`, inspect `777genius/agent-teams-ai#126` unless the user explicitly names another repository. +- Do not confuse this workspace with upstream or similarly named forks such as `matt1398/claude-devtools`. + Default local run target: + - Use the desktop Electron app: `pnpm dev` - Do not start the browser/web dev mode for normal development or smoke checks. The browser path is limited and lacks the full desktop runtime, IPC, terminal, provider auth, and team lifecycle behavior. - When documenting or recommending startup commands, point contributors to the desktop app unless a task explicitly asks for browser-mode internals. For new features: + - Default home for medium and large features: `src/features//` - Reference implementation: `src/features/recent-projects` - Feature-local guidance for work inside `src/features`: [src/features/CLAUDE.md](src/features/CLAUDE.md) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 49e262a1..3549649d 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -77,14 +77,26 @@ EOF Format: `MAJOR.MINOR.PATCH` -| Bump | When | Example | -|---------|-------------------------------------------------------------|------------------| -| MAJOR | Breaking changes, major UI overhaul, incompatible data format changes | 1.0.0 → 2.0.0 | -| MINOR | New features, new panels/views, new integrations | 1.0.0 → 1.1.0 | -| PATCH | Bug fixes, performance improvements, small UI tweaks | 1.0.0 → 1.0.1 | +| Bump | When | Example | +| ----- | --------------------------------------------------------------------- | ------------- | +| MAJOR | Breaking changes, major UI overhaul, incompatible data format changes | 1.0.0 → 2.0.0 | +| MINOR | New features, new panels/views, new integrations | 1.0.0 → 1.1.0 | +| PATCH | Bug fixes, performance improvements, small UI tweaks | 1.0.0 → 1.0.1 | ## Release Process +### Test Releases And Auto-Update Safety + +Packaged apps check GitHub releases through `electron-updater` shortly after startup and then periodically. A normal public release with a higher SemVer and uploaded `latest.yml`, `latest-linux.yml`, or `latest-mac.yml` can be shown to users as an available update. + +For smoke/testing releases, do not publish a normal stable release. Use at least one of these guards: + +- Mark the GitHub release as `prerelease`. +- Keep the GitHub release as `draft`. +- Add one of these exact markers to the release title or notes: `[skip-updater]`, `[test-release]`, `[internal-release]`, `[no-autoupdate]`. + +The app suppresses update notifications for releases with those flags or markers. A stable production release must not use those markers. + ### 1. Prepare ```bash @@ -101,6 +113,7 @@ git push origin v ``` This triggers the `release.yml` GitHub Actions workflow which: + - Builds the app (ubuntu) - Packages macOS arm64 + x64 (with code signing & notarization) - Packages Windows (NSIS installer) @@ -126,13 +139,16 @@ EOF <1-2 sentence summary of the release> ### What's New + - feat: - feat: ### Improvements + - improve: ### Bug Fixes + - fix: ### Downloads @@ -179,11 +195,13 @@ EOF Write changelog entries from the **user's perspective**, not the developer's. **Good:** + - "Add team member activity timeline with live status tracking" - "Fix crash when opening sessions with corrupted JSONL data" - "Improve session list loading speed by 3x with streaming parser" **Bad:** + - "Refactor ChunkBuilder to use new pipeline" - "Update dependencies" - "Fix bug in useEffect cleanup" @@ -194,17 +212,17 @@ Group entries by type: `What's New` > `Improvements` > `Bug Fixes` > `Breaking C electron-builder generates these artifacts per platform: -| Platform | Versioned Name | Stable Name (for /latest/download) | -|------------------|--------------------------------------------------|--------------------------------------------| -| macOS arm64 DMG | `Agent.Teams.AI--arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` | -| macOS x64 DMG | `Agent.Teams.AI--x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` | -| macOS arm64 ZIP | `Agent.Teams.AI--arm64-mac.zip` | - | -| macOS x64 ZIP | `Agent.Teams.AI--x64-mac.zip` | - | -| Windows | `Agent.Teams.AI.Setup..exe` | `Claude-Agent-Teams-UI-Setup.exe` | -| Linux AppImage | `Agent.Teams.AI-.AppImage` | `Claude-Agent-Teams-UI.AppImage` | -| Linux deb | `agent-teams-ai__amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` | -| Linux rpm | `agent-teams-ai-.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` | -| Linux pacman | `agent-teams-ai-.pacman` | `Claude-Agent-Teams-UI.pacman` | +| Platform | Versioned Name | Stable Name (for /latest/download) | +| --------------- | ------------------------------------ | ---------------------------------- | +| macOS arm64 DMG | `Agent.Teams.AI--arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` | +| macOS x64 DMG | `Agent.Teams.AI--x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` | +| macOS arm64 ZIP | `Agent.Teams.AI--arm64-mac.zip` | - | +| macOS x64 ZIP | `Agent.Teams.AI--x64-mac.zip` | - | +| Windows | `Agent.Teams.AI.Setup..exe` | `Claude-Agent-Teams-UI-Setup.exe` | +| Linux AppImage | `Agent.Teams.AI-.AppImage` | `Claude-Agent-Teams-UI.AppImage` | +| Linux deb | `agent-teams-ai__amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` | +| Linux rpm | `agent-teams-ai-.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` | +| Linux pacman | `agent-teams-ai-.pacman` | `Claude-Agent-Teams-UI.pacman` | ## Stable Download Links @@ -223,19 +241,20 @@ GitHub automatically redirects `/releases/latest/download/FILENAME` to the asset macOS builds are signed and notarized via GitHub Actions secrets: -| Secret | Description | -|-------------------------------|------------------------------| -| `CSC_LINK` | Base64-encoded .p12 certificate | -| `CSC_KEY_PASSWORD` | Certificate password | -| `APPLE_ID` | Apple Developer account email | +| Secret | Description | +| ----------------------------- | -------------------------------------------- | +| `CSC_LINK` | Base64-encoded .p12 certificate | +| `CSC_KEY_PASSWORD` | Certificate password | +| `APPLE_ID` | Apple Developer account email | | `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password from appleid.apple.com | -| `APPLE_TEAM_ID` | Apple Developer Team ID | +| `APPLE_TEAM_ID` | Apple Developer Team ID | Without these secrets, macOS builds will be unsigned (users need to bypass Gatekeeper manually). ## Auto-Update The release workflow publishes canonical updater metadata after all platform assets are uploaded: + - `latest.yml` for Windows - `latest-linux.yml` for Linux - `latest-mac.yml` for macOS diff --git a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts index 2366ff03..345051b0 100644 --- a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts +++ b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts @@ -28,20 +28,19 @@ interface UseRuntimeProviderManagementOptions { export type RuntimeProviderModelPickerMode = 'use' | 'runtime-default'; +const DEFAULT_DIRECTORY_FILTER: RuntimeProviderDirectoryFilterDto = 'all'; + export interface RuntimeProviderManagementState { view: RuntimeProviderManagementViewDto | null; providers: readonly RuntimeProviderConnectionDto[]; selectedProviderId: string | null; providerQuery: string; - directoryOpen: boolean; directoryLoading: boolean; directoryRefreshing: boolean; directoryError: string | null; directoryEntries: readonly RuntimeProviderDirectoryEntryDto[]; directoryTotalCount: number | null; directoryNextCursor: string | null; - directoryQuery: string; - directoryFilter: RuntimeProviderDirectoryFilterDto; directoryLoaded: boolean; directorySelectedProviderId: string | null; directorySupported: boolean; @@ -59,7 +58,7 @@ export interface RuntimeProviderManagementState { modelsLoading: boolean; modelsError: string | null; selectedModelId: string | null; - testingModelId: string | null; + testingModelIds: readonly string[]; savingDefaultModelId: string | null; modelResults: Readonly>; loading: boolean; @@ -72,10 +71,6 @@ export interface RuntimeProviderManagementActions { refresh: () => Promise; selectProvider: (providerId: string) => void; setProviderQuery: (value: string) => void; - openDirectory: () => void; - closeDirectory: () => void; - setDirectoryQuery: (value: string) => void; - setDirectoryFilter: (value: RuntimeProviderDirectoryFilterDto) => void; loadMoreDirectory: () => Promise; refreshDirectory: () => Promise; selectDirectoryProvider: (providerId: string) => void; @@ -110,24 +105,6 @@ function replaceProvider( }; } -function resetModelState(): { - modelPickerProviderId: null; - modelPickerMode: null; - models: readonly RuntimeProviderModelDto[]; - modelsError: null; - selectedModelId: null; - modelResults: Record; -} { - return { - modelPickerProviderId: null, - modelPickerMode: null, - models: [], - modelsError: null, - selectedModelId: null, - modelResults: {}, - }; -} - function withUiTimeout(promise: Promise, message: string, timeoutMs = 70_000): Promise { return new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { @@ -169,13 +146,29 @@ function resolveSavedModelForNewTeams(models: readonly RuntimeProviderModelDto[] return models.some((model) => model.modelId === savedModelId) ? savedModelId : null; } +function formatCredentialRemovedMessage(provider: RuntimeProviderConnectionDto | null): string { + if (!provider || provider.state !== 'connected') { + return 'Credential removed'; + } + + const ownership = new Set(provider.ownership); + if (!ownership.has('managed') && ownership.has('local')) { + return 'Managed credential removed. Provider remains connected through local OpenCode credentials.'; + } + + if (!ownership.has('managed') && ownership.size > 0) { + return 'Managed credential removed. Provider remains connected through another credential source.'; + } + + return 'Credential removed'; +} + export function useRuntimeProviderManagement( options: UseRuntimeProviderManagementOptions ): [RuntimeProviderManagementState, RuntimeProviderManagementActions] { const [view, setView] = useState(null); const [selectedProviderId, setSelectedProviderId] = useState(null); const [providerQuery, setProviderQuery] = useState(''); - const [directoryOpen, setDirectoryOpen] = useState(false); const [directoryLoading, setDirectoryLoading] = useState(false); const [directoryRefreshing, setDirectoryRefreshing] = useState(false); const [directoryError, setDirectoryError] = useState(null); @@ -185,8 +178,6 @@ export function useRuntimeProviderManagement( const [directoryTotalCount, setDirectoryTotalCount] = useState(null); const [directoryNextCursor, setDirectoryNextCursor] = useState(null); const [directoryQuery, setDirectoryQuery] = useState(''); - const [directoryFilter, setDirectoryFilterState] = - useState('all'); const [directoryLoaded, setDirectoryLoaded] = useState(false); const [directorySelectedProviderId, setDirectorySelectedProviderId] = useState( null @@ -208,7 +199,7 @@ export function useRuntimeProviderManagement( const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); const [selectedModelId, setSelectedModelId] = useState(null); - const [testingModelId, setTestingModelId] = useState(null); + const [testingModelIds, setTestingModelIds] = useState([]); const [savingDefaultModelId, setSavingDefaultModelId] = useState(null); const [modelResults, setModelResults] = useState< Record @@ -219,38 +210,86 @@ export function useRuntimeProviderManagement( const [successMessage, setSuccessMessage] = useState(null); const directoryRequestSeq = useRef(0); const setupFormRequestSeq = useRef(0); + const modelLoadRequestSeq = useRef(0); + const modelProbeGenerationRef = useRef(0); + const activeModelPickerProviderRef = useRef(null); - const refresh = useCallback(async (): Promise => { - if (!options.enabled) { - return; - } - setLoading(true); - setError(null); - try { - const response = await api.runtimeProviderManagement.loadView({ - runtimeId: options.runtimeId, - projectPath: options.projectPath ?? null, - }); - if (response.error) { - setView(null); - setError(response.error.message); + const openModelPickerState = useCallback( + (providerId: string, mode: RuntimeProviderModelPickerMode): void => { + modelLoadRequestSeq.current += 1; + modelProbeGenerationRef.current += 1; + activeModelPickerProviderRef.current = providerId; + setModelPickerProviderId(providerId); + setModelPickerMode(mode); + setModelQuery(''); + setModels([]); + setModelsLoading(false); + setModelsError(null); + setSelectedModelId(null); + setModelResults({}); + setTestingModelIds([]); + }, + [] + ); + + const closeModelPickerState = useCallback((): void => { + modelLoadRequestSeq.current += 1; + modelProbeGenerationRef.current += 1; + activeModelPickerProviderRef.current = null; + setModelPickerProviderId(null); + setModelPickerMode(null); + setModelQuery(''); + setModels([]); + setModelsLoading(false); + setModelsError(null); + setSelectedModelId(null); + setModelResults({}); + setTestingModelIds([]); + }, []); + + const refresh = useCallback( + async (input: { silent?: boolean } = {}): Promise => { + if (!options.enabled) { return; } - const nextView = response.view ?? null; - setView(nextView); - setSelectedProviderId((current) => { - if (current && nextView?.providers.some((provider) => provider.providerId === current)) { - return current; + const silent = input.silent === true; + if (!silent) { + setLoading(true); + } + setError(null); + try { + const response = await api.runtimeProviderManagement.loadView({ + runtimeId: options.runtimeId, + projectPath: options.projectPath ?? null, + }); + if (response.error) { + if (!silent) { + setView(null); + } + setError(response.error.message); + return; } - return selectInitialProviderId(nextView); - }); - } catch (loadError) { - setView(null); - setError(loadError instanceof Error ? loadError.message : 'Failed to load providers'); - } finally { - setLoading(false); - } - }, [options.enabled, options.projectPath, options.runtimeId]); + const nextView = response.view ?? null; + setView(nextView); + setSelectedProviderId((current) => { + if (current && nextView?.providers.some((provider) => provider.providerId === current)) { + return current; + } + return selectInitialProviderId(nextView); + }); + } catch (loadError) { + if (!silent) { + setView(null); + } + setError(loadError instanceof Error ? loadError.message : 'Failed to load providers'); + } finally { + if (!silent) { + setLoading(false); + } + } + }, + [options.enabled, options.projectPath, options.runtimeId] + ); const loadDirectoryPage = useCallback( async ( @@ -269,7 +308,7 @@ export function useRuntimeProviderManagement( const append = input.append === true; const refreshDirectoryData = input.refresh === true; const query = input.query ?? directoryQuery; - const filter = input.filter ?? directoryFilter; + const filter = input.filter ?? DEFAULT_DIRECTORY_FILTER; const cursor = input.cursor ?? null; const requestSeq = directoryRequestSeq.current + 1; directoryRequestSeq.current = requestSeq; @@ -330,20 +369,12 @@ export function useRuntimeProviderManagement( } } }, - [ - directoryFilter, - directoryQuery, - directorySupported, - options.enabled, - options.projectPath, - options.runtimeId, - ] + [directoryQuery, directorySupported, options.enabled, options.projectPath, options.runtimeId] ); useEffect(() => { if (!options.enabled) { setProviderQuery(''); - setDirectoryOpen(false); setDirectoryLoading(false); setDirectoryRefreshing(false); setDirectoryError(null); @@ -351,7 +382,6 @@ export function useRuntimeProviderManagement( setDirectoryTotalCount(null); setDirectoryNextCursor(null); setDirectoryQuery(''); - setDirectoryFilterState('all'); setDirectoryLoaded(false); setDirectorySelectedProviderId(null); setApiKeyValue(''); @@ -361,17 +391,11 @@ export function useRuntimeProviderManagement( setSetupFormError(null); setSetupSubmitError(null); setActiveFormProviderId(null); - const reset = resetModelState(); - setModelPickerProviderId(reset.modelPickerProviderId); - setModelPickerMode(reset.modelPickerMode); - setModels(reset.models); - setModelsError(reset.modelsError); - setSelectedModelId(reset.selectedModelId); - setModelResults(reset.modelResults); + closeModelPickerState(); return; } void refresh(); - }, [options.enabled, refresh]); + }, [closeModelPickerState, options.enabled, refresh]); useEffect(() => { if (!options.enabled || !directorySupported) { @@ -383,7 +407,7 @@ export function useRuntimeProviderManagement( void loadDirectoryPage({ append: false, query: directoryQuery, - filter: directoryFilter, + filter: DEFAULT_DIRECTORY_FILTER, cursor: null, }); }, @@ -391,27 +415,28 @@ export function useRuntimeProviderManagement( ); return () => window.clearTimeout(timeout); - }, [ - directoryFilter, - directoryLoaded, - directoryQuery, - directorySupported, - loadDirectoryPage, - options.enabled, - ]); + }, [directoryLoaded, directoryQuery, directorySupported, loadDirectoryPage, options.enabled]); useEffect(() => { if (!options.enabled || !modelPickerProviderId) { + modelLoadRequestSeq.current += 1; + setModelsLoading(false); return; } + const requestSeq = modelLoadRequestSeq.current + 1; + modelLoadRequestSeq.current = requestSeq; + const providerId = modelPickerProviderId; + const requestIsCurrent = (): boolean => + modelLoadRequestSeq.current === requestSeq && + activeModelPickerProviderRef.current === providerId; let cancelled = false; setModelsLoading(true); setModelsError(null); void withUiTimeout( api.runtimeProviderManagement.loadModels({ runtimeId: options.runtimeId, - providerId: modelPickerProviderId, + providerId, projectPath: options.projectPath ?? null, query: modelQuery.trim() || null, limit: 250, @@ -419,7 +444,7 @@ export function useRuntimeProviderManagement( 'Provider models load timed out' ) .then((response) => { - if (cancelled) { + if (cancelled || !requestIsCurrent()) { return; } if (response.error) { @@ -437,7 +462,7 @@ export function useRuntimeProviderManagement( }); }) .catch((modelsLoadError) => { - if (!cancelled) { + if (!cancelled && requestIsCurrent()) { setModels([]); setModelsError( modelsLoadError instanceof Error @@ -447,7 +472,7 @@ export function useRuntimeProviderManagement( } }) .finally(() => { - if (!cancelled) { + if (!cancelled && requestIsCurrent()) { setModelsLoading(false); } }); @@ -475,57 +500,25 @@ export function useRuntimeProviderManagement( ) { const providerId = selectedProvider?.providerId ?? selectedDirectoryProvider!.providerId; if (modelPickerProviderId !== providerId) { - setModelPickerProviderId(providerId); - setModelPickerMode('use'); - setModelQuery(''); - setModels([]); - setModelsError(null); - setSelectedModelId(null); - setModelResults({}); + openModelPickerState(providerId, 'use'); } return; } if (modelPickerProviderId) { - setModelPickerProviderId(null); - setModelPickerMode(null); - setModels([]); - setModelsError(null); - setSelectedModelId(null); - setModelResults({}); + closeModelPickerState(); } }, [ activeFormProviderId, + closeModelPickerState, directoryEntries, modelPickerProviderId, + openModelPickerState, options.enabled, selectedProviderId, view, ]); - const openDirectory = useCallback((): void => { - if (!directorySupported) { - return; - } - setDirectoryOpen(true); - setDirectoryError(null); - }, [directorySupported]); - - const closeDirectory = useCallback((): void => { - setDirectoryOpen(false); - setDirectorySelectedProviderId(null); - }, []); - - const setDirectoryFilter = useCallback((value: RuntimeProviderDirectoryFilterDto): void => { - setDirectoryFilterState(value); - setDirectoryNextCursor(null); - }, []); - - const updateDirectoryQuery = useCallback((value: string): void => { - setDirectoryQuery(value); - setDirectoryNextCursor(null); - }, []); - const loadMoreDirectory = useCallback(async (): Promise => { if (!directoryNextCursor || directoryLoading || directoryRefreshing) { return; @@ -537,11 +530,15 @@ export function useRuntimeProviderManagement( }, [directoryLoading, directoryNextCursor, directoryRefreshing, loadDirectoryPage]); const refreshDirectory = useCallback(async (): Promise => { - await loadDirectoryPage({ - refresh: true, - cursor: null, - }); - }, [loadDirectoryPage]); + setSuccessMessage(null); + await Promise.all([ + refresh({ silent: true }), + loadDirectoryPage({ + refresh: true, + cursor: null, + }), + ]); + }, [loadDirectoryPage, refresh]); const selectDirectoryProvider = useCallback( (providerId: string): void => { @@ -565,21 +562,16 @@ export function useRuntimeProviderManagement( const modelCount = compactProvider?.modelCount ?? directoryProvider?.modelCount ?? null; if (connected && modelCount !== 0) { - setModelPickerProviderId(providerId); - setModelPickerMode('use'); - setModelQuery(''); - setModels([]); - setModelsError(null); - setSelectedModelId(null); - setModelResults({}); + openModelPickerState(providerId, 'use'); + } else { + closeModelPickerState(); } }, - [directoryEntries, view] + [closeModelPickerState, directoryEntries, openModelPickerState, view] ); const searchAllProviders = useCallback((query: string): void => { setDirectoryQuery(query); - setDirectoryOpen(true); setDirectoryError(null); setDirectoryNextCursor(null); }, []); @@ -588,8 +580,7 @@ export function useRuntimeProviderManagement( (providerId: string): void => { setSelectedProviderId(providerId); setActiveFormProviderId(providerId); - setModelPickerProviderId(null); - setModelPickerMode(null); + closeModelPickerState(); setApiKeyValue(''); setSetupMetadata({}); setSetupForm(null); @@ -636,7 +627,7 @@ export function useRuntimeProviderManagement( } }); }, - [options.projectPath, options.runtimeId] + [closeModelPickerState, options.projectPath, options.runtimeId] ); const updateProviderQuery = useCallback( @@ -678,11 +669,6 @@ export function useRuntimeProviderManagement( const submitConnect = useCallback( async (providerId: string): Promise => { - const apiKey = apiKeyValue.trim(); - if (!apiKey) { - setSetupSubmitError('API key is required'); - return; - } if (!setupForm) { setSetupSubmitError(setupFormError ?? 'Provider setup form is not loaded'); return; @@ -693,6 +679,11 @@ export function useRuntimeProviderManagement( ); return; } + const apiKey = apiKeyValue.trim(); + if (setupForm.secret?.required && !apiKey) { + setSetupSubmitError(`${setupForm.secret.label} is required`); + return; + } setSavingProviderId(providerId); setError(null); @@ -704,7 +695,7 @@ export function useRuntimeProviderManagement( runtimeId: options.runtimeId, providerId, method: setupForm.method, - apiKey, + apiKey: apiKey || null, metadata: setupMetadata, projectPath: options.projectPath ?? null, }), @@ -719,20 +710,22 @@ export function useRuntimeProviderManagement( } setActiveFormProviderId(null); setSuccessMessage(null); - setSavingProviderId(null); setApiKeyValue(''); setSetupMetadata({}); setSetupForm(null); setSetupFormError(null); setSetupSubmitError(null); - void Promise.resolve(options.onProviderChanged?.()) - .then(() => refresh()) - .then(() => loadDirectoryPage({ refresh: true, cursor: null })) - .catch((refreshError) => { - setError( - refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' - ); - }); + try { + await options.onProviderChanged?.(); + await Promise.all([ + refresh({ silent: true }), + loadDirectoryPage({ refresh: true, cursor: null }), + ]); + } catch (refreshError) { + setError( + refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' + ); + } } catch (connectError) { setSetupSubmitError( connectError instanceof Error ? connectError.message : 'Failed to connect provider' @@ -765,16 +758,19 @@ export function useRuntimeProviderManagement( if (response.provider) { setView((current) => replaceProvider(current, response.provider!)); } - setSuccessMessage('Credential removed'); - setSavingProviderId(null); - void Promise.resolve(options.onProviderChanged?.()) - .then(() => refresh()) - .then(() => loadDirectoryPage({ refresh: true, cursor: null })) - .catch((refreshError) => { - setError( - refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' - ); - }); + const success = formatCredentialRemovedMessage(response.provider ?? null); + try { + await options.onProviderChanged?.(); + await Promise.all([ + refresh({ silent: true }), + loadDirectoryPage({ refresh: true, cursor: null }), + ]); + } catch (refreshError) { + setError( + refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' + ); + } + setSuccessMessage(success); } catch (forgetError) { setError( forgetError instanceof Error ? forgetError.message : 'Failed to forget credential' @@ -790,28 +786,16 @@ export function useRuntimeProviderManagement( (providerId: string, mode: RuntimeProviderModelPickerMode): void => { setSelectedProviderId(providerId); setActiveFormProviderId(null); - setModelPickerProviderId(providerId); - setModelPickerMode(mode); - setModelQuery(''); - setModels([]); - setModelsError(null); - setSelectedModelId(null); - setModelResults({}); + openModelPickerState(providerId, mode); setError(null); setSuccessMessage(null); }, - [] + [openModelPickerState] ); const closeModelPicker = useCallback((): void => { - setModelPickerProviderId(null); - setModelPickerMode(null); - setModelQuery(''); - setModels([]); - setModelsError(null); - setSelectedModelId(null); - setModelResults({}); - }, []); + closeModelPickerState(); + }, [closeModelPickerState]); const useModelForNewTeams = useCallback((modelId: string): void => { saveOpenCodeModelForNewTeams(modelId); @@ -822,7 +806,14 @@ export function useRuntimeProviderManagement( const testModel = useCallback( async (providerId: string, modelId: string): Promise => { - setTestingModelId(modelId); + const probeGeneration = modelProbeGenerationRef.current; + const activeProviderAtStart = activeModelPickerProviderRef.current; + const shouldRecordProbeResult = (): boolean => + modelProbeGenerationRef.current === probeGeneration && + (activeProviderAtStart === null || activeModelPickerProviderRef.current === providerId); + setTestingModelIds((current) => + current.includes(modelId) ? current : [...current, modelId] + ); setError(null); setSuccessMessage(null); try { @@ -837,29 +828,33 @@ export function useRuntimeProviderManagement( 100_000 ); if (response.error) { - setModelResults((current) => ({ - ...current, - [modelId]: buildFailedModelTestResult(providerId, modelId, response.error!.message), - })); + if (shouldRecordProbeResult()) { + setModelResults((current) => ({ + ...current, + [modelId]: buildFailedModelTestResult(providerId, modelId, response.error!.message), + })); + } return; } - if (response.result) { + if (response.result && shouldRecordProbeResult()) { setModelResults((current) => ({ ...current, [modelId]: response.result!, })); } } catch (testError) { - setModelResults((current) => ({ - ...current, - [modelId]: buildFailedModelTestResult( - providerId, - modelId, - testError instanceof Error ? testError.message : 'Failed to test model' - ), - })); + if (shouldRecordProbeResult()) { + setModelResults((current) => ({ + ...current, + [modelId]: buildFailedModelTestResult( + providerId, + modelId, + testError instanceof Error ? testError.message : 'Failed to test model' + ), + })); + } } finally { - setTestingModelId(null); + setTestingModelIds((current) => current.filter((entry) => entry !== modelId)); } }, [options.projectPath, options.runtimeId] @@ -909,16 +904,22 @@ export function useRuntimeProviderManagement( [options] ); - const selectProvider = useCallback((providerId: string): void => { - setupFormRequestSeq.current += 1; - setSelectedProviderId(providerId); - setActiveFormProviderId(null); - setSetupForm(null); - setSetupFormError(null); - setSetupSubmitError(null); - setSetupMetadata({}); - setApiKeyValue(''); - }, []); + const selectProvider = useCallback( + (providerId: string): void => { + setupFormRequestSeq.current += 1; + setSelectedProviderId(providerId); + setActiveFormProviderId(null); + setSetupForm(null); + setSetupFormError(null); + setSetupSubmitError(null); + setSetupMetadata({}); + setApiKeyValue(''); + if (activeModelPickerProviderRef.current !== providerId) { + closeModelPickerState(); + } + }, + [closeModelPickerState] + ); const state = useMemo( () => ({ @@ -926,15 +927,12 @@ export function useRuntimeProviderManagement( providers: view?.providers ?? [], selectedProviderId, providerQuery, - directoryOpen, directoryLoading, directoryRefreshing, directoryError, directoryEntries, directoryTotalCount, directoryNextCursor, - directoryQuery, - directoryFilter, directoryLoaded, directorySelectedProviderId, directorySupported, @@ -952,7 +950,7 @@ export function useRuntimeProviderManagement( modelsLoading, modelsError, selectedModelId, - testingModelId, + testingModelIds, savingDefaultModelId, modelResults, loading, @@ -970,12 +968,9 @@ export function useRuntimeProviderManagement( setupMetadata, directoryEntries, directoryError, - directoryFilter, directoryLoaded, directoryLoading, directoryNextCursor, - directoryOpen, - directoryQuery, directoryRefreshing, directorySelectedProviderId, directorySupported, @@ -995,7 +990,7 @@ export function useRuntimeProviderManagement( selectedModelId, selectedProviderId, successMessage, - testingModelId, + testingModelIds, view, ] ); @@ -1005,10 +1000,6 @@ export function useRuntimeProviderManagement( refresh, selectProvider, setProviderQuery: updateProviderQuery, - openDirectory, - closeDirectory, - setDirectoryQuery: updateDirectoryQuery, - setDirectoryFilter, loadMoreDirectory, refreshDirectory, selectDirectoryProvider, @@ -1029,11 +1020,9 @@ export function useRuntimeProviderManagement( }), [ cancelConnect, - closeDirectory, closeModelPicker, forgetProvider, loadMoreDirectory, - openDirectory, openModelPicker, refresh, refreshDirectory, @@ -1041,13 +1030,11 @@ export function useRuntimeProviderManagement( selectDirectoryProvider, selectProvider, setDefaultModel, - setDirectoryFilter, setSetupMetadataValue, startConnect, submitConnect, testModel, updateApiKeyValue, - updateDirectoryQuery, updateProviderQuery, useModelForNewTeams, ] diff --git a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx index 28c15fff..d78d696f 100644 --- a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx +++ b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx @@ -19,7 +19,6 @@ import { } from '@renderer/utils/openCodeModelRecommendations'; import { AlertTriangle, - ArrowLeft, CheckCircle2, KeyRound, Loader2, @@ -45,7 +44,6 @@ import type { import type { RuntimeProviderConnectionDto, RuntimeProviderDirectoryEntryDto, - RuntimeProviderDirectoryFilterDto, RuntimeProviderModelDto, RuntimeProviderModelTestResultDto, RuntimeProviderSetupPromptDto, @@ -77,15 +75,6 @@ interface ProviderRowProps { readonly actions: RuntimeProviderManagementActions; } -const DIRECTORY_FILTERS: { id: RuntimeProviderDirectoryFilterDto; label: string }[] = [ - { id: 'all', label: 'All' }, - { id: 'connectable', label: 'Connectable' }, - { id: 'connected', label: 'Connected' }, - { id: 'configured', label: 'Configured' }, - { id: 'manual', label: 'Manual setup' }, - { id: 'has-models', label: 'Has models' }, -]; - function getDirectoryAction( provider: RuntimeProviderDirectoryEntryDto, actionId: RuntimeProviderConnectionDto['actions'][number]['id'] @@ -120,6 +109,10 @@ function getDirectoryModelsLabel(provider: RuntimeProviderDirectoryEntryDto): st return `${provider.modelCount} model${provider.modelCount === 1 ? '' : 's'}`; } +function formatOpenCodeProviderCount(count: number): string { + return `${count} OpenCode provider${count === 1 ? '' : 's'}`; +} + function directoryEntryMatchesQuery( provider: RuntimeProviderDirectoryEntryDto, query: string @@ -223,7 +216,10 @@ function setupPromptVisible( function setupFormCanSubmit(state: RuntimeProviderManagementState, providerId: string): boolean { const form = state.setupForm?.providerId === providerId ? state.setupForm : null; - if (!form?.supported || !form.secret || !state.apiKeyValue.trim()) { + if (!form?.supported) { + return false; + } + if (form.secret?.required && !state.apiKeyValue.trim()) { return false; } return form.prompts @@ -231,6 +227,16 @@ function setupFormCanSubmit(state: RuntimeProviderManagementState, providerId: s .every((prompt) => !prompt.required || Boolean(state.setupMetadata[prompt.key]?.trim())); } +function eventStartedInInteractiveChild( + currentTarget: HTMLElement, + target: EventTarget | null +): boolean { + if (!(target instanceof HTMLElement) || target === currentTarget) { + return false; + } + return Boolean(target.closest('button, input, select, textarea, a, [role="button"], [tabindex]')); +} + function ProviderSetupFormPanel({ provider, state, @@ -637,31 +643,28 @@ function ProviderActions({ const forget = getProviderAction(provider, 'forget'); const configure = getProviderAction(provider, 'configure'); - if (connect) { - return ( - - ); - } - return (
+ {connect ? ( + + ) : null} {forget ? ( -
-
- {state.directoryTotalCount === null - ? 'All OpenCode providers' - : `${state.directoryTotalCount} OpenCode providers`} -
- -
-
- -
-
- - actions.setDirectoryQuery(event.target.value)} - placeholder="Search all OpenCode providers" - className="h-9 pr-3 text-sm" - style={{ paddingLeft: 40 }} - /> -
-
- {DIRECTORY_FILTERS.map((filter) => ( - - ))} -
-
- - {state.directoryError ? ( -
- {state.directoryError} -
- ) : null} - -
- {state.directoryLoading && state.directoryEntries.length === 0 ? ( - - ) : null} - {state.directoryEntries.map((provider) => { - const active = state.directorySelectedProviderId === provider.providerId; - return ( -
- -
- ); - })} -
- - {!state.directoryLoading && state.directoryEntries.length === 0 && !state.directoryError ? ( -
- No providers match this search. -
- ) : null} - - {state.directoryNextCursor ? ( -
- -
- ) : null} - - ); -} - function ModelBadges({ model, usedForNewTeams, @@ -1209,6 +1101,9 @@ function ModelRow({ if (event.key !== 'Enter' && event.key !== ' ') { return; } + if (eventStartedInInteractiveChild(event.currentTarget, event.target)) { + return; + } event.stopPropagation(); event.preventDefault(); chooseModel(); @@ -1216,11 +1111,14 @@ function ModelRow({ return (
{ event.stopPropagation(); chooseModel(); @@ -1378,7 +1276,7 @@ function ProviderModelList({ model={model} selected={state.selectedModelId === model.modelId} disabled={disabled} - testing={state.testingModelId === model.modelId} + testing={state.testingModelIds.includes(model.modelId)} result={state.modelResults[model.modelId]} actions={actions} /> @@ -1417,11 +1315,12 @@ export function RuntimeProviderManagementPanelView({ const visibleDirectoryRows = state.directoryEntries.filter((provider) => directoryEntryMatchesQuery(provider, providerQuery) ); - const providerCountLabel = state.directoryTotalCount - ? `${state.directoryTotalCount} OpenCode providers` - : state.directorySupported - ? 'OpenCode provider catalog' - : 'OpenCode providers'; + const providerCountLabel = + state.directoryTotalCount !== null + ? formatOpenCodeProviderCount(state.directoryTotalCount) + : state.directorySupported + ? 'OpenCode provider catalog' + : 'OpenCode providers'; return (
diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index 62545aa1..495ba50d 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -14,22 +14,23 @@ import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import { isVersionOlder, normalizeVersion } from '@shared/utils/version'; -import electronUpdater from 'electron-updater'; - -const { autoUpdater } = electronUpdater; - import { app, net } from 'electron'; +import electronUpdater from 'electron-updater'; import { getExpectedReleaseAssetUrls, getLatestMacMetadataUrls, + getReleaseApiUrls, isLatestMacMetadataCompatible, + shouldSkipReleaseForUpdater, } from './updaterReleaseMetadata'; +import type { GithubReleaseMetadata } from './updaterReleaseMetadata'; import type { UpdaterStatus } from '@shared/types'; import type { BrowserWindow } from 'electron'; const logger = createLogger('UpdaterService'); +const { autoUpdater } = electronUpdater; function shouldSkipDevUpdateCheck(): boolean { return ( @@ -72,6 +73,21 @@ async function fetchText(url: string): Promise { } } +async function fetchJson(url: string): Promise { + try { + const response = await net.fetch(url, { + method: 'GET', + headers: { Accept: 'application/vnd.github+json' }, + }); + if (!response.ok) { + return null; + } + return (await response.json()) as T; + } catch { + return null; + } +} + export class UpdaterService { private mainWindow: BrowserWindow | null = null; private periodicTimer: ReturnType | null = null; @@ -194,6 +210,20 @@ export class UpdaterService { return false; } + private async isSkippedRelease(version: string): Promise { + const metadataUrls = getReleaseApiUrls(version); + for (const metadataUrl of metadataUrls) { + const release = await fetchJson(metadataUrl); + if (!release) { + continue; + } + return shouldSkipReleaseForUpdater(release); + } + + logger.warn(`GitHub release metadata is not available for ${version}, allowing updater check`); + return false; + } + /** * Verify that the platform-specific asset exists before notifying the renderer. * If CI hasn't finished uploading the artifact for this OS yet, suppress the @@ -201,7 +231,8 @@ export class UpdaterService { */ private async verifyAndNotify(info: { version: string; - releaseNotes?: string | unknown; + releaseName?: unknown; + releaseNotes?: unknown; }): Promise { if (!this.isNewerThanCurrent(info.version)) { logger.warn( @@ -210,6 +241,22 @@ export class UpdaterService { return; } + if ( + shouldSkipReleaseForUpdater({ + tag_name: `v${info.version}`, + name: typeof info.releaseName === 'string' ? info.releaseName : undefined, + body: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, + }) + ) { + logger.warn(`Suppressing updater notification for locally marked release ${info.version}`); + return; + } + + if (await this.isSkippedRelease(info.version)) { + logger.warn(`Suppressing updater notification for skipped release ${info.version}`); + return; + } + const urls = getExpectedReleaseAssetUrls(info.version, process.platform, process.arch); if (urls.length > 0) { const exists = await assetExistsInAnyRepo(urls); diff --git a/src/main/services/infrastructure/updaterReleaseMetadata.ts b/src/main/services/infrastructure/updaterReleaseMetadata.ts index e1f26740..2ab0cd15 100644 --- a/src/main/services/infrastructure/updaterReleaseMetadata.ts +++ b/src/main/services/infrastructure/updaterReleaseMetadata.ts @@ -2,6 +2,21 @@ const REPO_OWNER = '777genius'; const REPO_NAME = 'agent-teams-ai'; const LEGACY_REPO_NAME = 'claude_agent_teams_ui'; +const UPDATER_SKIP_MARKERS = [ + '[skip-updater]', + '[test-release]', + '[internal-release]', + '[no-autoupdate]', +]; + +export interface GithubReleaseMetadata { + tag_name?: string | null; + name?: string | null; + body?: string | null; + draft?: boolean; + prerelease?: boolean; +} + export function buildReleaseAssetBase(version: string, repoName = REPO_NAME): string { return `https://github.com/${REPO_OWNER}/${repoName}/releases/download/v${version}`; } @@ -10,6 +25,25 @@ export function buildReleaseAssetBases(version: string): readonly string[] { return [buildReleaseAssetBase(version), buildReleaseAssetBase(version, LEGACY_REPO_NAME)]; } +export function getReleaseApiUrls(version: string): readonly string[] { + return [REPO_NAME, LEGACY_REPO_NAME].map( + (repoName) => `https://api.github.com/repos/${REPO_OWNER}/${repoName}/releases/tags/v${version}` + ); +} + +export function shouldSkipReleaseForUpdater(release: GithubReleaseMetadata): boolean { + if (release.draft || release.prerelease) { + return true; + } + + const searchableText = [release.tag_name, release.name, release.body] + .filter((value): value is string => typeof value === 'string') + .join('\n') + .toLowerCase(); + + return UPDATER_SKIP_MARKERS.some((marker) => searchableText.includes(marker)); +} + export function getExpectedReleaseAssetUrl( version: string, platform: NodeJS.Platform, @@ -78,12 +112,18 @@ export function parseReleaseMetadataAssetNames(metadataText: string): Set 0 ? `${rawLinesCapturedLabel}; none are assistant/tool output yet.` : undefined; + return (
{/* Toolbar */} @@ -72,8 +77,8 @@ export const ClaudeLogsPanel = ({ {data.total > 0 ? ( <> - {data.total} raw line - {data.total === 1 ? '' : 's'} + {data.total.toLocaleString()} raw line + {data.total === 1 ? '' : 's'} captured ) : isAlive ? ( 'No logs yet.' @@ -138,6 +143,7 @@ export const ClaudeLogsPanel = ({ containerRefCallback={containerRefCallback} onScroll={handleScroll} compactMetaInTooltip={compactMetaInTooltip} + emptyMessageOverride={emptyRawLogsMessage} viewerState={viewerState} onViewerStateChange={onViewerStateChange} footer={ diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 63480f94..1466b8f3 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -52,6 +52,8 @@ interface CliLogsRichViewProps { style?: React.CSSProperties; /** Content rendered at the very bottom of the scroll container (e.g. "Show more" button). */ footer?: React.ReactNode; + /** Optional message for non-empty raw input that produces no renderable log entries. */ + emptyMessageOverride?: string; /** When true, hide compact inline metadata and expose it via hover tooltip instead. */ compactMetaInTooltip?: boolean; @@ -354,6 +356,7 @@ export const CliLogsRichView = ({ className, style, footer, + emptyMessageOverride, compactMetaInTooltip = false, viewerState: controlledState, onViewerStateChange, @@ -378,7 +381,7 @@ export const CliLogsRichView = ({ const entries = useMemo(() => groupBySubagent(groups), [groups]); const emptyMessage = cliLogsTail.trim().length > 0 - ? 'No displayable assistant/runtime logs yet.' + ? (emptyMessageOverride ?? 'Raw log lines captured, but none are displayable yet.') : 'Waiting for response...'; // Derive expanded state: all groups expanded unless manually collapsed diff --git a/src/renderer/components/team/useClaudeLogsController.ts b/src/renderer/components/team/useClaudeLogsController.ts index a33025ab..95d00d3d 100644 --- a/src/renderer/components/team/useClaudeLogsController.ts +++ b/src/renderer/components/team/useClaudeLogsController.ts @@ -14,7 +14,6 @@ import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { - createDefaultClaudeLogsSidebarUiState, getTeamClaudeLogsSidebarUiState, setTeamClaudeLogsSidebarUiState, } from './sidebar/teamSidebarUiState'; @@ -58,7 +57,7 @@ export interface ClaudeLogsController { // Computed filteredText: string; online: boolean; - badge: number | undefined; + badge: string | undefined; showMoreVisible: boolean; lastLogPreview: LastLogPreview | null; @@ -364,14 +363,6 @@ function filterStreamJsonText( return out.join('\n'); } -// ============================================================================= -// Default viewer state -// ============================================================================= - -function createDefaultViewerState(): ClaudeLogsViewerState { - return createDefaultClaudeLogsSidebarUiState().viewerState; -} - // ============================================================================= // Hook // ============================================================================= @@ -608,7 +599,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController return filterStreamJsonText(data.lines, searchQuery, filter); }, [data.lines, normalizedText, searchQuery, filter]); - const badge = data.total > 0 ? data.total : undefined; + const badge = data.total > 0 ? `${data.total.toLocaleString()} raw` : undefined; // ── Container ref callback ──────────────────────────────────────────── const containerRefCallback = useCallback((el: HTMLDivElement | null) => { diff --git a/test/main/services/infrastructure/updaterReleaseMetadata.test.ts b/test/main/services/infrastructure/updaterReleaseMetadata.test.ts index 327a9072..ddf6d19d 100644 --- a/test/main/services/infrastructure/updaterReleaseMetadata.test.ts +++ b/test/main/services/infrastructure/updaterReleaseMetadata.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + getReleaseApiUrls, getExpectedLatestMacArtifacts, getExpectedReleaseAssetUrl, getExpectedReleaseAssetUrls, @@ -8,6 +9,7 @@ import { getLatestMacMetadataUrls, isLatestMacMetadataCompatible, parseReleaseMetadataAssetNames, + shouldSkipReleaseForUpdater, } from '../../../../src/main/services/infrastructure/updaterReleaseMetadata'; describe('updaterReleaseMetadata', () => { @@ -31,6 +33,28 @@ describe('updaterReleaseMetadata', () => { 'https://github.com/777genius/agent-teams-ai/releases/download/v1.2.3/Agent.Teams.AI-1.2.3-arm64.dmg', 'https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.2.3/Agent.Teams.AI-1.2.3-arm64.dmg', ]); + expect(getReleaseApiUrls('1.2.3')).toEqual([ + 'https://api.github.com/repos/777genius/agent-teams-ai/releases/tags/v1.2.3', + 'https://api.github.com/repos/777genius/claude_agent_teams_ui/releases/tags/v1.2.3', + ]); + }); + + it('detects releases that must be hidden from auto-updater', () => { + expect(shouldSkipReleaseForUpdater({ tag_name: 'v1.2.3', name: 'v1.2.3' })).toBe(false); + expect(shouldSkipReleaseForUpdater({ tag_name: 'v1.2.4', prerelease: true })).toBe(true); + expect(shouldSkipReleaseForUpdater({ tag_name: 'v1.2.5', draft: true })).toBe(true); + expect( + shouldSkipReleaseForUpdater({ + tag_name: 'v1.2.6', + name: 'Internal smoke [skip-updater]', + }) + ).toBe(true); + expect( + shouldSkipReleaseForUpdater({ + tag_name: 'v1.2.7', + body: 'Temporary QA build [test-release]', + }) + ).toBe(true); }); it('extracts updater asset names from latest-mac.yml text', () => { @@ -47,10 +71,7 @@ path: Agent.Teams.AI-1.2.3-arm64-mac.zip `; expect(parseReleaseMetadataAssetNames(metadata)).toEqual( - new Set([ - 'Agent.Teams.AI-1.2.3-arm64-mac.zip', - 'Agent.Teams.AI-1.2.3-arm64.dmg', - ]) + new Set(['Agent.Teams.AI-1.2.3-arm64-mac.zip', 'Agent.Teams.AI-1.2.3-arm64.dmg']) ); }); diff --git a/test/renderer/components/team/ClaudeLogsPanel.test.ts b/test/renderer/components/team/ClaudeLogsPanel.test.ts index 28d35eb4..7e32a9fb 100644 --- a/test/renderer/components/team/ClaudeLogsPanel.test.ts +++ b/test/renderer/components/team/ClaudeLogsPanel.test.ts @@ -5,17 +5,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController'; const cliLogsRichViewState = vi.hoisted(() => ({ - calls: [] as Array>, + calls: [] as Record[], })); vi.mock('@renderer/components/team/CliLogsRichView', () => ({ CliLogsRichView: (props: Record) => { cliLogsRichViewState.calls.push(props); - return React.createElement( - 'div', - { 'data-testid': 'cli-logs-rich-view' }, - String(props.cliLogsTail ?? '') - ); + const cliLogsTail = typeof props.cliLogsTail === 'string' ? props.cliLogsTail : ''; + return React.createElement('div', { 'data-testid': 'cli-logs-rich-view' }, cliLogsTail); }, })); @@ -58,8 +55,8 @@ function createController(overrides: Partial = {}): Claude setFilterOpen: vi.fn(), viewerState: {} as ClaudeLogsController['viewerState'], onViewerStateChange: vi.fn(), - applyPending: vi.fn(async () => {}), - loadOlderLogs: vi.fn(async () => {}), + applyPending: vi.fn(() => Promise.resolve()), + loadOlderLogs: vi.fn(() => Promise.resolve()), containerRefCallback: vi.fn(), handleScroll: vi.fn(), ...overrides, @@ -87,7 +84,7 @@ describe('ClaudeLogsPanel', () => { updatedAt: '2026-04-19T10:00:01.000Z', }, filteredText: '[stdout]\nfirst line\nsecond line', - badge: 2, + badge: '2 raw', }); await act(async () => { @@ -133,4 +130,39 @@ describe('ClaudeLogsPanel', () => { await Promise.resolve(); }); }); + + it('explains raw-only logs instead of showing an empty displayable-log message', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const ctrl = createController({ + isAlive: true, + data: { + lines: [ + '[stdout] {"type":"system","subtype":"init"}', + '[stdout] {"type":"thread.started","thread_id":"thread-1"}', + ], + total: 16, + hasMore: false, + }, + filteredText: '[stdout]\n{"type":"thread.started","thread_id":"thread-1"}', + badge: '16 raw', + }); + + await act(async () => { + root.render(React.createElement(ClaudeLogsPanel, { ctrl })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('16 raw lines captured'); + expect(cliLogsRichViewState.calls.at(-1)?.emptyMessageOverride).toBe( + '16 raw lines captured; none are assistant/tool output yet.' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts index 8dcdae41..272cf737 100644 --- a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts +++ b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts @@ -53,15 +53,12 @@ function createState( providers: [], selectedProviderId: 'openrouter', providerQuery: '', - directoryOpen: false, directoryLoading: false, directoryRefreshing: false, directoryError: null, directoryEntries: [], directoryTotalCount: null, directoryNextCursor: null, - directoryQuery: '', - directoryFilter: 'all', directoryLoaded: false, directorySelectedProviderId: null, directorySupported: true, @@ -79,7 +76,7 @@ function createState( modelsLoading: false, modelsError: null, selectedModelId: null, - testingModelId: null, + testingModelIds: [], savingDefaultModelId: null, modelResults: {}, loading: false, @@ -95,10 +92,6 @@ function createActions(): RuntimeProviderManagementActions { refresh: vi.fn(() => Promise.resolve()), selectProvider: vi.fn(), setProviderQuery: vi.fn(), - openDirectory: vi.fn(), - closeDirectory: vi.fn(), - setDirectoryQuery: vi.fn(), - setDirectoryFilter: vi.fn(), loadMoreDirectory: vi.fn(() => Promise.resolve()), refreshDirectory: vi.fn(() => Promise.resolve()), selectDirectoryProvider: vi.fn(), @@ -248,6 +241,146 @@ describe('RuntimeProviderManagementPanelView', () => { expect(host.textContent).not.toContain('sk-secret-value'); }); + it('allows supported setup forms that do not require a secret to submit', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const actions = createActions(); + const state = createState(); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: { + ...state, + providers: state.view?.providers ?? [], + activeFormProviderId: 'openrouter', + setupForm: { + runtimeId: 'opencode', + providerId: 'openrouter', + displayName: 'OpenRouter', + method: 'oauth', + supported: true, + title: 'Connect OpenRouter', + description: null, + submitLabel: 'Connect', + disabledReason: null, + source: 'oauth', + secret: null, + prompts: [], + }, + }, + actions, + disabled: false, + }) + ); + await Promise.resolve(); + }); + + const submitButton = Array.from(host.querySelectorAll('button')) + .filter((button) => button.textContent?.trim() === 'Connect') + .at(-1); + expect(submitButton?.disabled).toBe(false); + }); + + it('renders multiple compact provider actions without hiding forget behind connect', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const actions = createActions(); + const provider = { + ...createState().view!.providers[0], + actions: [ + { + id: 'connect' as const, + label: 'Connect', + enabled: true, + disabledReason: null, + requiresSecret: true, + ownershipScope: 'managed' as const, + }, + { + id: 'forget' as const, + label: 'Forget', + enabled: true, + disabledReason: null, + requiresSecret: false, + ownershipScope: 'managed' as const, + }, + ], + }; + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + view: { + ...createState().view!, + providers: [provider], + }, + providers: [provider], + }), + actions, + disabled: false, + }) + ); + await Promise.resolve(); + }); + + const buttons = Array.from(host.querySelectorAll('button')); + expect(buttons.some((button) => button.textContent?.includes('Connect'))).toBe(true); + expect(buttons.some((button) => button.textContent?.includes('Forget'))).toBe(true); + + await act(async () => { + buttons + .find((button) => button.textContent?.includes('Forget')) + ?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await Promise.resolve(); + }); + + expect(actions.startConnect).not.toHaveBeenCalled(); + + await act(async () => { + buttons + .find((button) => button.textContent?.includes('Forget')) + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(actions.forgetProvider).toHaveBeenCalledWith('openrouter'); + expect(actions.startConnect).not.toHaveBeenCalled(); + }); + + it('supports keyboard activation for compact provider rows', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const actions = createActions(); + const state = createState(); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: { ...state, providers: state.view?.providers ?? [] }, + actions, + disabled: false, + }) + ); + await Promise.resolve(); + }); + + const row = host.querySelector('[data-testid="runtime-provider-row-openrouter"]'); + expect(row?.getAttribute('role')).toBe('button'); + expect(row?.getAttribute('tabindex')).toBe('0'); + + await act(async () => { + row?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await Promise.resolve(); + }); + + expect(actions.startConnect).toHaveBeenCalledWith('openrouter'); + }); + it('filters providers from the local provider search', async () => { const host = document.createElement('div'); document.body.appendChild(host); @@ -353,7 +486,6 @@ describe('RuntimeProviderManagementPanelView', () => { root.render( React.createElement(RuntimeProviderManagementPanelView, { state: createState({ - directoryOpen: true, directoryLoaded: true, directoryTotalCount: 115, directoryEntries: [ @@ -454,6 +586,127 @@ describe('RuntimeProviderManagementPanelView', () => { expect(actions.selectDirectoryProvider).not.toHaveBeenCalled(); }); + it('shows an explicit zero-provider catalog count', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const actions = createActions(); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + directoryLoaded: true, + directoryTotalCount: 0, + directoryEntries: [], + }), + actions, + disabled: false, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('0 OpenCode providers'); + expect(host.textContent).not.toContain('OpenCode provider catalog.'); + }); + + it('uses singular provider catalog copy for one provider', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const actions = createActions(); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + directoryLoaded: true, + directoryTotalCount: 1, + directoryEntries: [], + }), + actions, + disabled: false, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('1 OpenCode provider.'); + expect(host.textContent).not.toContain('1 OpenCode providers'); + }); + + it('renders every advertised directory action instead of hiding configure behind connect', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const actions = createActions(); + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + directoryLoaded: true, + directoryTotalCount: 1, + directoryEntries: [ + { + providerId: 'manual-connectable', + displayName: 'Manual Connectable', + state: 'not-connected', + setupKind: 'connect-api-key', + ownership: [], + recommended: false, + modelCount: 1, + defaultModelId: null, + authMethods: ['api'], + actions: [ + { + id: 'connect', + label: 'Connect', + enabled: true, + disabledReason: null, + requiresSecret: true, + ownershipScope: 'managed', + }, + { + id: 'configure', + label: 'Configure manually', + enabled: false, + disabledReason: 'Manual fallback is also available', + requiresSecret: false, + ownershipScope: 'runtime', + }, + ], + sources: ['opencode-provider'], + sourceLabel: 'OpenCode catalog', + providerSource: 'models.dev', + detail: null, + metadata: { + hasKnownModels: true, + requiresManualConfig: true, + supportedInlineAuth: true, + }, + }, + ], + }), + actions, + disabled: false, + }) + ); + await Promise.resolve(); + }); + + const row = host.querySelector( + '[data-testid="runtime-provider-directory-row-manual-connectable"]' + ); + const actionLabels = Array.from(row?.querySelectorAll('button') ?? []).map((button) => + button.textContent?.trim() + ); + + expect(actionLabels).toContain('Connect'); + expect(actionLabels).toContain('Configure manually'); + }); + it('uses the unified provider search when compact search has no matches', async () => { const host = document.createElement('div'); document.body.appendChild(host); @@ -665,12 +918,8 @@ describe('RuntimeProviderManagementPanelView', () => { const modelList = host.querySelector( '[data-testid="runtime-provider-model-list"]' ); - expect( - modelSearch?.style.paddingLeft - ).toBe('42px'); - expect( - modelList?.style.maxHeight - ).toBe('300px'); + expect(modelSearch?.style.paddingLeft).toBe('42px'); + expect(modelList?.style.maxHeight).toBe('300px'); expect(host.textContent).not.toContain('OpenRouterfree'); const firstTestButton = Array.from(host.querySelectorAll('button')).find( (button) => button.textContent?.trim() === 'Test' @@ -709,6 +958,21 @@ describe('RuntimeProviderManagementPanelView', () => { expect(actions.selectProvider).not.toHaveBeenCalled(); vi.mocked(actions.useModelForNewTeams).mockClear(); + await act(async () => { + const notRecommendedRow = host.querySelector( + '[data-testid="runtime-provider-model-row-openrouter/openai/gpt-oss-20b:free"]' + ); + const notRecommendedTestButton = Array.from( + notRecommendedRow?.querySelectorAll('button') ?? [] + ).find((button) => button.textContent?.trim() === 'Test'); + notRecommendedTestButton?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + ); + await Promise.resolve(); + }); + + expect(actions.useModelForNewTeams).not.toHaveBeenCalled(); + await act(async () => { const notRecommendedRow = host.querySelector( '[data-testid="runtime-provider-model-row-openrouter/openai/gpt-oss-20b:free"]' @@ -783,6 +1047,66 @@ describe('RuntimeProviderManagementPanelView', () => { ); }); + it('does not expose disabled model rows as active buttons', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const actions = createActions(); + const connectedProvider = { + ...createState().view!.providers[0], + state: 'connected' as const, + ownership: ['managed'] as const, + modelCount: 1, + actions: [], + }; + + await act(async () => { + root.render( + React.createElement(RuntimeProviderManagementPanelView, { + state: createState({ + view: { + ...createState().view!, + providers: [connectedProvider], + }, + providers: [connectedProvider], + selectedProviderId: 'openrouter', + modelPickerProviderId: 'openrouter', + modelPickerMode: 'use', + models: [ + { + providerId: 'openrouter', + modelId: 'openrouter/google/gemini-3-flash-preview', + displayName: 'google/gemini-3-flash-preview', + sourceLabel: 'OpenRouter', + free: false, + default: false, + availability: 'untested', + }, + ], + }), + actions, + disabled: true, + }) + ); + await Promise.resolve(); + }); + + const row = host.querySelector( + '[data-testid="runtime-provider-model-row-openrouter/google/gemini-3-flash-preview"]' + ); + + expect(row?.getAttribute('role')).toBeNull(); + expect(row?.getAttribute('aria-disabled')).toBe('true'); + expect(row?.tabIndex).toBe(-1); + + await act(async () => { + row?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(actions.useModelForNewTeams).not.toHaveBeenCalled(); + }); + it('keeps directory provider models visible when a model row is selected', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts index 87948fab..7877c63e 100644 --- a/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts +++ b/test/renderer/features/runtime-provider-management/useRuntimeProviderManagement.test.ts @@ -13,9 +13,16 @@ import { } from '../../../../src/renderer/services/createTeamPreferences'; import type { ElectronAPI } from '../../../../src/shared/types/api'; -import type { RuntimeProviderManagementModelTestResponse } from '../../../../src/features/runtime-provider-management/contracts'; +import type { + RuntimeProviderConnectionDto, + RuntimeProviderDirectoryEntryDto, + RuntimeProviderManagementModelTestResponse, + RuntimeProviderManagementViewDto, +} from '../../../../src/features/runtime-provider-management/contracts'; -function installRuntimeProviderManagementApi(response: RuntimeProviderManagementModelTestResponse): void { +function installRuntimeProviderManagementApi( + response: RuntimeProviderManagementModelTestResponse +): void { Object.defineProperty(window, 'electronAPI', { configurable: true, value: { @@ -26,6 +33,56 @@ function installRuntimeProviderManagementApi(response: RuntimeProviderManagement }); } +function createRuntimeView( + providers: readonly RuntimeProviderConnectionDto[] = [] +): RuntimeProviderManagementViewDto { + return { + runtimeId: 'opencode', + title: 'OpenCode', + runtime: { + state: 'ready', + cliPath: '/opt/homebrew/bin/opencode', + version: '1.0.0', + managedProfile: 'active', + localAuth: 'synced', + }, + providers, + defaultModel: null, + fallbackModel: null, + diagnostics: [], + }; +} + +function createOpenAiLocalProvider(): RuntimeProviderConnectionDto { + return { + providerId: 'openai', + displayName: 'OpenAI', + state: 'connected', + ownership: ['local'], + recommended: true, + modelCount: 12, + defaultModelId: null, + authMethods: ['oauth'], + actions: [], + detail: 'Connected via local OpenCode credential', + }; +} + +function createOpenAiLocalDirectoryEntry(): RuntimeProviderDirectoryEntryDto { + return { + ...createOpenAiLocalProvider(), + setupKind: 'connected', + sources: ['opencode-provider'], + sourceLabel: 'OpenCode catalog', + providerSource: 'models.dev', + metadata: { + hasKnownModels: true, + requiresManualConfig: false, + supportedInlineAuth: false, + }, + }; +} + describe('useRuntimeProviderManagement', () => { let host: HTMLDivElement; let state: RuntimeProviderManagementState | null = null; @@ -90,21 +147,7 @@ describe('useRuntimeProviderManagement', () => { Promise.resolve({ 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: [], - defaultModel: null, - fallbackModel: null, - diagnostics: [], - }, + view: createRuntimeView(), }) ); Object.defineProperty(window, 'electronAPI', { @@ -128,6 +171,392 @@ describe('useRuntimeProviderManagement', () => { }); }); + it('refreshes view and catalog after forgetting managed auth while local auth remains', async () => { + const localProvider = createOpenAiLocalProvider(); + const loadView = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + view: createRuntimeView([localProvider]), + }) + ); + const loadProviderDirectory = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + directory: { + runtimeId: 'opencode', + totalCount: 1, + returnedCount: 1, + query: null, + filter: 'all', + limit: 50, + cursor: null, + nextCursor: null, + fetchedAt: '2026-04-25T00:00:00.000Z', + entries: [createOpenAiLocalDirectoryEntry()], + diagnostics: [], + }, + }) + ); + const forgetCredential = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + provider: localProvider, + }) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + loadView, + loadProviderDirectory, + forgetCredential, + loadModels: vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + models: { + runtimeId: 'opencode', + providerId: 'openai', + models: [], + defaultModelId: null, + diagnostics: [], + }, + }) + ), + }, + } as unknown as ElectronAPI, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(EnabledHarness, { projectPath: '/tmp/project-a' })); + await Promise.resolve(); + }); + await vi.waitFor(() => { + expect(loadView).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + await actions?.forgetProvider('openai'); + }); + + expect(forgetCredential).toHaveBeenCalledWith({ + runtimeId: 'opencode', + providerId: 'openai', + projectPath: '/tmp/project-a', + }); + expect(loadView).toHaveBeenCalledTimes(2); + const refreshDirectoryArgs = { + runtimeId: 'opencode', + projectPath: '/tmp/project-a', + query: null, + filter: 'all', + limit: 50, + cursor: null, + refresh: true, + }; + expect(loadProviderDirectory).toHaveBeenCalledWith(refreshDirectoryArgs); + expect(state?.successMessage).toBe( + 'Managed credential removed. Provider remains connected through local OpenCode credentials.' + ); + + await act(async () => { + await actions?.refreshDirectory(); + }); + + expect(loadView).toHaveBeenCalledTimes(3); + expect( + loadProviderDirectory.mock.calls.filter((call) => { + const input = (call as unknown[])[0] as { refresh?: boolean } | undefined; + return input?.refresh === true; + }) + ).toHaveLength(2); + expect(state?.successMessage).toBeNull(); + + await act(async () => { + root.unmount(); + }); + }); + + it('keeps connect action busy until the post-connect refresh finishes', async () => { + const disconnectedProvider: RuntimeProviderConnectionDto = { + ...createOpenAiLocalProvider(), + state: 'not-connected', + ownership: [], + modelCount: 0, + actions: [ + { + id: 'connect', + label: 'Connect', + enabled: true, + disabledReason: null, + requiresSecret: true, + ownershipScope: 'managed', + }, + ], + detail: null, + }; + const connectedProvider = createOpenAiLocalProvider(); + const initialViewResponse = { + schemaVersion: 1 as const, + runtimeId: 'opencode' as const, + view: createRuntimeView([disconnectedProvider]), + }; + const refreshedViewResponse = { + schemaVersion: 1 as const, + runtimeId: 'opencode' as const, + view: createRuntimeView([connectedProvider]), + }; + const directoryResponse = { + schemaVersion: 1 as const, + runtimeId: 'opencode' as const, + directory: { + runtimeId: 'opencode' as const, + totalCount: 1, + returnedCount: 1, + query: null, + filter: 'all' as const, + limit: 50, + cursor: null, + nextCursor: null, + fetchedAt: '2026-04-25T00:00:00.000Z', + entries: [createOpenAiLocalDirectoryEntry()], + diagnostics: [], + }, + }; + let resolveRefreshView: (() => void) | null = null; + let resolveRefreshDirectory: (() => void) | null = null; + const loadView = vi + .fn() + .mockResolvedValueOnce(initialViewResponse) + .mockImplementation( + () => + new Promise((resolve) => { + resolveRefreshView = () => resolve(refreshedViewResponse); + }) + ); + const loadProviderDirectory = vi + .fn() + .mockResolvedValueOnce(directoryResponse) + .mockImplementation( + () => + new Promise((resolve) => { + resolveRefreshDirectory = () => resolve(directoryResponse); + }) + ); + const loadSetupForm = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + setupForm: { + runtimeId: 'opencode', + providerId: 'openai', + displayName: 'OpenAI', + method: 'api', + supported: true, + title: 'Connect OpenAI', + description: null, + submitLabel: 'Connect', + disabledReason: null, + source: 'curated', + secret: { + key: 'key', + label: 'API key', + placeholder: 'Paste API key', + required: true, + }, + prompts: [], + }, + }) + ); + const connectProvider = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + provider: connectedProvider, + }) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + loadView, + loadProviderDirectory, + loadSetupForm, + connectProvider, + loadModels: vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + models: { + runtimeId: 'opencode', + providerId: 'openai', + models: [], + defaultModelId: null, + diagnostics: [], + }, + }) + ), + }, + } 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 () => { + actions?.startConnect('openai'); + actions?.setApiKeyValue('sk-good-value'); + await vi.waitFor(() => { + expect(loadSetupForm).toHaveBeenCalled(); + }); + }); + + let submitPromise: Promise | null = null; + await act(async () => { + submitPromise = actions?.submitConnect('openai') ?? null; + await vi.waitFor(() => { + expect(connectProvider).toHaveBeenCalled(); + }); + await Promise.resolve(); + }); + + expect(state?.savingProviderId).toBe('openai'); + expect(state?.activeFormProviderId).toBeNull(); + + await act(async () => { + resolveRefreshView?.(); + resolveRefreshDirectory?.(); + await submitPromise; + }); + + expect(loadView).toHaveBeenCalledTimes(2); + expect( + loadProviderDirectory.mock.calls.filter((call) => { + const input = (call as unknown[])[0] as { refresh?: boolean } | undefined; + return input?.refresh === true; + }) + ).toHaveLength(1); + expect(state?.savingProviderId).toBeNull(); + + await act(async () => { + root.unmount(); + }); + }); + + it('keeps provider data visible during catalog refresh', async () => { + const localProvider = { ...createOpenAiLocalProvider(), modelCount: 0 }; + const localDirectoryEntry = { ...createOpenAiLocalDirectoryEntry(), modelCount: 0 }; + const viewResponse = { + schemaVersion: 1 as const, + runtimeId: 'opencode' as const, + view: createRuntimeView([localProvider]), + }; + const directoryResponse = { + schemaVersion: 1 as const, + runtimeId: 'opencode' as const, + directory: { + runtimeId: 'opencode' as const, + totalCount: 1, + returnedCount: 1, + query: null, + filter: 'all' as const, + limit: 50, + cursor: null, + nextCursor: null, + fetchedAt: '2026-04-25T00:00:00.000Z', + entries: [localDirectoryEntry], + diagnostics: [], + }, + }; + let resolveRefreshView: (() => void) | null = null; + let resolveRefreshDirectory: (() => void) | null = null; + const loadView = vi + .fn() + .mockResolvedValueOnce(viewResponse) + .mockImplementation( + () => + new Promise((resolve) => { + resolveRefreshView = () => resolve(viewResponse); + }) + ); + const loadProviderDirectory = vi + .fn() + .mockResolvedValueOnce(directoryResponse) + .mockImplementation( + () => + new Promise((resolve) => { + resolveRefreshDirectory = () => resolve(directoryResponse); + }) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + loadView, + loadProviderDirectory, + loadModels: vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + models: { + runtimeId: 'opencode', + providerId: 'openai', + models: [], + defaultModelId: null, + diagnostics: [], + }, + }) + ), + }, + } 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 () => { + await new Promise((resolve) => window.setTimeout(resolve, 10)); + }); + await act(async () => { + await vi.waitFor(() => { + expect(state?.providers).toHaveLength(1); + expect(state?.directoryEntries).toHaveLength(1); + }); + }); + + let refreshPromise: Promise | null = null; + await act(async () => { + refreshPromise = actions?.refreshDirectory() ?? null; + await Promise.resolve(); + }); + + expect(state?.loading).toBe(false); + expect(state?.directoryRefreshing).toBe(true); + expect(state?.providers).toHaveLength(1); + expect(state?.directoryEntries).toHaveLength(1); + + await act(async () => { + resolveRefreshView?.(); + resolveRefreshDirectory?.(); + await refreshPromise; + }); + + expect(state?.loading).toBe(false); + expect(state?.directoryRefreshing).toBe(false); + + await act(async () => { + root.unmount(); + }); + }); + it('lazy-loads provider directory and ignores stale search responses', async () => { let resolveFirst: ((value: unknown) => void) | null = null; const loadView = vi.fn(() => @@ -151,53 +580,52 @@ describe('useRuntimeProviderManagement', () => { }, }) ); - const loadProviderDirectory = vi - .fn() - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirst = resolve; - }) - ) - .mockResolvedValueOnce({ - schemaVersion: 1, - runtimeId: 'opencode', - directory: { - runtimeId: 'opencode', - totalCount: 1, - returnedCount: 1, - query: 'deep', - filter: 'all', - limit: 50, - cursor: null, - nextCursor: null, - fetchedAt: '2026-04-25T00:00:00.000Z', - entries: [ - { - providerId: 'deepseek', - displayName: 'DeepSeek', - state: 'available', - setupKind: 'available-readonly', - ownership: [], - recommended: false, - modelCount: 62, - authMethods: [], - defaultModelId: null, - sources: ['opencode-provider'], - sourceLabel: 'OpenCode catalog', - providerSource: 'models.dev', - detail: null, - actions: [], - metadata: { - hasKnownModels: true, - requiresManualConfig: false, - supportedInlineAuth: false, - }, + const deepseekDirectoryResponse = { + schemaVersion: 1 as const, + runtimeId: 'opencode' as const, + directory: { + runtimeId: 'opencode' as const, + totalCount: 1, + returnedCount: 1, + query: 'deep', + filter: 'all' as const, + limit: 50, + cursor: null, + nextCursor: null, + fetchedAt: '2026-04-25T00:00:00.000Z', + entries: [ + { + providerId: 'deepseek', + displayName: 'DeepSeek', + state: 'available' as const, + setupKind: 'available-readonly' as const, + ownership: [], + recommended: false, + modelCount: 62, + authMethods: [], + defaultModelId: null, + sources: ['opencode-provider'] as const, + sourceLabel: 'OpenCode catalog', + providerSource: 'models.dev', + detail: null, + actions: [], + metadata: { + hasKnownModels: true, + requiresManualConfig: false, + supportedInlineAuth: false, }, - ], - diagnostics: [], - }, - }); + }, + ], + diagnostics: [], + }, + }; + const loadProviderDirectory = vi.fn().mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ); + loadProviderDirectory.mockResolvedValue(deepseekDirectoryResponse); Object.defineProperty(window, 'electronAPI', { configurable: true, value: { @@ -214,25 +642,23 @@ describe('useRuntimeProviderManagement', () => { await Promise.resolve(); }); - act(() => { - actions?.openDirectory(); - }); await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 10)); }); await act(async () => { await vi.waitFor(() => { - expect(loadProviderDirectory).toHaveBeenCalledTimes(1); + expect(loadProviderDirectory).toHaveBeenCalled(); }); }); + const callCountBeforeSearch = loadProviderDirectory.mock.calls.length; act(() => { - actions?.setDirectoryQuery('deep'); + actions?.setProviderQuery('deep'); }); await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 300)); await vi.waitFor(() => { - expect(loadProviderDirectory).toHaveBeenCalledTimes(2); + expect(loadProviderDirectory.mock.calls.length).toBeGreaterThan(callCountBeforeSearch); }); }); @@ -370,6 +796,376 @@ describe('useRuntimeProviderManagement', () => { expect(state?.apiKeyValue).toBe('sk-bad-value'); }); + it('submits a supported setup form without a secret as a null API key', async () => { + const loadSetupForm = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + setupForm: { + runtimeId: 'opencode', + providerId: 'openai', + displayName: 'OpenAI', + method: 'oauth', + supported: true, + title: 'Connect OpenAI', + description: null, + submitLabel: 'Connect', + disabledReason: null, + source: 'oauth', + secret: null, + prompts: [], + }, + }) + ); + const connectProvider = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + provider: createOpenAiLocalProvider(), + }) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + loadSetupForm, + connectProvider, + }, + } as unknown as ElectronAPI, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + act(() => { + actions?.startConnect('openai'); + }); + await act(async () => { + await vi.waitFor(() => { + expect(loadSetupForm).toHaveBeenCalled(); + }); + }); + + await act(async () => { + await actions?.submitConnect('openai'); + }); + + expect(connectProvider).toHaveBeenCalledWith({ + runtimeId: 'opencode', + providerId: 'openai', + method: 'oauth', + apiKey: null, + metadata: {}, + projectPath: null, + }); + expect(state?.setupSubmitError).toBeNull(); + + await act(async () => { + root.unmount(); + }); + }); + + it('clears model loading when switching from model picker to setup form', async () => { + const localProvider = createOpenAiLocalProvider(); + let resolveModels: ((value: unknown) => void) | null = null; + const loadView = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + view: createRuntimeView([localProvider]), + }) + ); + const loadProviderDirectory = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + directory: { + runtimeId: 'opencode', + totalCount: 0, + returnedCount: 0, + query: null, + filter: 'all', + limit: 50, + cursor: null, + nextCursor: null, + fetchedAt: '2026-04-25T00:00:00.000Z', + entries: [], + diagnostics: [], + }, + }) + ); + const loadModels = vi.fn( + () => + new Promise((resolve) => { + resolveModels = resolve; + }) + ); + const loadSetupForm = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + setupForm: { + runtimeId: 'opencode', + providerId: 'openrouter', + displayName: 'OpenRouter', + method: 'api', + supported: true, + title: 'Connect OpenRouter', + description: null, + submitLabel: 'Connect', + disabledReason: null, + source: 'curated', + secret: { + key: 'key', + label: 'API key', + placeholder: 'Paste API key', + required: true, + }, + prompts: [], + }, + }) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + loadView, + loadProviderDirectory, + loadModels, + loadSetupForm, + }, + } 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 () => { + await vi.waitFor(() => { + expect(loadModels).toHaveBeenCalled(); + expect(state?.modelsLoading).toBe(true); + }); + }); + + await act(async () => { + actions?.startConnect('openrouter'); + await Promise.resolve(); + }); + + expect(state?.modelPickerProviderId).toBeNull(); + expect(state?.activeFormProviderId).toBe('openrouter'); + expect(state?.modelsLoading).toBe(false); + + await act(async () => { + resolveModels?.({ + schemaVersion: 1, + runtimeId: 'opencode', + models: { + runtimeId: 'opencode', + providerId: 'openai', + models: [ + { + modelId: 'openai/stale-model', + providerId: 'openai', + displayName: 'Stale model', + sourceLabel: 'OpenCode catalog', + free: false, + default: false, + availability: 'available', + }, + ], + defaultModelId: null, + diagnostics: [], + }, + }); + await Promise.resolve(); + }); + + expect(state?.modelsLoading).toBe(false); + expect(state?.models).toEqual([]); + + await act(async () => { + root.unmount(); + }); + }); + + it('tracks concurrent model probes independently', async () => { + const firstModelId = 'openrouter/anthropic/claude-3.5-haiku'; + const secondModelId = 'openrouter/openai/gpt-oss-20b:free'; + const resolvers = new Map void>(); + const testModel = vi.fn( + (input: { modelId: string }) => + new Promise((resolve) => { + resolvers.set(input.modelId, resolve); + }) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + testModel, + }, + } as unknown as ElectronAPI, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + let firstProbe: Promise | null = null; + let secondProbe: Promise | null = null; + await act(async () => { + firstProbe = actions?.testModel('openrouter', firstModelId) ?? null; + secondProbe = actions?.testModel('openrouter', secondModelId) ?? null; + await Promise.resolve(); + }); + + expect(state?.testingModelIds).toEqual([firstModelId, secondModelId]); + + await act(async () => { + resolvers.get(firstModelId)?.({ + schemaVersion: 1, + runtimeId: 'opencode', + result: { + providerId: 'openrouter', + modelId: firstModelId, + ok: true, + availability: 'available', + message: 'First passed', + diagnostics: [], + }, + }); + await firstProbe; + }); + + expect(state?.testingModelIds).toEqual([secondModelId]); + + await act(async () => { + resolvers.get(secondModelId)?.({ + schemaVersion: 1, + runtimeId: 'opencode', + result: { + providerId: 'openrouter', + modelId: secondModelId, + ok: true, + availability: 'available', + message: 'Second passed', + diagnostics: [], + }, + }); + await secondProbe; + }); + + expect(state?.testingModelIds).toEqual([]); + expect(state?.modelResults[firstModelId]?.message).toBe('First passed'); + expect(state?.modelResults[secondModelId]?.message).toBe('Second passed'); + + await act(async () => { + root.unmount(); + }); + }); + + it('drops stale model probe results after leaving the model picker', async () => { + const modelId = 'openrouter/anthropic/claude-3.5-haiku'; + let resolveProbe: ((value: RuntimeProviderManagementModelTestResponse) => void) | null = null; + const testModel = vi.fn( + () => + new Promise((resolve) => { + resolveProbe = resolve; + }) + ); + const loadSetupForm = vi.fn(() => + Promise.resolve({ + schemaVersion: 1, + runtimeId: 'opencode', + setupForm: { + runtimeId: 'opencode', + providerId: 'openai', + displayName: 'OpenAI', + method: 'api', + supported: true, + title: 'Connect OpenAI', + description: null, + submitLabel: 'Connect', + disabledReason: null, + source: 'curated', + secret: { + key: 'key', + label: 'API key', + placeholder: 'Paste API key', + required: true, + }, + prompts: [], + }, + }) + ); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + runtimeProviderManagement: { + testModel, + loadSetupForm, + }, + } as unknown as ElectronAPI, + }); + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + act(() => { + actions?.openModelPicker('openrouter', 'use'); + }); + + let probe: Promise | null = null; + await act(async () => { + probe = actions?.testModel('openrouter', modelId) ?? null; + await Promise.resolve(); + }); + + expect(state?.testingModelIds).toEqual([modelId]); + + await act(async () => { + actions?.startConnect('openai'); + await Promise.resolve(); + }); + + expect(state?.modelPickerProviderId).toBeNull(); + expect(state?.testingModelIds).toEqual([]); + + await act(async () => { + resolveProbe?.({ + schemaVersion: 1, + runtimeId: 'opencode', + result: { + providerId: 'openrouter', + modelId, + ok: true, + availability: 'available', + message: 'Stale probe passed', + diagnostics: [], + }, + }); + await probe; + }); + + expect(state?.modelResults[modelId]).toBeUndefined(); + expect(state?.testingModelIds).toEqual([]); + + await act(async () => { + root.unmount(); + }); + }); + it('keeps failed model probes scoped to the model result instead of a global success banner', async () => { const modelId = 'openrouter/anthropic/claude-3.5-haiku'; const message =