From f82f5fdabad083eea2237d3d8828c33e2ac9ebfa Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Tue, 18 Aug 2026 12:07:06 +0000 Subject: [PATCH 1/3] feat(providers): persist account ordering --- .../settings/providers/providers-settings.tsx | 19 ++ packages/presentation/i18n/src/locales/en.ts | 3 + .../presentation/i18n/src/locales/zh-cn.ts | 2 + .../__tests__/account-master-list.test.tsx | 108 ++++++++++ .../shell/providers/account-master-list.tsx | 197 ++++++++++++------ 5 files changed, 262 insertions(+), 67 deletions(-) create mode 100644 packages/presentation/ui/src/shell/__tests__/account-master-list.test.tsx diff --git a/packages/client/workbench/src/settings/providers/providers-settings.tsx b/packages/client/workbench/src/settings/providers/providers-settings.tsx index da3d7424..4d78cffb 100644 --- a/packages/client/workbench/src/settings/providers/providers-settings.tsx +++ b/packages/client/workbench/src/settings/providers/providers-settings.tsx @@ -78,6 +78,21 @@ 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 { + await mutateAccounts(pool, { revalidate: false }); + } + }; + // 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 +138,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 6afb6446..372d80da 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1047,6 +1047,9 @@ 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}', customService: 'Custom endpoint', noMatches: 'No matching accounts.', emptyTitle: 'No accounts yet', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 6dda1827..87bda1e7 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1021,6 +1021,8 @@ export const zhCN = { hint: '把订阅、AI 网关或自定义端点接入你的智能体;一个账号可接入多个智能体,每个智能体同一时刻使用一个账号。', searchPlaceholder: '搜索账号…', addAccount: '添加账号', + orderHint: '拖动账号调整优先级;新建任务默认使用首个兼容账号的首个模型。', + reorderAccount: '调整{label}的顺序', customService: '自定义端点', noMatches: '没有匹配的账号。', emptyTitle: '尚未添加账号', 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 00000000..e470777e --- /dev/null +++ b/packages/presentation/ui/src/shell/__tests__/account-master-list.test.tsx @@ -0,0 +1,108 @@ +// @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): string { + return 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.getAllByRole('button', { name: 'reorderAccount' })).toHaveLength(2); + 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( + , + ); + + fireEvent.change(screen.getByPlaceholderText('searchPlaceholder'), { + target: { value: 'Account A' }, + }); + expect(screen.getByRole('button', { name: 'reorderAccount' })).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 369e7a94..c9574329 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 { SIDEBAR_SORTABLE_SENSORS } from '../sidebar'; 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({
+ {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 ( +
  • +
    + + +
    +
  • + ); +} From b3aa8543fd9070957536532fefe9af8f9e6338cf Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Tue, 18 Aug 2026 12:07:27 +0000 Subject: [PATCH 2/3] feat(providers): allow choosing default model --- .../__tests__/model-selection.test.tsx | 24 ++++++++ .../settings/providers/model-selection.tsx | 61 +++++++++++++------ packages/presentation/i18n/src/locales/en.ts | 2 + .../presentation/i18n/src/locales/zh-cn.ts | 2 + 4 files changed, 71 insertions(+), 18 deletions(-) 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 6368bc97..19a7bd91 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 @@ -84,6 +84,30 @@ 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' })).toHaveProperty( + 'disabled', + true, + ); + fireEvent.click(screen.getByRole('button', { name: 'models.makeDefault' })); + + expect(onChange).toHaveBeenCalledWith([ + { id: 'model-b', label: 'Model B' }, + { id: 'model-a', label: 'Model A' }, + ]); + }); + 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/model-selection.tsx b/packages/client/workbench/src/settings/providers/model-selection.tsx index 1f57a60f..a2f4df5a 100644 --- a/packages/client/workbench/src/settings/providers/model-selection.tsx +++ b/packages/client/workbench/src/settings/providers/model-selection.tsx @@ -5,7 +5,7 @@ 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'; @@ -109,6 +109,10 @@ export function ModelSelection({ setDraft(''); }; + const makeDefault = (model: AccountModel): void => { + onChange([model, ...selected.filter((candidate) => candidate.id !== model.id)]); + }; + return (
    @@ -131,25 +135,46 @@ export function ModelSelection({

    {onFetch ? t('models.hint') : t('models.hintUnlistable')}

    - {error !== undefined ?

    {error}

    : null} + {error === undefined ? null :

    {error}

    } {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/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 372d80da..f54ec3e2 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1083,6 +1083,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', 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 87bda1e7..c3d0bdd9 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1055,6 +1055,8 @@ export const zhCN = { refresh: '获取列表', fetchFailed: '获取模型列表失败', secretFirst: '请先填写密钥,再获取模型列表', + defaultModel: '{model}是默认模型', + makeDefault: '将{model}设为默认模型', add: '添加', addPlaceholder: '手动添加模型 ID', }, From a1840e5ed9d16e4059df18459f7926e82cc70089 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Wed, 19 Aug 2026 06:56:28 +0000 Subject: [PATCH 3/3] fix(providers): address account ordering review --- .../__tests__/model-selection.test.tsx | 29 ++- .../__tests__/providers-settings.test.tsx | 178 ++++++++++++++++++ .../settings/providers/model-selection.tsx | 6 +- .../settings/providers/providers-settings.tsx | 11 +- packages/presentation/i18n/src/locales/en.ts | 1 + .../presentation/i18n/src/locales/zh-cn.ts | 1 + .../__tests__/account-master-list.test.tsx | 15 +- .../shell/providers/account-master-list.tsx | 6 +- .../ui/src/shell/sidebar/index.ts | 1 - .../shell/{sidebar => }/sortable-sensors.ts | 10 +- .../ui/src/shell/threads-view.tsx | 4 +- 11 files changed, 238 insertions(+), 24 deletions(-) create mode 100644 packages/client/workbench/src/settings/providers/__tests__/providers-settings.test.tsx rename packages/presentation/ui/src/shell/{sidebar => }/sortable-sensors.ts (57%) 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 19a7bd91..4cd4314a 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', () => ({ @@ -96,11 +97,11 @@ describe('ModelSelection', () => { />, ); - expect(screen.getByRole('button', { name: 'models.defaultModel' })).toHaveProperty( + expect(screen.getByRole('button', { name: 'models.defaultModel:Model A' })).toHaveProperty( 'disabled', true, ); - fireEvent.click(screen.getByRole('button', { name: 'models.makeDefault' })); + fireEvent.click(screen.getByRole('button', { name: 'models.makeDefault:Model B' })); expect(onChange).toHaveBeenCalledWith([ { id: 'model-b', label: 'Model B' }, @@ -108,6 +109,26 @@ describe('ModelSelection', () => { ]); }); + 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 00000000..88dae40c --- /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 a2f4df5a..668da9c3 100644 --- a/packages/client/workbench/src/settings/providers/model-selection.tsx +++ b/packages/client/workbench/src/settings/providers/model-selection.tsx @@ -1,6 +1,7 @@ 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'; @@ -166,7 +167,10 @@ export function ModelSelection({ aria-label={t(isDefault ? 'models.defaultModel' : 'models.makeDefault', { model: model.label ?? model.id, })} - className="me-0.5 size-7 shrink-0 text-label-tertiary disabled:opacity-100" + className={cn( + 'me-0.5 size-7 shrink-0 text-label-tertiary', + isDefault && 'disabled:opacity-100', + )} onClick={() => makeDefault(model)} > diff --git a/packages/client/workbench/src/settings/providers/providers-settings.tsx b/packages/client/workbench/src/settings/providers/providers-settings.tsx index 4d78cffb..f6c90ac1 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'; @@ -88,9 +90,16 @@ export function ProvidersSettingsPanel({ await mutateAccounts(reordered, { revalidate: false }); try { await saveAccounts.trigger({ accounts: reordered }); - } catch { + } 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 diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index f54ec3e2..8e8c58fe 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1050,6 +1050,7 @@ export const en = { 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', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index c3d0bdd9..2ac8f8e6 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1023,6 +1023,7 @@ export const zhCN = { addAccount: '添加账号', orderHint: '拖动账号调整优先级;新建任务默认使用首个兼容账号的首个模型。', reorderAccount: '调整{label}的顺序', + reorderFailed: '保存账号顺序失败', customService: '自定义端点', noMatches: '没有匹配的账号。', emptyTitle: '尚未添加账号', 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 index e470777e..345c7b92 100644 --- a/packages/presentation/ui/src/shell/__tests__/account-master-list.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/account-master-list.test.tsx @@ -9,8 +9,9 @@ function reversed(items: string[]): string[] { return [...items].reverse(); } -function passthrough(key: string): string { - return key; +function passthrough(key: string, values?: Record): string { + const interpolation = values ? Object.values(values).join(',') : ''; + return interpolation ? `${key}:${interpolation}` : key; } function translations(): typeof passthrough { @@ -78,7 +79,8 @@ describe('AccountList', () => { />, ); - expect(screen.getAllByRole('button', { name: 'reorderAccount' })).toHaveLength(2); + 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 }); @@ -97,10 +99,15 @@ describe('AccountList', () => { />, ); + expect(screen.getByText('orderHint')).toBeTruthy(); fireEvent.change(screen.getByPlaceholderText('searchPlaceholder'), { target: { value: 'Account A' }, }); - expect(screen.getByRole('button', { name: 'reorderAccount' })).toHaveProperty('disabled', true); + 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 c9574329..82bc4314 100644 --- a/packages/presentation/ui/src/shell/providers/account-master-list.tsx +++ b/packages/presentation/ui/src/shell/providers/account-master-list.tsx @@ -12,7 +12,7 @@ import { ChevronRightIcon, GripVerticalIcon, PlusIcon } from 'lucide-react'; import { useState } from 'react'; import { useTranslations } from 'use-intl'; import { ServiceIcon } from '../service-icon'; -import { SIDEBAR_SORTABLE_SENSORS } from '../sidebar'; +import { SORTABLE_SENSORS } from '../sortable-sensors'; import type { ProviderAccountRouting } from './routing'; export interface ProviderAccountListItem { @@ -108,11 +108,11 @@ export function AccountList({
    - {accounts.length > 1 ? ( + {canReorder && accounts.length > 1 ? (

    {t('orderHint')}

    ) : null} - +
      {loading && accounts.length === 0 ? ( <> diff --git a/packages/presentation/ui/src/shell/sidebar/index.ts b/packages/presentation/ui/src/shell/sidebar/index.ts index a5729b60..ae2b019a 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 7c66874e..d86ee96c 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 85cbbd78..d09ca8bc 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 (