diff --git a/packages/client/workbench/src/settings/providers/__tests__/model-selection.test.tsx b/packages/client/workbench/src/settings/providers/__tests__/model-selection.test.tsx index 6368bc97d..4cd4314ad 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/model-selection.test.tsx +++ b/packages/client/workbench/src/settings/providers/__tests__/model-selection.test.tsx @@ -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 { + const interpolation = values ? Object.values(values).join(',') : ''; + return interpolation ? `${key}:${interpolation}` : key; } vi.mock('use-intl', () => ({ @@ -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( + , + ); + + 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( + , + ); + + 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(); diff --git a/packages/client/workbench/src/settings/providers/__tests__/providers-settings.test.tsx b/packages/client/workbench/src/settings/providers/__tests__/providers-settings.test.tsx new file mode 100644 index 000000000..88dae40ca --- /dev/null +++ b/packages/client/workbench/src/settings/providers/__tests__/providers-settings.test.tsx @@ -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 ( + <> + {accounts.map(({ label }) => label).join(',')} + + + ); + }, +})); + +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(); + 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(); + 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(); + + 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(); + expect(screen.getByTestId('account-order').textContent).toBe('Account A,Account B'); + }); +}); diff --git a/packages/client/workbench/src/settings/providers/model-selection.tsx b/packages/client/workbench/src/settings/providers/model-selection.tsx index 3554db750..41737491b 100644 --- a/packages/client/workbench/src/settings/providers/model-selection.tsx +++ b/packages/client/workbench/src/settings/providers/model-selection.tsx @@ -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'; @@ -111,6 +112,10 @@ export function ModelSelection({ setDraft(''); }; + const makeDefault = (model: AccountModel): void => { + onChange([model, ...selected.filter((candidate) => candidate.id !== model.id)]); + }; + return (
@@ -148,22 +153,46 @@ export function ModelSelection({ {error !== undefined ?

{error}

: null} {listed.length > 0 ? (
- {listed.map((model) => ( - - ))} + {listed.map((model) => { + const isPicked = picked.has(model.id); + const isDefault = selected[0]?.id === model.id; + return ( +
+ + {isPicked ? ( + + ) : null} +
+ ); + })}
) : null}
diff --git a/packages/client/workbench/src/settings/providers/providers-settings.tsx b/packages/client/workbench/src/settings/providers/providers-settings.tsx index da3d74243..f6c90ac10 100644 --- a/packages/client/workbench/src/settings/providers/providers-settings.tsx +++ b/packages/client/workbench/src/settings/providers/providers-settings.tsx @@ -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'; @@ -78,6 +80,28 @@ export function ProvidersSettingsPanel({ void applyProviders(withAccountEnabled(providers ?? {}, kind, selected.id, enabled, pool)); }; + const handleReorder = async (orderedIds: string[]): Promise => { + 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; + } + 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 => { @@ -123,7 +147,11 @@ export function ProvidersSettingsPanel({ { + void handleReorder(orderedIds); + }} onAdd={startAdd} onUseLinkCodeGateway={ linkCodeGateway ? () => pickService(LINKCODE_GATEWAY_SERVICE_ID) : undefined diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index c908be79b..8351ea6f9 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -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', @@ -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', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 783a0aa41..82660b602 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1021,6 +1021,9 @@ export const zhCN = { hint: '把订阅、AI 网关或自定义端点接入你的智能体;一个账号可接入多个智能体,每个智能体同一时刻使用一个账号。', searchPlaceholder: '搜索账号…', addAccount: '添加账号', + orderHint: '拖动账号调整优先级;新建任务默认使用首个兼容账号的首个模型。', + reorderAccount: '调整{label}的顺序', + reorderFailed: '保存账号顺序失败', customService: '自定义端点', noMatches: '没有匹配的账号。', emptyTitle: '尚未添加账号', @@ -1053,6 +1056,8 @@ export const zhCN = { refresh: '获取列表', fetchFailed: '获取模型列表失败', secretFirst: '请先填写密钥,再获取模型列表', + defaultModel: '{model}是默认模型', + makeDefault: '将{model}设为默认模型', required: '至少选择一个模型后才能添加账号。', add: '添加', addPlaceholder: '手动添加模型 ID', diff --git a/packages/presentation/ui/src/shell/__tests__/account-master-list.test.tsx b/packages/presentation/ui/src/shell/__tests__/account-master-list.test.tsx new file mode 100644 index 000000000..345c7b92a --- /dev/null +++ b/packages/presentation/ui/src/shell/__tests__/account-master-list.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ProviderAccountListItem } from '../providers/account-master-list'; +import { AccountList } from '../providers/account-master-list'; + +function reversed(items: string[]): string[] { + return [...items].reverse(); +} + +function passthrough(key: string, values?: Record): string { + const interpolation = values ? Object.values(values).join(',') : ''; + return interpolation ? `${key}:${interpolation}` : key; +} + +function translations(): typeof passthrough { + return passthrough; +} + +const dnd = vi.hoisted(() => ({ + onDragEnd: undefined as undefined | ((event: { canceled: boolean }) => void), + move: vi.fn(reversed), +})); + +vi.mock('@dnd-kit/helpers', () => ({ move: dnd.move })); +vi.mock('@dnd-kit/react', () => ({ + DragDropProvider({ + children, + onDragEnd, + }: { + children: React.ReactNode; + onDragEnd: (event: { canceled: boolean }) => void; + }) { + dnd.onDragEnd = onDragEnd; + return children; + }, +})); +vi.mock('@dnd-kit/react/sortable', () => ({ + useSortable: () => ({ + ref: vi.fn(), + handleRef: vi.fn(), + isDragging: false, + }), +})); +vi.mock('use-intl', () => ({ useTranslations: translations })); + +afterEach(() => { + cleanup(); + dnd.move.mockClear(); + dnd.onDragEnd = undefined; +}); + +const ACCOUNTS: ProviderAccountListItem[] = [ + { + id: 'account-a', + label: 'Account A', + credentialType: 'api-key', + boundAgents: [], + }, + { + id: 'account-b', + label: 'Account B', + credentialType: 'api-key', + boundAgents: [], + }, +]; + +describe('AccountList', () => { + it('emits the full reordered account id list when a drag ends', () => { + const onReorder = vi.fn(); + render( + , + ); + + expect(screen.getByRole('button', { name: 'reorderAccount:Account A' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'reorderAccount:Account B' })).toBeTruthy(); + act(() => dnd.onDragEnd?.({ canceled: false })); + + expect(dnd.move).toHaveBeenCalledWith(['account-a', 'account-b'], { canceled: false }); + expect(onReorder).toHaveBeenCalledWith(['account-b', 'account-a']); + }); + + it('disables reordering while the account list is filtered', () => { + const onReorder = vi.fn(); + render( + , + ); + + expect(screen.getByText('orderHint')).toBeTruthy(); + fireEvent.change(screen.getByPlaceholderText('searchPlaceholder'), { + target: { value: 'Account A' }, + }); + expect(screen.queryByText('orderHint')).toBeNull(); + expect(screen.getByRole('button', { name: 'reorderAccount:Account A' })).toHaveProperty( + 'disabled', + true, + ); + act(() => dnd.onDragEnd?.({ canceled: false })); + + expect(onReorder).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/presentation/ui/src/shell/providers/account-master-list.tsx b/packages/presentation/ui/src/shell/providers/account-master-list.tsx index 369e7a94a..82bc4314b 100644 --- a/packages/presentation/ui/src/shell/providers/account-master-list.tsx +++ b/packages/presentation/ui/src/shell/providers/account-master-list.tsx @@ -1,13 +1,18 @@ +import { move } from '@dnd-kit/helpers'; +import type { DragEndEvent } from '@dnd-kit/react'; +import { DragDropProvider } from '@dnd-kit/react'; +import { useSortable } from '@dnd-kit/react/sortable'; import type { AgentKind } from '@linkcode/schema'; import { Badge } from 'coss-ui/components/badge'; import { Button } from 'coss-ui/components/button'; import { Card } from 'coss-ui/components/card'; import { Input } from 'coss-ui/components/input'; import { Skeleton } from 'coss-ui/components/skeleton'; -import { ChevronRightIcon, PlusIcon } from 'lucide-react'; +import { ChevronRightIcon, GripVerticalIcon, PlusIcon } from 'lucide-react'; import { useState } from 'react'; import { useTranslations } from 'use-intl'; import { ServiceIcon } from '../service-icon'; +import { SORTABLE_SENSORS } from '../sortable-sensors'; import type { ProviderAccountRouting } from './routing'; export interface ProviderAccountListItem { @@ -29,18 +34,21 @@ export interface ProviderAccountListViewModel { export function AccountList({ accounts, loading, + reorderDisabled = false, onSelect, + onReorder, onAdd, onUseLinkCodeGateway, }: ProviderAccountListViewModel & { loading: boolean; + reorderDisabled?: boolean; onSelect: (id: string) => void; + onReorder?: (orderedIds: string[]) => void; onAdd: () => void; /** Explicit first-party path shown only when no third-party account or login is available. */ onUseLinkCodeGateway?: () => void; }): React.ReactNode { const t = useTranslations('settings.providers'); - const tAgent = useTranslations('workbench.agentKind'); const [query, setQuery] = useState(''); const credentialLabel = (account: ProviderAccountListItem): string => { @@ -74,6 +82,14 @@ export function AccountList({ .includes(needle), ) : accounts; + const canReorder = onReorder !== undefined && !reorderDisabled && needle === ''; + + function handleDragEnd(event: DragEndEvent): void { + if (!canReorder || event.canceled) return; + const current = accounts.map(({ id }) => id); + const reordered = move(current, event); + if (reordered.some((id, index) => id !== current[index])) onReorder(reordered); + } return (
@@ -92,75 +108,122 @@ export function AccountList({
+ {canReorder && accounts.length > 1 ? ( +

{t('orderHint')}

+ ) : null} -
    - {loading && accounts.length === 0 ? ( - <> -
  • - + +
      + {loading && accounts.length === 0 ? ( + <> +
    • + +
    • +
    • + +
    • + + ) : null} + {rows.map((account, index) => ( + + ))} + {!loading && needle && rows.length === 0 ? ( +
    • + {t('noMatches')}
    • -
    • - + ) : null} + {!loading && needle === '' && accounts.length === 0 ? ( +
    • + {t('emptyTitle')} + {t('emptyHint')} + {onUseLinkCodeGateway ? ( + + ) : null}
    • - - ) : null} - {rows.map((account) => { - const detailLine = accountDetailLine(account); - return ( -
    • - -
    • - ); - })} - {!loading && needle && rows.length === 0 ? ( -
    • - {t('noMatches')} -
    • - ) : null} - {!loading && needle === '' && accounts.length === 0 ? ( -
    • - {t('emptyTitle')} - {t('emptyHint')} - {onUseLinkCodeGateway ? ( - - ) : null} -
    • - ) : null} -
    + ) : null} +
+
); } + +function AccountRow({ + account, + detailLine, + credentialLabel, + index, + reorderEnabled, + onSelect, +}: { + account: ProviderAccountListItem; + detailLine: string | undefined; + credentialLabel: string; + index: number; + reorderEnabled: boolean; + onSelect: (id: string) => void; +}): React.ReactNode { + const t = useTranslations('settings.providers'); + const tAgent = useTranslations('workbench.agentKind'); + const { ref, handleRef, isDragging } = useSortable({ + id: account.id, + index, + type: 'provider-account', + accept: 'provider-account', + disabled: !reorderEnabled, + }); + + return ( +
  • +
    + + +
    +
  • + ); +} diff --git a/packages/presentation/ui/src/shell/sidebar/index.ts b/packages/presentation/ui/src/shell/sidebar/index.ts index a5729b60b..ae2b019a6 100644 --- a/packages/presentation/ui/src/shell/sidebar/index.ts +++ b/packages/presentation/ui/src/shell/sidebar/index.ts @@ -11,7 +11,6 @@ export { PinnedSection } from './pinned-section'; export { SectionAccordionTrigger } from './section-header'; export type { ShowMoreToggleProps } from './show-more-toggle'; export { ShowMoreToggle } from './show-more-toggle'; -export { SIDEBAR_SORTABLE_SENSORS } from './sortable-sensors'; export type { SidebarSectionKey, ThreadGroupActions, diff --git a/packages/presentation/ui/src/shell/sidebar/sortable-sensors.ts b/packages/presentation/ui/src/shell/sortable-sensors.ts similarity index 57% rename from packages/presentation/ui/src/shell/sidebar/sortable-sensors.ts rename to packages/presentation/ui/src/shell/sortable-sensors.ts index 7c66874ef..d86ee96c6 100644 --- a/packages/presentation/ui/src/shell/sidebar/sortable-sensors.ts +++ b/packages/presentation/ui/src/shell/sortable-sensors.ts @@ -9,14 +9,8 @@ function isTextInputTarget(target: EventTarget | null): boolean { ); } -/** - * Overrides two PointerSensor defaults that assume non-interactive drag handles: default - * `preventActivation` would make the button-built rows/headers undraggable, so only text inputs - * stay protected (drag-to-select in the rename field must not start a group drag); and instant - * in-handle activation would swallow plain clicks, so a 5px distance threshold keeps clicks as - * clicks while touch keeps a hold delay so scrolling over rows doesn't start drags. - */ -export const SIDEBAR_SORTABLE_SENSORS: Sensors = [ +/** Preserve text editing and plain clicks while requiring deliberate pointer or touch drags. */ +export const SORTABLE_SENSORS: Sensors = [ PointerSensor.configure({ activationConstraints: (event) => event.pointerType === 'touch' diff --git a/packages/presentation/ui/src/shell/threads-view.tsx b/packages/presentation/ui/src/shell/threads-view.tsx index 85cbbd78e..d09ca8bce 100644 --- a/packages/presentation/ui/src/shell/threads-view.tsx +++ b/packages/presentation/ui/src/shell/threads-view.tsx @@ -17,10 +17,10 @@ import { PinnedSection, SectionAccordionTrigger, ShowMoreToggle, - SIDEBAR_SORTABLE_SENSORS, ThreadGroupHeader, ThreadRow, } from './sidebar'; +import { SORTABLE_SENSORS } from './sortable-sensors'; const SIDEBAR_SECTIONS = [ 'pinned', @@ -154,7 +154,7 @@ export function ThreadsView({ return (