From 189de310c1579aa3a6c51ba76e2faa8d8b2c91d1 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:03:50 -0700 Subject: [PATCH 1/2] feat(agent-settings): one wake table per agent, pods in its rows (TASK-689 slice a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The D215 artboard turns the repeated wake blocks into one table: a row per pod with the message choice as a segment, board wake as a switch, heartbeat on the same row, and the pod's actions in a ⋯ menu. The separate Pods list folds into that table, so "Remove from pod" moves into the row's menu. Behaviour is unchanged: the same save queue, rollback and identity guards, and the same labels for each control. Heartbeat stays a checkbox plus an interval select with its explicit Save interval button; autosave is slice (b). The table stacks by data-label at 390 wide. Tests: the remove flow opens the row menu first, the Pods heading is now the Wake heading, and the layout invariant pins the menu surface. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- frontend/src/i18n/locales/en.json | 2 + frontend/src/i18n/locales/zh-CN.json | 2 + .../v2/__tests__/V2AgentSettingsPage.test.tsx | 2 +- .../src/v2/__tests__/V2AgentWakePods.test.tsx | 10 +- .../v2/__tests__/v2-layout-invariants.test.ts | 8 +- frontend/src/v2/agents/V2AgentWakePods.tsx | 160 +++++++++++------- frontend/src/v2/agents/v2-agent-wake-pods.css | 46 +++-- 7 files changed, 143 insertions(+), 87 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 86b1dd8e56..49d0defd12 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2724,6 +2724,8 @@ "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..7f7635b9d5 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2727,6 +2727,8 @@ "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..764a13af03 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,12 +189,12 @@ 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 () => { @@ -223,7 +224,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..38dbb7b660 100644 --- a/frontend/src/v2/agents/V2AgentWakePods.tsx +++ b/frontend/src/v2/agents/V2AgentWakePods.tsx @@ -45,6 +45,7 @@ const V2AgentWakePods: React.FC<{ 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`; @@ -63,6 +64,7 @@ const V2AgentWakePods: React.FC<{ setBusyPod(null); setError(null); setDraftMinutes({}); + setOpenMenuPod(null); committedRef.current = {}; queuedRef.current = {}; sendingRef.current = {}; @@ -185,71 +187,99 @@ 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 = draftMinutes[entry.podId] ?? 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')}`} +

+ {entry.heartbeatEnabled && minutes !== entry.heartbeatMinutes + && } +
+ + {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; } +} From 059a31f69a8df4836edbd973b37edb0ffd0a16cd Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:05:54 -0700 Subject: [PATCH 2/2] feat(agent-settings): heartbeat interval autosaves, no Save button (TASK-689 slice b) Co-Authored-By: Claude Haiku 5.5 Co-authored-by: dart Agent-Model: claude/claude-haiku-5-5/default --- frontend/src/i18n/locales/en.json | 1 - frontend/src/i18n/locales/zh-CN.json | 1 - frontend/src/v2/__tests__/V2AgentWakePods.test.tsx | 8 ++++++-- frontend/src/v2/agents/V2AgentWakePods.tsx | 13 ++----------- 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 49d0defd12..1c0060d540 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2720,7 +2720,6 @@ "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", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 7f7635b9d5..ca6b2ea819 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2723,7 +2723,6 @@ "turnsPerDay": "每天{{count}}次 · 上限{{cap}}次", "capUnavailable": "每天{{count}}次 · 套餐上限暂不可用", "overCap": "已超过每日上限。", - "saveInterval": "保存间隔", "everyMinutes_one": "每分钟", "everyMinutes_other": "每{{count}}分钟", "podsTitle": "Pod", diff --git a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx index 764a13af03..8619be3b68 100644 --- a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx @@ -197,13 +197,17 @@ describe('per-agent Wake and Pods settings', () => { 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 () => { diff --git a/frontend/src/v2/agents/V2AgentWakePods.tsx b/frontend/src/v2/agents/V2AgentWakePods.tsx index 38dbb7b660..9cbbc6b225 100644 --- a/frontend/src/v2/agents/V2AgentWakePods.tsx +++ b/frontend/src/v2/agents/V2AgentWakePods.tsx @@ -44,7 +44,6 @@ 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)}`; @@ -63,7 +62,6 @@ const V2AgentWakePods: React.FC<{ setInstallations([]); setBusyPod(null); setError(null); - setDraftMinutes({}); setOpenMenuPod(null); committedRef.current = {}; queuedRef.current = {}; @@ -79,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 @@ -200,7 +197,7 @@ const V2AgentWakePods: React.FC<{ {installations.map((entry) => { - const minutes = draftMinutes[entry.podId] ?? entry.heartbeatMinutes; + const minutes = entry.heartbeatMinutes; const turns = Math.ceil(1440 / minutes); return ( @@ -235,8 +232,7 @@ const V2AgentWakePods: React.FC<{