fix: harden provider management and updater flows

This commit is contained in:
777genius 2026-05-16 23:23:27 +03:00
parent b616a53255
commit b88b2db365
13 changed files with 1714 additions and 540 deletions

View file

@ -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/<feature-name>/`
- Reference implementation: `src/features/recent-projects`
- Feature-local guidance for work inside `src/features`: [src/features/CLAUDE.md](src/features/CLAUDE.md)

View file

@ -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<VERSION>
```
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: <feature description>
- feat: <feature description>
### Improvements
- improve: <improvement description>
### Bug Fixes
- fix: <bug fix description>
### 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-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` |
| macOS x64 DMG | `Agent.Teams.AI-<VER>-x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
| macOS arm64 ZIP | `Agent.Teams.AI-<VER>-arm64-mac.zip` | - |
| macOS x64 ZIP | `Agent.Teams.AI-<VER>-x64-mac.zip` | - |
| Windows | `Agent.Teams.AI.Setup.<VER>.exe` | `Claude-Agent-Teams-UI-Setup.exe` |
| Linux AppImage | `Agent.Teams.AI-<VER>.AppImage` | `Claude-Agent-Teams-UI.AppImage` |
| Linux deb | `agent-teams-ai_<VER>_amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` |
| Linux rpm | `agent-teams-ai-<VER>.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` |
| Linux pacman | `agent-teams-ai-<VER>.pacman` | `Claude-Agent-Teams-UI.pacman` |
| Platform | Versioned Name | Stable Name (for /latest/download) |
| --------------- | ------------------------------------ | ---------------------------------- |
| macOS arm64 DMG | `Agent.Teams.AI-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` |
| macOS x64 DMG | `Agent.Teams.AI-<VER>-x64.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
| macOS arm64 ZIP | `Agent.Teams.AI-<VER>-arm64-mac.zip` | - |
| macOS x64 ZIP | `Agent.Teams.AI-<VER>-x64-mac.zip` | - |
| Windows | `Agent.Teams.AI.Setup.<VER>.exe` | `Claude-Agent-Teams-UI-Setup.exe` |
| Linux AppImage | `Agent.Teams.AI-<VER>.AppImage` | `Claude-Agent-Teams-UI.AppImage` |
| Linux deb | `agent-teams-ai_<VER>_amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` |
| Linux rpm | `agent-teams-ai-<VER>.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` |
| Linux pacman | `agent-teams-ai-<VER>.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

View file

@ -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<Record<string, RuntimeProviderModelTestResultDto>>;
loading: boolean;
@ -72,10 +71,6 @@ export interface RuntimeProviderManagementActions {
refresh: () => Promise<void>;
selectProvider: (providerId: string) => void;
setProviderQuery: (value: string) => void;
openDirectory: () => void;
closeDirectory: () => void;
setDirectoryQuery: (value: string) => void;
setDirectoryFilter: (value: RuntimeProviderDirectoryFilterDto) => void;
loadMoreDirectory: () => Promise<void>;
refreshDirectory: () => Promise<void>;
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<string, RuntimeProviderModelTestResultDto>;
} {
return {
modelPickerProviderId: null,
modelPickerMode: null,
models: [],
modelsError: null,
selectedModelId: null,
modelResults: {},
};
}
function withUiTimeout<T>(promise: Promise<T>, message: string, timeoutMs = 70_000): Promise<T> {
return new Promise<T>((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<RuntimeProviderManagementViewDto | null>(null);
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null);
const [providerQuery, setProviderQuery] = useState('');
const [directoryOpen, setDirectoryOpen] = useState(false);
const [directoryLoading, setDirectoryLoading] = useState(false);
const [directoryRefreshing, setDirectoryRefreshing] = useState(false);
const [directoryError, setDirectoryError] = useState<string | null>(null);
@ -185,8 +178,6 @@ export function useRuntimeProviderManagement(
const [directoryTotalCount, setDirectoryTotalCount] = useState<number | null>(null);
const [directoryNextCursor, setDirectoryNextCursor] = useState<string | null>(null);
const [directoryQuery, setDirectoryQuery] = useState('');
const [directoryFilter, setDirectoryFilterState] =
useState<RuntimeProviderDirectoryFilterDto>('all');
const [directoryLoaded, setDirectoryLoaded] = useState(false);
const [directorySelectedProviderId, setDirectorySelectedProviderId] = useState<string | null>(
null
@ -208,7 +199,7 @@ export function useRuntimeProviderManagement(
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [testingModelId, setTestingModelId] = useState<string | null>(null);
const [testingModelIds, setTestingModelIds] = useState<readonly string[]>([]);
const [savingDefaultModelId, setSavingDefaultModelId] = useState<string | null>(null);
const [modelResults, setModelResults] = useState<
Record<string, RuntimeProviderModelTestResultDto>
@ -219,38 +210,86 @@ export function useRuntimeProviderManagement(
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const directoryRequestSeq = useRef(0);
const setupFormRequestSeq = useRef(0);
const modelLoadRequestSeq = useRef(0);
const modelProbeGenerationRef = useRef(0);
const activeModelPickerProviderRef = useRef<string | null>(null);
const refresh = useCallback(async (): Promise<void> => {
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<void> => {
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<void> => {
if (!directoryNextCursor || directoryLoading || directoryRefreshing) {
return;
@ -537,11 +530,15 @@ export function useRuntimeProviderManagement(
}, [directoryLoading, directoryNextCursor, directoryRefreshing, loadDirectoryPage]);
const refreshDirectory = useCallback(async (): Promise<void> => {
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<void> => {
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<void> => {
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<RuntimeProviderManagementState>(
() => ({
@ -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,
]

View file

@ -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 (
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || busy || !connect.enabled}
title={connect.disabledReason ?? undefined}
onClick={(event) => {
event.stopPropagation();
onStartConnect();
}}
>
{busy ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : (
<KeyRound className="mr-1 size-3.5" />
)}
{connect.label}
</Button>
);
}
return (
<div className="flex flex-wrap justify-end gap-1.5">
{connect ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || busy || !connect.enabled}
title={connect.disabledReason ?? undefined}
onClick={(event) => {
event.stopPropagation();
onStartConnect();
}}
>
{busy ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : (
<KeyRound className="mr-1 size-3.5" />
)}
{connect.label}
</Button>
) : null}
{forget ? (
<Button
type="button"
@ -721,11 +724,23 @@ function ProviderRow({
}
actions.selectProvider(provider.providerId);
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
if (eventStartedInInteractiveChild(event.currentTarget, event.target)) {
return;
}
event.preventDefault();
handleActivate();
};
return (
<div
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : -1}
data-testid={`runtime-provider-row-${provider.providerId}`}
className={`rounded-lg border p-3 transition-all ${
className={`rounded-lg border p-3 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40 ${
clickable
? 'cursor-pointer hover:border-sky-300/60 hover:bg-sky-400/[0.08] hover:shadow-[0_0_0_1px_rgba(125,211,252,0.18)]'
: 'cursor-default'
@ -735,6 +750,7 @@ function ProviderRow({
: 'border-[var(--color-border-subtle)] bg-white/[0.02]'
}`}
onClick={handleActivate}
onKeyDown={handleKeyDown}
>
<div className="grid w-full grid-cols-[1fr_auto] items-start gap-3">
<div className="min-w-0 text-left">
@ -846,7 +862,7 @@ function DirectoryProviderRow({
return (
<div
role="button"
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : -1}
data-testid={`runtime-provider-directory-row-${provider.providerId}`}
className={`rounded-lg border p-3 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40 ${
@ -863,6 +879,9 @@ function DirectoryProviderRow({
if (!clickable || (event.key !== 'Enter' && event.key !== ' ')) {
return;
}
if (eventStartedInInteractiveChild(event.currentTarget, event.target)) {
return;
}
event.preventDefault();
handleActivate();
}}
@ -940,7 +959,7 @@ function DirectoryProviderRow({
{forget.label}
</Button>
) : null}
{!connect && configure ? (
{configure ? (
<Button
type="button"
size="sm"
@ -977,133 +996,6 @@ function DirectoryProviderRow({
);
}
function ProviderDirectoryPanel({
state,
actions,
disabled,
}: {
readonly state: RuntimeProviderManagementState;
readonly actions: RuntimeProviderManagementActions;
readonly disabled: boolean;
}): JSX.Element {
return (
<div
className="rounded-lg border p-3"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255,255,255,0.018)',
}}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<Button type="button" size="sm" variant="ghost" onClick={actions.closeDirectory}>
<ArrowLeft className="mr-1 size-3.5" />
Providers
</Button>
<div className="flex items-center gap-2">
<div className="text-xs text-[var(--color-text-muted)]">
{state.directoryTotalCount === null
? 'All OpenCode providers'
: `${state.directoryTotalCount} OpenCode providers`}
</div>
<Button
type="button"
size="sm"
variant="ghost"
disabled={disabled || state.directoryLoading || state.directoryRefreshing}
onClick={() => void actions.refreshDirectory()}
>
{state.directoryRefreshing ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : (
<RefreshCcw className="mr-1 size-3.5" />
)}
Refresh
</Button>
</div>
</div>
<div className="mt-3 space-y-2">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-text-muted)]" />
<Input
data-testid="runtime-provider-directory-search"
value={state.directoryQuery}
disabled={disabled || state.directoryLoading}
onChange={(event) => actions.setDirectoryQuery(event.target.value)}
placeholder="Search all OpenCode providers"
className="h-9 pr-3 text-sm"
style={{ paddingLeft: 40 }}
/>
</div>
<div className="flex flex-wrap gap-1.5">
{DIRECTORY_FILTERS.map((filter) => (
<Button
key={filter.id}
type="button"
size="sm"
variant={state.directoryFilter === filter.id ? 'default' : 'outline'}
className="h-7 px-2 text-xs"
disabled={disabled || state.directoryLoading}
onClick={() => actions.setDirectoryFilter(filter.id)}
>
{filter.label}
</Button>
))}
</div>
</div>
{state.directoryError ? (
<div className="mt-3 rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
{state.directoryError}
</div>
) : null}
<div className="mt-3 max-h-[48vh] space-y-2 overflow-y-auto pr-1">
{state.directoryLoading && state.directoryEntries.length === 0 ? (
<RuntimeProviderLoadingPlaceholder />
) : null}
{state.directoryEntries.map((provider) => {
const active = state.directorySelectedProviderId === provider.providerId;
return (
<div key={provider.providerId}>
<DirectoryProviderRow
provider={provider}
state={state}
active={active}
formOpen={state.activeFormProviderId === provider.providerId}
disabled={disabled || state.directoryLoading}
busy={state.savingProviderId === provider.providerId}
actions={actions}
/>
</div>
);
})}
</div>
{!state.directoryLoading && state.directoryEntries.length === 0 && !state.directoryError ? (
<div className="mt-3 rounded-md border border-white/10 px-3 py-3 text-sm text-[var(--color-text-muted)]">
No providers match this search.
</div>
) : null}
{state.directoryNextCursor ? (
<div className="mt-3 flex justify-center">
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || state.directoryRefreshing}
onClick={() => void actions.loadMoreDirectory()}
>
{state.directoryRefreshing ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
Load more
</Button>
</div>
) : null}
</div>
);
}
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 (
<div
role="button"
role={disabled ? undefined : 'button'}
tabIndex={disabled ? -1 : 0}
aria-pressed={selected}
aria-disabled={disabled || undefined}
aria-pressed={disabled ? undefined : selected}
data-testid={`runtime-provider-model-row-${model.modelId}`}
className="cursor-pointer rounded-md border px-3 py-2.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/45"
className={`rounded-md border px-3 py-2.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/45 ${
disabled ? 'cursor-default' : 'cursor-pointer'
}`}
onClick={(event) => {
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 (
<div className="space-y-3">

View file

@ -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<string | null> {
}
}
async function fetchJson<T>(url: string): Promise<T | null> {
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<typeof setInterval> | null = null;
@ -194,6 +210,20 @@ export class UpdaterService {
return false;
}
private async isSkippedRelease(version: string): Promise<boolean> {
const metadataUrls = getReleaseApiUrls(version);
for (const metadataUrl of metadataUrls) {
const release = await fetchJson<GithubReleaseMetadata>(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<void> {
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);

View file

@ -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<string
for (const rawLine of metadataText.split(/\r?\n/u)) {
const line = rawLine.trim();
const match = /^(?:-\s+)?(url|path):\s+(.+)$/u.exec(line);
if (!match) {
const normalizedLine = line.startsWith('- ') ? line.slice(2).trimStart() : line;
const separatorIndex = normalizedLine.indexOf(':');
if (separatorIndex <= 0) {
continue;
}
assets.add(stripYamlScalar(match[2]));
const key = normalizedLine.slice(0, separatorIndex).trim();
if (key !== 'url' && key !== 'path') {
continue;
}
assets.add(stripYamlScalar(normalizedLine.slice(separatorIndex + 1)));
}
return assets;

View file

@ -65,6 +65,11 @@ export const ClaudeLogsPanel = ({
handleScroll,
} = ctrl;
const rawLineLabel = data.total === 1 ? '1 raw line' : `${data.total.toLocaleString()} raw lines`;
const rawLinesCapturedLabel = `${rawLineLabel} captured`;
const emptyRawLogsMessage =
data.total > 0 ? `${rawLinesCapturedLabel}; none are assistant/tool output yet.` : undefined;
return (
<div className={cn('min-w-0', className)}>
{/* Toolbar */}
@ -72,8 +77,8 @@ export const ClaudeLogsPanel = ({
<span className="text-[11px] text-[var(--color-text-muted)]">
{data.total > 0 ? (
<>
<span className="font-mono">{data.total}</span> raw line
{data.total === 1 ? '' : 's'}
<span className="font-mono">{data.total.toLocaleString()}</span> 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={

View file

@ -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

View file

@ -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) => {

View file

@ -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'])
);
});

View file

@ -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<Record<string, unknown>>,
calls: [] as Record<string, unknown>[],
}));
vi.mock('@renderer/components/team/CliLogsRichView', () => ({
CliLogsRichView: (props: Record<string, unknown>) => {
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<ClaudeLogsController> = {}): 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();
});
});
});

View file

@ -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<HTMLElement>(
'[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<HTMLElement>(
'[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);

View file

@ -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<typeof refreshedViewResponse>((resolve) => {
resolveRefreshView = () => resolve(refreshedViewResponse);
})
);
const loadProviderDirectory = vi
.fn()
.mockResolvedValueOnce(directoryResponse)
.mockImplementation(
() =>
new Promise<typeof directoryResponse>((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<void> | 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<typeof viewResponse>((resolve) => {
resolveRefreshView = () => resolve(viewResponse);
})
);
const loadProviderDirectory = vi
.fn()
.mockResolvedValueOnce(directoryResponse)
.mockImplementation(
() =>
new Promise<typeof directoryResponse>((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<void> | 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<string, (value: unknown) => 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<void> | null = null;
let secondProbe: Promise<void> | 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<RuntimeProviderManagementModelTestResponse>((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<void> | 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 =