diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts index d9c753501..0adc0992e 100644 --- a/desktop/src/features/chats/lib/chatWorkAutomation.ts +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -25,6 +25,10 @@ export type ChatWorkAutomation = { lastCiNudgeSha: string | null; /** Comment total at the last address-comments nudge. */ lastCommentNudgeCount: number | null; + /** Epoch ms of the last CI nudge — drives the persistent-failure re-nudge. */ + lastCiNudgeAt: number | null; + /** Epoch ms of the last comment nudge. */ + lastCommentNudgeAt: number | null; }; const DEFAULTS: ChatWorkAutomation = { @@ -32,6 +36,8 @@ const DEFAULTS: ChatWorkAutomation = { addressComments: false, lastCiNudgeSha: null, lastCommentNudgeCount: null, + lastCiNudgeAt: null, + lastCommentNudgeAt: null, }; function storageKey(chatId: string) { @@ -59,6 +65,12 @@ export function readChatWorkAutomation(chatId: string): ChatWorkAutomation { typeof parsed.lastCommentNudgeCount === "number" ? parsed.lastCommentNudgeCount : null, + lastCiNudgeAt: + typeof parsed.lastCiNudgeAt === "number" ? parsed.lastCiNudgeAt : null, + lastCommentNudgeAt: + typeof parsed.lastCommentNudgeAt === "number" + ? parsed.lastCommentNudgeAt + : null, }; } catch { return DEFAULTS; diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index b434049a9..9544e782d 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -723,6 +723,7 @@ export function ChatDetail({ void onSend(content, [], [CHAT_AUTOMATION_TAG]) } diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 80b0ee274..50106b03a 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -37,6 +37,12 @@ const CHIP_CLASS = const lastShownBranchByChat = new Map(); const lastShownPrByChat = new Map(); +// A consumed watermark must not strand a persisting condition: if CI is +// still red (or comments still open) and NO turn is running, armed +// automation re-nudges after this cooldown — the earlier nudge may have +// landed while the agent was stopped or the message failed to take. +const RENUDGE_COOLDOWN_MS = 15 * 60_000; + /** * Right-hand work drawer for a chat: branch, live PR card, and a CI monitor * once the agent has produced a pull request; an empty state before that. @@ -47,6 +53,7 @@ const lastShownPrByChat = new Map(); export function ChatWorkPanel({ branch = null, chatId, + isTurnActive = false, onAutomationPrompt, open = true, prHref, @@ -55,6 +62,8 @@ export function ChatWorkPanel({ /** Live branch from the agent's worktree/checkout activity, if any. */ branch?: string | null; chatId: string; + /** Whether an agent turn is currently running in this chat. */ + isTurnActive?: boolean; onAutomationPrompt?: (content: string) => void; open?: boolean; prHref?: string | null; @@ -120,43 +129,75 @@ export function ChatWorkPanel({ } }, [chatId, preview?.href]); + const ciConditionActive = Boolean( + checks && checks.failed > 0 && checks.pending === 0, + ); + const sendCiNudge = React.useCallback(() => { + if (!onAutomationPrompt || !effectiveHref || !pr || !checks) { + return; + } + updateChatWorkAutomation(chatId, { + lastCiNudgeSha: pr.headSha, + lastCiNudgeAt: Date.now(), + }); + onAutomationPrompt( + `CI is failing on ${effectiveHref} (${checks.failed} of ${checks.total} checks). Investigate the failures and push fixes until the checks pass.`, + ); + }, [chatId, checks, effectiveHref, onAutomationPrompt, pr]); + const sendCommentNudge = React.useCallback(() => { + if (!onAutomationPrompt || !effectiveHref) { + return; + } + updateChatWorkAutomation(chatId, { + lastCommentNudgeCount: Math.max(openThreads, 1), + lastCommentNudgeAt: Date.now(), + }); + onAutomationPrompt( + `There are unanswered review comments on ${effectiveHref}. Address each comment and its replies, push any needed changes, reply to the threads, and resolve every conversation that has been addressed.`, + ); + }, [chatId, effectiveHref, onAutomationPrompt, openThreads]); + // Automation: prompt the agent on CI failure / newly-open review threads. - // Watermarks in storage keep this to one nudge per failing sha and per - // rise in open threads; the thread watermark re-arms once everything has - // been replied to (count back at zero). + // Watermarks keep this to one nudge per failing sha and per rise in open + // threads (the thread watermark re-arms at zero); the cooldown path above + // covers conditions that persist with no agent working. React.useEffect(() => { if (!onAutomationPrompt || !effectiveHref || !pr) { return; } - if ( - automation.autoFixCi && - checks && - checks.failed > 0 && - checks.pending === 0 && - automation.lastCiNudgeSha !== pr.headSha - ) { - updateChatWorkAutomation(chatId, { lastCiNudgeSha: pr.headSha }); - onAutomationPrompt( - `CI is failing on ${effectiveHref} (${checks.failed} of ${checks.total} checks). Investigate the failures and push fixes until the checks pass.`, - ); + if (automation.autoFixCi && ciConditionActive && checks) { + const isNewFailure = automation.lastCiNudgeSha !== pr.headSha; + const cooledDown = + !isTurnActive && + Date.now() - (automation.lastCiNudgeAt ?? 0) > RENUDGE_COOLDOWN_MS; + if (isNewFailure || cooledDown) { + sendCiNudge(); + } } const threadWatermark = automation.lastCommentNudgeCount ?? 0; if (openThreads === 0 && threadWatermark !== 0) { updateChatWorkAutomation(chatId, { lastCommentNudgeCount: 0 }); - } else if (automation.addressComments && openThreads > threadWatermark) { - updateChatWorkAutomation(chatId, { lastCommentNudgeCount: openThreads }); - onAutomationPrompt( - `There are unanswered review comments on ${effectiveHref}. Address each comment and its replies, push any needed changes, reply to the threads, and resolve every conversation that has been addressed.`, - ); + } else if (automation.addressComments && openThreads > 0) { + const isNewComment = openThreads > threadWatermark; + const cooledDown = + !isTurnActive && + Date.now() - (automation.lastCommentNudgeAt ?? 0) > RENUDGE_COOLDOWN_MS; + if (isNewComment || cooledDown) { + sendCommentNudge(); + } } }, [ automation, chatId, checks, + ciConditionActive, effectiveHref, + isTurnActive, onAutomationPrompt, openThreads, pr, + sendCiNudge, + sendCommentNudge, ]); return ( @@ -234,38 +275,62 @@ export function ChatWorkPanel({ )} diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 0c4935de8..a45de1dfb 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -218,6 +218,10 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { page.getByLabel("Chat messages").getByText("unanswered review comments"), ).toHaveCount(0); + // Manual overrides: open comments expose "Run now"; green CI does not. + await expect(page.getByTestId("automation-run-comments-now")).toBeVisible(); + await expect(page.getByTestId("automation-run-ci-now")).toHaveCount(0); + // The header's PR button toggles the panel. await page.getByTestId("toggle-work-panel").click(); await expect(workPanel).not.toBeVisible();