feat(app): improve runtime provider and tmux flows

This commit is contained in:
777genius 2026-04-25 17:28:56 +03:00
parent 19b6937446
commit 523d450bc8
36 changed files with 3043 additions and 355 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}`;
}

View file

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

View file

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

View file

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

View file

@ -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'),

View file

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

View file

@ -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][][] = [
[

View file

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

View file

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

View 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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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