Skip to content
Closed
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
3 changes: 2 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2720,10 +2720,11 @@
"turnsPerDay": "{{count}} turns/day · {{cap}} available",
"capUnavailable": "{{count}} turns/day · plan cap unavailable",
"overCap": "This exceeds the daily cap.",
"saveInterval": "Save interval",
"everyMinutes_one": "Every minute",
"everyMinutes_other": "Every {{count}} minutes",
"podsTitle": "Pods",
"podColumn": "Pod",
"podActions": "Pod actions",
"remove": "Remove from pod",
"removeConfirm": "Remove this agent from {{podName}}? Its identity and memory stay intact.",
"removeLastConfirm": "Remove this agent from its last pod, {{podName}}? Every key will be revoked and the agent will disconnect. Its identity and memory remain.",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2723,10 +2723,11 @@
"turnsPerDay": "每天{{count}}次 · 上限{{cap}}次",
"capUnavailable": "每天{{count}}次 · 套餐上限暂不可用",
"overCap": "已超过每日上限。",
"saveInterval": "保存间隔",
"everyMinutes_one": "每分钟",
"everyMinutes_other": "每{{count}}分钟",
"podsTitle": "Pod",
"podColumn": "Pod",
"podActions": "Pod 操作",
"remove": "从 Pod 中移除",
"removeConfirm": "从 {{podName}} 移除此智能体?身份和记忆会保留。",
"removeLastConfirm": "从最后一个 Pod「{{podName}}」移除此智能体?所有密钥都会撤销,智能体会断开连接。身份和记忆仍会保留。",
Expand Down
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('heading', { name: 'Wake' })).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
18 changes: 12 additions & 6 deletions frontend/src/v2/__tests__/V2AgentWakePods.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ describe('per-agent Wake and Pods settings', () => {
it('removes a pod installation and leaves the settings view open', async () => {
const confirm = jest.spyOn(window, 'confirm').mockReturnValue(true);
renderSettings();
fireEvent.click(await screen.findByRole('button', { name: 'Remove from pod' }));
fireEvent.click(await screen.findByRole('button', { name: 'Pod actions' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Remove from pod' }));
await waitFor(() => expect(client.delete).toHaveBeenCalledWith(
'/api/registry/agents/test-agent/pods/pod-1?instanceId=my-machine',
));
Expand All @@ -188,21 +189,25 @@ 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'))).toHaveLength(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.getAllByText('New pod')).toHaveLength(1);
});

it('previews heartbeat turns against the plan cap before saving', async () => {
it('autosaves the heartbeat interval and previews its turns against the plan cap', async () => {
renderSettings();
expect(await screen.findByText('24 turns/day · 30 available')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Interval'), { target: { value: '30' } });
expect(screen.getByText(/48 turns\/day · 30 available/)).toBeInTheDocument();
expect(screen.getByText(/This exceeds the daily cap/)).toBeInTheDocument();
expect(updatePod).not.toHaveBeenCalled();
await waitFor(() => expect(updatePod).toHaveBeenCalledWith(
'pod-1',
expect.objectContaining({ heartbeatMinutes: 30 }),
));
expect(screen.queryByRole('button', { name: 'Save interval' })).not.toBeInTheDocument();
});

it('states the daily turn count clearly when the cap is unavailable', async () => {
Expand All @@ -223,7 +228,8 @@ describe('per-agent Wake and Pods settings', () => {
] } });
const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false);
renderSettings();
fireEvent.click((await screen.findAllByRole('button', { name: 'Remove from pod' }))[0]);
fireEvent.click((await screen.findAllByRole('button', { name: 'Pod actions' }))[0]);
fireEvent.click(screen.getByRole('menuitem', { name: 'Remove from pod' }));
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('No active installation will remain'));
expect(client.delete).not.toHaveBeenCalled();
confirm.mockRestore();
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4959,11 +4959,11 @@ 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:not(.v2-agent-wake-pods__more)');
const menu = ruleBody(css, '.v2-agent-wake-pods__menu');
expect(save).toContain('background: var(--v2-surface)');
expect(remove).toContain('background: var(--v2-surface)');
expect(remove).toContain('border: 1px solid var(--v2-border)');
expect(menu).toContain('background: var(--v2-surface)');
expect(menu).toContain('border: 1px solid var(--v2-border)');
});
});

Expand Down
157 changes: 89 additions & 68 deletions frontend/src/v2/agents/V2AgentWakePods.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const V2AgentWakePods: React.FC<{
const [activePodCount, setActivePodCount] = useState(0);
const [podCount, setPodCount] = useState(0);
const [turnCap, setTurnCap] = useState<number | null>(null);
const [draftMinutes, setDraftMinutes] = useState<Record<string, number>>({});
const [openMenuPod, setOpenMenuPod] = useState<string | null>(null);
const [loadedIdentity, setLoadedIdentity] = useState<string | null>(null);
const query = `instanceId=${encodeURIComponent(instanceId)}`;
const base = `/api/registry/agents/${encodeURIComponent(agentName)}/manage-settings`;
Expand All @@ -62,7 +62,7 @@ const V2AgentWakePods: React.FC<{
setInstallations([]);
setBusyPod(null);
setError(null);
setDraftMinutes({});
setOpenMenuPod(null);
committedRef.current = {};
queuedRef.current = {};
sendingRef.current = {};
Expand All @@ -77,7 +77,6 @@ const V2AgentWakePods: React.FC<{
committedRef.current = Object.fromEntries(rows.map((row) => [row.podId, row]));
setActivePodCount(settingsResult.data.activePodCount ?? rows.filter((row) => row.status === 'active').length);
setPodCount(settingsResult.data.podCount ?? rows.length);
setDraftMinutes(Object.fromEntries(rows.map((row) => [row.podId, row.heartbeatMinutes])));
setTurnCap(settingsResult.data.turnCap ?? null);
} catch (requestError) {
if (active) setError((requestError as { response?: { status?: number } })?.response?.status === 403
Expand Down Expand Up @@ -185,71 +184,93 @@ 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>
<fieldset className="v2-agent-wake-pods__choice" aria-busy={busyPod === entry.podId}>
<legend>{t('agentSettings.messageWake')}</legend>
{MESSAGE_WAKE_CHOICES.map((choice) => (
<label className="v2-agent-wake-pods__row v2-agent-wake-pods__subrow" key={choice}>
<span>{t(`agentSettings.messageWake_${choice}`)}</span>
<input type="radio" name={`message-wake-${entry.podId}`} value={choice}
checked={messageWakeChoice(entry) === choice}
onChange={() => { void save(entry, MESSAGE_WAKE_SETTINGS[choice]); }} />
</label>
))}
</fieldset>
<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.podColumn')}</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.podActions')}</span></th>
</tr>
</thead>
<tbody>
{installations.map((entry) => {
const minutes = entry.heartbeatMinutes;
const turns = Math.ceil(1440 / minutes);
return (
<tr className="v2-agent-wake-pods__pod" key={entry.podId} aria-busy={busyPod === entry.podId}>
<td data-label={t('agentSettings.podColumn')}>
<Link to={`/v2/pods/${entry.podId}`}>{entry.podName}</Link>
</td>
<td data-label={t('agentSettings.messageWake')}>
<fieldset className="v2-agent-wake-pods__choice">
<legend className="v2-agent-wake-pods__sr">{t('agentSettings.messageWake')}</legend>
{MESSAGE_WAKE_CHOICES.map((choice) => (
<label className="v2-agent-wake-pods__radio" 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>
))}
</fieldset>
</td>
<td data-label={t('agentSettings.boardWake')}>
<input type="checkbox" aria-label={t('agentSettings.boardWake')} checked={entry.boardWake}
disabled={busyPod === entry.podId}
onChange={(event) => { void save(entry, { boardWake: event.target.checked }); }} />
</td>
<td data-label={t('agentSettings.heartbeat')}>
<label className="v2-agent-wake-pods__radio">
<input type="checkbox" checked={entry.heartbeatEnabled} disabled={busyPod === entry.podId}
onChange={(event) => { void save(entry, { heartbeatEnabled: event.target.checked,
heartbeatMinutes: minutes }); }} />
<span>{t('agentSettings.heartbeat')}</span>
</label>
<label className="v2-agent-wake-pods__interval">
<span className="v2-agent-wake-pods__sr">{t('agentSettings.heartbeatInterval')}</span>
<select value={minutes} disabled={busyPod === entry.podId}
onChange={(event) => { void save(entry, { heartbeatMinutes: Number(event.target.value) }); }}>
{[...new Set([15, 30, 60, 120, 360, 720, 1440, entry.heartbeatMinutes])]
.sort((a, b) => a - b).map((option) => (
<option value={option} key={option}>{t('agentSettings.everyMinutes', { count: option })}</option>
))}
</select>
</label>
<p className="v2-agent-wake-pods__hint">
{turnCap === null
? t('agentSettings.capUnavailable', { count: turns })
: t('agentSettings.turnsPerDay', { count: turns, cap: turnCap })}
{turnCap !== null && turns > turnCap && ` ${t('agentSettings.overCap')}`}
</p>
</td>
<td className="v2-agent-wake-pods__actions">
<button type="button" className="v2-agent-wake-pods__more" aria-haspopup="menu"
aria-expanded={openMenuPod === entry.podId}
aria-label={t('agentSettings.podActions')} title={t('agentSettings.podActions')}
disabled={busyPod === entry.podId}
onClick={() => setOpenMenuPod((current) => (current === entry.podId ? null : entry.podId))}>
⋯
</button>
{openMenuPod === entry.podId && (
<div className="v2-agent-wake-pods__menu" role="menu">
<button type="button" role="menuitem" onClick={() => {
setOpenMenuPod(null);
void remove(entry);
}}>
{t('agentSettings.remove')}
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
) : <p className="v2-agent-wake-pods__empty">{t('agentSettings.noPods')}</p>}
</section>
</div>
);
Expand Down
Loading
Loading