diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index b2a6f620a2..497320c15f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2867,6 +2867,21 @@ } }, "agentSettings": { + "name": "Name", + "rename": "Rename", + "keyActions": "Key actions", + "keyUsed": "used {{when}}", + "manageListing": "Manage listing", + "connectors": "Connectors", + "none": "None", + "grantConnector": "Grant a connector", + "memoryNotes_one": "{{count}} note", + "memoryNotes_other": "{{count}} notes", + "memorySaved": "saved {{when}}", + "skillCount_one": "{{count}} skill", + "skillCount_other": "{{count}} skills", + "podCount_one": "{{count}} pod", + "podCount_other": "{{count}} pods", "identityTitle": "Identity", "identityAccess": "Identity & access", "keyActive": "active", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 99358c4738..c1a0c15ce2 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2870,6 +2870,21 @@ } }, "agentSettings": { + "name": "名称", + "rename": "重命名", + "keyActions": "密钥操作", + "keyUsed": "{{when}}使用过", + "manageListing": "管理市场条目", + "connectors": "连接器", + "none": "暂无", + "grantConnector": "授权连接器", + "memoryNotes_one": "{{count}}条笔记", + "memoryNotes_other": "{{count}}条笔记", + "memorySaved": "{{when}}更新", + "skillCount_one": "{{count}}个技能", + "skillCount_other": "{{count}}个技能", + "podCount_one": "{{count}}个 Pod", + "podCount_other": "{{count}}个 Pod", "identityTitle": "身份", "identityAccess": "身份与访问", "keyActive": "已启用", diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index 00a6065e09..a42fc12403 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -308,7 +308,7 @@ const V2App: React.FC = () => { /> , false, false)} + element={feature('Agent settings', , false, false, 'v2-feature--agent-settings')} /> { .toHaveAttribute('href', '/v2/agents/byo?pod=owned-pod&name=local&instanceId=default&reconnect=1&mode=machine'); expect(api.get).toHaveBeenCalledWith('/api/registry/agents/local/manage-settings', { params: { instanceId: 'default' } }); - expect(onConnectionState).toHaveBeenCalledWith('revoked'); + expect(onConnectionState).toHaveBeenCalledWith('revoked', { state: 'revoked', lastSeenAt: null }); }); it('refreshes the header after a key change and drops the old connected state', async () => { @@ -128,3 +128,36 @@ it('uses unavailable only when the roster has no connection signal', async () => render(); await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Connection status unavailable')); }); + +describe('hideWhenConnected (Settings reports a healthy connection in its header)', () => { + const showHidden = (state: 'revoked' | 'connected', runtimeType = 'webhook') => render( + , + ); + + it('renders nothing for a connected webhook agent', () => { + showHidden('connected'); + expect(screen.queryByTestId('agent-control-section')).toBeNull(); + }); + + it('keeps the row for a revoked agent, with its Reconnect action', () => { + showHidden('revoked'); + expect(screen.getByTestId('agent-control-section')).toHaveTextContent('Disconnected: key revoked'); + expect(screen.getByRole('link', { name: 'Reconnect' })).toBeInTheDocument(); + }); + + it('keeps a connected wrapper row, whose machine-key regeneration lives only there', () => { + showHidden('connected', 'wrapper'); + expect(screen.getByRole('link', { name: 'Regenerate key' })).toBeInTheDocument(); + }); + + it('shows nothing while the first read is in flight, and the unavailable alert after a failure', async () => { + api.get.mockImplementation(() => new Promise(() => {})); + const view = render(); + expect(screen.queryByTestId('agent-control-section')).toBeNull(); + view.unmount(); + api.get.mockImplementation(() => Promise.reject(new Error('offline'))); + render(); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Connection status unavailable')); + }); +}); diff --git a/frontend/src/v2/__tests__/V2AgentIdentityOwnedEntry.test.tsx b/frontend/src/v2/__tests__/V2AgentListingRow.test.tsx similarity index 79% rename from frontend/src/v2/__tests__/V2AgentIdentityOwnedEntry.test.tsx rename to frontend/src/v2/__tests__/V2AgentListingRow.test.tsx index 29fb60acff..82c99621d4 100644 --- a/frontend/src/v2/__tests__/V2AgentIdentityOwnedEntry.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentListingRow.test.tsx @@ -1,12 +1,12 @@ // @ts-nocheck -// The registry-entry owner's controls in the agent identity section (TASK-419 +// The registry-entry owner's controls in the agent settings Listing row (TASK-419 // part 2, UI). The GET names the publisher; only that user sees the rename and // retire controls. The server re-checks ownership on every PATCH. import React from 'react'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import axios from '../../utils/axiosConfig'; -import V2AgentIdentitySection from '../agents/V2AgentIdentitySection'; +import V2AgentListingRow from '../agents/V2AgentListingRow'; import { AuthContext } from '../../context/AuthContext'; import i18n, { i18nReady } from '../../i18n'; @@ -15,13 +15,6 @@ jest.mock('../../utils/axiosConfig', () => ({ default: { get: jest.fn(), patch: jest.fn() }, })); -const installation = { - name: 'scout', - instanceId: 'scout-1', - displayName: 'Scout', - profile: null, -}; - const entry = (publisherId, status = 'active') => ({ data: { status, registry: 'public', verified: true, displayName: 'Scout Agent', publisher: { userId: publisherId } }, }); @@ -30,15 +23,17 @@ const renderSection = (viewerId) => { render( - +
, ); }; const retireButton = () => screen.queryByRole('button', { name: 'Retire listing' }); +// The owner controls sit behind the row's Manage listing toggle. +const openOwnerControls = async () => fireEvent.click(await screen.findByRole('button', { name: 'Manage listing' })); -describe('V2AgentIdentitySection — registry entry owner controls (TASK-419)', () => { +describe('V2AgentListingRow — registry entry owner controls (TASK-419)', () => { beforeAll(async () => { await i18nReady; await i18n.changeLanguage('en'); @@ -58,10 +53,19 @@ describe('V2AgentIdentitySection — registry entry owner controls (TASK-419)', // waiting for it makes the absence check run after the owner data lands. await screen.findByText('Published'); expect(retireButton()).toBeNull(); + expect(screen.queryByRole('button', { name: 'Manage listing' })).toBeNull(); + // The public profile link is for every viewer. + expect(screen.getByRole('link', { name: 'View public profile' })).toHaveAttribute('href', '/v2/agent/scout/scout-1'); cleanup(); axios.get.mockResolvedValue(entry('owner-1')); renderSection('owner-1'); + const toggle = await screen.findByRole('button', { name: 'Manage listing' }); + // Closed by default: the owner controls appear only once the toggle opens them. + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(retireButton()).toBeNull(); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); expect(await screen.findByRole('button', { name: 'Retire listing' })).toBeInTheDocument(); }); @@ -69,6 +73,7 @@ describe('V2AgentIdentitySection — registry entry owner controls (TASK-419)', axios.get.mockResolvedValue(entry('owner-1')); axios.patch.mockResolvedValue({ data: { agentName: 'scout', displayName: 'Scout Agent', status: 'unpublished' } }); renderSection('owner-1'); + await openOwnerControls(); fireEvent.click(await screen.findByRole('button', { name: 'Retire listing' })); await waitFor(() => expect(axios.patch).toHaveBeenCalledTimes(1)); @@ -83,6 +88,7 @@ describe('V2AgentIdentitySection — registry entry owner controls (TASK-419)', axios.patch.mockResolvedValue({ data: { agentName: 'scout', displayName: 'Scout Agent', status: 'unpublished' } }); renderSection('owner-1'); await screen.findByText('Published'); + await openOwnerControls(); fireEvent.click(await screen.findByRole('button', { name: 'Retire listing' })); // Without the fix the status line keeps "Published" after the entry is retired. @@ -95,6 +101,7 @@ describe('V2AgentIdentitySection — registry entry owner controls (TASK-419)', axios.patch.mockResolvedValue({ data: { agentName: 'scout', displayName: 'Scout Agent', status: 'active' } }); renderSection('owner-1'); await screen.findByText('Not published'); + await openOwnerControls(); fireEvent.click(await screen.findByRole('button', { name: 'Restore listing' })); expect(await screen.findByText('Published')).toBeInTheDocument(); @@ -104,6 +111,7 @@ describe('V2AgentIdentitySection — registry entry owner controls (TASK-419)', axios.get.mockResolvedValue(entry('owner-1')); axios.patch.mockResolvedValue({ data: {} }); renderSection('owner-1'); + await openOwnerControls(); const input = await screen.findByDisplayValue('Scout Agent'); fireEvent.change(input, { target: { value: ' Scout Pro ' } }); fireEvent.click(screen.getByRole('button', { name: 'Save listing name' })); @@ -118,6 +126,7 @@ describe('V2AgentIdentitySection — registry entry owner controls (TASK-419)', axios.get.mockResolvedValue(entry('owner-1')); axios.patch.mockRejectedValue(new Error('500')); renderSection('owner-1'); + await openOwnerControls(); fireEvent.click(await screen.findByRole('button', { name: 'Retire listing' })); expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't update this listing. Try again."); diff --git a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx index a2722ae20f..d629694173 100644 --- a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx @@ -1,7 +1,8 @@ // @ts-nocheck import React from 'react'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import V2AgentRuntimeGrants, { describeRuntime } from '../components/V2AgentRuntimeGrants'; +import { MemoryRouter } from 'react-router-dom'; +import V2AgentRuntimeBody, { V2AgentConnectorsRow, describeRuntime } from '../components/V2AgentRuntimeGrants'; import { AuthContext } from '../../context/AuthContext'; import { daemonSetupCommand } from '../utils/daemonSetupCommand'; @@ -28,11 +29,19 @@ const response = { }], }; +// The settings page renders the two halves apart: the Connectors row in the +// Identity & access list, the runtime body inside How it runs. const renderSection = (runtime = { runtimeType: 'wrapper', adapter: 'claude', model: 'sonnet' }) => render( - ({ harness, installed: true, version: '1', models: null, efforts: ['low', 'medium', 'high', 'xhigh'] })), ...runtime }} onSaveRuntime={saveRuntime} /> + +
+ ({ harness, installed: true, version: '1', models: null, efforts: ['low', 'medium', 'high', 'xhigh'] })), ...runtime }} onSaveRuntime={saveRuntime} /> +
, ); +// The grants list sits behind the Connectors row's toggle. +const openGrants = () => fireEvent.click(screen.getByRole('button', { name: 'Connector grants' })); +const grantsRegion = () => screen.getByRole('region', { name: 'Connector grants' }); beforeEach(() => { jest.clearAllMocks(); @@ -73,6 +82,30 @@ test('revoked Runtime row offers an active computer and moves in one request', a expect(axios.post).toHaveBeenCalledTimes(1); }); +test('a revoked machine tells the page the body needs attention; a healthy one does not', async () => { + const runtime = { runtimeType: 'wrapper', adapter: 'claude', model: 'sonnet', managed: true }; + const renderBody = (onNeedsAttention) => render( + + + , + ); + axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement') + ? { boundMachineId: 'old', requestedMachineId: null, boundMachineRevoked: true, + activeMachines: [{ machineId: 'new', name: 'New Mac' }] } : response })); + const revoked = jest.fn(); + const { unmount } = renderBody(revoked); + await waitFor(() => expect(revoked).toHaveBeenLastCalledWith(true)); + unmount(); + + axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement') + ? { boundMachineId: 'old', requestedMachineId: null, boundMachineRevoked: false, + activeMachines: [{ machineId: 'old', name: 'Old Mac' }] } : response })); + const healthy = jest.fn(); + renderBody(healthy); + expect(await screen.findByText(/Old Mac/)).toBeInTheDocument(); + expect(healthy).not.toHaveBeenCalledWith(true); +}); + test('two computers with one name are told apart by when each was added', async () => { axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement') ? { boundMachineId: 'old', requestedMachineId: null, boundMachineRevoked: true, @@ -128,6 +161,7 @@ test('shows this agent grant and requires a second click to revoke through the e renderSection(); expect(await screen.findByText('changes apply on the next spawn')).toBeInTheDocument(); expect(screen.getByRole('option', { name: 'Claude Code 1' })).toBeInTheDocument(); + openGrants(); expect(await screen.findByText(/Work · Granted by Owner/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Revoke' })); expect(axios.post).not.toHaveBeenCalled(); @@ -143,7 +177,7 @@ test('model changes delegate to the shared settings hook; a single CLI has no ed await waitFor(() => expect(saveRuntime).toHaveBeenCalledWith({ adapter: 'claude', model: 'opus' })); expect(axios.post).not.toHaveBeenCalled(); - rerender(); + rerender(); expect(await screen.findByText('read only · this seat runs from the cli')).toBeInTheDocument(); expect(screen.getByLabelText('model')).toBeDisabled(); }); @@ -168,17 +202,18 @@ test('a failed runtime save reports next to the editor', async () => { renderSection(); fireEvent.change(screen.getByLabelText('model'), { target: { value: 'opus' } }); fireEvent.click(screen.getByRole('button', { name: 'Save runtime' })); - const runtimeSection = screen.getByRole('heading', { name: 'How it runs' }).closest('section'); + const runtimeSection = screen.getByRole('region', { name: 'How it runs' }); expect(await within(runtimeSection).findByRole('alert')).toHaveTextContent('Could not save runtime settings.'); }); test('a successful revoke stays ended if the grant reload fails', async () => { renderSection(); + openGrants(); await screen.findByText(/Work · Granted by Owner/); axios.get.mockRejectedValue(new Error('reload failed')); 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'); + const grantsSection = grantsRegion(); expect(await within(grantsSection).findByRole('alert')).toHaveTextContent('Could not load connector grants.'); expect(screen.queryByRole('button', { name: 'Revoke' })).toBeNull(); }); @@ -186,6 +221,7 @@ test('a successful revoke stays ended if the grant reload fails', async () => { test('does not offer revoke when the server says this viewer cannot revoke', async () => { axios.get.mockResolvedValue({ data: { ...response, grants: [{ ...response.grants[0], canRevoke: false }] } }); renderSection(); + openGrants(); expect(await screen.findByText(/Work · Granted by Owner/)).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Revoke' })).toBeNull(); }); @@ -198,7 +234,8 @@ test('a failed grant load is one row with Try again, which reloads the list', as return Promise.resolve({ data: response }); }); renderSection(); - const grantsSection = screen.getByRole('heading', { name: 'Connector grants' }).closest('section'); + // A failed load opens the list by itself, so the alert and Try again are in view. + const grantsSection = await screen.findByRole('region', { name: 'Connector grants' }); expect(await within(grantsSection).findByRole('alert')).toHaveTextContent('Could not load connector grants.'); fail = false; fireEvent.click(within(grantsSection).getByRole('button', { name: 'Try again' })); @@ -209,10 +246,11 @@ test('a failed grant load is one row with Try again, which reloads the list', as 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(); + openGrants(); 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'); + const grantsSection = grantsRegion(); 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(); @@ -224,6 +262,7 @@ test('the inline confirm swaps back to Revoke on Cancel, and the title counts ac : { 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(); + openGrants(); expect(screen.getAllByRole('button', { name: 'Revoke' })).toHaveLength(1); fireEvent.click(screen.getByRole('button', { name: 'Revoke' })); expect(screen.queryByRole('button', { name: 'Revoke' })).toBeNull(); @@ -238,16 +277,32 @@ test('names a grant by its connector brand, not its id', async () => { ? { boundMachineId: null, requestedMachineId: null, boundMachineRevoked: false, activeMachines: [] } : { grants: [{ ...response.grants[0], connector: 'github', tools: ['github.list_issues'] }] } })); renderSection(); + openGrants(); expect(await screen.findByText('GitHub')).toBeInTheDocument(); }); -test('Connector grants is one heading, named by its title and not its count', async () => { +test('Connectors is a row with no heading of its own; its grants list is closed until the toggle opens it', 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]); + expect(screen.queryAllByRole('heading').filter((node) => node.textContent.includes('Connector grants'))).toHaveLength(0); + const toggle = screen.getByRole('button', { name: 'Connector grants' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('region', { name: 'Connector grants' })).toBeNull(); + expect(screen.queryByText(/Work · Granted by Owner/)).toBeNull(); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(within(grantsRegion()).getByText(/Work · Granted by Owner/)).toBeInTheDocument(); + fireEvent.click(toggle); + expect(screen.queryByRole('region', { name: 'Connector grants' })).toBeNull(); +}); + +test('an agent without grants reads None, and the grant link goes to the Connectors page', async () => { + axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement') + ? { boundMachineId: null, requestedMachineId: null, boundMachineRevoked: false, activeMachines: [] } + : { grants: [] } })); + renderSection(); + expect(await screen.findByText('None')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Grant a connector' })).toHaveAttribute('href', '/v2/connectors'); }); test('each grant shows its tools as text, so two grants for one connector are told apart', async () => { @@ -255,6 +310,7 @@ test('each grant shows its tools as text, so two grants for one connector are to ? { 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(); + openGrants(); expect(await screen.findByText('linear.list_issues')).toBeVisible(); expect(screen.getByText('linear.create_issue, linear.update_issue')).toBeVisible(); }); @@ -262,8 +318,9 @@ test('each grant shows its tools as text, so two grants for one connector are to test('a revoke error clears on Cancel and when a new confirm opens', async () => { axios.post.mockRejectedValue(new Error('denied')); renderSection(); + openGrants(); await screen.findByText(/Work · Granted by Owner/); - const grantsSection = screen.getByRole('heading', { name: 'Connector grants' }).closest('section'); + const grantsSection = grantsRegion(); 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.'); @@ -277,8 +334,9 @@ test('opening another grant\'s confirm clears the last revoke error', async () = ? { boundMachineId: null, requestedMachineId: null, boundMachineRevoked: false, activeMachines: [] } : { grants: [response.grants[0], { ...response.grants[0], grantId: 'grant-2', connector: 'sentry', tools: ['sentry.list_issues'] }] } })); renderSection(); + openGrants(); await screen.findByText('sentry.list_issues'); - const grantsSection = screen.getByRole('heading', { name: 'Connector grants' }).closest('section'); + const grantsSection = grantsRegion(); 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.'); diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 7292608817..9e6b1e6d4c 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -1,6 +1,6 @@ // @ts-nocheck import React from 'react'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; import V2AgentSettingsPage from '../agents/V2AgentSettingsPage'; import { AuthContext } from '../../context/AuthContext'; @@ -28,6 +28,10 @@ const renderPage = (path = '/v2/agents/maya-claude/default') => render( , ); +// Row panels in the Identity & access list are closed until their toggle opens them. +const openKeys = async () => fireEvent.click(await screen.findByRole('button', { name: 'Key actions' })); +const openRename = async () => fireEvent.click(await screen.findByRole('button', { name: 'Rename' })); + beforeEach(() => { jest.clearAllMocks(); axios.get.mockImplementation((url) => { @@ -78,6 +82,7 @@ test('wrapper key replacement cannot bypass the daemon reconnect flow', async () return Promise.resolve({ data: [] }); }); renderPage(); + await openKeys(); await screen.findByText("Replace this computer's key from Control."); expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Reconnect to regenerate key' })).not.toBeInTheDocument(); @@ -104,6 +109,7 @@ test('revoked webhook key has one Reconnect action in Control', async () => { renderPage(); expect(await screen.findByRole('link', { name: 'Reconnect' })) .toHaveAttribute('href', '/v2/agents/byo?pod=pod-1&name=maya-claude&instanceId=default&reconnect=1'); + await openKeys(); expect(screen.queryByRole('button', { name: 'Regenerate key' })).toBeNull(); }); @@ -127,6 +133,7 @@ test('connected nondefault wrapper regenerates from Control without a second Key renderPage('/v2/agents/maya-claude/second'); expect(await screen.findByRole('link', { name: 'Regenerate key' })) .toHaveAttribute('href', '/v2/agents/byo?pod=pod-1&name=maya-claude&instanceId=second&reconnect=1&mode=machine'); + await openKeys(); expect(screen.queryByRole('button', { name: 'Regenerate key' })).toBeNull(); }); @@ -149,6 +156,7 @@ test('identity edits go through the shared installation PATCH with the exact ins }); axios.patch.mockResolvedValue({ data: { success: true } }); renderPage(); + await openRename(); const save = await screen.findByRole('button', { name: 'Save identity' }); expect(save).toBeDisabled(); fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Maya 2' } }); @@ -179,6 +187,7 @@ test('OpenClaw identity keeps the live Persona editor and saves its changes', as }); axios.patch.mockResolvedValue({ data: { success: true } }); renderPage('/v2/agents/openclaw/scout'); + await openRename(); expect(await screen.findByRole('group', { name: 'Persona' })).toBeInTheDocument(); fireEvent.change(screen.getByRole('textbox', { name: 'Persona notes' }), { target: { value: 'Keep detail' } }); fireEvent.click(screen.getByRole('button', { name: 'Save identity' })); @@ -206,6 +215,7 @@ test('OpenClaw name-only edits leave Persona out of the PATCH', async () => { }); axios.patch.mockResolvedValue({ data: { success: true } }); renderPage('/v2/agents/openclaw/scout'); + await openRename(); expect(await screen.findByRole('group', { name: 'Persona' })).toBeInTheDocument(); fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Scout 2' } }); fireEvent.click(screen.getByRole('button', { name: 'Save identity' })); @@ -222,6 +232,7 @@ test.each([ ])('identity refusal %i uses localized UI copy', async (status, expectedMessage) => { axios.patch.mockRejectedValue({ response: { status, data: { error: 'Raw English server text' } } }); renderPage(); + await openRename(); await screen.findByRole('button', { name: 'Save identity' }); fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Maya 2' } }); fireEvent.click(screen.getByRole('button', { name: 'Save identity' })); @@ -234,6 +245,7 @@ test('a taken display name has its own refusal, separate from the writable-pods code: 'agent_display_name_taken', error: 'Raw English server text', } } }); renderPage(); + await openRename(); await screen.findByRole('button', { name: 'Save identity' }); fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Scout (Cody)' } }); fireEvent.click(screen.getByRole('button', { name: 'Save identity' })); @@ -246,6 +258,7 @@ test('a partial rename tells the installer to retry Save', async () => { status: 503, data: { code: 'identity_projection_retryable', error: 'Raw server text' }, } }); renderPage(); + await openRename(); await screen.findByRole('button', { name: 'Save identity' }); fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Maya 2' } }); fireEvent.click(screen.getByRole('button', { name: 'Save identity' })); @@ -265,6 +278,7 @@ test('nondefault wrapper instances never link to a default-instance reconnect', return Promise.resolve({ data: [] }); }); renderPage('/v2/agents/maya-claude/second'); + await openKeys(); await screen.findByText("Replace this computer's key from Control."); expect(screen.queryByRole('link', { name: 'Reconnect to regenerate key' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument(); @@ -273,6 +287,7 @@ test('nondefault wrapper instances never link to a default-instance reconnect', test('regeneration waits for the explicit running-key replacement confirmation', async () => { axios.post.mockResolvedValue({ data: { token: 'cm_agent_once' } }); renderPage(); + await openKeys(); await screen.findByText('Runtime key · Laptop'); fireEvent.click(screen.getByRole('button', { name: 'Regenerate key' })); expect(axios.post).not.toHaveBeenCalled(); @@ -315,12 +330,13 @@ test('an in-flight replacement never reveals its key on another agent page', asy , ); + await openKeys(); await screen.findByText('Runtime key · Laptop'); fireEvent.click(screen.getByRole('button', { name: 'Regenerate key' })); fireEvent.click(screen.getByRole('button', { name: /replace the running agent/i })); await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1)); fireEvent.click(screen.getByRole('button', { name: 'Next agent' })); - await screen.findByText('Other agent'); + await screen.findByRole('heading', { name: 'Other agent' }); await act(async () => { finishPost({ data: { token: 'cm_agent_old_identity' } }); }); expect(screen.queryByTestId('agent-one-time-key')).not.toBeInTheDocument(); expect(screen.queryByText('cm_agent_old_identity')).not.toBeInTheDocument(); @@ -346,8 +362,10 @@ test('a partial installation owner cannot see identity-wide key controls', async return Promise.reject({ response: { status: 403, data: { message: 'Access denied' } } }); }); renderPage(); - expect(await screen.findByRole('heading', { name: 'Policy' })).toBeInTheDocument(); + expect(await screen.findByRole('region', { name: 'Policy' })).toBeInTheDocument(); expect(await screen.findByText('Key management is unavailable for this agent. Ask an admin for access.')).toBeInTheDocument(); + // No key panel to open: the row offers no Key actions. + expect(screen.queryByRole('button', { name: 'Key actions' })).toBeNull(); expect(screen.getByRole('columnheader', { name: 'Pods' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument(); expect(screen.queryByText('Laptop')).not.toBeInTheDocument(); @@ -357,6 +375,7 @@ test('a partial installation owner cannot see identity-wide key controls', async test('revocation uses the credential id only after confirmation', async () => { axios.delete.mockResolvedValue({ data: { revoked: 1 } }); renderPage(); + await openKeys(); await screen.findByText('Runtime key · Laptop'); fireEvent.click(screen.getByRole('button', { name: 'Revoke' })); expect(axios.delete).not.toHaveBeenCalled(); @@ -375,6 +394,7 @@ test('shows active keys first and keeps revoked history collapsed', async () => ] }) : defaultGet(url, options) )); renderPage(); + await openKeys(); expect(await screen.findByText("Runtime key · Sam's MacBook")).toBeInTheDocument(); const disclosure = screen.getByText('1 revoked'); expect(disclosure.closest('details')).not.toHaveAttribute('open'); @@ -392,6 +412,7 @@ test('a revoked-only key list has a useful empty state and accessible history', )); renderPage(); expect(await screen.findByText('No active keys.')).toBeInTheDocument(); + await openKeys(); expect(screen.getByText('1 revoked').closest('details')).not.toHaveAttribute('open'); }); @@ -411,6 +432,7 @@ test('selects an installation the owner can manage without exposing a foreign po }); axios.post.mockResolvedValue({ data: { token: 'cm_agent_once' } }); renderPage(); + await openKeys(); await screen.findByRole('button', { name: 'Regenerate key' }); await waitFor(() => expect(axios.get).toHaveBeenCalledWith( '/api/skills/pods/owned/imported', @@ -421,12 +443,11 @@ test('selects an installation the owner can manage without exposing a foreign po ); expect(screen.getByText('Pod · My pod')).toBeInTheDocument(); expect(screen.queryByText('Pod · Other pod')).not.toBeInTheDocument(); - expect(screen.getByText('Key', { selector: '.lb' }).compareDocumentPosition( - screen.getByRole('heading', { name: 'Skills' }), - ) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(screen.getByRole('heading', { name: /^Wake/ }).compareDocumentPosition( - screen.getByRole('heading', { name: 'Skills' }), - ) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + const skillsRow = screen.getByText('Skills', { selector: '.lb' }); + expect(screen.getByText('Runtime key', { selector: '.lb' }).compareDocumentPosition(skillsRow) + & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.getByRole('heading', { name: /^Wake/ }).compareDocumentPosition(skillsRow) + & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: 'Regenerate key' })); fireEvent.click(screen.getByRole('button', { name: /replace the running agent/i })); await waitFor(() => expect(axios.post).toHaveBeenCalledWith( @@ -453,21 +474,20 @@ test('runtime editor writes environment controls through the shared settings hoo renderPage(); const model = await screen.findByLabelText('model'); await screen.findByRole('heading', { name: /^Wake/ }); - await screen.findByRole('heading', { name: 'Policy' }); - await screen.findByRole('heading', { name: 'Skills' }); + await screen.findByRole('region', { name: 'Policy' }); // Headings and row labels, in document order. The Wake title carries its tooltip text, so match it by prefix. - const sections = Array.from(document.querySelectorAll('.v2-agent-settings h2, .v2-agent-settings .lb')) + const sections = Array.from(document.querySelectorAll('.v2-agent-settings h2, .v2-agent-settings-page__group > .list > .lrow > .lb, .v2-agent-settings-page__group > .list > details > summary > .lb')) .map((node) => node.textContent.trim()); - const at = (label) => sections.findIndex((text) => (label === 'Wake' ? text.startsWith('Wake') : text === label)); - expect(sections).toEqual(expect.arrayContaining(['Identity', 'Policy', 'Skills'])); - // D215 artboard: Wake first, then Identity & access (Key, Identity). - expect(at('Wake')).toBeLessThan(at('Key')); - expect(at('Key')).toBeLessThan(at('Identity')); - expect(at('Identity')).toBeLessThan(at('How it runs')); - expect(at('Wake')).toBeLessThan(at('How it runs')); - expect(at('How it runs')).toBeLessThan(at('Connector grants')); - expect(at('Connector grants')).toBeLessThan(at('Policy')); - expect(at('Policy')).toBeLessThan(at('Skills')); + // D215 artboard: Wake, then ONE Identity & access list in the frame's row order. This roster + // read carries no connection signal, so the Connection row stays to say so. + expect(sections.filter((text) => !text.startsWith('Wake'))).toEqual([ + 'Identity & access', 'Name', 'Runtime key', 'Connection', 'Marketplace listing', 'Connectors', 'Memory', 'Skills', 'How it runs', + ]); + expect(sections[0].startsWith('Wake')).toBe(true); + // None of the old section headings remain. + for (const gone of ['Identity', 'Policy', 'Skills', 'Connector grants', 'How it runs']) { + expect(screen.queryByRole('heading', { name: gone })).toBeNull(); + } fireEvent.change(model, { target: { value: 'new-model' } }); fireEvent.click(screen.getByRole('button', { name: 'Save runtime' })); await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( @@ -596,6 +616,7 @@ test('the key list re-reads while visible, so an outside call updates Last used' )); const view = renderPage(); try { + await openKeys(); const before = await screen.findByText(/last used/i); const beforeText = before.textContent; used = true; @@ -608,6 +629,7 @@ test('key polling stops on unmount', async () => { jest.useFakeTimers(); const view = renderPage(); try { + await openKeys(); await screen.findByText(/last used/i); view.unmount(); const calls = axios.get.mock.calls.filter(([url]) => url === '/api/credentials').length; @@ -650,6 +672,7 @@ test('key polling pauses while hidden and re-reads on focus', async () => { const view = renderPage(); const keyReads = () => axios.get.mock.calls.filter(([url]) => url === '/api/credentials').length; try { + await openKeys(); await screen.findByText(/last used/i); const hidden = jest.spyOn(document, 'hidden', 'get').mockReturnValue(true); const before = keyReads(); @@ -664,6 +687,7 @@ test('key polling pauses while hidden and re-reads on focus', async () => { test('a name outside 1–80 characters is refused under the field; other refusals sit by Save', async () => { renderPage(); + await openRename(); const save = await screen.findByRole('button', { name: 'Save identity' }); const name = screen.getByRole('textbox', { name: 'Display name' }); fireEvent.change(name, { target: { value: ' ' } }); @@ -680,3 +704,286 @@ test('a name outside 1–80 characters is refused under the field; other refusal expect(name).not.toHaveAttribute('aria-invalid'); expect(screen.queryByText('Enter a display name of 1–80 characters.')).toBeNull(); }); + +describe('one Identity & access list (d215 frame)', () => { + // A connected webhook agent with one active key, a memory index and no skills or grants. + const fleet = ({ state = 'connected', memory = 'ok', skills = 'ok', runtimeType = 'webhook', placement = null } = {}) => (url) => { + if (url.includes('/agent-binding/placement')) return Promise.resolve({ data: placement === 'revoked' + ? { boundMachineId: 'old', requestedMachineId: null, boundMachineRevoked: true, activeMachines: [{ machineId: 'new', name: 'New Mac' }] } + : { boundMachineId: 'old', requestedMachineId: null, boundMachineRevoked: false, activeMachines: [{ machineId: 'old', name: 'Old Mac' }] } }); + if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', installations: [{ podId: 'pod-1', podName: 'Workspace', status: 'active' }], + } }); + if (url === '/api/registry/pods/pod-1/agents') return Promise.resolve({ data: { agents: [{ + name: 'maya-claude', instanceId: 'default', runtime: { runtimeType }, + connection: { state, lastSeenAt: null }, + }] } }); + if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { + name: 'maya-claude', instanceId: 'default', displayName: 'Maya', runtime: { runtimeType }, + } } }); + if (url === '/api/registry/agents/maya-claude') return Promise.resolve({ data: { status: 'active', verified: true } }); + if (url === '/api/agent-memory/maya-claude/default') { + if (memory === 'denied') return Promise.reject({ response: { status: 403 } }); + return Promise.resolve({ data: { viewerRole: 'owner', totalEntries: 3, sections: [ + { key: 'long_term', label: 'Long-term', kind: 'doc', updatedAt: new Date(Date.now() - 2 * 3600_000).toISOString(), + notes: [{ header: 'Rules', snippet: 'a' }, { header: 'How', snippet: 'b' }] }, + { key: 'daily', label: 'Daily', kind: 'log', updatedAt: null, notes: [{ header: '2026-10-30', snippet: 'c' }] }, + ] } }); + } + if (url.startsWith('/api/skills/pods/')) { + if (skills === 'failed') return Promise.reject({ response: { status: 500 } }); + return Promise.resolve({ data: { items: [], canEdit: true, skillLoad: null } }); + } + if (url.includes('/grants')) return Promise.resolve({ data: { grants: [] } }); + if (url === '/api/credentials') return Promise.resolve({ data: [{ + _id: 'tok-1', label: 'Laptop', createdAt: '2026-10-01T00:00:00Z', lastUsedAt: new Date(Date.now() - 5 * 60_000).toISOString(), status: 'active', + }] }); + return Promise.resolve({ data: [] }); + }; + + test('How it runs opens by itself when the machine was revoked, and stays closed when it is healthy', async () => { + axios.get.mockImplementation(fleet({ runtimeType: 'wrapper', placement: 'revoked' })); + const { unmount } = renderPage(); + await waitFor(() => expect(screen.getByTestId('agent-how-it-runs')).toHaveAttribute('open')); + unmount(); + axios.get.mockImplementation(fleet({ runtimeType: 'wrapper', placement: 'healthy' })); + renderPage(); + expect(await screen.findByText(/Old Mac/)).toBeInTheDocument(); + expect(screen.getByTestId('agent-how-it-runs')).not.toHaveAttribute('open'); + }); + + test('How it runs stays open after a successful move clears what opened it', async () => { + let moved = false; + const base = fleet({ runtimeType: 'wrapper', placement: 'revoked' }); + axios.get.mockImplementation((url) => (url.includes('/agent-binding/placement') && moved + ? Promise.resolve({ data: { boundMachineId: 'new', requestedMachineId: null, boundMachineRevoked: false, activeMachines: [{ machineId: 'new', name: 'New Mac' }] } }) + : base(url))); + axios.post.mockImplementation(() => { moved = true; return Promise.resolve({ data: {} }); }); + renderPage(); + const runs = await screen.findByTestId('agent-how-it-runs'); + await waitFor(() => expect(runs).toHaveAttribute('open')); + fireEvent.click(await screen.findByRole('button', { name: 'Move agent' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/agent-binding/move', expect.anything(), expect.anything())); + expect(await screen.findByText(/New Mac/, { selector: 'span' })).toBeInTheDocument(); + expect(runs).toHaveAttribute('open'); + }); + + test('a failed skills read opens the Skills panel, where its error is', async () => { + axios.get.mockImplementation(fleet({ skills: 'failed' })); + renderPage(); + expect(await screen.findByText('Connected · 1 pod')).toBeInTheDocument(); + await waitFor(() => expect(document.getElementById('agent-skills-panel')).not.toHaveAttribute('hidden')); + expect(screen.getByRole('button', { name: 'Skills' })).toHaveAttribute('aria-expanded', 'true'); + }); + + test('a healthy skills read leaves the Skills panel closed', async () => { + axios.get.mockImplementation(fleet()); + renderPage(); + expect(await screen.findByText('Connected · 1 pod')).toBeInTheDocument(); + await waitFor(() => expect(axios.get).toHaveBeenCalledWith(expect.stringMatching(/^\/api\/skills\/pods\//), expect.anything())); + expect(document.getElementById('agent-skills-panel')).toHaveAttribute('hidden'); + }); + + test('a healthy connection lives in the header, and the Connection row is hidden', async () => { + axios.get.mockImplementation(fleet()); + renderPage(); + expect(await screen.findByText('Connected · 1 pod')).toBeInTheDocument(); + expect(document.querySelector('.ph .st .dot')).not.toHaveClass('dot--off'); + expect(screen.queryByTestId('agent-control-section')).toBeNull(); + expect(screen.queryByText('Connection', { selector: '.lb' })).toBeNull(); + }); + + test('a revoked connection dims the header dot and shows the Connection row with Reconnect', async () => { + axios.get.mockImplementation(fleet({ state: 'revoked' })); + renderPage(); + const row = await screen.findByTestId('agent-control-section'); + expect(row).toHaveTextContent('Disconnected: key revoked'); + expect(within(row).getByRole('link', { name: 'Reconnect' })).toHaveAttribute('href', expect.stringContaining('reconnect=1')); + expect(document.querySelector('.ph .st .dot')).toHaveClass('dot--off'); + expect(screen.getByText('Disconnected: key revoked · 1 pod')).toBeInTheDocument(); + }); + + test('the key row reads active with its last use, and Key actions opens the key list (closed by default)', async () => { + axios.get.mockImplementation(fleet()); + renderPage(); + const toggle = await screen.findByRole('button', { name: 'Key actions' }); + expect(screen.getByText('used 5m ago')).toBeInTheDocument(); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('Runtime key · Laptop')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Regenerate key' })).toBeNull(); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('Runtime key · Laptop')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Regenerate key' })).toBeInTheDocument(); + }); + + test('a one-time key forces the key panel open until it is dismissed', async () => { + axios.get.mockImplementation(fleet()); + axios.post.mockResolvedValue({ data: { token: 'cm_agent_once' } }); + renderPage(); + await openKeys(); + fireEvent.click(screen.getByRole('button', { name: 'Regenerate key' })); + fireEvent.click(screen.getByRole('button', { name: /replace the running agent/i })); + expect(await screen.findByTestId('agent-one-time-key')).toHaveTextContent('cm_agent_once'); + const toggle = screen.getByRole('button', { name: 'Key actions' }); + fireEvent.click(toggle); + // Closing the toggle cannot hide a key that is shown only once. + expect(screen.getByTestId('agent-one-time-key')).toBeInTheDocument(); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'Done' })); + expect(screen.queryByTestId('agent-one-time-key')).toBeNull(); + expect(screen.queryByText('Runtime key · Laptop')).toBeNull(); + }); + + test('a key error forces the panel open', async () => { + axios.get.mockImplementation(fleet()); + axios.post.mockRejectedValue({ response: { data: { error: 'Replacement refused' } } }); + renderPage(); + await openKeys(); + fireEvent.click(screen.getByRole('button', { name: 'Regenerate key' })); + fireEvent.click(screen.getByRole('button', { name: /replace the running agent/i })); + expect(await screen.findByText('Replacement refused')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Key actions' })); + expect(screen.getByText('Replacement refused')).toBeInTheDocument(); + }); + + test('Rename is closed by default and opens the name form inside the list', async () => { + axios.get.mockImplementation(fleet()); + renderPage(); + const rename = await screen.findByRole('button', { name: 'Rename' }); + expect(screen.getByText('Maya', { selector: '.lrow .vl' })).toBeInTheDocument(); + expect(rename).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('textbox', { name: 'Display name' })).toBeNull(); + fireEvent.click(rename); + expect(rename).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('textbox', { name: 'Display name' })).toHaveValue('Maya'); + expect(screen.getByRole('button', { name: 'Save identity' }).closest('.list')).toBe(rename.closest('.list')); + }); + + test('Skills is closed by default; its toggle opens the skills panel', async () => { + axios.get.mockImplementation(fleet()); + renderPage(); + const toggle = await screen.findByRole('button', { name: 'Skills' }); + expect(await within(toggle.closest('.lrow')).findByText('None')).toBeInTheDocument(); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('region', { name: 'Skills' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Add skill' })).toBeNull(); + fireEvent.click(toggle); + expect(screen.getByRole('region', { name: 'Skills' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add skill' })).toBeInTheDocument(); + }); + + test('Connectors reads None, opens its grants list, and links to where grants are made', async () => { + axios.get.mockImplementation(fleet()); + renderPage(); + const toggle = await screen.findByRole('button', { name: 'Connector grants' }); + expect(await within(toggle.closest('.lrow')).findByText('None')).toBeInTheDocument(); + expect(screen.queryByRole('region', { name: 'Connector grants' })).toBeNull(); + fireEvent.click(toggle); + expect(within(screen.getByRole('region', { name: 'Connector grants' })).getByText('This agent has no connector grants.')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Grant a connector' })).toHaveAttribute('href', '/v2/connectors'); + }); + + test('How it runs is a closed disclosure whose summary names the runtime', async () => { + axios.get.mockImplementation(fleet()); + renderPage(); + const disc = await screen.findByTestId('agent-how-it-runs'); + expect(disc).not.toHaveAttribute('open'); + expect(disc.querySelector('summary')).toHaveTextContent('webhook'); + expect(within(disc).getByRole('region', { name: 'Policy' })).toBeInTheDocument(); + expect(within(disc).getByRole('region', { name: 'How it runs' })).toBeInTheDocument(); + }); + + test('Memory counts notes the way the Memory tab does, and says nothing when refused', async () => { + axios.get.mockImplementation(fleet()); + const view = renderPage(); + expect(await screen.findByText('3 notes')).toBeInTheDocument(); + expect(screen.getByText('saved 2h ago')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Open memory' })).toHaveAttribute('href', '/v2/agents/maya-claude/default/memory'); + view.unmount(); + + axios.get.mockImplementation(fleet({ memory: 'denied' })); + renderPage(); + await screen.findByRole('link', { name: 'Open memory' }); + await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/agent-memory/maya-claude/default')); + expect(screen.queryByText(/notes?$/)).toBeNull(); + expect(screen.queryByText(/^saved /)).toBeNull(); + }); + + test('no dead buttons: every list control either toggles a panel or links somewhere, and there is no More', async () => { + axios.get.mockImplementation(fleet()); + renderPage(); + await screen.findByRole('button', { name: 'Key actions' }); + expect(screen.queryByRole('button', { name: 'More' })).toBeNull(); + const list = document.querySelector('.v2-agent-settings-page__group > .list'); + const controls = Array.from(list.querySelectorAll(':scope > .lrow > .ac > button, :scope > .lrow > .ac > a')); + expect(controls.length).toBeGreaterThanOrEqual(6); + for (const control of controls) { + if (control.tagName === 'A') { + expect(control.getAttribute('href')).toMatch(/^\/v2\//); + } else { + expect(control).toHaveAttribute('aria-controls'); + expect(control).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(control); + expect(control).toHaveAttribute('aria-expanded', 'true'); + expect(document.getElementById(control.getAttribute('aria-controls'))).not.toBeNull(); + expect(document.getElementById(control.getAttribute('aria-controls'))).not.toHaveAttribute('hidden'); + } + } + }); + + test('Talk to opens the agent room from the header', async () => { + axios.get.mockImplementation(fleet()); + axios.post.mockResolvedValue({ data: { room: { _id: 'room-1' } } }); + render( + + + + } /> + room opened} /> + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'Talk to Maya' })); + expect(await screen.findByText('room opened')).toBeInTheDocument(); + expect(axios.post).toHaveBeenCalledWith('/api/agents/runtime/room', { agentName: 'maya-claude', instanceId: 'default' }); + }); + + describe('phone (390)', () => { + const realMatchMedia = window.matchMedia; + beforeEach(() => { + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: query === '(max-width: 600px)', media: query, onchange: null, + addEventListener: jest.fn(), removeEventListener: jest.fn(), addListener: jest.fn(), removeListener: jest.fn(), + })); + }); + afterEach(() => { window.matchMedia = realMatchMedia; }); + + test('a 52px bar with back to Your team, the tabs as their own row, and every row still reachable', async () => { + axios.get.mockImplementation(fleet()); + render( + + + + } /> + your team} /> + + + , + ); + await screen.findByRole('button', { name: 'Key actions' }); + expect(document.querySelector('.mtop')).not.toBeNull(); + expect(document.querySelector('.ph')).toBeNull(); + expect(screen.queryByRole('button', { name: 'More' })).toBeNull(); + const tabs = screen.getByRole('navigation', { name: 'Agent tabs' }); + expect(tabs.closest('.mtop')).toBeNull(); + expect(tabs).toHaveClass('seg--full'); + for (const label of ['Name', 'Runtime key', 'Marketplace listing', 'Connectors', 'Memory', 'Skills', 'How it runs']) { + expect(screen.getByText(label, { selector: '.lb' })).toBeInTheDocument(); + } + fireEvent.click(screen.getByRole('button', { name: 'Your team' })); + expect(await screen.findByText('your team')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 3d14cbc2d8..1445fb9e6d 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -220,11 +220,27 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(header).toContain('align-items: flex-start'); }); - test('agent Settings phone rows keep the value cell visible under the label', () => { + test('agent Settings phone rows keep the value cell visible, beside the label on one line', () => { const surface = read('../agents/v2-agent-settings-surface.css'); const phone = surface.slice(surface.indexOf('@media (max-width: 600px)')); - expect(ruleBody(phone, '.v2-agent-settings-page .lrow .vl')).toContain('grid-row: 2'); - expect(ruleBody(phone, '.v2-agent-settings-page .lrow .ac')).toContain('grid-row: 1 / span 2'); + // Three columns: label, value, action. The value keeps its own cell and wraps inside it. + expect(ruleBody(phone, '.v2-agent-settings-page .lrow')).toContain('grid-template-columns: max-content minmax(0, 1fr) auto'); + const value = ruleBody(phone, '.v2-agent-settings-page .lrow .vl'); + expect(value).not.toContain('display: none'); + expect(value).not.toContain('grid-row'); + expect(value).toContain('justify-content: flex-end'); + }); + test('a text action folds to a 44px icon button on phone so the value keeps the row', () => { + const surface = read('../agents/v2-agent-settings-surface.css'); + const phone = surface.slice(surface.indexOf('@media (max-width: 600px)')); + expect(ruleBody(phone, '.v2-agent-settings-page .lrow .ac .b.v2-agent-settings-page__textbtn')).toContain('width: 44px'); + expect(ruleBody(phone, '.v2-agent-settings-page__textbtn-label')).toContain('display: none'); + // Its name survives the hidden label. + expect(read('../agents/V2AgentListingRow.tsx')).toMatch(/v2-agent-settings-page__textbtn"[^>]*\n?[^>]*aria-label=\{t\('agentSettings.viewPublicProfile'\)\}/); + }); + test('the lifted page drops the old phone inset; its body pads itself', () => { + const surface = read('../agents/v2-agent-settings-surface.css'); + expect(surface).toContain('.v2-agent-settings.v2-agent-settings-page { padding: 0; }'); }); test('agent Settings buttons retain their chrome over the v2 button reset', () => { @@ -275,6 +291,19 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(selectorRuleBody(targets, '.v2-agent-settings-page .ph .seg > a')).toContain('height: 44px'); }); + test('agent Settings phone bar, full-width tabs and row panels keep their targets', () => { + const surface = read('../agents/v2-agent-settings-surface.css'); + // The 52px phone bar's back and talk buttons, and the tab row under it, are 44px targets. + expect(ruleBody(surface, '.v2-agent-settings-page .mtop')).toContain('height: 52px'); + const bar = ruleBody(surface, '.v2-agent-settings-page .mtop .b.ib'); + expect(bar).toContain('width: 44px'); + expect(bar).toContain('height: 44px'); + expect(ruleBody(surface, '.v2-agent-settings-page .seg--full > a, .v2-agent-settings-page .seg--full > button')).toContain('height: 44px'); + // The Skills panel stays mounted while closed; its own display must not override [hidden]. + expect(ruleBody(surface, '.v2-agent-settings-page__panel[hidden]')).toContain('display: none'); + expect(surface).toContain('.v2-agent-settings-page__panel--pad:not([hidden])'); + }); + test('agent Control reconnect remains an ink action with a focus edge', () => { const action = ruleBody(agentSettings, '.v2-root .v2-agent-settings a.v2-agent-settings__action'); expect(action).toContain('background: var(--v2-ink)'); @@ -5203,6 +5232,14 @@ describe('GitHub gig card fits the chat column', () => { describe('agent settings surface (D223 lift)', () => { const css = read('../agents/v2-agent-settings-surface.css'); + test('the feature-page wrapper does not inset or cap it (v2-feature--agent-settings opt-out)', () => { + expect(ruleBody(css, '.v2-feature--agent-settings > .v2-feature__body')).toContain('padding: 0'); + const legacy = ruleBody(css, '.v2-feature--agent-settings .v2-feature__legacy'); + expect(legacy).toContain('max-width: none'); + expect(legacy).toContain('margin: 0'); + expect(ruleBody(css, '.v2-feature--agent-settings .v2-agent-settings-page .pbody')).toContain('overflow-y: auto'); + expect(read('../V2App.tsx')).toContain(", false, false, 'v2-feature--agent-settings')"); + }); test('the Save action keeps a visible surface over the v2 button reset', () => { expect(ruleBody(css, '.v2-agent-settings-page .b-s')).toContain('background: linear-gradient'); }); @@ -5239,7 +5276,7 @@ describe('agent settings surface (D223 lift)', () => { expect(ruleBody(css, '.v2-agent-settings-page details.disc > summary .lb svg.i')).toContain('width: 16px'); expect(ruleBody(css, '.v2-agent-settings-page .b svg.i, .v2-agent-settings-page .ib svg.i')).toContain('flex: none'); expect(ruleBody(css, '.v2-agent-settings-page .lrow')).toContain('grid-template-columns: 180px minmax(0, 1fr) auto'); - expect(css).toContain('.v2-agent-settings-page .lrow { grid-template-columns: minmax(0, 1fr) auto;'); + expect(css).toContain('.v2-agent-settings-page .lrow { grid-template-columns: max-content minmax(0, 1fr) auto;'); expect(css).toContain('.v2-agent-settings-page .ph { flex-wrap: wrap;'); }); }); diff --git a/frontend/src/v2/agents/V2AgentListingRow.tsx b/frontend/src/v2/agents/V2AgentListingRow.tsx new file mode 100644 index 0000000000..27668454fe --- /dev/null +++ b/frontend/src/v2/agents/V2AgentListingRow.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useState } from 'react'; +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'; + +interface Props { + agentName: string; + instanceId: string; +} + +interface ListingData { + status?: string; registry?: string; ephemeral?: boolean; verified?: boolean; + displayName?: string; publisher?: { userId?: string }; +} + +// One mapper for the GET and for the PATCH answer, so the status line can't drift from the entry. +const listingStatusKey = (data: ListingData): string => { + if (data.ephemeral || data.registry === 'private' || data.status === 'unpublished') return 'listingNotPublished'; + if (data.status === 'pending-review') return 'listingInReview'; + if (data.status === 'deprecated') return 'listingDeprecated'; + if (data.status === 'active') return data.verified ? 'listingPublished' : 'listingNotInCatalog'; + return 'listingNotPublished'; +}; + +// The Identity & access list's Listing row: the registry status and the public +// profile link. Registry owner controls (TASK-419 part 2) sit behind a toggle +// that only the listing's publisher sees; the server re-checks ownership on every PATCH. +const V2AgentListingRow: React.FC = ({ agentName, instanceId }) => { + const { t } = useTranslation(); + const { currentUser } = useAuth(); + const [listingStatus, setListingStatus] = useState('listingLoading'); + const [listing, setListing] = useState(null); + const [listingName, setListingName] = useState(''); + const [listingBusy, setListingBusy] = useState(false); + const [listingError, setListingError] = useState(null); + const [open, setOpen] = useState(false); + + useEffect(() => { + let active = true; + setListingStatus('listingLoading'); + void axios.get(`/api/registry/agents/${encodeURIComponent(agentName)}`) + .then(({ data }) => { + if (!active) return; + setListing(data); + setListingName(data.displayName || ''); + setListingStatus(listingStatusKey(data)); + }).catch((requestError) => { + if (!active) return; + const status = (requestError as { response?: { status?: number } })?.response?.status; + setListingStatus(status === 404 ? 'listingNotPublished' : 'listingUnavailable'); + }); + return () => { active = false; }; + }, [agentName]); + + const entryOwnerId = listing?.publisher?.userId ? String(listing.publisher.userId) : null; + const ownsListing = Boolean(currentUser?._id) && entryOwnerId === String(currentUser._id); + const retired = listing?.status === 'unpublished'; + const saveListing = async (patch: { displayName?: string; retired?: boolean }) => { + setListingBusy(true); + setListingError(null); + try { + const { data } = await axios.patch(`/api/registry/agents/${encodeURIComponent(agentName)}/owned-entry`, patch); + // The PATCH answer carries the new status; verified and registry are not in it, so keep the GET's. + const next = { ...listing, ...data }; + setListing(next); + setListingName(next.displayName || ''); + setListingStatus(listingStatusKey(next)); + } catch { + setListingError(t('agentSettings.ownedFailed')); + } finally { + setListingBusy(false); + } + }; + const panelId = `agent-listing-owner-${instanceId}`; + const panelOpen = ownsListing && (open || Boolean(listingError)); + return <> +
+ {t('agentSettings.listingTitle')} + {t(`agentSettings.${listingStatus}`)} + {ownsListing && {t('agentSettings.ownedTitle')}} + + + {/* On a phone the label folds away and the link is an icon button, so the status keeps the row. */} + + {t('agentSettings.viewPublicProfile')} + + {ownsListing && } + +
+ {panelOpen &&
+ +
+ + +
+ {listingError && + {listingError} + } +
} + ; +}; + +export default V2AgentListingRow; diff --git a/frontend/src/v2/agents/V2AgentMemory.tsx b/frontend/src/v2/agents/V2AgentMemory.tsx index 4931e3bdb2..dcba138abd 100644 --- a/frontend/src/v2/agents/V2AgentMemory.tsx +++ b/frontend/src/v2/agents/V2AgentMemory.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; import V2Avatar from '../components/V2Avatar'; import { usePhone } from '../connectors/usePhone'; +import { useTalkToAgent } from './useTalkToAgent'; import { localizeRelativeTime } from '../utils/localizeRelativeTime'; import '../primitives/primitives.css'; import './V2AgentMemory.css'; @@ -153,8 +154,7 @@ const V2AgentMemory: React.FC = () => { const [state, setState] = useState<'loading' | 'ready' | 'private' | 'failed'>('loading'); const [query, setQuery] = useState(''); const [activeGroup, setActiveGroup] = useState(null); - const [openingRoom, setOpeningRoom] = useState(false); - const [roomError, setRoomError] = useState(false); + const { talkTo, opening: openingRoom, failed: roomError } = useTalkToAgent(agentName, instanceId); // Public header facts (name, avatar, pod count). Readable by any viewer, so the // shell renders the same for owners, admins and everyone else. const [head, setHead] = useState<{ displayName: string; profilePicture?: string; pods: number } | null>(null); @@ -186,22 +186,6 @@ const V2AgentMemory: React.FC = () => { return () => { cancelled = true; }; }, [agentName, instanceId]); - // Same idempotent room endpoint as the profile and Your Team entry points: - // returns the existing DM or opens one. - const talkTo = async () => { - setOpeningRoom(true); - setRoomError(false); - try { - const res = await axios.post<{ room?: { _id?: string } }>('/api/agents/runtime/room', { agentName, instanceId: instanceId || 'default' }); - const roomId = res.data?.room?._id; - if (!roomId) throw new Error('Agent room not returned'); - navigate(`/v2/pods/${roomId}`); - } catch { - setRoomError(true); - setOpeningRoom(false); - } - }; - const profileHref = `/v2/agent/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`; const settingsHref = `/v2/agents/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`; const memoryLabel = t('agentSettings.memory', { defaultValue: 'Memory' }); diff --git a/frontend/src/v2/agents/V2AgentIdentitySection.tsx b/frontend/src/v2/agents/V2AgentNameRow.tsx similarity index 62% rename from frontend/src/v2/agents/V2AgentIdentitySection.tsx rename to frontend/src/v2/agents/V2AgentNameRow.tsx index 88e61d4365..41ba85a39c 100644 --- a/frontend/src/v2/agents/V2AgentIdentitySection.tsx +++ b/frontend/src/v2/agents/V2AgentNameRow.tsx @@ -1,8 +1,5 @@ import React, { useEffect, useState } from 'react'; -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'; @@ -30,24 +27,13 @@ const asPersona = (value: unknown): PersonaFields => { }; }; -interface ListingData { - status?: string; registry?: string; ephemeral?: boolean; verified?: boolean; - displayName?: string; publisher?: { userId?: string }; -} - -// One mapper for the GET and for the PATCH answer, so the status line can't drift from the entry. -const listingStatusKey = (data: ListingData): string => { - if (data.ephemeral || data.registry === 'private' || data.status === 'unpublished') return 'listingNotPublished'; - if (data.status === 'pending-review') return 'listingInReview'; - if (data.status === 'deprecated') return 'listingDeprecated'; - if (data.status === 'active') return data.verified ? 'listingPublished' : 'listingNotInCatalog'; - return 'listingNotPublished'; -}; - -const V2AgentIdentitySection: React.FC = ({ installation, update }) => { +// The Identity & access list's Name row. Rename opens the name (and, for +// OpenClaw, persona) form below the row, inside the list. +const V2AgentNameRow: React.FC = ({ installation, update }) => { const { t } = useTranslation(); const profile = installation.profile; const supportsPersona = installation.name.toLowerCase() === 'openclaw'; + const [open, setOpen] = useState(false); const [displayName, setDisplayName] = useState(installation.displayName || ''); const [persona, setPersona] = useState(() => asPersona(profile?.persona)); const personaChanged = supportsPersona && Object.entries(asPersona(profile?.persona)) @@ -55,31 +41,6 @@ const V2AgentIdentitySection: React.FC = ({ installation, update }) => { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); - const [listingStatus, setListingStatus] = useState('listingLoading'); - // Registry owner controls (TASK-419 part 2). The GET names the publisher; - // the server re-checks ownership on every PATCH. - const { currentUser } = useAuth(); - const [listing, setListing] = useState(null); - const [listingName, setListingName] = useState(''); - const [listingBusy, setListingBusy] = useState(false); - const [listingError, setListingError] = useState(null); - - useEffect(() => { - let active = true; - setListingStatus('listingLoading'); - void axios.get(`/api/registry/agents/${encodeURIComponent(installation.name)}`) - .then(({ data }) => { - if (!active) return; - setListing(data); - setListingName(data.displayName || ''); - setListingStatus(listingStatusKey(data)); - }).catch((requestError) => { - if (!active) return; - const status = (requestError as { response?: { status?: number } })?.response?.status; - setListingStatus(status === 404 ? 'listingNotPublished' : 'listingUnavailable'); - }); - return () => { active = false; }; - }, [installation.name]); useEffect(() => { setDisplayName(installation.displayName || ''); @@ -129,25 +90,6 @@ const V2AgentIdentitySection: React.FC = ({ installation, update }) => { } }; - const entryOwnerId = listing?.publisher?.userId ? String(listing.publisher.userId) : null; - const ownsListing = Boolean(currentUser?._id) && entryOwnerId === String(currentUser._id); - const retired = listing?.status === 'unpublished'; - const saveListing = async (patch: { displayName?: string; retired?: boolean }) => { - setListingBusy(true); - setListingError(null); - try { - const { data } = await axios.patch(`/api/registry/agents/${encodeURIComponent(installation.name)}/owned-entry`, patch); - // The PATCH answer carries the new status; verified and registry are not in it, so keep the GET's. - const next = { ...listing, ...data }; - setListing(next); - setListingName(next.displayName || ''); - setListingStatus(listingStatusKey(next)); - } catch { - setListingError(t('agentSettings.ownedFailed')); - } finally { - setListingBusy(false); - } - }; // 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) => @@ -155,9 +97,19 @@ const V2AgentIdentitySection: React.FC = ({ installation, update }) => { ; const specialtyCount = commaList(persona.specialties).length; const boundaryCount = commaList(persona.boundaries).length; - return
-

{t('agentSettings.identityTitle')}

-
+ const formId = `agent-identity-form-${installation.instanceId}`; + return <> +
+ {t('agentSettings.name')} + {installation.displayName || installation.instanceId} + + + +
+ {open &&
@@ -221,38 +173,8 @@ const V2AgentIdentitySection: React.FC = ({ installation, update }) => {
-
- - {t('agentSettings.listingTitle')} - {t(`agentSettings.${listingStatus}`)} - {ownsListing && {t('agentSettings.ownedTitle')}} - - - -
- {ownsListing && } -
- {ownsListing && <> - - - } - - {t('agentSettings.viewPublicProfile')} - -
- {listingError && alertRow(listingError)} -
-
-
-
; + } + ; }; -export default V2AgentIdentitySection; +export default V2AgentNameRow; diff --git a/frontend/src/v2/agents/V2AgentPolicy.tsx b/frontend/src/v2/agents/V2AgentPolicy.tsx index 5e9ce6c24a..564ab988ff 100644 --- a/frontend/src/v2/agents/V2AgentPolicy.tsx +++ b/frontend/src/v2/agents/V2AgentPolicy.tsx @@ -123,8 +123,8 @@ const V2AgentPolicy: React.FC = ({ agentName, instanceId, installation, u ); const hasQuota = installation?.runtime?.runtimeType === 'hosted' || installation?.runtime?.runtimeType === 'native'; - return
-

{t('agentPolicy.title')}

+ // Lives in the How it runs disclosure; the section is named, not headed. + return
{(canSetTrust || hasQuota) &&
{canSetTrust &&
{t('agentPolicy.repoAccess')} diff --git a/frontend/src/v2/agents/V2AgentSettingsIcon.tsx b/frontend/src/v2/agents/V2AgentSettingsIcon.tsx index abb5226ba5..a3ca7defc0 100644 --- a/frontend/src/v2/agents/V2AgentSettingsIcon.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsIcon.tsx @@ -19,6 +19,13 @@ const PATHS = { plug: , key: <>, rule: <>, + pencil: , + more: <>, + chevr: , + grid: <>, + chat: , + back: , + plus: , id: <>, }; diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index 106649c882..1db5f3dfe5 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -1,13 +1,18 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Link, useParams } from 'react-router-dom'; +import { Link, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; import V2Avatar from '../components/V2Avatar'; -import V2AgentRuntimeGrants, { Runtime } from '../components/V2AgentRuntimeGrants'; +import V2AgentRuntimeBody, { Runtime, V2AgentConnectorsRow, describeRuntime } from '../components/V2AgentRuntimeGrants'; import { AgentControlLoader } from '../components/V2AgentControlSection'; -import { AgentConnection } from '../utils/agentConnection'; +import { AgentConnection, connectionLabel } from '../utils/agentConnection'; +import { usePhone } from '../connectors/usePhone'; +import { localizeRelativeTime } from '../utils/localizeRelativeTime'; import { useAgentSettings } from './useAgentSettings'; -import V2AgentIdentitySection from './V2AgentIdentitySection'; +import { useTalkToAgent } from './useTalkToAgent'; +import V2AgentNameRow from './V2AgentNameRow'; +import V2AgentListingRow from './V2AgentListingRow'; +import V2AgentSettingsIcon from './V2AgentSettingsIcon'; import V2AgentWakePods from './V2AgentWakePods'; import V2AgentPolicy from './V2AgentPolicy'; import V2AgentSkills from '../components/V2AgentSkills'; @@ -31,6 +36,9 @@ const dateLabel = (value: string | null | undefined, t: (key: string) => string) return Number.isNaN(date.getTime()) ? t('agentSettings.unknown') : date.toLocaleString(); }; +// The owner memory index the Memory tab reads; only its counts are used here. +interface MemoryIndex { sections?: Array<{ updatedAt?: string | null; notes?: unknown[] }> } + const errorLabel = (error: unknown, fallback: string): string => ( (error as { response?: { data?: { error?: string } } })?.response?.data?.error || (error instanceof Error && error.message) @@ -50,15 +58,31 @@ const V2AgentSettingsPage: React.FC = () => { const [pendingAction, setPendingAction] = useState<'replace' | string | null>(null); const [busy, setBusy] = useState(false); const [oneTimeKey, setOneTimeKey] = useState(null); - const [connectionState, setConnectionState] = useState(null); + const [connection, setConnection] = useState(null); + const connectionState = connection?.state ?? null; + const [keyOpen, setKeyOpen] = useState(false); + const [skillsOpen, setSkillsOpen] = useState(false); + const [skillCount, setSkillCount] = useState(undefined); + const [runsNeedAttention, setRunsNeedAttention] = useState(false); + const runsRef = useRef(null); + // Open How it runs when its body starts needing action, and leave closing to the person, + // so a fix (a successful move) doesn't snap shut over its own confirmation. + useEffect(() => { + if (runsNeedAttention && runsRef.current) runsRef.current.open = true; + }, [runsNeedAttention]); + const [memory, setMemory] = useState<{ notes: number; updatedAt: string | null } | null>(null); + const navigate = useNavigate(); + // The same width the page's one-line phone rows start at. + const phone = usePhone('(max-width: 600px)'); + const { talkTo, opening: openingRoom, failed: roomFailed } = useTalkToAgent(agentName, instanceId); const [connectionRefreshNonce, setConnectionRefreshNonce] = useState(0); const identityKey = `${agentName}:${instanceId}:${podId || ''}`; const currentIdentity = useRef(identityKey); currentIdentity.current = identityKey; const disconnected = connectionState === 'revoked' || connectionState === 'stale'; - const onConnectionState = useCallback((state: AgentConnection['state'] | null) => { - setConnectionState(state); + const onConnectionState = useCallback((state: AgentConnection['state'] | null, next?: AgentConnection | null) => { + setConnection(next ?? null); if (state === 'revoked' || state === 'stale') setPendingAction(null); }, []); @@ -82,7 +106,11 @@ const V2AgentSettingsPage: React.FC = () => { setOneTimeKey(null); setPendingAction(null); setBusy(false); - setConnectionState(null); + setConnection(null); + setKeyOpen(false); + setSkillsOpen(false); + setSkillCount(undefined); + setRunsNeedAttention(false); if (access && podId) { void fetchKeys().then((rows) => { if (active) { @@ -131,6 +159,24 @@ const V2AgentSettingsPage: React.FC = () => { }; }, [access, podId, fetchKeys]); + // The Memory row's summary, from the same owner/admin index the Memory tab reads. + // A viewer without identity-wide key access is not the owner, so it is not asked. + const memoryReady = keysReadyFor === identityKey && !keysForbidden; + useEffect(() => { + setMemory(null); + if (!memoryReady) return undefined; + let active = true; + void axios.get(`/api/agent-memory/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId || 'default')}`) + .then(({ data }) => { + if (!active || !Array.isArray(data?.sections)) return; + const notes = data.sections.reduce((count, section) => count + (Array.isArray(section.notes) ? section.notes.length : 0), 0); + const updatedAt = data.sections.map((section) => section.updatedAt).filter((value): value is string => Boolean(value)).sort().pop() ?? null; + setMemory({ notes, updatedAt }); + }) + .catch(() => { /* A refused or failed read leaves the row without a summary. */ }); + return () => { active = false; }; + }, [memoryReady, agentName, instanceId]); + const replaceKey = async () => { if (!access || !podId || pendingAction !== 'replace' || busy || installation?.runtime?.runtimeType === 'wrapper') return; const requestIdentity = identityKey; @@ -253,46 +299,101 @@ const V2AgentSettingsPage: React.FC = () => { ); + const displayName = access.displayName || agentName; + const newestUse = activeKeys.map((key) => key.lastUsedAt).filter((value): value is string => Boolean(value)).sort().pop(); + // The key panel stays open whenever it holds something the owner must see or answer. + const keyPanelOpen = keyOpen || Boolean(oneTimeKey || pendingAction || error); + // A failed skills read opens its panel, where the error is. + const skillsShown = skillsOpen || skillCount === null; + const runtimeDetails = describeRuntime(runtime); + const runtimeSummary = [runtimeDetails.adapter || runtime.adapter || runtime.runtimeType, runtime.model].filter(Boolean).join(' · '); + const processState = runtime.processConnected === true ? 'connected' : runtime.processConnected === false ? 'disconnected' : 'notReported'; + const memoryHref = `/v2/agents/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}/memory`; + const talkLabel = t('agentProfile.hero.talkTo', { name: displayName }); + const talkButton = ( + + ); + const status = ( +
+ {connection &&
+ ); + const tabs = ( + + ); + return (
-
- -
-

{access.displayName || agentName}

-
{t('agentProfile.hero.activeInPods', { count: access.pods.length })}
-
- - -
+ {phone ? ( +
+ + +

{displayName}

+ {talkButton} +
+ ) : ( +
+ +
+

{displayName}

+ {status} +
+ + {tabs} + + {talkButton} +
+ )}
+ {phone && <>{tabs}
{status}
} + {roomFailed &&

{t('agentProfile.hero.openRoomFailed')}

} -

{t('agentSettings.identityAccess')}

-
- -
- - {keysForbidden ? t('agentSettings.keyUnavailable') : activeKeys.length ? t('agentSettings.keyActive') : t('agentSettings.noActiveKeys')} - - {keysForbidden ? null : installation?.runtime?.runtimeType === 'wrapper' ? ( - {t('agentSettings.wrapperReconnect')} - ) : !disconnected && ( - - )} - -
-
+
+

{t('agentSettings.identityAccess')}

+
+ {installation && } +
+ {t('agentSettings.runtimeKey')} + + {keysForbidden ? t('agentSettings.keyUnavailable') : <> + + + {!keysForbidden && ( + + )} + +
+ {keyPanelOpen &&
{error &&

{error}

} + {!keysForbidden && (installation?.runtime?.runtimeType === 'wrapper' ? ( +

{t('agentSettings.wrapperReconnect')}

+ ) : !disconnected && ( +
+ +
+ ))} {pendingAction === 'replace' && !keysForbidden && (
-

{t('agentByo.rekey.warning', { name: access.displayName || agentName })}

+

{t('agentByo.rekey.warning', { name: displayName })}

@@ -316,34 +417,70 @@ const V2AgentSettingsPage: React.FC = () => { } )} +
} + + + +
+ {t('agentSettings.memory', { defaultValue: 'Memory' })} + + {memory && <> + {t('agentSettings.memoryNotes', { count: memory.notes })} + {memory.updatedAt && {t('agentSettings.memorySaved', { when: localizeRelativeTime(memory.updatedAt, t, { pastEvent: true }) })}} + } + + + + + + +
+
+ {t('agentSkills.title')} + + {typeof skillCount === 'number' && (skillCount > 0 + ? t('agentSettings.skillCount', { count: skillCount }) + : {t('agentSettings.none')})} + + + + +
+ {/* Mounted while closed, so the row can count what the panel loads. */} + + {/* Opens by itself when the machine placement needs a move or failed. */} +
+ + {t('agentManage.controls.title')} + {runtimeSummary || t('agentManage.controls.notReported')} + {t(`agentManage.controls.${processState}`)} + + + +
+ { + await updateRuntime(changes); + } : undefined} + /> + +
+
-
- {t('agentSettings.memory', { defaultValue: 'Memory' })} - - - - - - -
-
- {installation && } - { - await updateRuntime(changes); - } : undefined} - /> - -
- pod.id === podId)?.name || podId} agentName={agentName} instanceId={instanceId} /> -
+
diff --git a/frontend/src/v2/agents/useTalkToAgent.ts b/frontend/src/v2/agents/useTalkToAgent.ts new file mode 100644 index 0000000000..1ac2b0de56 --- /dev/null +++ b/frontend/src/v2/agents/useTalkToAgent.ts @@ -0,0 +1,25 @@ +import { useCallback, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import axios from '../../utils/axiosConfig'; + +// Opens (or reuses) the 1:1 room with an agent and navigates to it. The same +// idempotent room endpoint the profile and Your Team entry points call. +export const useTalkToAgent = (agentName: string, instanceId: string) => { + const navigate = useNavigate(); + const [opening, setOpening] = useState(false); + const [failed, setFailed] = useState(false); + const talkTo = useCallback(async () => { + setOpening(true); + setFailed(false); + try { + const res = await axios.post<{ room?: { _id?: string } }>('/api/agents/runtime/room', { agentName, instanceId: instanceId || 'default' }); + const roomId = res.data?.room?._id; + if (!roomId) throw new Error('Agent room not returned'); + navigate(`/v2/pods/${roomId}`); + } catch { + setFailed(true); + setOpening(false); + } + }, [agentName, instanceId, navigate]); + return { talkTo, opening, failed }; +}; diff --git a/frontend/src/v2/agents/v2-agent-settings-surface.css b/frontend/src/v2/agents/v2-agent-settings-surface.css index 71e9315fc8..2caa86e45b 100644 --- a/frontend/src/v2/agents/v2-agent-settings-surface.css +++ b/frontend/src/v2/agents/v2-agent-settings-surface.css @@ -36,6 +36,14 @@ font-family: var(--f-body); } +/* The frame's header spans the pane and the body scrolls under it, so the feature-page + wrapper neither insets nor caps this page: the route passes v2-feature--agent-settings, + the same opt-out the memory page uses. */ +.v2-feature--agent-settings > .v2-feature__body { padding: 0; display: flex; flex-direction: column; overflow: hidden; } +.v2-feature--agent-settings .v2-feature__legacy { flex: 1 1 auto; width: 100%; max-width: none; min-height: 0; margin: 0; display: flex; flex-direction: column; } +.v2-feature--agent-settings .v2-agent-settings-page { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } +.v2-feature--agent-settings .v2-agent-settings-page .pbody { overflow-y: auto; } + /* buttons */ .v2-agent-settings-page .b { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 34px; padding: 0 13px; border-radius: 10px; font: 600 13px/1 var(--f-body); cursor: pointer; white-space: nowrap; transition: transform 120ms, background 160ms, box-shadow 140ms; } .v2-agent-settings-page .b svg.i { width: 16px; height: 16px; } @@ -128,10 +136,13 @@ .v2-agent-settings-page .ph { flex-wrap: wrap; min-height: 0; padding: 14px 16px; row-gap: 10px; } .v2-agent-settings-page .ph h1 { white-space: normal; overflow-wrap: anywhere; } .v2-agent-settings-page .ph .sp { display: none; } - .v2-agent-settings-page .lrow { grid-template-columns: minmax(0, 1fr) auto; min-height: 48px; padding: 8px 12px; } + /* The 390 frame keeps each row on one 48px line: the label, the value beside it, the + action. A long value (a refusal, a status) wraps inside its own cell, never under the label. */ + .v2-agent-settings-page .lrow { grid-template-columns: max-content minmax(0, 1fr) auto; gap: 10px; min-height: 48px; padding: 4px 4px 4px 12px; } .v2-agent-settings-page .lrow .lb { font-weight: 500; } - .v2-agent-settings-page .lrow .vl { grid-column: 1; grid-row: 2; font-size: 12px; } - .v2-agent-settings-page .lrow .ac { grid-row: 1 / span 2; } + .v2-agent-settings-page .lrow .vl { justify-content: flex-end; text-align: right; font-size: 12px; color: var(--mu); } + .v2-agent-settings-page .lrow .ac .b.v2-agent-settings-page__textbtn { width: 44px; padding: 0; } + .v2-agent-settings-page__textbtn-label { display: none; } } @media (max-width: 760px) { .v2-agent-settings-page .wt thead { display: none; } @@ -247,9 +258,8 @@ .v2-agent-settings-page__confirm { display: inline-flex; gap: 6px; } @media (max-width: 600px) { - .v2-agent-settings-page details.disc > summary { grid-template-columns: minmax(0, 1fr) auto; row-gap: 4px; padding: 8px 12px; } - .v2-agent-settings-page details.disc > summary .vl { grid-column: 1; grid-row: 2; font-size: 12px; } - .v2-agent-settings-page details.disc > summary .chev { grid-column: 2; grid-row: 1; } + .v2-agent-settings-page details.disc > summary { grid-template-columns: max-content minmax(0, 1fr) auto; gap: 10px; min-height: 48px; padding: 4px 14px 4px 12px; } + .v2-agent-settings-page details.disc > summary .vl { justify-content: flex-end; text-align: right; font-size: 12px; color: var(--mu); } .v2-agent-settings-page .dbody { padding: 0 12px 12px; } .v2-agent-settings-page__pair { grid-template-columns: minmax(0, 1fr); gap: 4px; } .v2-agent-settings-page__pair > .mu, .v2-agent-settings-page__pair--top > .mu { font-size: 12px; padding-top: 0; } @@ -297,3 +307,29 @@ .v2-agent-settings-page .v2-agent-wake-pods__toggle { min-width: 44px; min-height: 44px; align-items: center; } .v2-agent-settings-page .ph .seg > a, .v2-agent-settings-page .ph .seg > button { height: 44px; } } + +/* One Identity & access list: each row's panel opens below it, inside the list. */ +.v2-agent-settings-page__panel { border-top: 1px solid var(--d); } +.v2-agent-settings-page__panel[hidden] { display: none; } +.v2-agent-settings-page__panel--pad:not([hidden]) { padding: 12px 16px; display: grid; gap: 10px; } +.v2-agent-settings-page__toggle svg.i { transition: transform 160ms; } +.v2-agent-settings-page__toggle[aria-expanded="true"] svg.i { transform: rotate(180deg); } +/* How it runs holds whole lists, so its body drops the value-column indent. */ +.v2-agent-settings-page .dbody.v2-agent-settings-page__runsbody { padding: 0 16px 14px; gap: 12px; } +.v2-agent-settings-page .v2-agent-skills__head { justify-content: flex-end; } +.v2-agent-settings-page .v2-agent-skills__head:empty { display: none; } + +/* Phone (frame 390): a 52px bar with back, avatar and name; the tabs as a full-width row under it. */ +.v2-agent-settings-page .mtop { display: flex; align-items: center; gap: 8px; height: 52px; padding: 0 8px; border-bottom: 1px solid var(--d); box-sizing: border-box; } +.v2-agent-settings-page .mtop .v2-avatar { flex: none; } +.v2-agent-settings-page .mtop .tt2 { flex: 1; min-width: 0; margin: 0; font: 700 16px/1.2 var(--f-display); letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.v2-agent-settings-page .mtop .b.ib { width: 44px; height: 44px; } +.v2-agent-settings-page .seg.seg--full { display: flex; width: 100%; box-sizing: border-box; } +.v2-agent-settings-page .seg--full > a, .v2-agent-settings-page .seg--full > button { flex: 1; justify-content: center; height: 44px; } +.v2-agent-settings-page__phonestatus .st { display: flex; align-items: center; gap: 6px; margin: 12px 4px 10px; font: 500 11.5px/1 var(--f-mono); color: var(--mu); } +@media (max-width: 760px) { + .v2-agent-settings.v2-agent-settings-page { padding: 0; } + .v2-agent-settings-page .pbody { padding: 12px; } + .v2-root .v2-agent-settings-page .v2-agent-skills button { min-height: 44px; } + .v2-agent-settings-page .v2-agent-settings-page__panel--pad:not([hidden]) { padding: 10px 12px; } +} diff --git a/frontend/src/v2/components/V2AgentControlSection.tsx b/frontend/src/v2/components/V2AgentControlSection.tsx index 676ff50f48..cc0fbafe5f 100644 --- a/frontend/src/v2/components/V2AgentControlSection.tsx +++ b/frontend/src/v2/components/V2AgentControlSection.tsx @@ -10,14 +10,18 @@ interface Props { podId: string; runtimeType?: string; connection: AgentConnection; + /** Settings reports a healthy connection in its header, so the row renders only when it has more to say. */ + hideWhenConnected?: boolean; } // Owner/admin-only: the parent must obtain these props from an authenticated // roster read after its server-gated Settings access check. -const V2AgentControlSection: React.FC = ({ agentName, instanceId, podId, runtimeType, connection }) => { +const V2AgentControlSection: React.FC = ({ agentName, instanceId, podId, runtimeType, connection, hideWhenConnected }) => { const { t } = useTranslation(); const needsReconnect = connection.state === 'revoked' || connection.state === 'stale'; const canRegenerateMachineKey = runtimeType === 'wrapper' && connection.state === 'connected'; + // A connected wrapper keeps the row: its machine-key regeneration lives only here. + if (hideWhenConnected && connection.state === 'connected' && !canRegenerateMachineKey) return null; return (
{t('agentSettings.connection')} @@ -43,8 +47,9 @@ export const AgentControlLoader: React.FC<{ agentName: string; instanceId: string; refreshNonce?: number; - onConnectionState?: (state: AgentConnection['state'] | null) => void; -}> = ({ agentName, instanceId, refreshNonce, onConnectionState }) => { + onConnectionState?: (state: AgentConnection['state'] | null, connection?: AgentConnection | null) => void; + hideWhenConnected?: boolean; +}> = ({ agentName, instanceId, refreshNonce, onConnectionState, hideWhenConnected }) => { const { t } = useTranslation(); const [status, setStatus] = useState<(Props & { podId: string }) | null>(null); const [unavailable, setUnavailable] = useState(false); @@ -76,13 +81,13 @@ export const AgentControlLoader: React.FC<{ setStatus({ agentName, instanceId, podId, runtimeType: match.runtime?.runtimeType, connection: match.connection }); setUnavailable(false); - onConnectionState?.(match.connection.state); + onConnectionState?.(match.connection.state, match.connection); } } catch { if (active && currentRequest === requestId) { setStatus(null); setUnavailable(true); - onConnectionState?.(null); + onConnectionState?.(null, null); } } }; @@ -91,7 +96,9 @@ export const AgentControlLoader: React.FC<{ return () => { active = false; window.clearInterval(interval); }; }, [agentName, instanceId, refreshNonce, onConnectionState]); - if (status) return ; + if (status) return ; + // While the first read is in flight there is nothing to report yet. + if (hideWhenConnected && !unavailable) return null; return
{t('agentSettings.connection')} diff --git a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx index b0806ccfc0..e943a6eb44 100644 --- a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx +++ b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { machineDisplayName } from '../utils/machineLabel'; import { useV2Api } from '../hooks/useV2Api'; @@ -42,6 +43,8 @@ interface Props { instanceId: string; runtime: Runtime; onSaveRuntime?: (update: RuntimeUpdate) => Promise; + /** Told when the body holds something to act on, so a closed disclosure around it can open. */ + onNeedsAttention?: (needs: boolean) => void; } const WALKED_ADAPTERS: Record = { @@ -71,17 +74,12 @@ const dateLabel = (value: string | null): string => { const grantEnded = (grant: AgentGrant): boolean => Boolean(grant.revokedAt) || new Date(grant.expiresAt).getTime() <= Date.now(); -const V2AgentRuntimeGrants: React.FC = ({ - name, instanceId, runtime, onSaveRuntime, +// The body of the How it runs disclosure: where it runs, the runtime controls and the own-key entry. +const V2AgentRuntimeBody: React.FC = ({ + name, instanceId, runtime, onSaveRuntime, onNeedsAttention, }) => { const { t, i18n } = useTranslation(); const api = useV2Api(); - const [grants, setGrants] = useState([]); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(''); - const [revokeError, setRevokeError] = useState(''); - const [busy, setBusy] = useState(false); - const [confirmGrantId, setConfirmGrantId] = useState(null); const [placement, setPlacement] = useState(null); const [machineChoice, setMachineChoice] = useState(''); const [placementError, setPlacementError] = useState(''); @@ -112,22 +110,6 @@ const V2AgentRuntimeGrants: React.FC = ({ return next; }, [api, name, instanceId]); - const load = useCallback(async () => { - setLoading(true); - setLoadError(''); - try { - const next = await api.get( - `/api/registry/agents/${encodeURIComponent(name)}/instances/${encodeURIComponent(instanceId)}/grants`, - ); - setGrants(next.grants || []); - } catch { - setLoadError(t('agentManage.grants.loadError', { defaultValue: 'Could not load connector grants.' })); - } finally { - setLoading(false); - } - }, [api, name, instanceId, t]); - - useEffect(() => { void load(); }, [load]); useEffect(() => { if (runtime.runtimeType !== 'wrapper') return; setPlacement(null); @@ -136,23 +118,6 @@ const V2AgentRuntimeGrants: React.FC = ({ }, [runtime.runtimeType, loadPlacement, t]); const details = useMemo(() => describeRuntime(runtime), [runtime]); - const revoke = async (grantId: string) => { - setBusy(true); - setRevokeError(''); - try { - await api.post(`/api/grants/${encodeURIComponent(grantId)}/revoke`); - setConfirmGrantId(null); - setGrants((current) => current.map((grant) => ( - grant.grantId === grantId ? { ...grant, revokedAt: new Date().toISOString() } : grant - ))); - await load(); - } catch { - setRevokeError(t('agentManage.grants.revokeError', { defaultValue: 'Could not revoke the grant.' })); - } finally { - setBusy(false); - } - }; - const moveAgent = async () => { if (!placement || !machineChoice || moving) return; setMoving(true); @@ -182,6 +147,8 @@ const V2AgentRuntimeGrants: React.FC = ({ const machineName = (id: string | null) => placement?.activeMachines.find((machine) => machine.machineId === id)?.name || id; const canMove = Boolean(placement && (placement.boundMachineRevoked || !placement.boundMachineId)); + const needsAttention = Boolean(placementError) || (details.mode === 'daemon' && canMove); + useEffect(() => { onNeedsAttention?.(needsAttention); }, [needsAttention, onNeedsAttention]); const machineState = placement && <> {placement.boundMachineRevoked && {t('agentManage.runtime.machineRevoked')}} {!placement.boundMachineRevoked && placement.boundMachineId && } @@ -191,13 +158,8 @@ const V2AgentRuntimeGrants: React.FC = ({ {text} {action && {action}}
; - const activeGrants = grants.filter((grant) => !grantEnded(grant)).length; - const endedGrants = grants.length - activeGrants; - return ( - <> -
-

{t('agentManage.controls.title')}

+
{details.mode === 'daemon' && placement && (canMove ? (
@@ -233,55 +195,127 @@ const V2AgentRuntimeGrants: React.FC = ({
-
- {/* One heading; its name is the title alone, so the count beside it stays out of the section's name. */} -

- {t('agentManage.grants.title', { defaultValue: 'Connector grants' })} - {grants.length > 0 && - {[t('agentManage.grants.activeCount', { count: activeGrants }), - endedGrants > 0 && t('agentManage.grants.endedCount', { count: endedGrants })].filter(Boolean).join(' · ')} - } -

- {loadError &&
- {alertRow(loadError, 'muted', )} -
} - {loading && !grants.length &&
- {t('agentManage.grants.loading', { defaultValue: 'Loading grants…' })} -
} - {!loading && !loadError && grants.length === 0 &&
- {t('agentManage.grants.empty', { defaultValue: 'This agent has no connector grants.' })} -
} - {(grants.length > 0 || revokeError) &&
- {revokeError && alertRow(revokeError)} - {grants.map((grant) => { - const ended = grantEnded(grant); - const connector = grant.connector || grant.tools[0]?.split('.')[0] || ''; - return ( -
- {connector ? : null} - - {connector ? connectorName(connector) : t('agentManage.grants.tool', { defaultValue: 'Tool' })} - {grant.podName || t('agentManage.grants.direct', { defaultValue: 'Direct to this agent' })} · {t('agentManage.grants.by', { defaultValue: 'Granted by' })} {grant.grantedBy || t('agentManage.grants.unknown', { defaultValue: 'unknown' })} · {dateLabel(grant.createdAt)} - {grant.tools.join(', ')} - - - {ended ? t('agentManage.grants.ended', { defaultValue: 'Ended' }) : t('agentManage.grants.ends', { defaultValue: 'Ends' })} {dateLabel(grant.revokedAt || grant.expiresAt)} - {!ended && grant.canRevoke && (confirmGrantId === grant.grantId ? ( - - - - - ) : ( - - ))} - -
- ); - })} -
} -
+ ); +}; + +interface ConnectorsProps { name: string; instanceId: string } + +// The Identity & access list's Connectors row: the active-grant count, a link to +// the Connectors page (where grants are made), and a toggle for the grants list +// with its revoke flow. +export const V2AgentConnectorsRow: React.FC = ({ name, instanceId }) => { + const { t } = useTranslation(); + const api = useV2Api(); + const [grants, setGrants] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + const [revokeError, setRevokeError] = useState(''); + const [busy, setBusy] = useState(false); + const [confirmGrantId, setConfirmGrantId] = useState(null); + const [open, setOpen] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setLoadError(''); + try { + const next = await api.get( + `/api/registry/agents/${encodeURIComponent(name)}/instances/${encodeURIComponent(instanceId)}/grants`, + ); + setGrants(next.grants || []); + } catch { + setLoadError(t('agentManage.grants.loadError', { defaultValue: 'Could not load connector grants.' })); + } finally { + setLoading(false); + } + }, [api, name, instanceId, t]); + + useEffect(() => { void load(); }, [load]); + const revoke = async (grantId: string) => { + setBusy(true); + setRevokeError(''); + try { + await api.post(`/api/grants/${encodeURIComponent(grantId)}/revoke`); + setConfirmGrantId(null); + setGrants((current) => current.map((grant) => ( + grant.grantId === grantId ? { ...grant, revokedAt: new Date().toISOString() } : grant + ))); + await load(); + } catch { + setRevokeError(t('agentManage.grants.revokeError', { defaultValue: 'Could not revoke the grant.' })); + } finally { + setBusy(false); + } + }; + + const alertRow = (text: string, tone?: 'muted', action?: React.ReactNode) =>
+ {text} + {action && {action}} +
; + const activeGrants = grants.filter((grant) => !grantEnded(grant)).length; + const endedGrants = grants.length - activeGrants; + // A failed load or revoke, or a pending confirm, keeps the list open so its message and action stay in view. + const panelOpen = open || Boolean(loadError || revokeError || confirmGrantId); + const panelId = `agent-grants-${instanceId}`; + const title = t('agentManage.grants.title', { defaultValue: 'Connector grants' }); + + return ( + <> +
+ {t('agentSettings.connectors')} + + {grants.length > 0 ? [t('agentManage.grants.activeCount', { count: activeGrants }), + endedGrants > 0 && t('agentManage.grants.endedCount', { count: endedGrants })].filter(Boolean).join(' · ') + : !loadError && {loading ? t('agentManage.grants.loading', { defaultValue: 'Loading grants…' }) : t('agentSettings.none')}} + + + + + + + +
+ {panelOpen &&
+ {loadError && alertRow(loadError, 'muted', )} + {loading && !grants.length &&
+ {t('agentManage.grants.loading', { defaultValue: 'Loading grants…' })} +
} + {!loading && !loadError && grants.length === 0 &&
+ {t('agentManage.grants.empty', { defaultValue: 'This agent has no connector grants.' })} +
} + {(grants.length > 0 || revokeError) && <> + {revokeError && alertRow(revokeError)} + {grants.map((grant) => { + const ended = grantEnded(grant); + const connector = grant.connector || grant.tools[0]?.split('.')[0] || ''; + return ( +
+ {connector ? : null} + + {connector ? connectorName(connector) : t('agentManage.grants.tool', { defaultValue: 'Tool' })} + {grant.podName || t('agentManage.grants.direct', { defaultValue: 'Direct to this agent' })} · {t('agentManage.grants.by', { defaultValue: 'Granted by' })} {grant.grantedBy || t('agentManage.grants.unknown', { defaultValue: 'unknown' })} · {dateLabel(grant.createdAt)} + {grant.tools.join(', ')} + + + {ended ? t('agentManage.grants.ended', { defaultValue: 'Ended' }) : t('agentManage.grants.ends', { defaultValue: 'Ends' })} {dateLabel(grant.revokedAt || grant.expiresAt)} + {!ended && grant.canRevoke && (confirmGrantId === grant.grantId ? ( + + + + + ) : ( + + ))} + +
+ ); + })} + } +
} ); }; -export default V2AgentRuntimeGrants; +export default V2AgentRuntimeBody; diff --git a/frontend/src/v2/components/V2AgentSkills.tsx b/frontend/src/v2/components/V2AgentSkills.tsx index ff516c3a55..d35f6f90f5 100644 --- a/frontend/src/v2/components/V2AgentSkills.tsx +++ b/frontend/src/v2/components/V2AgentSkills.tsx @@ -8,7 +8,11 @@ interface SkillLoad { at: string; mounted: string[]; conflicted: string[]; statu interface SkillsResponse { items: SkillItem[]; canEdit: boolean; skillLoad: SkillLoad | null } interface CatalogItem extends SkillItem { content?: string; license?: string | { name?: string } } -interface Props { podId: string; podName: string; agentName: string; instanceId: string } +interface Props { + podId: string; podName: string; agentName: string; instanceId: string; + /** The installed-skill count once loaded, null on a failed load. */ + onCount?: (count: number | null) => void; +} const requestError = (error: unknown, fallback: string): string => ( (error as { response?: { data?: { error?: string } } })?.response?.data?.error || fallback @@ -23,7 +27,7 @@ const loadLabel = (skill: SkillItem, load: SkillLoad | null, t: (key: string) => return t('agentSkills.notLoaded'); }; -const V2AgentSkills: React.FC = ({ podId, podName, agentName, instanceId }) => { +const V2AgentSkills: React.FC = ({ podId, podName, agentName, instanceId, onCount }) => { const { t } = useTranslation(); const [skills, setSkills] = useState([]); const [skillLoad, setSkillLoad] = useState(null); @@ -36,6 +40,10 @@ const V2AgentSkills: React.FC = ({ podId, podName, agentName, instanceId const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + useEffect(() => { + if (!loading) onCount?.(error && !skills.length ? null : skills.length); + }, [loading, error, skills.length, onCount]); + const refresh = useCallback(async () => { const response = await axios.get(`/api/skills/pods/${encodeURIComponent(podId)}/imported`, { params: { scope: 'agent', agentName, instanceId }, @@ -122,9 +130,9 @@ const V2AgentSkills: React.FC = ({ podId, podName, agentName, instanceId const available = catalog.filter((item) => !installed.has(item.name.toLowerCase())); return ( -
+ // The Skills row above names this panel in the settings list, so it has no heading of its own. +
-

{t('agentSkills.title')}

{canEdit && }

{t('agentSkills.pod')} · {podName}