diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 86b1dd8e56..1c0060d540 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 9d56b08582..ca6b2ea819 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -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}}」移除此智能体?所有密钥都会撤销,智能体会断开连接。身份和记忆仍会保留。", diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 280e945a90..98a19b5bd1 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -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); diff --git a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx index dce2de0c26..8619be3b68 100644 --- a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx @@ -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', )); @@ -188,21 +189,25 @@ describe('per-agent Wake and Pods settings', () => { view.rerender( , ); - 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 () => { @@ -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(); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 39f06e4591..9059aae8a7 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -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)'); }); }); diff --git a/frontend/src/v2/agents/V2AgentWakePods.tsx b/frontend/src/v2/agents/V2AgentWakePods.tsx index 8af91001d8..9cbbc6b225 100644 --- a/frontend/src/v2/agents/V2AgentWakePods.tsx +++ b/frontend/src/v2/agents/V2AgentWakePods.tsx @@ -44,7 +44,7 @@ const V2AgentWakePods: React.FC<{ const [activePodCount, setActivePodCount] = useState(0); const [podCount, setPodCount] = useState(0); const [turnCap, setTurnCap] = useState(null); - const [draftMinutes, setDraftMinutes] = useState>({}); + const [openMenuPod, setOpenMenuPod] = useState(null); const [loadedIdentity, setLoadedIdentity] = useState(null); const query = `instanceId=${encodeURIComponent(instanceId)}`; const base = `/api/registry/agents/${encodeURIComponent(agentName)}/manage-settings`; @@ -62,7 +62,7 @@ const V2AgentWakePods: React.FC<{ setInstallations([]); setBusyPod(null); setError(null); - setDraftMinutes({}); + setOpenMenuPod(null); committedRef.current = {}; queuedRef.current = {}; sendingRef.current = {}; @@ -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 @@ -185,71 +184,93 @@ const V2AgentWakePods: React.FC<{

{t('agentSettings.wakeTitle')}

{t('agentSettings.mentionsAlwaysWake')}

- {installations.map((entry) => ( -
- {entry.podName} -
- {t('agentSettings.messageWake')} - {MESSAGE_WAKE_CHOICES.map((choice) => ( - - ))} -
- - - -

- {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')}`} -

- {entry.heartbeatEnabled && draftMinutes[entry.podId] !== entry.heartbeatMinutes - && } -
- ))} -
-
-

{t('agentSettings.podsTitle')}

- {installations.length ? installations.map((entry) => ( -
- {entry.podName} - -
- )) :

{t('agentSettings.noPods')}

} + {installations.length ? ( + + + + + + + + + + + + {installations.map((entry) => { + const minutes = entry.heartbeatMinutes; + const turns = Math.ceil(1440 / minutes); + return ( + + + + + + + + ); + })} + +
{t('agentSettings.podColumn')}{t('agentSettings.messageWake')}{t('agentSettings.boardWake')}{t('agentSettings.heartbeat')}{t('agentSettings.podActions')}
+ {entry.podName} + +
+ {t('agentSettings.messageWake')} + {MESSAGE_WAKE_CHOICES.map((choice) => ( + + ))} +
+
+ { void save(entry, { boardWake: event.target.checked }); }} /> + + + +

+ {turnCap === null + ? t('agentSettings.capUnavailable', { count: turns }) + : t('agentSettings.turnsPerDay', { count: turns, cap: turnCap })} + {turnCap !== null && turns > turnCap && ` ${t('agentSettings.overCap')}`} +

+
+ + {openMenuPod === entry.podId && ( +
+ +
+ )} +
+ ) :

{t('agentSettings.noPods')}

}
); diff --git a/frontend/src/v2/agents/v2-agent-wake-pods.css b/frontend/src/v2/agents/v2-agent-wake-pods.css index 19ff78fe40..62c2375d2f 100644 --- a/frontend/src/v2/agents/v2-agent-wake-pods.css +++ b/frontend/src/v2/agents/v2-agent-wake-pods.css @@ -1,19 +1,39 @@ .v2-agent-wake-pods { display: grid; gap: 24px; } .v2-agent-wake-pods__section { border: 1px solid var(--v2-border); border-radius: 6px; padding: 20px; background: var(--v2-surface); } .v2-agent-wake-pods__section h2 { margin: 0 0 16px; color: var(--v2-text-primary); font: 700 20px/28px var(--v2-font-display); } -.v2-agent-wake-pods__pod + .v2-agent-wake-pods__pod { border-top: 1px solid var(--v2-border-soft); margin-top: 16px; padding-top: 16px; } -.v2-agent-wake-pods__pod > a, .v2-agent-wake-pods__pod-row > a { color: var(--v2-accent-text); font: 600 14px/20px var(--v2-font); } -.v2-agent-wake-pods__row, .v2-agent-wake-pods__pod-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 44px; color: var(--v2-text-primary); font: 500 14px/20px var(--v2-font); } -.v2-agent-wake-pods__row input { width: 18px; height: 18px; accent-color: var(--v2-ink); } -.v2-agent-wake-pods__subrow { padding-left: 18px; } +.v2-agent-wake-pods__pod > td > a { color: var(--v2-accent-text); font: 600 14px/20px var(--v2-font); } .v2-agent-wake-pods__hint { margin: 4px 0 12px; color: var(--v2-text-muted); font: 400 13px/19px var(--v2-font); } -.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__pod > button { border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px 10px; background: var(--v2-surface); color: var(--v2-text-primary); cursor: pointer; } -.v2-agent-wake-pods__row select { max-width: 55%; border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px; background: var(--v2-surface); color: var(--v2-text-primary); } -.v2-agent-wake-pods__pod-row + .v2-agent-wake-pods__pod-row { border-top: 1px solid var(--v2-border-soft); } -.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__pod-row button { border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px 10px; background: var(--v2-surface); color: var(--v2-text-secondary); cursor: pointer; } -.v2-agent-wake-pods__pod-row button:disabled { opacity: .5; cursor: default; } .v2-agent-wake-pods__error { margin: 0; color: var(--v2-danger); } .v2-agent-wake-pods__empty, .v2-agent-wake-pods__loading { margin: 0; color: var(--v2-text-muted); } -@media (max-width: 600px) { .v2-agent-wake-pods__section { padding: 16px; } } -.v2-agent-wake-pods__choice { min-width: 0; margin: 0; border: 0; padding: 0; } -.v2-agent-wake-pods__choice legend { padding: 12px 0 0; color: var(--v2-text-primary); font: 500 14px/20px var(--v2-font); } +.v2-agent-wake-pods__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +.v2-agent-wake-pods__table { width: 100%; border-collapse: collapse; color: var(--v2-text-primary); font: 500 14px/20px var(--v2-font); } +.v2-agent-wake-pods__table th { padding: 0 12px 8px 0; color: var(--v2-text-muted); font: 600 12px/16px var(--v2-font); text-align: left; } +.v2-agent-wake-pods__table td { padding: 12px 12px 12px 0; border-top: 1px solid var(--v2-border-soft); vertical-align: top; } +.v2-agent-wake-pods__table td:last-child, .v2-agent-wake-pods__table th:last-child { width: 44px; padding-right: 0; text-align: right; } +.v2-agent-wake-pods__table td > input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--v2-ink); } + +.v2-agent-wake-pods__choice { min-width: 0; margin: 0; border: 0; padding: 0; display: grid; gap: 6px; } +.v2-agent-wake-pods__radio { display: inline-flex; align-items: center; gap: 8px; min-height: 28px; } +.v2-agent-wake-pods__radio input { width: 18px; height: 18px; accent-color: var(--v2-ink); } +.v2-agent-wake-pods__interval { display: block; margin-top: 8px; } +.v2-agent-wake-pods__interval select { max-width: 100%; border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px; background: var(--v2-surface); color: var(--v2-text-primary); } + +.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__more { border: 1px solid var(--v2-border); border-radius: 4px; padding: 4px 10px; background: var(--v2-surface); color: var(--v2-text-primary); cursor: pointer; } +.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__pod button:not(.v2-agent-wake-pods__more) { border: 1px solid var(--v2-border); border-radius: 4px; padding: 7px 10px; background: var(--v2-surface); color: var(--v2-text-primary); cursor: pointer; } +.v2-agent-wake-pods__more:disabled { opacity: .5; cursor: default; } +.v2-agent-wake-pods__actions { position: relative; } +.v2-agent-wake-pods__menu { position: absolute; right: 0; top: 100%; z-index: 2; margin-top: 4px; min-width: 160px; border: 1px solid var(--v2-border); border-radius: 6px; padding: 4px; background: var(--v2-surface); } +.v2-agent-settings .v2-agent-wake-pods .v2-agent-wake-pods__menu button { display: block; width: 100%; border: 0; padding: 8px 10px; background: transparent; color: var(--v2-danger); text-align: left; cursor: pointer; } + +@media (max-width: 600px) { + .v2-agent-wake-pods__section { padding: 16px; } + .v2-agent-wake-pods__table, .v2-agent-wake-pods__table tbody, .v2-agent-wake-pods__table tr, .v2-agent-wake-pods__table td { display: block; width: 100%; } + .v2-agent-wake-pods__table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } + .v2-agent-wake-pods__table tr { border-top: 1px solid var(--v2-border-soft); padding: 12px 0; } + .v2-agent-wake-pods__table td { border-top: 0; padding: 6px 0; } + .v2-agent-wake-pods__table td::before { content: attr(data-label); display: block; margin-bottom: 4px; color: var(--v2-text-muted); font: 600 12px/16px var(--v2-font); } + .v2-agent-wake-pods__table td:last-child { width: 100%; text-align: left; } + .v2-agent-wake-pods__table td:last-child::before { content: none; } + .v2-agent-wake-pods__menu { left: 0; right: auto; } +}