From db536ec8bcbbc63034d009a7257ecee0f3468abf Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 19 Sep 2026 16:09:51 +0800 Subject: [PATCH] fix(channels): simplify model redirects and clarify panel controls --- web/src/components/floating-window.tsx | 33 +- .../__tests__/floating-window.test.tsx | 7 +- .../__tests__/channel-configuration.test.tsx | 166 +++++- .../model-mapping-batch-dialog.test.tsx | 243 ++++++-- .../upstream-model-selection.test.tsx | 24 + .../drawers/channel-mutate-drawer.tsx | 55 +- .../components/model-mapping-batch-dialog.tsx | 559 +++++++++--------- .../components/model-mapping-editor.tsx | 5 + .../components/model-redirect-panel.tsx | 78 +-- .../components/upstream-model-selection.tsx | 4 +- web/src/i18n/locales/en.json | 14 + web/src/i18n/locales/fr.json | 14 + web/src/i18n/locales/ja.json | 14 + web/src/i18n/locales/ru.json | 14 + web/src/i18n/locales/vi.json | 14 + web/src/i18n/locales/zh-TW.json | 14 + web/src/i18n/locales/zh.json | 14 + 17 files changed, 792 insertions(+), 480 deletions(-) diff --git a/web/src/components/floating-window.tsx b/web/src/components/floating-window.tsx index af7b3468c..9aca191dd 100644 --- a/web/src/components/floating-window.tsx +++ b/web/src/components/floating-window.tsx @@ -16,6 +16,8 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { ArrowExpand02Icon } from '@hugeicons/core-free-icons' +import { HugeiconsIcon } from '@hugeicons/react' import { ChevronsUpDown, GripHorizontal, X } from 'lucide-react' import { type PointerEvent as ReactPointerEvent, @@ -60,6 +62,8 @@ type FloatingWindowProps = { * key so the window reopens where and how the user left it. */ storageKey?: string + /** A new request expands the window without resetting its position or size. */ + expandRequest?: number className?: string } @@ -88,7 +92,7 @@ const RESIZE_HANDLE_CLASS: Record = { w: 'top-2 bottom-2 left-0 w-1.5 cursor-ew-resize', ne: 'top-0 right-0 size-3 cursor-nesw-resize', nw: 'top-0 left-0 size-3 cursor-nwse-resize', - se: 'bottom-0 right-0 size-3 cursor-nwse-resize', + se: 'bottom-0 right-0 size-8 cursor-nwse-resize', sw: 'bottom-0 left-0 size-3 cursor-nesw-resize', } @@ -199,7 +203,8 @@ export function FloatingWindow(props: FloatingWindowProps) { size.width ), size, - collapsed: stored?.collapsed ?? false, + collapsed: + props.expandRequest === undefined && (stored?.collapsed ?? false), } }) const windowRef = useRef(null) @@ -217,6 +222,13 @@ export function FloatingWindow(props: FloatingWindowProps) { size: { width: number; height: number } } | null>(null) + useEffect(() => { + if (props.expandRequest === undefined) return + setState((previous) => + previous.collapsed ? { ...previous, collapsed: false } : previous + ) + }, [props.expandRequest]) + useEffect(() => { if (!props.storageKey) return try { @@ -455,6 +467,9 @@ export function FloatingWindow(props: FloatingWindowProps) { {props.footer} )} +
+ {t('Drag to resize')} +
{RESIZE_DIRECTIONS.map((direction) => ( ))} ) diff --git a/web/src/components/floating-window/__tests__/floating-window.test.tsx b/web/src/components/floating-window/__tests__/floating-window.test.tsx index 6dbcb791d..3cfd2db95 100644 --- a/web/src/components/floating-window/__tests__/floating-window.test.tsx +++ b/web/src/components/floating-window/__tests__/floating-window.test.tsx @@ -76,10 +76,13 @@ function resizeHandle(direction: string) { return handle } -test('dragging the bottom-right corner grows both dimensions from the rendered size and persists them', () => { +test('the visible resize hint leads to a bottom-right grip that grows both dimensions and persists them', () => { renderWindow({ storageKey: 'scratch' }) const window_ = screen.getByRole('complementary', { name: 'Scratch pad' }) const handle = resizeHandle('se') + expect(screen.getByText('Drag to resize')).toBeVisible() + expect(handle.querySelector('svg')).toBeVisible() + expect(handle).toHaveClass('size-8', 'cursor-nwse-resize') fireEvent.pointerDown(handle, { button: 0, pointerId: 3, @@ -145,11 +148,13 @@ test('collapses to a pill that keeps the title and reopens, and the close contro ).not.toBeInTheDocument() expect(screen.getByText('Scratch pad')).toBeVisible() expect(screen.queryByText('body')).not.toBeInTheDocument() + expect(screen.queryByText('Drag to resize')).not.toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Expand panel' })) expect( screen.getByRole('complementary', { name: 'Scratch pad' }) ).toBeVisible() + expect(screen.getByText('Drag to resize')).toBeVisible() await user.click(screen.getByRole('button', { name: 'Close panel' })) expect(onClose).toHaveBeenCalledTimes(1) }) diff --git a/web/src/features/channels/components/__tests__/channel-configuration.test.tsx b/web/src/features/channels/components/__tests__/channel-configuration.test.tsx index 2d571a470..73df7a237 100644 --- a/web/src/features/channels/components/__tests__/channel-configuration.test.tsx +++ b/web/src/features/channels/components/__tests__/channel-configuration.test.tsx @@ -2304,10 +2304,14 @@ test('redirecting a model from the model picker publishes it, adds a mapping row }) test('fetched upstream models can be redirected in bulk with the model list synced', async () => { + editingChannel.models = 'custom-model,upstream-model' const user = userEvent.setup() render() await screen.findByDisplayValue('Existing channel') await user.click(screen.getByRole('button', { name: 'Fetch from Upstream' })) + await user.click( + await screen.findByRole('tab', { name: 'Existing Models (1)' }) + ) await screen.findByRole('checkbox', { name: 'upstream-model' }) await user.click(screen.getByRole('button', { name: 'Batch redirect' })) @@ -2317,6 +2321,9 @@ test('fetched upstream models can be redirected in bulk with the model list sync expect( dialog.getByRole('tab', { name: /Upstream model list/ }) ).toHaveAttribute('aria-selected', 'true') + expect( + dialog.getByRole('checkbox', { name: 'upstream-model' }) + ).not.toBeChecked() await user.click(dialog.getByRole('checkbox', { name: 'upstream-model' })) await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-model') await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' })) @@ -2423,9 +2430,127 @@ test('on wide screens redirecting a fetched model opens the floating panel besid ).toHaveValue('upstream') }) -test('a suggested rule in the floating panel maps every matching fetched model and publishes the derived names', async () => { +test.each(['current session', 'saved preference'])( + 'quick redirect expands a panel collapsed in the %s and focuses the new request name', + async (collapsedIn) => { + useWideScreen() + editingChannel.model_mapping = '{"existing-alias":"existing-upstream"}' + if (collapsedIn === 'saved preference') { + window.localStorage.setItem( + 'floating-window:channel-model-redirects', + JSON.stringify({ + x: 1000, + y: 80, + width: 380, + height: 360, + collapsed: true, + }) + ) + } + const user = userEvent.setup() + render() + await screen.findByDisplayValue('Existing channel') + await user.click( + screen.getByRole('button', { name: 'Fetch from Upstream' }) + ) + await screen.findByRole('checkbox', { name: 'upstream-model' }) + if (collapsedIn === 'current session') { + await user.click(screen.getByRole('button', { name: 'Redirect panel' })) + await user.click(screen.getByRole('button', { name: 'Collapse panel' })) + expect(screen.getByRole('button', { name: 'Expand panel' })).toBeVisible() + } + + await user.click( + screen.getByRole('button', { name: 'Redirect upstream-model' }) + ) + let panelElement = await screen.findByRole('complementary', { + name: 'Model redirects', + }) + let panel = within(panelElement) + await waitFor(() => + expect(panel.getByDisplayValue('upstream-model')).toBeVisible() + ) + const request = panel.getAllByRole('combobox', { + name: 'Request Model Name', + })[1] + await waitFor(() => expect(request).toHaveFocus()) + expect(request).toHaveValue('') + expect(panel.getByDisplayValue('existing-alias')).toBeVisible() + expect(panel.getByDisplayValue('existing-upstream')).toBeVisible() + expect(screen.getByText('1 model(s) redirected')).toBeVisible() + if (collapsedIn === 'saved preference') { + expect(panelElement).toHaveStyle({ + left: '1000px', + top: '80px', + width: '380px', + height: '360px', + }) + } + + // Handling the draft must not prevent collapse or swallow the next shortcut for the same model. + await user.click(panel.getByRole('button', { name: 'Collapse panel' })) + expect( + screen.queryByRole('complementary', { name: 'Model redirects' }) + ).not.toBeInTheDocument() + await user.click( + screen.getByRole('button', { name: 'Redirect upstream-model' }) + ) + panelElement = await screen.findByRole('complementary', { + name: 'Model redirects', + }) + panel = within(panelElement) + await waitFor(() => + expect(panel.getByDisplayValue('upstream-model')).toBeVisible() + ) + await waitFor(() => + expect( + panel.getAllByRole('combobox', { name: 'Request Model Name' })[1] + ).toHaveFocus() + ) + } +) + +test('redirecting a recognized upstream name opens an empty alias draft without changing the model list', async () => { useWideScreen() const baseGet = vi.mocked(api.get).getMockImplementation() + vi.spyOn(api, 'get').mockImplementation(async (url, ...rest) => { + if (url === '/api/channel/fetch_models/42') { + return { data: { success: true, data: ['gpt-4o-all'] } } + } + if (url === '/api/channel/models') { + return { data: { success: true, data: [{ id: 'gpt-4o' }] } } + } + return baseGet?.(url, ...rest) + }) + const user = userEvent.setup() + render() + await screen.findByDisplayValue('Existing channel') + await user.click(screen.getByRole('button', { name: 'Fetch from Upstream' })) + await user.click( + await screen.findByRole('button', { name: 'Redirect gpt-4o-all' }) + ) + const panel = within( + await screen.findByRole('complementary', { name: 'Model redirects' }) + ) + const request = panel.getByRole('combobox', { name: 'Request Model Name' }) + await waitFor(() => expect(request).toHaveFocus()) + expect(request).toHaveValue('') + expect( + panel.getByRole('combobox', { name: 'Upstream Model Name' }) + ).toHaveValue('gpt-4o-all') + expect(screen.queryByLabelText('Published as gpt-4o')).not.toBeInTheDocument() + expect( + within(screen.getByRole('group', { name: 'Models' })).getByRole('button', { + name: 'custom-model', + }) + ).toBeVisible() + expect(screen.queryByText('1 model(s) redirected')).not.toBeInTheDocument() +}) + +test('the redirect panel batch action starts empty and only applies explicitly selected models after preview', async () => { + useWideScreen() + editingChannel.models = 'custom-model,gpt-4o-all,o3-all' + const baseGet = vi.mocked(api.get).getMockImplementation() vi.spyOn(api, 'get').mockImplementation(async (url, ...rest) => { if (url === '/api/channel/fetch_models/42') { return { data: { success: true, data: ['gpt-4o-all', 'o3-all'] } } @@ -2439,22 +2564,47 @@ test('a suggested rule in the floating panel maps every matching fetched model a render() await screen.findByDisplayValue('Existing channel') await user.click(screen.getByRole('button', { name: 'Fetch from Upstream' })) + await user.click( + await screen.findByRole('tab', { name: 'Existing Models (2)' }) + ) await screen.findByRole('checkbox', { name: 'gpt-4o-all' }) await user.click(screen.getByRole('button', { name: 'Redirect panel' })) const panel = within( await screen.findByRole('complementary', { name: 'Model redirects' }) ) - await user.click(panel.getByRole('button', { name: /Strip -all/ })) + expect(panel.queryByText('Suggested rules')).not.toBeInTheDocument() + await user.click(panel.getByRole('button', { name: 'Batch Add' })) + let dialog = within( + screen.getByRole('dialog', { name: 'Batch add mappings' }) + ) + expect(dialog.getByRole('checkbox', { name: 'gpt-4o-all' })).not.toBeChecked() + expect(dialog.getByRole('checkbox', { name: 'o3-all' })).not.toBeChecked() + await user.click(dialog.getByRole('checkbox', { name: 'gpt-4o-all' })) + await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all') + await user.click(dialog.getByRole('button', { name: 'Cancel' })) + const picker = within(screen.getByRole('group', { name: 'Models' })) + expect(picker.getByRole('button', { name: 'gpt-4o-all' })).toBeVisible() + expect( + picker.queryByRole('button', { name: 'gpt-4o' }) + ).not.toBeInTheDocument() + + await user.click(panel.getByRole('button', { name: 'Batch Add' })) + dialog = within(screen.getByRole('dialog', { name: 'Batch add mappings' })) + expect(dialog.getByRole('checkbox', { name: 'gpt-4o-all' })).not.toBeChecked() + await user.click(dialog.getByRole('checkbox', { name: 'gpt-4o-all' })) + await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all') + expect(dialog.getByRole('list', { name: 'Preview' })).toHaveTextContent( + 'gpt-4ogpt-4o-all' + ) + await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' })) expect( panel.getAllByRole('combobox', { name: 'Request Model Name' }) - ).toHaveLength(2) - expect(screen.getByLabelText('Published as gpt-4o')).toBeVisible() - expect(screen.getByLabelText('Published as o3')).toBeVisible() - const picker = within(screen.getByRole('group', { name: 'Models' })) + ).toHaveLength(1) expect(picker.getByRole('button', { name: 'gpt-4o' })).toBeVisible() - expect(picker.getByRole('button', { name: 'o3' })).toBeVisible() - expect(screen.getByText('2 model(s) redirected')).toBeVisible() + expect(picker.getByRole('button', { name: 'o3-all' })).toBeVisible() + expect(picker.queryByRole('button', { name: 'o3' })).not.toBeInTheDocument() + expect(screen.getByText('1 model(s) redirected')).toBeVisible() await user.click(panel.getByRole('button', { name: 'Collapse panel' })) expect( diff --git a/web/src/features/channels/components/__tests__/model-mapping-batch-dialog.test.tsx b/web/src/features/channels/components/__tests__/model-mapping-batch-dialog.test.tsx index 131551ddb..287f1efcb 100644 --- a/web/src/features/channels/components/__tests__/model-mapping-batch-dialog.test.tsx +++ b/web/src/features/channels/components/__tests__/model-mapping-batch-dialog.test.tsx @@ -18,11 +18,15 @@ For commercial licensing, please contact support@quantumnous.com */ import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { createInstance } from 'i18next' +import { I18nextProvider } from 'react-i18next' import { expect, test, vi } from 'vitest' +import fr from '@/i18n/locales/fr.json' + import { ModelMappingBatchDialog } from '../model-mapping-batch-dialog' -test('upstream names are the default source: stripping a suffix derives request names, skips unchanged models, and syncs the model list', async () => { +test('creating user aliases starts unselected, previews request-to-upstream pairs, and skips unchanged names', async () => { const user = userEvent.setup() const onApply = vi.fn() render( @@ -30,113 +34,236 @@ test('upstream names are the default source: stripping a suffix derives request open onOpenChange={vi.fn()} upstreamModels={['gpt-4o-all', 'o3']} - channelModels={['gpt-4o']} + channelModels={['gpt-4o-all']} onApply={onApply} /> ) - const dialog = within( - screen.getByRole('dialog', { name: 'Batch add mappings' }) - ) expect( - dialog.getByRole('tab', { name: /Upstream model list/ }) - ).toHaveAttribute('aria-selected', 'true') - expect( - dialog.getByRole('radio', { - name: 'Upstream names returned by the provider', - }) + screen.getByRole('radio', { name: 'Create aliases for users' }) ).toBeChecked() - expect(dialog.getByRole('radio', { name: 'Strip suffix' })).toBeChecked() - expect( - dialog.getByRole('button', { name: 'Add 0 mapping(s)' }) - ).toBeDisabled() - - await user.click(dialog.getByRole('checkbox', { name: 'gpt-4o-all' })) - await user.click(dialog.getByRole('checkbox', { name: 'o3' })) - await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all') - expect(dialog.getByRole('list', { name: 'Preview' })).toHaveTextContent( - 'gpt-4ogpt-4o-all' + expect(screen.getByRole('combobox', { name: 'Rule' })).toHaveTextContent( + 'Strip suffix' ) + expect(screen.getByRole('checkbox', { name: 'gpt-4o-all' })).not.toBeChecked() expect( - dialog.getByText('1 model(s) unchanged by the rule were skipped') + screen.getByRole('button', { name: 'Add 0 mapping(s)' }) + ).toBeDisabled() + expect( + screen.getByText( + 'Example: users call gpt-4o; upstream receives gpt-4o-all.' + ) ).toBeVisible() - await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' })) + await user.click(screen.getByRole('checkbox', { name: 'gpt-4o-all' })) + await user.click(screen.getByRole('checkbox', { name: 'o3' })) + expect(screen.getByText('Enter a rule to preview mappings.')).toBeVisible() + expect(screen.queryByText(/unchanged by the rule/)).not.toBeInTheDocument() + await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all') + const preview = within(screen.getByRole('list', { name: 'Preview' })) + expect(preview.getByRole('listitem')).toHaveTextContent('gpt-4ogpt-4o-all') + expect( + screen.getByText('1 model(s) unchanged by the rule were skipped') + ).toBeVisible() + expect(onApply).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' })) expect(onApply).toHaveBeenCalledWith({ pairs: [{ from: 'gpt-4o', to: 'gpt-4o-all' }], syncModels: true, }) }) -test('the channel-model source flips to request names, offers add rules, and never syncs the model list', async () => { +test('changing upstream names preserves the request name and leaves model-list syncing off', async () => { const user = userEvent.setup() const onApply = vi.fn() render( ) - const dialog = within( - screen.getByRole('dialog', { name: 'Batch add mappings' }) - ) - await user.click(dialog.getByRole('tab', { name: /Channel models/ })) expect( - dialog.getByRole('radio', { name: 'Request names your users call' }) + screen.getByRole('radio', { name: 'Change upstream model names' }) ).toBeChecked() - expect(dialog.getByRole('radio', { name: 'Add suffix' })).toBeChecked() expect( - dialog.queryByRole('checkbox', { name: /Publish the request names/ }) + screen.queryByRole('checkbox', { name: /Publish the request names/ }) ).not.toBeInTheDocument() - - await user.click(dialog.getByRole('checkbox', { name: 'gemini-2.5-flash' })) - await user.type(dialog.getByRole('textbox', { name: 'Suffix' }), '-all') - await user.click(dialog.getByRole('button', { name: 'Add 1 mapping(s)' })) + await user.click(screen.getByRole('checkbox', { name: 'gemini-2.5-flash' })) + await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all') + await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' })) expect(onApply).toHaveBeenCalledWith({ pairs: [{ from: 'gemini-2.5-flash', to: 'gemini-2.5-flash-all' }], syncModels: false, }) }) -test('without an upstream list the dialog offers only channel models, and conflicting request names are reported', async () => { +test('switching the model list clears selection without changing the chosen task or naming rule', async () => { const user = userEvent.setup() + const onApply = vi.fn() + render( + + ) + await user.click(screen.getByRole('checkbox', { name: 'gpt-4o-all' })) + await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all') + await user.click(screen.getByRole('tab', { name: /Channel models/ })) + expect( + screen.getByRole('radio', { name: 'Create aliases for users' }) + ).toBeChecked() + expect(screen.getByRole('textbox', { name: 'Suffix' })).toHaveValue('-all') + expect( + screen.getByRole('button', { name: 'Add 0 mapping(s)' }) + ).toBeDisabled() + await user.click(screen.getByRole('checkbox', { name: 'claude-all' })) + await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' })) + expect(onApply).toHaveBeenCalledWith({ + pairs: [{ from: 'claude', to: 'claude-all' }], + syncModels: true, + }) +}) + +test('creating aliases from channel models reports conflicts and allows opting out of model-list syncing', async () => { + const user = userEvent.setup() + const onApply = vi.fn() render( ) - const dialog = within( - screen.getByRole('dialog', { name: 'Batch add mappings' }) - ) - expect(dialog.queryByRole('tab')).not.toBeInTheDocument() - expect( - dialog.getByRole('radio', { name: 'Request names your users call' }) - ).toBeChecked() - + expect(screen.queryByRole('tab')).not.toBeInTheDocument() await user.click( - dialog.getByRole('radio', { - name: 'Upstream names returned by the provider', - }) + screen.getByRole('radio', { name: 'Create aliases for users' }) ) - expect(dialog.getByRole('radio', { name: 'Strip suffix' })).toBeChecked() - await user.click(dialog.getByRole('checkbox', { name: 'claude-all' })) - await user.click(dialog.getByRole('checkbox', { name: 'claude-latest' })) + await user.click(screen.getByRole('checkbox', { name: 'claude-all' })) + await user.click(screen.getByRole('checkbox', { name: 'claude-latest' })) await user.type( - dialog.getByRole('textbox', { name: 'Suffix' }), + screen.getByRole('textbox', { name: 'Suffix' }), '-all, -latest' ) - expect(dialog.getByRole('list', { name: 'Preview' })).toHaveTextContent( - 'claudeclaude-all' - ) expect( - dialog.getByText( + screen.getByText( '1 model(s) skipped because another model derives the same request name' ) ).toBeVisible() - expect(dialog.getByRole('button', { name: 'Add 1 mapping(s)' })).toBeEnabled() + await user.click( + screen.getByRole('checkbox', { name: /Publish the request names/ }) + ) + await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' })) + expect(onApply).toHaveBeenCalledWith({ + pairs: [{ from: 'claude', to: 'claude-all' }], + syncModels: false, + }) +}) + +test('the alias task can add a prefix and only shows fields for the selected rule', async () => { + const user = userEvent.setup() + const onApply = vi.fn() + render( + + ) + await user.click(screen.getByRole('checkbox', { name: 'gpt-4o' })) + expect( + screen.queryByRole('textbox', { name: 'Prefix' }) + ).not.toBeInTheDocument() + await user.click(screen.getByRole('combobox', { name: 'Rule' })) + await user.click(screen.getByRole('option', { name: 'Add prefix' })) + expect( + screen.queryByRole('textbox', { name: 'Suffix' }) + ).not.toBeInTheDocument() + await user.type(screen.getByRole('textbox', { name: 'Prefix' }), 'team/') + await user.click(screen.getByRole('button', { name: 'Add 1 mapping(s)' })) + expect(onApply).toHaveBeenCalledWith({ + pairs: [{ from: 'team/gpt-4o', to: 'gpt-4o' }], + syncModels: true, + }) +}) + +test('replacement previews include every selected model even when several share an upstream target', async () => { + const user = userEvent.setup() + const onApply = vi.fn() + const models = [ + 'gpt-4o-a', + 'gpt-4oa', + ...Array.from({ length: 8 }, (_, n) => `gpt-${n}`), + ] + render( + + ) + await user.click( + screen.getByRole('checkbox', { name: 'Select all models in OpenAI' }) + ) + await user.click(screen.getByRole('combobox', { name: 'Rule' })) + await user.click(screen.getByRole('option', { name: 'Replace text' })) + await user.type(screen.getByRole('textbox', { name: 'Find' }), '-') + const preview = within(screen.getByRole('list', { name: 'Preview' })) + expect(preview.getAllByRole('listitem')).toHaveLength(10) + expect(preview.getAllByText('gpt4oa')).toHaveLength(2) + await user.click(screen.getByRole('button', { name: 'Add 10 mapping(s)' })) + expect(onApply).toHaveBeenCalledWith({ + pairs: models.map((from) => ({ from, to: from.replaceAll('-', '') })), + syncModels: false, + }) +}) + +test('cancelling after configuring a rule does not apply mappings', async () => { + const user = userEvent.setup() + const onApply = vi.fn() + const onOpenChange = vi.fn() + render( + + ) + await user.click(screen.getByRole('checkbox', { name: 'gpt-4o' })) + await user.type(screen.getByRole('textbox', { name: 'Suffix' }), '-all') + await user.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(onApply).not.toHaveBeenCalled() +}) + +test('long translated source labels wrap inside their row without covering the model search', async () => { + const i18n = createInstance() + await i18n.init({ lng: 'fr', resources: { fr }, keySeparator: false }) + render( + + + + ) + const tabs = screen.getByRole('tablist', { name: 'Sélectionner les modèles' }) + expect(tabs).toHaveClass('grid-cols-2', 'group-data-horizontal/tabs:h-auto') + for (const tab of within(tabs).getAllByRole('tab')) { + expect(tab).toHaveClass('whitespace-normal', 'h-auto') + } + expect( + screen.getByRole('textbox', { name: 'Rechercher des modèles...' }) + ).toBeVisible() }) diff --git a/web/src/features/channels/components/__tests__/upstream-model-selection.test.tsx b/web/src/features/channels/components/__tests__/upstream-model-selection.test.tsx index 0073d88fe..6cd07930b 100644 --- a/web/src/features/channels/components/__tests__/upstream-model-selection.test.tsx +++ b/web/src/features/channels/components/__tests__/upstream-model-selection.test.tsx @@ -251,3 +251,27 @@ test('models published under another name show that name as a badge next to the ) expect(screen.queryByLabelText(/Published as o3/)).not.toBeInTheDocument() }) + +test('long model names remain fully labeled and selectable in a container-responsive list', async () => { + const user = userEvent.setup() + const model = 'gpt-4.1-mini-deployment-with-a-long-region-name-2025-04-14' + const onChange = vi.fn() + render( + + ) + const checkbox = screen.getByRole('checkbox', { name: model }) + const category = checkbox.closest('[data-slot="collapsible"]') + expect(category).toHaveClass('@container') + const grid = checkbox.parentElement?.parentElement + expect(grid).toHaveClass('@lg:grid-cols-2') + expect(grid).not.toHaveClass('sm:grid-cols-2') + expect(screen.getByText(model)).toBeVisible() + await user.click(checkbox) + expect(onChange).toHaveBeenCalledWith([model]) +}) diff --git a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 25e50e0f5..1aba7df0e 100644 --- a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -177,11 +177,7 @@ import { parseModelsString, formatModelsArray, mergeModelMappingPairs, - deriveModelMappingPairs, - detectModelNamingPatterns, - findVerifiedAlias, type ModelMappingPair, - type ModelNamingSuggestion, extractRedirectModels, extractMappingSourceModels, hasModelConfigChanged, @@ -483,7 +479,6 @@ export function ChannelMutateDrawer({ const mappingDraftTokenRef = useRef(0) const [batchMapping, setBatchMapping] = useState<{ source: ModelMappingBatchSource - selected: string[] } | null>(null) const [redirectPanelOpen, setRedirectPanelOpen] = useState(false) const [redirectSyncModels, setRedirectSyncModels] = useState(true) @@ -1246,10 +1241,6 @@ export function ChannelMutateDrawer({ upstreamModelList.length > 0 ? 'upstream' : 'channel' const canBatchMap = currentModelsArray.length > 0 || upstreamModelList.length > 0 - const namingSuggestions = useMemo( - () => detectModelNamingPatterns(upstreamModelList, allModelsList), - [upstreamModelList, allModelsList] - ) const fetchDiscoveredModels = discovery.fetch const handleFetchModels = useCallback(async () => { const type = form.getValues('type') @@ -1495,9 +1486,7 @@ export function ChannelMutateDrawer({ setRedirectPanelOpen(false) }, []) - // Redirect one fetched upstream model. On wide screens the floating panel - // stays beside the list; a name the platform already knows is applied at - // once, anything else becomes a draft row awaiting the request name. + // Open a draft without guessing or publishing a request name. const handleFetchedRedirect = useCallback( (model: string) => { if (window.innerWidth < REDIRECT_PANEL_MIN_VIEWPORT) { @@ -1505,35 +1494,9 @@ export function ChannelMutateDrawer({ return } openRedirectPanel() - const alias = findVerifiedAlias(model, namingSuggestions, allModelsList) - if (alias) { - applyMappingPairs([alias], redirectSyncModels) - raiseMappingDraft(alias.from, alias.to, 'from') - return - } raiseMappingDraft('', model, 'from') }, - [ - requestMappingDraft, - openRedirectPanel, - namingSuggestions, - allModelsList, - applyMappingPairs, - redirectSyncModels, - raiseMappingDraft, - ] - ) - - const handleApplySuggestion = useCallback( - (suggestion: ModelNamingSuggestion) => { - const derivation = deriveModelMappingPairs( - suggestion.models, - 'upstream', - suggestion.rule - ) - applyMappingPairs(derivation.pairs, redirectSyncModels) - }, - [applyMappingPairs, redirectSyncModels] + [requestMappingDraft, openRedirectPanel, raiseMappingDraft] ) // Handle successful submission @@ -2509,7 +2472,6 @@ export function ChannelMutateDrawer({ ? () => setBatchMapping({ source: batchMappingSource, - selected: [], }) : undefined } @@ -3408,9 +3370,6 @@ export function ChannelMutateDrawer({ onClick={() => setBatchMapping({ source: 'upstream', - selected: discovery.models.filter((model) => - currentModelsArray.includes(model) - ), }) } > @@ -4997,14 +4956,9 @@ export function ChannelMutateDrawer({ mappingValue={formValues.model_mapping || ''} onMappingChange={handlePanelMappingChange} onMappingCommit={handlePanelMappingCommit} - suggestions={namingSuggestions} - onApplySuggestion={handleApplySuggestion} - onOpenRules={() => + onBatchAdd={() => setBatchMapping({ - source: 'upstream', - selected: upstreamModelList.filter((model) => - currentModelsArray.includes(model) - ), + source: batchMappingSource, }) } syncModels={redirectSyncModels} @@ -5044,7 +4998,6 @@ export function ChannelMutateDrawer({ upstreamModels={upstreamModelList} channelModels={currentModelsArray} initialSource={batchMapping.source} - initialSelected={batchMapping.selected} onOpenChange={(nextOpen) => { if (!nextOpen) setBatchMapping(null) }} diff --git a/web/src/features/channels/components/model-mapping-batch-dialog.tsx b/web/src/features/channels/components/model-mapping-batch-dialog.tsx index 29dd0f736..fc1c3b13f 100644 --- a/web/src/features/channels/components/model-mapping-batch-dialog.tsx +++ b/web/src/features/channels/components/model-mapping-batch-dialog.tsx @@ -16,16 +16,31 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { ArrowRight } from 'lucide-react' -import { type ReactNode, useId, useMemo, useState } from 'react' +import { ArrowRight01Icon } from '@hugeicons/core-free-icons' +import { HugeiconsIcon } from '@hugeicons/react' +import { useId, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Dialog } from '@/components/dialog' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from '@/components/ui/field' import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { @@ -36,8 +51,6 @@ import { } from '../lib' import { UpstreamModelSelection } from './upstream-model-selection' -const PREVIEW_LIMIT = 8 - /** Where the selectable names come from. */ export type ModelMappingBatchSource = 'upstream' | 'channel' @@ -47,16 +60,12 @@ export type ModelMappingBatchResult = { syncModels: boolean } -type RuleType = ModelMappingRule['type'] - type ModelMappingBatchDialogProps = { open: boolean onOpenChange: (open: boolean) => void - /** Names returned by the provider's model list; enables the upstream source. */ upstreamModels?: string[] channelModels: string[] initialSource?: ModelMappingBatchSource - initialSelected?: string[] onApply: (result: ModelMappingBatchResult) => void } @@ -65,153 +74,48 @@ export function ModelMappingBatchDialog(props: ModelMappingBatchDialogProps) { const id = useId() const upstreamModels = props.upstreamModels ?? [] const hasUpstream = upstreamModels.length > 0 - const [source, setSource] = useState( - () => props.initialSource ?? (hasUpstream ? 'upstream' : 'channel') - ) - const [selected, setSelected] = useState( - props.initialSelected ?? [] + const [source, setSource] = useState(() => + hasUpstream ? (props.initialSource ?? 'upstream') : 'channel' ) + // Opening a batch action never opts models into a change on the user's behalf. + const [selected, setSelected] = useState([]) const [direction, setDirection] = useState( source === 'upstream' ? 'upstream' : 'request' ) - const [ruleType, setRuleType] = useState( + const [ruleType, setRuleType] = useState( direction === 'upstream' ? 'strip-suffix' : 'add-suffix' ) - const [stripPrefixes, setStripPrefixes] = useState('') - const [stripSuffixes, setStripSuffixes] = useState('') - const [prefix, setPrefix] = useState('') - const [suffix, setSuffix] = useState('') + const [affix, setAffix] = useState('') const [find, setFind] = useState('') const [replaceWith, setReplaceWith] = useState('') const [syncModels, setSyncModels] = useState(true) - const candidates = source === 'upstream' ? upstreamModels : props.channelModels const rule = useMemo(() => { - if (ruleType === 'strip-prefix') { - return { type: 'strip-prefix', values: stripPrefixes } + if (ruleType === 'strip-prefix' || ruleType === 'strip-suffix') { + return { type: ruleType, values: affix } } - if (ruleType === 'strip-suffix') { - return { type: 'strip-suffix', values: stripSuffixes } + if (ruleType === 'add-prefix' || ruleType === 'add-suffix') { + return { type: ruleType, value: affix } } - if (ruleType === 'add-prefix') return { type: 'add-prefix', value: prefix } - if (ruleType === 'add-suffix') return { type: 'add-suffix', value: suffix } return { type: 'replace', find, replaceWith } - }, [ - ruleType, - stripPrefixes, - stripSuffixes, - prefix, - suffix, - find, - replaceWith, - ]) + }, [ruleType, affix, find, replaceWith]) const derivation = useMemo( () => deriveModelMappingPairs(selected, direction, rule), [selected, direction, rule] ) - const preview = derivation.pairs.slice(0, PREVIEW_LIMIT) - const remaining = derivation.pairs.length - preview.length - - const changeDirection = (next: ModelMappingDirection) => { - setDirection(next) - setRuleType(next === 'upstream' ? 'strip-suffix' : 'add-suffix') - } - - const changeSource = (next: ModelMappingBatchSource) => { - setSource(next) - setSelected([]) - changeDirection(next === 'upstream' ? 'upstream' : 'request') - } - - const replaceControls = ( -
- setFind(event.target.value)} - disabled={ruleType !== 'replace'} - /> - setReplaceWith(event.target.value)} - disabled={ruleType !== 'replace'} - /> -
- ) - const affixHint = t('Separate several values with commas') - let ruleOptions: Array<{ - type: RuleType - label: string - control: ReactNode - hint?: string - }> - if (direction === 'upstream') { - ruleOptions = [ - { - type: 'strip-suffix', - label: t('Strip suffix'), - hint: affixHint, - control: ( - setStripSuffixes(event.target.value)} - disabled={ruleType !== 'strip-suffix'} - /> - ), - }, - { - type: 'strip-prefix', - label: t('Strip prefix'), - hint: affixHint, - control: ( - setStripPrefixes(event.target.value)} - disabled={ruleType !== 'strip-prefix'} - /> - ), - }, - { type: 'replace', label: t('Replace text'), control: replaceControls }, + const hasRule = + ruleType === 'replace' ? find.length > 0 : affix.trim().length > 0 + const isPrefix = ruleType === 'add-prefix' || ruleType === 'strip-prefix' + const isStrip = ruleType === 'strip-prefix' || ruleType === 'strip-suffix' + const ruleOptions: Array<{ value: ModelMappingRule['type']; label: string }> = + [ + { value: 'strip-suffix', label: t('Strip suffix') }, + { value: 'strip-prefix', label: t('Strip prefix') }, + { value: 'add-suffix', label: t('Add suffix') }, + { value: 'add-prefix', label: t('Add prefix') }, + { value: 'replace', label: t('Replace text') }, ] - } else { - ruleOptions = [ - { - type: 'add-suffix', - label: t('Add suffix'), - control: ( - setSuffix(event.target.value)} - disabled={ruleType !== 'add-suffix'} - /> - ), - }, - { - type: 'add-prefix', - label: t('Add prefix'), - control: ( - setPrefix(event.target.value)} - disabled={ruleType !== 'add-prefix'} - /> - ), - }, - { type: 'replace', label: t('Replace text'), control: replaceControls }, - ] - } return ( ) diff --git a/web/src/features/channels/components/model-mapping-editor.tsx b/web/src/features/channels/components/model-mapping-editor.tsx index 139d24d7d..45f396fe9 100644 --- a/web/src/features/channels/components/model-mapping-editor.tsx +++ b/web/src/features/channels/components/model-mapping-editor.tsx @@ -434,6 +434,11 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) { )} +

+ {t( + 'Users call the model on the left. The platform forwards the request to the upstream model on the right.' + )} +

{rows.length > 0 ? (
{showFilter && ( diff --git a/web/src/features/channels/components/model-redirect-panel.tsx b/web/src/features/channels/components/model-redirect-panel.tsx index 60260c97f..83b3a0880 100644 --- a/web/src/features/channels/components/model-redirect-panel.tsx +++ b/web/src/features/channels/components/model-redirect-panel.tsx @@ -16,7 +16,6 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { Wand2 } from 'lucide-react' import { useId } from 'react' import { useTranslation } from 'react-i18next' @@ -25,11 +24,9 @@ import { type FloatingWindowPosition, } from '@/components/floating-window' import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Label } from '@/components/ui/label' -import type { ModelNamingSuggestion } from '../lib' import { ModelMappingEditor, type ModelMappingDraftRequest, @@ -44,9 +41,7 @@ type ModelRedirectPanelProps = { onMappingChange: (value: string) => void /** Called once an edit settles; the drawer syncs the model list here. */ onMappingCommit: (value: string) => void - suggestions: ModelNamingSuggestion[] - onApplySuggestion: (suggestion: ModelNamingSuggestion) => void - onOpenRules: () => void + onBatchAdd: () => void syncModels: boolean onSyncModelsChange: (value: boolean) => void sourceModelOptions: string[] @@ -77,6 +72,7 @@ export function ModelRedirectPanel(props: ModelRedirectPanelProps) { defaultWidth={props.width} minWidth={340} storageKey='channel-model-redirects' + expandRequest={props.draftRequest?.token} onClose={props.onClose} footer={
@@ -99,65 +95,17 @@ export function ModelRedirectPanel(props: ModelRedirectPanelProps) {
} > -
-
- {props.suggestions.length > 0 && ( -

- {t('Suggested rules')} -

- )} -
- {props.suggestions.map((suggestion) => { - let label = '' - if (suggestion.rule.type === 'replace') { - label = t('Replace {{find}} with {{replaceWith}}', { - find: suggestion.rule.find, - replaceWith: suggestion.rule.replaceWith, - }) - } else if ('values' in suggestion.rule) { - label = t('Strip {{affix}}', { - affix: suggestion.rule.values, - }) - } - return ( - - ) - })} - -
-
- -
+ ) } diff --git a/web/src/features/channels/components/upstream-model-selection.tsx b/web/src/features/channels/components/upstream-model-selection.tsx index da5622a2b..bb3744195 100644 --- a/web/src/features/channels/components/upstream-model-selection.tsx +++ b/web/src/features/channels/components/upstream-model-selection.tsx @@ -72,7 +72,7 @@ function ModelCategory(props: ModelCategoryProps) { const allSelected = selectedCount === props.models.length return ( - +
-
+
{props.models.map((model) => (