Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ModelSelection } from '../model-selection';

function translateKey(key: string): string {
return key;
function translateKey(key: string, values?: Record<string, unknown>): string {
const interpolation = values ? Object.values(values).join(',') : '';
return interpolation ? `${key}:${interpolation}` : key;
}

vi.mock('use-intl', () => ({
Expand Down Expand Up @@ -84,6 +85,50 @@ describe('ModelSelection', () => {
expect(screen.getAllByText('already')).toHaveLength(1);
});

it('moves a picked model to the head when it becomes the default', () => {
const onChange = vi.fn();
render(
<ModelSelection
selected={[
{ id: 'model-a', label: 'Model A' },
{ id: 'model-b', label: 'Model B' },
]}
onChange={onChange}
/>,
);

expect(screen.getByRole('button', { name: 'models.defaultModel:Model A' })).toHaveProperty(
'disabled',
true,
);
fireEvent.click(screen.getByRole('button', { name: 'models.makeDefault:Model B' }));

expect(onChange).toHaveBeenCalledWith([
{ id: 'model-b', label: 'Model B' },
{ id: 'model-a', label: 'Model A' },
]);
});

it('keeps only the disabled default marker fully opaque while the form is busy', () => {
render(
<ModelSelection
selected={[
{ id: 'model-a', label: 'Model A' },
{ id: 'model-b', label: 'Model B' },
]}
onChange={vi.fn()}
disabled
/>,
);

expect(screen.getByRole('button', { name: 'models.defaultModel:Model A' }).className).toContain(
'disabled:opacity-100',
);
expect(
screen.getByRole('button', { name: 'models.makeDefault:Model B' }).className,
).not.toContain('disabled:opacity-100');
});

it("surfaces the fetch failure's own reason instead of swallowing it", async () => {
const onFetch = vi.fn().mockRejectedValue(new Error('401 Unauthorized — invalid api key'));
render(<Harness onFetch={onFetch} />);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// @vitest-environment jsdom

import type { Accounts } from '@linkcode/schema';
import { getAccounts, getProviderConfig, setAccounts, setProviderConfig } from '@linkcode/sdk';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProvidersSettingsPanel } from '../providers-settings';

const mocks = vi.hoisted(() => ({
mutateAccounts: vi.fn(),
mutateProviders: vi.fn(),
saveAccounts: vi.fn(),
saveProviders: vi.fn(),
toastAdd: vi.fn(),
translate: vi.fn((key: string) => key),
useData: vi.fn(),
useMutation: vi.fn(),
}));

vi.mock('../../../runtime/tayori', () => ({
useData: mocks.useData,
useMutation: mocks.useMutation,
}));

vi.mock('../../../agent-runtime/hooks', () => ({
useAgentRuntimes: () => ({ data: undefined }),
}));

vi.mock('../../../agent-runtime/onboarding', () => ({
useAgentRuntimeOnboarding: () => ({ cancelLogin: vi.fn() }),
}));

vi.mock('../add-flow', () => ({
AddAccountForm: () => null,
EditAccountForm: () => null,
ServiceCatalogView: () => null,
}));

vi.mock('../model-selection', () => ({
useModelSources: () => ({}),
}));

vi.mock('@linkcode/ui', () => ({
AccountDetail: () => null,
AccountList({
accounts,
onReorder,
}: {
accounts: Array<{ id: string; label: string }>;
onReorder?: (orderedIds: string[]) => void;
}) {
return (
<>
<output data-testid="account-order">{accounts.map(({ label }) => label).join(',')}</output>
<button
type="button"
onClick={() => onReorder?.([...accounts].reverse().map(({ id }) => id))}
>
reorder
</button>
</>
);
},
}));

vi.mock('coss-ui/components/toast', () => ({
toastManager: { add: mocks.toastAdd },
}));

vi.mock('use-intl', () => ({
useTranslations() {
return mocks.translate;
},
}));

const INITIAL_ACCOUNTS = [
{
id: 'account-a',
label: 'Account A',
service: 'anthropic-api',
credential: { type: 'api-key', key: 'anthropic-key' },
models: [{ id: 'claude-opus-5' }],
createdAt: 1,
},
{
id: 'account-b',
label: 'Account B',
service: 'deepseek',
credential: { type: 'api-key', key: 'deepseek-key' },
models: [{ id: 'deepseek-v4-pro' }],
createdAt: 2,
},
] satisfies Accounts;

let accountData: Accounts;
let daemonAccounts: Accounts;

beforeEach(() => {
accountData = [...INITIAL_ACCOUNTS];
daemonAccounts = [...INITIAL_ACCOUNTS];

mocks.mutateAccounts.mockImplementation((next?: Accounts) => {
accountData = next ?? daemonAccounts;
return Promise.resolve(accountData);
});
mocks.saveAccounts.mockImplementation(({ accounts }: { accounts: Accounts }) => {
daemonAccounts = accounts;
return Promise.resolve();
});
mocks.useData.mockImplementation((operation: unknown) => {
if (operation === getAccounts) {
return { data: accountData, isLoading: false, mutate: mocks.mutateAccounts };
}
if (operation === getProviderConfig) {
return { data: {}, mutate: mocks.mutateProviders };
}
throw new Error('Unexpected data operation');
});
mocks.useMutation.mockImplementation((operation: unknown) => {
if (operation === setAccounts) {
return { trigger: mocks.saveAccounts, isMutating: false };
}
if (operation === setProviderConfig) {
return { trigger: mocks.saveProviders, isMutating: false };
}
throw new Error('Unexpected mutation operation');
});
});

afterEach(() => {
cleanup();
vi.clearAllMocks();
});

describe('provider account ordering', () => {
it('persists the emitted order and reconciles it from daemon state', async () => {
const { rerender } = render(<ProvidersSettingsPanel />);
expect(screen.getByTestId('account-order').textContent).toBe('Account A,Account B');

fireEvent.click(screen.getByRole('button', { name: 'reorder' }));

await waitFor(() => expect(mocks.saveAccounts).toHaveBeenCalledTimes(1));
expect(
mocks.saveAccounts.mock.calls[0]?.[0].accounts.map(({ id }: Accounts[number]) => id),
).toEqual(['account-b', 'account-a']);
await waitFor(() => expect(mocks.mutateAccounts).toHaveBeenCalledTimes(2));
expect(mocks.mutateAccounts.mock.calls[0]?.[0].map(({ id }: Accounts[number]) => id)).toEqual([
'account-b',
'account-a',
]);
expect(mocks.mutateAccounts.mock.calls[1]).toEqual([]);

rerender(<ProvidersSettingsPanel />);
expect(screen.getByTestId('account-order').textContent).toBe('Account B,Account A');
});

it('restores the previous order and reports a rejected save', async () => {
mocks.saveAccounts.mockRejectedValueOnce(new Error('disk full'));
const { rerender } = render(<ProvidersSettingsPanel />);

fireEvent.click(screen.getByRole('button', { name: 'reorder' }));

await waitFor(() => expect(mocks.toastAdd).toHaveBeenCalledTimes(1));
expect(mocks.mutateAccounts).toHaveBeenCalledTimes(2);
expect(mocks.mutateAccounts.mock.calls[1]?.[0].map(({ id }: Accounts[number]) => id)).toEqual([
'account-a',
'account-b',
]);
expect(mocks.toastAdd).toHaveBeenCalledWith({
type: 'error',
title: 'reorderFailed',
description: 'disk full',
});

rerender(<ProvidersSettingsPanel />);
expect(screen.getByTestId('account-order').textContent).toBe('Account A,Account B');
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { CURATED_AGENT_MODELS } from '@linkcode/providers';
import type { AccountModel, AccountSecret, AgentKind } from '@linkcode/schema';
import { getAgentCatalog, probeAccountModels } from '@linkcode/sdk';
import { cn } from '@linkcode/ui';
import { Button } from 'coss-ui/components/button';
import { Checkbox } from 'coss-ui/components/checkbox';
import { Input } from 'coss-ui/components/input';
import { extractErrorMessage } from 'foxts/extract-error-message';
import { PlusIcon, RefreshCwIcon } from 'lucide-react';
import { PlusIcon, RefreshCwIcon, StarIcon } from 'lucide-react';
import { useState } from 'react';
import { useTranslations } from 'use-intl';
import { useMutation } from '../../runtime/tayori';
Expand Down Expand Up @@ -111,6 +112,10 @@ export function ModelSelection({
setDraft('');
};

const makeDefault = (model: AccountModel): void => {
onChange([model, ...selected.filter((candidate) => candidate.id !== model.id)]);
};

return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
Expand Down Expand Up @@ -148,22 +153,46 @@ export function ModelSelection({
{error !== undefined ? <p className="text-destructive text-xs">{error}</p> : null}
{listed.length > 0 ? (
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto rounded-lg border border-border p-2">
{listed.map((model) => (
<label
className="flex min-w-0 items-center gap-2 rounded-md px-1.5 py-(--density-row-py) hover:bg-muted/50"
key={model.id}
>
<Checkbox
checked={picked.has(model.id)}
disabled={disabled}
onCheckedChange={(next) => toggle(model, next)}
/>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{model.id}</span>
{model.label !== undefined ? (
<span className="shrink-0 text-label-tertiary text-xs">{model.label}</span>
) : null}
</label>
))}
{listed.map((model) => {
const isPicked = picked.has(model.id);
const isDefault = selected[0]?.id === model.id;
return (
<div
className="flex min-w-0 items-center rounded-md hover:bg-muted/50"
key={model.id}
>
<label className="flex min-w-0 flex-1 items-center gap-2 px-1.5 py-(--density-row-py)">
<Checkbox
checked={isPicked}
disabled={disabled}
onCheckedChange={(next) => toggle(model, next)}
/>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{model.id}</span>
{model.label === undefined ? null : (
<span className="shrink-0 text-label-tertiary text-xs">{model.label}</span>
)}
</label>
{isPicked ? (
<Button
type="button"
size="icon"
variant="ghost"
disabled={disabled || isDefault}
aria-label={t(isDefault ? 'models.defaultModel' : 'models.makeDefault', {
model: model.label ?? model.id,
})}
className={cn(
'me-0.5 size-7 shrink-0 text-label-tertiary',
isDefault && 'disabled:opacity-100',
)}
onClick={() => makeDefault(model)}
>
<StarIcon className={isDefault ? 'size-3.5 fill-current' : 'size-3.5'} />
</Button>
) : null}
</div>
);
})}
</div>
) : null}
<div className="flex gap-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
DialogTitle,
} from 'coss-ui/components/dialog';
import { Skeleton } from 'coss-ui/components/skeleton';
import { toastManager } from 'coss-ui/components/toast';
import { extractErrorMessage } from 'foxts/extract-error-message';
import { useTranslations } from 'use-intl';
import { useAgentRuntimes } from '../../agent-runtime/hooks';
import { useAgentRuntimeOnboarding } from '../../agent-runtime/onboarding';
Expand Down Expand Up @@ -78,6 +80,28 @@ export function ProvidersSettingsPanel({
void applyProviders(withAccountEnabled(providers ?? {}, kind, selected.id, enabled, pool));
};

const handleReorder = async (orderedIds: string[]): Promise<void> => {
const reordered = orderedIds.flatMap((id) => {
const account = accountsById.get(id);
return account ? [account] : [];
});
if (reordered.length !== pool.length) return;

await mutateAccounts(reordered, { revalidate: false });
try {
await saveAccounts.trigger({ accounts: reordered });
} catch (error) {
await mutateAccounts(pool, { revalidate: false });
toastManager.add({
type: 'error',
title: t('reorderFailed'),
description: extractErrorMessage(error, false),
});
return;
}
Comment thread
lucas77778 marked this conversation as resolved.
await mutateAccounts();
};

// Every account joins the pool the same way. A subscription used to bind itself to its agent on
// the way in; with no default to claim, adding one is adding one.
const handleAdd = async (account: Account): Promise<void> => {
Expand Down Expand Up @@ -123,7 +147,11 @@ export function ProvidersSettingsPanel({
<AccountList
{...accountList}
loading={accountsLoading}
reorderDisabled={busy}
onSelect={select}
onReorder={(orderedIds) => {
void handleReorder(orderedIds);
}}
onAdd={startAdd}
onUseLinkCodeGateway={
linkCodeGateway ? () => pickService(LINKCODE_GATEWAY_SERVICE_ID) : undefined
Expand Down
6 changes: 6 additions & 0 deletions packages/presentation/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,10 @@ export const en = {
hint: 'Connect subscriptions, AI gateways, or custom endpoints to your agents; one account can back several agents, and each agent uses one account at a time.',
searchPlaceholder: 'Search accounts…',
addAccount: 'Add account',
orderHint:
'Drag accounts to set their priority. New tasks use the first model from the first compatible account.',
reorderAccount: 'Reorder {label}',
reorderFailed: 'Could not save account order',
customService: 'Custom endpoint',
noMatches: 'No matching accounts.',
emptyTitle: 'No accounts yet',
Expand Down Expand Up @@ -1080,6 +1084,8 @@ export const en = {
refresh: 'Fetch list',
fetchFailed: 'Could not read the model list',
secretFirst: 'Enter the key first, then fetch the model list',
defaultModel: '{model} is the default model',
makeDefault: 'Make {model} the default model',
required: 'Select at least one model before adding the account.',
add: 'Add',
addPlaceholder: 'Add a model id by hand',
Expand Down
Loading
Loading