Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ test('a partial installation owner cannot see identity-wide key controls', async
renderPage();
expect(await screen.findByRole('heading', { name: 'Policy' })).toBeInTheDocument();
expect(await screen.findByText('Key management is unavailable for this agent. Ask an admin for access.')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Pods' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Pods' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument();
expect(screen.queryByText('Laptop')).not.toBeInTheDocument();
expect(axios.get.mock.calls.some(([url]) => url === '/api/agent-memory/maya-claude/default')).toBe(false);
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/v2/__tests__/V2AgentWakePods.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,12 @@ describe('per-agent Wake and Pods settings', () => {
view.rerender(
<MemoryRouter><V2AgentWakePods agentName="new-agent" instanceId="default" updatePod={updatePod} /></MemoryRouter>,
);
expect((await screen.findAllByText('New pod')).length).toBe(2);
expect((await screen.findAllByText('New pod')).length).toBe(1);
await act(async () => { finishOld({ data: { installations: [{
podId: 'pod-old', podName: 'Old pod', wakeOnMessage: true, boardWake: true, heartbeatMinutes: 60,
}] } }); });
expect(screen.queryByText('Old pod')).not.toBeInTheDocument();
expect(screen.getAllByText('New pod')).toHaveLength(2);
expect(screen.getByText('New pod')).toBeInTheDocument();
});

it('previews heartbeat turns against the plan cap before saving', async () => {
Expand Down
13 changes: 11 additions & 2 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5053,8 +5053,8 @@ describe('GitHub gig card fits the chat column', () => {
describe('agent Wake and Pods controls', () => {
const css = read('../agents/v2-agent-wake-pods.css');
test('action buttons keep a visible surface over the v2 button reset', () => {
const save = ruleBody(css, '.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__pod > button');
const remove = ruleBody(css, '.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__pod-row button');
const save = ruleBody(css, '.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__pod button');
const remove = ruleBody(css, '.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__remove');
expect(save).toContain('background: var(--v2-surface)');
expect(remove).toContain('background: var(--v2-surface)');
expect(remove).toContain('border: 1px solid var(--v2-border)');
Expand All @@ -5065,6 +5065,15 @@ describe('agent Wake and Pods controls', () => {
expect(segment).toContain('display: flex');
expect(segment).toContain('flex-wrap: wrap');
});

it('keeps the settings header on one row with the tabs at its end', () => {
const css = fs.readFileSync(path.join(__dirname, '../agents/v2-agent-settings.css'), 'utf8');
const head = ruleBody(css, '.v2-agent-settings__head');
expect(head).toContain('display: flex');
expect(head).toContain('align-items: center');
expect(ruleBody(css, '.v2-agent-settings__head .v2-agent-settings__seg')).toContain('margin-left: auto');
expect(ruleBody(css, '.v2-agent-settings__head .v2-agent-settings__seg [aria-current="page"]')).toContain('border-bottom: 0');
});
});

describe('agent runtime and grants fit the Settings column', () => {
Expand Down
15 changes: 10 additions & 5 deletions frontend/src/v2/agents/V2AgentSettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import axios from '../../utils/axiosConfig';
import V2PageHead from '../components/V2PageHead';
import V2Avatar from '../components/V2Avatar';
import V2AgentRuntimeGrants, { Runtime } from '../components/V2AgentRuntimeGrants';
import { AgentControlLoader } from '../components/V2AgentControlSection';
import { AgentConnection } from '../utils/agentConnection';
Expand Down Expand Up @@ -254,13 +254,18 @@ const V2AgentSettingsPage: React.FC = () => {

return (
<main className="v2-agent-settings">
<V2PageHead title={access.displayName || agentName} actions={<Link to="/v2/agents">{t('agentSettings.yourTeam')}</Link>} />
<div className="v2-agent-settings__column">
<nav className="v2-agent-settings__tabs" aria-label={t('agentSettings.tabs')}>
<header className="v2-agent-settings__head">
<V2Avatar name={access.displayName || agentName} size="lg" kind="agent" seed={`${agentName}:${instanceId}`} />
<div className="v2-agent-settings__ident">
<h1>{access.displayName || agentName}</h1>
</div>
<nav className="v2-agent-settings__tabs v2-agent-settings__seg" aria-label={t('agentSettings.tabs')}>
<Link to={`/v2/agent/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`}>{t('agentSettings.profile')}</Link>
<span aria-current="page">{t('agentSettings.settings')}</span>
<Link to={`/v2/agents/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}/memory`}>{t('agentSettings.memory', { defaultValue: 'Memory' })}</Link>
<span aria-current="page">{t('agentSettings.settings')}</span>
</nav>
</header>
<div className="v2-agent-settings__column">
<AgentControlLoader agentName={agentName} instanceId={instanceId}
refreshNonce={connectionRefreshNonce} onConnectionState={onConnectionState} />
<V2AgentWakePods agentName={agentName} instanceId={instanceId} updatePod={updatePod} onRemoved={onRemoved} />
Expand Down
151 changes: 83 additions & 68 deletions frontend/src/v2/agents/V2AgentWakePods.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,74 +185,89 @@ const V2AgentWakePods: React.FC<{
<section className="v2-agent-wake-pods__section" aria-labelledby="agent-wake-title">
<h2 id="agent-wake-title">{t('agentSettings.wakeTitle')}</h2>
<p className="v2-agent-wake-pods__hint">{t('agentSettings.mentionsAlwaysWake')}</p>
{installations.map((entry) => (
<div className="v2-agent-wake-pods__pod" key={entry.podId}>
<Link to={`/v2/pods/${entry.podId}`}>{entry.podName}</Link>
<div className="v2-agent-wake-pods__choice" role="group" aria-busy={busyPod === entry.podId}
aria-labelledby={`message-wake-label-${entry.podId}`}>
<span id={`message-wake-label-${entry.podId}`}>{t('agentSettings.messageWake')}</span>
<div className="v2-agent-wake-pods__segment">
{MESSAGE_WAKE_CHOICES.map((choice) => (
<label className="v2-agent-wake-pods__option" key={choice}>
<input type="radio" name={`message-wake-${entry.podId}`} value={choice}
checked={messageWakeChoice(entry) === choice}
onChange={() => { void save(entry, MESSAGE_WAKE_SETTINGS[choice]); }} />
<span>{t(`agentSettings.messageWake_${choice}`)}</span>
</label>
))}
</div>
</div>
<label className="v2-agent-wake-pods__row">
<span>{t('agentSettings.boardWake')}</span>
<input type="checkbox" checked={entry.boardWake} disabled={busyPod === entry.podId}
onChange={(event) => { void save(entry, { boardWake: event.target.checked }); }} />
</label>
<label className="v2-agent-wake-pods__row">
<span>{t('agentSettings.heartbeat')}</span>
<input type="checkbox" checked={entry.heartbeatEnabled} disabled={busyPod === entry.podId}
onChange={(event) => { void save(entry, { heartbeatEnabled: event.target.checked,
heartbeatMinutes: draftMinutes[entry.podId] ?? entry.heartbeatMinutes }); }} />
</label>
<label className="v2-agent-wake-pods__row v2-agent-wake-pods__subrow">
<span>{t('agentSettings.heartbeatInterval')}</span>
<select value={draftMinutes[entry.podId] ?? entry.heartbeatMinutes} disabled={busyPod === entry.podId}
onChange={(event) => { setDraftMinutes((current) => ({ ...current,
[entry.podId]: Number(event.target.value) })); }}>
{[...new Set([15, 30, 60, 120, 360, 720, 1440, entry.heartbeatMinutes])]
.sort((a, b) => a - b).map((minutes) => (
<option value={minutes} key={minutes}>{t('agentSettings.everyMinutes', { count: minutes })}</option>
))}
</select>
</label>
<p className="v2-agent-wake-pods__hint">
{turnCap === null
? t('agentSettings.capUnavailable', {
count: Math.ceil(1440 / (draftMinutes[entry.podId] ?? entry.heartbeatMinutes)),
})
: t('agentSettings.turnsPerDay', {
count: Math.ceil(1440 / (draftMinutes[entry.podId] ?? entry.heartbeatMinutes)), cap: turnCap,
})}
{turnCap !== null && Math.ceil(1440 / (draftMinutes[entry.podId] ?? entry.heartbeatMinutes)) > turnCap
&& ` ${t('agentSettings.overCap')}`}
</p>
{entry.heartbeatEnabled && draftMinutes[entry.podId] !== entry.heartbeatMinutes
&& <button type="button" disabled={busyPod === entry.podId}
onClick={() => { void save(entry, { heartbeatMinutes: draftMinutes[entry.podId] }); }}>
{t('agentSettings.saveInterval')}
</button>}
</div>
))}
</section>
<section className="v2-agent-wake-pods__section" aria-labelledby="agent-pods-title">
<h2 id="agent-pods-title">{t('agentSettings.podsTitle')}</h2>
{installations.length ? installations.map((entry) => (
<div className="v2-agent-wake-pods__pod-row" key={entry.podId}>
<Link to={`/v2/pods/${entry.podId}`}>{entry.podName}</Link>
<button type="button" disabled={busyPod === entry.podId} onClick={() => { void remove(entry); }}>
{t('agentSettings.remove')}
</button>
</div>
)) : <p className="v2-agent-wake-pods__empty">{t('agentSettings.noPods')}</p>}
{installations.length ? (
<table className="v2-agent-wake-pods__table">
<thead>
<tr>
<th scope="col">{t('agentSettings.podsTitle')}</th>
<th scope="col">{t('agentSettings.messageWake')}</th>
<th scope="col">{t('agentSettings.boardWake')}</th>
<th scope="col">{t('agentSettings.heartbeat')}</th>
<th scope="col"><span className="v2-agent-wake-pods__sr">{t('agentSettings.remove')}</span></th>
</tr>
</thead>
<tbody>
{installations.map((entry) => (
Comment thread
lilyshen0722 marked this conversation as resolved.
Resolved
<tr className="v2-agent-wake-pods__pod" key={entry.podId}>
<th scope="row"><Link to={`/v2/pods/${entry.podId}`}>{entry.podName}</Link></th>
<td>
<div className="v2-agent-wake-pods__choice" role="group" aria-busy={busyPod === entry.podId}
aria-label={t('agentSettings.messageWake')}>
<div className="v2-agent-wake-pods__segment">
{MESSAGE_WAKE_CHOICES.map((choice) => (
<label className="v2-agent-wake-pods__option" key={choice}>
<input type="radio" name={`message-wake-${entry.podId}`} value={choice}
checked={messageWakeChoice(entry) === choice}
onChange={() => { void save(entry, MESSAGE_WAKE_SETTINGS[choice]); }} />
<span>{t(`agentSettings.messageWake_${choice}`)}</span>
</label>
))}
</div>
</div>
</td>
<td>
<label className="v2-agent-wake-pods__row">
<span>{t('agentSettings.boardWake')}</span>
<input type="checkbox" checked={entry.boardWake} disabled={busyPod === entry.podId}
onChange={(event) => { void save(entry, { boardWake: event.target.checked }); }} />
</label>
</td>
<td>
<label className="v2-agent-wake-pods__row">
<span>{t('agentSettings.heartbeat')}</span>
<input type="checkbox" checked={entry.heartbeatEnabled} disabled={busyPod === entry.podId}
onChange={(event) => { void save(entry, { heartbeatEnabled: event.target.checked,
heartbeatMinutes: draftMinutes[entry.podId] ?? entry.heartbeatMinutes }); }} />
</label>
<label className="v2-agent-wake-pods__row v2-agent-wake-pods__subrow">
<span>{t('agentSettings.heartbeatInterval')}</span>
<select value={draftMinutes[entry.podId] ?? entry.heartbeatMinutes} disabled={busyPod === entry.podId}
onChange={(event) => { setDraftMinutes((current) => ({ ...current,
[entry.podId]: Number(event.target.value) })); }}>
{[...new Set([15, 30, 60, 120, 360, 720, 1440, entry.heartbeatMinutes])]
.sort((a, b) => a - b).map((minutes) => (
<option value={minutes} key={minutes}>{t('agentSettings.everyMinutes', { count: minutes })}</option>
))}
</select>
</label>
<p className="v2-agent-wake-pods__hint">
{turnCap === null
? t('agentSettings.capUnavailable', {
count: Math.ceil(1440 / (draftMinutes[entry.podId] ?? entry.heartbeatMinutes)),
})
: t('agentSettings.turnsPerDay', {
count: Math.ceil(1440 / (draftMinutes[entry.podId] ?? entry.heartbeatMinutes)), cap: turnCap,
})}
{turnCap !== null && Math.ceil(1440 / (draftMinutes[entry.podId] ?? entry.heartbeatMinutes)) > turnCap
&& ` ${t('agentSettings.overCap')}`}
</p>
{entry.heartbeatEnabled && draftMinutes[entry.podId] !== entry.heartbeatMinutes
&& <button type="button" disabled={busyPod === entry.podId}
onClick={() => { void save(entry, { heartbeatMinutes: draftMinutes[entry.podId] }); }}>
{t('agentSettings.saveInterval')}
</button>}
</td>
<td>
<button type="button" className="v2-agent-wake-pods__remove" disabled={busyPod === entry.podId}
onClick={() => { void remove(entry); }}>
{t('agentSettings.remove')}
</button>
</td>
</tr>
))}
</tbody>
</table>
) : <p className="v2-agent-wake-pods__empty">{t('agentSettings.noPods')}</p>}
</section>
</div>
);
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/v2/agents/v2-agent-settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@

.v2-agent-settings__state { padding: 32px; color: var(--v2-text-secondary); }

/* Header: avatar, name and the Profile / Memory / Settings tabs on one row. */
.v2-agent-settings__head { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 12px 28px; min-height: 76px; border-bottom: 1px solid var(--v2-border-soft); box-sizing: border-box; }
.v2-agent-settings__ident h1 { margin: 0; font-size: 20px; }
/* Scoped under the header so these beat the base tab rules declared later. */
.v2-agent-settings__head .v2-agent-settings__seg { margin-left: auto; margin-top: 0; margin-bottom: 0; border-bottom: 0; }
.v2-agent-settings__head .v2-agent-settings__seg [aria-current="page"] { border-bottom: 0; }
@media (max-width: 600px) { .v2-agent-settings__head { padding: 12px 16px; } }

.v2-agent-settings__tabs {
display: flex;
gap: 24px;
Expand Down
Loading
Loading