diff --git a/desktop/src/api/providers.ts b/desktop/src/api/providers.ts index a99c9734..08292399 100644 --- a/desktop/src/api/providers.ts +++ b/desktop/src/api/providers.ts @@ -61,6 +61,10 @@ export const providersApi = { return api.post<{ ok: true }>('/api/providers/official') }, + reorder(orderedIds: string[]) { + return api.put<{ providers: SavedProvider[] }>('/api/providers/reorder', { orderedIds }) + }, + test(id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string }) { return api.post(`/api/providers/${id}/test`, overrides) }, diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 38dae10e..3f224c6a 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -381,6 +381,7 @@ export const en = { 'settings.providers.setDefault': 'Set default', 'settings.providers.test': 'Test', 'settings.providers.edit': 'Edit', + 'settings.providers.dragToReorder': 'Drag to reorder', 'settings.providers.requestFailed': 'Request failed', 'settings.providers.addTitle': 'Add Provider', 'settings.providers.editTitle': 'Edit Provider', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index ab73d5c9..61ee9c35 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -383,6 +383,7 @@ export const jp: Record = { 'settings.providers.setDefault': 'デフォルトに設定', 'settings.providers.test': 'テスト', 'settings.providers.edit': '編集', + 'settings.providers.dragToReorder': 'ドラッグして並べ替え', 'settings.providers.requestFailed': 'リクエストに失敗しました', 'settings.providers.addTitle': 'プロバイダーを追加', 'settings.providers.editTitle': 'プロバイダーを編集', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 096fd5d9..0633078d 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -383,6 +383,7 @@ export const kr: Record = { 'settings.providers.setDefault': '기본값으로 설정', 'settings.providers.test': '테스트', 'settings.providers.edit': '편집', + 'settings.providers.dragToReorder': '드래그하여 순서 변경', 'settings.providers.requestFailed': '요청에 실패했습니다', 'settings.providers.addTitle': '공급자 추가', 'settings.providers.editTitle': '공급자 편집', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index b75a3d22..d30f243f 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -383,6 +383,7 @@ export const zh: Record = { 'settings.providers.setDefault': '設為預設', 'settings.providers.test': '測試', 'settings.providers.edit': '編輯', + 'settings.providers.dragToReorder': '拖曳排序', 'settings.providers.requestFailed': '請求失敗', 'settings.providers.addTitle': '新增服務商', 'settings.providers.editTitle': '編輯服務商', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index ea308130..093182ce 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -383,6 +383,7 @@ export const zh: Record = { 'settings.providers.setDefault': '设为默认', 'settings.providers.test': '测试', 'settings.providers.edit': '编辑', + 'settings.providers.dragToReorder': '拖动排序', 'settings.providers.requestFailed': '请求失败', 'settings.providers.addTitle': '添加服务商', 'settings.providers.editTitle': '编辑服务商', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 36e64a91..01a04f05 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -229,6 +229,7 @@ function ProviderSettings() { fetchProviders, fetchPresets, deleteProvider, + reorderProviders, activateProvider, activateOfficial, testProvider, @@ -240,6 +241,8 @@ function ProviderSettings() { const [pendingDeleteProvider, setPendingDeleteProvider] = useState(null) const [isDeletingProvider, setIsDeletingProvider] = useState(false) const [testResults, setTestResults] = useState>({}) + const [draggingId, setDraggingId] = useState(null) + const [dragOverId, setDragOverId] = useState(null) useEffect(() => { void fetchProviders() @@ -289,6 +292,46 @@ function ProviderSettings() { await fetchSettings() } + const handleProviderDragStart = (event: React.DragEvent, id: string) => { + setDraggingId(id) + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', id) + } + + const handleProviderDragOver = (event: React.DragEvent, overId: string) => { + if (!draggingId || draggingId === overId) return + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + if (dragOverId !== overId) setDragOverId(overId) + } + + const clearProviderDragState = () => { + setDraggingId(null) + setDragOverId(null) + } + + const handleProviderDrop = (event: React.DragEvent, targetId: string) => { + event.preventDefault() + const sourceId = draggingId || event.dataTransfer.getData('text/plain') + if (!sourceId || sourceId === targetId) { + clearProviderDragState() + return + } + + const ids = providers.map((p) => p.id) + const from = ids.indexOf(sourceId) + const to = ids.indexOf(targetId) + if (from === -1 || to === -1) { + clearProviderDragState() + return + } + + ids.splice(from, 1) + ids.splice(to, 0, sourceId) + clearProviderDragState() + void reorderProviders(ids) + } + const isClaudeOfficialActive = hasLoadedProviders && activeId === null const isOpenAIOfficialActive = hasLoadedProviders && activeId === OPENAI_OFFICIAL_PROVIDER_ID @@ -382,12 +425,28 @@ function ProviderSettings() { return (
handleProviderDragOver(e, provider.id)} + onDrop={(e) => handleProviderDrop(e, provider.id)} + onDragLeave={() => setDragOverId((cur) => (cur === provider.id ? null : cur))} + className={`relative flex items-center gap-3 px-4 py-3.5 rounded-xl border transition-all group ${ isActive ? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]' : 'border-[var(--color-border)] hover:border-[var(--color-border-focus)]' + } ${draggingId === provider.id ? 'opacity-50' : ''} ${ + dragOverId === provider.id ? 'border-[var(--color-brand)] border-dashed' : '' }`} > + handleProviderDragStart(e, provider.id)} + onDragEnd={clearProviderDragState} + role="button" + aria-label={t('settings.providers.dragToReorder')} + title={t('settings.providers.dragToReorder')} + className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] flex-shrink-0 cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity hover:text-[var(--color-text-secondary)]" + > + drag_indicator +
diff --git a/desktop/src/stores/providerStore.test.ts b/desktop/src/stores/providerStore.test.ts index 7d16a7c0..32ef9ac1 100644 --- a/desktop/src/stores/providerStore.test.ts +++ b/desktop/src/stores/providerStore.test.ts @@ -19,6 +19,7 @@ const { create: vi.fn(), update: vi.fn(), delete: vi.fn(), + reorder: vi.fn(), activate: vi.fn(), activateOfficial: vi.fn(), test: vi.fn(), @@ -181,3 +182,69 @@ describe('providerStore runtime refresh', () => { expect(settingsFetchAllMock).toHaveBeenCalled() }) }) + +describe('providerStore reorderProviders', () => { + beforeEach(() => { + vi.clearAllMocks() + chatStoreState.sessions = {} + runtimeStoreState.selections = {} + providersApiMock.list.mockResolvedValue({ providers: [], activeId: null }) + }) + + it('optimistically applies the new order before the request resolves', async () => { + const a = makeProvider({ id: 'a', name: 'A' }) + const b = makeProvider({ id: 'b', name: 'B' }) + const c = makeProvider({ id: 'c', name: 'C' }) + + let resolveReorder: (value: { providers: SavedProvider[] }) => void = () => {} + providersApiMock.reorder.mockReturnValue( + new Promise((resolve) => { + resolveReorder = resolve + }), + ) + + const { useProviderStore } = await import('./providerStore') + useProviderStore.setState({ providers: [a, b, c], activeId: null }) + + const promise = useProviderStore.getState().reorderProviders(['c', 'a', 'b']) + + // Optimistic update is visible immediately, before the API resolves. + expect(useProviderStore.getState().providers.map((p) => p.id)).toEqual(['c', 'a', 'b']) + + resolveReorder({ providers: [c, a, b] }) + await promise + + expect(providersApiMock.reorder).toHaveBeenCalledWith(['c', 'a', 'b']) + expect(useProviderStore.getState().providers.map((p) => p.id)).toEqual(['c', 'a', 'b']) + }) + + it('rolls back to the previous order when the request fails', async () => { + const a = makeProvider({ id: 'a', name: 'A' }) + const b = makeProvider({ id: 'b', name: 'B' }) + providersApiMock.reorder.mockRejectedValue(new Error('network down')) + + const { useProviderStore } = await import('./providerStore') + useProviderStore.setState({ providers: [a, b], activeId: null }) + + await useProviderStore.getState().reorderProviders(['b', 'a']) + + // Rolls back to the pre-drag order and surfaces the error. + expect(useProviderStore.getState().providers.map((p) => p.id)).toEqual(['a', 'b']) + expect(useProviderStore.getState().error).toBe('network down') + }) + + it('refetches instead of reordering when the id set is stale', async () => { + const a = makeProvider({ id: 'a', name: 'A' }) + const b = makeProvider({ id: 'b', name: 'B' }) + providersApiMock.list.mockResolvedValue({ providers: [a, b], activeId: null }) + + const { useProviderStore } = await import('./providerStore') + useProviderStore.setState({ providers: [a, b], activeId: null }) + + // Only one id supplied — the list changed under us, so don't persist a bad order. + await useProviderStore.getState().reorderProviders(['a']) + + expect(providersApiMock.reorder).not.toHaveBeenCalled() + expect(providersApiMock.list).toHaveBeenCalled() + }) +}) diff --git a/desktop/src/stores/providerStore.ts b/desktop/src/stores/providerStore.ts index e0f52833..55afb3f2 100644 --- a/desktop/src/stores/providerStore.ts +++ b/desktop/src/stores/providerStore.ts @@ -34,6 +34,7 @@ type ProviderStore = { createProvider: (input: CreateProviderInput) => Promise updateProvider: (id: string, input: UpdateProviderInput) => Promise deleteProvider: (id: string) => Promise + reorderProviders: (orderedIds: string[]) => Promise activateProvider: (id: string) => Promise activateOfficial: () => Promise testProvider: (id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string }) => Promise @@ -145,6 +146,29 @@ export const useProviderStore = create((set, get) => ({ await get().fetchProviders() }, + reorderProviders: async (orderedIds) => { + const previous = get().providers + // Optimistically reorder locally so the drag feels instant… + const byId = new Map(previous.map((p) => [p.id, p])) + const next = orderedIds + .map((id) => byId.get(id)) + .filter((p): p is SavedProvider => p !== undefined) + // Bail out if the id set is stale (e.g. a provider was deleted mid-drag). + if (next.length !== previous.length) { + await get().fetchProviders() + return + } + set({ providers: next }) + try { + const { providers } = await providersApi.reorder(orderedIds) + set({ providers }) + } catch (err) { + // …and roll back to the pre-drag order if persistence fails. `previous` + // is the server's last-known truth, so no refetch is needed. + set({ providers: previous, error: err instanceof Error ? err.message : String(err) }) + } + }, + activateProvider: async (id) => { await providersApi.activate(id) await get().fetchProviders() diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 2354693e..f775c793 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -625,6 +625,81 @@ describe('ProviderService', () => { }) }) + // ─── reorderProviders ──────────────────────────────────────────────────── + + describe('reorderProviders', () => { + test('should reorder providers to match the given id order and persist it', async () => { + const svc = new ProviderService() + const a = await svc.addProvider(sampleInput({ name: 'A' })) + const b = await svc.addProvider(sampleInput({ name: 'B' })) + const c = await svc.addProvider(sampleInput({ name: 'C' })) + + const result = await svc.reorderProviders([c.id, a.id, b.id]) + expect(result.map((p) => p.name)).toEqual(['C', 'A', 'B']) + + // Persisted order survives a fresh read + const { providers } = await svc.listProviders() + expect(providers.map((p) => p.name)).toEqual(['C', 'A', 'B']) + + const config = await readProvidersConfig() + expect((config.providers as Array<{ name: string }>).map((p) => p.name)).toEqual(['C', 'A', 'B']) + }) + + test('should not change activeId when reordering', async () => { + const svc = new ProviderService() + const a = await svc.addProvider(sampleInput({ name: 'A' })) + const b = await svc.addProvider(sampleInput({ name: 'B' })) + await svc.activateProvider(a.id) + + await svc.reorderProviders([b.id, a.id]) + + const { activeId } = await svc.listProviders() + expect(activeId).toBe(a.id) + }) + + test('should throw 400 when orderedIds is missing a provider', async () => { + const svc = new ProviderService() + const a = await svc.addProvider(sampleInput({ name: 'A' })) + await svc.addProvider(sampleInput({ name: 'B' })) + + try { + await svc.reorderProviders([a.id]) + expect(true).toBe(false) + } catch (err: unknown) { + const apiErr = err as { statusCode: number } + expect(apiErr.statusCode).toBe(400) + } + }) + + test('should throw 400 when orderedIds contains an unknown id', async () => { + const svc = new ProviderService() + const a = await svc.addProvider(sampleInput({ name: 'A' })) + const b = await svc.addProvider(sampleInput({ name: 'B' })) + + try { + await svc.reorderProviders([a.id, b.id, 'ghost-id']) + expect(true).toBe(false) + } catch (err: unknown) { + const apiErr = err as { statusCode: number } + expect(apiErr.statusCode).toBe(400) + } + }) + + test('should throw 400 when orderedIds contains duplicates', async () => { + const svc = new ProviderService() + const a = await svc.addProvider(sampleInput({ name: 'A' })) + await svc.addProvider(sampleInput({ name: 'B' })) + + try { + await svc.reorderProviders([a.id, a.id]) + expect(true).toBe(false) + } catch (err: unknown) { + const apiErr = err as { statusCode: number } + expect(apiErr.statusCode).toBe(400) + } + }) + }) + // ─── activateProvider ──────────────────────────────────────────────────── describe('activateProvider', () => { @@ -1518,6 +1593,54 @@ describe('Providers API', () => { expect(res.status).toBe(400) }) + // ─── PUT /api/providers/reorder ────────────────────────────────────────── + + test('PUT /api/providers/reorder should reorder providers', async () => { + const svc = new ProviderService() + const a = await svc.addProvider(sampleInput({ name: 'A' })) + const b = await svc.addProvider(sampleInput({ name: 'B' })) + + const { req, url, segments } = makeRequest('PUT', '/api/providers/reorder', { + orderedIds: [b.id, a.id], + }) + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(200) + const body = (await res.json()) as { providers: { name: string }[] } + expect(body.providers.map((p) => p.name)).toEqual(['B', 'A']) + }) + + test('PUT /api/providers/reorder should return 400 for a non-permutation', async () => { + const svc = new ProviderService() + const a = await svc.addProvider(sampleInput({ name: 'A' })) + await svc.addProvider(sampleInput({ name: 'B' })) + + const { req, url, segments } = makeRequest('PUT', '/api/providers/reorder', { + orderedIds: [a.id], // missing B + }) + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(400) + }) + + test('PUT /api/providers/reorder should return 400 for empty orderedIds', async () => { + const { req, url, segments } = makeRequest('PUT', '/api/providers/reorder', { + orderedIds: [], + }) + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(400) + }) + + test('POST /api/providers/reorder should be method-not-allowed', async () => { + const { req, url, segments } = makeRequest('POST', '/api/providers/reorder', { + orderedIds: [], + }) + const res = await handleProvidersApi(req, url, segments) + + expect(res.status).toBe(405) + }) + test('POST /api/providers should return 400 for invalid auto compact window', async () => { const { req, url, segments } = makeRequest('POST', '/api/providers', { presetId: 'custom', diff --git a/src/server/api/providers.ts b/src/server/api/providers.ts index 7998dc80..d046c0f1 100644 --- a/src/server/api/providers.ts +++ b/src/server/api/providers.ts @@ -22,6 +22,7 @@ import { CreateProviderSchema, UpdateProviderSchema, TestProviderSchema, + ReorderProvidersSchema, } from '../types/provider.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' import { diagnosticsService } from '../services/diagnosticsService.js' @@ -72,6 +73,12 @@ export async function handleProvidersApi( return Response.json({ ok: true }) } + // PUT /api/providers/reorder + if (id === 'reorder') { + if (req.method !== 'PUT') throw methodNotAllowed(req.method) + return await handleReorder(req) + } + // /api/providers (no ID) if (!id) { if (req.method === 'GET') { @@ -161,6 +168,18 @@ async function handleUpdate(req: Request, id: string): Promise { } } +async function handleReorder(req: Request): Promise { + const body = await parseJsonBody(req) + try { + const input = ReorderProvidersSchema.parse(body) + const providers = await providerService.reorderProviders(input.orderedIds) + return Response.json({ providers }) + } catch (err) { + if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) + throw err + } +} + async function handleTestUnsaved(req: Request): Promise { const body = await parseJsonBody(req) try { diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index 7a7fa48d..8f0aa773 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -213,6 +213,36 @@ export class ProviderService { await this.writeIndex(index) } + /** + * Reorder saved providers to match the given id order. + * + * The display order is simply the array order in providers.json, so reordering + * persists by rewriting the array — no separate `order` field is needed. + * `orderedIds` must be a permutation of the existing provider ids (same members, + * no duplicates, none missing); otherwise the request is rejected so a stale + * client can't silently drop or duplicate a provider. + */ + async reorderProviders(orderedIds: string[]): Promise { + const index = await this.readIndex() + + const currentIds = index.providers.map((p) => p.id) + const orderedSet = new Set(orderedIds) + const isPermutation = + orderedIds.length === currentIds.length && + orderedSet.size === orderedIds.length && + currentIds.every((id) => orderedSet.has(id)) + + if (!isPermutation) { + throw ApiError.badRequest('orderedIds must be a permutation of existing provider ids') + } + + const byId = new Map(index.providers.map((p) => [p.id, p])) + index.providers = orderedIds.map((id) => byId.get(id) as SavedProvider) + await this.writeIndex(index) + + return index.providers + } + // --- Activation --- async activateProvider(id: string): Promise { diff --git a/src/server/types/provider.ts b/src/server/types/provider.ts index 3ddf303f..97579298 100644 --- a/src/server/types/provider.ts +++ b/src/server/types/provider.ts @@ -98,6 +98,11 @@ export const TestProviderSchema = z.object({ apiFormat: ApiFormatSchema.default('anthropic'), }) +export const ReorderProvidersSchema = z.object({ + // A permutation of the existing saved provider ids, in the desired display order. + orderedIds: z.array(z.string().min(1)).min(1), +}) + // TypeScript types export type ModelMapping = z.infer export type SavedProvider = z.infer @@ -105,6 +110,7 @@ export type ProvidersIndex = z.infer export type CreateProviderInput = z.infer export type UpdateProviderInput = z.infer export type TestProviderInput = z.infer +export type ReorderProvidersInput = z.infer export interface ProviderTestStepResult { success: boolean