diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3dbd8b52a5..a773907da6 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2651,8 +2651,10 @@ "listingSeparate": "Publishing a listing is managed separately from Settings.", "wakeTitle": "Wake", "mentionsAlwaysWake": "Mentions always wake this agent.", - "wakeOnMessage": "Wake on messages", - "humanOnly": "Human messages only", + "messageWake": "Messages that wake it", + "messageWake_mentions": "Mentions only", + "messageWake_human": "Messages from people", + "messageWake_all": "Every message", "boardWake": "Wake on board changes", "heartbeat": "Heartbeat", "heartbeatInterval": "Interval", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 4755445bb2..f21af6e9d3 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2642,8 +2642,10 @@ "listingSeparate": "发布市场条目需在设置页之外完成。", "wakeTitle": "唤醒", "mentionsAlwaysWake": "提及始终会唤醒此智能体。", - "wakeOnMessage": "收到消息时唤醒", - "humanOnly": "仅限真人消息", + "messageWake": "哪些消息会唤醒它", + "messageWake_mentions": "仅 @提及", + "messageWake_human": "来自真人的消息", + "messageWake_all": "所有消息", "boardWake": "看板变更时唤醒", "heartbeat": "心跳间隔", "heartbeatInterval": "间隔", diff --git a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx index a3ccb6e1ba..dce2de0c26 100644 --- a/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentWakePods.test.tsx @@ -43,6 +43,126 @@ describe('per-agent Wake and Pods settings', () => { )); }); + it('shows all three message-wake choices without first opting in (D173)', async () => { + client.get.mockResolvedValue({ data: { turnCap: 30, activePodCount: 1, podCount: 1, installations: [{ + podId: 'pod-1', podName: 'Launch', status: 'active', wakeOnMessage: false, humanOnly: false, + boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60, + }] } }); + renderSettings(); + expect(await screen.findByRole('group', { name: 'Messages that wake it' })).toBeInTheDocument(); + expect(screen.getByLabelText('Mentions only')).toBeChecked(); + expect(screen.getByLabelText('Every message')).not.toBeChecked(); + fireEvent.click(screen.getByLabelText('Messages from people')); + await waitFor(() => expect(updatePod).toHaveBeenCalledWith( + 'pod-1', + { wakeOnMessage: true, humanOnly: true, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60 }, + )); + expect(screen.getByLabelText('Messages from people')).toBeChecked(); + }); + + it('marks the saved human-only setting and clears it on mentions only', async () => { + renderSettings(); + expect(await screen.findByLabelText('Messages from people')).toBeChecked(); + fireEvent.click(screen.getByLabelText('Mentions only')); + await waitFor(() => expect(updatePod).toHaveBeenCalledWith( + 'pod-1', + { wakeOnMessage: false, humanOnly: false, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60 }, + )); + }); + + it('loads and saves the every-message choice', async () => { + client.get.mockResolvedValue({ data: { turnCap: 30, activePodCount: 1, podCount: 1, installations: [{ + podId: 'pod-1', podName: 'Launch', status: 'active', wakeOnMessage: true, humanOnly: false, + boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60, + }] } }); + renderSettings(); + expect(await screen.findByLabelText('Every message')).toBeChecked(); + fireEvent.click(screen.getByLabelText('Mentions only')); + await waitFor(() => expect(updatePod).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByLabelText('Every message')); + await waitFor(() => expect(updatePod).toHaveBeenLastCalledWith( + 'pod-1', + { wakeOnMessage: true, humanOnly: false, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60 }, + )); + }); + + const mentionsOnlyInstall = () => client.get.mockResolvedValue({ data: { + turnCap: 30, activePodCount: 1, podCount: 1, installations: [{ + podId: 'pod-1', podName: 'Launch', status: 'active', wakeOnMessage: false, humanOnly: false, + boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60, + }], + } }); + const EVERY = { wakeOnMessage: true, humanOnly: false, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60 }; + + it('keeps the radios usable while a pick saves, so a second arrow key reaches the third choice', async () => { + mentionsOnlyInstall(); + let settleFirst: (value?: unknown) => void = () => {}; + updatePod.mockImplementationOnce(() => new Promise((resolve) => { settleFirst = resolve; })); + renderSettings(); + const human = await screen.findByLabelText('Messages from people'); + // jsdom has no native arrow-key radio movement: an ArrowDown is a focus + // move plus a change on the next radio. + act(() => { human.focus(); }); + fireEvent.click(human); + await waitFor(() => expect(updatePod).toHaveBeenCalledTimes(1)); + expect(human).toBeChecked(); + expect(human).not.toBeDisabled(); + expect(document.activeElement).toBe(human); + + const every = screen.getByLabelText('Every message'); + expect(every).not.toBeDisabled(); + act(() => { every.focus(); }); + fireEvent.click(every); + expect(every).toBeChecked(); + expect(document.activeElement).toBe(every); + // The second pick waits for the first, so the server sees them in order. + expect(updatePod).toHaveBeenCalledTimes(1); + await act(async () => { settleFirst({}); }); + await waitFor(() => expect(updatePod).toHaveBeenCalledTimes(2)); + expect(updatePod).toHaveBeenLastCalledWith('pod-1', EVERY); + expect(every).toBeChecked(); + }); + + it('a failed save rolls back to what the server last accepted, not to an unsaved pick', async () => { + mentionsOnlyInstall(); + let failFirst: (error: unknown) => void = () => {}; + updatePod.mockImplementationOnce(() => new Promise((_, reject) => { failFirst = reject; })); + renderSettings(); + fireEvent.click(await screen.findByLabelText('Messages from people')); + fireEvent.click(screen.getByLabelText('Every message')); + // The second pick shows while the first save is pending (a disabled + // fieldset would have swallowed it), so the rollback has something to undo. + expect(screen.getByLabelText('Every message')).toBeChecked(); + await act(async () => { failFirst(new Error('down')); }); + await waitFor(() => expect(screen.getByLabelText('Mentions only')).toBeChecked()); + // The queued pick is dropped with the failure; it never reached the server. + expect(updatePod).toHaveBeenCalledTimes(1); + }); + + it('a save still in flight for the previous agent does not let the next agent\'s saves overlap', async () => { + mentionsOnlyInstall(); + const settles: Array<(value?: unknown) => void> = []; + updatePod.mockImplementation(() => new Promise((resolve) => { settles.push(resolve); })); + const view = renderSettings(); + fireEvent.click(await screen.findByLabelText('Messages from people')); + await waitFor(() => expect(updatePod).toHaveBeenCalledTimes(1)); + + view.rerender( + , + ); + fireEvent.click(await screen.findByLabelText('Messages from people')); + await waitFor(() => expect(updatePod).toHaveBeenCalledTimes(2)); + + // The old agent's save settles while the new agent's first save is still out. + await act(async () => { settles[0]({}); }); + fireEvent.click(screen.getByLabelText('Every message')); + // The new pick queues behind the new agent's pending save instead of racing it. + expect(updatePod).toHaveBeenCalledTimes(2); + await act(async () => { settles[1]({}); }); + await waitFor(() => expect(updatePod).toHaveBeenCalledTimes(3)); + expect(updatePod).toHaveBeenLastCalledWith('pod-1', EVERY); + }); + it('removes a pod installation and leaves the settings view open', async () => { const confirm = jest.spyOn(window, 'confirm').mockReturnValue(true); renderSettings(); diff --git a/frontend/src/v2/agents/V2AgentWakePods.tsx b/frontend/src/v2/agents/V2AgentWakePods.tsx index 96b4ee20f6..8af91001d8 100644 --- a/frontend/src/v2/agents/V2AgentWakePods.tsx +++ b/frontend/src/v2/agents/V2AgentWakePods.tsx @@ -16,6 +16,20 @@ interface InstallationSettings { heartbeatMinutes: number; } +// Message wake is one choice of three, shown together: the human-only +// filter used to appear only after "Wake on messages" was ticked (D173). +const MESSAGE_WAKE_CHOICES = ['mentions', 'human', 'all'] as const; +type MessageWakeChoice = typeof MESSAGE_WAKE_CHOICES[number]; +const MESSAGE_WAKE_SETTINGS: Record> = { + mentions: { wakeOnMessage: false, humanOnly: false }, + human: { wakeOnMessage: true, humanOnly: true }, + all: { wakeOnMessage: true, humanOnly: false }, +}; +const messageWakeChoice = (entry: InstallationSettings): MessageWakeChoice => { + if (!entry.wakeOnMessage) return 'mentions'; + return entry.humanOnly ? 'human' : 'all'; +}; + const V2AgentWakePods: React.FC<{ agentName: string; instanceId: string; @@ -37,6 +51,10 @@ const V2AgentWakePods: React.FC<{ const identity = `${agentName}:${instanceId}`; const identityRef = useRef(identity); identityRef.current = identity; + // Per-pod save state; see save() below. + const committedRef = useRef>({}); + const queuedRef = useRef>({}); + const sendingRef = useRef>({}); useEffect(() => { let active = true; @@ -45,6 +63,9 @@ const V2AgentWakePods: React.FC<{ setBusyPod(null); setError(null); setDraftMinutes({}); + committedRef.current = {}; + queuedRef.current = {}; + sendingRef.current = {}; const load = async () => { try { const settingsResult = await axios.get<{ @@ -53,6 +74,7 @@ const V2AgentWakePods: React.FC<{ if (!active) return; const rows = Array.isArray(settingsResult.data.installations) ? settingsResult.data.installations : []; setInstallations(rows); + 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]))); @@ -71,26 +93,56 @@ const V2AgentWakePods: React.FC<{ return () => { active = false; }; }, [base, query, identity, t]); + // Saves per pod go out one at a time, newest pick last. The message-wake + // radios stay enabled while a save is in flight (disabling them dropped + // keyboard focus to the page), so a second pick queues behind the first + // rather than racing it to the server. A failure rolls the row back to the + // last settings the server accepted, never to an unsaved pick. + const save = async (entry: InstallationSettings, changes: Partial) => { const requestedIdentity = identity; - setBusyPod(entry.podId); + const { podId } = entry; + const next = { ...entry, ...changes }; + // An agent switch replaces these objects. Hold the ones this save started + // with, so a loop still in flight for the old agent never writes into the + // new agent's queue. + const committed = committedRef.current; + const queued = queuedRef.current; + const sending = sendingRef.current; + if (!committed[podId]) committed[podId] = entry; + queued[podId] = next; + setBusyPod(podId); setError(null); + // Show the pick at once, so the next arrow key moves on from it. + setInstallations((current) => current.map((item) => item.podId === podId ? next : item)); + if (sending[podId]) return; + sending[podId] = true; try { - const next = { ...entry, ...changes }; - await updatePod(entry.podId, { - wakeOnMessage: next.wakeOnMessage, - boardWake: next.boardWake, - humanOnly: next.humanOnly, - heartbeatEnabled: next.heartbeatEnabled, - heartbeatMinutes: next.heartbeatMinutes, - }); - if (identityRef.current === requestedIdentity) { - setInstallations((current) => current.map((item) => item.podId === entry.podId ? next : item)); + while (queued[podId]) { + const target = queued[podId] as InstallationSettings; + queued[podId] = undefined; + try { + // eslint-disable-next-line no-await-in-loop + await updatePod(podId, { + wakeOnMessage: target.wakeOnMessage, + boardWake: target.boardWake, + humanOnly: target.humanOnly, + heartbeatEnabled: target.heartbeatEnabled, + heartbeatMinutes: target.heartbeatMinutes, + }); + committed[podId] = target; + } catch (requestError) { + queued[podId] = undefined; + if (identityRef.current === requestedIdentity) { + const saved = committed[podId]; + setInstallations((current) => current.map((item) => item.podId === podId ? saved : item)); + setError((requestError as { response?: { status?: number } })?.response?.status === 403 + ? t('agentSettings.forbidden') : t('agentSettings.saveError')); + } + } } - } catch (requestError) { - if (identityRef.current === requestedIdentity) setError((requestError as { response?: { status?: number } })?.response?.status === 403 - ? t('agentSettings.forbidden') : t('agentSettings.saveError')); } finally { + sending[podId] = false; if (identityRef.current === requestedIdentity) setBusyPod(null); } }; @@ -136,16 +188,17 @@ const V2AgentWakePods: React.FC<{ {installations.map((entry) => (
{entry.podName} - - {entry.wakeOnMessage && } +
+ {t('agentSettings.messageWake')} + {MESSAGE_WAKE_CHOICES.map((choice) => ( + + ))} +