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')}
-
-
-
{t('agentSettings.listingTitle')}
-
{t(`agentSettings.${listingStatus}`)}
-
- {t('agentSettings.viewPublicProfile')}
-
-
{t('agentSettings.listingSeparate')}
- {ownsListing &&
-
{t('agentSettings.ownedTitle')}
-
-
-
- {listingError &&
{listingError}
}
-
}
+ // Errors are stored as keys so the name refusals can sit under the field and the rest beside Save.
+ const nameError = error === 'agentSettings.identityNameError' || error === 'agentSettings.identityNameTakenError';
+ const alertRow = (text: string) =>
+ {text}
+ ;
+ const specialtyCount = commaList(persona.specialties).length;
+ const boundaryCount = commaList(persona.boundaries).length;
+ return
+
;
+ >;
};
export default V2RuntimeControls;
diff --git a/frontend/src/v2/connectors/ConnectorLogo.tsx b/frontend/src/v2/connectors/ConnectorLogo.tsx
index bc606d283c..e29871bd9f 100644
--- a/frontend/src/v2/connectors/ConnectorLogo.tsx
+++ b/frontend/src/v2/connectors/ConnectorLogo.tsx
@@ -55,6 +55,15 @@ const LOGOS: Record = {
// The connector id a catalog row uses is not always the logo's name.
const ALIASES: Record = { 'github-mcp': 'github' };
+// Brand spellings for the ids above, for surfaces that show a bare connector id.
+const NAMES: Record = {
+ slack: 'Slack', telegram: 'Telegram', github: 'GitHub', linear: 'Linear',
+ atlassian: 'Atlassian', airtable: 'Airtable', sentry: 'Sentry',
+};
+
+/** A connector id as its brand writes it; an unknown id is returned as is. */
+export const connectorName = (type: string): string => NAMES[ALIASES[type] || type] || type;
+
interface Props {
type: string;
size?: number;