feat(app): improve runtime provider and tmux flows
This commit is contained in:
parent
19b6937446
commit
523d450bc8
36 changed files with 3043 additions and 355 deletions
|
|
@ -18,8 +18,8 @@ import type { ServiceContext } from '@main/services';
|
|||
|
||||
const CODEX_THREAD_LIMIT = 20;
|
||||
const CODEX_INITIALIZE_TIMEOUT_MS = 6_000;
|
||||
const CODEX_LIVE_FETCH_TIMEOUT_MS = 12_000;
|
||||
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 4_000;
|
||||
const CODEX_LIVE_FETCH_TIMEOUT_MS = 18_000;
|
||||
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 6_000;
|
||||
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
|
||||
const CODEX_TOTAL_FETCH_TIMEOUT_MS =
|
||||
CODEX_INITIALIZE_TIMEOUT_MS +
|
||||
|
|
@ -61,6 +61,10 @@ function getFullFailureReason(result: CodexRecentThreadsResult): string | null {
|
|||
return result.live.error;
|
||||
}
|
||||
|
||||
if (result.archived.skipped) {
|
||||
return result.live.error;
|
||||
}
|
||||
|
||||
return `live: ${result.live.error}; archived: ${result.archived.error}`;
|
||||
}
|
||||
|
||||
|
|
@ -227,6 +231,14 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
return;
|
||||
}
|
||||
|
||||
if (result[segment].skipped) {
|
||||
this.deps.logger.info('codex recent-projects thread list skipped', {
|
||||
segment,
|
||||
reason: error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (segment === 'archived' && !result.live.error) {
|
||||
this.deps.logger.info('codex recent-projects archived thread list degraded', {
|
||||
error,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export interface CodexThreadSummary {
|
|||
export interface CodexThreadSegmentResult {
|
||||
threads: CodexThreadSummary[];
|
||||
error?: string;
|
||||
skipped?: boolean;
|
||||
}
|
||||
|
||||
export interface CodexRecentThreadsResult {
|
||||
|
|
@ -142,6 +143,17 @@ export class CodexAppServerClient {
|
|||
limit: options.limit,
|
||||
timeoutMs: liveRequestTimeoutMs,
|
||||
});
|
||||
if (live.error) {
|
||||
return {
|
||||
live,
|
||||
archived: {
|
||||
threads: [],
|
||||
error: `Skipped archived thread/list after live thread/list failed: ${live.error}`,
|
||||
skipped: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const archived = await this.#requestThreadListSegment(session, {
|
||||
archived: true,
|
||||
limit: options.limit,
|
||||
|
|
|
|||
|
|
@ -10,18 +10,19 @@ import type {
|
|||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementRuntimeId,
|
||||
RuntimeProviderManagementSetDefaultModelInput,
|
||||
RuntimeProviderManagementTestModelInput,
|
||||
RuntimeProviderManagementRuntimeId,
|
||||
RuntimeProviderManagementViewResponse,
|
||||
} from '@features/runtime-provider-management/contracts';
|
||||
import type { ChildProcessWithoutNullStreams } from 'child_process';
|
||||
|
||||
const COMMAND_TIMEOUT_MS = 45_000;
|
||||
const PROBE_COMMAND_TIMEOUT_MS = 90_000;
|
||||
const COMMAND_ERROR_DETAIL_LIMIT = 1_600;
|
||||
|
||||
type RuntimeProviderManagementErrorResponse =
|
||||
| RuntimeProviderManagementViewResponse
|
||||
|
|
@ -54,9 +55,54 @@ function extractJsonObject<T>(raw: string): T {
|
|||
return JSON.parse(raw.slice(start, end + 1)) as T;
|
||||
}
|
||||
|
||||
function tryExtractJsonObject<T>(raw: string | null): T | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return extractJsonObject<T>(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readErrorTextProperty(error: unknown, propertyName: 'stderr' | 'stdout'): string | null {
|
||||
if (!error || typeof error !== 'object' || !(propertyName in error)) {
|
||||
return null;
|
||||
}
|
||||
const value = (error as Record<string, unknown>)[propertyName];
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractJsonObjectFromError<T>(error: unknown): T | null {
|
||||
return (
|
||||
tryExtractJsonObject<T>(readErrorTextProperty(error, 'stdout')) ??
|
||||
tryExtractJsonObject<T>(readErrorTextProperty(error, 'stderr'))
|
||||
);
|
||||
}
|
||||
|
||||
function truncateCommandErrorDetail(message: string): string {
|
||||
if (message.length <= COMMAND_ERROR_DETAIL_LIMIT) {
|
||||
return message;
|
||||
}
|
||||
return `${message.slice(0, COMMAND_ERROR_DETAIL_LIMIT).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function normalizeCommandFailure(error: unknown): string {
|
||||
const stderr = readErrorTextProperty(error, 'stderr');
|
||||
if (stderr) {
|
||||
return truncateCommandErrorDetail(stderr);
|
||||
}
|
||||
const stdout = readErrorTextProperty(error, 'stdout');
|
||||
if (stdout) {
|
||||
return truncateCommandErrorDetail(stdout);
|
||||
}
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
return truncateCommandErrorDetail(error.message);
|
||||
}
|
||||
return 'Runtime provider management command failed';
|
||||
}
|
||||
|
|
@ -156,6 +202,10 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
|
|||
);
|
||||
return extractJsonObject<RuntimeProviderManagementViewResponse>(stdout);
|
||||
} catch (error) {
|
||||
const response = extractJsonObjectFromError<RuntimeProviderManagementViewResponse>(error);
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return errorResponse<RuntimeProviderManagementViewResponse>(
|
||||
input.runtimeId,
|
||||
normalizeCommandFailure(error)
|
||||
|
|
@ -208,6 +258,10 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
|
|||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const response = extractJsonObjectFromError<RuntimeProviderManagementProviderResponse>(error);
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return errorResponse<RuntimeProviderManagementProviderResponse>(
|
||||
input.runtimeId,
|
||||
normalizeCommandFailure(error)
|
||||
|
|
@ -287,6 +341,10 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
|
|||
});
|
||||
return extractJsonObject<RuntimeProviderManagementModelsResponse>(stdout);
|
||||
} catch (error) {
|
||||
const response = extractJsonObjectFromError<RuntimeProviderManagementModelsResponse>(error);
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return errorResponse<RuntimeProviderManagementModelsResponse>(
|
||||
input.runtimeId,
|
||||
normalizeCommandFailure(error)
|
||||
|
|
@ -325,6 +383,11 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
|
|||
);
|
||||
return extractJsonObject<RuntimeProviderManagementModelTestResponse>(stdout);
|
||||
} catch (error) {
|
||||
const response =
|
||||
extractJsonObjectFromError<RuntimeProviderManagementModelTestResponse>(error);
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return errorResponse<RuntimeProviderManagementModelTestResponse>(
|
||||
input.runtimeId,
|
||||
normalizeCommandFailure(error),
|
||||
|
|
@ -366,6 +429,10 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
|
|||
);
|
||||
return extractJsonObject<RuntimeProviderManagementViewResponse>(stdout);
|
||||
} catch (error) {
|
||||
const response = extractJsonObjectFromError<RuntimeProviderManagementViewResponse>(error);
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return errorResponse<RuntimeProviderManagementViewResponse>(
|
||||
input.runtimeId,
|
||||
normalizeCommandFailure(error),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import {
|
||||
getStoredCreateTeamModel,
|
||||
setStoredCreateTeamModel,
|
||||
setStoredCreateTeamProvider,
|
||||
} from '@renderer/services/createTeamPreferences';
|
||||
|
||||
export function getOpenCodeModelForNewTeams(): string | null {
|
||||
const modelId = getStoredCreateTeamModel('opencode').trim();
|
||||
return modelId.length > 0 ? modelId : null;
|
||||
}
|
||||
|
||||
export function saveOpenCodeModelForNewTeams(modelId: string): void {
|
||||
setStoredCreateTeamProvider('opencode');
|
||||
setStoredCreateTeamModel('opencode', modelId);
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 536 B |
|
|
@ -3,7 +3,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
import { api } from '@renderer/api';
|
||||
|
||||
import { selectInitialProviderId } from '../../core/domain';
|
||||
import { saveOpenCodeModelForNewTeams } from '../adapters/createTeamDefaultModelWriter';
|
||||
import {
|
||||
getOpenCodeModelForNewTeams,
|
||||
saveOpenCodeModelForNewTeams,
|
||||
} from '../adapters/createTeamDefaultModelWriter';
|
||||
|
||||
import type {
|
||||
RuntimeProviderConnectionDto,
|
||||
|
|
@ -25,6 +28,7 @@ export interface RuntimeProviderManagementState {
|
|||
view: RuntimeProviderManagementViewDto | null;
|
||||
providers: readonly RuntimeProviderConnectionDto[];
|
||||
selectedProviderId: string | null;
|
||||
providerQuery: string;
|
||||
activeFormProviderId: string | null;
|
||||
apiKeyValue: string;
|
||||
modelPickerProviderId: string | null;
|
||||
|
|
@ -46,6 +50,7 @@ export interface RuntimeProviderManagementState {
|
|||
export interface RuntimeProviderManagementActions {
|
||||
refresh: () => Promise<void>;
|
||||
selectProvider: (providerId: string) => void;
|
||||
setProviderQuery: (value: string) => void;
|
||||
startConnect: (providerId: string) => void;
|
||||
cancelConnect: () => void;
|
||||
setApiKeyValue: (value: string) => void;
|
||||
|
|
@ -111,11 +116,35 @@ function withUiTimeout<T>(promise: Promise<T>, message: string, timeoutMs = 70_0
|
|||
});
|
||||
}
|
||||
|
||||
function buildFailedModelTestResult(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
message: string
|
||||
): RuntimeProviderModelTestResultDto {
|
||||
return {
|
||||
providerId,
|
||||
modelId,
|
||||
ok: false,
|
||||
availability: 'unknown',
|
||||
message,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSavedModelForNewTeams(models: readonly RuntimeProviderModelDto[]): string | null {
|
||||
const savedModelId = getOpenCodeModelForNewTeams();
|
||||
if (!savedModelId) {
|
||||
return null;
|
||||
}
|
||||
return models.some((model) => model.modelId === savedModelId) ? savedModelId : null;
|
||||
}
|
||||
|
||||
export function useRuntimeProviderManagement(
|
||||
options: UseRuntimeProviderManagementOptions
|
||||
): [RuntimeProviderManagementState, RuntimeProviderManagementActions] {
|
||||
const [view, setView] = useState<RuntimeProviderManagementViewDto | null>(null);
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null);
|
||||
const [providerQuery, setProviderQuery] = useState('');
|
||||
const [activeFormProviderId, setActiveFormProviderId] = useState<string | null>(null);
|
||||
const [apiKeyValue, setApiKeyValue] = useState('');
|
||||
const [modelPickerProviderId, setModelPickerProviderId] = useState<string | null>(null);
|
||||
|
|
@ -170,6 +199,7 @@ export function useRuntimeProviderManagement(
|
|||
|
||||
useEffect(() => {
|
||||
if (!options.enabled) {
|
||||
setProviderQuery('');
|
||||
setApiKeyValue('');
|
||||
setActiveFormProviderId(null);
|
||||
const reset = resetModelState();
|
||||
|
|
@ -216,9 +246,7 @@ export function useRuntimeProviderManagement(
|
|||
if (current && nextModels.some((model) => model.modelId === current)) {
|
||||
return current;
|
||||
}
|
||||
return (
|
||||
nextModels.find((model) => model.default)?.modelId ?? nextModels[0]?.modelId ?? null
|
||||
);
|
||||
return resolveSavedModelForNewTeams(nextModels);
|
||||
});
|
||||
})
|
||||
.catch((modelsLoadError) => {
|
||||
|
|
@ -413,7 +441,7 @@ export function useRuntimeProviderManagement(
|
|||
const useModelForNewTeams = useCallback((modelId: string): void => {
|
||||
saveOpenCodeModelForNewTeams(modelId);
|
||||
setSelectedModelId(modelId);
|
||||
setSuccessMessage('Model saved for new teams');
|
||||
setSuccessMessage(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
|
|
@ -433,7 +461,10 @@ export function useRuntimeProviderManagement(
|
|||
100_000
|
||||
);
|
||||
if (response.error) {
|
||||
setError(response.error.message);
|
||||
setModelResults((current) => ({
|
||||
...current,
|
||||
[modelId]: buildFailedModelTestResult(providerId, modelId, response.error!.message),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (response.result) {
|
||||
|
|
@ -441,10 +472,16 @@ export function useRuntimeProviderManagement(
|
|||
...current,
|
||||
[modelId]: response.result!,
|
||||
}));
|
||||
setSuccessMessage(response.result.ok ? 'Model probe passed' : response.result.message);
|
||||
}
|
||||
} catch (testError) {
|
||||
setError(testError instanceof Error ? testError.message : 'Failed to test model');
|
||||
setModelResults((current) => ({
|
||||
...current,
|
||||
[modelId]: buildFailedModelTestResult(
|
||||
providerId,
|
||||
modelId,
|
||||
testError instanceof Error ? testError.message : 'Failed to test model'
|
||||
),
|
||||
}));
|
||||
} finally {
|
||||
setTestingModelId(null);
|
||||
}
|
||||
|
|
@ -504,6 +541,7 @@ export function useRuntimeProviderManagement(
|
|||
view,
|
||||
providers: view?.providers ?? [],
|
||||
selectedProviderId,
|
||||
providerQuery,
|
||||
activeFormProviderId,
|
||||
apiKeyValue,
|
||||
modelPickerProviderId,
|
||||
|
|
@ -533,6 +571,7 @@ export function useRuntimeProviderManagement(
|
|||
models,
|
||||
modelsError,
|
||||
modelsLoading,
|
||||
providerQuery,
|
||||
savingDefaultModelId,
|
||||
savingProviderId,
|
||||
selectedModelId,
|
||||
|
|
@ -547,6 +586,7 @@ export function useRuntimeProviderManagement(
|
|||
() => ({
|
||||
refresh,
|
||||
selectProvider,
|
||||
setProviderQuery,
|
||||
startConnect,
|
||||
cancelConnect,
|
||||
setApiKeyValue,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import {
|
||||
compareOpenCodeTeamModelRecommendations,
|
||||
getOpenCodeTeamModelRecommendation,
|
||||
isOpenCodeTeamModelRecommended,
|
||||
} from '@renderer/utils/openCodeModelRecommendations';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Play,
|
||||
RefreshCcw,
|
||||
Search,
|
||||
Star,
|
||||
|
|
@ -21,6 +28,8 @@ import {
|
|||
getProviderModelsLabel,
|
||||
} from '../../core/domain';
|
||||
|
||||
import { ProviderBrandIcon } from './providerBrandIcons';
|
||||
|
||||
import type {
|
||||
RuntimeProviderManagementActions,
|
||||
RuntimeProviderManagementState,
|
||||
|
|
@ -30,7 +39,7 @@ import type {
|
|||
RuntimeProviderModelDto,
|
||||
RuntimeProviderModelTestResultDto,
|
||||
} from '@features/runtime-provider-management/contracts';
|
||||
import type { JSX } from 'react';
|
||||
import type { CSSProperties, JSX, KeyboardEvent } from 'react';
|
||||
|
||||
interface RuntimeProviderManagementPanelViewProps {
|
||||
readonly state: RuntimeProviderManagementState;
|
||||
|
|
@ -43,8 +52,6 @@ interface ProviderActionsProps {
|
|||
readonly busy: boolean;
|
||||
readonly disabled: boolean;
|
||||
readonly onStartConnect: () => void;
|
||||
readonly onUse: () => void;
|
||||
readonly onSetDefault: () => void;
|
||||
readonly onForget: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +69,7 @@ interface ProviderRowProps {
|
|||
function stateClassName(provider: RuntimeProviderConnectionDto): string {
|
||||
switch (provider.state) {
|
||||
case 'connected':
|
||||
return 'border-emerald-400/25 bg-emerald-400/10 text-emerald-200';
|
||||
return 'border-emerald-400/35 bg-emerald-400/10';
|
||||
case 'available':
|
||||
return 'border-sky-400/25 bg-sky-400/10 text-sky-200';
|
||||
case 'error':
|
||||
|
|
@ -74,6 +81,18 @@ function stateClassName(provider: RuntimeProviderConnectionDto): string {
|
|||
}
|
||||
}
|
||||
|
||||
function stateStyle(provider: RuntimeProviderConnectionDto): CSSProperties | undefined {
|
||||
if (provider.state !== 'connected') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
color: '#86efac',
|
||||
borderColor: 'rgba(74, 222, 128, 0.38)',
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.11)',
|
||||
};
|
||||
}
|
||||
|
||||
function RuntimeSummary({
|
||||
state,
|
||||
onRefresh,
|
||||
|
|
@ -82,9 +101,11 @@ function RuntimeSummary({
|
|||
onRefresh: () => void;
|
||||
}): JSX.Element {
|
||||
const runtime = state.view?.runtime;
|
||||
const loadingWithoutRuntime = state.loading && !runtime;
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border p-3"
|
||||
aria-busy={state.loading}
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.025)',
|
||||
|
|
@ -96,18 +117,36 @@ function RuntimeSummary({
|
|||
OpenCode runtime
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
<Badge variant="outline" className="border-white/10">
|
||||
{runtime ? formatRuntimeState(runtime) : state.loading ? 'Loading' : 'Unavailable'}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`border-white/10 ${loadingWithoutRuntime ? 'bg-white/[0.04]' : ''}`}
|
||||
>
|
||||
{runtime
|
||||
? formatRuntimeState(runtime)
|
||||
: state.loading
|
||||
? 'Checking runtime'
|
||||
: 'Unavailable'}
|
||||
</Badge>
|
||||
{runtime?.version ? (
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>v{runtime.version}</span>
|
||||
) : null}
|
||||
{state.view?.defaultModel ? (
|
||||
<span className="break-all" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Default: {state.view.defaultModel}
|
||||
OpenCode default: {state.view.defaultModel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{state.loading ? (
|
||||
<div
|
||||
className="mt-2 flex items-center gap-2 text-xs"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>
|
||||
Loading managed OpenCode runtime, connected providers, and model defaults...
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{state.view?.diagnostics.length ? (
|
||||
<div
|
||||
className="mt-2 space-y-1 text-[11px]"
|
||||
|
|
@ -131,25 +170,170 @@ function RuntimeSummary({
|
|||
) : (
|
||||
<RefreshCcw className="mr-1 size-3.5" />
|
||||
)}
|
||||
Refresh
|
||||
{state.loading ? 'Checking...' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeProviderLoadingPlaceholder(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
data-testid="runtime-provider-loading-skeleton"
|
||||
className="rounded-lg border p-3"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="skeleton-shimmer size-6 rounded-md border"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'var(--skeleton-base)',
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
Loading OpenCode providers
|
||||
</div>
|
||||
<div
|
||||
className="skeleton-shimmer mt-1 h-3 w-72 max-w-full rounded-sm"
|
||||
style={{ backgroundColor: 'var(--skeleton-base-dim)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2" aria-hidden="true">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-md border px-3 py-2.5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255,255,255,0.018)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="skeleton-shimmer size-5 rounded-md border"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'var(--skeleton-base)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="skeleton-shimmer h-4 rounded-sm"
|
||||
style={{
|
||||
width: index === 0 ? 120 : index === 1 ? 92 : 150,
|
||||
backgroundColor: 'var(--skeleton-base)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="skeleton-shimmer h-5 rounded-md border"
|
||||
style={{
|
||||
width: index === 1 ? 72 : 96,
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'var(--skeleton-base-dim)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<div
|
||||
className="skeleton-shimmer h-3 rounded-sm"
|
||||
style={{
|
||||
width: index === 2 ? 64 : 82,
|
||||
backgroundColor: 'var(--skeleton-base-dim)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="skeleton-shimmer h-3 rounded-sm"
|
||||
style={{
|
||||
width: index === 0 ? 178 : 132,
|
||||
backgroundColor: 'var(--skeleton-base-dim)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="skeleton-shimmer h-8 w-20 shrink-0 rounded-md border"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'var(--skeleton-base-dim)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="skeleton-shimmer h-9 rounded-md border"
|
||||
style={{
|
||||
width: '74%',
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'var(--skeleton-base-dim)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeProviderModelLoadingSkeleton(): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-2" data-testid="runtime-provider-model-loading-skeleton">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-md border px-3 py-2.5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="skeleton-shimmer h-4 rounded-sm"
|
||||
style={{
|
||||
width: index === 0 ? '42%' : index === 1 ? '54%' : '36%',
|
||||
backgroundColor: 'var(--skeleton-base)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="skeleton-shimmer mt-2 h-3 rounded-sm"
|
||||
style={{
|
||||
width: index === 0 ? '64%' : index === 1 ? '46%' : '58%',
|
||||
backgroundColor: 'var(--skeleton-base-dim)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="skeleton-shimmer h-8 w-20 shrink-0 rounded-md border"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'var(--skeleton-base-dim)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderActions({
|
||||
provider,
|
||||
busy,
|
||||
disabled,
|
||||
onStartConnect,
|
||||
onUse,
|
||||
onSetDefault,
|
||||
onForget,
|
||||
}: ProviderActionsProps): JSX.Element {
|
||||
const connect = getProviderAction(provider, 'connect');
|
||||
const use = getProviderAction(provider, 'use');
|
||||
const setDefault = getProviderAction(provider, 'set-default');
|
||||
const forget = getProviderAction(provider, 'forget');
|
||||
const configure = getProviderAction(provider, 'configure');
|
||||
|
||||
|
|
@ -175,32 +359,6 @@ function ProviderActions({
|
|||
|
||||
return (
|
||||
<div className="flex flex-wrap justify-end gap-1.5">
|
||||
{use ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || busy || !use.enabled}
|
||||
title={use.disabledReason ?? undefined}
|
||||
onClick={onUse}
|
||||
>
|
||||
<Play className="mr-1 size-3.5" />
|
||||
{use.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{setDefault ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={disabled || busy || !setDefault.enabled}
|
||||
title={setDefault.disabledReason ?? undefined}
|
||||
onClick={onSetDefault}
|
||||
>
|
||||
<Star className="mr-1 size-3.5" />
|
||||
{setDefault.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{forget ? (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -229,9 +387,6 @@ function ProviderActions({
|
|||
{configure.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{!use && !setDefault && !forget && !configure ? (
|
||||
<span className="text-xs text-[var(--color-text-muted)]">No actions</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -248,25 +403,25 @@ function ProviderRow({
|
|||
}: ProviderRowProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border p-3"
|
||||
style={{
|
||||
borderColor: active ? 'rgba(96, 165, 250, 0.4)' : 'var(--color-border-subtle)',
|
||||
backgroundColor: active ? 'rgba(96, 165, 250, 0.055)' : 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
data-testid={`runtime-provider-row-${provider.providerId}`}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-all hover:border-sky-300/60 hover:bg-sky-400/[0.08] hover:shadow-[0_0_0_1px_rgba(125,211,252,0.18)] ${
|
||||
active
|
||||
? 'border-sky-300/70 bg-sky-400/[0.075] shadow-[0_0_0_1px_rgba(125,211,252,0.22)]'
|
||||
: 'border-[var(--color-border-subtle)] bg-white/[0.02]'
|
||||
}`}
|
||||
onClick={() => actions.selectProvider(provider.providerId)}
|
||||
>
|
||||
<div className="grid w-full grid-cols-[1fr_auto] items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 text-left"
|
||||
onClick={() => actions.selectProvider(provider.providerId)}
|
||||
>
|
||||
<div className="min-w-0 text-left">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ProviderBrandIcon provider={provider} />
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
{provider.displayName}
|
||||
</span>
|
||||
{provider.recommended ? <Badge variant="secondary">Recommended</Badge> : null}
|
||||
<span
|
||||
className={`rounded-md border px-2 py-0.5 text-[11px] ${stateClassName(provider)}`}
|
||||
style={stateStyle(provider)}
|
||||
>
|
||||
{formatProviderState(provider)}
|
||||
</span>
|
||||
|
|
@ -277,7 +432,7 @@ function ProviderRow({
|
|||
</span>
|
||||
{provider.defaultModelId ? (
|
||||
<span className="break-all" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Default: {provider.defaultModelId}
|
||||
OpenCode default: {provider.defaultModelId}
|
||||
</span>
|
||||
) : null}
|
||||
{provider.ownership.map((owner) => (
|
||||
|
|
@ -295,15 +450,13 @@ function ProviderRow({
|
|||
{provider.detail}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<ProviderActions
|
||||
provider={provider}
|
||||
busy={busy}
|
||||
disabled={disabled}
|
||||
onStartConnect={() => actions.startConnect(provider.providerId)}
|
||||
onUse={() => actions.openModelPicker(provider.providerId, 'use')}
|
||||
onSetDefault={() => actions.openModelPicker(provider.providerId, 'runtime-default')}
|
||||
onForget={() => void actions.forgetProvider(provider.providerId)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -364,12 +517,44 @@ function ProviderRow({
|
|||
);
|
||||
}
|
||||
|
||||
function ModelBadges({ model }: { readonly model: RuntimeProviderModelDto }): JSX.Element {
|
||||
function ModelBadges({
|
||||
model,
|
||||
usedForNewTeams,
|
||||
}: {
|
||||
readonly model: RuntimeProviderModelDto;
|
||||
readonly usedForNewTeams: boolean;
|
||||
}): JSX.Element | null {
|
||||
const modelRecommendation = getOpenCodeTeamModelRecommendation(model.modelId);
|
||||
|
||||
if (!model.free && !model.default && !usedForNewTeams && !modelRecommendation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="outline" className="border-white/10 px-1.5 py-0 text-[10px]">
|
||||
{model.sourceLabel}
|
||||
</Badge>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{modelRecommendation ? (
|
||||
<Badge
|
||||
className={
|
||||
modelRecommendation.level === 'recommended'
|
||||
? 'bg-amber-400/15 px-1.5 py-0 text-[10px] text-amber-200'
|
||||
: 'bg-red-400/15 px-1.5 py-0 text-[10px] text-red-200'
|
||||
}
|
||||
title={modelRecommendation.reason}
|
||||
>
|
||||
{modelRecommendation.level === 'recommended' ? (
|
||||
<Star className="mr-1 size-3 fill-current" />
|
||||
) : (
|
||||
<AlertTriangle className="mr-1 size-3" />
|
||||
)}
|
||||
{modelRecommendation.label}
|
||||
</Badge>
|
||||
) : null}
|
||||
{usedForNewTeams ? (
|
||||
<Badge className="bg-sky-400/15 px-1.5 py-0 text-[10px] text-sky-100">
|
||||
<Star className="mr-1 size-3" />
|
||||
Used for new teams
|
||||
</Badge>
|
||||
) : null}
|
||||
{model.free ? (
|
||||
<Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-200">free</Badge>
|
||||
) : null}
|
||||
|
|
@ -389,7 +574,11 @@ function ModelResult({
|
|||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={`mt-2 text-xs ${result.ok ? 'text-emerald-200' : 'text-red-200'}`}>
|
||||
<div
|
||||
className="mt-2 text-xs"
|
||||
style={{ color: result.ok ? '#86efac' : '#fecaca' }}
|
||||
data-testid={`runtime-provider-model-result-${result.modelId}`}
|
||||
>
|
||||
{result.message}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -401,82 +590,79 @@ function ModelRow({
|
|||
selected,
|
||||
disabled,
|
||||
testing,
|
||||
savingDefault,
|
||||
result,
|
||||
actions,
|
||||
mode,
|
||||
}: {
|
||||
readonly provider: RuntimeProviderConnectionDto;
|
||||
readonly model: RuntimeProviderModelDto;
|
||||
readonly selected: boolean;
|
||||
readonly disabled: boolean;
|
||||
readonly testing: boolean;
|
||||
readonly savingDefault: boolean;
|
||||
readonly result: RuntimeProviderModelTestResultDto | undefined;
|
||||
readonly actions: RuntimeProviderManagementActions;
|
||||
readonly mode: RuntimeProviderManagementState['modelPickerMode'];
|
||||
}): JSX.Element {
|
||||
const useButton = (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={() => actions.useModelForNewTeams(model.modelId)}
|
||||
>
|
||||
Use for new teams
|
||||
</Button>
|
||||
);
|
||||
const setDefaultButton = (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={model.default ? 'ghost' : 'outline'}
|
||||
disabled={disabled || savingDefault}
|
||||
onClick={() => void actions.setDefaultModel(provider.providerId, model.modelId)}
|
||||
>
|
||||
{savingDefault ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
|
||||
Set OpenCode default
|
||||
</Button>
|
||||
);
|
||||
const chooseModel = (): void => {
|
||||
if (!disabled) {
|
||||
actions.useModelForNewTeams(model.modelId);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
chooseModel();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border p-3"
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-pressed={selected}
|
||||
data-testid={`runtime-provider-model-row-${model.modelId}`}
|
||||
className="cursor-pointer rounded-md border px-3 py-2.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/45"
|
||||
onClick={chooseModel}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
borderColor: selected ? 'rgba(96, 165, 250, 0.45)' : 'var(--color-border-subtle)',
|
||||
backgroundColor: selected ? 'rgba(96, 165, 250, 0.06)' : 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 text-left"
|
||||
onClick={() => actions.selectModel(model.modelId)}
|
||||
>
|
||||
<div className="break-all text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
|
||||
<div className="block w-full min-w-0 text-left">
|
||||
<div
|
||||
className="text-sm font-medium leading-5"
|
||||
style={{ color: 'var(--color-text)', overflowWrap: 'anywhere' }}
|
||||
>
|
||||
{model.displayName}
|
||||
</div>
|
||||
<div className="mt-1 break-all text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
<div
|
||||
className="mt-1 text-[11px] leading-4"
|
||||
style={{ color: 'var(--color-text-muted)', overflowWrap: 'anywhere' }}
|
||||
>
|
||||
{model.modelId}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ModelBadges model={model} />
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<ModelBadges model={model} usedForNewTeams={selected} />
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
className="h-8 min-w-20 justify-center"
|
||||
disabled={disabled || testing}
|
||||
onClick={() => void actions.testModel(provider.providerId, model.modelId)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void actions.testModel(provider.providerId, model.modelId);
|
||||
}}
|
||||
>
|
||||
{testing ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
|
||||
{testing ? (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-1 size-3.5" />
|
||||
)}
|
||||
Test
|
||||
</Button>
|
||||
{mode === 'runtime-default' ? setDefaultButton : useButton}
|
||||
{mode === 'runtime-default' ? useButton : setDefaultButton}
|
||||
</div>
|
||||
</div>
|
||||
<ModelResult result={result} />
|
||||
|
|
@ -496,18 +682,66 @@ function ProviderModelList({
|
|||
readonly disabled: boolean;
|
||||
}): JSX.Element {
|
||||
const pickerOpen = state.modelPickerProviderId === provider.providerId;
|
||||
const [recommendedOnly, setRecommendedOnly] = useState(false);
|
||||
const hasRecommendedModels = useMemo(
|
||||
() => state.models.some((model) => isOpenCodeTeamModelRecommended(model.modelId)),
|
||||
[state.models]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRecommendedModels) {
|
||||
setRecommendedOnly(false);
|
||||
}
|
||||
}, [hasRecommendedModels]);
|
||||
|
||||
const visibleModels = useMemo(
|
||||
() =>
|
||||
state.models
|
||||
.map((model, index) => ({ model, index }))
|
||||
.filter(({ model }) => !recommendedOnly || isOpenCodeTeamModelRecommended(model.modelId))
|
||||
.sort((left, right) => {
|
||||
const recommendationOrder = compareOpenCodeTeamModelRecommendations(
|
||||
left.model.modelId,
|
||||
right.model.modelId
|
||||
);
|
||||
return recommendationOrder || left.index - right.index;
|
||||
})
|
||||
.map(({ model }) => model),
|
||||
[recommendedOnly, state.models]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3 border-t border-white/10 pt-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-2.5 size-4 text-[var(--color-text-muted)]" />
|
||||
<Input
|
||||
value={state.modelQuery}
|
||||
disabled={disabled || state.modelsLoading}
|
||||
onChange={(event) => actions.setModelQuery(event.target.value)}
|
||||
placeholder="Search models"
|
||||
className="h-9 pl-9 text-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[220px] flex-1">
|
||||
<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-model-search"
|
||||
value={state.modelQuery}
|
||||
disabled={disabled || state.modelsLoading}
|
||||
onChange={(event) => actions.setModelQuery(event.target.value)}
|
||||
placeholder="Search models"
|
||||
className="h-10 pl-10 pr-3 text-sm leading-5"
|
||||
style={{ paddingLeft: 42 }}
|
||||
/>
|
||||
</div>
|
||||
{hasRecommendedModels ? (
|
||||
<div className="flex h-10 items-center gap-2 rounded-md border border-white/10 px-3">
|
||||
<Checkbox
|
||||
id={`runtime-provider-${provider.providerId}-recommended-only`}
|
||||
checked={recommendedOnly}
|
||||
disabled={disabled || state.modelsLoading}
|
||||
onCheckedChange={(checked) => setRecommendedOnly(checked === true)}
|
||||
className="size-3.5"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`runtime-provider-${provider.providerId}-recommended-only`}
|
||||
className="cursor-pointer text-xs font-normal text-[var(--color-text-secondary)]"
|
||||
>
|
||||
Recommended only
|
||||
</Label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{state.modelsError ? (
|
||||
|
|
@ -516,18 +750,19 @@ function ProviderModelList({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-[360px] space-y-2 overflow-y-auto pr-1">
|
||||
{!pickerOpen || state.modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-text-muted)]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading models
|
||||
<div
|
||||
data-testid="runtime-provider-model-list"
|
||||
className="space-y-2 overflow-y-auto pr-1"
|
||||
style={{ maxHeight: 300 }}
|
||||
>
|
||||
{!pickerOpen || state.modelsLoading ? <RuntimeProviderModelLoadingSkeleton /> : null}
|
||||
{pickerOpen && !state.modelsLoading && visibleModels.length === 0 && !state.modelsError ? (
|
||||
<div className="text-sm text-[var(--color-text-muted)]">
|
||||
{recommendedOnly ? 'No recommended models found.' : 'No models found.'}
|
||||
</div>
|
||||
) : null}
|
||||
{pickerOpen && !state.modelsLoading && state.models.length === 0 && !state.modelsError ? (
|
||||
<div className="text-sm text-[var(--color-text-muted)]">No models found.</div>
|
||||
) : null}
|
||||
{pickerOpen
|
||||
? state.models.map((model) => (
|
||||
? visibleModels.map((model) => (
|
||||
<ModelRow
|
||||
key={model.modelId}
|
||||
provider={provider}
|
||||
|
|
@ -535,10 +770,8 @@ function ProviderModelList({
|
|||
selected={state.selectedModelId === model.modelId}
|
||||
disabled={disabled}
|
||||
testing={state.testingModelId === model.modelId}
|
||||
savingDefault={state.savingDefaultModelId === model.modelId}
|
||||
result={state.modelResults[model.modelId]}
|
||||
actions={actions}
|
||||
mode={state.modelPickerMode}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
|
|
@ -552,7 +785,27 @@ export function RuntimeProviderManagementPanelView({
|
|||
actions,
|
||||
disabled,
|
||||
}: RuntimeProviderManagementPanelViewProps): JSX.Element {
|
||||
const selectedProviderId = state.selectedProviderId ?? state.providers[0]?.providerId ?? null;
|
||||
const providerQuery = state.providerQuery.trim().toLowerCase();
|
||||
const filteredProviders = providerQuery
|
||||
? state.providers.filter((provider) =>
|
||||
[
|
||||
provider.providerId,
|
||||
provider.displayName,
|
||||
provider.detail ?? '',
|
||||
provider.defaultModelId ?? '',
|
||||
getProviderModelsLabel(provider),
|
||||
formatProviderState(provider),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(providerQuery)
|
||||
)
|
||||
: state.providers;
|
||||
const selectedProviderId = filteredProviders.some(
|
||||
(provider) => provider.providerId === state.selectedProviderId
|
||||
)
|
||||
? state.selectedProviderId
|
||||
: (filteredProviders[0]?.providerId ?? state.selectedProviderId ?? null);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -586,8 +839,26 @@ export function RuntimeProviderManagementPanelView({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{state.providers.length > 0 ? (
|
||||
<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-search"
|
||||
value={state.providerQuery}
|
||||
disabled={disabled || state.loading}
|
||||
onChange={(event) => actions.setProviderQuery(event.target.value)}
|
||||
placeholder="Search providers"
|
||||
className="h-9 pr-3 text-sm"
|
||||
style={{ paddingLeft: 40 }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-[62vh] space-y-2 overflow-y-auto pr-1">
|
||||
{state.providers.map((provider) => (
|
||||
{state.loading && state.providers.length === 0 ? (
|
||||
<RuntimeProviderLoadingPlaceholder />
|
||||
) : null}
|
||||
{filteredProviders.map((provider) => (
|
||||
<ProviderRow
|
||||
key={provider.providerId}
|
||||
provider={provider}
|
||||
|
|
@ -602,6 +873,18 @@ export function RuntimeProviderManagementPanelView({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{!state.loading && state.providers.length > 0 && filteredProviders.length === 0 ? (
|
||||
<div
|
||||
className="rounded-lg border p-3 text-sm"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
No providers match that search.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!state.loading && state.providers.length === 0 ? (
|
||||
<div
|
||||
className="rounded-lg border p-3 text-sm"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,478 @@
|
|||
import opencodeIconUrl from '../assets/provider-icons/opencode-favicon.png';
|
||||
|
||||
import type { RuntimeProviderConnectionDto } from '@features/runtime-provider-management/contracts';
|
||||
import type { CSSProperties, JSX } from 'react';
|
||||
|
||||
interface SvgPath {
|
||||
d: string;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
type BrandIconDescriptor =
|
||||
| {
|
||||
kind: 'svg';
|
||||
viewBox: string;
|
||||
paths: readonly SvgPath[];
|
||||
background: string;
|
||||
border: string;
|
||||
color: string;
|
||||
}
|
||||
| {
|
||||
kind: 'image';
|
||||
src: string;
|
||||
background: string;
|
||||
border: string;
|
||||
}
|
||||
| {
|
||||
kind: 'letters';
|
||||
label: string;
|
||||
background: string;
|
||||
border: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const OPENAI_PATH =
|
||||
'M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z';
|
||||
const GOOGLE_PATH =
|
||||
'M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z';
|
||||
const GOOGLE_CLOUD_PATH =
|
||||
'M12.19 2.38a9.344 9.344 0 0 0-9.234 6.893c.053-.02-.055.013 0 0-3.875 2.551-3.922 8.11-.247 10.941l.006-.007-.007.03a6.717 6.717 0 0 0 4.077 1.356h5.173l.03.03h5.192c6.687.053 9.376-8.605 3.835-12.35a9.365 9.365 0 0 0-2.821-4.552l-.043.043.006-.05A9.344 9.344 0 0 0 12.19 2.38zm-.358 4.146c1.244-.04 2.518.368 3.486 1.15a5.186 5.186 0 0 1 1.862 4.078v.518c3.53-.07 3.53 5.262 0 5.193h-5.193l-.008.009v-.04H6.785a2.59 2.59 0 0 1-1.067-.23h.001a2.597 2.597 0 1 1 3.437-3.437l3.013-3.012A6.747 6.747 0 0 0 8.11 8.24c.018-.01.04-.026.054-.023a5.186 5.186 0 0 1 3.67-1.69z';
|
||||
const GITHUB_PATH =
|
||||
'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12';
|
||||
const MISTRAL_PATH =
|
||||
'M17.143 3.429v3.428h-3.429v3.429h-3.428V6.857H6.857V3.43H3.43v13.714H0v3.428h10.286v-3.428H6.857v-3.429h3.429v3.429h3.429v-3.429h3.428v3.429h-3.428v3.428H24v-3.428h-3.43V3.429z';
|
||||
const MINIMAX_PATH =
|
||||
'M11.43 3.92a.86.86 0 1 0-1.718 0v14.236a1.999 1.999 0 0 1-3.997 0V9.022a.86.86 0 1 0-1.718 0v3.87a1.999 1.999 0 0 1-3.997 0V11.49a.57.57 0 0 1 1.139 0v1.404a.86.86 0 0 0 1.719 0V9.022a1.999 1.999 0 0 1 3.997 0v9.134a.86.86 0 0 0 1.719 0V3.92a1.998 1.998 0 1 1 3.996 0v11.788a.57.57 0 1 1-1.139 0zm10.572 3.105a2 2 0 0 0-1.999 1.997v7.63a.86.86 0 0 1-1.718 0V3.923a1.999 1.999 0 0 0-3.997 0v16.16a.86.86 0 0 1-1.719 0V18.08a.57.57 0 1 0-1.138 0v2a1.998 1.998 0 0 0 3.996 0V3.92a.86.86 0 0 1 1.719 0v12.73a1.999 1.999 0 0 0 3.996 0V9.023a.86.86 0 1 1 1.72 0v6.686a.57.57 0 0 0 1.138 0V9.022a2 2 0 0 0-1.998-1.997';
|
||||
const NVIDIA_PATH =
|
||||
'M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z';
|
||||
const PERPLEXITY_PATH =
|
||||
'M22.3977 7.0896h-2.3106V.0676l-7.5094 6.3542V.1577h-1.1554v6.1966L4.4904 0v7.0896H1.6023v10.3976h2.8882V24l6.932-6.3591v6.2005h1.1554v-6.0469l6.9318 6.1807v-6.4879h2.8882V7.0896zm-3.4657-4.531v4.531h-5.355l5.355-4.531zm-13.2862.0676 4.8691 4.4634H5.6458V2.6262zM2.7576 16.332V8.245h7.8476l-6.1149 6.1147v1.9723H2.7576zm2.8882 5.0404v-3.8852h.0001v-2.6488l5.7763-5.7764v7.0111l-5.7764 5.2993zm12.7086.0248-5.7766-5.1509V9.0618l5.7766 5.7766v6.5588zm2.8882-5.0652h-1.733v-1.9723L13.3948 8.245h7.8478v8.087z';
|
||||
const VERCEL_PATH = 'm12 1.608 12 20.784H0Z';
|
||||
|
||||
// Brand marks are sourced from provider-owned assets where available, or from Simple Icons
|
||||
// 16.17.0 entries whose source URLs point to the provider's official site/press kit.
|
||||
// Providers without a verified compact mark use branded initials instead of approximate logos.
|
||||
const BRAND_ICONS: Record<string, BrandIconDescriptor> = {
|
||||
'github-models': {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: '#f8fafc',
|
||||
border: 'rgba(248, 250, 252, 0.5)',
|
||||
color: '#181717',
|
||||
paths: [{ d: GITHUB_PATH }],
|
||||
},
|
||||
'github-copilot': {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: '#f8fafc',
|
||||
border: 'rgba(248, 250, 252, 0.5)',
|
||||
color: '#181717',
|
||||
paths: [{ d: GITHUB_PATH }],
|
||||
},
|
||||
'google-cloud': {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(66, 133, 244, 0.14)',
|
||||
border: 'rgba(66, 133, 244, 0.42)',
|
||||
color: '#4285F4',
|
||||
paths: [{ d: GOOGLE_CLOUD_PATH }],
|
||||
},
|
||||
'google-vertex': {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(66, 133, 244, 0.14)',
|
||||
border: 'rgba(66, 133, 244, 0.42)',
|
||||
color: '#4285F4',
|
||||
paths: [{ d: GOOGLE_CLOUD_PATH }],
|
||||
},
|
||||
anthropic: {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: '#CC9B7A',
|
||||
border: 'rgba(204, 155, 122, 0.5)',
|
||||
color: '#1F1F1E',
|
||||
paths: [
|
||||
{
|
||||
d: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
'cloudflare-ai-gateway': {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(243, 128, 32, 0.14)',
|
||||
border: 'rgba(243, 128, 32, 0.4)',
|
||||
color: '#F38020',
|
||||
paths: [
|
||||
{
|
||||
d: 'M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727',
|
||||
},
|
||||
],
|
||||
},
|
||||
'cloudflare-workers-ai': {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(243, 128, 32, 0.14)',
|
||||
border: 'rgba(243, 128, 32, 0.4)',
|
||||
color: '#F38020',
|
||||
paths: [
|
||||
{
|
||||
d: 'm8.213.063 8.879 12.136-8.67 11.739h2.476l8.665-11.735-8.89-12.14Zm4.728 0 9.02 11.992-9.018 11.883h2.496L24 12.656v-1.199L15.434.063ZM7.178 2.02.01 11.398l-.01 1.2 7.203 9.644 1.238-1.676-6.396-8.556 6.361-8.313Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
cloudflare: {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(243, 128, 32, 0.14)',
|
||||
border: 'rgba(243, 128, 32, 0.4)',
|
||||
color: '#F38020',
|
||||
paths: [
|
||||
{
|
||||
d: 'M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727',
|
||||
},
|
||||
],
|
||||
},
|
||||
github: {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: '#f8fafc',
|
||||
border: 'rgba(248, 250, 252, 0.5)',
|
||||
color: '#181717',
|
||||
paths: [{ d: GITHUB_PATH }],
|
||||
},
|
||||
'gitlab-duo': {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(252, 109, 38, 0.14)',
|
||||
border: 'rgba(252, 109, 38, 0.42)',
|
||||
color: '#FC6D26',
|
||||
paths: [
|
||||
{
|
||||
d: 'm23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z',
|
||||
},
|
||||
],
|
||||
},
|
||||
gitlab: {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(252, 109, 38, 0.14)',
|
||||
border: 'rgba(252, 109, 38, 0.42)',
|
||||
color: '#FC6D26',
|
||||
paths: [
|
||||
{
|
||||
d: 'm23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z',
|
||||
},
|
||||
],
|
||||
},
|
||||
google: {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(66, 133, 244, 0.13)',
|
||||
border: 'rgba(66, 133, 244, 0.42)',
|
||||
color: '#4285F4',
|
||||
paths: [{ d: GOOGLE_PATH }],
|
||||
},
|
||||
'hugging-face': {
|
||||
kind: 'letters',
|
||||
label: 'HF',
|
||||
background: 'rgba(255, 210, 30, 0.18)',
|
||||
border: 'rgba(255, 210, 30, 0.42)',
|
||||
color: '#FFD21E',
|
||||
},
|
||||
huggingface: {
|
||||
kind: 'letters',
|
||||
label: 'HF',
|
||||
background: 'rgba(255, 210, 30, 0.18)',
|
||||
border: 'rgba(255, 210, 30, 0.42)',
|
||||
color: '#FFD21E',
|
||||
},
|
||||
minimax: {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(231, 53, 98, 0.14)',
|
||||
border: 'rgba(231, 53, 98, 0.42)',
|
||||
color: '#E73562',
|
||||
paths: [{ d: MINIMAX_PATH }],
|
||||
},
|
||||
mistral: {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(250, 82, 15, 0.14)',
|
||||
border: 'rgba(250, 82, 15, 0.42)',
|
||||
color: '#FA520F',
|
||||
paths: [{ d: MISTRAL_PATH }],
|
||||
},
|
||||
'mistral-ai': {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(250, 82, 15, 0.14)',
|
||||
border: 'rgba(250, 82, 15, 0.42)',
|
||||
color: '#FA520F',
|
||||
paths: [{ d: MISTRAL_PATH }],
|
||||
},
|
||||
nvidia: {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(118, 185, 0, 0.14)',
|
||||
border: 'rgba(118, 185, 0, 0.42)',
|
||||
color: '#76B900',
|
||||
paths: [{ d: NVIDIA_PATH }],
|
||||
},
|
||||
opencode: {
|
||||
kind: 'image',
|
||||
src: opencodeIconUrl,
|
||||
background: 'rgba(148, 163, 184, 0.12)',
|
||||
border: 'rgba(148, 163, 184, 0.32)',
|
||||
},
|
||||
openai: {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 256 260',
|
||||
background: '#f8fafc',
|
||||
border: 'rgba(248, 250, 252, 0.5)',
|
||||
color: '#111827',
|
||||
paths: [{ d: OPENAI_PATH }],
|
||||
},
|
||||
openrouter: {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(148, 163, 184, 0.13)',
|
||||
border: 'rgba(148, 163, 184, 0.38)',
|
||||
color: '#94A3B8',
|
||||
paths: [
|
||||
{
|
||||
d: 'M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
perplexity: {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(31, 184, 205, 0.14)',
|
||||
border: 'rgba(31, 184, 205, 0.42)',
|
||||
color: '#1FB8CD',
|
||||
paths: [{ d: PERPLEXITY_PATH }],
|
||||
},
|
||||
'perplexity-agent': {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(31, 184, 205, 0.14)',
|
||||
border: 'rgba(31, 184, 205, 0.42)',
|
||||
color: '#1FB8CD',
|
||||
paths: [{ d: PERPLEXITY_PATH }],
|
||||
},
|
||||
poe: {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: 'rgba(93, 92, 222, 0.16)',
|
||||
border: 'rgba(93, 92, 222, 0.44)',
|
||||
color: '#8f8df2',
|
||||
paths: [
|
||||
{
|
||||
d: 'M24 12.513V8.36c0-.888-.717-1.608-1.603-1.615h-.013c-.498-.009-1.194-.123-1.688-.619-.44-.439-.584-1.172-.622-1.783l-.001.003c-.002-.014-.002-.03-.003-.044l-.001-.03a1.616 1.616 0 0 0-1.607-1.45H5.54a1.59 1.59 0 0 0-.164.008l-.055.009c-.034.004-.068.008-.102.015l-.069.017c-.028.008-.056.013-.083.022-.024.007-.045.015-.07.024-.026.01-.053.018-.08.03-.021.008-.042.02-.063.029-.027.013-.054.024-.08.038l-.059.034c-.025.015-.052.03-.077.047a.967.967 0 0 0-.061.045c-.021.015-.044.03-.065.05a1.21 1.21 0 0 0-.099.09c-.006.005-.013.01-.018.016l-.014.016a1.59 1.59 0 0 0-.094.102c-.017.02-.03.042-.046.062-.016.021-.033.042-.047.063l-.045.074-.037.062-.036.076a.682.682 0 0 0-.058.143l-.027.075-.02.074a.773.773 0 0 0-.018.078c-.006.03-.009.058-.013.088-.003.022-.008.045-.01.069-.003.022-.003.045-.004.068l-.002-.002c-.036.61-.182 1.345-.62 1.784-.496.495-1.191.61-1.69.618h-.012c-.05 0-.1.003-.147.007a1.27 1.27 0 0 0-.072.012c-.029.004-.057.007-.084.012l-.082.02-.072.018c-.026.009-.052.019-.079.027-.024.009-.048.016-.07.026-.024.01-.048.022-.072.034a.767.767 0 0 0-.072.033l-.068.04-.068.041a1.228 1.228 0 0 0-.072.054c-.018.014-.037.026-.053.04a1.627 1.627 0 0 0-.226.227c-.015.016-.027.036-.041.053a1.398 1.398 0 0 0-.054.074c-.016.022-.028.045-.041.067L.19 7.6c-.012.023-.022.047-.033.07l-.034.073c-.01.024-.017.046-.026.07-.01.027-.02.053-.027.08-.007.023-.012.047-.018.071l-.02.082-.012.084c-.003.024-.009.048-.01.072-.007.052-.01.106-.01.16v4.152c0 .888.717 1.609 1.603 1.616h.01c.5.008 1.196.123 1.69.618.43.43.577 1.143.618 1.746v4.13c0 .524.66.754.986.346l2.333-2.92h11.22c.861 0 1.563-.675 1.611-1.524l.001.003c.037-.61.183-1.344.622-1.783.495-.496 1.19-.61 1.689-.619h.012c.044 0 .088-.003.132-.007l.022-.001A1.613 1.613 0 0 0 24 12.513zm-3.85 1.69c-.502.503-1.215.613-1.717.619H5.566c-.501-.006-1.215-.114-1.717-.618-.408-.409-.565-1.117-.618-1.744V8.415c.052-.627.209-1.337.618-1.745.503-.503 1.216-.613 1.717-.619h12.867c.502.006 1.216.115 1.718.619.409.41.564 1.117.618 1.744v4.041c-.052.63-.209 1.339-.618 1.749zM8.424 7.99c-.892 0-1.615.723-1.615 1.615v1.616a1.615 1.615 0 1 0 3.23 0V9.604c0-.892-.723-1.615-1.615-1.615Zm7.154 0c-.893 0-1.616.723-1.616 1.615v1.616a1.615 1.615 0 1 0 3.231 0V9.604c0-.892-.723-1.615-1.615-1.615z',
|
||||
},
|
||||
],
|
||||
},
|
||||
vercel: {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: '#f8fafc',
|
||||
border: 'rgba(248, 250, 252, 0.5)',
|
||||
color: '#000000',
|
||||
paths: [{ d: VERCEL_PATH }],
|
||||
},
|
||||
'vercel-ai-gateway': {
|
||||
kind: 'svg',
|
||||
viewBox: '0 0 24 24',
|
||||
background: '#f8fafc',
|
||||
border: 'rgba(248, 250, 252, 0.5)',
|
||||
color: '#000000',
|
||||
paths: [{ d: VERCEL_PATH }],
|
||||
},
|
||||
};
|
||||
|
||||
const LETTER_BRANDS: Record<string, BrandIconDescriptor> = {
|
||||
'amazon-bedrock': {
|
||||
kind: 'letters',
|
||||
label: 'AWS',
|
||||
background: 'rgba(255, 153, 0, 0.14)',
|
||||
border: 'rgba(255, 153, 0, 0.42)',
|
||||
color: '#FF9900',
|
||||
},
|
||||
azure: {
|
||||
kind: 'letters',
|
||||
label: 'AZ',
|
||||
background: 'rgba(0, 120, 212, 0.14)',
|
||||
border: 'rgba(0, 120, 212, 0.42)',
|
||||
color: '#60a5fa',
|
||||
},
|
||||
cohere: {
|
||||
kind: 'letters',
|
||||
label: 'CO',
|
||||
background: 'rgba(57, 210, 192, 0.14)',
|
||||
border: 'rgba(57, 210, 192, 0.42)',
|
||||
color: '#39D2C0',
|
||||
},
|
||||
deepinfra: {
|
||||
kind: 'letters',
|
||||
label: 'DI',
|
||||
background: 'rgba(125, 92, 255, 0.14)',
|
||||
border: 'rgba(125, 92, 255, 0.42)',
|
||||
color: '#a78bfa',
|
||||
},
|
||||
deepseek: {
|
||||
kind: 'letters',
|
||||
label: 'DS',
|
||||
background: 'rgba(77, 132, 255, 0.14)',
|
||||
border: 'rgba(77, 132, 255, 0.42)',
|
||||
color: '#93c5fd',
|
||||
},
|
||||
'fireworks-ai': {
|
||||
kind: 'letters',
|
||||
label: 'FW',
|
||||
background: 'rgba(255, 112, 67, 0.14)',
|
||||
border: 'rgba(255, 112, 67, 0.42)',
|
||||
color: '#fb923c',
|
||||
},
|
||||
groq: {
|
||||
kind: 'letters',
|
||||
label: 'G',
|
||||
background: 'rgba(255, 93, 56, 0.14)',
|
||||
border: 'rgba(255, 93, 56, 0.42)',
|
||||
color: '#ff8a65',
|
||||
},
|
||||
'ollama-cloud': {
|
||||
kind: 'letters',
|
||||
label: 'OL',
|
||||
background: 'rgba(248, 250, 252, 0.12)',
|
||||
border: 'rgba(248, 250, 252, 0.36)',
|
||||
color: '#f8fafc',
|
||||
},
|
||||
togetherai: {
|
||||
kind: 'letters',
|
||||
label: 'TA',
|
||||
background: 'rgba(32, 201, 151, 0.14)',
|
||||
border: 'rgba(32, 201, 151, 0.42)',
|
||||
color: '#5eead4',
|
||||
},
|
||||
xai: {
|
||||
kind: 'letters',
|
||||
label: 'xAI',
|
||||
background: 'rgba(248, 250, 252, 0.12)',
|
||||
border: 'rgba(248, 250, 252, 0.36)',
|
||||
color: '#f8fafc',
|
||||
},
|
||||
};
|
||||
|
||||
const BRAND_ALIASES: Record<string, string> = {
|
||||
'amazon-bedrock': 'amazon-bedrock',
|
||||
'aws-bedrock': 'amazon-bedrock',
|
||||
'cloudflare-ai-gateway': 'cloudflare-ai-gateway',
|
||||
'cloudflare-workers-ai': 'cloudflare-workers-ai',
|
||||
'deep-infra': 'deepinfra',
|
||||
fireworks: 'fireworks-ai',
|
||||
'github-copilot': 'github-copilot',
|
||||
'github-models': 'github-models',
|
||||
'gitlab-duo': 'gitlab-duo',
|
||||
'google-vertex': 'google-vertex',
|
||||
'hugging-face': 'huggingface',
|
||||
'mistral-ai': 'mistral',
|
||||
'ollama-cloud': 'ollama-cloud',
|
||||
'opencode-zen': 'opencode',
|
||||
'perplexity-agent': 'perplexity',
|
||||
'together-ai': 'togetherai',
|
||||
'vercel-ai-gateway': 'vercel',
|
||||
vertex: 'google-vertex',
|
||||
};
|
||||
|
||||
function normalizeProviderKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(?:^-)|(?:-$)/g, '');
|
||||
}
|
||||
|
||||
function getBrandIconKey(provider: RuntimeProviderConnectionDto): string | null {
|
||||
const providerId = normalizeProviderKey(provider.providerId);
|
||||
const displayName = normalizeProviderKey(provider.displayName);
|
||||
const aliasedProviderId = BRAND_ALIASES[providerId] ?? providerId;
|
||||
const aliasedDisplayName = BRAND_ALIASES[displayName] ?? displayName;
|
||||
const direct = BRAND_ICONS[aliasedProviderId]
|
||||
? aliasedProviderId
|
||||
: LETTER_BRANDS[aliasedProviderId]
|
||||
? aliasedProviderId
|
||||
: BRAND_ICONS[aliasedDisplayName]
|
||||
? aliasedDisplayName
|
||||
: LETTER_BRANDS[aliasedDisplayName]
|
||||
? aliasedDisplayName
|
||||
: null;
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
for (const [needle, iconKey] of Object.entries(BRAND_ALIASES)) {
|
||||
if (displayName.includes(needle) || providerId.includes(needle)) {
|
||||
return iconKey;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function fallbackDescriptor(provider: RuntimeProviderConnectionDto): BrandIconDescriptor {
|
||||
const displayName = provider.displayName.trim();
|
||||
return {
|
||||
kind: 'letters',
|
||||
label: displayName.slice(0, 2).toUpperCase() || provider.providerId.slice(0, 2).toUpperCase(),
|
||||
background: 'rgba(148, 163, 184, 0.12)',
|
||||
border: 'rgba(148, 163, 184, 0.26)',
|
||||
color: '#cbd5e1',
|
||||
};
|
||||
}
|
||||
|
||||
function descriptorFor(provider: RuntimeProviderConnectionDto): BrandIconDescriptor {
|
||||
const key = getBrandIconKey(provider);
|
||||
return key
|
||||
? (BRAND_ICONS[key] ?? LETTER_BRANDS[key] ?? fallbackDescriptor(provider))
|
||||
: fallbackDescriptor(provider);
|
||||
}
|
||||
|
||||
function shellStyle(descriptor: BrandIconDescriptor): CSSProperties {
|
||||
return {
|
||||
backgroundColor: descriptor.background,
|
||||
borderColor: descriptor.border,
|
||||
color: descriptor.kind === 'image' ? undefined : descriptor.color,
|
||||
};
|
||||
}
|
||||
|
||||
export function ProviderBrandIcon({
|
||||
provider,
|
||||
}: {
|
||||
readonly provider: RuntimeProviderConnectionDto;
|
||||
}): JSX.Element {
|
||||
const descriptor = descriptorFor(provider);
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid={`runtime-provider-logo-${provider.providerId}`}
|
||||
aria-hidden="true"
|
||||
className="inline-flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-md border"
|
||||
style={shellStyle(descriptor)}
|
||||
>
|
||||
{descriptor.kind === 'image' ? (
|
||||
<img src={descriptor.src} alt="" className="size-5 object-contain" draggable={false} />
|
||||
) : null}
|
||||
{descriptor.kind === 'svg' ? (
|
||||
<svg viewBox={descriptor.viewBox} className="h-[18px] w-[18px]" focusable="false">
|
||||
{descriptor.paths.map((path) => (
|
||||
<path key={path.d} d={path.d} fill={path.fill ?? 'currentColor'} />
|
||||
))}
|
||||
</svg>
|
||||
) : null}
|
||||
{descriptor.kind === 'letters' ? (
|
||||
<span className="text-[10px] font-semibold leading-none">{descriptor.label}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -34,6 +34,11 @@ interface WindowsOptionalFeatureProbe {
|
|||
hasConfiguredWslFeature: boolean;
|
||||
}
|
||||
|
||||
interface WslDistroGroups {
|
||||
userDistros: string[];
|
||||
serviceDistros: string[];
|
||||
}
|
||||
|
||||
type ExecFileCallback = (
|
||||
error: Error | null,
|
||||
stdout: string | Buffer,
|
||||
|
|
@ -64,6 +69,13 @@ const POWERSHELL_FEATURE_QUERY = [
|
|||
'$features = Get-WindowsOptionalFeature -Online -FeatureName "Microsoft-Windows-Subsystem-Linux","VirtualMachinePlatform"',
|
||||
'$features | Select-Object FeatureName, State, RestartRequired | ConvertTo-Json -Compress',
|
||||
].join('; ');
|
||||
const SERVICE_WSL_DISTRO_EXACT_NAMES = new Set([
|
||||
'docker-desktop',
|
||||
'docker-desktop-data',
|
||||
'rancher-desktop',
|
||||
'rancher-desktop-data',
|
||||
]);
|
||||
const SERVICE_WSL_DISTRO_PREFIXES = ['podman-machine-'];
|
||||
|
||||
export class TmuxWslService {
|
||||
readonly #execFile: ExecFileLike;
|
||||
|
|
@ -139,10 +151,35 @@ export class TmuxWslService {
|
|||
};
|
||||
}
|
||||
|
||||
const distroGroups = this.#groupWslDistros(distros);
|
||||
if (distroGroups.userDistros.length === 0) {
|
||||
if (persistedPreferredDistro) {
|
||||
await this.#preferenceStore.clearPreferredDistro();
|
||||
}
|
||||
return {
|
||||
preference: null,
|
||||
status: {
|
||||
wslInstalled: true,
|
||||
rebootRequired,
|
||||
distroName: null,
|
||||
distroVersion: null,
|
||||
distroBootstrapped: false,
|
||||
innerPackageManager: null,
|
||||
tmuxAvailableInsideWsl: false,
|
||||
tmuxVersion: null,
|
||||
tmuxBinaryPath: null,
|
||||
statusDetail: this.#buildNoUserDistroDetail({
|
||||
rebootRequired,
|
||||
serviceDistros: distroGroups.serviceDistros,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const verboseProbe = await this.#run(['--list', '--verbose'], 4_000);
|
||||
const verboseEntries = this.#parseVerboseDistroEntries(verboseProbe.stdout, distros);
|
||||
const preferredDistro = this.#resolvePreferredDistro({
|
||||
distros,
|
||||
userDistros: distroGroups.userDistros,
|
||||
verboseEntries,
|
||||
persistedPreferredDistro,
|
||||
});
|
||||
|
|
@ -158,7 +195,7 @@ export class TmuxWslService {
|
|||
return {
|
||||
preference: {
|
||||
preferredDistroName: null,
|
||||
source: usingPersistedPreference ? 'persisted' : null,
|
||||
source: null,
|
||||
},
|
||||
status: {
|
||||
wslInstalled: true,
|
||||
|
|
@ -171,18 +208,19 @@ export class TmuxWslService {
|
|||
tmuxVersion: null,
|
||||
tmuxBinaryPath: null,
|
||||
statusDetail:
|
||||
distros.length > 1
|
||||
? 'WSL has multiple Linux distributions, but no default or saved distro target is configured yet.'
|
||||
distroGroups.userDistros.length > 1
|
||||
? 'WSL has multiple user Linux distributions, but no default or saved distro target is configured yet.'
|
||||
: 'WSL is available, but the app could not determine which Linux distribution to target.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const preferredEntry = verboseEntries.find((entry) => entry.name === preferredDistro);
|
||||
const preference: TmuxWslPreference = {
|
||||
preferredDistroName: preferredDistro,
|
||||
source: usingPersistedPreference
|
||||
? 'persisted'
|
||||
: verboseEntries.some((entry) => entry.isDefault)
|
||||
: preferredEntry?.isDefault
|
||||
? 'default'
|
||||
: 'manual',
|
||||
};
|
||||
|
|
@ -452,25 +490,93 @@ export class TmuxWslService {
|
|||
return entries;
|
||||
}
|
||||
|
||||
#groupWslDistros(distros: string[]): WslDistroGroups {
|
||||
const userDistros: string[] = [];
|
||||
const serviceDistros: string[] = [];
|
||||
|
||||
for (const distro of distros) {
|
||||
if (this.#isServiceWslDistro(distro)) {
|
||||
serviceDistros.push(distro);
|
||||
} else {
|
||||
userDistros.push(distro);
|
||||
}
|
||||
}
|
||||
|
||||
return { userDistros, serviceDistros };
|
||||
}
|
||||
|
||||
#isServiceWslDistro(distro: string): boolean {
|
||||
const normalized = distro.trim().toLowerCase();
|
||||
return (
|
||||
SERVICE_WSL_DISTRO_EXACT_NAMES.has(normalized) ||
|
||||
SERVICE_WSL_DISTRO_PREFIXES.some((prefix) => normalized.startsWith(prefix))
|
||||
);
|
||||
}
|
||||
|
||||
#resolvePreferredDistro(input: {
|
||||
distros: string[];
|
||||
userDistros: string[];
|
||||
verboseEntries: WslVerboseDistroEntry[];
|
||||
persistedPreferredDistro: string | null;
|
||||
}): string | null {
|
||||
if (input.persistedPreferredDistro && input.distros.includes(input.persistedPreferredDistro)) {
|
||||
if (
|
||||
input.persistedPreferredDistro &&
|
||||
input.userDistros.includes(input.persistedPreferredDistro)
|
||||
) {
|
||||
return input.persistedPreferredDistro;
|
||||
}
|
||||
|
||||
const defaultDistro = input.verboseEntries.find((entry) => entry.isDefault)?.name ?? null;
|
||||
const defaultDistro =
|
||||
input.verboseEntries.find(
|
||||
(entry) => entry.isDefault && input.userDistros.includes(entry.name)
|
||||
)?.name ?? null;
|
||||
if (defaultDistro) {
|
||||
return defaultDistro;
|
||||
}
|
||||
|
||||
if (input.distros.length === 1) {
|
||||
return input.distros[0] ?? null;
|
||||
if (input.userDistros.length === 1) {
|
||||
return input.userDistros[0] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
return this.#findRecommendedUserDistro(input.userDistros);
|
||||
}
|
||||
|
||||
#findRecommendedUserDistro(userDistros: string[]): string | null {
|
||||
const exactPriority = ['ubuntu', 'ubuntu-24.04', 'ubuntu-22.04', 'debian'];
|
||||
for (const preferredName of exactPriority) {
|
||||
const matched = userDistros.find((distro) => distro.toLowerCase() === preferredName);
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
|
||||
return userDistros.find((distro) => this.#looksLikeVersionedUbuntuDistro(distro)) ?? null;
|
||||
}
|
||||
|
||||
#looksLikeVersionedUbuntuDistro(distro: string): boolean {
|
||||
const normalized = distro.toLowerCase();
|
||||
if (!normalized.startsWith('ubuntu-')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const versionSuffix = normalized.slice('ubuntu-'.length);
|
||||
return (
|
||||
versionSuffix.length > 0 &&
|
||||
[...versionSuffix].every((character) => {
|
||||
return (character >= '0' && character <= '9') || character === '.';
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#buildNoUserDistroDetail(input: { rebootRequired: boolean; serviceDistros: string[] }): string {
|
||||
if (input.rebootRequired) {
|
||||
return 'WSL was installed, but Windows still needs a restart before a Linux distro can be configured.';
|
||||
}
|
||||
|
||||
if (input.serviceDistros.length > 0) {
|
||||
return `WSL is available, but only service distributions are installed (${input.serviceDistros.join(', ')}). Install Ubuntu or another user Linux distro before setting up tmux.`;
|
||||
}
|
||||
|
||||
return 'WSL is available, but no Linux distribution is installed yet.';
|
||||
}
|
||||
|
||||
#looksLikeRestartRequired(output: string): boolean {
|
||||
|
|
|
|||
|
|
@ -108,6 +108,90 @@ describe('TmuxWslService', () => {
|
|||
expect(result.status.tmuxBinaryPath).toBe('/usr/bin/tmux');
|
||||
});
|
||||
|
||||
it('does not target Docker Desktop when it is the only WSL distribution', async () => {
|
||||
const service = new TmuxWslService(
|
||||
createExecFileMock({
|
||||
'--status': { stdout: 'Default Distribution: docker-desktop\nDefault Version: 2\n' },
|
||||
'--list --quiet': { stdout: 'docker-desktop\n' },
|
||||
}),
|
||||
createPreferenceStore() as never
|
||||
);
|
||||
|
||||
const result = await service.probe();
|
||||
|
||||
expect(result.preference).toBeNull();
|
||||
expect(result.status.wslInstalled).toBe(true);
|
||||
expect(result.status.distroName).toBeNull();
|
||||
expect(result.status.distroBootstrapped).toBe(false);
|
||||
expect(result.status.innerPackageManager).toBeNull();
|
||||
expect(result.status.tmuxAvailableInsideWsl).toBe(false);
|
||||
expect(result.status.statusDetail).toContain('only service distributions');
|
||||
expect(result.status.statusDetail).toContain('docker-desktop');
|
||||
});
|
||||
|
||||
it('ignores a service default distro and targets the only user distro', async () => {
|
||||
const service = new TmuxWslService(
|
||||
createExecFileMock({
|
||||
'--status': { stdout: 'Default Distribution: docker-desktop\nDefault Version: 2\n' },
|
||||
'--list --quiet': { stdout: 'docker-desktop\nUbuntu\n' },
|
||||
'--list --verbose': {
|
||||
stdout: '* docker-desktop Running 2\n Ubuntu Stopped 2\n',
|
||||
},
|
||||
'-d Ubuntu -- sh -lc printf ready': { stdout: 'ready' },
|
||||
'-d Ubuntu -- sh -lc . /etc/os-release >/dev/null 2>&1 && printf %s "$ID"': {
|
||||
stdout: 'ubuntu',
|
||||
},
|
||||
'-d Ubuntu -- sh -lc command -v tmux >/dev/null 2>&1 && { tmux -V; printf "\\n"; command -v tmux; }':
|
||||
{
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
error: Object.assign(new Error('tmux missing'), { code: 'EFAIL' }),
|
||||
},
|
||||
}),
|
||||
createPreferenceStore() as never
|
||||
);
|
||||
|
||||
const result = await service.probe();
|
||||
|
||||
expect(result.preference?.preferredDistroName).toBe('Ubuntu');
|
||||
expect(result.preference?.source).toBe('manual');
|
||||
expect(result.status.distroName).toBe('Ubuntu');
|
||||
expect(result.status.innerPackageManager).toBe('apt');
|
||||
expect(result.status.tmuxAvailableInsideWsl).toBe(false);
|
||||
expect(result.status.statusDetail).toBe(
|
||||
'tmux is not installed inside the Ubuntu WSL distro yet.'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a recommended Ubuntu target when a service distro is default among multiple user distros', async () => {
|
||||
const service = new TmuxWslService(
|
||||
createExecFileMock({
|
||||
'--status': { stdout: 'Default Distribution: docker-desktop\nDefault Version: 2\n' },
|
||||
'--list --quiet': { stdout: 'docker-desktop\nDebian\nUbuntu-24.04\nFedora\n' },
|
||||
'--list --verbose': {
|
||||
stdout:
|
||||
'* docker-desktop Running 2\n Debian Stopped 2\n Ubuntu-24.04 Stopped 2\n Fedora Stopped 2\n',
|
||||
},
|
||||
'-d Ubuntu-24.04 -- sh -lc printf ready': { stdout: 'ready' },
|
||||
'-d Ubuntu-24.04 -- sh -lc . /etc/os-release >/dev/null 2>&1 && printf %s "$ID"': {
|
||||
stdout: 'ubuntu',
|
||||
},
|
||||
'-d Ubuntu-24.04 -- sh -lc command -v tmux >/dev/null 2>&1 && { tmux -V; printf "\\n"; command -v tmux; }':
|
||||
{
|
||||
stdout: 'tmux 3.4\n/usr/bin/tmux\n',
|
||||
},
|
||||
}),
|
||||
createPreferenceStore() as never
|
||||
);
|
||||
|
||||
const result = await service.probe();
|
||||
|
||||
expect(result.preference?.preferredDistroName).toBe('Ubuntu-24.04');
|
||||
expect(result.preference?.source).toBe('manual');
|
||||
expect(result.status.distroName).toBe('Ubuntu-24.04');
|
||||
expect(result.status.tmuxAvailableInsideWsl).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers the persisted distro over the default WSL marker', async () => {
|
||||
const service = new TmuxWslService(
|
||||
createExecFileMock({
|
||||
|
|
@ -133,6 +217,35 @@ describe('TmuxWslService', () => {
|
|||
expect(result.status.distroName).toBe('Ubuntu');
|
||||
});
|
||||
|
||||
it('prefers the persisted user distro over a service default distro', async () => {
|
||||
const service = new TmuxWslService(
|
||||
createExecFileMock({
|
||||
'--status': { stdout: 'Default Distribution: docker-desktop\nDefault Version: 2\n' },
|
||||
'--list --quiet': { stdout: 'docker-desktop\nUbuntu\nDebian\n' },
|
||||
'--list --verbose': {
|
||||
stdout:
|
||||
'* docker-desktop Running 2\n Ubuntu Stopped 2\n Debian Stopped 2\n',
|
||||
},
|
||||
'-d Debian -- sh -lc printf ready': { stdout: 'ready' },
|
||||
'-d Debian -- sh -lc . /etc/os-release >/dev/null 2>&1 && printf %s "$ID"': {
|
||||
stdout: 'debian',
|
||||
},
|
||||
'-d Debian -- sh -lc command -v tmux >/dev/null 2>&1 && { tmux -V; printf "\\n"; command -v tmux; }':
|
||||
{
|
||||
stdout: 'tmux 3.4\n/usr/bin/tmux\n',
|
||||
},
|
||||
}),
|
||||
createPreferenceStore('Debian') as never
|
||||
);
|
||||
|
||||
const result = await service.probe();
|
||||
|
||||
expect(result.preference?.preferredDistroName).toBe('Debian');
|
||||
expect(result.preference?.source).toBe('persisted');
|
||||
expect(result.status.distroName).toBe('Debian');
|
||||
expect(result.status.tmuxAvailableInsideWsl).toBe(true);
|
||||
});
|
||||
|
||||
it('clears a stale preferred distro when WSL has no installed distributions', async () => {
|
||||
const preferenceStore = createPreferenceStore('Ubuntu');
|
||||
const service = new TmuxWslService(
|
||||
|
|
@ -205,7 +318,7 @@ describe('TmuxWslService', () => {
|
|||
encoding: 'buffer';
|
||||
},
|
||||
callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void
|
||||
) => {
|
||||
): void => {
|
||||
if (command === 'powershell.exe') {
|
||||
callback(
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
expect(result.manualHintsCollapsible).toBe(false);
|
||||
expect(result.body).toContain('persistent teammate reliability');
|
||||
expect(result.benefitsBody).toContain('Optional, but recommended');
|
||||
expect(result.benefitsBody).toContain('multi-agent teams that mix providers');
|
||||
expect(result.installButtonPrimary).toBe(true);
|
||||
expect(result.showRefreshButton).toBe(true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -77,10 +77,11 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
|
|||
const manualHintsVisible =
|
||||
viewModel.manualHints.length > 0 && (!viewModel.manualHintsCollapsible || manualHintsExpanded);
|
||||
const primaryGuideUrl = viewModel.primaryGuideUrl;
|
||||
const bannerPaddingClass = expanded ? `py-3 ${BANNER_MIN_H}` : 'py-2.5';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mb-6 rounded-lg border-l-4 px-3 ${expanded ? `py-3 ${BANNER_MIN_H}` : 'py-2.5'}`}
|
||||
className={`mb-6 rounded-lg border-l-4 px-3 ${bannerPaddingClass}`}
|
||||
style={{
|
||||
borderLeftColor: viewModel.error ? '#ef4444' : '#f59e0b',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.08)',
|
||||
|
|
@ -93,19 +94,29 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
|
|||
onClick={() => setExpanded((current) => !current)}
|
||||
className="group flex w-full items-center justify-between gap-3 rounded-md px-1 py-0.5 text-left transition-colors hover:bg-white/[0.03]"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="inline-flex shrink-0 items-center justify-center">
|
||||
<span className="flex min-w-0 flex-1 items-start gap-2.5">
|
||||
<span className="inline-flex shrink-0 items-center justify-center pt-[3px]">
|
||||
{viewModel.error ? (
|
||||
<AlertTriangle className="size-3.5 text-red-300" />
|
||||
) : (
|
||||
<Wrench className="size-3.5 text-amber-300" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="truncate text-xs font-medium leading-5"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
{SUMMARY_TITLE}
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
className="block truncate text-xs font-medium leading-5"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
{SUMMARY_TITLE}
|
||||
</span>
|
||||
{!expanded && viewModel.benefitsBody && (
|
||||
<span
|
||||
className="mt-0.5 block max-w-4xl text-[11px] leading-4"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{viewModel.benefitsBody}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const baseViewModel: TmuxInstallerBannerViewModel = {
|
|||
title: 'tmux is not installed',
|
||||
body: 'WSL is available, but no Linux distribution is installed yet.',
|
||||
benefitsBody:
|
||||
'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable.',
|
||||
'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable. Without tmux, creating multi-agent teams that mix providers may be blocked.',
|
||||
error: null,
|
||||
platformLabel: 'Windows',
|
||||
locationLabel: null,
|
||||
|
|
@ -94,6 +94,8 @@ describe('TmuxInstallerBannerView', () => {
|
|||
const { host, root } = renderBanner(baseViewModel);
|
||||
|
||||
expect(host.textContent).toContain('tmux is not installed');
|
||||
expect(host.textContent).toContain('Optional, but recommended');
|
||||
expect(host.textContent).toContain('multi-agent teams that mix providers');
|
||||
expect(host.textContent).not.toContain(
|
||||
'WSL is available, but no Linux distribution is installed yet.'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -67,9 +67,12 @@ export function formatTmuxOptionalBenefits(platform: TmuxPlatform | null): strin
|
|||
return null;
|
||||
}
|
||||
|
||||
const mixedProviderLimit =
|
||||
'Without tmux, creating multi-agent teams that mix providers may be blocked.';
|
||||
|
||||
if (platform === 'win32') {
|
||||
return 'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better.';
|
||||
return `Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better. ${mixedProviderLimit}`;
|
||||
}
|
||||
|
||||
return 'Optional, but recommended. The app works without tmux. With tmux, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better.';
|
||||
return `Optional, but recommended. The app works without tmux. With tmux, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better. ${mixedProviderLimit}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5951,6 +5951,16 @@ export class TeamProvisioningService {
|
|||
if (trackedRun && this.shouldRouteOpenCodeToRuntimeAdapter(trackedRun.request)) {
|
||||
return trackedRunId;
|
||||
}
|
||||
if (
|
||||
trackedRunId &&
|
||||
this.provisioningRunByTeam.get(teamName) === trackedRunId &&
|
||||
this.runtimeAdapterProgressByRunId.has(trackedRunId)
|
||||
) {
|
||||
const runtimeProgress = this.runtimeAdapterProgressByRunId.get(trackedRunId);
|
||||
if (runtimeProgress && this.isCancellableRuntimeAdapterProgress(runtimeProgress)) {
|
||||
return trackedRunId;
|
||||
}
|
||||
}
|
||||
const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName);
|
||||
if (runtimeRun?.providerId === 'opencode') {
|
||||
return runtimeRun.runId;
|
||||
|
|
@ -13447,6 +13457,18 @@ export class TeamProvisioningService {
|
|||
message.from.trim().toLowerCase() !== memberName.trim().toLowerCase()
|
||||
? message.from.trim()
|
||||
: 'user';
|
||||
const effectiveReplyRecipient =
|
||||
existingRecord?.replyRecipient ??
|
||||
options.deliveryMetadata?.replyRecipient ??
|
||||
fallbackReplyRecipient;
|
||||
const effectiveActionMode =
|
||||
existingRecord?.actionMode ??
|
||||
options.deliveryMetadata?.actionMode ??
|
||||
message.actionMode ??
|
||||
null;
|
||||
const effectiveTaskRefs =
|
||||
existingRecord?.taskRefs ?? options.deliveryMetadata?.taskRefs ?? message.taskRefs ?? [];
|
||||
const effectiveSource = existingRecord?.source ?? options.source ?? 'watcher';
|
||||
result.attempted += 1;
|
||||
if (message.attachments?.length) {
|
||||
const reason = 'opencode_attachments_not_supported_for_secondary_runtime';
|
||||
|
|
@ -13458,17 +13480,17 @@ export class TeamProvisioningService {
|
|||
runId: await this.resolveCurrentOpenCodeRuntimeRunId(teamName, memberIdentity.laneId),
|
||||
inboxMessageId: message.messageId,
|
||||
inboxTimestamp: message.timestamp,
|
||||
source: options.source ?? 'watcher',
|
||||
replyRecipient: options.deliveryMetadata?.replyRecipient ?? fallbackReplyRecipient,
|
||||
actionMode: options.deliveryMetadata?.actionMode ?? message.actionMode ?? null,
|
||||
taskRefs: options.deliveryMetadata?.taskRefs ?? message.taskRefs ?? [],
|
||||
source: effectiveSource,
|
||||
replyRecipient: effectiveReplyRecipient,
|
||||
actionMode: effectiveActionMode,
|
||||
taskRefs: effectiveTaskRefs,
|
||||
payloadHash: hashOpenCodePromptDeliveryPayload({
|
||||
text: message.text,
|
||||
replyRecipient: options.deliveryMetadata?.replyRecipient ?? fallbackReplyRecipient,
|
||||
actionMode: options.deliveryMetadata?.actionMode ?? message.actionMode ?? null,
|
||||
taskRefs: options.deliveryMetadata?.taskRefs ?? message.taskRefs ?? [],
|
||||
replyRecipient: effectiveReplyRecipient,
|
||||
actionMode: effectiveActionMode,
|
||||
taskRefs: effectiveTaskRefs,
|
||||
attachments: message.attachments,
|
||||
source: options.source ?? 'watcher',
|
||||
source: effectiveSource,
|
||||
}),
|
||||
now,
|
||||
});
|
||||
|
|
@ -13499,10 +13521,10 @@ export class TeamProvisioningService {
|
|||
memberName,
|
||||
text: message.text,
|
||||
messageId: message.messageId,
|
||||
replyRecipient: options.deliveryMetadata?.replyRecipient ?? fallbackReplyRecipient,
|
||||
actionMode: options.deliveryMetadata?.actionMode ?? message.actionMode,
|
||||
taskRefs: options.deliveryMetadata?.taskRefs ?? message.taskRefs,
|
||||
source: options.source ?? 'watcher',
|
||||
replyRecipient: effectiveReplyRecipient,
|
||||
actionMode: effectiveActionMode ?? undefined,
|
||||
taskRefs: effectiveTaskRefs,
|
||||
source: effectiveSource,
|
||||
inboxTimestamp: message.timestamp,
|
||||
});
|
||||
result.lastDelivery = delivery;
|
||||
|
|
|
|||
|
|
@ -647,12 +647,16 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
|||
'Do not answer only with plain assistant text when agent-teams_message_send is available.',
|
||||
'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.',
|
||||
'Do not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.',
|
||||
'The inbound app message follows. Treat it as the actual instruction to process now, not as background context.',
|
||||
'If the inbound message asks for exact reply text, use that exact text. Do not replace concrete instructions with a generic greeting or availability message.',
|
||||
input.actionMode ? `Action mode for this message: ${input.actionMode}.` : null,
|
||||
taskRefs ? `If your reply is about these tasks, include taskRefs exactly: ${taskRefs}` : null,
|
||||
input.messageId ? `The inbound app messageId is "${input.messageId}".` : null,
|
||||
'</opencode_app_message_delivery>',
|
||||
'',
|
||||
'<opencode_inbound_app_message>',
|
||||
input.text,
|
||||
'</opencode_inbound_app_message>',
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join('\n');
|
||||
|
|
|
|||
|
|
@ -28,11 +28,15 @@ function execFileAsync(
|
|||
child = execFile(cmd, args, options, (err, stdout, stderr) => {
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (err)
|
||||
reject(
|
||||
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error')
|
||||
);
|
||||
else resolve({ stdout: String(stdout), stderr: String(stderr) });
|
||||
if (err) {
|
||||
const normalizedError =
|
||||
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error');
|
||||
Object.assign(normalizedError, {
|
||||
stdout: String(stdout),
|
||||
stderr: String(stderr),
|
||||
});
|
||||
reject(normalizedError);
|
||||
} else resolve({ stdout: String(stdout), stderr: String(stderr) });
|
||||
});
|
||||
if (!settled) {
|
||||
trackCliProcess(child);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ export function buildMergedCliPath(binaryPath?: string | null): string {
|
|||
if (cachedEnv?.PATH) {
|
||||
extraDirs.push(...cachedEnv.PATH.split(sep).filter(Boolean));
|
||||
extraDirs.push(vendorBinDir);
|
||||
if (process.platform !== 'win32') {
|
||||
extraDirs.push(pathPosix.join(home, '.bun', 'bin'));
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
extraDirs.push(
|
||||
vendorBinDir,
|
||||
|
|
@ -53,6 +56,7 @@ export function buildMergedCliPath(binaryPath?: string | null): string {
|
|||
} else {
|
||||
extraDirs.push(
|
||||
vendorBinDir,
|
||||
pathPosix.join(home, '.bun', 'bin'),
|
||||
pathPosix.join(home, '.local', 'bin'),
|
||||
pathPosix.join(home, '.npm-global', 'bin'),
|
||||
pathPosix.join(home, '.npm', 'bin'),
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ const VARIANT_STYLES: Record<BannerVariant, { border: string; bg: string }> = {
|
|||
warning: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.06)' },
|
||||
};
|
||||
|
||||
const OPENCODE_DOWNLOAD_URL = 'https://opencode.ai/download';
|
||||
|
||||
/** Minimum banner height — prevents layout shift between states (loading → installed → checking). */
|
||||
const BANNER_MIN_H = 'min-h-[4.25rem]';
|
||||
|
||||
|
|
@ -560,6 +562,19 @@ function hasVisibleAuthenticatedMultimodelProvider(
|
|||
return visibleProviders.some((provider) => provider.authenticated);
|
||||
}
|
||||
|
||||
function shouldShowOpenCodeDownloadAction(
|
||||
provider: CliProviderStatus,
|
||||
showSkeleton: boolean
|
||||
): boolean {
|
||||
return (
|
||||
provider.providerId === 'opencode' &&
|
||||
!showSkeleton &&
|
||||
!provider.supported &&
|
||||
!provider.authenticated &&
|
||||
provider.backend == null
|
||||
);
|
||||
}
|
||||
|
||||
const InstalledBanner = ({
|
||||
cliStatus,
|
||||
sourceProviderMap,
|
||||
|
|
@ -901,6 +916,21 @@ const InstalledBanner = ({
|
|||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start gap-2">
|
||||
{shouldShowOpenCodeDownloadAction(provider, showSkeleton) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void api.openExternal(OPENCODE_DOWNLOAD_URL)}
|
||||
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: 'rgba(14, 165, 233, 0.36)',
|
||||
color: '#7dd3fc',
|
||||
}}
|
||||
title="Download OpenCode CLI"
|
||||
>
|
||||
<Download className="size-3" />
|
||||
Download
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => onProviderManage(provider.providerId)}
|
||||
disabled={actionDisabled}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ interface TeamNode {
|
|||
robots: RobotNode[];
|
||||
}
|
||||
|
||||
interface MessageFlightState {
|
||||
progress: number;
|
||||
motionSpeed: number;
|
||||
bubbleScale: number;
|
||||
bubbleAlpha: number;
|
||||
}
|
||||
|
||||
interface DepthParticle {
|
||||
x: number;
|
||||
y: number;
|
||||
|
|
@ -52,7 +59,6 @@ interface Palette {
|
|||
isLight: boolean;
|
||||
centerGlow: string;
|
||||
teamColors: string[];
|
||||
teamLineAlpha: number;
|
||||
robotBody: string;
|
||||
robotShade: string;
|
||||
robotEye: string;
|
||||
|
|
@ -63,6 +69,7 @@ interface Palette {
|
|||
const TAU = Math.PI * 2;
|
||||
const TEAM_MEMBER_COUNTS = [4, 3, 5] as const;
|
||||
const TEAM_MEMBER_OFFSETS = [0, 4, 7] as const;
|
||||
const TEAM_LABELS = ['Marketing', 'Researchers', 'Coding'] as const;
|
||||
const MAX_DPR = 2;
|
||||
const avatarCache = new Map<string, HTMLImageElement>();
|
||||
const avatarLoading = new Map<string, Promise<HTMLImageElement | null>>();
|
||||
|
|
@ -190,15 +197,15 @@ function drawScene(
|
|||
|
||||
drawMessages(ctx, teams, sceneTime, palette, mobile);
|
||||
|
||||
for (const team of teams) {
|
||||
drawTeamLinks(ctx, team, palette);
|
||||
}
|
||||
|
||||
for (const team of teams) {
|
||||
for (const robot of team.robots) {
|
||||
drawRobot(ctx, robot, sceneTime, palette);
|
||||
}
|
||||
}
|
||||
|
||||
for (const team of teams) {
|
||||
drawTeamLabel(ctx, team, palette, mobile);
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePalette(): Palette {
|
||||
|
|
@ -208,7 +215,6 @@ function resolvePalette(): Palette {
|
|||
isLight,
|
||||
centerGlow: '#4f46e5',
|
||||
teamColors: ['#0369a1', '#047857', '#b45309'],
|
||||
teamLineAlpha: 0.26,
|
||||
robotBody: '#eef2ff',
|
||||
robotShade: '#dbe4ff',
|
||||
robotEye: '#ffffff',
|
||||
|
|
@ -219,7 +225,6 @@ function resolvePalette(): Palette {
|
|||
isLight,
|
||||
centerGlow: '#7c83f7',
|
||||
teamColors: ['#24a8d8', '#23b488', '#d58a19'],
|
||||
teamLineAlpha: 0.28,
|
||||
robotBody: '#0f1724',
|
||||
robotShade: '#1a2438',
|
||||
robotEye: '#d8f3ff',
|
||||
|
|
@ -412,22 +417,6 @@ function drawTeamHalo(
|
|||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
function drawTeamLinks(ctx: CanvasRenderingContext2D, team: TeamNode, palette: Palette): void {
|
||||
const pairs = getTeamConnectionPairs(team.robots.length);
|
||||
|
||||
for (const [fromIndex, toIndex] of pairs) {
|
||||
const from = team.robots[fromIndex];
|
||||
const to = team.robots[toIndex];
|
||||
if (!from || !to) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.strokeStyle = withAlpha(team.color, palette.teamLineAlpha);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawMessages(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
teams: TeamNode[],
|
||||
|
|
@ -459,10 +448,10 @@ function drawLocalMessages(
|
|||
if (!from || !to) continue;
|
||||
const raw = positiveModulo(time + team.index * 0.7 + pairIndex * 0.36, period) / period;
|
||||
applyReceivePulse(to, getReceivePulse(raw, activeWindow));
|
||||
if (raw > activeWindow) continue;
|
||||
const progress = easeInOutCubic(raw / activeWindow);
|
||||
const flightState = getMessageFlightState(raw, activeWindow, 0.12);
|
||||
if (!flightState) continue;
|
||||
const curve = makeLocalCurve(from, to, team.center, team.radius * 0.42);
|
||||
drawMessageFlight(ctx, curve, progress, team.color, time, mobile ? 4.6 : 5.8, palette);
|
||||
drawMessageFlight(ctx, curve, flightState, team.color, mobile ? 4.6 : 5.8, palette);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -491,15 +480,14 @@ function drawCrossTeamMessages(
|
|||
const to = toTeam.robots[route.toRobot % toTeam.robots.length];
|
||||
if (!from || !to) continue;
|
||||
applyReceivePulse(to, getReceivePulse(raw, activeWindow) * 0.88);
|
||||
if (raw > activeWindow) continue;
|
||||
const progress = easeInOutCubic(raw / activeWindow);
|
||||
const curve = makeStraightCurve(fromTeam.center, toTeam.center);
|
||||
const flightState = getMessageFlightState(raw, activeWindow, 0.1);
|
||||
if (!flightState) continue;
|
||||
const curve = makeStraightCurve(from, to);
|
||||
drawMessageFlight(
|
||||
ctx,
|
||||
curve,
|
||||
progress,
|
||||
flightState,
|
||||
route.accent ? palette.messageAccent : fromTeam.color,
|
||||
time,
|
||||
mobile ? 5.2 : 6.8,
|
||||
palette,
|
||||
true
|
||||
|
|
@ -510,58 +498,144 @@ function drawCrossTeamMessages(
|
|||
function drawMessageFlight(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
curve: [Point, Point, Point, Point],
|
||||
progress: number,
|
||||
state: MessageFlightState,
|
||||
color: string,
|
||||
time: number,
|
||||
size: number,
|
||||
palette: Palette,
|
||||
crossTeam = false
|
||||
): void {
|
||||
const [p0, p1, p2, p3] = curve;
|
||||
ctx.save();
|
||||
if (!crossTeam) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p0.x, p0.y);
|
||||
ctx.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
|
||||
ctx.strokeStyle = withAlpha(color, 0.12);
|
||||
ctx.lineWidth = 0.85;
|
||||
ctx.setLineDash([4, 8]);
|
||||
ctx.lineDashOffset = -time * 34;
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
for (let i = 7; i >= 1; i--) {
|
||||
const t = progress - i * 0.036;
|
||||
if (t <= 0) continue;
|
||||
const point = cubicPoint(p0, p1, p2, p3, t);
|
||||
const alpha = (1 - i / 8) * (palette.isLight ? 0.14 : 0.2);
|
||||
ctx.fillStyle = withAlpha(color, alpha);
|
||||
ctx.beginPath();
|
||||
ctx.arc(point.x, point.y, size * (0.18 + i * 0.025), 0, TAU);
|
||||
ctx.fill();
|
||||
const progress = state.progress;
|
||||
const speed = clamp(state.motionSpeed, 0, 1);
|
||||
if (speed > 0.045) {
|
||||
drawSpeedTrail(ctx, curve, progress, speed, color, size, palette, crossTeam);
|
||||
}
|
||||
|
||||
const position = cubicPoint(p0, p1, p2, p3, progress);
|
||||
const tangent = cubicTangent(p0, p1, p2, p3, progress);
|
||||
const angle = Math.atan2(tangent.y, tangent.x);
|
||||
drawMessageBubble(ctx, position, angle, size, color, palette, crossTeam);
|
||||
drawMessageBubble(
|
||||
ctx,
|
||||
position,
|
||||
angle,
|
||||
size,
|
||||
color,
|
||||
palette,
|
||||
crossTeam,
|
||||
state.bubbleScale,
|
||||
state.bubbleAlpha
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawSpeedTrail(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
curve: [Point, Point, Point, Point],
|
||||
progress: number,
|
||||
speed: number,
|
||||
color: string,
|
||||
size: number,
|
||||
palette: Palette,
|
||||
crossTeam: boolean
|
||||
): void {
|
||||
const [p0, p1, p2, p3] = curve;
|
||||
const trailLength = (crossTeam ? 0.26 : 0.21) * (0.24 + speed * 1.08);
|
||||
const segmentCount = Math.round(9 + speed * 10);
|
||||
const alphaBase = (palette.isLight ? 0.22 : 0.32) * speed;
|
||||
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.shadowColor = withAlpha(color, alphaBase * 0.58);
|
||||
ctx.shadowBlur = size * (0.78 + speed * 1.28);
|
||||
|
||||
for (let segment = 0; segment < segmentCount; segment++) {
|
||||
const startRatio = segment / segmentCount;
|
||||
const endRatio = (segment + 1) / segmentCount;
|
||||
const t0 = progress - trailLength * (1 - startRatio);
|
||||
const t1 = progress - trailLength * (1 - endRatio);
|
||||
if (t1 <= 0) continue;
|
||||
|
||||
const from = cubicPoint(p0, p1, p2, p3, Math.max(0, t0));
|
||||
const to = cubicPoint(p0, p1, p2, p3, Math.max(0, t1));
|
||||
const headWeight = endRatio * endRatio;
|
||||
const width = size * (0.12 + headWeight * 0.48) * (0.9 + speed * 0.45);
|
||||
const alpha = alphaBase * headWeight;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.strokeStyle = withAlpha(color, alpha * 0.34);
|
||||
ctx.lineWidth = width * 2.35;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.strokeStyle = withAlpha(color, alpha);
|
||||
ctx.lineWidth = width;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function getMessageFlightState(
|
||||
raw: number,
|
||||
activeWindow: number,
|
||||
settleWindow: number
|
||||
): MessageFlightState | null {
|
||||
if (raw > activeWindow + settleWindow) return null;
|
||||
|
||||
if (raw <= activeWindow) {
|
||||
const phase = raw / activeWindow;
|
||||
return {
|
||||
progress: easeInOutCubic(phase),
|
||||
motionSpeed: getEasedMotionSpeed(phase),
|
||||
bubbleScale: 1,
|
||||
bubbleAlpha: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const settlePhase = (raw - activeWindow) / settleWindow;
|
||||
const eased = easeOutCubic(settlePhase);
|
||||
return {
|
||||
progress: 1,
|
||||
motionSpeed: 0,
|
||||
bubbleScale: Math.max(0.12, 1 - eased * 0.88),
|
||||
bubbleAlpha: Math.max(0, 1 - eased),
|
||||
};
|
||||
}
|
||||
|
||||
function applyReceivePulse(robot: RobotNode, pulse: number): void {
|
||||
robot.receivePulse = Math.max(robot.receivePulse, pulse);
|
||||
}
|
||||
|
||||
function getReceivePulse(raw: number, activeWindow: number): number {
|
||||
const start = activeWindow * 0.78;
|
||||
const end = Math.min(0.96, activeWindow + 0.11);
|
||||
const previousStart = activeWindow * 0.78;
|
||||
const previousEnd = Math.min(0.96, activeWindow + 0.11);
|
||||
const duration = (previousEnd - previousStart) / 3;
|
||||
const start = activeWindow - duration * 0.62;
|
||||
const end = activeWindow + duration * 0.38;
|
||||
if (raw < start || raw > end) return 0;
|
||||
|
||||
const phase = (raw - start) / (end - start);
|
||||
return Math.sin(phase * Math.PI) * (1 - phase * 0.28);
|
||||
}
|
||||
|
||||
function getEasedMotionSpeed(value: number): number {
|
||||
const t = clamp(value, 0, 1);
|
||||
const derivative = t < 0.5 ? 12 * t * t : 12 * (1 - t) * (1 - t);
|
||||
return clamp(derivative / 3, 0, 1);
|
||||
}
|
||||
|
||||
function easeOutCubic(value: number): number {
|
||||
const t = clamp(value, 0, 1);
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
function drawMessageBubble(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
position: Point,
|
||||
|
|
@ -569,13 +643,19 @@ function drawMessageBubble(
|
|||
size: number,
|
||||
color: string,
|
||||
palette: Palette,
|
||||
crossTeam: boolean
|
||||
crossTeam: boolean,
|
||||
scale = 1,
|
||||
alpha = 1
|
||||
): void {
|
||||
if (scale <= 0.02 || alpha <= 0.01) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(position.x, position.y);
|
||||
ctx.rotate(angle * 0.08);
|
||||
ctx.shadowColor = withAlpha(color, palette.isLight ? 0.16 : 0.3);
|
||||
ctx.shadowBlur = crossTeam ? 12 : 8;
|
||||
ctx.scale(scale, scale);
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.shadowColor = withAlpha(color, (palette.isLight ? 0.16 : 0.3) * alpha);
|
||||
ctx.shadowBlur = (crossTeam ? 12 : 8) * (0.5 + scale * 0.5);
|
||||
|
||||
const width = size * (crossTeam ? 2.28 : 2.06);
|
||||
const height = size * 1.42;
|
||||
|
|
@ -600,6 +680,44 @@ function drawMessageBubble(
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawTeamLabel(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
team: TeamNode,
|
||||
palette: Palette,
|
||||
mobile: boolean
|
||||
): void {
|
||||
const label = TEAM_LABELS[team.index] ?? '';
|
||||
if (!label) return;
|
||||
|
||||
const fontSize = mobile ? 7.5 : 8.5;
|
||||
const y = team.center.y + team.radius * (mobile ? 1.65 : 1.58);
|
||||
ctx.save();
|
||||
ctx.font = `600 ${fontSize}px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const metrics = ctx.measureText(label);
|
||||
const paddingX = mobile ? 4 : 5;
|
||||
const paddingY = mobile ? 2 : 2.5;
|
||||
const width = metrics.width + paddingX * 2;
|
||||
const height = fontSize + paddingY * 2;
|
||||
const x = team.center.x - width / 2;
|
||||
const rectY = y - height / 2;
|
||||
|
||||
roundRectPath(ctx, x, rectY, width, height, height / 2);
|
||||
ctx.fillStyle = withAlpha(palette.isLight ? '#ffffff' : '#090a14', palette.isLight ? 0.36 : 0.24);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = withAlpha(team.color, palette.isLight ? 0.18 : 0.24);
|
||||
ctx.lineWidth = 0.75;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.shadowColor = withAlpha(team.color, palette.isLight ? 0.12 : 0.22);
|
||||
ctx.shadowBlur = mobile ? 4 : 6;
|
||||
ctx.fillStyle = withAlpha(palette.isLight ? '#3f3f46' : '#e4e4e7', palette.isLight ? 0.58 : 0.66);
|
||||
ctx.fillText(label, team.center.x, y + 0.2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawRobot(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
robot: RobotNode,
|
||||
|
|
@ -705,24 +823,6 @@ function drawAvatarFallback(
|
|||
ctx.fill();
|
||||
}
|
||||
|
||||
function getTeamConnectionPairs(memberCount: number): [number, number][] {
|
||||
if (memberCount <= 3) {
|
||||
return [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 0],
|
||||
];
|
||||
}
|
||||
|
||||
const pairs: [number, number][] = [];
|
||||
for (let index = 0; index < memberCount; index++) {
|
||||
pairs.push([index, (index + 1) % memberCount]);
|
||||
}
|
||||
if (memberCount >= 4) pairs.push([0, 2]);
|
||||
if (memberCount >= 5) pairs.push([1, 4]);
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function getLocalMessagePairs(teamIndex: number, memberCount: number): [number, number][] {
|
||||
const routeMap: [number, number][][] = [
|
||||
[
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useEffect, useMemo } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
import {
|
||||
|
|
@ -17,6 +18,11 @@ import {
|
|||
GEMINI_UI_DISABLED_REASON,
|
||||
isGeminiUiFrozen,
|
||||
} from '@renderer/utils/geminiUiFreeze';
|
||||
import {
|
||||
compareOpenCodeTeamModelRecommendations,
|
||||
getOpenCodeTeamModelRecommendation,
|
||||
isOpenCodeTeamModelRecommended,
|
||||
} from '@renderer/utils/openCodeModelRecommendations';
|
||||
import {
|
||||
getAvailableTeamProviderModelOptions,
|
||||
getTeamModelUiDisabledReason,
|
||||
|
|
@ -37,7 +43,7 @@ import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext
|
|||
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
|
||||
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
||||
import { isTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
import { AlertTriangle, Info, Star } from 'lucide-react';
|
||||
|
||||
import type { CliProviderStatus, TeamProviderId } from '@shared/types';
|
||||
|
||||
|
|
@ -161,6 +167,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
modelIssueReasonByValue,
|
||||
}) => {
|
||||
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
|
||||
const [recommendedOnly, setRecommendedOnly] = useState(false);
|
||||
|
||||
const effectiveProviderId =
|
||||
disableGeminiOption && isGeminiUiFrozen() && providerId === 'gemini' ? 'anthropic' : providerId;
|
||||
|
|
@ -301,6 +308,47 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
}
|
||||
return getAvailableTeamProviderModelOptions(effectiveProviderId, runtimeProviderStatus);
|
||||
}, [effectiveProviderId, runtimeProviderStatus, shouldAwaitRuntimeModelList]);
|
||||
const hasRecommendedOpenCodeModels = useMemo(
|
||||
() =>
|
||||
effectiveProviderId === 'opencode' &&
|
||||
modelOptions.some((option) => isOpenCodeTeamModelRecommended(option.value)),
|
||||
[effectiveProviderId, modelOptions]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveProviderId !== 'opencode' || !hasRecommendedOpenCodeModels) {
|
||||
setRecommendedOnly(false);
|
||||
}
|
||||
}, [effectiveProviderId, hasRecommendedOpenCodeModels]);
|
||||
|
||||
const visibleModelOptions = useMemo(() => {
|
||||
if (effectiveProviderId !== 'opencode') {
|
||||
return modelOptions;
|
||||
}
|
||||
|
||||
const concreteOptions = modelOptions
|
||||
.filter((option) => option.value.trim().length > 0)
|
||||
.map((option, index) => ({ option, index }))
|
||||
.filter(({ option }) => !recommendedOnly || isOpenCodeTeamModelRecommended(option.value))
|
||||
.sort((left, right) => {
|
||||
const recommendationOrder = compareOpenCodeTeamModelRecommendations(
|
||||
left.option.value,
|
||||
right.option.value
|
||||
);
|
||||
return recommendationOrder || left.index - right.index;
|
||||
})
|
||||
.map(({ option }) => option);
|
||||
|
||||
if (recommendedOnly) {
|
||||
return concreteOptions;
|
||||
}
|
||||
|
||||
return [
|
||||
...modelOptions.filter((option) => option.value.trim().length === 0),
|
||||
...concreteOptions,
|
||||
];
|
||||
}, [effectiveProviderId, modelOptions, recommendedOnly]);
|
||||
const shouldConstrainModelListHeight = visibleModelOptions.length > 8;
|
||||
|
||||
return (
|
||||
<div className="mb-5">
|
||||
|
|
@ -384,11 +432,34 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
list is syncing.
|
||||
</p>
|
||||
) : null}
|
||||
{hasRecommendedOpenCodeModels ? (
|
||||
<div className="mb-2 flex w-fit items-center gap-2">
|
||||
<Checkbox
|
||||
id="opencode-team-model-recommended-only"
|
||||
checked={recommendedOnly}
|
||||
onCheckedChange={(checked) => setRecommendedOnly(checked === true)}
|
||||
className="size-3.5"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="opencode-team-model-recommended-only"
|
||||
className="cursor-pointer text-[11px] font-normal text-[var(--color-text-secondary)]"
|
||||
>
|
||||
Recommended only
|
||||
</Label>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="grid gap-1.5 rounded-md bg-[var(--color-surface)]"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
|
||||
data-testid="team-model-selector-model-grid"
|
||||
className={cn(
|
||||
'grid gap-1.5 rounded-md bg-[var(--color-surface)]',
|
||||
shouldConstrainModelListHeight && 'overflow-y-auto pr-1'
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
maxHeight: shouldConstrainModelListHeight ? 400 : undefined,
|
||||
}}
|
||||
>
|
||||
{modelOptions.map((opt) =>
|
||||
{visibleModelOptions.map((opt) =>
|
||||
(() => {
|
||||
const modelDisabledReason = getTeamModelUiDisabledReason(
|
||||
effectiveProviderId,
|
||||
|
|
@ -414,6 +485,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
effectiveProviderId === 'opencode' && opt.value !== ''
|
||||
? opt.badgeLabel?.trim() || null
|
||||
: null;
|
||||
const modelRecommendation =
|
||||
effectiveProviderId === 'opencode'
|
||||
? getOpenCodeTeamModelRecommendation(opt.value)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
@ -456,6 +531,24 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
{sourceBadgeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{modelRecommendation ? (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold',
|
||||
modelRecommendation.level === 'recommended'
|
||||
? 'bg-amber-300/12 border-amber-300/35 text-amber-200'
|
||||
: 'border-red-300/35 bg-red-400/10 text-red-200'
|
||||
)}
|
||||
title={modelRecommendation.reason}
|
||||
>
|
||||
{modelRecommendation.level === 'recommended' ? (
|
||||
<Star className="size-3 shrink-0 fill-current" />
|
||||
) : (
|
||||
<AlertTriangle className="size-3 shrink-0" />
|
||||
)}
|
||||
<span>{modelRecommendation.label}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{opt.value === '' && (
|
||||
<span className="flex items-center justify-center gap-1">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
|
|
@ -527,6 +620,11 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
})()
|
||||
)}
|
||||
</div>
|
||||
{effectiveProviderId === 'opencode' && visibleModelOptions.length === 0 ? (
|
||||
<div className="rounded-md border border-white/10 px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
No recommended OpenCode models are available in the current runtime list.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@
|
|||
href="./assets/participant-avatars/13.png"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<title>Agent Teams UI</title>
|
||||
<title>Agent Teams AI</title>
|
||||
<style>
|
||||
/* Splash: animated gradient background */
|
||||
#splash {
|
||||
|
|
@ -155,10 +155,16 @@
|
|||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 1;
|
||||
opacity: 0;
|
||||
animation: splash-canvas-in 0.62s ease-out 0.08s forwards;
|
||||
}
|
||||
@keyframes splash-canvas-in {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
#splash-logo,
|
||||
#splash-text {
|
||||
#splash-copy {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
|
@ -168,6 +174,13 @@
|
|||
splash-breathe 3s ease-in-out infinite,
|
||||
splash-glow 3s ease-in-out infinite;
|
||||
}
|
||||
#splash-copy {
|
||||
display: flex;
|
||||
width: min(84vw, 360px);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
@keyframes splash-breathe {
|
||||
0%,
|
||||
100% {
|
||||
|
|
@ -197,6 +210,34 @@
|
|||
color: #a1a1aa;
|
||||
text-shadow: 0 0 22px rgba(129, 140, 248, 0.26);
|
||||
}
|
||||
#splash-tagline {
|
||||
margin-top: 7px;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
color: rgba(212, 212, 216, 0.68);
|
||||
text-shadow: 0 0 18px rgba(129, 140, 248, 0.18);
|
||||
}
|
||||
#splash-tagline > span {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
clip-path: inset(0 100% 0 0);
|
||||
animation: splash-tagline-type 1.05s steps(28, end) 0.22s forwards;
|
||||
will-change: clip-path;
|
||||
}
|
||||
@keyframes splash-tagline-type {
|
||||
to {
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Logo node breathing - cycles through 3 agent nodes */
|
||||
@keyframes splash-node {
|
||||
|
|
@ -236,6 +277,9 @@
|
|||
:root.light #splash-text {
|
||||
color: #52525b;
|
||||
}
|
||||
:root.light #splash-tagline {
|
||||
color: rgba(63, 63, 70, 0.66);
|
||||
}
|
||||
:root.light #splash-noise {
|
||||
opacity: 0.02;
|
||||
}
|
||||
|
|
@ -257,10 +301,18 @@
|
|||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#splash,
|
||||
#splash-enhanced-canvas,
|
||||
#splash-logo,
|
||||
#splash-tagline > span,
|
||||
.splash-node {
|
||||
animation: none !important;
|
||||
}
|
||||
#splash-enhanced-canvas {
|
||||
opacity: 1;
|
||||
}
|
||||
#splash-tagline > span {
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
|
|
@ -387,7 +439,10 @@
|
|||
fill="#f3e8ff"
|
||||
/>
|
||||
</svg>
|
||||
<div id="splash-text">Agent Teams UI</div>
|
||||
<div id="splash-copy">
|
||||
<div id="splash-text">Agent Teams AI</div>
|
||||
<div id="splash-tagline"><span>Get more done by doing less.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
|
|
@ -397,6 +452,7 @@
|
|||
var TAU = Math.PI * 2;
|
||||
var TEAM_MEMBER_COUNTS = [4, 3, 5];
|
||||
var TEAM_MEMBER_OFFSETS = [0, 4, 7];
|
||||
var TEAM_LABELS = ['Marketing', 'Researchers', 'Coding'];
|
||||
var MAX_DPR = 2;
|
||||
var AVATAR_URLS = [
|
||||
'./assets/participant-avatars/01.png',
|
||||
|
|
@ -545,12 +601,14 @@
|
|||
|
||||
for (var i = 0; i < teams.length; i++) drawHalo(ctx, teams[i], time, p);
|
||||
drawMessages(ctx, teams, time, p, mobile);
|
||||
for (var t = 0; t < teams.length; t++) drawLinks(ctx, teams[t], p);
|
||||
for (var ti = 0; ti < teams.length; ti++) {
|
||||
for (var ri = 0; ri < teams[ti].robots.length; ri++) {
|
||||
drawRobot(ctx, teams[ti].robots[ri], time, p);
|
||||
}
|
||||
}
|
||||
for (var labelIndex = 0; labelIndex < teams.length; labelIndex++) {
|
||||
drawTeamLabel(ctx, teams[labelIndex], p, mobile);
|
||||
}
|
||||
}
|
||||
|
||||
function buildTeams(width, height, time, mobile, p, center) {
|
||||
|
|
@ -700,21 +758,6 @@
|
|||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
function drawLinks(ctx, team, p) {
|
||||
var pairs = connectionPairs(team.robots.length);
|
||||
for (var i = 0; i < pairs.length; i++) {
|
||||
var from = team.robots[pairs[i][0]];
|
||||
var to = team.robots[pairs[i][1]];
|
||||
if (!from || !to) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.strokeStyle = withAlpha(team.color, p.isLight ? 0.34 : 0.42);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawMessages(ctx, teams, time, p, mobile) {
|
||||
for (var i = 0; i < teams.length; i++) {
|
||||
var pairs = localPairs(i, teams[i].robots.length);
|
||||
|
|
@ -724,14 +767,14 @@
|
|||
var from = teams[i].robots[pairs[pi][0]];
|
||||
var to = teams[i].robots[pairs[pi][1]];
|
||||
if (to) applyReceivePulse(to, receivePulse(raw, 0.76));
|
||||
if (raw > 0.76) continue;
|
||||
var flightState = messageFlightState(raw, 0.76, 0.12);
|
||||
if (!flightState) continue;
|
||||
if (from && to)
|
||||
drawFlight(
|
||||
ctx,
|
||||
localCurve(from, to, teams[i].center, teams[i].radius * 0.42),
|
||||
ease(raw / 0.76),
|
||||
flightState,
|
||||
teams[i].color,
|
||||
time,
|
||||
mobile ? 4.6 : 5.8,
|
||||
p,
|
||||
false
|
||||
|
|
@ -752,13 +795,13 @@
|
|||
var crossFrom = fromTeam.robots[route[1] % fromTeam.robots.length];
|
||||
var crossTo = toTeam.robots[route[3] % toTeam.robots.length];
|
||||
applyReceivePulse(crossTo, receivePulse(rawCross, 0.64) * 0.88);
|
||||
if (rawCross > 0.64) continue;
|
||||
var crossState = messageFlightState(rawCross, 0.64, 0.1);
|
||||
if (!crossState) continue;
|
||||
drawFlight(
|
||||
ctx,
|
||||
straightCurve(fromTeam.center, toTeam.center),
|
||||
ease(rawCross / 0.64),
|
||||
straightCurve(crossFrom, crossTo),
|
||||
crossState,
|
||||
route[5] ? p.messageAccent : fromTeam.color,
|
||||
time,
|
||||
mobile ? 5.2 : 6.8,
|
||||
p,
|
||||
true
|
||||
|
|
@ -766,58 +809,142 @@
|
|||
}
|
||||
}
|
||||
|
||||
function drawFlight(ctx, curve, progress, color, time, size, p, crossTeam) {
|
||||
function drawFlight(ctx, curve, state, color, size, p, crossTeam) {
|
||||
var p0 = curve[0],
|
||||
p1 = curve[1],
|
||||
p2 = curve[2],
|
||||
p3 = curve[3];
|
||||
ctx.save();
|
||||
if (!crossTeam) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p0.x, p0.y);
|
||||
ctx.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
|
||||
ctx.strokeStyle = withAlpha(color, 0.12);
|
||||
ctx.lineWidth = 0.85;
|
||||
ctx.setLineDash([4, 8]);
|
||||
ctx.lineDashOffset = -time * 34;
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
for (var i = 6; i >= 1; i--) {
|
||||
var t = progress - i * 0.04;
|
||||
if (t <= 0) continue;
|
||||
var trailPoint = cubicPoint(p0, p1, p2, p3, t);
|
||||
ctx.fillStyle = withAlpha(color, (1 - i / 7) * (p.isLight ? 0.14 : 0.2));
|
||||
ctx.beginPath();
|
||||
ctx.arc(trailPoint.x, trailPoint.y, size * 0.34, 0, TAU);
|
||||
ctx.fill();
|
||||
var progress = state.progress;
|
||||
var speed = clamp(state.motionSpeed, 0, 1);
|
||||
if (speed > 0.045) {
|
||||
drawSpeedTrail(ctx, curve, progress, speed, color, size, p, crossTeam);
|
||||
}
|
||||
|
||||
var position = cubicPoint(p0, p1, p2, p3, progress);
|
||||
var tangent = cubicTangent(p0, p1, p2, p3, progress);
|
||||
drawBubble(ctx, position, Math.atan2(tangent.y, tangent.x), size, color, p, crossTeam);
|
||||
drawBubble(
|
||||
ctx,
|
||||
position,
|
||||
Math.atan2(tangent.y, tangent.x),
|
||||
size,
|
||||
color,
|
||||
p,
|
||||
crossTeam,
|
||||
state.bubbleScale,
|
||||
state.bubbleAlpha
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawSpeedTrail(ctx, curve, progress, speed, color, size, p, crossTeam) {
|
||||
var p0 = curve[0],
|
||||
p1 = curve[1],
|
||||
p2 = curve[2],
|
||||
p3 = curve[3];
|
||||
var trailLength = (crossTeam ? 0.26 : 0.21) * (0.24 + speed * 1.08);
|
||||
var segmentCount = Math.round(9 + speed * 10);
|
||||
var alphaBase = (p.isLight ? 0.22 : 0.32) * speed;
|
||||
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.shadowColor = withAlpha(color, alphaBase * 0.58);
|
||||
ctx.shadowBlur = size * (0.78 + speed * 1.28);
|
||||
|
||||
for (var segment = 0; segment < segmentCount; segment++) {
|
||||
var startRatio = segment / segmentCount;
|
||||
var endRatio = (segment + 1) / segmentCount;
|
||||
var t0 = progress - trailLength * (1 - startRatio);
|
||||
var t1 = progress - trailLength * (1 - endRatio);
|
||||
if (t1 <= 0) continue;
|
||||
|
||||
var from = cubicPoint(p0, p1, p2, p3, Math.max(0, t0));
|
||||
var to = cubicPoint(p0, p1, p2, p3, Math.max(0, t1));
|
||||
var headWeight = endRatio * endRatio;
|
||||
var width = size * (0.12 + headWeight * 0.48) * (0.9 + speed * 0.45);
|
||||
var alpha = alphaBase * headWeight;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.strokeStyle = withAlpha(color, alpha * 0.34);
|
||||
ctx.lineWidth = width * 2.35;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.strokeStyle = withAlpha(color, alpha);
|
||||
ctx.lineWidth = width;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function messageFlightState(raw, activeWindow, settleWindow) {
|
||||
if (raw > activeWindow + settleWindow) return null;
|
||||
|
||||
if (raw <= activeWindow) {
|
||||
var phase = raw / activeWindow;
|
||||
return {
|
||||
progress: ease(phase),
|
||||
motionSpeed: easedMotionSpeed(phase),
|
||||
bubbleScale: 1,
|
||||
bubbleAlpha: 1,
|
||||
};
|
||||
}
|
||||
|
||||
var settlePhase = (raw - activeWindow) / settleWindow;
|
||||
var eased = easeOutCubic(settlePhase);
|
||||
return {
|
||||
progress: 1,
|
||||
motionSpeed: 0,
|
||||
bubbleScale: Math.max(0.12, 1 - eased * 0.88),
|
||||
bubbleAlpha: Math.max(0, 1 - eased),
|
||||
};
|
||||
}
|
||||
|
||||
function applyReceivePulse(robot, pulse) {
|
||||
robot.receivePulse = Math.max(robot.receivePulse, pulse);
|
||||
}
|
||||
|
||||
function receivePulse(raw, activeWindow) {
|
||||
var start = activeWindow * 0.78;
|
||||
var end = Math.min(0.96, activeWindow + 0.11);
|
||||
var previousStart = activeWindow * 0.78;
|
||||
var previousEnd = Math.min(0.96, activeWindow + 0.11);
|
||||
var duration = (previousEnd - previousStart) / 3;
|
||||
var start = activeWindow - duration * 0.62;
|
||||
var end = activeWindow + duration * 0.38;
|
||||
if (raw < start || raw > end) return 0;
|
||||
var phase = (raw - start) / (end - start);
|
||||
return Math.sin(phase * Math.PI) * (1 - phase * 0.28);
|
||||
}
|
||||
|
||||
function drawBubble(ctx, position, angle, size, color, p, crossTeam) {
|
||||
function easedMotionSpeed(value) {
|
||||
var t = clamp(value, 0, 1);
|
||||
var derivative = t < 0.5 ? 12 * t * t : 12 * (1 - t) * (1 - t);
|
||||
return clamp(derivative / 3, 0, 1);
|
||||
}
|
||||
|
||||
function easeOutCubic(value) {
|
||||
var t = clamp(value, 0, 1);
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
function drawBubble(ctx, position, angle, size, color, p, crossTeam, scale, alpha) {
|
||||
scale = scale === undefined ? 1 : scale;
|
||||
alpha = alpha === undefined ? 1 : alpha;
|
||||
if (scale <= 0.02 || alpha <= 0.01) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(position.x, position.y);
|
||||
ctx.rotate(angle * 0.08);
|
||||
ctx.shadowColor = withAlpha(color, p.isLight ? 0.16 : 0.3);
|
||||
ctx.shadowBlur = crossTeam ? 12 : 8;
|
||||
ctx.scale(scale, scale);
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.shadowColor = withAlpha(color, (p.isLight ? 0.16 : 0.3) * alpha);
|
||||
ctx.shadowBlur = (crossTeam ? 12 : 8) * (0.5 + scale * 0.5);
|
||||
var width = size * (crossTeam ? 2.28 : 2.06);
|
||||
var height = size * 1.42;
|
||||
roundRectPath(ctx, -width / 2, -height / 2, width, height, size * 0.28);
|
||||
|
|
@ -839,6 +966,42 @@
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawTeamLabel(ctx, team, p, mobile) {
|
||||
var label = TEAM_LABELS[team.index] || '';
|
||||
if (!label) return;
|
||||
|
||||
var fontSize = mobile ? 7.5 : 8.5;
|
||||
var y = team.center.y + team.radius * (mobile ? 1.65 : 1.58);
|
||||
ctx.save();
|
||||
ctx.font =
|
||||
'600 ' +
|
||||
fontSize +
|
||||
'px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
var metrics = ctx.measureText(label);
|
||||
var paddingX = mobile ? 4 : 5;
|
||||
var paddingY = mobile ? 2 : 2.5;
|
||||
var width = metrics.width + paddingX * 2;
|
||||
var height = fontSize + paddingY * 2;
|
||||
var x = team.center.x - width / 2;
|
||||
var rectY = y - height / 2;
|
||||
|
||||
roundRectPath(ctx, x, rectY, width, height, height / 2);
|
||||
ctx.fillStyle = withAlpha(p.isLight ? '#ffffff' : '#090a14', p.isLight ? 0.36 : 0.24);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = withAlpha(team.color, p.isLight ? 0.18 : 0.24);
|
||||
ctx.lineWidth = 0.75;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.shadowColor = withAlpha(team.color, p.isLight ? 0.12 : 0.22);
|
||||
ctx.shadowBlur = mobile ? 4 : 6;
|
||||
ctx.fillStyle = withAlpha(p.isLight ? '#3f3f46' : '#e4e4e7', p.isLight ? 0.58 : 0.66);
|
||||
ctx.fillText(label, team.center.x, y + 0.2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawRobot(ctx, robot, time, p) {
|
||||
var size = robot.size;
|
||||
var y = robot.y + robot.bob * 0.9 - robot.receivePulse * size * 0.24;
|
||||
|
|
@ -933,20 +1096,6 @@
|
|||
ctx.fill();
|
||||
}
|
||||
|
||||
function connectionPairs(count) {
|
||||
if (count <= 3)
|
||||
return [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 0],
|
||||
];
|
||||
var pairs = [];
|
||||
for (var i = 0; i < count; i++) pairs.push([i, (i + 1) % count]);
|
||||
if (count >= 4) pairs.push([0, 2]);
|
||||
if (count >= 5) pairs.push([1, 4]);
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function localPairs(teamIndex, count) {
|
||||
var map = [
|
||||
[
|
||||
|
|
|
|||
126
src/renderer/utils/openCodeModelRecommendations.ts
Normal file
126
src/renderer/utils/openCodeModelRecommendations.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
export type OpenCodeTeamModelRecommendationLevel = 'recommended' | 'not-recommended';
|
||||
|
||||
export interface OpenCodeTeamModelRecommendation {
|
||||
readonly level: OpenCodeTeamModelRecommendationLevel;
|
||||
readonly label: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
const PASSED_REAL_AGENT_TEAMS_E2E_REASON =
|
||||
'This exact model route passed real OpenCode Agent Teams E2E: launch, direct reply, and teammate-to-teammate relay.';
|
||||
|
||||
const OPENCODE_TEAM_RECOMMENDED_MODELS = new Set<string>([
|
||||
'opencode/minimax-m2.5-free',
|
||||
'openrouter/anthropic/claude-haiku-4.5',
|
||||
'openrouter/anthropic/claude-sonnet-4.5',
|
||||
'openrouter/deepseek/deepseek-v3.2',
|
||||
'openrouter/google/gemini-2.5-flash',
|
||||
'openrouter/google/gemini-2.5-flash-lite',
|
||||
'openrouter/google/gemini-3-flash-preview',
|
||||
'openrouter/minimax/minimax-m2.5',
|
||||
'openrouter/mistralai/codestral-2508',
|
||||
'openrouter/openai/gpt-5.4-mini',
|
||||
'openrouter/openai/gpt-oss-120b:free',
|
||||
'openrouter/qwen/qwen3-coder',
|
||||
'openrouter/qwen/qwen3-coder-flash',
|
||||
]);
|
||||
|
||||
const OPENCODE_TEAM_NOT_RECOMMENDED_MODELS = new Map<string, string>([
|
||||
[
|
||||
'opencode/ling-2.6-flash-free',
|
||||
'Real OpenCode Agent Teams E2E showed unreliable peer relay for this model.',
|
||||
],
|
||||
[
|
||||
'opencode/nemotron-3-super-free',
|
||||
'Real OpenCode Agent Teams E2E showed empty assistant turns during peer relay.',
|
||||
],
|
||||
[
|
||||
'openrouter/google/gemini-2.5-pro',
|
||||
'Real OpenCode Agent Teams E2E passed direct reply but failed peer relay.',
|
||||
],
|
||||
[
|
||||
'openrouter/google/gemini-3-pro-preview',
|
||||
'OpenRouter reported no runnable endpoints for this model during execution verification.',
|
||||
],
|
||||
[
|
||||
'openrouter/meta-llama/llama-3.3-70b-instruct:free',
|
||||
'Execution verification timed out before Agent Teams launch could proceed.',
|
||||
],
|
||||
[
|
||||
'openrouter/minimax/minimax-m2.5:free',
|
||||
'This OpenRouter free route for MiniMax M2.5 passed direct reply but failed teammate-to-teammate relay. The non-free OpenRouter route and the OpenCode free alias are tracked separately.',
|
||||
],
|
||||
[
|
||||
'openrouter/openai/gpt-oss-20b:free',
|
||||
'Execution verification passed, but real Agent Teams E2E produced fake tool text instead of MCP message_send.',
|
||||
],
|
||||
[
|
||||
'openrouter/openrouter/free',
|
||||
'Aggregator routing was unstable in real Agent Teams E2E and timed out during peer relay.',
|
||||
],
|
||||
[
|
||||
'openrouter/z-ai/glm-4.5-air:free',
|
||||
'Real OpenCode Agent Teams E2E was slow and failed peer relay with empty assistant turns.',
|
||||
],
|
||||
]);
|
||||
|
||||
function normalizeOpenCodeTeamModelId(modelId: string | null | undefined): string {
|
||||
return modelId?.trim().toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
export function getOpenCodeTeamModelRecommendation(
|
||||
modelId: string | null | undefined
|
||||
): OpenCodeTeamModelRecommendation | null {
|
||||
const normalizedModelId = normalizeOpenCodeTeamModelId(modelId);
|
||||
if (!normalizedModelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (OPENCODE_TEAM_RECOMMENDED_MODELS.has(normalizedModelId)) {
|
||||
return {
|
||||
level: 'recommended',
|
||||
label: 'Recommended',
|
||||
reason: PASSED_REAL_AGENT_TEAMS_E2E_REASON,
|
||||
};
|
||||
}
|
||||
|
||||
const notRecommendedReason = OPENCODE_TEAM_NOT_RECOMMENDED_MODELS.get(normalizedModelId);
|
||||
if (notRecommendedReason) {
|
||||
return {
|
||||
level: 'not-recommended',
|
||||
label: 'Not recommended',
|
||||
reason: notRecommendedReason,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isOpenCodeTeamModelRecommended(modelId: string | null | undefined): boolean {
|
||||
return getOpenCodeTeamModelRecommendation(modelId)?.level === 'recommended';
|
||||
}
|
||||
|
||||
export function getOpenCodeTeamModelRecommendationSortRank(
|
||||
modelId: string | null | undefined
|
||||
): number {
|
||||
const recommendation = getOpenCodeTeamModelRecommendation(modelId);
|
||||
if (recommendation?.level === 'recommended') {
|
||||
return 0;
|
||||
}
|
||||
if (recommendation?.level === 'not-recommended') {
|
||||
return 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function compareOpenCodeTeamModelRecommendations(
|
||||
leftModelId: string | null | undefined,
|
||||
rightModelId: string | null | undefined
|
||||
): number {
|
||||
const leftRank = getOpenCodeTeamModelRecommendationSortRank(leftModelId);
|
||||
const rightRank = getOpenCodeTeamModelRecommendationSortRank(rightModelId);
|
||||
if (leftRank !== rightRank) {
|
||||
return leftRank - rightRank;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -180,6 +180,62 @@ describe('CodexRecentProjectsSourceAdapter', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('treats archived skip after live timeout as one full failure', async () => {
|
||||
const logger = createLogger();
|
||||
const appServerClient = {
|
||||
listRecentThreads: vi.fn().mockResolvedValue({
|
||||
live: {
|
||||
threads: [],
|
||||
error: 'JSON-RPC request timed out: thread/list',
|
||||
},
|
||||
archived: {
|
||||
threads: [],
|
||||
error:
|
||||
'Skipped archived thread/list after live thread/list failed: JSON-RPC request timed out: thread/list',
|
||||
skipped: true,
|
||||
},
|
||||
}),
|
||||
listRecentLiveThreads: vi.fn(),
|
||||
} as unknown as CodexAppServerClient;
|
||||
const identityResolver = {
|
||||
resolve: vi.fn(),
|
||||
} as unknown as RecentProjectIdentityResolver;
|
||||
|
||||
const adapter = new CodexRecentProjectsSourceAdapter({
|
||||
getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||
getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||
resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'),
|
||||
appServerClient,
|
||||
identityResolver,
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(adapter.list()).resolves.toEqual({
|
||||
candidates: [],
|
||||
degraded: true,
|
||||
});
|
||||
await expect(adapter.list()).resolves.toEqual({
|
||||
candidates: [],
|
||||
degraded: true,
|
||||
});
|
||||
|
||||
expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1);
|
||||
expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith('codex recent-projects thread list failed', {
|
||||
segment: 'live',
|
||||
error: 'JSON-RPC request timed out: thread/list',
|
||||
});
|
||||
expect(logger.warn).not.toHaveBeenCalledWith('codex recent-projects thread list failed', {
|
||||
segment: 'archived',
|
||||
error:
|
||||
'Skipped archived thread/list after live thread/list failed: JSON-RPC request timed out: thread/list',
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith('codex recent-projects source cooldown active', {
|
||||
retryAfterMs: expect.any(Number),
|
||||
reason: 'JSON-RPC request timed out: thread/list',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops Codex appstyle temp workspaces from dashboard candidates', async () => {
|
||||
const logger = createLogger();
|
||||
const appServerClient = {
|
||||
|
|
|
|||
|
|
@ -126,6 +126,52 @@ describe('CodexAppServerClient', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not queue archived loading after live thread loading times out', async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockImplementation((method: string, params?: { archived?: boolean }) => {
|
||||
if (method === 'initialize') {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
if (method === 'thread/list' && params?.archived === false) {
|
||||
return Promise.reject(new Error('JSON-RPC request timed out: thread/list'));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected method: ${method}`));
|
||||
});
|
||||
const session = createSession(request);
|
||||
|
||||
const withSession = vi.fn().mockImplementation((_options, handler) => handler(session));
|
||||
const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient);
|
||||
|
||||
const result = await client.listRecentThreads('/usr/local/bin/codex', {
|
||||
limit: 40,
|
||||
liveRequestTimeoutMs: 4500,
|
||||
archivedRequestTimeoutMs: 2500,
|
||||
totalTimeoutMs: 4500,
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(request).not.toHaveBeenCalledWith(
|
||||
'thread/list',
|
||||
expect.objectContaining({ archived: true }),
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(result).toEqual({
|
||||
live: {
|
||||
threads: [],
|
||||
error: 'JSON-RPC request timed out: thread/list',
|
||||
},
|
||||
archived: {
|
||||
threads: [],
|
||||
error:
|
||||
'Skipped archived thread/list after live thread/list failed: JSON-RPC request timed out: thread/list',
|
||||
skipped: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('raises the session timeout budget above sequential request timeouts', async () => {
|
||||
const session = createSession(
|
||||
vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const buildProviderAwareCliEnvMock = vi.fn();
|
||||
const resolveBinaryMock = vi.fn();
|
||||
const execCliMock = vi.fn();
|
||||
const resolveInteractiveShellEnvMock = vi.fn();
|
||||
|
||||
vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
|
||||
buildProviderAwareCliEnv: (...args: unknown[]) => buildProviderAwareCliEnvMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
||||
ClaudeBinaryResolver: {
|
||||
resolve: () => resolveBinaryMock(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/childProcess', () => ({
|
||||
execCli: (...args: unknown[]) => execCliMock(...args),
|
||||
spawnCli: vi.fn(),
|
||||
killProcessTree: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/shellEnv', () => ({
|
||||
resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(),
|
||||
}));
|
||||
|
||||
import { AgentTeamsRuntimeProviderManagementCliClient } from '../../../../src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient';
|
||||
|
||||
describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveBinaryMock.mockResolvedValue('/repo/cli-dev');
|
||||
resolveInteractiveShellEnvMock.mockResolvedValue({ PATH: '/Users/test/.bun/bin:/usr/bin' });
|
||||
buildProviderAwareCliEnvMock.mockResolvedValue({
|
||||
env: { PATH: '/Users/test/.bun/bin:/usr/bin' },
|
||||
connectionIssues: {},
|
||||
providerArgs: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns stderr details for failed model tests instead of hiding them behind the command', async () => {
|
||||
const error = new Error('Command failed: /repo/cli-dev runtime providers test-model');
|
||||
Object.assign(error, {
|
||||
stderr: './cli-dev: line 47: exec: bun: not found\n',
|
||||
stdout: '',
|
||||
});
|
||||
execCliMock.mockRejectedValue(error);
|
||||
|
||||
const client = new AgentTeamsRuntimeProviderManagementCliClient();
|
||||
const response = await client.testModel({
|
||||
runtimeId: 'opencode',
|
||||
providerId: 'opencode',
|
||||
modelId: 'opencode/nemotron-3-super-free',
|
||||
});
|
||||
|
||||
expect(response.error?.message).toBe('./cli-dev: line 47: exec: bun: not found');
|
||||
expect(response.error?.message).not.toContain('runtime providers test-model');
|
||||
});
|
||||
|
||||
it('parses JSON error responses from stdout when the CLI exits non-zero', async () => {
|
||||
const error = new Error('Command failed: /repo/cli-dev runtime providers test-model');
|
||||
Object.assign(error, {
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
error: {
|
||||
code: 'auth-required',
|
||||
message: 'Provider opencode must be connected before testing a model',
|
||||
recoverable: true,
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
});
|
||||
execCliMock.mockRejectedValue(error);
|
||||
|
||||
const client = new AgentTeamsRuntimeProviderManagementCliClient();
|
||||
const response = await client.testModel({
|
||||
runtimeId: 'opencode',
|
||||
providerId: 'opencode',
|
||||
modelId: 'opencode/nemotron-3-super-free',
|
||||
});
|
||||
|
||||
expect(response.error?.code).toBe('auth-required');
|
||||
expect(response.error?.message).toBe(
|
||||
'Provider opencode must be connected before testing a model'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -190,8 +190,8 @@ liveDescribe('OpenCode semantic messaging live e2e', () => {
|
|||
const replyToken = `opencode-peer-reply-e2e-${Date.now()}`;
|
||||
const peerInstructionText = [
|
||||
`Peer relay token: ${peerToken}.`,
|
||||
`Please reply to the app user with exactly ${replyToken}.`,
|
||||
`Use agent-teams_message_send with teamName="${teamName}", to="user", from="${recipientName}", text exactly "${replyToken}", and summary "peer reply".`,
|
||||
`Jack, reply to the app user with exactly ${replyToken}.`,
|
||||
`Use agent-teams_message_send to user from ${recipientName} with summary "peer reply".`,
|
||||
].join(' ');
|
||||
const progressEvents: TeamProvisioningProgress[] = [];
|
||||
|
||||
|
|
@ -258,11 +258,10 @@ liveDescribe('OpenCode semantic messaging live e2e', () => {
|
|||
messageId: `ui-peer-message-${Date.now()}`,
|
||||
replyRecipient: recipientName,
|
||||
text: [
|
||||
`Send ${recipientName} a team message by calling agent-teams_message_send exactly once.`,
|
||||
`Set to="${recipientName}" and from="${senderName}".`,
|
||||
'Use this exact message text, with no extra text:',
|
||||
`Send one team message to ${recipientName}.`,
|
||||
'Use the exact message text below and no extra commentary:',
|
||||
peerInstructionText,
|
||||
`Use agent-teams_message_send with to="${recipientName}" and from="${senderName}".`,
|
||||
`Call agent-teams_message_send with to="${recipientName}", from="${senderName}", text set to the exact message text above, and summary "peer relay".`,
|
||||
'Do not reply to user instead of sending the team message.',
|
||||
].join('\n'),
|
||||
});
|
||||
|
|
@ -280,7 +279,7 @@ liveDescribe('OpenCode semantic messaging live e2e', () => {
|
|||
recipientName,
|
||||
senderName,
|
||||
replyToken,
|
||||
90_000
|
||||
180_000
|
||||
);
|
||||
} catch (error) {
|
||||
const transcript = await getRuntimeTranscript(bridgeClient, teamName, senderName);
|
||||
|
|
@ -293,16 +292,13 @@ liveDescribe('OpenCode semantic messaging live e2e', () => {
|
|||
);
|
||||
}
|
||||
|
||||
const relay = await svc.relayOpenCodeMemberInboxMessages(teamName, recipientName, {
|
||||
onlyMessageId: peerMessage.messageId,
|
||||
source: 'manual',
|
||||
deliveryMetadata: {
|
||||
replyRecipient: 'user',
|
||||
},
|
||||
});
|
||||
if (relay.delivered < 1) {
|
||||
throw new Error(`OpenCode peer relay failed: ${JSON.stringify(relay, null, 2)}`);
|
||||
}
|
||||
await waitForOpenCodePeerRelay(
|
||||
svc,
|
||||
teamName,
|
||||
recipientName,
|
||||
peerMessage.messageId,
|
||||
180_000
|
||||
);
|
||||
|
||||
let reply: InboxMessage;
|
||||
try {
|
||||
|
|
@ -410,6 +406,37 @@ async function waitForMemberInboxMessage(
|
|||
);
|
||||
}
|
||||
|
||||
async function waitForOpenCodePeerRelay(
|
||||
svc: TeamProvisioningService,
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
messageId: string,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastRelay: Awaited<ReturnType<TeamProvisioningService['relayOpenCodeMemberInboxMessages']>> | null =
|
||||
null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
lastRelay = await svc.relayOpenCodeMemberInboxMessages(teamName, memberName, {
|
||||
onlyMessageId: messageId,
|
||||
source: 'manual',
|
||||
deliveryMetadata: {
|
||||
replyRecipient: 'user',
|
||||
},
|
||||
});
|
||||
if (lastRelay.delivered >= 1) {
|
||||
return;
|
||||
}
|
||||
if (lastRelay.failed > 0 && lastRelay.lastDelivery?.responsePending !== true) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 3_000));
|
||||
}
|
||||
|
||||
throw new Error(`OpenCode peer relay failed: ${JSON.stringify(lastRelay, null, 2)}`);
|
||||
}
|
||||
|
||||
async function readInboxMessages(inboxPath: string): Promise<InboxMessage[]> {
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(inboxPath, 'utf8'));
|
||||
|
|
|
|||
|
|
@ -109,6 +109,42 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
).resolves.toContain('"teamLaunchState": "clean_success"');
|
||||
});
|
||||
|
||||
it('accepts pure OpenCode runtime bootstrap check-ins during adapter launch', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const adapter = new BootstrapCheckingOpenCodeRuntimeAdapter(svc);
|
||||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||
|
||||
const { runId } = await svc.createTeam(
|
||||
{
|
||||
teamName: 'pure-opencode-bootstrap-during-launch-safe-e2e',
|
||||
cwd: projectPath,
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/big-pickle',
|
||||
skipPermissions: true,
|
||||
members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }],
|
||||
},
|
||||
() => undefined
|
||||
);
|
||||
|
||||
expect(runId).toBe(adapter.launchInputs[0]?.runId);
|
||||
expect(adapter.bootstrapCheckins).toEqual([
|
||||
{
|
||||
memberName: 'alice',
|
||||
runId,
|
||||
state: 'accepted',
|
||||
},
|
||||
]);
|
||||
|
||||
const statuses = await svc.getMemberSpawnStatuses(
|
||||
'pure-opencode-bootstrap-during-launch-safe-e2e'
|
||||
);
|
||||
expect(statuses.statuses.alice).toMatchObject({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
bootstrapConfirmed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps failed OpenCode runtime adapter launches out of alive teams', async () => {
|
||||
const adapter = new FakeOpenCodeRuntimeAdapter('partial_failure');
|
||||
const svc = new TeamProvisioningService();
|
||||
|
|
@ -16127,6 +16163,36 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
class BootstrapCheckingOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter {
|
||||
readonly bootstrapCheckins: { memberName: string; runId: string; state: string }[] = [];
|
||||
|
||||
constructor(private readonly svc: TeamProvisioningService) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult> {
|
||||
const firstMember = input.expectedMembers[0];
|
||||
if (!firstMember) {
|
||||
return super.launch(input);
|
||||
}
|
||||
|
||||
const ack = await this.svc.recordOpenCodeRuntimeBootstrapCheckin({
|
||||
teamName: input.teamName,
|
||||
runId: input.runId,
|
||||
memberName: firstMember.name,
|
||||
runtimeSessionId: `session-${firstMember.name}`,
|
||||
observedAt: new Date().toISOString(),
|
||||
});
|
||||
this.bootstrapCheckins.push({
|
||||
memberName: firstMember.name,
|
||||
runId: input.runId,
|
||||
state: ack.state,
|
||||
});
|
||||
|
||||
return super.launch(input);
|
||||
}
|
||||
}
|
||||
|
||||
class BlockingOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter {
|
||||
readonly pendingLaunchInputs: TeamRuntimeLaunchInput[] = [];
|
||||
private releaseGate: (() => void) | null = null;
|
||||
|
|
|
|||
|
|
@ -1736,6 +1736,73 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
expect(rows[0].read).toBe(false);
|
||||
});
|
||||
|
||||
it('reuses existing OpenCode prompt ledger metadata during watchdog relay retries', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
const taskRefs = [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }];
|
||||
hoisted.files.set(
|
||||
`/mock/teams/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
seedMemberInbox(teamName, 'jack', [
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'jack',
|
||||
text: 'Please answer the app user.',
|
||||
timestamp: '2026-02-23T17:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'opencode-ledger-metadata-1',
|
||||
actionMode: 'ask',
|
||||
},
|
||||
]);
|
||||
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
|
||||
getByInboxMessage: vi.fn(async () => ({
|
||||
id: 'record-1',
|
||||
status: 'retry_scheduled',
|
||||
replyRecipient: 'user',
|
||||
actionMode: 'delegate',
|
||||
taskRefs,
|
||||
source: 'manual',
|
||||
})),
|
||||
});
|
||||
const deliverSpy = vi.spyOn(service, 'deliverOpenCodeMemberMessage').mockResolvedValue({
|
||||
delivered: true,
|
||||
accepted: true,
|
||||
responsePending: true,
|
||||
responseState: 'pending',
|
||||
diagnostics: ['opencode_delivery_response_pending'],
|
||||
});
|
||||
|
||||
const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack', {
|
||||
onlyMessageId: 'opencode-ledger-metadata-1',
|
||||
source: 'watchdog',
|
||||
});
|
||||
|
||||
expect(relay).toMatchObject({
|
||||
attempted: 1,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
lastDelivery: { delivered: true, responsePending: true },
|
||||
});
|
||||
expect(deliverSpy).toHaveBeenCalledWith(
|
||||
teamName,
|
||||
expect.objectContaining({
|
||||
messageId: 'opencode-ledger-metadata-1',
|
||||
replyRecipient: 'user',
|
||||
actionMode: 'delegate',
|
||||
taskRefs,
|
||||
source: 'manual',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips failed-terminal OpenCode rows without blocking newer unread rows', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
|
|||
|
|
@ -239,5 +239,22 @@ describe('cli child process helpers', () => {
|
|||
expect(execMock).toHaveBeenCalled();
|
||||
expect(result.stdout).toBe('2.3.4');
|
||||
});
|
||||
|
||||
it('preserves stdout and stderr on execFile failures', async () => {
|
||||
setPlatform('linux');
|
||||
const execFileMock = child.execFile as unknown as Mock;
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
cb(new Error('Command failed'), '{"error":"bad"}', 'bun: not found');
|
||||
return {} as any;
|
||||
}
|
||||
);
|
||||
|
||||
await expect(execCli('/usr/bin/claude', ['--version'])).rejects.toMatchObject({
|
||||
message: 'Command failed',
|
||||
stdout: '{"error":"bad"}',
|
||||
stderr: 'bun: not found',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ describe('buildMergedCliPath', () => {
|
|||
expect(p.split(':')).toEqual(
|
||||
expect.arrayContaining([
|
||||
'/home/testuser/.claude/local/node_modules/.bin',
|
||||
'/home/testuser/.bun/bin',
|
||||
'/home/testuser/.local/bin',
|
||||
'/home/testuser/.npm-global/bin',
|
||||
'/home/testuser/.npm/bin',
|
||||
|
|
@ -66,6 +67,7 @@ describe('buildMergedCliPath', () => {
|
|||
expect(p.startsWith('/opt/custom/bin')).toBe(true);
|
||||
expect(p).toContain('/bin');
|
||||
expect(p).toContain('/home/testuser/.claude/local/node_modules/.bin');
|
||||
expect(p).toContain('/home/testuser/.bun/bin');
|
||||
expect(p).toContain('/usr/bin');
|
||||
expect(p).not.toContain('/home/testuser/.local/bin');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ const codexAccountHookState = {
|
|||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
openExternal: vi.fn(() => Promise.resolve({ success: true })),
|
||||
showInFolder: vi.fn(),
|
||||
},
|
||||
isElectronMode: () => true,
|
||||
|
|
@ -404,6 +405,67 @@ describe('CLI status visibility during completed install state', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows an OpenCode download action on the dashboard when the OpenCode CLI is missing', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { api } = await import('@renderer/api');
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
displayName: 'Multimodel runtime',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
authLoggedIn: false,
|
||||
providers: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode (75+ LLM providers)',
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'error',
|
||||
statusMessage: 'OpenCode CLI is not installed.',
|
||||
models: [],
|
||||
canLoginFromUi: false,
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
},
|
||||
backend: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('OpenCode (75+ LLM providers)');
|
||||
expect(host.textContent).toContain('Download');
|
||||
|
||||
const downloadButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Download'
|
||||
);
|
||||
expect(downloadButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
downloadButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(api.openExternal).toHaveBeenCalledWith('https://opencode.ai/download');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves dashboard runtime backend refresh errors for the manage dialog', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
|
|
|
|||
|
|
@ -231,6 +231,90 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('labels, sorts, and filters OpenCode models with real Agent Teams E2E recommendations', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
providers: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
authMethod: 'api_key',
|
||||
backend: {
|
||||
kind: 'opencode-cli',
|
||||
label: 'OpenCode CLI',
|
||||
endpointLabel: 'opencode',
|
||||
},
|
||||
authenticated: true,
|
||||
supported: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
},
|
||||
models: [
|
||||
'openrouter/openai/gpt-oss-20b:free',
|
||||
'opencode/big-pickle',
|
||||
'openrouter/qwen/qwen3-coder-flash',
|
||||
],
|
||||
modelVerificationState: 'idle',
|
||||
modelAvailability: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TeamModelSelector, {
|
||||
providerId: 'opencode',
|
||||
onProviderChange: () => undefined,
|
||||
value: '',
|
||||
onValueChange: () => undefined,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Recommended only');
|
||||
expect(host.textContent).toContain('qwen/qwen3-coder-flash');
|
||||
expect(host.textContent).toContain('Recommended');
|
||||
expect(host.textContent).toContain('big-pickle');
|
||||
expect(host.textContent).toContain('openai/gpt-oss-20b:free');
|
||||
expect(host.textContent).toContain('Not recommended');
|
||||
|
||||
const buttonTexts = Array.from(host.querySelectorAll('button')).map(
|
||||
(button) => button.textContent ?? ''
|
||||
);
|
||||
const recommendedIndex = buttonTexts.findIndex((text) =>
|
||||
text.includes('qwen/qwen3-coder-flash')
|
||||
);
|
||||
const neutralIndex = buttonTexts.findIndex((text) => text.includes('big-pickle'));
|
||||
const notRecommendedIndex = buttonTexts.findIndex((text) =>
|
||||
text.includes('openai/gpt-oss-20b:free')
|
||||
);
|
||||
expect(recommendedIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(neutralIndex).toBeGreaterThan(recommendedIndex);
|
||||
expect(notRecommendedIndex).toBeGreaterThan(neutralIndex);
|
||||
|
||||
await act(async () => {
|
||||
const checkbox = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.getAttribute('role') === 'checkbox'
|
||||
);
|
||||
checkbox?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('qwen/qwen3-coder-flash');
|
||||
expect(host.textContent).not.toContain('big-pickle');
|
||||
expect(host.textContent).not.toContain('openai/gpt-oss-20b:free');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the runtime-reported Codex model list visible during a background refresh', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ function createState(
|
|||
},
|
||||
providers: [],
|
||||
selectedProviderId: 'openrouter',
|
||||
providerQuery: '',
|
||||
activeFormProviderId: null,
|
||||
apiKeyValue: '',
|
||||
modelPickerProviderId: null,
|
||||
|
|
@ -76,6 +77,7 @@ function createActions(): RuntimeProviderManagementActions {
|
|||
return {
|
||||
refresh: vi.fn(() => Promise.resolve()),
|
||||
selectProvider: vi.fn(),
|
||||
setProviderQuery: vi.fn(),
|
||||
startConnect: vi.fn(),
|
||||
cancelConnect: vi.fn(),
|
||||
setApiKeyValue: vi.fn(),
|
||||
|
|
@ -101,6 +103,39 @@ describe('RuntimeProviderManagementPanelView', () => {
|
|||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders an explicit loading state while the managed OpenCode view is loading', 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({
|
||||
view: null,
|
||||
providers: [],
|
||||
loading: true,
|
||||
}),
|
||||
actions,
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Checking runtime');
|
||||
expect(host.textContent).toContain('Loading managed OpenCode runtime');
|
||||
expect(host.textContent).toContain('Loading OpenCode providers');
|
||||
expect(host.querySelector('[data-testid="runtime-provider-loading-skeleton"]')).not.toBeNull();
|
||||
expect(host.querySelectorAll('.skeleton-shimmer').length).toBeGreaterThanOrEqual(10);
|
||||
expect(host.textContent).toContain('Checking...');
|
||||
const refreshButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Checking...')
|
||||
);
|
||||
expect(refreshButton?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('renders provider actions and opens API-key form state without exposing a raw secret', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -121,6 +156,19 @@ describe('RuntimeProviderManagementPanelView', () => {
|
|||
|
||||
expect(host.textContent).toContain('OpenRouter');
|
||||
expect(host.textContent).toContain('4 models');
|
||||
expect(host.querySelector('[data-testid="runtime-provider-search"]')).not.toBeNull();
|
||||
expect(
|
||||
host.querySelector('[data-testid="runtime-provider-row-openrouter"]')?.className
|
||||
).toContain('hover:bg-sky-400');
|
||||
|
||||
await act(async () => {
|
||||
host
|
||||
.querySelector('[data-testid="runtime-provider-row-openrouter"]')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(actions.selectProvider).toHaveBeenCalledWith('openrouter');
|
||||
|
||||
await act(async () => {
|
||||
const connect = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
|
|
@ -152,6 +200,43 @@ describe('RuntimeProviderManagementPanelView', () => {
|
|||
expect(host.textContent).not.toContain('sk-secret-value');
|
||||
});
|
||||
|
||||
it('filters providers from the local provider search', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const actions = createActions();
|
||||
const openRouterProvider = createState().view!.providers[0];
|
||||
const openAiProvider = {
|
||||
...openRouterProvider,
|
||||
providerId: 'openai',
|
||||
displayName: 'OpenAI',
|
||||
recommended: false,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: createState({
|
||||
view: {
|
||||
...createState().view!,
|
||||
providers: [openRouterProvider, openAiProvider],
|
||||
},
|
||||
providers: [openRouterProvider, openAiProvider],
|
||||
providerQuery: 'router',
|
||||
}),
|
||||
actions,
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('OpenRouter');
|
||||
expect(host.textContent).not.toContain('OpenAI');
|
||||
|
||||
expect(host.querySelector('[data-testid="runtime-provider-search"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders connected provider model picker actions', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -204,8 +289,36 @@ describe('RuntimeProviderManagementPanelView', () => {
|
|||
default: false,
|
||||
availability: 'untested',
|
||||
},
|
||||
{
|
||||
providerId: 'openrouter',
|
||||
modelId: 'opencode/big-pickle',
|
||||
displayName: 'opencode/big-pickle',
|
||||
sourceLabel: 'OpenCode',
|
||||
free: false,
|
||||
default: false,
|
||||
availability: 'untested',
|
||||
},
|
||||
{
|
||||
providerId: 'openrouter',
|
||||
modelId: 'openrouter/qwen/qwen3-coder-flash',
|
||||
displayName: 'qwen/qwen3-coder-flash',
|
||||
sourceLabel: 'OpenRouter',
|
||||
free: false,
|
||||
default: false,
|
||||
availability: 'untested',
|
||||
},
|
||||
],
|
||||
selectedModelId: 'openrouter/openai/gpt-oss-20b:free',
|
||||
modelResults: {
|
||||
'openrouter/openai/gpt-oss-20b:free': {
|
||||
providerId: 'openrouter',
|
||||
modelId: 'openrouter/openai/gpt-oss-20b:free',
|
||||
ok: true,
|
||||
availability: 'available',
|
||||
message: 'Model probe passed',
|
||||
diagnostics: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -220,19 +333,198 @@ describe('RuntimeProviderManagementPanelView', () => {
|
|||
});
|
||||
|
||||
expect(host.textContent).toContain('openrouter/openai/gpt-oss-20b:free');
|
||||
expect(host.textContent).toContain('Use for new teams');
|
||||
expect(host.textContent).toContain('Set OpenCode default');
|
||||
expect(host.textContent).toContain('Used for new teams');
|
||||
expect(host.textContent).toContain('Model probe passed');
|
||||
expect(host.textContent).toContain('Not recommended');
|
||||
expect(host.textContent).toContain('Recommended only');
|
||||
expect(host.textContent).toContain('Recommended');
|
||||
expect(host.textContent).not.toContain('Set OpenCode default');
|
||||
expect(
|
||||
Array.from(host.querySelectorAll('button')).some(
|
||||
(button) => button.textContent?.trim() === 'Use for new teams'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
host.querySelector('[data-testid="runtime-provider-logo-openrouter"] svg')
|
||||
).not.toBeNull();
|
||||
const connectedBadge = Array.from(host.querySelectorAll('span')).find(
|
||||
(span) => span.textContent === 'Connected'
|
||||
) as HTMLElement | undefined;
|
||||
expect(connectedBadge?.style.color).toBeTruthy();
|
||||
expect(
|
||||
host.querySelector('[data-testid="runtime-provider-model-search"]')?.style.paddingLeft
|
||||
).toBe('42px');
|
||||
expect(host.querySelector('[data-testid="runtime-provider-model-list"]')?.style.maxHeight).toBe(
|
||||
'300px'
|
||||
);
|
||||
expect(host.textContent).not.toContain('OpenRouterfree');
|
||||
const firstTestButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Test'
|
||||
);
|
||||
expect(firstTestButton?.className).toContain('border');
|
||||
const modelResult = host.querySelector(
|
||||
'[data-testid="runtime-provider-model-result-openrouter/openai/gpt-oss-20b:free"]'
|
||||
);
|
||||
expect(modelResult?.style.color).toBe('#86efac');
|
||||
expect((host.textContent ?? '').indexOf('qwen/qwen3-coder-flash')).toBeLessThan(
|
||||
(host.textContent ?? '').indexOf('opencode/big-pickle')
|
||||
);
|
||||
expect((host.textContent ?? '').indexOf('opencode/big-pickle')).toBeLessThan(
|
||||
(host.textContent ?? '').indexOf('openrouter/openai/gpt-oss-20b:free')
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const useButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Use for new teams')
|
||||
const checkbox = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.getAttribute('role') === 'checkbox'
|
||||
);
|
||||
useButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
checkbox?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(actions.useModelForNewTeams).toHaveBeenCalledWith(
|
||||
expect(host.textContent).toContain('qwen/qwen3-coder-flash');
|
||||
expect(host.textContent).not.toContain('opencode/big-pickle');
|
||||
expect(host.textContent).not.toContain('openrouter/openai/gpt-oss-20b:free');
|
||||
|
||||
await act(async () => {
|
||||
const checkbox = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.getAttribute('role') === 'checkbox'
|
||||
);
|
||||
checkbox?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
host
|
||||
.querySelector(
|
||||
'[data-testid="runtime-provider-model-row-openrouter/openai/gpt-oss-20b:free"]'
|
||||
)
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(actions.useModelForNewTeams).toHaveBeenCalledWith('openrouter/openai/gpt-oss-20b:free');
|
||||
|
||||
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 MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(actions.testModel).toHaveBeenCalledWith(
|
||||
'openrouter',
|
||||
'openrouter/openai/gpt-oss-20b:free'
|
||||
);
|
||||
expect(actions.useModelForNewTeams).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders verified brand icons for common OpenCode providers', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const actions = createActions();
|
||||
const baseProvider = createState().view!.providers[0];
|
||||
const providers = [
|
||||
{ providerId: 'openrouter', displayName: 'OpenRouter' },
|
||||
{ providerId: 'opencode', displayName: 'OpenCode Zen' },
|
||||
{ providerId: 'openai', displayName: 'OpenAI' },
|
||||
{ providerId: 'anthropic', displayName: 'Anthropic' },
|
||||
{ providerId: 'google', displayName: 'Google' },
|
||||
{ providerId: 'google-vertex', displayName: 'Vertex' },
|
||||
{ providerId: 'vercel', displayName: 'Vercel AI Gateway' },
|
||||
{ providerId: 'mistral', displayName: 'Mistral' },
|
||||
{ providerId: 'github-models', displayName: 'GitHub Models' },
|
||||
{ providerId: 'perplexity-agent', displayName: 'Perplexity Agent' },
|
||||
{ providerId: 'nvidia', displayName: 'Nvidia' },
|
||||
{ providerId: 'minimax', displayName: 'MiniMax' },
|
||||
{ providerId: 'cloudflare-ai-gateway', displayName: 'Cloudflare AI Gateway' },
|
||||
{ providerId: 'cloudflare-workers-ai', displayName: 'Cloudflare Workers AI' },
|
||||
{ providerId: 'gitlab-duo', displayName: 'GitLab Duo' },
|
||||
{ providerId: 'poe', displayName: 'Poe' },
|
||||
].map((provider) => ({
|
||||
...baseProvider,
|
||||
...provider,
|
||||
state: 'not-connected' as const,
|
||||
recommended: false,
|
||||
}));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: createState({
|
||||
view: {
|
||||
...createState().view!,
|
||||
providers,
|
||||
},
|
||||
providers,
|
||||
}),
|
||||
actions,
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
for (const provider of providers) {
|
||||
const logo = host.querySelector(
|
||||
`[data-testid="runtime-provider-logo-${provider.providerId}"]`
|
||||
);
|
||||
expect(logo).not.toBeNull();
|
||||
expect(logo?.querySelector('svg,img')).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses branded initials for popular providers without verified compact logo assets', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const actions = createActions();
|
||||
const baseProvider = createState().view!.providers[0];
|
||||
const providers = [
|
||||
{ providerId: 'xai', displayName: 'xAI', label: 'xAI' },
|
||||
{ providerId: 'groq', displayName: 'Groq', label: 'G' },
|
||||
{ providerId: 'deepseek', displayName: 'DeepSeek', label: 'DS' },
|
||||
{ providerId: 'deepinfra', displayName: 'Deep Infra', label: 'DI' },
|
||||
{ providerId: 'fireworks-ai', displayName: 'Fireworks AI', label: 'FW' },
|
||||
{ providerId: 'togetherai', displayName: 'Together AI', label: 'TA' },
|
||||
{ providerId: 'amazon-bedrock', displayName: 'Amazon Bedrock', label: 'AWS' },
|
||||
{ providerId: 'azure', displayName: 'Azure', label: 'AZ' },
|
||||
{ providerId: 'cohere', displayName: 'Cohere', label: 'CO' },
|
||||
{ providerId: 'ollama-cloud', displayName: 'Ollama Cloud', label: 'OL' },
|
||||
].map((provider) => ({
|
||||
...baseProvider,
|
||||
...provider,
|
||||
state: 'not-connected' as const,
|
||||
recommended: false,
|
||||
}));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: createState({
|
||||
view: {
|
||||
...createState().view!,
|
||||
providers,
|
||||
},
|
||||
providers,
|
||||
}),
|
||||
actions,
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
for (const provider of providers) {
|
||||
const logo = host.querySelector(
|
||||
`[data-testid="runtime-provider-logo-${provider.providerId}"]`
|
||||
);
|
||||
expect(logo?.textContent).toBe(provider.label);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
useRuntimeProviderManagement,
|
||||
type RuntimeProviderManagementActions,
|
||||
type RuntimeProviderManagementState,
|
||||
} from '../../../../src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement';
|
||||
import {
|
||||
getStoredCreateTeamModel,
|
||||
getStoredCreateTeamProvider,
|
||||
} from '../../../../src/renderer/services/createTeamPreferences';
|
||||
|
||||
import type { ElectronAPI } from '../../../../src/shared/types/api';
|
||||
import type { RuntimeProviderManagementModelTestResponse } from '../../../../src/features/runtime-provider-management/contracts';
|
||||
|
||||
function installRuntimeProviderManagementApi(response: RuntimeProviderManagementModelTestResponse): void {
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtimeProviderManagement: {
|
||||
testModel: vi.fn(() => Promise.resolve(response)),
|
||||
},
|
||||
} as unknown as ElectronAPI,
|
||||
});
|
||||
}
|
||||
|
||||
describe('useRuntimeProviderManagement', () => {
|
||||
let host: HTMLDivElement;
|
||||
let state: RuntimeProviderManagementState | null = null;
|
||||
let actions: RuntimeProviderManagementActions | null = null;
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
const hook = useRuntimeProviderManagement({
|
||||
runtimeId: 'opencode',
|
||||
enabled: false,
|
||||
});
|
||||
state = hook[0];
|
||||
actions = hook[1];
|
||||
return React.createElement('div');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
window.localStorage.clear();
|
||||
state = null;
|
||||
actions = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'electronAPI');
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uses a clicked model as the app default for new teams without a global success banner', async () => {
|
||||
const modelId = 'openrouter/openai/gpt-oss-20b:free';
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
actions?.useModelForNewTeams(modelId);
|
||||
});
|
||||
|
||||
expect(state?.selectedModelId).toBe(modelId);
|
||||
expect(state?.successMessage).toBeNull();
|
||||
expect(getStoredCreateTeamProvider()).toBe('opencode');
|
||||
expect(getStoredCreateTeamModel('opencode')).toBe(modelId);
|
||||
});
|
||||
|
||||
it('keeps failed model probes scoped to the model result instead of a global success banner', async () => {
|
||||
const modelId = 'openrouter/anthropic/claude-3.5-haiku';
|
||||
const message =
|
||||
'This request requires more credits, or fewer max_tokens. You requested up to 8192 tokens, but can only afford 381.';
|
||||
installRuntimeProviderManagementApi({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
result: {
|
||||
providerId: 'openrouter',
|
||||
modelId,
|
||||
ok: false,
|
||||
availability: 'unavailable',
|
||||
message,
|
||||
diagnostics: [],
|
||||
},
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await actions?.testModel('openrouter', modelId);
|
||||
});
|
||||
|
||||
expect(state?.successMessage).toBeNull();
|
||||
expect(state?.error).toBeNull();
|
||||
expect(state?.modelResults[modelId]?.ok).toBe(false);
|
||||
expect(state?.modelResults[modelId]?.message).toBe(message);
|
||||
});
|
||||
|
||||
it('keeps successful model probes scoped to the model card instead of a global success banner', async () => {
|
||||
const modelId = 'openrouter/openai/gpt-oss-20b:free';
|
||||
installRuntimeProviderManagementApi({
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
result: {
|
||||
providerId: 'openrouter',
|
||||
modelId,
|
||||
ok: true,
|
||||
availability: 'available',
|
||||
message: 'Model probe passed',
|
||||
diagnostics: [],
|
||||
},
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await actions?.testModel('openrouter', modelId);
|
||||
});
|
||||
|
||||
expect(state?.successMessage).toBeNull();
|
||||
expect(state?.error).toBeNull();
|
||||
expect(state?.modelResults[modelId]?.ok).toBe(true);
|
||||
expect(state?.modelResults[modelId]?.message).toBe('Model probe passed');
|
||||
});
|
||||
});
|
||||
69
test/renderer/utils/openCodeModelRecommendations.test.ts
Normal file
69
test/renderer/utils/openCodeModelRecommendations.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
compareOpenCodeTeamModelRecommendations,
|
||||
getOpenCodeTeamModelRecommendation,
|
||||
isOpenCodeTeamModelRecommended,
|
||||
} from '@renderer/utils/openCodeModelRecommendations';
|
||||
|
||||
describe('getOpenCodeTeamModelRecommendation', () => {
|
||||
it('marks models that passed real OpenCode Agent Teams E2E as recommended', () => {
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-coder-flash')).toMatchObject({
|
||||
level: 'recommended',
|
||||
label: 'Recommended',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation(' OPENROUTER/GOOGLE/GEMINI-2.5-FLASH-LITE ')
|
||||
).toMatchObject({
|
||||
level: 'recommended',
|
||||
label: 'Recommended',
|
||||
});
|
||||
expect(isOpenCodeTeamModelRecommended('openrouter/qwen/qwen3-coder-flash')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps similarly named models distinct when real E2E disagreed', () => {
|
||||
expect(getOpenCodeTeamModelRecommendation('opencode/minimax-m2.5-free')).toMatchObject({
|
||||
level: 'recommended',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/minimax/minimax-m2.5:free')
|
||||
).toMatchObject({
|
||||
level: 'not-recommended',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks models with real launch or messaging failures as not recommended', () => {
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-oss-20b:free')).toMatchObject({
|
||||
level: 'not-recommended',
|
||||
label: 'Not recommended',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/google/gemini-3-pro-preview')
|
||||
).toMatchObject({
|
||||
level: 'not-recommended',
|
||||
label: 'Not recommended',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not label noisy or unproven models as good or bad', () => {
|
||||
expect(getOpenCodeTeamModelRecommendation('opencode/big-pickle')).toBeNull();
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-code-fast-1')).toBeNull();
|
||||
expect(getOpenCodeTeamModelRecommendation('')).toBeNull();
|
||||
});
|
||||
|
||||
it('sorts recommended routes before neutral routes and not-recommended routes last', () => {
|
||||
const models = [
|
||||
'openrouter/openai/gpt-oss-20b:free',
|
||||
'opencode/big-pickle',
|
||||
'openrouter/qwen/qwen3-coder-flash',
|
||||
];
|
||||
|
||||
expect(
|
||||
[...models].sort((left, right) => compareOpenCodeTeamModelRecommendations(left, right))
|
||||
).toEqual([
|
||||
'openrouter/qwen/qwen3-coder-flash',
|
||||
'opencode/big-pickle',
|
||||
'openrouter/openai/gpt-oss-20b:free',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue