diff --git a/apps/desktop/e2e/providers.spec.ts b/apps/desktop/e2e/providers.spec.ts index d6371299b9..536d359b25 100644 --- a/apps/desktop/e2e/providers.spec.ts +++ b/apps/desktop/e2e/providers.spec.ts @@ -38,6 +38,10 @@ async function openCatalog(page: Page, options: { category: string; search: stri await page.getByRole('button', { name: '添加连接', exact: true }).click(); const catalog = page.locator('[data-maka-contract="provider-catalog"]'); await expect(catalog).toBeVisible(); + // The catalog level lands on its search field before anything else is + // touched: it is what a user arrives here to do, and the shared route-focus + // hook is what puts them there. + await expect(catalog.getByPlaceholder('搜索服务商')).toBeFocused(); await catalog.getByRole('combobox', { name: '分类', exact: true }).click(); await page.getByRole('option', { name: options.category, exact: true }).click(); await catalog.getByPlaceholder('搜索服务商').fill(options.search); @@ -128,11 +132,33 @@ test('adds a catalog provider through the canonical API-key setup page', async ( // the page that owns every next move — no hunting for the new row. await expect(setup).toHaveCount(0); await expect(detail).toBeVisible(); + // The level itself takes focus, not its back button, and it is a region + // named by its own heading so the landing is announced. + await expect(detail).toBeFocused(); + await expect(detail).toHaveAttribute('role', 'region'); + await expect(page.getByRole('region', { name: 'Cerebras' })).toBeVisible(); const detailMark = detail.locator('.providerLogo[data-provider="cerebras"] img'); await expect(detailMark).toBeVisible(); expect(await detailMark.evaluate(colorAssetRenderContract)).toEqual(COLOR_ASSET_RENDER_CONTRACT); }); + await test.step('going back lands where the user came from', async () => { + await page.getByRole('button', { name: '返回模型连接', exact: true }).click(); + await expect(detail).toHaveCount(0); + // This detail was reached by saving a new provider, not by opening a row, + // so there is no row to go back to and the primary action takes the ring. + await expect(page.getByRole('button', { name: '添加连接', exact: true })).toBeFocused(); + + // Opened from a row, the way back is that row — the ring returns to where + // the user left, not to the top of the list. + await connection.click(); + await expect(detail).toBeVisible(); + await page.getByRole('button', { name: '返回模型连接', exact: true }).click(); + await expect(connection).toBeFocused(); + await connection.click(); + await expect(detail).toBeVisible(); + }); + await test.step('the detail replaces a key and manages enabled and default models', async () => { // A settled credential is a row, not a form: it reports its state and // carries one control. The input only exists while the user is changing it. diff --git a/apps/desktop/e2e/settings.spec.ts b/apps/desktop/e2e/settings.spec.ts index 0411971704..efff2d268e 100644 --- a/apps/desktop/e2e/settings.spec.ts +++ b/apps/desktop/e2e/settings.spec.ts @@ -42,7 +42,11 @@ test('subagent presets can be reviewed and edited in desktop settings', async ({ profile: 'local_read', connectionSlug: connection.slug, model: connection.enabledModelIds?.[0] ?? connection.defaultModel, - enabled: true, + // Seeded DISABLED on purpose: the editor's own switch reads from the + // preset, so this run is what proves saving an unrelated field makes + // the round trip without quietly re-enabling a preset the user + // turned off. + enabled: false, }], }, }); @@ -50,25 +54,144 @@ test('subagent presets can be reviewed and edited in desktop settings', async ({ await page.getByRole('button', { name: '展开侧边栏' }).click(); await page.getByRole('button', { name: '设置' }).click(); - await settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true }).click(); + const navItem = settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true }); + await navItem.click(); const settings = page.getByRole('main', { name: '设置内容' }); await expect(settings.getByRole('heading', { name: '子 Agent', exact: true })).toBeVisible(); await expect(settings.getByText('E2E 快速阅读', { exact: true })).toBeVisible(); - await expect(settings.getByText('可用', { exact: true })).toBeVisible(); + // The row's switch states the disabled preset; a badge beside it would be the + // same fact twice. + const rowSwitch = settings.getByRole('switch', { name: '启用: E2E 快速阅读' }); + await expect(rowSwitch).not.toBeChecked(); + // Arriving is not navigating: focus stays on the settings nav item the user + // just clicked. Only a level change moves it. + await expect(navItem).toBeFocused(); - await settings.getByRole('button', { name: '编辑', exact: true }).click(); - const dialog = page.getByRole('dialog', { name: '编辑子 Agent' }); - const description = dialog.getByRole('textbox', { name: '适用场景' }); - await description.fill('快速阅读代码,并总结关键调用链。'); - await dialog.getByRole('button', { name: '保存', exact: true }).click(); + // The editor is a route level, not a dialog: the list is replaced in place + // and the back affordance is the only way out. + await settings.getByRole('button', { name: '配置“E2E 快速阅读”' }).click(); + await expect(settings.getByRole('heading', { name: 'E2E 快速阅读', exact: true })).toBeVisible(); + await expect(settings.getByRole('button', { name: '添加子 Agent' })).toBeHidden(); + // A level change moves focus to the level itself; without it the chevron + // that had focus unmounts and a keyboard user restarts from document.body. + await expect(settings.locator('[data-maka-contract="subagent-detail"]')).toBeFocused(); + // The level owns the whole preset, so it carries the two things the list row + // deliberately does not: the settled id, and deletion. + await expect(settings.getByText('e2e-fast-reader', { exact: true })).toBeVisible(); + await expect(settings.getByRole('button', { name: '删除', exact: true })).toBeVisible(); + // Renaming is the one edit that could re-key the preset: the id derives from + // the name while creating, and an existing preset must never follow it. + await settings.getByRole('textbox', { name: '显示名称' }).fill('E2E 快速阅读 v2'); + await settings.getByRole('textbox', { name: '适用场景' }).fill('快速阅读代码,并总结关键调用链。'); + await settings.getByRole('button', { name: '保存', exact: true }).click(); - await expect(dialog).toBeHidden(); + await expect(settings.getByRole('button', { name: '添加子 Agent' })).toBeVisible(); await expect(settings.getByText('快速阅读代码,并总结关键调用链。', { exact: true })).toBeVisible(); + // Returning to the list puts focus back on the row the user left from. + await expect(settings.locator('[data-subagent-preset="e2e-fast-reader"]')).toBeFocused(); + await expect.poll(async () => page.evaluate(async () => { + const current = await window.maka.settings.get(); + const preset = current.subagents.presets[0]; + return { id: preset?.id, name: preset?.name, description: preset?.description, enabled: preset?.enabled }; + })).toEqual({ + id: 'e2e-fast-reader', + name: 'E2E 快速阅读 v2', + description: '快速阅读代码,并总结关键调用链。', + enabled: false, + }); +}); + +test('deleting a subagent preset is reversible until the confirm is accepted', async ({ window: page }) => { + await page.evaluate(async () => { + const connections = await window.maka.connections.list(); + const connection = connections[0]; + if (!connection) throw new Error('E2E subagent settings requires a seeded connection'); + await window.maka.settings.update({ + subagents: { + presets: [{ + id: 'e2e-doomed', + name: 'E2E 待删除', + description: '这个配置会在本次测试里被删除。', + profile: 'local_read', + connectionSlug: connection.slug, + model: connection.enabledModelIds?.[0] ?? connection.defaultModel, + enabled: true, + }], + }, + }); + }); + + await page.getByRole('button', { name: '展开侧边栏' }).click(); + await page.getByRole('button', { name: '设置' }).click(); + await settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true }).click(); + + const settings = page.getByRole('main', { name: '设置内容' }); + await settings.getByRole('button', { name: '配置“E2E 待删除”' }).click(); + const deleteButton = settings.getByRole('button', { name: '删除', exact: true }); + + // Cancelling the confirm has to leave the preset alone — the destructive path + // is the one place where "it did nothing" cannot be checked by eye. + await deleteButton.click(); + const confirm = page.getByRole('alertdialog'); + await expect(confirm).toBeVisible(); + await confirm.getByRole('button', { name: '取消', exact: true }).click(); + await expect(confirm).toBeHidden(); + await expect(settings.getByText('e2e-doomed', { exact: true })).toBeVisible(); + + await deleteButton.click(); + await expect(confirm).toBeVisible(); + await confirm.getByRole('button', { name: '删除', exact: true }).click(); + await expect(confirm).toBeHidden(); + + // Deletion is the only way the row a user came from can be missing, so it is + // the only thing that exercises the focus fallback. + await expect(settings.getByText('E2E 待删除', { exact: true })).toBeHidden(); + await expect(settings.getByRole('button', { name: '添加子 Agent' })).toBeFocused(); + await expect.poll(async () => page.evaluate(async () => { + const current = await window.maka.settings.get(); + return current.subagents.presets.length; + })).toBe(0); +}); + +test('a subagent preset can be created disabled and then enabled from its row', async ({ window: page }) => { + await page.evaluate(async () => { + await window.maka.settings.update({ subagents: { presets: [] } }); + }); + + await page.getByRole('button', { name: '展开侧边栏' }).click(); + await page.getByRole('button', { name: '设置' }).click(); + await settingsNavigation(page).getByRole('button', { name: '子 Agent', exact: true }).click(); + + const settings = page.getByRole('main', { name: '设置内容' }); + // The create branch is a structurally different tree from the edit branch — + // a typed id instead of a settled one, and no delete section — so it needs + // its own journey rather than riding on the edit one. + await settings.getByRole('button', { name: '添加子 Agent' }).click(); + await settings.getByRole('textbox', { name: '显示名称' }).fill('E2E Web Research'); + // The id derives from the name until the user takes it over. + await expect(settings.getByRole('textbox', { name: 'subagent_id' })).toHaveValue('e2e-web-research'); + // Taking the id over stops the derivation for good: a later name edit must + // not walk over what the user typed. + await settings.getByRole('textbox', { name: 'subagent_id' }).fill('web-research-owned'); + await settings.getByRole('textbox', { name: '显示名称' }).fill('E2E Web Research 2'); + await expect(settings.getByRole('textbox', { name: 'subagent_id' })).toHaveValue('web-research-owned'); + await settings.getByRole('textbox', { name: '适用场景' }).fill('查找外部资料。'); + await settings.getByRole('switch', { name: '启用', exact: true }).click(); + await settings.getByRole('button', { name: '创建', exact: true }).click(); + + await expect(settings.getByText('E2E Web Research 2', { exact: true })).toBeVisible(); + await expect.poll(async () => page.evaluate(async () => { + const current = await window.maka.settings.get(); + const preset = current.subagents.presets[0]; + return { id: preset?.id, enabled: preset?.enabled }; + })).toEqual({ id: 'web-research-owned', enabled: false }); + + await settings.getByRole('switch', { name: '启用: E2E Web Research 2' }).click(); await expect.poll(async () => page.evaluate(async () => { const current = await window.maka.settings.get(); - return current.subagents.presets[0]?.description; - })).toBe('快速阅读代码,并总结关键调用链。'); + return current.subagents.presets[0]?.enabled; + })).toBe(true); }); test('remote access prioritizes a configured channel that needs attention', async ({ window: page }) => { diff --git a/apps/desktop/src/main/__tests__/subagent-preset-presentation.test.ts b/apps/desktop/src/main/__tests__/subagent-preset-presentation.test.ts index 5e59c0b986..e1ab4fbef3 100644 --- a/apps/desktop/src/main/__tests__/subagent-preset-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/subagent-preset-presentation.test.ts @@ -2,6 +2,8 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import type { LlmConnection, SubagentPreset } from '@maka/core'; import { + nextSubagentDraftForName, + resolveSubagentRoute, subagentPresetAvailability, suggestSubagentPresetId, } from '../../renderer/settings/subagent-preset-presentation.js'; @@ -39,15 +41,26 @@ describe('subagentPresetAvailability', () => { kind: 'available', tone: 'success', }); - assert.equal(subagentPresetAvailability(preset({ enabled: false }), []).kind, 'disabled'); - assert.equal(subagentPresetAvailability(preset(), []).kind, 'missing_connection'); - assert.equal( - subagentPresetAvailability(preset(), [connection({ enabled: false })]).kind, - 'connection_disabled', - ); - assert.equal( - subagentPresetAvailability(preset({ model: 'deepseek-reasoner' }), [connection()]).kind, - 'model_disabled', + // The tone is asserted with the kind, not separately: it is what decides + // the badge variant, so a broken route quietly turning green is a pure-data + // regression this file is the only place to catch. `available` and + // `disabled` render no badge — the switch beside the row says the second — + // but they stay in the value range so callers branch on one thing. + assert.deepEqual(subagentPresetAvailability(preset({ enabled: false }), []), { + kind: 'disabled', + tone: 'neutral', + }); + assert.deepEqual(subagentPresetAvailability(preset(), []), { + kind: 'missing_connection', + tone: 'destructive', + }); + assert.deepEqual(subagentPresetAvailability(preset(), [connection({ enabled: false })]), { + kind: 'connection_disabled', + tone: 'warning', + }); + assert.deepEqual( + subagentPresetAvailability(preset({ model: 'deepseek-reasoner' }), [connection()]), + { kind: 'model_disabled', tone: 'warning' }, ); }); }); @@ -62,3 +75,74 @@ describe('suggestSubagentPresetId', () => { ); }); }); + +describe('nextSubagentDraftForName', () => { + const draft = { name: '', id: '' }; + + it('derives the id from the name until the user takes the id over', () => { + assert.deepEqual(nextSubagentDraftForName(draft, 'Fast Code Reader', false, new Set()), { + name: 'Fast Code Reader', + id: 'fast-code-reader', + }); + // Once the user has typed an id, a later name edit must not overwrite it — + // an editor that silently drops this gate looks identical on screen. + assert.deepEqual( + nextSubagentDraftForName({ name: 'Fast', id: 'my-own-id' }, 'Fast Reader', true, new Set()), + { name: 'Fast Reader', id: 'my-own-id' }, + ); + }); + + it('keeps other draft fields untouched and resolves collisions', () => { + assert.deepEqual( + nextSubagentDraftForName( + { name: '', id: '', model: 'glm-4.7' }, + 'Fast Code Reader', + false, + new Set(['fast-code-reader']), + ), + { name: 'Fast Code Reader', id: 'fast-code-reader-2', model: 'glm-4.7' }, + ); + }); + + it('falls back to a safe id for a name with no ASCII, so two of them differ', () => { + const first = nextSubagentDraftForName(draft, '快速阅读', false, new Set()); + assert.equal(first.id, 'subagent'); + assert.equal( + nextSubagentDraftForName(draft, '网页研究', false, new Set([first.id])).id, + 'subagent-2', + ); + }); +}); + +describe('resolveSubagentRoute', () => { + const fastReader = preset(); + + it('resolves an edit route to the preset it names', () => { + assert.deepEqual(resolveSubagentRoute({ kind: 'edit', presetId: 'fast-reader' }, [fastReader]), { + level: 'edit', + preset: fastReader, + }); + }); + + it('renders the list for an edit route whose preset is gone', () => { + // The whole reason this is a function: `preset: null` is what the editor + // reads as "new", so an edit level that kept rendering with a vanished + // preset would be its create branch — and saving appends rather than + // updates. Falling back to the list is what keeps those two apart. + assert.deepEqual(resolveSubagentRoute({ kind: 'edit', presetId: 'gone' }, [fastReader]), { + level: 'list', + preset: null, + }); + }); + + it('carries list and create through untouched', () => { + assert.deepEqual(resolveSubagentRoute({ kind: 'list' }, [fastReader]), { + level: 'list', + preset: null, + }); + assert.deepEqual(resolveSubagentRoute({ kind: 'create' }, []), { + level: 'create', + preset: null, + }); + }); +}); diff --git a/apps/desktop/src/renderer/locales/settings-subagents-copy.ts b/apps/desktop/src/renderer/locales/settings-subagents-copy.ts index 71f7aad786..67b45224d5 100644 --- a/apps/desktop/src/renderer/locales/settings-subagents-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-subagents-copy.ts @@ -13,55 +13,54 @@ type ProfileCopy = { export type SubagentSettingsCopy = { section: { title: string; - description: string; - count(enabled: number, total: number): string; + count(total: number, max: number): string; add: string; - limitReached: string; emptyTitle: string; emptyDescription: string; }; row: { - edit: string; - remove: string; enabled: string; + configure(name: string): string; fallbackDescription: string; - route(profile: string, connection: string, model: string, thinking?: string): string; }; status: { - available: string; - disabled: string; missingConnection: string; connectionDisabled: string; modelDisabled: string; }; editor: { - createTitle: string; + backToList: string; createSubtitle: string; - editTitle: string; editSubtitle: string; + groupPurpose: string; + groupPurposeHelp: string; + groupRoute: string; + groupRouteHelp: string; + dangerZone: string; + dangerZoneHelp: string; + delete: string; + enabled: string; + enabledDescription: string; name: string; namePlaceholder: string; id: string; idDescription: string; idPlaceholder: string; description: string; - descriptionHelp: string; descriptionPlaceholder: string; profile: string; connection: string; model: string; thinking: string; defaultThinking: string; - enabled: string; - enabledDescription: string; implementationWarning: string; noConnection: string; noModel: string; requiredName: string; - requiredDescription: string; - invalidId: string; + invalidId(max: number): string; duplicateId: string; - invalidRoute: string; + invalidConnection: string; + invalidModel: string; cancel: string; create: string; save: string; @@ -75,6 +74,7 @@ export type SubagentSettingsCopy = { }; toast: { saveFailed: string; + rejected: string; }; profiles: Record; thinking: Record; @@ -84,56 +84,54 @@ const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = { zh: { section: { title: '已批准的子 Agent', - description: '主 Agent 会根据适用场景,从已启用且可用的配置中选择。每个配置固定自己的能力边界、连接和模型。', - count: (enabled, total) => `已启用 ${enabled} / 共 ${total}`, + count: (total, max) => `共 ${total} / ${max} 个配置`, add: '添加子 Agent', - limitReached: '已达到 64 个配置的上限', emptyTitle: '还没有子 Agent 配置', emptyDescription: '添加一个配置后,主 Agent 就能把合适的任务交给独立模型处理。', }, row: { - edit: '编辑', - remove: '删除', enabled: '启用', + configure: (name) => `配置“${name}”`, fallbackDescription: '尚未填写适用场景', - route: (profile, connection, model, thinking) => - `${profile} · ${connection} / ${model}${thinking ? ` · 思考 ${thinking}` : ''}`, }, status: { - available: '可用', - disabled: '已停用', missingConnection: '连接不存在', connectionDisabled: '连接已停用', modelDisabled: '模型未启用', }, editor: { - createTitle: '添加子 Agent', + backToList: '返回子 Agent 列表', createSubtitle: '创建一个可由主 Agent 自动选择的模型配置。', - editTitle: '编辑子 Agent', editSubtitle: '修改适用场景、能力边界和模型路由。', + groupPurpose: '用途', + groupPurposeHelp: '主 Agent 主要根据这里的名称和适用场景挑选配置。', + groupRoute: '能力与模型', + groupRouteHelp: '固定这个子 Agent 能做什么,以及它运行在哪个模型上。', + dangerZone: '删除子 Agent', + dangerZoneHelp: '此操作不可撤销。', + delete: '删除', + enabled: '启用', + enabledDescription: '关闭后配置仍会保留,但主 Agent 暂时不会选择它。', name: '显示名称', namePlaceholder: '快速代码阅读', id: 'subagent_id', idDescription: '创建后保持不变,主 Agent 和历史会话会用它识别此配置。', idPlaceholder: 'fast-reader', description: '适用场景', - descriptionHelp: '写清楚何时应该使用它;主 Agent 主要根据这段描述挑选配置。', descriptionPlaceholder: '适合快速、低成本地阅读大型仓库', profile: '能力 Profile', connection: '模型连接', model: '模型', thinking: '思考级别', defaultThinking: '跟随模型默认', - enabled: '立即启用', - enabledDescription: '启用后,主 Agent 可以选择这个配置。', implementationWarning: '实现代码 Profile 可以写文件和执行命令,并会在隔离 worktree 中运行。', noConnection: '请先在“模型”页启用一个模型连接。', noModel: '所选连接没有已启用的模型。', requiredName: '请输入显示名称。', - requiredDescription: '请说明这个子 Agent 的适用场景。', - invalidId: '只能使用字母、数字、点、下划线、冒号和连字符,最多 128 个字符。', + invalidId: (max) => `只能使用字母、数字、点、下划线、冒号和连字符,最多 ${max} 个字符。`, duplicateId: '这个 subagent_id 已经存在。', - invalidRoute: '请选择已启用的连接和模型。', + invalidConnection: '请选择一个已启用的模型连接。', + invalidModel: '请选择一个已启用的模型。', cancel: '取消', create: '创建', save: '保存', @@ -147,6 +145,7 @@ const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = { }, toast: { saveFailed: '保存子 Agent 配置失败', + rejected: '配置没有被保存。请确认名称长度和配置数量都在上限之内。', }, profiles: { local_read: { label: '代码阅读', description: '只读访问当前工作区,适合搜索、理解和总结代码。' }, @@ -166,56 +165,54 @@ const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = { en: { section: { title: 'Approved subagents', - description: 'The main agent selects from enabled, available presets based on when each should be used. Every preset fixes its capability boundary, connection, and model.', - count: (enabled, total) => `${enabled} enabled · ${total} total`, + count: (total, max) => `${total} of ${max} presets`, add: 'Add subagent', - limitReached: 'The 64-preset limit has been reached', emptyTitle: 'No subagent presets yet', emptyDescription: 'Add a preset so the main agent can delegate suitable work to a separate model.', }, row: { - edit: 'Edit', - remove: 'Remove', enabled: 'Enabled', + configure: (name) => `Configure “${name}”`, fallbackDescription: 'No usage guidance yet', - route: (profile, connection, model, thinking) => - `${profile} · ${connection} / ${model}${thinking ? ` · Thinking ${thinking}` : ''}`, }, status: { - available: 'Available', - disabled: 'Disabled', missingConnection: 'Connection missing', connectionDisabled: 'Connection disabled', modelDisabled: 'Model not enabled', }, editor: { - createTitle: 'Add subagent', + backToList: 'Back to subagents', createSubtitle: 'Create a model preset that the main agent can select automatically.', - editTitle: 'Edit subagent', editSubtitle: 'Change its usage guidance, capability boundary, and model route.', + groupPurpose: 'Purpose', + groupPurposeHelp: 'The main agent selects a preset primarily from the name and guidance here.', + groupRoute: 'Capability and model', + groupRouteHelp: 'Fix what this subagent may do, and which model it runs on.', + dangerZone: 'Remove subagent', + dangerZoneHelp: 'This cannot be undone.', + delete: 'Remove', + enabled: 'Enabled', + enabledDescription: 'Turn this off to keep the preset without letting the main agent select it.', name: 'Display name', namePlaceholder: 'Fast code reader', id: 'subagent_id', idDescription: 'Stable after creation. The main agent and session history use it to identify this preset.', idPlaceholder: 'fast-reader', description: 'When to use', - descriptionHelp: 'Describe when this preset is the right choice. The main agent relies primarily on this guidance.', descriptionPlaceholder: 'Fast, low-cost exploration of large repositories', profile: 'Capability profile', connection: 'Model connection', model: 'Model', thinking: 'Thinking level', defaultThinking: 'Use model default', - enabled: 'Enable immediately', - enabledDescription: 'When enabled, the main agent may select this preset.', implementationWarning: 'The Implementation profile can write files and run commands inside an isolated worktree.', noConnection: 'Enable a model connection on the Models page first.', noModel: 'The selected connection has no enabled models.', requiredName: 'Enter a display name.', - requiredDescription: 'Describe when this subagent should be used.', - invalidId: 'Use only letters, numbers, dots, underscores, colons, and hyphens, up to 128 characters.', + invalidId: (max) => `Use only letters, numbers, dots, underscores, colons, and hyphens, up to ${max} characters.`, duplicateId: 'That subagent_id already exists.', - invalidRoute: 'Select an enabled connection and model.', + invalidConnection: 'Select an enabled model connection.', + invalidModel: 'Select an enabled model.', cancel: 'Cancel', create: 'Create', save: 'Save', @@ -229,6 +226,7 @@ const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = { }, toast: { saveFailed: 'Failed to save subagent presets', + rejected: 'The preset was not saved. Check that its name length and the preset count are within their limits.', }, profiles: { local_read: { label: 'Code reading', description: 'Read-only access to the current workspace for search, understanding, and summaries.' }, diff --git a/apps/desktop/src/renderer/settings/ProvidersPanel.tsx b/apps/desktop/src/renderer/settings/ProvidersPanel.tsx index 1e2639f203..162b064c0e 100644 --- a/apps/desktop/src/renderer/settings/ProvidersPanel.tsx +++ b/apps/desktop/src/renderer/settings/ProvidersPanel.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; import { Badge, Banner, @@ -6,7 +6,6 @@ import { EmptyState, Heading, HStack, - IconButton, List, ListItem, Skeleton, @@ -15,7 +14,7 @@ import { Toolbar, VStack, } from '@astryxdesign/core'; -import { ArrowLeft, ChevronRight } from '@maka/ui/icons'; +import { ChevronRight } from '@maka/ui/icons'; import { type LlmConnection, type ProviderType, @@ -31,6 +30,8 @@ import { type SetupTarget, } from './provider-catalog-page'; import { ConnectionDetail } from './provider-connection-detail'; +import { useSettingsRouteFocus } from './settings-route-focus'; +import { SettingsRouteHeader } from './settings-route-header'; import { ProviderLogo, providerDisplay } from './provider-display'; import { oauthPanelSubtitle } from './provider-oauth-section'; import { providerPanelActionErrorMessage, type ConnectionsBridge } from './provider-panel-shared'; @@ -99,7 +100,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon // Which row the user left the list from, so returning puts focus back where // they were rather than on the page's primary action. const listReturnFocusRef = useRef(null); - const hasNavigatedRef = useRef(false); + const detailTitleId = useId(); const locale = useUiLocale(); const providerCopy = getProviderSettingsCopy(locale); const copy = providerCopy.panel; @@ -199,60 +200,30 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon // as. Deriving that beats scheduling a setState from inside render. const level: PanelRoute['kind'] = route.kind === 'detail' && !selected ? 'list' : route.kind; - // Focus follows the level, to its first meaningful control. Navigating - // without this leaves the ring on `document.body` every time a level - // unmounts, which costs a keyboard user their place on every move. - // - // Navigating, not arriving: the panel does not grab focus when the settings - // page first renders the list — the user is still in the settings nav they - // clicked to get here. - useEffect(() => { - if (loading) return; - if (!hasNavigatedRef.current) { - hasNavigatedRef.current = true; - return; - } - const frame = window.requestAnimationFrame(focusLevel); - return () => window.cancelAnimationFrame(frame); - - function focusLevel() { + useSettingsRouteFocus({ + level, + routeKey: route, + isReady: !loading, + resolveTarget: (current) => { const find = (selector: string) => document.querySelector(selector); - // `preventScroll` because this is a landing, not a jump: the level just - // rendered at the top of the content area, and scrolling to whatever the - // focus target happens to be would push its own header out of view. - const focusFirst = (...selectors: string[]) => { - for (const selector of selectors) { - const element = find(selector); - if (element) return element.focus({ preventScroll: true }); - } - }; - switch (level) { - case 'catalog': - focusFirst('[data-maka-contract="provider-catalog"] input'); - return; - case 'setup': - focusFirst('[data-maka-contract="provider-setup"] input', '[data-maka-contract="provider-setup"]'); - return; - case 'detail': - // The region, not its back button: an IconButton opens its tooltip on - // focus, so focusing one on arrival would pop a tooltip at every - // mouse user who merely clicked a row. - focusFirst('[data-maka-contract="connection-detail"]'); - return; - case 'list': { - // Consumed here and only here: the ref is set on the way down and has - // to survive the levels in between. - const returnToSlug = listReturnFocusRef.current; - listReturnFocusRef.current = null; - // The row the user came from may be gone — that is exactly what - // happens after a deletion — so the primary action is the fallback, - // not the default. - ((returnToSlug ? find(`[data-connection-slug="${returnToSlug}"] button`) : null) - ?? addButtonRef.current)?.focus({ preventScroll: true }); - } + if (current === 'catalog') return find('[data-maka-contract="provider-catalog"] input'); + if (current === 'setup') { + return find('[data-maka-contract="provider-setup"] input') + ?? find('[data-maka-contract="provider-setup"]'); } - } - }, [level, route, loading]); + // The region rather than its back button, so a screen reader announces + // the level the user landed in instead of the way out of it. + if (current === 'detail') return find('[data-maka-contract="connection-detail"]'); + // Consumed here and only here: the ref is set on the way down and has to + // survive the levels in between. The row the user came from may be gone — + // that is exactly what a deletion does — so the primary action is the + // fallback, not the default. + const returnToSlug = listReturnFocusRef.current; + listReturnFocusRef.current = null; + return (returnToSlug ? find(`[data-connection-slug="${returnToSlug}"] button`) : null) + ?? addButtonRef.current; + }, + }); if (loading) { return ( @@ -274,12 +245,12 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon // tabIndex -1 so a route change can land focus on the level itself — // the standard SPA answer to "where does focus go when the page // swaps", and it draws no ring. - - + } + titleId={detailTitleId} title={selected.name} badge={selected.slug === defaultSlug ? : null} subtitle={connectionSubtitle(selected, locale)} @@ -299,10 +270,9 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon ) : level === 'catalog' ? ( - @@ -314,10 +284,9 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon ) : level === 'setup' && route.kind === 'setup' ? ( - } title={copy.connectTitle(route.target.name)} subtitle={route.target.method === 'account' @@ -435,51 +404,6 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon } } -/** - * The way out of any level below the list, spelled once. Modelled on the - * settings-sidebar template's detail view, which puts the same Toolbar inside - * the content area rather than reaching for a second page shell. - */ -function RouteHeader(props: { - onBack(): void; - backLabel: string; - contract: string; - logo?: ReactNode; - title: string; - badge?: ReactNode; - subtitle?: string; -}) { - return ( - -