diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e9a07edc29..f6dcd6ee7f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2775,7 +2775,8 @@ "targetUnavailable": "That computer is no longer available. Choose another computer.", "placementChanged": "This agent’s computer changed. Review its current placement before moving it.", "placementError": "Could not load this agent’s computer placement.", - "moveError": "Could not move this agent. Try again." + "moveError": "Could not move this agent. Try again.", + "machine": "Machine" }, "grants": { "title": "Connector grants", @@ -2791,7 +2792,10 @@ "confirmRevoke": "Yes, revoke it", "cancel": "Cancel", "loadError": "Could not load connector grants.", - "revokeError": "Could not revoke the grant." + "revokeError": "Could not revoke the grant.", + "activeCount": "{{count}} active", + "endedCount": "{{count}} ended", + "retry": "Try again" }, "controls": { "title": "How it runs", @@ -2838,7 +2842,8 @@ "remove": "Remove", "set": "set", "loadError": "Could not load key status.", - "saveError": "Could not update the key. Enter it again to retry." + "saveError": "Could not update the key. Enter it again to retry.", + "neverShown": "never shown again" } }, "agentSettings": { @@ -2951,7 +2956,11 @@ "messageWakeShort_human": "People", "messageWakeShort_all": "All", "turnsUsed": "{{used}} / {{cap}} turns a day", - "turnsUsedNoCap": "{{used}} turns a day · cap unavailable" + "turnsUsedNoCap": "{{used}} turns a day · cap unavailable", + "specialtyCount_one": "{{count}} specialty", + "specialtyCount_other": "{{count}} specialties", + "boundaryCount_one": "{{count}} boundary", + "boundaryCount_other": "{{count}} boundaries" }, "agentPolicy": { "title": "Policy", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 1e62ebb298..e0f548e67d 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2778,7 +2778,8 @@ "targetUnavailable": "这台电脑已不可用,请选择另一台电脑。", "placementChanged": "此智能体的电脑分配已变更。请先查看当前分配,再重新迁移。", "placementError": "无法加载此智能体的电脑分配信息。", - "moveError": "无法迁移此智能体,请重试。" + "moveError": "无法迁移此智能体,请重试。", + "machine": "电脑" }, "grants": { "title": "连接器授权", @@ -2794,7 +2795,10 @@ "confirmRevoke": "确认撤销", "cancel": "取消", "loadError": "无法加载连接器授权。", - "revokeError": "无法撤销授权。" + "revokeError": "无法撤销授权。", + "activeCount": "{{count}}项生效中", + "endedCount": "{{count}}项已结束", + "retry": "重试" }, "controls": { "title": "运行方式", @@ -2841,7 +2845,8 @@ "remove": "移除", "set": "已设置", "loadError": "无法加载密钥状态。", - "saveError": "无法更新密钥。请重新输入后重试。" + "saveError": "无法更新密钥。请重新输入后重试。", + "neverShown": "保存后不再显示" } }, "agentSettings": { @@ -2954,7 +2959,9 @@ "messageWakeShort_human": "真人", "messageWakeShort_all": "全部", "turnsUsed": "{{used}} / {{cap}}次/天", - "turnsUsedNoCap": "{{used}}次/天 · 上限不可用" + "turnsUsedNoCap": "{{used}}次/天 · 上限不可用", + "specialtyCount_other": "{{count}}项专长", + "boundaryCount_other": "{{count}}条行为边界" }, "agentPolicy": { "title": "策略", diff --git a/frontend/src/v2/__tests__/V2AgentPolicy.test.tsx b/frontend/src/v2/__tests__/V2AgentPolicy.test.tsx index 505d1df6ef..643a9d9d78 100644 --- a/frontend/src/v2/__tests__/V2AgentPolicy.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentPolicy.test.tsx @@ -1,6 +1,6 @@ // @ts-nocheck import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import V2AgentPolicy from '../agents/V2AgentPolicy'; jest.mock('axios', () => { @@ -58,7 +58,7 @@ it('hides stored approval and context settings that no runtime enforces', async it('writes only the chosen trust mode and edits memory with its revision', async () => { renderPolicy(); await screen.findByDisplayValue('Original'); - fireEvent.change(screen.getByLabelText('Repository access'), { target: { value: 'workspace' } }); + fireEvent.click(screen.getByRole('radio', { name: 'Allow repository edits' })); fireEvent.click(screen.getByRole('button', { name: 'Save trust mode' })); await waitFor(() => expect(update).toHaveBeenCalledWith({ config: { environment: { sandbox: { mode: 'workspace' } } }, @@ -81,7 +81,8 @@ it('shows server quota for hosted agents without editable trust controls', async it('offers trust mode for the canonical BYO runtime shape', async () => { renderPolicy({ ...installation, runtime: { runtimeType: 'codex', host: 'byo' }, config: { environment: { sandbox: { mode: 'read-only' } } } }); - expect(await screen.findByLabelText('Repository access')).toHaveValue('read-only'); + expect(await screen.findByRole('radiogroup', { name: 'Repository access' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Read only' })).toBeChecked(); }); it('shows a retry in Memory when loading fails, then opens the editor', async () => { @@ -97,3 +98,40 @@ it('shows a retry in Memory when loading fails, then opens the editor', async () fireEvent.click(screen.getByRole('button', { name: 'Try again' })); expect(await screen.findByDisplayValue('Original')).toBeInTheDocument(); }); + +it('shows Saved in the trust row for a moment, then leaves the button alone', async () => { + renderPolicy(); + await screen.findByDisplayValue('Original'); + jest.useFakeTimers(); + try { + fireEvent.click(screen.getByRole('radio', { name: 'Allow repository edits' })); + fireEvent.click(screen.getByRole('button', { name: 'Save trust mode' })); + expect(await screen.findByRole('status')).toHaveTextContent('Saved'); + await act(async () => { jest.advanceTimersByTime(3000); }); + expect(screen.queryByRole('status')).toBeNull(); + expect(screen.getByRole('button', { name: 'Save trust mode' })).toBeInTheDocument(); + } finally { jest.useRealTimers(); } +}); + +it('a failed trust save stays in the action slot', async () => { + update.mockRejectedValueOnce(new Error('Could not save changes.')); + renderPolicy(); + await screen.findByDisplayValue('Original'); + fireEvent.click(screen.getByRole('button', { name: 'Save trust mode' })); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Could not save changes.'); + expect(alert.closest('.ac')).toContainElement(screen.getByRole('button', { name: 'Save trust mode' })); +}); + +it('the memory editor keeps its Saved until the next action', async () => { + renderPolicy(); + await screen.findByDisplayValue('Original'); + jest.useFakeTimers(); + try { + fireEvent.change(screen.getByLabelText('Long-term'), { target: { value: 'Changed' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save memory' })); + expect(await screen.findByRole('status')).toHaveTextContent('Saved'); + await act(async () => { jest.advanceTimersByTime(5000); }); + expect(screen.getByRole('status')).toHaveTextContent('Saved'); + } finally { jest.useRealTimers(); } +}); diff --git a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx index da2520bc86..a2722ae20f 100644 --- a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx @@ -189,3 +189,108 @@ test('does not offer revoke when the server says this viewer cannot revoke', asy expect(await screen.findByText(/Work · Granted by Owner/)).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Revoke' })).toBeNull(); }); + +test('a failed grant load is one row with Try again, which reloads the list', async () => { + let fail = true; + axios.get.mockImplementation((url) => { + if (url.includes('/agent-binding/placement')) return Promise.resolve({ data: { boundMachineId: null, requestedMachineId: null, boundMachineRevoked: false, activeMachines: [] } }); + if (url.includes('/grants') && fail) return Promise.reject(new Error('offline')); + return Promise.resolve({ data: response }); + }); + renderSection(); + const grantsSection = screen.getByRole('heading', { name: 'Connector grants' }).closest('section'); + expect(await within(grantsSection).findByRole('alert')).toHaveTextContent('Could not load connector grants.'); + fail = false; + fireEvent.click(within(grantsSection).getByRole('button', { name: 'Try again' })); + expect(await within(grantsSection).findByText(/Work · Granted by Owner/)).toBeInTheDocument(); + expect(within(grantsSection).queryByRole('alert')).toBeNull(); +}); + +test('a failed revoke says so above the rows, keeps the grant revocable and offers no reload', async () => { + axios.post.mockRejectedValue(new Error('denied')); + renderSection(); + await screen.findByText(/Work · Granted by Owner/); + fireEvent.click(screen.getByRole('button', { name: 'Revoke' })); + fireEvent.click(screen.getByRole('button', { name: 'Yes, revoke it' })); + const grantsSection = screen.getByRole('heading', { name: 'Connector grants' }).closest('section'); + expect(await within(grantsSection).findByRole('alert')).toHaveTextContent('Could not revoke the grant.'); + expect(within(grantsSection).queryByRole('button', { name: 'Try again' })).toBeNull(); + expect(within(grantsSection).getByRole('button', { name: 'Yes, revoke it' })).toBeEnabled(); +}); + +test('the inline confirm swaps back to Revoke on Cancel, and the title counts active and ended grants', async () => { + axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement') + ? { boundMachineId: null, requestedMachineId: null, boundMachineRevoked: false, activeMachines: [] } + : { grants: [...response.grants, { ...response.grants[0], grantId: 'grant-2', connector: 'sentry', revokedAt: '2026-10-20T00:00:00Z' }] } })); + renderSection(); + expect(await screen.findByText('1 active · 1 ended')).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Revoke' })).toHaveLength(1); + fireEvent.click(screen.getByRole('button', { name: 'Revoke' })); + expect(screen.queryByRole('button', { name: 'Revoke' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.getByRole('button', { name: 'Revoke' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Yes, revoke it' })).toBeNull(); + expect(axios.post).not.toHaveBeenCalled(); +}); + +test('names a grant by its connector brand, not its id', async () => { + axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement') + ? { boundMachineId: null, requestedMachineId: null, boundMachineRevoked: false, activeMachines: [] } + : { grants: [{ ...response.grants[0], connector: 'github', tools: ['github.list_issues'] }] } })); + renderSection(); + expect(await screen.findByText('GitHub')).toBeInTheDocument(); +}); + +test('Connector grants is one heading, named by its title and not its count', async () => { + renderSection(); + await screen.findByText('1 active'); + const headings = screen.getAllByRole('heading').filter((node) => node.textContent.includes('Connector grants')); + expect(headings).toHaveLength(1); + expect(headings[0]).toHaveClass('gtitle'); + expect(screen.getByRole('heading', { name: 'Connector grants' })).toBe(headings[0]); +}); + +test('each grant shows its tools as text, so two grants for one connector are told apart', async () => { + axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement') + ? { boundMachineId: null, requestedMachineId: null, boundMachineRevoked: false, activeMachines: [] } + : { grants: [response.grants[0], { ...response.grants[0], grantId: 'grant-2', tools: ['linear.create_issue', 'linear.update_issue'] }] } })); + renderSection(); + expect(await screen.findByText('linear.list_issues')).toBeVisible(); + expect(screen.getByText('linear.create_issue, linear.update_issue')).toBeVisible(); +}); + +test('a revoke error clears on Cancel and when a new confirm opens', async () => { + axios.post.mockRejectedValue(new Error('denied')); + renderSection(); + await screen.findByText(/Work · Granted by Owner/); + const grantsSection = screen.getByRole('heading', { name: 'Connector grants' }).closest('section'); + fireEvent.click(screen.getByRole('button', { name: 'Revoke' })); + fireEvent.click(screen.getByRole('button', { name: 'Yes, revoke it' })); + expect(await within(grantsSection).findByRole('alert')).toHaveTextContent('Could not revoke the grant.'); + fireEvent.click(within(grantsSection).getByRole('button', { name: 'Cancel' })); + expect(within(grantsSection).queryByRole('alert')).toBeNull(); +}); + +test('opening another grant\'s confirm clears the last revoke error', async () => { + axios.post.mockRejectedValue(new Error('denied')); + axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement') + ? { boundMachineId: null, requestedMachineId: null, boundMachineRevoked: false, activeMachines: [] } + : { grants: [response.grants[0], { ...response.grants[0], grantId: 'grant-2', connector: 'sentry', tools: ['sentry.list_issues'] }] } })); + renderSection(); + await screen.findByText('sentry.list_issues'); + const grantsSection = screen.getByRole('heading', { name: 'Connector grants' }).closest('section'); + fireEvent.click(screen.getAllByRole('button', { name: 'Revoke' })[0]); + fireEvent.click(screen.getByRole('button', { name: 'Yes, revoke it' })); + expect(await within(grantsSection).findByRole('alert')).toHaveTextContent('Could not revoke the grant.'); + fireEvent.click(screen.getByRole('button', { name: 'Revoke' })); + expect(within(grantsSection).queryByRole('alert')).toBeNull(); +}); + +test('a revoked machine and a requested one both show', async () => { + axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement') + ? { boundMachineId: null, requestedMachineId: 'new', boundMachineRevoked: true, + activeMachines: [{ machineId: 'new', name: 'New Mac' }] } : response })); + renderSection(); + expect(await screen.findByText('This computer’s key was revoked. Choose another computer to run this agent.')).toBeInTheDocument(); + expect(screen.getByText('Waiting for New Mac to start this agent')).toBeInTheDocument(); +}); diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 4705699567..7292608817 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -661,3 +661,22 @@ test('key polling pauses while hidden and re-reads on focus', async () => { hidden.mockRestore(); } finally { view.unmount(); jest.useRealTimers(); } }); + +test('a name outside 1–80 characters is refused under the field; other refusals sit by Save', async () => { + renderPage(); + const save = await screen.findByRole('button', { name: 'Save identity' }); + const name = screen.getByRole('textbox', { name: 'Display name' }); + fireEvent.change(name, { target: { value: ' ' } }); + fireEvent.click(save); + const error = await screen.findByText('Enter a display name of 1–80 characters.'); + expect(name).toHaveAttribute('aria-invalid', 'true'); + expect(name).toHaveAttribute('aria-describedby', error.id); + expect(axios.patch).not.toHaveBeenCalled(); + + axios.patch.mockRejectedValue({ response: { status: 403, data: {} } }); + fireEvent.change(name, { target: { value: 'Maya 2' } }); + fireEvent.click(save); + await screen.findByText("You don't have permission to change this agent's identity."); + expect(name).not.toHaveAttribute('aria-invalid'); + expect(screen.queryByText('Enter a display name of 1–80 characters.')).toBeNull(); +}); diff --git a/frontend/src/v2/__tests__/V2RuntimeControls.test.tsx b/frontend/src/v2/__tests__/V2RuntimeControls.test.tsx index b82b17053e..fb1e49ff3e 100644 --- a/frontend/src/v2/__tests__/V2RuntimeControls.test.tsx +++ b/frontend/src/v2/__tests__/V2RuntimeControls.test.tsx @@ -27,7 +27,7 @@ test('missing model inventory permits text, but missing effort inventory never p expect(screen.getByText(/max stays stored, not sent/)).toBeInTheDocument(); expect(screen.queryByText(/stored choice unsupported/)).toBeNull(); expect(screen.getByLabelText('effort')).not.toHaveClass('is-unsupported'); - const effortRow = screen.getByLabelText('effort').closest('.v2-runtime-controls__row')!; + const effortRow = screen.getByLabelText('effort').closest('.lrow')!; expect(within(effortRow as HTMLElement).queryByLabelText('pending next spawn')).toBeNull(); fireEvent.change(screen.getByLabelText('model'), { target: { value: 'new-unlisted-model' } }); fireEvent.click(screen.getByRole('button', { name: 'Save runtime' })); @@ -70,7 +70,7 @@ test('OpenCode provider qualification alone does not leave a permanent pending m inventory: [{ harness: 'opencode', installed: true, version: '1', models: null, efforts: [] }], observation: { ...runtime.observation, lastSpawn: { ...receipt, harness: 'opencode', model: 'explicit-provider/saved-model', effort: null } }, }} onSave={jest.fn()} />); - const modelRow = screen.getByLabelText('model').closest('.v2-runtime-controls__row')!; + const modelRow = screen.getByLabelText('model').closest('.lrow')!; expect(within(modelRow as HTMLElement).queryByLabelText('pending next spawn')).toBeNull(); expect(screen.getByText('explicit-provider/saved-model')).toBeInTheDocument(); }); @@ -82,3 +82,47 @@ test('read-only inventory mismatch preserves the saved model without claiming a expect(screen.queryByText(/saved-model · stored/)).toBeNull(); expect(screen.queryByText(/stored choice unsupported/)).toBeNull(); }); + +test('a dot marks only the rows that change on the next spawn', () => { + render(); + const row = (field: string) => screen.getByLabelText(field).closest('.lrow') as HTMLElement; + expect(within(row('model')).getByRole('img', { name: 'pending next spawn' })).toBeInTheDocument(); + expect(within(row('harness')).queryByRole('img', { name: 'pending next spawn' })).toBeNull(); + expect(within(row('effort')).queryByRole('img', { name: 'pending next spawn' })).toBeNull(); +}); + +test('a set key rests as a row; a failed save keeps the entry open, empty, with Save disabled', async () => { + mockApi.get.mockResolvedValue({ set: true, setAt: at, setBy: 'owner', lastUsedAt: null }); + mockApi.put.mockRejectedValue(new Error('proxy')); + render(); + expect(await screen.findByText('never shown again')).toBeInTheDocument(); + expect(screen.getByText('key for anthropic')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Replace key' })); + fireEvent.change(screen.getByLabelText('API key'), { target: { value: 'sk-private-test-value' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save key' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Could not update the key. Enter it again to retry.'); + expect(screen.getByLabelText('API key')).toHaveValue(''); + expect(screen.getByRole('button', { name: 'Save key' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled(); +}); + +test('a row with no confirmation reads "unconfirmed", never "confirmed unconfirmed"', () => { + render(); + const row = screen.getByLabelText('model').closest('.lrow') as HTMLElement; + expect(row.textContent).toContain('last spawn actual-model · unconfirmed'); + expect(row.textContent).not.toContain('confirmed unconfirmed'); +}); +test('a set key keeps its date and Remove when the harness cannot take a key, and while replacing it', async () => { + mockApi.get.mockResolvedValue({ set: true, setAt: at, setBy: 'owner', lastUsedAt: null }); + const view = render(); + expect(await screen.findByText(/^set · /)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument(); + view.unmount(); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Replace key' })); + expect(screen.getByLabelText('API key')).toBeInTheDocument(); + expect(screen.getByText(/^set · /)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument(); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index c4bbeadc74..4445376438 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -5139,7 +5139,8 @@ describe('agent settings surface (D223 lift)', () => { test('segment tabs keep a keyboard focus ring now that the old base rule skips them', () => { expect(ruleBody(css, '.v2-agent-settings-page .seg > button:focus-visible, .v2-agent-settings-page .seg > a:focus-visible')) - .toContain('box-shadow: 0 0 0 3px var(--c-soft)'); + .toContain('box-shadow: var(--v2-focus-ring)'); + expect(css).not.toContain('--v2-accent-soft'); }); test('disabled lifted controls still dim, since the old :disabled rule skips .b and seg tabs', () => { @@ -5173,26 +5174,56 @@ describe('agent settings surface (D223 lift)', () => { }); }); -describe('agent runtime and grants fit the Settings column', () => { - const css = read('../components/V2AgentRuntimeGrants.css'); - test('facts stack on phones and long grant details wrap', () => { - const phone = blockContaining(css, '@media (max-width: 760px)', '.v2-agent-runtime-grants__facts > div'); - expect(phone).toContain('grid-template-columns: 1fr'); - expect(ruleBody(css, '.v2-agent-runtime-grants__list li p')).toContain('overflow-wrap: anywhere'); - }); -}); - -describe('agent Identity editor', () => { - const css = read('../agents/v2-agent-settings.css'); - test('form fields retain a visible boundary over the v2 input reset', () => { - const input = ruleBody(css, '.v2-root .v2-agent-settings .v2-agent-settings__identity input'); - expect(input).toContain('border: 1px solid var(--v2-border)'); - expect(input).toContain('background: var(--v2-surface)'); - }); - test('listing status and public-profile link remain visible in the Settings flow', () => { - expect(ruleBody(css, '.v2-agent-settings__listing')).toContain('display: grid'); - expect(ruleBody(css, '.v2-root .v2-agent-settings .v2-agent-settings__listing a')).toContain('color: var(--v2-accent)'); - expect(ruleBody(css, '.v2-root .v2-agent-settings .v2-agent-settings__listing a:focus-visible')).toContain('box-shadow: var(--v2-focus-ring)'); +describe('agent settings sections lifted from #s-agent-states', () => { + const css = read('../agents/v2-agent-settings-surface.css'); + const phone = blockContaining(css, '@media (max-width: 600px)', '.v2-agent-settings-page__stack'); + const targets = blockContaining(css, '@media (max-width: 760px)', '.v2-agent-settings-page__group .b'); + test('every lifted control is a 44px target on phones', () => { + expect(ruleBody(targets, '.v2-agent-settings-page__group .b, .v2-agent-settings-page__group .b-sm')).toContain('height: 44px'); + expect(ruleBody(targets, '.v2-agent-settings-page__group .sel, .v2-agent-settings-page__group .field:not(.field--area)')).toContain('--box-h: 44px'); + expect(ruleBody(targets, '.v2-agent-settings-page__group .seg > label')).toContain('height: 44px'); + }); + test('setting rows stack on phones and keep their value under the label', () => { + expect(ruleBody(phone, '.v2-agent-settings-page__group .lrow.v2-agent-settings-page__stack', { within: phone })) + .toContain('grid-template-columns: minmax(0, 1fr)'); + expect(ruleBody(phone, '.v2-agent-settings-page__group .v2-agent-settings-page__stack > .vl, .v2-agent-settings-page__group .v2-agent-settings-page__stack > .ac', { within: phone })) + .toContain('grid-row: auto'); + expect(ruleBody(phone, '.v2-agent-settings-page .dbody', { within: phone })).toContain('padding: 0 12px 12px'); + }); + test('grant rows keep the logo column and long reach lines wrap', () => { + expect(ruleBody(css, '.v2-agent-settings-page .lrow.v2-agent-settings-page__grant')).toContain('grid-template-columns: 28px minmax(0, 1fr) auto'); + expect(ruleBody(css, '.v2-agent-settings-page__grantname .mo')).toContain('overflow-wrap: anywhere'); + expect(ruleBody(css, '.v2-agent-settings-page__grantname')).toContain('min-width: 0'); + }); + test('native controls fill the frame box, and the box carries the focus ring', () => { + expect(css).toContain('.v2-agent-settings-page .sel input, .v2-agent-settings-page .sel select { all: unset;'); + expect(ruleBody(css, '.v2-agent-settings-page .field:focus-within, .v2-agent-settings-page .sel:focus-within')).toContain('var(--v2-focus-ring)'); + expect(ruleBody(css, '.v2-agent-settings-page .sel:has(.is-unsupported)')).toContain('inset 0 0 0 2px var(--ink)'); + }); + test('a confirming grant keeps its date under the name and gives Yes/Cancel the full row on phones', () => { + expect(ruleBody(phone, '.v2-agent-settings-page .lrow.v2-agent-settings-page__grant:has(.v2-agent-settings-page__confirm) > .ac', { within: phone })).toContain('display: contents'); + expect(ruleBody(phone, '.v2-agent-settings-page .lrow.v2-agent-settings-page__grant > .ac > .mo', { within: phone })).toContain('grid-column: 2 / -1'); + expect(ruleBody(phone, '.v2-agent-settings-page .lrow.v2-agent-settings-page__grant > .ac > .v2-agent-settings-page__confirm', { within: phone })).toContain('grid-column: 1 / -1'); + }); + test('a select fills its box, so the whole box takes the click, with the value centred', () => { + const select = ruleBody(css, '.v2-agent-settings-page .field select, .v2-agent-settings-page .sel select'); + expect(select).toContain('height: var(--box-h)'); + expect(select).toContain('line-height: var(--box-h)'); + expect(ruleBody(css, '.v2-agent-settings-page .sel')).toContain('--box-h: 28px; height: var(--box-h)'); + expect(ruleBody(css, '.v2-agent-settings-page .field')).toContain('--box-h: 34px; height: var(--box-h)'); + }); + test('a read-only runtime draws its disabled selects as values, without the box ring or chevron', () => { + expect(ruleBody(css, '.v2-agent-settings-page .sel:has(:disabled)')).toContain('box-shadow: none'); + expect(ruleBody(css, '.v2-agent-settings-page .sel:has(:disabled) svg.i')).toContain('display: none'); + }); + test('the identity form does not add a box between the list and its rows', () => { + expect(ruleBody(css, '.v2-agent-settings-page__form')).toContain('display: contents'); + }); + test('the old section sheets are gone', () => { + expect(fs.existsSync(path.join(__dirname, '../components/V2AgentRuntimeGrants.css'))).toBe(false); + expect(fs.existsSync(path.join(__dirname, '../components/V2RuntimeControls.css'))).toBe(false); + expect(read('../agents/v2-agent-settings.css')).not.toMatch(/v2-agent-settings__(identity|listing|owned)/); + expect(read('../agents/v2-agent-policy.css')).not.toContain('v2-agent-policy__quota'); }); }); @@ -5345,15 +5376,6 @@ test('TASK-482 offer follows the final CTA so existing commands and controls can // Actual before/after top coordinates are asserted by e2e/byo-offer-layout.spec.ts. }); -test('TASK-435 runtime columns can shrink on phones without dropping the observed values', () => { - const css = read('../components/V2RuntimeControls.css'); - expect(css).toContain('@media (max-width: 760px)'); - expect(css).toContain('grid-template-columns: minmax(0, 1fr)'); - expect(css).toContain('width: 100%; min-height: 44px'); - expect(ruleBody(css, '.v2-runtime-controls__saved')).toContain('min-width: 0'); - expect(ruleBody(css, '.v2-runtime-controls__observed')).toContain('overflow-wrap: anywhere'); - expect(css).toContain('content: attr(data-label)'); -}); test('teammate invite cards keep one-column phone controls at the 44px minimum', () => { const css = read('../components/V2BringTeammate.css'); diff --git a/frontend/src/v2/agents/V2AgentIdentitySection.tsx b/frontend/src/v2/agents/V2AgentIdentitySection.tsx index 5600f96a33..88e61d4365 100644 --- a/frontend/src/v2/agents/V2AgentIdentitySection.tsx +++ b/frontend/src/v2/agents/V2AgentIdentitySection.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; import { useAuth } from '../../context/AuthContext'; +import V2AgentSettingsIcon from './V2AgentSettingsIcon'; import type { AgentSettingsInstallation, AgentSettingsPatch } from './useAgentSettings'; interface Props { @@ -91,7 +92,7 @@ const V2AgentIdentitySection: React.FC = ({ installation, update }) => { event.preventDefault(); const name = displayName.trim(); if (!name || name.length > 80) { - setError(t('agentSettings.identityNameError')); + setError('agentSettings.identityNameError'); return; } setBusy(true); @@ -118,11 +119,11 @@ const V2AgentIdentitySection: React.FC = ({ installation, update }) => { 503: 'agentSettings.identityMirrorError', }; const code = response?.data?.code; - setError(t(code === 'agent_display_name_taken' + setError(code === 'agent_display_name_taken' ? 'agentSettings.identityNameTakenError' : code === 'identity_projection_retryable' ? 'agentSettings.identityProjectionError' - : messageKey[status || 0] || 'agentSettings.identitySaveError')); + : messageKey[status || 0] || 'agentSettings.identitySaveError'); } finally { setBusy(false); } @@ -147,56 +148,109 @@ const V2AgentIdentitySection: React.FC = ({ installation, update }) => { setListingBusy(false); } }; - return
-

{t('agentSettings.identityTitle')}

-
- - {supportsPersona &&
{t('agentSettings.persona')} - - - -