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