diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx index 70fc802709..9b5479cd35 100644 --- a/ui/src/components/IssueBlockedNotice.test.tsx +++ b/ui/src/components/IssueBlockedNotice.test.tsx @@ -149,6 +149,9 @@ describe("IssueBlockedNotice", () => { expect(node.textContent).toContain("Asked CodexCoder to choose the next step"); expect(node.textContent).toContain("Detected progress: Updated the plan and left follow-up work."); expect(node.querySelector('[data-testid="issue-next-step-retry-now"]')).toBeNull(); + // No live continuation ⇒ the alarm stays exactly as it was; the calm + // in-flight line must not appear alongside it. + expect(node.querySelector('[data-testid="issue-next-step-in-flight"]')).toBeNull(); }); it("shows retry-now action for next-step notices with a scheduled retry", async () => { @@ -187,7 +190,7 @@ describe("IssueBlockedNotice", () => { }); }); - it("hides the next-step notice while a live continuation is running the issue", () => { + it("replaces the alarm with the calm in-flight line while a live continuation is running the issue", () => { const node = render( { />, ); + // The amber alarm and every remediation bullet are gone... expect(node.querySelector('[data-successful-run-handoff="required"]')).toBeNull(); - expect(node.textContent).toBe(""); + expect(node.textContent).not.toContain("This task still needs a next step."); + expect(node.textContent).not.toContain("Mark it done or cancelled."); + expect(node.querySelector(".bg-amber-50\\/90")).toBeNull(); + + // ...replaced by one quiet line that links the live run. + const calm = node.querySelector('[data-testid="issue-next-step-in-flight"]'); + expect(calm).not.toBeNull(); + expect(calm!.getAttribute("data-successful-run-handoff")).toBe("in_flight"); + expect(node.textContent).toContain( + "A correction run is in progress — the agent is working. This alert returns if the run stops without choosing a next step.", + ); + const runLink = calm!.querySelector("a"); + expect(runLink?.getAttribute("href")).toBe( + "/agents/agent-1/runs/87654321-dddd-eeee-ffff-123456789abc", + ); + expect(runLink?.textContent).toBe("run 87654321"); }); - it("hides the next-step notice when the live-run set includes this issue", () => { + it("shows the calm in-flight line when the live-run set includes this issue", () => { const node = render( { ); expect(node.querySelector('[data-successful-run-handoff="required"]')).toBeNull(); + const calm = node.querySelector('[data-testid="issue-next-step-in-flight"]'); + expect(calm).not.toBeNull(); + // No `liveRunId` on the payload — the copy stands alone, with no run link. + expect(calm!.querySelector("a")).toBeNull(); + expect(node.textContent).toContain("A correction run is in progress"); + }); + + it("omits the run link but keeps the calm copy when the live run has no known agent", () => { + const node = render( + , + ); + + const calm = node.querySelector('[data-testid="issue-next-step-in-flight"]'); + expect(calm).not.toBeNull(); + expect(calm!.querySelector("a")).toBeNull(); + expect(calm!.textContent).toContain("run 87654321"); + }); + + it("stays silent when the handoff is not required at all", () => { + const node = render( + , + ); + + expect(node.querySelector('[data-testid="issue-next-step-in-flight"]')).toBeNull(); expect(node.textContent).toBe(""); }); @@ -259,6 +330,10 @@ describe("IssueBlockedNotice", () => { expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull(); expect(node.querySelector('[data-testid="issue-next-step-retry-now"]')).not.toBeNull(); + // The carve-out wins over the calm line: the alarm is the only thing that + // keeps "Retry now" reachable, so it must not be quieted or duplicated. + expect(node.querySelector('[data-testid="issue-next-step-in-flight"]')).toBeNull(); + expect(node.textContent).toContain("This task still needs a next step."); }); it("does not render when the issue is done even if a stale handoff state is required", () => { diff --git a/ui/src/components/IssueBlockedNotice.tsx b/ui/src/components/IssueBlockedNotice.tsx index 83e766a13a..9968bf545a 100644 --- a/ui/src/components/IssueBlockedNotice.tsx +++ b/ui/src/components/IssueBlockedNotice.tsx @@ -185,6 +185,56 @@ function WaitingStepGlyph({ status }: { status: WaitingStepStatus }) { return ; } +/** + * Calm in-flight counterpart to the amber "still needs a next step" alarm. + * The handoff is still `required`, but a correction run is live on the issue, + * so the alarm would be crying wolf while an agent is already working. Saying + * it quietly beats saying nothing: the reader still learns a disposition is + * outstanding, and learns that the alarm comes back if the run ends without + * choosing one. + */ +function SuccessfulRunHandoffInFlightNotice({ + liveRunId, + assigneeAgentId, +}: { + liveRunId?: string | null; + assigneeAgentId?: string | null; +}) { + const shortRunId = liveRunId ? liveRunId.slice(0, 8) : null; + return ( +
+
+ + + +

+ A correction run is in progress — the agent is working. This alert returns if the run + stops without choosing a next step. + {shortRunId ? ( + <> + {" "} + {assigneeAgentId ? ( + + run {shortRunId} + + ) : ( + run {shortRunId} + )} + + ) : null} +

+
+
+ ); +} + /** * Blue "Waiting on live work" variant — rendered in place of the * amber notice when `blockerAttention.state === "covered"`: the blocker chain @@ -384,11 +434,33 @@ export function IssueBlockedNotice({ // missing-disposition complaint only applies when the issue is stuck. // `hasLiveContinuation` is the server's view; `liveIssueIds` catches runs // that started after the issue payload was fetched. + const issueHasLiveRun = Boolean(issueId && liveIssueIds?.has(issueId)); const showSuccessfulRunHandoff = successfulRunHandoff != null && isSuccessfulRunHandoffRequired({ successfulRunHandoff, scheduledRetry }) - && !(issueId && liveIssueIds?.has(issueId)); - if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null; + && !issueHasLiveRun; + // Outstanding handoff + a live run on the issue: the alarm is suppressed, so + // render the quiet in-flight line in its place rather than nothing at all. + // The unpromoted-scheduled-retry carve-out keeps `showSuccessfulRunHandoff` + // true, so the amber notice (and its "Retry now" control) still wins there. + // This stands in for the handoff alarm only. When the issue also has + // blockers, the blocker notice below is the stronger signal and owns the + // slot, exactly as it did before this line existed. + const handoffInFlightNotice = + successfulRunHandoff != null + && successfulRunHandoff.required === true + && !showSuccessfulRunHandoff + && (successfulRunHandoff.hasLiveContinuation || issueHasLiveRun) + ? ( + + ) + : null; + if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") { + return handoffInFlightNotice; + } const successfulRunRetryNow = showSuccessfulRunHandoff && issueId && scheduledRetry?.status === "scheduled_retry" diff --git a/ui/storybook/stories/successful-run-handoff.stories.tsx b/ui/storybook/stories/successful-run-handoff.stories.tsx index 77b188cd7e..18085a7dee 100644 --- a/ui/storybook/stories/successful-run-handoff.stories.tsx +++ b/ui/storybook/stories/successful-run-handoff.stories.tsx @@ -33,13 +33,14 @@ function ActivityExample({ action }: { action: string }) { function SuccessfulRunHandoffStates() { return ( + +
- +
-
- +
@@ -68,6 +69,25 @@ function handoffIssue() { }); } +/** + * Same outstanding handoff as {@link handoffIssue}, but a correction run is + * live on the issue — the state that must NOT raise the amber alarm. + */ +function handoffIssueInFlight() { + const issue = handoffIssue(); + return createIssue({ + ...issue, + id: "issue-handoff-live", + identifier: "PAP-3055", + issueNumber: 3055, + successfulRunHandoff: { + ...issue.successfulRunHandoff!, + hasLiveContinuation: true, + liveRunId: "61fdb79b-8012-4676-ac71-2971830e126a", + }, + }); +} + function StoryFrame({ children, title = "Board-visible handoff states" }: { children: ReactNode; title?: string }) { return (
@@ -97,6 +117,33 @@ function PinnedNoticePanel() { ); } +function InFlightNoticePanel() { + const issue = handoffIssueInFlight(); + return ( +
+
+ A2. Calm in-flight notice (live continuation) +
+ +
+ ); +} + +/** The two states side by side — the contrast is the point. */ +function HandoffLivenessComparisonPanel() { + return ( +
+ + +
+ ); +} + function ActivityEventsPanel() { return (
@@ -166,6 +213,18 @@ function SuccessfulRunHandoffPinnedNotice() { return ; } +function SuccessfulRunHandoffInFlightNoticeStory() { + return ; +} + +function SuccessfulRunHandoffLivenessComparison() { + return ( + + + + ); +} + function SuccessfulRunHandoffActivityEvents() { return ; } @@ -192,6 +251,9 @@ type Story = StoryObj; export const AllStates: Story = {}; export const PinnedNotice: Story = { render: () => }; +/** Handoff required, but a correction run is live — no alarm. */ +export const InFlightNotice: Story = { render: () => }; +export const LivenessComparison: Story = { render: () => }; export const ActivityEvents: Story = { render: () => }; export const IssueCardIndicator: Story = { render: () => }; export const EscalationComment: Story = { render: () => };