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