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. This file is a navigation layer for architecture and implementation guidance.
Start here: Start here:
- Repo overview and commands: [README.md](README.md) - Repo overview and commands: [README.md](README.md)
- Working instructions and project conventions: [CLAUDE.md](CLAUDE.md) - Working instructions and project conventions: [CLAUDE.md](CLAUDE.md)
- Hard guardrails: [AGENT_CRITICAL_GUARDRAILS.md](AGENT_CRITICAL_GUARDRAILS.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) - 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) - 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: Default local run target:
- Use the desktop Electron app: `pnpm dev` - 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. - 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. - When documenting or recommending startup commands, point contributors to the desktop app unless a task explicitly asks for browser-mode internals.
For new features: For new features:
- Default home for medium and large features: `src/features/<feature-name>/` - Default home for medium and large features: `src/features/<feature-name>/`
- Reference implementation: `src/features/recent-projects` - Reference implementation: `src/features/recent-projects`
- Feature-local guidance for work inside `src/features`: [src/features/CLAUDE.md](src/features/CLAUDE.md) - 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` Format: `MAJOR.MINOR.PATCH`
| Bump | When | Example | | Bump | When | Example |
|---------|-------------------------------------------------------------|------------------| | ----- | --------------------------------------------------------------------- | ------------- |
| MAJOR | Breaking changes, major UI overhaul, incompatible data format changes | 1.0.0 → 2.0.0 | | 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 | | 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 | | PATCH | Bug fixes, performance improvements, small UI tweaks | 1.0.0 → 1.0.1 |
## Release Process ## 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 ### 1. Prepare
```bash ```bash
@ -101,6 +113,7 @@ git push origin v<VERSION>
``` ```
This triggers the `release.yml` GitHub Actions workflow which: This triggers the `release.yml` GitHub Actions workflow which:
- Builds the app (ubuntu) - Builds the app (ubuntu)
- Packages macOS arm64 + x64 (with code signing & notarization) - Packages macOS arm64 + x64 (with code signing & notarization)
- Packages Windows (NSIS installer) - Packages Windows (NSIS installer)
@ -126,13 +139,16 @@ EOF
<1-2 sentence summary of the release> <1-2 sentence summary of the release>
### What's New ### What's New
- feat: <feature description> - feat: <feature description>
- feat: <feature description> - feat: <feature description>
### Improvements ### Improvements
- improve: <improvement description> - improve: <improvement description>
### Bug Fixes ### Bug Fixes
- fix: <bug fix description> - fix: <bug fix description>
### Downloads ### Downloads
@ -179,11 +195,13 @@ EOF
Write changelog entries from the **user's perspective**, not the developer's. Write changelog entries from the **user's perspective**, not the developer's.
**Good:** **Good:**
- "Add team member activity timeline with live status tracking" - "Add team member activity timeline with live status tracking"
- "Fix crash when opening sessions with corrupted JSONL data" - "Fix crash when opening sessions with corrupted JSONL data"
- "Improve session list loading speed by 3x with streaming parser" - "Improve session list loading speed by 3x with streaming parser"
**Bad:** **Bad:**
- "Refactor ChunkBuilder to use new pipeline" - "Refactor ChunkBuilder to use new pipeline"
- "Update dependencies" - "Update dependencies"
- "Fix bug in useEffect cleanup" - "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: electron-builder generates these artifacts per platform:
| Platform | Versioned Name | Stable Name (for /latest/download) | | Platform | Versioned Name | Stable Name (for /latest/download) |
|------------------|--------------------------------------------------|--------------------------------------------| | --------------- | ------------------------------------ | ---------------------------------- |
| macOS arm64 DMG | `Agent.Teams.AI-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` | | 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 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 arm64 ZIP | `Agent.Teams.AI-<VER>-arm64-mac.zip` | - |
| macOS x64 ZIP | `Agent.Teams.AI-<VER>-x64-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` | | 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 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 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 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` | | Linux pacman | `agent-teams-ai-<VER>.pacman` | `Claude-Agent-Teams-UI.pacman` |
## Stable Download Links ## 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: macOS builds are signed and notarized via GitHub Actions secrets:
| Secret | Description | | Secret | Description |
|-------------------------------|------------------------------| | ----------------------------- | -------------------------------------------- |
| `CSC_LINK` | Base64-encoded .p12 certificate | | `CSC_LINK` | Base64-encoded .p12 certificate |
| `CSC_KEY_PASSWORD` | Certificate password | | `CSC_KEY_PASSWORD` | Certificate password |
| `APPLE_ID` | Apple Developer account email | | `APPLE_ID` | Apple Developer account email |
| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password from appleid.apple.com | | `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). Without these secrets, macOS builds will be unsigned (users need to bypass Gatekeeper manually).
## Auto-Update ## Auto-Update
The release workflow publishes canonical updater metadata after all platform assets are uploaded: The release workflow publishes canonical updater metadata after all platform assets are uploaded:
- `latest.yml` for Windows - `latest.yml` for Windows
- `latest-linux.yml` for Linux - `latest-linux.yml` for Linux
- `latest-mac.yml` for macOS - `latest-mac.yml` for macOS

View file

@ -28,20 +28,19 @@ interface UseRuntimeProviderManagementOptions {
export type RuntimeProviderModelPickerMode = 'use' | 'runtime-default'; export type RuntimeProviderModelPickerMode = 'use' | 'runtime-default';
const DEFAULT_DIRECTORY_FILTER: RuntimeProviderDirectoryFilterDto = 'all';
export interface RuntimeProviderManagementState { export interface RuntimeProviderManagementState {
view: RuntimeProviderManagementViewDto | null; view: RuntimeProviderManagementViewDto | null;
providers: readonly RuntimeProviderConnectionDto[]; providers: readonly RuntimeProviderConnectionDto[];
selectedProviderId: string | null; selectedProviderId: string | null;
providerQuery: string; providerQuery: string;
directoryOpen: boolean;
directoryLoading: boolean; directoryLoading: boolean;
directoryRefreshing: boolean; directoryRefreshing: boolean;
directoryError: string | null; directoryError: string | null;
directoryEntries: readonly RuntimeProviderDirectoryEntryDto[]; directoryEntries: readonly RuntimeProviderDirectoryEntryDto[];
directoryTotalCount: number | null; directoryTotalCount: number | null;
directoryNextCursor: string | null; directoryNextCursor: string | null;
directoryQuery: string;
directoryFilter: RuntimeProviderDirectoryFilterDto;
directoryLoaded: boolean; directoryLoaded: boolean;
directorySelectedProviderId: string | null; directorySelectedProviderId: string | null;
directorySupported: boolean; directorySupported: boolean;
@ -59,7 +58,7 @@ export interface RuntimeProviderManagementState {
modelsLoading: boolean; modelsLoading: boolean;
modelsError: string | null; modelsError: string | null;
selectedModelId: string | null; selectedModelId: string | null;
testingModelId: string | null; testingModelIds: readonly string[];
savingDefaultModelId: string | null; savingDefaultModelId: string | null;
modelResults: Readonly<Record<string, RuntimeProviderModelTestResultDto>>; modelResults: Readonly<Record<string, RuntimeProviderModelTestResultDto>>;
loading: boolean; loading: boolean;
@ -72,10 +71,6 @@ export interface RuntimeProviderManagementActions {
refresh: () => Promise<void>; refresh: () => Promise<void>;
selectProvider: (providerId: string) => void; selectProvider: (providerId: string) => void;
setProviderQuery: (value: string) => void; setProviderQuery: (value: string) => void;
openDirectory: () => void;
closeDirectory: () => void;
setDirectoryQuery: (value: string) => void;
setDirectoryFilter: (value: RuntimeProviderDirectoryFilterDto) => void;
loadMoreDirectory: () => Promise<void>; loadMoreDirectory: () => Promise<void>;
refreshDirectory: () => Promise<void>; refreshDirectory: () => Promise<void>;
selectDirectoryProvider: (providerId: string) => 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> { function withUiTimeout<T>(promise: Promise<T>, message: string, timeoutMs = 70_000): Promise<T> {
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
@ -169,13 +146,29 @@ function resolveSavedModelForNewTeams(models: readonly RuntimeProviderModelDto[]
return models.some((model) => model.modelId === savedModelId) ? savedModelId : null; 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( export function useRuntimeProviderManagement(
options: UseRuntimeProviderManagementOptions options: UseRuntimeProviderManagementOptions
): [RuntimeProviderManagementState, RuntimeProviderManagementActions] { ): [RuntimeProviderManagementState, RuntimeProviderManagementActions] {
const [view, setView] = useState<RuntimeProviderManagementViewDto | null>(null); const [view, setView] = useState<RuntimeProviderManagementViewDto | null>(null);
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null); const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null);
const [providerQuery, setProviderQuery] = useState(''); const [providerQuery, setProviderQuery] = useState('');
const [directoryOpen, setDirectoryOpen] = useState(false);
const [directoryLoading, setDirectoryLoading] = useState(false); const [directoryLoading, setDirectoryLoading] = useState(false);
const [directoryRefreshing, setDirectoryRefreshing] = useState(false); const [directoryRefreshing, setDirectoryRefreshing] = useState(false);
const [directoryError, setDirectoryError] = useState<string | null>(null); const [directoryError, setDirectoryError] = useState<string | null>(null);
@ -185,8 +178,6 @@ export function useRuntimeProviderManagement(
const [directoryTotalCount, setDirectoryTotalCount] = useState<number | null>(null); const [directoryTotalCount, setDirectoryTotalCount] = useState<number | null>(null);
const [directoryNextCursor, setDirectoryNextCursor] = useState<string | null>(null); const [directoryNextCursor, setDirectoryNextCursor] = useState<string | null>(null);
const [directoryQuery, setDirectoryQuery] = useState(''); const [directoryQuery, setDirectoryQuery] = useState('');
const [directoryFilter, setDirectoryFilterState] =
useState<RuntimeProviderDirectoryFilterDto>('all');
const [directoryLoaded, setDirectoryLoaded] = useState(false); const [directoryLoaded, setDirectoryLoaded] = useState(false);
const [directorySelectedProviderId, setDirectorySelectedProviderId] = useState<string | null>( const [directorySelectedProviderId, setDirectorySelectedProviderId] = useState<string | null>(
null null
@ -208,7 +199,7 @@ export function useRuntimeProviderManagement(
const [modelsLoading, setModelsLoading] = useState(false); const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null); const [modelsError, setModelsError] = useState<string | null>(null);
const [selectedModelId, setSelectedModelId] = 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 [savingDefaultModelId, setSavingDefaultModelId] = useState<string | null>(null);
const [modelResults, setModelResults] = useState< const [modelResults, setModelResults] = useState<
Record<string, RuntimeProviderModelTestResultDto> Record<string, RuntimeProviderModelTestResultDto>
@ -219,38 +210,86 @@ export function useRuntimeProviderManagement(
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const directoryRequestSeq = useRef(0); const directoryRequestSeq = useRef(0);
const setupFormRequestSeq = 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> => { const openModelPickerState = useCallback(
if (!options.enabled) { (providerId: string, mode: RuntimeProviderModelPickerMode): void => {
return; modelLoadRequestSeq.current += 1;
} modelProbeGenerationRef.current += 1;
setLoading(true); activeModelPickerProviderRef.current = providerId;
setError(null); setModelPickerProviderId(providerId);
try { setModelPickerMode(mode);
const response = await api.runtimeProviderManagement.loadView({ setModelQuery('');
runtimeId: options.runtimeId, setModels([]);
projectPath: options.projectPath ?? null, setModelsLoading(false);
}); setModelsError(null);
if (response.error) { setSelectedModelId(null);
setView(null); setModelResults({});
setError(response.error.message); 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; return;
} }
const nextView = response.view ?? null; const silent = input.silent === true;
setView(nextView); if (!silent) {
setSelectedProviderId((current) => { setLoading(true);
if (current && nextView?.providers.some((provider) => provider.providerId === current)) { }
return current; 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); const nextView = response.view ?? null;
}); setView(nextView);
} catch (loadError) { setSelectedProviderId((current) => {
setView(null); if (current && nextView?.providers.some((provider) => provider.providerId === current)) {
setError(loadError instanceof Error ? loadError.message : 'Failed to load providers'); return current;
} finally { }
setLoading(false); return selectInitialProviderId(nextView);
} });
}, [options.enabled, options.projectPath, options.runtimeId]); } 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( const loadDirectoryPage = useCallback(
async ( async (
@ -269,7 +308,7 @@ export function useRuntimeProviderManagement(
const append = input.append === true; const append = input.append === true;
const refreshDirectoryData = input.refresh === true; const refreshDirectoryData = input.refresh === true;
const query = input.query ?? directoryQuery; const query = input.query ?? directoryQuery;
const filter = input.filter ?? directoryFilter; const filter = input.filter ?? DEFAULT_DIRECTORY_FILTER;
const cursor = input.cursor ?? null; const cursor = input.cursor ?? null;
const requestSeq = directoryRequestSeq.current + 1; const requestSeq = directoryRequestSeq.current + 1;
directoryRequestSeq.current = requestSeq; directoryRequestSeq.current = requestSeq;
@ -330,20 +369,12 @@ export function useRuntimeProviderManagement(
} }
} }
}, },
[ [directoryQuery, directorySupported, options.enabled, options.projectPath, options.runtimeId]
directoryFilter,
directoryQuery,
directorySupported,
options.enabled,
options.projectPath,
options.runtimeId,
]
); );
useEffect(() => { useEffect(() => {
if (!options.enabled) { if (!options.enabled) {
setProviderQuery(''); setProviderQuery('');
setDirectoryOpen(false);
setDirectoryLoading(false); setDirectoryLoading(false);
setDirectoryRefreshing(false); setDirectoryRefreshing(false);
setDirectoryError(null); setDirectoryError(null);
@ -351,7 +382,6 @@ export function useRuntimeProviderManagement(
setDirectoryTotalCount(null); setDirectoryTotalCount(null);
setDirectoryNextCursor(null); setDirectoryNextCursor(null);
setDirectoryQuery(''); setDirectoryQuery('');
setDirectoryFilterState('all');
setDirectoryLoaded(false); setDirectoryLoaded(false);
setDirectorySelectedProviderId(null); setDirectorySelectedProviderId(null);
setApiKeyValue(''); setApiKeyValue('');
@ -361,17 +391,11 @@ export function useRuntimeProviderManagement(
setSetupFormError(null); setSetupFormError(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setActiveFormProviderId(null); setActiveFormProviderId(null);
const reset = resetModelState(); closeModelPickerState();
setModelPickerProviderId(reset.modelPickerProviderId);
setModelPickerMode(reset.modelPickerMode);
setModels(reset.models);
setModelsError(reset.modelsError);
setSelectedModelId(reset.selectedModelId);
setModelResults(reset.modelResults);
return; return;
} }
void refresh(); void refresh();
}, [options.enabled, refresh]); }, [closeModelPickerState, options.enabled, refresh]);
useEffect(() => { useEffect(() => {
if (!options.enabled || !directorySupported) { if (!options.enabled || !directorySupported) {
@ -383,7 +407,7 @@ export function useRuntimeProviderManagement(
void loadDirectoryPage({ void loadDirectoryPage({
append: false, append: false,
query: directoryQuery, query: directoryQuery,
filter: directoryFilter, filter: DEFAULT_DIRECTORY_FILTER,
cursor: null, cursor: null,
}); });
}, },
@ -391,27 +415,28 @@ export function useRuntimeProviderManagement(
); );
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [ }, [directoryLoaded, directoryQuery, directorySupported, loadDirectoryPage, options.enabled]);
directoryFilter,
directoryLoaded,
directoryQuery,
directorySupported,
loadDirectoryPage,
options.enabled,
]);
useEffect(() => { useEffect(() => {
if (!options.enabled || !modelPickerProviderId) { if (!options.enabled || !modelPickerProviderId) {
modelLoadRequestSeq.current += 1;
setModelsLoading(false);
return; return;
} }
const requestSeq = modelLoadRequestSeq.current + 1;
modelLoadRequestSeq.current = requestSeq;
const providerId = modelPickerProviderId;
const requestIsCurrent = (): boolean =>
modelLoadRequestSeq.current === requestSeq &&
activeModelPickerProviderRef.current === providerId;
let cancelled = false; let cancelled = false;
setModelsLoading(true); setModelsLoading(true);
setModelsError(null); setModelsError(null);
void withUiTimeout( void withUiTimeout(
api.runtimeProviderManagement.loadModels({ api.runtimeProviderManagement.loadModels({
runtimeId: options.runtimeId, runtimeId: options.runtimeId,
providerId: modelPickerProviderId, providerId,
projectPath: options.projectPath ?? null, projectPath: options.projectPath ?? null,
query: modelQuery.trim() || null, query: modelQuery.trim() || null,
limit: 250, limit: 250,
@ -419,7 +444,7 @@ export function useRuntimeProviderManagement(
'Provider models load timed out' 'Provider models load timed out'
) )
.then((response) => { .then((response) => {
if (cancelled) { if (cancelled || !requestIsCurrent()) {
return; return;
} }
if (response.error) { if (response.error) {
@ -437,7 +462,7 @@ export function useRuntimeProviderManagement(
}); });
}) })
.catch((modelsLoadError) => { .catch((modelsLoadError) => {
if (!cancelled) { if (!cancelled && requestIsCurrent()) {
setModels([]); setModels([]);
setModelsError( setModelsError(
modelsLoadError instanceof Error modelsLoadError instanceof Error
@ -447,7 +472,7 @@ export function useRuntimeProviderManagement(
} }
}) })
.finally(() => { .finally(() => {
if (!cancelled) { if (!cancelled && requestIsCurrent()) {
setModelsLoading(false); setModelsLoading(false);
} }
}); });
@ -475,57 +500,25 @@ export function useRuntimeProviderManagement(
) { ) {
const providerId = selectedProvider?.providerId ?? selectedDirectoryProvider!.providerId; const providerId = selectedProvider?.providerId ?? selectedDirectoryProvider!.providerId;
if (modelPickerProviderId !== providerId) { if (modelPickerProviderId !== providerId) {
setModelPickerProviderId(providerId); openModelPickerState(providerId, 'use');
setModelPickerMode('use');
setModelQuery('');
setModels([]);
setModelsError(null);
setSelectedModelId(null);
setModelResults({});
} }
return; return;
} }
if (modelPickerProviderId) { if (modelPickerProviderId) {
setModelPickerProviderId(null); closeModelPickerState();
setModelPickerMode(null);
setModels([]);
setModelsError(null);
setSelectedModelId(null);
setModelResults({});
} }
}, [ }, [
activeFormProviderId, activeFormProviderId,
closeModelPickerState,
directoryEntries, directoryEntries,
modelPickerProviderId, modelPickerProviderId,
openModelPickerState,
options.enabled, options.enabled,
selectedProviderId, selectedProviderId,
view, 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> => { const loadMoreDirectory = useCallback(async (): Promise<void> => {
if (!directoryNextCursor || directoryLoading || directoryRefreshing) { if (!directoryNextCursor || directoryLoading || directoryRefreshing) {
return; return;
@ -537,11 +530,15 @@ export function useRuntimeProviderManagement(
}, [directoryLoading, directoryNextCursor, directoryRefreshing, loadDirectoryPage]); }, [directoryLoading, directoryNextCursor, directoryRefreshing, loadDirectoryPage]);
const refreshDirectory = useCallback(async (): Promise<void> => { const refreshDirectory = useCallback(async (): Promise<void> => {
await loadDirectoryPage({ setSuccessMessage(null);
refresh: true, await Promise.all([
cursor: null, refresh({ silent: true }),
}); loadDirectoryPage({
}, [loadDirectoryPage]); refresh: true,
cursor: null,
}),
]);
}, [loadDirectoryPage, refresh]);
const selectDirectoryProvider = useCallback( const selectDirectoryProvider = useCallback(
(providerId: string): void => { (providerId: string): void => {
@ -565,21 +562,16 @@ export function useRuntimeProviderManagement(
const modelCount = compactProvider?.modelCount ?? directoryProvider?.modelCount ?? null; const modelCount = compactProvider?.modelCount ?? directoryProvider?.modelCount ?? null;
if (connected && modelCount !== 0) { if (connected && modelCount !== 0) {
setModelPickerProviderId(providerId); openModelPickerState(providerId, 'use');
setModelPickerMode('use'); } else {
setModelQuery(''); closeModelPickerState();
setModels([]);
setModelsError(null);
setSelectedModelId(null);
setModelResults({});
} }
}, },
[directoryEntries, view] [closeModelPickerState, directoryEntries, openModelPickerState, view]
); );
const searchAllProviders = useCallback((query: string): void => { const searchAllProviders = useCallback((query: string): void => {
setDirectoryQuery(query); setDirectoryQuery(query);
setDirectoryOpen(true);
setDirectoryError(null); setDirectoryError(null);
setDirectoryNextCursor(null); setDirectoryNextCursor(null);
}, []); }, []);
@ -588,8 +580,7 @@ export function useRuntimeProviderManagement(
(providerId: string): void => { (providerId: string): void => {
setSelectedProviderId(providerId); setSelectedProviderId(providerId);
setActiveFormProviderId(providerId); setActiveFormProviderId(providerId);
setModelPickerProviderId(null); closeModelPickerState();
setModelPickerMode(null);
setApiKeyValue(''); setApiKeyValue('');
setSetupMetadata({}); setSetupMetadata({});
setSetupForm(null); setSetupForm(null);
@ -636,7 +627,7 @@ export function useRuntimeProviderManagement(
} }
}); });
}, },
[options.projectPath, options.runtimeId] [closeModelPickerState, options.projectPath, options.runtimeId]
); );
const updateProviderQuery = useCallback( const updateProviderQuery = useCallback(
@ -678,11 +669,6 @@ export function useRuntimeProviderManagement(
const submitConnect = useCallback( const submitConnect = useCallback(
async (providerId: string): Promise<void> => { async (providerId: string): Promise<void> => {
const apiKey = apiKeyValue.trim();
if (!apiKey) {
setSetupSubmitError('API key is required');
return;
}
if (!setupForm) { if (!setupForm) {
setSetupSubmitError(setupFormError ?? 'Provider setup form is not loaded'); setSetupSubmitError(setupFormError ?? 'Provider setup form is not loaded');
return; return;
@ -693,6 +679,11 @@ export function useRuntimeProviderManagement(
); );
return; return;
} }
const apiKey = apiKeyValue.trim();
if (setupForm.secret?.required && !apiKey) {
setSetupSubmitError(`${setupForm.secret.label} is required`);
return;
}
setSavingProviderId(providerId); setSavingProviderId(providerId);
setError(null); setError(null);
@ -704,7 +695,7 @@ export function useRuntimeProviderManagement(
runtimeId: options.runtimeId, runtimeId: options.runtimeId,
providerId, providerId,
method: setupForm.method, method: setupForm.method,
apiKey, apiKey: apiKey || null,
metadata: setupMetadata, metadata: setupMetadata,
projectPath: options.projectPath ?? null, projectPath: options.projectPath ?? null,
}), }),
@ -719,20 +710,22 @@ export function useRuntimeProviderManagement(
} }
setActiveFormProviderId(null); setActiveFormProviderId(null);
setSuccessMessage(null); setSuccessMessage(null);
setSavingProviderId(null);
setApiKeyValue(''); setApiKeyValue('');
setSetupMetadata({}); setSetupMetadata({});
setSetupForm(null); setSetupForm(null);
setSetupFormError(null); setSetupFormError(null);
setSetupSubmitError(null); setSetupSubmitError(null);
void Promise.resolve(options.onProviderChanged?.()) try {
.then(() => refresh()) await options.onProviderChanged?.();
.then(() => loadDirectoryPage({ refresh: true, cursor: null })) await Promise.all([
.catch((refreshError) => { refresh({ silent: true }),
setError( loadDirectoryPage({ refresh: true, cursor: null }),
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' ]);
); } catch (refreshError) {
}); setError(
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
);
}
} catch (connectError) { } catch (connectError) {
setSetupSubmitError( setSetupSubmitError(
connectError instanceof Error ? connectError.message : 'Failed to connect provider' connectError instanceof Error ? connectError.message : 'Failed to connect provider'
@ -765,16 +758,19 @@ export function useRuntimeProviderManagement(
if (response.provider) { if (response.provider) {
setView((current) => replaceProvider(current, response.provider!)); setView((current) => replaceProvider(current, response.provider!));
} }
setSuccessMessage('Credential removed'); const success = formatCredentialRemovedMessage(response.provider ?? null);
setSavingProviderId(null); try {
void Promise.resolve(options.onProviderChanged?.()) await options.onProviderChanged?.();
.then(() => refresh()) await Promise.all([
.then(() => loadDirectoryPage({ refresh: true, cursor: null })) refresh({ silent: true }),
.catch((refreshError) => { loadDirectoryPage({ refresh: true, cursor: null }),
setError( ]);
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' } catch (refreshError) {
); setError(
}); refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
);
}
setSuccessMessage(success);
} catch (forgetError) { } catch (forgetError) {
setError( setError(
forgetError instanceof Error ? forgetError.message : 'Failed to forget credential' forgetError instanceof Error ? forgetError.message : 'Failed to forget credential'
@ -790,28 +786,16 @@ export function useRuntimeProviderManagement(
(providerId: string, mode: RuntimeProviderModelPickerMode): void => { (providerId: string, mode: RuntimeProviderModelPickerMode): void => {
setSelectedProviderId(providerId); setSelectedProviderId(providerId);
setActiveFormProviderId(null); setActiveFormProviderId(null);
setModelPickerProviderId(providerId); openModelPickerState(providerId, mode);
setModelPickerMode(mode);
setModelQuery('');
setModels([]);
setModelsError(null);
setSelectedModelId(null);
setModelResults({});
setError(null); setError(null);
setSuccessMessage(null); setSuccessMessage(null);
}, },
[] [openModelPickerState]
); );
const closeModelPicker = useCallback((): void => { const closeModelPicker = useCallback((): void => {
setModelPickerProviderId(null); closeModelPickerState();
setModelPickerMode(null); }, [closeModelPickerState]);
setModelQuery('');
setModels([]);
setModelsError(null);
setSelectedModelId(null);
setModelResults({});
}, []);
const useModelForNewTeams = useCallback((modelId: string): void => { const useModelForNewTeams = useCallback((modelId: string): void => {
saveOpenCodeModelForNewTeams(modelId); saveOpenCodeModelForNewTeams(modelId);
@ -822,7 +806,14 @@ export function useRuntimeProviderManagement(
const testModel = useCallback( const testModel = useCallback(
async (providerId: string, modelId: string): Promise<void> => { 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); setError(null);
setSuccessMessage(null); setSuccessMessage(null);
try { try {
@ -837,29 +828,33 @@ export function useRuntimeProviderManagement(
100_000 100_000
); );
if (response.error) { if (response.error) {
setModelResults((current) => ({ if (shouldRecordProbeResult()) {
...current, setModelResults((current) => ({
[modelId]: buildFailedModelTestResult(providerId, modelId, response.error!.message), ...current,
})); [modelId]: buildFailedModelTestResult(providerId, modelId, response.error!.message),
}));
}
return; return;
} }
if (response.result) { if (response.result && shouldRecordProbeResult()) {
setModelResults((current) => ({ setModelResults((current) => ({
...current, ...current,
[modelId]: response.result!, [modelId]: response.result!,
})); }));
} }
} catch (testError) { } catch (testError) {
setModelResults((current) => ({ if (shouldRecordProbeResult()) {
...current, setModelResults((current) => ({
[modelId]: buildFailedModelTestResult( ...current,
providerId, [modelId]: buildFailedModelTestResult(
modelId, providerId,
testError instanceof Error ? testError.message : 'Failed to test model' modelId,
), testError instanceof Error ? testError.message : 'Failed to test model'
})); ),
}));
}
} finally { } finally {
setTestingModelId(null); setTestingModelIds((current) => current.filter((entry) => entry !== modelId));
} }
}, },
[options.projectPath, options.runtimeId] [options.projectPath, options.runtimeId]
@ -909,16 +904,22 @@ export function useRuntimeProviderManagement(
[options] [options]
); );
const selectProvider = useCallback((providerId: string): void => { const selectProvider = useCallback(
setupFormRequestSeq.current += 1; (providerId: string): void => {
setSelectedProviderId(providerId); setupFormRequestSeq.current += 1;
setActiveFormProviderId(null); setSelectedProviderId(providerId);
setSetupForm(null); setActiveFormProviderId(null);
setSetupFormError(null); setSetupForm(null);
setSetupSubmitError(null); setSetupFormError(null);
setSetupMetadata({}); setSetupSubmitError(null);
setApiKeyValue(''); setSetupMetadata({});
}, []); setApiKeyValue('');
if (activeModelPickerProviderRef.current !== providerId) {
closeModelPickerState();
}
},
[closeModelPickerState]
);
const state = useMemo<RuntimeProviderManagementState>( const state = useMemo<RuntimeProviderManagementState>(
() => ({ () => ({
@ -926,15 +927,12 @@ export function useRuntimeProviderManagement(
providers: view?.providers ?? [], providers: view?.providers ?? [],
selectedProviderId, selectedProviderId,
providerQuery, providerQuery,
directoryOpen,
directoryLoading, directoryLoading,
directoryRefreshing, directoryRefreshing,
directoryError, directoryError,
directoryEntries, directoryEntries,
directoryTotalCount, directoryTotalCount,
directoryNextCursor, directoryNextCursor,
directoryQuery,
directoryFilter,
directoryLoaded, directoryLoaded,
directorySelectedProviderId, directorySelectedProviderId,
directorySupported, directorySupported,
@ -952,7 +950,7 @@ export function useRuntimeProviderManagement(
modelsLoading, modelsLoading,
modelsError, modelsError,
selectedModelId, selectedModelId,
testingModelId, testingModelIds,
savingDefaultModelId, savingDefaultModelId,
modelResults, modelResults,
loading, loading,
@ -970,12 +968,9 @@ export function useRuntimeProviderManagement(
setupMetadata, setupMetadata,
directoryEntries, directoryEntries,
directoryError, directoryError,
directoryFilter,
directoryLoaded, directoryLoaded,
directoryLoading, directoryLoading,
directoryNextCursor, directoryNextCursor,
directoryOpen,
directoryQuery,
directoryRefreshing, directoryRefreshing,
directorySelectedProviderId, directorySelectedProviderId,
directorySupported, directorySupported,
@ -995,7 +990,7 @@ export function useRuntimeProviderManagement(
selectedModelId, selectedModelId,
selectedProviderId, selectedProviderId,
successMessage, successMessage,
testingModelId, testingModelIds,
view, view,
] ]
); );
@ -1005,10 +1000,6 @@ export function useRuntimeProviderManagement(
refresh, refresh,
selectProvider, selectProvider,
setProviderQuery: updateProviderQuery, setProviderQuery: updateProviderQuery,
openDirectory,
closeDirectory,
setDirectoryQuery: updateDirectoryQuery,
setDirectoryFilter,
loadMoreDirectory, loadMoreDirectory,
refreshDirectory, refreshDirectory,
selectDirectoryProvider, selectDirectoryProvider,
@ -1029,11 +1020,9 @@ export function useRuntimeProviderManagement(
}), }),
[ [
cancelConnect, cancelConnect,
closeDirectory,
closeModelPicker, closeModelPicker,
forgetProvider, forgetProvider,
loadMoreDirectory, loadMoreDirectory,
openDirectory,
openModelPicker, openModelPicker,
refresh, refresh,
refreshDirectory, refreshDirectory,
@ -1041,13 +1030,11 @@ export function useRuntimeProviderManagement(
selectDirectoryProvider, selectDirectoryProvider,
selectProvider, selectProvider,
setDefaultModel, setDefaultModel,
setDirectoryFilter,
setSetupMetadataValue, setSetupMetadataValue,
startConnect, startConnect,
submitConnect, submitConnect,
testModel, testModel,
updateApiKeyValue, updateApiKeyValue,
updateDirectoryQuery,
updateProviderQuery, updateProviderQuery,
useModelForNewTeams, useModelForNewTeams,
] ]

View file

@ -19,7 +19,6 @@ import {
} from '@renderer/utils/openCodeModelRecommendations'; } from '@renderer/utils/openCodeModelRecommendations';
import { import {
AlertTriangle, AlertTriangle,
ArrowLeft,
CheckCircle2, CheckCircle2,
KeyRound, KeyRound,
Loader2, Loader2,
@ -45,7 +44,6 @@ import type {
import type { import type {
RuntimeProviderConnectionDto, RuntimeProviderConnectionDto,
RuntimeProviderDirectoryEntryDto, RuntimeProviderDirectoryEntryDto,
RuntimeProviderDirectoryFilterDto,
RuntimeProviderModelDto, RuntimeProviderModelDto,
RuntimeProviderModelTestResultDto, RuntimeProviderModelTestResultDto,
RuntimeProviderSetupPromptDto, RuntimeProviderSetupPromptDto,
@ -77,15 +75,6 @@ interface ProviderRowProps {
readonly actions: RuntimeProviderManagementActions; 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( function getDirectoryAction(
provider: RuntimeProviderDirectoryEntryDto, provider: RuntimeProviderDirectoryEntryDto,
actionId: RuntimeProviderConnectionDto['actions'][number]['id'] actionId: RuntimeProviderConnectionDto['actions'][number]['id']
@ -120,6 +109,10 @@ function getDirectoryModelsLabel(provider: RuntimeProviderDirectoryEntryDto): st
return `${provider.modelCount} model${provider.modelCount === 1 ? '' : 's'}`; return `${provider.modelCount} model${provider.modelCount === 1 ? '' : 's'}`;
} }
function formatOpenCodeProviderCount(count: number): string {
return `${count} OpenCode provider${count === 1 ? '' : 's'}`;
}
function directoryEntryMatchesQuery( function directoryEntryMatchesQuery(
provider: RuntimeProviderDirectoryEntryDto, provider: RuntimeProviderDirectoryEntryDto,
query: string query: string
@ -223,7 +216,10 @@ function setupPromptVisible(
function setupFormCanSubmit(state: RuntimeProviderManagementState, providerId: string): boolean { function setupFormCanSubmit(state: RuntimeProviderManagementState, providerId: string): boolean {
const form = state.setupForm?.providerId === providerId ? state.setupForm : null; 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 false;
} }
return form.prompts return form.prompts
@ -231,6 +227,16 @@ function setupFormCanSubmit(state: RuntimeProviderManagementState, providerId: s
.every((prompt) => !prompt.required || Boolean(state.setupMetadata[prompt.key]?.trim())); .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({ function ProviderSetupFormPanel({
provider, provider,
state, state,
@ -637,31 +643,28 @@ function ProviderActions({
const forget = getProviderAction(provider, 'forget'); const forget = getProviderAction(provider, 'forget');
const configure = getProviderAction(provider, 'configure'); 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 ( return (
<div className="flex flex-wrap justify-end gap-1.5"> <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 ? ( {forget ? (
<Button <Button
type="button" type="button"
@ -721,11 +724,23 @@ function ProviderRow({
} }
actions.selectProvider(provider.providerId); 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 ( return (
<div <div
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : -1}
data-testid={`runtime-provider-row-${provider.providerId}`} 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 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-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' : 'cursor-default'
@ -735,6 +750,7 @@ function ProviderRow({
: 'border-[var(--color-border-subtle)] bg-white/[0.02]' : 'border-[var(--color-border-subtle)] bg-white/[0.02]'
}`} }`}
onClick={handleActivate} onClick={handleActivate}
onKeyDown={handleKeyDown}
> >
<div className="grid w-full grid-cols-[1fr_auto] items-start gap-3"> <div className="grid w-full grid-cols-[1fr_auto] items-start gap-3">
<div className="min-w-0 text-left"> <div className="min-w-0 text-left">
@ -846,7 +862,7 @@ function DirectoryProviderRow({
return ( return (
<div <div
role="button" role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : -1} tabIndex={clickable ? 0 : -1}
data-testid={`runtime-provider-directory-row-${provider.providerId}`} 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 ${ 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 !== ' ')) { if (!clickable || (event.key !== 'Enter' && event.key !== ' ')) {
return; return;
} }
if (eventStartedInInteractiveChild(event.currentTarget, event.target)) {
return;
}
event.preventDefault(); event.preventDefault();
handleActivate(); handleActivate();
}} }}
@ -940,7 +959,7 @@ function DirectoryProviderRow({
{forget.label} {forget.label}
</Button> </Button>
) : null} ) : null}
{!connect && configure ? ( {configure ? (
<Button <Button
type="button" type="button"
size="sm" 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({ function ModelBadges({
model, model,
usedForNewTeams, usedForNewTeams,
@ -1209,6 +1101,9 @@ function ModelRow({
if (event.key !== 'Enter' && event.key !== ' ') { if (event.key !== 'Enter' && event.key !== ' ') {
return; return;
} }
if (eventStartedInInteractiveChild(event.currentTarget, event.target)) {
return;
}
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
chooseModel(); chooseModel();
@ -1216,11 +1111,14 @@ function ModelRow({
return ( return (
<div <div
role="button" role={disabled ? undefined : 'button'}
tabIndex={disabled ? -1 : 0} tabIndex={disabled ? -1 : 0}
aria-pressed={selected} aria-disabled={disabled || undefined}
aria-pressed={disabled ? undefined : selected}
data-testid={`runtime-provider-model-row-${model.modelId}`} 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) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
chooseModel(); chooseModel();
@ -1378,7 +1276,7 @@ function ProviderModelList({
model={model} model={model}
selected={state.selectedModelId === model.modelId} selected={state.selectedModelId === model.modelId}
disabled={disabled} disabled={disabled}
testing={state.testingModelId === model.modelId} testing={state.testingModelIds.includes(model.modelId)}
result={state.modelResults[model.modelId]} result={state.modelResults[model.modelId]}
actions={actions} actions={actions}
/> />
@ -1417,11 +1315,12 @@ export function RuntimeProviderManagementPanelView({
const visibleDirectoryRows = state.directoryEntries.filter((provider) => const visibleDirectoryRows = state.directoryEntries.filter((provider) =>
directoryEntryMatchesQuery(provider, providerQuery) directoryEntryMatchesQuery(provider, providerQuery)
); );
const providerCountLabel = state.directoryTotalCount const providerCountLabel =
? `${state.directoryTotalCount} OpenCode providers` state.directoryTotalCount !== null
: state.directorySupported ? formatOpenCodeProviderCount(state.directoryTotalCount)
? 'OpenCode provider catalog' : state.directorySupported
: 'OpenCode providers'; ? 'OpenCode provider catalog'
: 'OpenCode providers';
return ( return (
<div className="space-y-3"> <div className="space-y-3">

View file

@ -14,22 +14,23 @@ import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
import { getErrorMessage } from '@shared/utils/errorHandling'; import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { isVersionOlder, normalizeVersion } from '@shared/utils/version'; import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
import electronUpdater from 'electron-updater';
const { autoUpdater } = electronUpdater;
import { app, net } from 'electron'; import { app, net } from 'electron';
import electronUpdater from 'electron-updater';
import { import {
getExpectedReleaseAssetUrls, getExpectedReleaseAssetUrls,
getLatestMacMetadataUrls, getLatestMacMetadataUrls,
getReleaseApiUrls,
isLatestMacMetadataCompatible, isLatestMacMetadataCompatible,
shouldSkipReleaseForUpdater,
} from './updaterReleaseMetadata'; } from './updaterReleaseMetadata';
import type { GithubReleaseMetadata } from './updaterReleaseMetadata';
import type { UpdaterStatus } from '@shared/types'; import type { UpdaterStatus } from '@shared/types';
import type { BrowserWindow } from 'electron'; import type { BrowserWindow } from 'electron';
const logger = createLogger('UpdaterService'); const logger = createLogger('UpdaterService');
const { autoUpdater } = electronUpdater;
function shouldSkipDevUpdateCheck(): boolean { function shouldSkipDevUpdateCheck(): boolean {
return ( 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 { export class UpdaterService {
private mainWindow: BrowserWindow | null = null; private mainWindow: BrowserWindow | null = null;
private periodicTimer: ReturnType<typeof setInterval> | null = null; private periodicTimer: ReturnType<typeof setInterval> | null = null;
@ -194,6 +210,20 @@ export class UpdaterService {
return false; 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. * 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 * 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: { private async verifyAndNotify(info: {
version: string; version: string;
releaseNotes?: string | unknown; releaseName?: unknown;
releaseNotes?: unknown;
}): Promise<void> { }): Promise<void> {
if (!this.isNewerThanCurrent(info.version)) { if (!this.isNewerThanCurrent(info.version)) {
logger.warn( logger.warn(
@ -210,6 +241,22 @@ export class UpdaterService {
return; 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); const urls = getExpectedReleaseAssetUrls(info.version, process.platform, process.arch);
if (urls.length > 0) { if (urls.length > 0) {
const exists = await assetExistsInAnyRepo(urls); const exists = await assetExistsInAnyRepo(urls);

View file

@ -2,6 +2,21 @@ const REPO_OWNER = '777genius';
const REPO_NAME = 'agent-teams-ai'; const REPO_NAME = 'agent-teams-ai';
const LEGACY_REPO_NAME = 'claude_agent_teams_ui'; 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 { export function buildReleaseAssetBase(version: string, repoName = REPO_NAME): string {
return `https://github.com/${REPO_OWNER}/${repoName}/releases/download/v${version}`; 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)]; 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( export function getExpectedReleaseAssetUrl(
version: string, version: string,
platform: NodeJS.Platform, platform: NodeJS.Platform,
@ -78,12 +112,18 @@ export function parseReleaseMetadataAssetNames(metadataText: string): Set<string
for (const rawLine of metadataText.split(/\r?\n/u)) { for (const rawLine of metadataText.split(/\r?\n/u)) {
const line = rawLine.trim(); const line = rawLine.trim();
const match = /^(?:-\s+)?(url|path):\s+(.+)$/u.exec(line); const normalizedLine = line.startsWith('- ') ? line.slice(2).trimStart() : line;
if (!match) { const separatorIndex = normalizedLine.indexOf(':');
if (separatorIndex <= 0) {
continue; 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; return assets;

View file

@ -65,6 +65,11 @@ export const ClaudeLogsPanel = ({
handleScroll, handleScroll,
} = ctrl; } = 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 ( return (
<div className={cn('min-w-0', className)}> <div className={cn('min-w-0', className)}>
{/* Toolbar */} {/* Toolbar */}
@ -72,8 +77,8 @@ export const ClaudeLogsPanel = ({
<span className="text-[11px] text-[var(--color-text-muted)]"> <span className="text-[11px] text-[var(--color-text-muted)]">
{data.total > 0 ? ( {data.total > 0 ? (
<> <>
<span className="font-mono">{data.total}</span> raw line <span className="font-mono">{data.total.toLocaleString()}</span> raw line
{data.total === 1 ? '' : 's'} {data.total === 1 ? '' : 's'} captured
</> </>
) : isAlive ? ( ) : isAlive ? (
'No logs yet.' 'No logs yet.'
@ -138,6 +143,7 @@ export const ClaudeLogsPanel = ({
containerRefCallback={containerRefCallback} containerRefCallback={containerRefCallback}
onScroll={handleScroll} onScroll={handleScroll}
compactMetaInTooltip={compactMetaInTooltip} compactMetaInTooltip={compactMetaInTooltip}
emptyMessageOverride={emptyRawLogsMessage}
viewerState={viewerState} viewerState={viewerState}
onViewerStateChange={onViewerStateChange} onViewerStateChange={onViewerStateChange}
footer={ footer={

View file

@ -52,6 +52,8 @@ interface CliLogsRichViewProps {
style?: React.CSSProperties; style?: React.CSSProperties;
/** Content rendered at the very bottom of the scroll container (e.g. "Show more" button). */ /** Content rendered at the very bottom of the scroll container (e.g. "Show more" button). */
footer?: React.ReactNode; 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. */ /** When true, hide compact inline metadata and expose it via hover tooltip instead. */
compactMetaInTooltip?: boolean; compactMetaInTooltip?: boolean;
@ -354,6 +356,7 @@ export const CliLogsRichView = ({
className, className,
style, style,
footer, footer,
emptyMessageOverride,
compactMetaInTooltip = false, compactMetaInTooltip = false,
viewerState: controlledState, viewerState: controlledState,
onViewerStateChange, onViewerStateChange,
@ -378,7 +381,7 @@ export const CliLogsRichView = ({
const entries = useMemo(() => groupBySubagent(groups), [groups]); const entries = useMemo(() => groupBySubagent(groups), [groups]);
const emptyMessage = const emptyMessage =
cliLogsTail.trim().length > 0 cliLogsTail.trim().length > 0
? 'No displayable assistant/runtime logs yet.' ? (emptyMessageOverride ?? 'Raw log lines captured, but none are displayable yet.')
: 'Waiting for response...'; : 'Waiting for response...';
// Derive expanded state: all groups expanded unless manually collapsed // 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 { useStore } from '@renderer/store';
import { import {
createDefaultClaudeLogsSidebarUiState,
getTeamClaudeLogsSidebarUiState, getTeamClaudeLogsSidebarUiState,
setTeamClaudeLogsSidebarUiState, setTeamClaudeLogsSidebarUiState,
} from './sidebar/teamSidebarUiState'; } from './sidebar/teamSidebarUiState';
@ -58,7 +57,7 @@ export interface ClaudeLogsController {
// Computed // Computed
filteredText: string; filteredText: string;
online: boolean; online: boolean;
badge: number | undefined; badge: string | undefined;
showMoreVisible: boolean; showMoreVisible: boolean;
lastLogPreview: LastLogPreview | null; lastLogPreview: LastLogPreview | null;
@ -364,14 +363,6 @@ function filterStreamJsonText(
return out.join('\n'); return out.join('\n');
} }
// =============================================================================
// Default viewer state
// =============================================================================
function createDefaultViewerState(): ClaudeLogsViewerState {
return createDefaultClaudeLogsSidebarUiState().viewerState;
}
// ============================================================================= // =============================================================================
// Hook // Hook
// ============================================================================= // =============================================================================
@ -608,7 +599,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
return filterStreamJsonText(data.lines, searchQuery, filter); return filterStreamJsonText(data.lines, searchQuery, filter);
}, [data.lines, normalizedText, 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 ──────────────────────────────────────────── // ── Container ref callback ────────────────────────────────────────────
const containerRefCallback = useCallback((el: HTMLDivElement | null) => { const containerRefCallback = useCallback((el: HTMLDivElement | null) => {

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
getReleaseApiUrls,
getExpectedLatestMacArtifacts, getExpectedLatestMacArtifacts,
getExpectedReleaseAssetUrl, getExpectedReleaseAssetUrl,
getExpectedReleaseAssetUrls, getExpectedReleaseAssetUrls,
@ -8,6 +9,7 @@ import {
getLatestMacMetadataUrls, getLatestMacMetadataUrls,
isLatestMacMetadataCompatible, isLatestMacMetadataCompatible,
parseReleaseMetadataAssetNames, parseReleaseMetadataAssetNames,
shouldSkipReleaseForUpdater,
} from '../../../../src/main/services/infrastructure/updaterReleaseMetadata'; } from '../../../../src/main/services/infrastructure/updaterReleaseMetadata';
describe('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/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', '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', () => { 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( expect(parseReleaseMetadataAssetNames(metadata)).toEqual(
new Set([ new Set(['Agent.Teams.AI-1.2.3-arm64-mac.zip', 'Agent.Teams.AI-1.2.3-arm64.dmg'])
'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'; import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController';
const cliLogsRichViewState = vi.hoisted(() => ({ const cliLogsRichViewState = vi.hoisted(() => ({
calls: [] as Array<Record<string, unknown>>, calls: [] as Record<string, unknown>[],
})); }));
vi.mock('@renderer/components/team/CliLogsRichView', () => ({ vi.mock('@renderer/components/team/CliLogsRichView', () => ({
CliLogsRichView: (props: Record<string, unknown>) => { CliLogsRichView: (props: Record<string, unknown>) => {
cliLogsRichViewState.calls.push(props); cliLogsRichViewState.calls.push(props);
return React.createElement( const cliLogsTail = typeof props.cliLogsTail === 'string' ? props.cliLogsTail : '';
'div', return React.createElement('div', { 'data-testid': 'cli-logs-rich-view' }, cliLogsTail);
{ 'data-testid': 'cli-logs-rich-view' },
String(props.cliLogsTail ?? '')
);
}, },
})); }));
@ -58,8 +55,8 @@ function createController(overrides: Partial<ClaudeLogsController> = {}): Claude
setFilterOpen: vi.fn(), setFilterOpen: vi.fn(),
viewerState: {} as ClaudeLogsController['viewerState'], viewerState: {} as ClaudeLogsController['viewerState'],
onViewerStateChange: vi.fn(), onViewerStateChange: vi.fn(),
applyPending: vi.fn(async () => {}), applyPending: vi.fn(() => Promise.resolve()),
loadOlderLogs: vi.fn(async () => {}), loadOlderLogs: vi.fn(() => Promise.resolve()),
containerRefCallback: vi.fn(), containerRefCallback: vi.fn(),
handleScroll: vi.fn(), handleScroll: vi.fn(),
...overrides, ...overrides,
@ -87,7 +84,7 @@ describe('ClaudeLogsPanel', () => {
updatedAt: '2026-04-19T10:00:01.000Z', updatedAt: '2026-04-19T10:00:01.000Z',
}, },
filteredText: '[stdout]\nfirst line\nsecond line', filteredText: '[stdout]\nfirst line\nsecond line',
badge: 2, badge: '2 raw',
}); });
await act(async () => { await act(async () => {
@ -133,4 +130,39 @@ describe('ClaudeLogsPanel', () => {
await Promise.resolve(); 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: [], providers: [],
selectedProviderId: 'openrouter', selectedProviderId: 'openrouter',
providerQuery: '', providerQuery: '',
directoryOpen: false,
directoryLoading: false, directoryLoading: false,
directoryRefreshing: false, directoryRefreshing: false,
directoryError: null, directoryError: null,
directoryEntries: [], directoryEntries: [],
directoryTotalCount: null, directoryTotalCount: null,
directoryNextCursor: null, directoryNextCursor: null,
directoryQuery: '',
directoryFilter: 'all',
directoryLoaded: false, directoryLoaded: false,
directorySelectedProviderId: null, directorySelectedProviderId: null,
directorySupported: true, directorySupported: true,
@ -79,7 +76,7 @@ function createState(
modelsLoading: false, modelsLoading: false,
modelsError: null, modelsError: null,
selectedModelId: null, selectedModelId: null,
testingModelId: null, testingModelIds: [],
savingDefaultModelId: null, savingDefaultModelId: null,
modelResults: {}, modelResults: {},
loading: false, loading: false,
@ -95,10 +92,6 @@ function createActions(): RuntimeProviderManagementActions {
refresh: vi.fn(() => Promise.resolve()), refresh: vi.fn(() => Promise.resolve()),
selectProvider: vi.fn(), selectProvider: vi.fn(),
setProviderQuery: vi.fn(), setProviderQuery: vi.fn(),
openDirectory: vi.fn(),
closeDirectory: vi.fn(),
setDirectoryQuery: vi.fn(),
setDirectoryFilter: vi.fn(),
loadMoreDirectory: vi.fn(() => Promise.resolve()), loadMoreDirectory: vi.fn(() => Promise.resolve()),
refreshDirectory: vi.fn(() => Promise.resolve()), refreshDirectory: vi.fn(() => Promise.resolve()),
selectDirectoryProvider: vi.fn(), selectDirectoryProvider: vi.fn(),
@ -248,6 +241,146 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(host.textContent).not.toContain('sk-secret-value'); 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 () => { it('filters providers from the local provider search', async () => {
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
@ -353,7 +486,6 @@ describe('RuntimeProviderManagementPanelView', () => {
root.render( root.render(
React.createElement(RuntimeProviderManagementPanelView, { React.createElement(RuntimeProviderManagementPanelView, {
state: createState({ state: createState({
directoryOpen: true,
directoryLoaded: true, directoryLoaded: true,
directoryTotalCount: 115, directoryTotalCount: 115,
directoryEntries: [ directoryEntries: [
@ -454,6 +586,127 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(actions.selectDirectoryProvider).not.toHaveBeenCalled(); 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 () => { it('uses the unified provider search when compact search has no matches', async () => {
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
@ -665,12 +918,8 @@ describe('RuntimeProviderManagementPanelView', () => {
const modelList = host.querySelector<HTMLElement>( const modelList = host.querySelector<HTMLElement>(
'[data-testid="runtime-provider-model-list"]' '[data-testid="runtime-provider-model-list"]'
); );
expect( expect(modelSearch?.style.paddingLeft).toBe('42px');
modelSearch?.style.paddingLeft expect(modelList?.style.maxHeight).toBe('300px');
).toBe('42px');
expect(
modelList?.style.maxHeight
).toBe('300px');
expect(host.textContent).not.toContain('OpenRouterfree'); expect(host.textContent).not.toContain('OpenRouterfree');
const firstTestButton = Array.from(host.querySelectorAll('button')).find( const firstTestButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Test' (button) => button.textContent?.trim() === 'Test'
@ -709,6 +958,21 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(actions.selectProvider).not.toHaveBeenCalled(); expect(actions.selectProvider).not.toHaveBeenCalled();
vi.mocked(actions.useModelForNewTeams).mockClear(); 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 () => { await act(async () => {
const notRecommendedRow = host.querySelector( const notRecommendedRow = host.querySelector(
'[data-testid="runtime-provider-model-row-openrouter/openai/gpt-oss-20b:free"]' '[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 () => { it('keeps directory provider models visible when a model row is selected', async () => {
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);

View file

@ -13,9 +13,16 @@ import {
} from '../../../../src/renderer/services/createTeamPreferences'; } from '../../../../src/renderer/services/createTeamPreferences';
import type { ElectronAPI } from '../../../../src/shared/types/api'; 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', { Object.defineProperty(window, 'electronAPI', {
configurable: true, configurable: true,
value: { 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', () => { describe('useRuntimeProviderManagement', () => {
let host: HTMLDivElement; let host: HTMLDivElement;
let state: RuntimeProviderManagementState | null = null; let state: RuntimeProviderManagementState | null = null;
@ -90,21 +147,7 @@ describe('useRuntimeProviderManagement', () => {
Promise.resolve({ Promise.resolve({
schemaVersion: 1, schemaVersion: 1,
runtimeId: 'opencode', runtimeId: 'opencode',
view: { view: createRuntimeView(),
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: [],
},
}) })
); );
Object.defineProperty(window, 'electronAPI', { 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 () => { it('lazy-loads provider directory and ignores stale search responses', async () => {
let resolveFirst: ((value: unknown) => void) | null = null; let resolveFirst: ((value: unknown) => void) | null = null;
const loadView = vi.fn(() => const loadView = vi.fn(() =>
@ -151,53 +580,52 @@ describe('useRuntimeProviderManagement', () => {
}, },
}) })
); );
const loadProviderDirectory = vi const deepseekDirectoryResponse = {
.fn() schemaVersion: 1 as const,
.mockImplementationOnce( runtimeId: 'opencode' as const,
() => directory: {
new Promise((resolve) => { runtimeId: 'opencode' as const,
resolveFirst = resolve; totalCount: 1,
}) returnedCount: 1,
) query: 'deep',
.mockResolvedValueOnce({ filter: 'all' as const,
schemaVersion: 1, limit: 50,
runtimeId: 'opencode', cursor: null,
directory: { nextCursor: null,
runtimeId: 'opencode', fetchedAt: '2026-04-25T00:00:00.000Z',
totalCount: 1, entries: [
returnedCount: 1, {
query: 'deep', providerId: 'deepseek',
filter: 'all', displayName: 'DeepSeek',
limit: 50, state: 'available' as const,
cursor: null, setupKind: 'available-readonly' as const,
nextCursor: null, ownership: [],
fetchedAt: '2026-04-25T00:00:00.000Z', recommended: false,
entries: [ modelCount: 62,
{ authMethods: [],
providerId: 'deepseek', defaultModelId: null,
displayName: 'DeepSeek', sources: ['opencode-provider'] as const,
state: 'available', sourceLabel: 'OpenCode catalog',
setupKind: 'available-readonly', providerSource: 'models.dev',
ownership: [], detail: null,
recommended: false, actions: [],
modelCount: 62, metadata: {
authMethods: [], hasKnownModels: true,
defaultModelId: null, requiresManualConfig: false,
sources: ['opencode-provider'], supportedInlineAuth: false,
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', { Object.defineProperty(window, 'electronAPI', {
configurable: true, configurable: true,
value: { value: {
@ -214,25 +642,23 @@ describe('useRuntimeProviderManagement', () => {
await Promise.resolve(); await Promise.resolve();
}); });
act(() => {
actions?.openDirectory();
});
await act(async () => { await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 10)); await new Promise((resolve) => window.setTimeout(resolve, 10));
}); });
await act(async () => { await act(async () => {
await vi.waitFor(() => { await vi.waitFor(() => {
expect(loadProviderDirectory).toHaveBeenCalledTimes(1); expect(loadProviderDirectory).toHaveBeenCalled();
}); });
}); });
const callCountBeforeSearch = loadProviderDirectory.mock.calls.length;
act(() => { act(() => {
actions?.setDirectoryQuery('deep'); actions?.setProviderQuery('deep');
}); });
await act(async () => { await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 300)); await new Promise((resolve) => window.setTimeout(resolve, 300));
await vi.waitFor(() => { 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'); 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 () => { 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 modelId = 'openrouter/anthropic/claude-3.5-haiku';
const message = const message =