diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 485f217e246..f07542687d8 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -967,6 +967,8 @@ export interface IssueCommentFeedbackDelivery { * mystery action. */ targetRunId: string | null; + /** Agent that owns `targetRunId`; both are needed to build the run route. */ + targetRunAgentId: string | null; /** True when the viewer may ask for one more explicit delivery attempt. */ canRetry: boolean; /** diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts index e4d2f3bf648..a91226929b8 100644 --- a/server/src/__tests__/attention-service.test.ts +++ b/server/src/__tests__/attention-service.test.ts @@ -1255,6 +1255,54 @@ describeEmbeddedPostgres("attention service", () => { expect(items[0]).toMatchObject({ sourceKind: "blocker_attention", whyNow: "Approve the exception" }); }); + it("surfaces an agent-owned exhausted feedback delivery, deep-linked to the comment banner", async () => { + const { companyId, workerId } = await seedCompany("ATF"); + const issueId = await insertIssue({ + companyId, + identifier: "ATF-1", + title: "Feedback never landed", + status: "in_review", + assigneeAgentId: workerId, + }); + await db.insert(issueRecoveryActions).values({ + id: randomUUID(), + companyId, + sourceIssueId: issueId, + kind: "feedback_delivery", + status: "active", + // Agent-owned: the wake policy is manual_repair_required, so no agent will + // ever act on it. Without the operator row this is invisible from the inbox. + ownerType: "agent", + ownerAgentId: workerId, + ownerUserId: null, + cause: "feedback_delivery_exhausted", + fingerprint: `feedback_delivery:${companyId}:${issueId}:${workerId}:root-wake-1`, + evidence: { outstandingCommentIds: ["comment-a", "comment-b"] }, + nextAction: "Handle the outstanding human feedback comment(s).", + createdAt: new Date("2026-07-23T18:30:00.000Z"), + updatedAt: new Date("2026-07-23T18:30:00.000Z"), + }); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + const items = feed.items.filter((item) => item.sourceKind === "recovery_action"); + + expect(items).toHaveLength(1); + expect(items[0]?.subject.title).toBe("Feedback delivery needs attention"); + expect(items[0]?.whyNow).toContain("Paperclip stopped retrying"); + const detail = items[0]?.detail; + const summaryExcerpt = detail && "summaryExcerpt" in detail ? detail.summaryExcerpt : null; + expect(summaryExcerpt).toContain("The agent may not have received your comment."); + // One row per feedback batch, with the rollup stated rather than split into + // an inbox entry per comment. + expect(summaryExcerpt).toContain("Covers 2 comments."); + expect(items[0]?.subject.href).toContain("#feedback-delivery-comment-a"); + expect(items[0]?.subject.metadata).toMatchObject({ + sourceLabel: "Feedback delivery", + sourceCommentId: "comment-a", + outstandingCommentCount: 2, + }); + }); + it("keeps legacy blocker attention visible for pre-rollout blocked issues", async () => { const { companyId } = await seedCompany("ATP"); const issueId = await insertIssue({ diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts index f78347aadc3..88d74ada62f 100644 --- a/server/src/services/attention.ts +++ b/server/src/services/attention.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, notInArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, @@ -50,6 +50,7 @@ import type { } from "@paperclipai/shared"; import { badRequest } from "../errors.js"; import { PRODUCTIVITY_REVIEW_ORIGIN_KIND } from "./productivity-review.js"; +import { FEEDBACK_DELIVERY_RECOVERY_ACTION_KIND } from "./recovery/feedback-delivery.js"; import { budgetService } from "./budgets.js"; import { BLOCKER_ATTENTION_MAX_DEPTH, @@ -106,6 +107,49 @@ const PENDING_INTERACTION_STATUSES = ["pending"] as const; const OPEN_RECOVERY_STATUSES = ["active", "escalated"] as const; const HUMAN_RECOVERY_OWNER_TYPES = ["user", "board"] as const; const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"] as const; + +/** + * Operator-facing copy for an exhausted feedback delivery. Plain language on + * purpose: "wake request", "adapter", and transport codes are meaningless to + * the person who just wrote a comment and is waiting for an answer. + */ +const FEEDBACK_DELIVERY_ATTENTION_SOURCE_LABEL = "Feedback delivery"; +const FEEDBACK_DELIVERY_ATTENTION_TITLE = "Feedback delivery needs attention"; +const FEEDBACK_DELIVERY_ATTENTION_DETAIL = "The agent may not have received your comment."; +const FEEDBACK_DELIVERY_ATTENTION_WHY_NOW = + "Paperclip stopped retrying delivery of a human comment on this task."; + +/** + * Shapes the attention row for an exhausted feedback delivery. + * + * One row per feedback batch (the recovery action's fingerprint), deep-linked to + * the banner anchor of the first outstanding comment. A batched wake can cover + * several comments; they stay individually actionable in the thread while the + * queue keeps one entry, so a single lost wake cannot flood the inbox. + */ +function readFeedbackDeliveryAttentionRow( + recovery: { evidence: unknown }, + sourceIssueHref: string | null, +) { + const evidence = typeof recovery.evidence === "object" && recovery.evidence !== null + ? (recovery.evidence as Record) + : {}; + const commentIds = Array.isArray(evidence.outstandingCommentIds) + ? evidence.outstandingCommentIds.filter((entry): entry is string => typeof entry === "string") + : []; + const sourceCommentId = commentIds[0] ?? null; + return { + title: FEEDBACK_DELIVERY_ATTENTION_TITLE, + detail: commentIds.length > 1 + ? `${FEEDBACK_DELIVERY_ATTENTION_DETAIL} Covers ${commentIds.length} comments.` + : FEEDBACK_DELIVERY_ATTENTION_DETAIL, + sourceCommentId, + outstandingCommentCount: commentIds.length, + href: sourceIssueHref && sourceCommentId + ? `${sourceIssueHref}#feedback-delivery-${sourceCommentId}` + : sourceIssueHref, + }; +} const FAILED_RUN_STATUSES = ["failed", "timed_out"] as const; const DETAIL_EXCERPT_LENGTH = 160; const DETAIL_IMAGE_LIMIT = 3; @@ -1391,7 +1435,15 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions .where(and( eq(issueRecoveryActions.companyId, companyId), inArray(issueRecoveryActions.status, [...OPEN_RECOVERY_STATUSES]), - inArray(issueRecoveryActions.ownerType, [...HUMAN_RECOVERY_OWNER_TYPES]), + or( + inArray(issueRecoveryActions.ownerType, [...HUMAN_RECOVERY_OWNER_TYPES]), + // An exhausted feedback delivery carries a `manual_repair_required` + // wake policy, so no agent owner will ever act on it even when the + // action is nominally agent-owned. Without this the only surface is + // the task thread, and an operator cannot find the task from the + // inbox at all. + eq(issueRecoveryActions.kind, FEEDBACK_DELIVERY_RECOVERY_ACTION_KIND), + ), )) .orderBy(desc(issueRecoveryActions.updatedAt), desc(issueRecoveryActions.id)); const [recoveryIssueMap, recoveryImageMap] = await Promise.all([ @@ -1407,6 +1459,9 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions const sourceIssue = recoveryIssueMap.get(recovery.sourceIssueId) ?? null; const recoveryIssue = recovery.recoveryIssueId ? recoveryIssueMap.get(recovery.recoveryIssueId) ?? null : null; const dedupKey = `recovery:${recovery.kind}:${recovery.sourceIssueId}:${recovery.cause}:${recovery.fingerprint}`; + const feedbackDelivery = recovery.kind === FEEDBACK_DELIVERY_RECOVERY_ACTION_KIND + ? readFeedbackDeliveryAttentionRow(recovery, sourceIssue ? issueHref(prefix, sourceIssue) : null) + : null; add(createItem({ companyId, sourceKind: "recovery_action", @@ -1414,10 +1469,12 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions kind: "recovery_action", id: recovery.id, companyId, - title: recovery.nextAction, + title: feedbackDelivery?.title ?? recovery.nextAction, identifier: null, status: recovery.status, - href: recoveryIssue ? issueHref(prefix, recoveryIssue) : sourceIssue ? issueHref(prefix, sourceIssue) : null, + href: feedbackDelivery + ? feedbackDelivery.href + : recoveryIssue ? issueHref(prefix, recoveryIssue) : sourceIssue ? issueHref(prefix, sourceIssue) : null, metadata: { kind: recovery.kind, cause: recovery.cause, @@ -1425,9 +1482,18 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions ownerUserId: recovery.ownerUserId, sourceIssueId: recovery.sourceIssueId, recoveryIssueId: recovery.recoveryIssueId, + ...(feedbackDelivery + ? { + sourceLabel: FEEDBACK_DELIVERY_ATTENTION_SOURCE_LABEL, + sourceCommentId: feedbackDelivery.sourceCommentId, + outstandingCommentCount: feedbackDelivery.outstandingCommentCount, + } + : {}), }, }, - whyNow: recovery.status === "escalated" + whyNow: feedbackDelivery + ? FEEDBACK_DELIVERY_ATTENTION_WHY_NOW + : recovery.status === "escalated" ? "Recovery action escalated to a human owner." : "Recovery action is assigned to a human owner.", decisionVerbs: decisionVerbs( @@ -1445,7 +1511,10 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions updatedAt: toIso(recovery.updatedAt), relatedIssue: sourceIssue ? issueSubject(prefix, sourceIssue) : null, ...issueContext(sourceIssue), - detail: genericDetail(recovery.nextAction, issueImages(recoveryImageMap, recovery.sourceIssueId)), + detail: genericDetail( + feedbackDelivery?.detail ?? recovery.nextAction, + issueImages(recoveryImageMap, recovery.sourceIssueId), + ), })); } diff --git a/server/src/services/feedback-delivery-comment-states.ts b/server/src/services/feedback-delivery-comment-states.ts index 32f2a82b8c7..99aed7e48e4 100644 --- a/server/src/services/feedback-delivery-comment-states.ts +++ b/server/src/services/feedback-delivery-comment-states.ts @@ -88,6 +88,7 @@ export function feedbackDeliveryCommentStateService(db: Db) { evidence: issueRecoveryActions.evidence, attemptCount: issueRecoveryActions.attemptCount, maxAttempts: issueRecoveryActions.maxAttempts, + previousOwnerAgentId: issueRecoveryActions.previousOwnerAgentId, lastAttemptAt: issueRecoveryActions.lastAttemptAt, resolvedAt: issueRecoveryActions.resolvedAt, createdAt: issueRecoveryActions.createdAt, @@ -160,6 +161,7 @@ export function feedbackDeliveryCommentStateService(db: Db) { agentId: wake.agentId, rootWakeupRequestId, }), + agentId: wake.agentId, commentIds: readReplayCommentIds(payload), generation: readGeneration(payload.feedbackDeliveryRetryGeneration), runId: wake.runId ?? null, @@ -179,6 +181,7 @@ export function feedbackDeliveryCommentStateService(db: Db) { agentId: run.agentId, rootWakeupRequestId, }), + agentId: run.agentId, commentIds: readReplayCommentIds(context), generation: readGeneration(context.feedbackDeliveryRetryGeneration), runId: run.id, diff --git a/server/src/services/recovery/feedback-delivery-projection.test.ts b/server/src/services/recovery/feedback-delivery-projection.test.ts index b8973a397f7..fd1461c0e37 100644 --- a/server/src/services/recovery/feedback-delivery-projection.test.ts +++ b/server/src/services/recovery/feedback-delivery-projection.test.ts @@ -31,6 +31,7 @@ function action( // Deliberately present: the projection must never surface it. lastFailureReason: "Error: ECONNREFUSED /home/agent/.secret/token.json", }, + previousOwnerAgentId: "agent-1", attemptCount: 1, maxAttempts: 1, lastAttemptAt: new Date("2026-08-14T17:58:00.000Z"), @@ -45,6 +46,7 @@ function replay( ): FeedbackDeliveryPendingReplay { return { fingerprint: FINGERPRINT, + agentId: "agent-1", commentIds: ["comment-1"], generation: 1, runId: "run-2", diff --git a/server/src/services/recovery/feedback-delivery-projection.ts b/server/src/services/recovery/feedback-delivery-projection.ts index 8a32568bb12..025e9aa7be4 100644 --- a/server/src/services/recovery/feedback-delivery-projection.ts +++ b/server/src/services/recovery/feedback-delivery-projection.ts @@ -54,6 +54,8 @@ export type FeedbackDeliveryRecoveryActionRow = { status: string; fingerprint: string; evidence: unknown; + /** Original assignee, used to build the run route for `sourceRunId`. */ + previousOwnerAgentId: string | null; attemptCount: number; maxAttempts: number | null; lastAttemptAt: Date | string | null; @@ -67,6 +69,7 @@ export type FeedbackDeliveryRecoveryActionRow = { */ export type FeedbackDeliveryPendingReplay = { fingerprint: string | null; + agentId: string; commentIds: readonly string[]; generation: number; runId: string | null; @@ -186,6 +189,7 @@ export function projectFeedbackDeliveryByComment( lastAttemptAt: toIso(action.lastAttemptAt), deliveredAt: toIso(action.resolvedAt), targetRunId, + targetRunAgentId: targetRunId ? action.previousOwnerAgentId : null, canRetry: false, retryInFlight: false, safeFailureReason: null, @@ -215,6 +219,7 @@ export function projectFeedbackDeliveryByComment( lastAttemptAt: toIso(replay.requestedAt), deliveredAt: null, targetRunId: input.viewerCanReadRuns ? replay.runId : null, + targetRunAgentId: input.viewerCanReadRuns && replay.runId ? replay.agentId : null, canRetry: false, retryInFlight: true, safeFailureReason: null, @@ -230,6 +235,11 @@ export function projectFeedbackDeliveryByComment( const targetRunId = input.viewerCanReadRuns ? replay?.runId ?? readEvidenceString(action.evidence, "sourceRunId") : null; + const targetRunAgentId = !targetRunId + ? null + : replay?.runId === targetRunId + ? replay.agentId + : action.previousOwnerAgentId; const safeFailureReason = toSafeFeedbackDeliveryFailureReason(readEvidenceString(action.evidence, "lastFailureCode")) ?? toSafeFeedbackDeliveryFailureReason(readEvidenceString(action.evidence, "gateErrorCode")); @@ -246,6 +256,7 @@ export function projectFeedbackDeliveryByComment( lastAttemptAt: toIso(action.lastAttemptAt ?? action.createdAt), deliveredAt: null, targetRunId, + targetRunAgentId, canRetry: input.viewerCanRetry && !retryInFlight, retryInFlight, safeFailureReason, diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 5eae6f0685e..cec14f299c9 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -16,6 +16,7 @@ import type { IssueDocument, IssueLabel, IssueRecoveryAction, + IssueFeedbackDeliveryRetryResponse, IssueRetryNowResponse, StalledReviewDecision, StalledReviewDecisionResponse, @@ -215,6 +216,8 @@ export const issuesApi = { checkMonitorNow: (id: string) => api.post<{ ok: true }>(`/issues/${id}/monitor/check-now`, {}), retryScheduledRetryNow: (id: string) => api.post(`/issues/${id}/scheduled-retry/retry-now`, {}), + retryFeedbackDelivery: (id: string) => + api.post(`/issues/${id}/feedback-delivery/retry`, {}), remove: (id: string) => api.delete(`/issues/${id}`), checkout: (id: string, agentId: string) => api.post(`/issues/${id}/checkout`, { diff --git a/ui/src/components/FeedbackDeliveryState.test.tsx b/ui/src/components/FeedbackDeliveryState.test.tsx new file mode 100644 index 00000000000..e63dcd6e7b6 --- /dev/null +++ b/ui/src/components/FeedbackDeliveryState.test.tsx @@ -0,0 +1,398 @@ +// @vitest-environment jsdom + +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import type { ReactElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import type { IssueCommentFeedbackDelivery } from "@paperclipai/shared"; + +import { + FEEDBACK_DELIVERY_RETRYING_REVEAL_MS, + FEEDBACK_DELIVERY_TRANSIENT_SUCCESS_MS, + FeedbackDeliveryFooterStatus, + FeedbackDeliveryNotice, + feedbackDeliveryAnchorId, + isIssueCommentFeedbackDelivery, + useFeedbackDeliveryTransientSuccess, +} from "./FeedbackDeliveryState"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +/** + * Synchronous act shim, matching the convention the other jsdom component + * tests in this directory use (React 19 does not expose `act` from the entry + * point this suite resolves). + */ +function act(callback: () => T): T { + let result: T | undefined; + flushSync(() => { + result = callback(); + }); + return result as T; +} + +let root: ReturnType | null = null; +let container: HTMLDivElement | null = null; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-14T18:00:00.000Z")); +}); + +afterEach(() => { + if (root) act(() => root?.unmount()); + root = null; + container?.remove(); + container = null; + vi.useRealTimers(); +}); + +function render(element: ReactElement) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => root?.render({element})); + return container; +} + +function rerender(element: ReactElement) { + act(() => root?.render({element})); + return container!; +} + +/** + * React schedules passive effects on a macrotask (MessageChannel), which fake + * timers do not advance, so effect-driven state has to be awaited rather than + * flushed synchronously. + */ +async function flushEffects() { + await new Promise((resolve) => { + const channel = new MessageChannel(); + channel.port1.onmessage = () => resolve(); + channel.port2.postMessage(null); + }); + act(() => {}); +} + +function delivery( + overrides: Partial = {}, +): IssueCommentFeedbackDelivery { + return { + state: "exhausted_needs_attention", + sourceCommentId: "comment-1", + attempt: 1, + maxAttempts: 1, + nextAttemptAt: null, + lastAttemptAt: "2026-08-14T17:58:00.000Z", + deliveredAt: null, + targetRunId: "a1b2c3d4e5f6", + targetRunAgentId: "agent-1", + canRetry: true, + retryInFlight: false, + safeFailureReason: null, + batchedCommentCount: 1, + ...overrides, + }; +} + +describe("FeedbackDeliveryFooterStatus", () => { + it("stays quiet inside the Doherty window and appears after it", () => { + const node = render( + , + ); + expect(node.querySelector("[data-testid='feedback-delivery-footer-status']")).toBeNull(); + + act(() => { + vi.advanceTimersByTime(FEEDBACK_DELIVERY_RETRYING_REVEAL_MS); + }); + const status = node.querySelector("[data-testid='feedback-delivery-footer-status']"); + expect(status?.textContent).toContain("Retrying feedback delivery"); + // Automatic recovery is still running: no operator control here. + expect(node.querySelector("button")).toBeNull(); + }); + + it("announces politely rather than as an alert", () => { + const node = render( + , + ); + act(() => { + vi.advanceTimersByTime(FEEDBACK_DELIVERY_RETRYING_REVEAL_MS); + }); + const status = node.querySelector("[data-testid='feedback-delivery-footer-status']"); + expect(status?.getAttribute("role")).toBe("status"); + expect(status?.getAttribute("aria-live")).toBe("polite"); + }); + + it("prefers the countdown over the attempt tally", () => { + const node = render( + , + ); + act(() => { + vi.advanceTimersByTime(FEEDBACK_DELIVERY_RETRYING_REVEAL_MS); + }); + const text = node.textContent ?? ""; + expect(text).toContain("Next attempt in"); + expect(text).not.toContain("Attempt"); + }); + + it("falls back to the attempt tally when no countdown is known", () => { + const node = render( + , + ); + act(() => { + vi.advanceTimersByTime(FEEDBACK_DELIVERY_RETRYING_REVEAL_MS); + }); + expect(node.textContent).toContain("Attempt 1 of 2"); + }); + + it("disables spin under reduced motion without dropping the text", () => { + const node = render( + , + ); + act(() => { + vi.advanceTimersByTime(FEEDBACK_DELIVERY_RETRYING_REVEAL_MS); + }); + const icon = node.querySelector("svg"); + expect(icon?.getAttribute("class")).toContain("motion-reduce:animate-none"); + expect(node.textContent).toContain("Retrying feedback delivery"); + }); + + it("renders nothing for a comment with no delivery state", () => { + const node = render(); + act(() => { + vi.advanceTimersByTime(FEEDBACK_DELIVERY_RETRYING_REVEAL_MS); + }); + expect(node.textContent).toBe(""); + }); + + it("shows the transient success line only when asked", () => { + const node = render(); + const status = node.querySelector("[data-testid='feedback-delivery-footer-status']"); + expect(status?.getAttribute("data-state")).toBe("delivered_after_retry"); + expect(status?.textContent).toContain("Delivered after retry"); + }); + + it("omits the run link when the viewer cannot open the run", () => { + const node = render( + , + ); + act(() => { + vi.advanceTimersByTime(FEEDBACK_DELIVERY_RETRYING_REVEAL_MS); + }); + expect(node.querySelector("a")).toBeNull(); + }); +}); + +function TransientProbe({ delivery: value }: { delivery: IssueCommentFeedbackDelivery | null }) { + const show = useFeedbackDeliveryTransientSuccess(value); + return ; +} + +describe("useFeedbackDeliveryTransientSuccess", () => { + it("confirms a watched recovery, then removes itself", async () => { + const node = render(); + rerender(); + // The hook flips its flag in a passive effect, so the confirmation lands on + // the render that effect schedules, not the one that dropped the state. + await flushEffects(); + expect(node.textContent).toContain("Delivered after retry"); + + act(() => { + vi.advanceTimersByTime(FEEDBACK_DELIVERY_TRANSIENT_SUCCESS_MS); + }); + await flushEffects(); + expect(node.textContent).toBe(""); + }); + + it("leaves no trace for a delivery that was never visibly retrying", async () => { + const node = render(); + rerender(); + await flushEffects(); + expect(node.textContent).toBe(""); + }); + + it("does not celebrate a promotion to needs-attention", async () => { + const node = render(); + rerender(); + await flushEffects(); + expect(node.textContent).not.toContain("Delivered after retry"); + }); +}); + +describe("FeedbackDeliveryNotice — exhausted", () => { + it("states the problem in plain language with an alert announcement", () => { + const node = render( {}} />); + const banner = node.querySelector("[data-testid='feedback-delivery-exhausted']"); + expect(banner).not.toBeNull(); + expect(node.textContent).toContain("Feedback wasn't delivered"); + expect(node.textContent).toContain("Paperclip stopped retrying"); + expect(node.querySelector("[role='alert']")).not.toBeNull(); + for (const jargon of ["wake request", "adapter", "heartbeat"]) { + expect(node.textContent?.toLowerCase()).not.toContain(jargon); + } + }); + + it("offers retry plus an authorized run link, both large enough to tap", () => { + const node = render( {}} />); + const retry = node.querySelector("[data-testid='feedback-delivery-retry']"); + expect(retry?.textContent).toBe("Retry delivery"); + expect(retry?.getAttribute("class")).toContain("min-h-11"); + const runLink = node.querySelector("a[href='/agents/agent-1/runs/a1b2c3d4e5f6']"); + expect(runLink?.textContent).toBe("View run"); + }); + + it("shortens the run reference to the first 8 characters", () => { + const node = render(); + expect(node.textContent).toContain("Run a1b2c3d4"); + expect(node.textContent).not.toContain("a1b2c3d4e5f6"); + }); + + it("omits the run action entirely when no authorized route exists", () => { + const node = render( + {}} + />, + ); + expect([...node.querySelectorAll("a")].map((a) => a.textContent)).not.toContain("View run"); + // Recovery is still offered without it. + expect(node.querySelector("[data-testid='feedback-delivery-retry']")).not.toBeNull(); + }); + + it("calls back with the source comment id", () => { + const onRetry = vi.fn(); + const node = render(); + const retry = node.querySelector( + "[data-testid='feedback-delivery-retry']", + ); + act(() => retry?.click()); + expect(onRetry).toHaveBeenCalledWith("comment-1"); + }); + + it("disables only the retry button while a retry is pending", () => { + const node = render( + {}} retryPending />, + ); + const retry = node.querySelector( + "[data-testid='feedback-delivery-retry']", + ); + expect(retry?.textContent).toBe("Retrying…"); + expect(retry?.disabled).toBe(true); + const runLink = node.querySelector("a[href='/agents/agent-1/runs/a1b2c3d4e5f6']"); + expect(runLink).not.toBeNull(); + }); + + it("keeps the pending affordance across a reload via retryInFlight", () => { + const node = render( + {}} + />, + ); + const retry = node.querySelector( + "[data-testid='feedback-delivery-retry']", + ); + expect(retry?.textContent).toBe("Retrying…"); + expect(retry?.disabled).toBe(true); + }); + + it("shows the retry failure inline, not only in a toast", () => { + const node = render( + {}} retryError />, + ); + expect(node.querySelector("[data-testid='feedback-delivery-retry-error']")?.textContent).toContain( + "Couldn't start another retry", + ); + // The control is restored so the operator can act on that copy. + expect( + node.querySelector("[data-testid='feedback-delivery-retry']")?.disabled, + ).toBe(false); + }); + + it("hides retry from a viewer without the authority to trigger it", () => { + const node = render(); + expect(node.querySelector("[data-testid='feedback-delivery-retry']")).toBeNull(); + expect(node.textContent).toContain("Feedback wasn't delivered"); + }); + + it("surfaces a safe failure reason when the server mapped one", () => { + const node = render( + , + ); + expect(node.textContent).toContain("The agent is over its budget."); + }); + + it("rolls a batched obligation up in the banner copy", () => { + const node = render(); + expect(node.textContent).toContain("Covers 3 comments."); + }); + + it("exposes a focusable anchor for the attention deep link", () => { + const node = render(); + const anchor = node.querySelector(`#${feedbackDeliveryAnchorId("comment-1")}`); + expect(anchor).not.toBeNull(); + expect(node.querySelector("[tabindex='-1']")?.textContent).toBe("Feedback wasn't delivered"); + }); +}); + +describe("FeedbackDeliveryNotice — recovered", () => { + const recovered = delivery({ + state: "recovered_after_attention", + canRetry: false, + deliveredAt: "2026-08-14T17:42:00.000Z", + }); + + it("replaces the warning with a historical delivered notice", () => { + const node = render(); + expect(node.querySelector("[data-testid='feedback-delivery-recovered']")).not.toBeNull(); + expect(node.querySelector("[data-testid='feedback-delivery-exhausted']")).toBeNull(); + expect(node.textContent).toContain("Feedback delivered"); + expect(node.textContent).toContain("Recovered after delivery needed attention."); + }); + + it("offers no retry once delivery has recovered", () => { + const node = render( {}} />); + expect(node.querySelector("[data-testid='feedback-delivery-retry']")).toBeNull(); + }); + + it("keeps the authorized run reference for auditability", () => { + const node = render(); + expect(node.querySelector("a[href='/agents/agent-1/runs/a1b2c3d4e5f6']")).not.toBeNull(); + expect(node.textContent).toContain("Run a1b2c3d4"); + }); + + it("renders nothing for an unrecognized state", () => { + const node = render( + , + ); + expect(node.textContent).toBe(""); + }); +}); + +describe("isIssueCommentFeedbackDelivery", () => { + it("accepts a well-formed projection", () => { + expect(isIssueCommentFeedbackDelivery(delivery())).toBe(true); + }); + + it("rejects anything else, including a future unknown state", () => { + expect(isIssueCommentFeedbackDelivery(null)).toBe(false); + expect(isIssueCommentFeedbackDelivery("retrying")).toBe(false); + expect(isIssueCommentFeedbackDelivery({ state: "retrying" })).toBe(false); + expect( + isIssueCommentFeedbackDelivery({ state: "quantum_delivered", sourceCommentId: "c" }), + ).toBe(false); + }); +}); diff --git a/ui/src/components/FeedbackDeliveryState.tsx b/ui/src/components/FeedbackDeliveryState.tsx new file mode 100644 index 00000000000..e64829bbbd7 --- /dev/null +++ b/ui/src/components/FeedbackDeliveryState.tsx @@ -0,0 +1,334 @@ +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { CircleCheck, RefreshCw } from "lucide-react"; +import type { IssueCommentFeedbackDelivery } from "@paperclipai/shared"; + +import { InlineBanner } from "@/components/InlineBanner"; +import { SystemNotice } from "@/components/SystemNotice"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { timeAgo } from "@/lib/timeAgo"; + +/** + * Doherty-threshold window. A first delivery that succeeds quickly stays + * visually quiet: the retrying line only appears once the wait is long enough + * for the operator to have noticed it. + */ +export const FEEDBACK_DELIVERY_RETRYING_REVEAL_MS = 400; + +/** How long the ephemeral "Delivered after retry" confirmation stays visible. */ +export const FEEDBACK_DELIVERY_TRANSIENT_SUCCESS_MS = 5000; + +const FEEDBACK_DELIVERY_STATES = [ + "retrying", + "exhausted_needs_attention", + "recovered_after_attention", +]; + +/** + * Narrows the untyped `custom` bag a thread message carries. Thread metadata + * crosses an `unknown` boundary, so the state string is validated rather than + * cast — an unknown future state renders nothing instead of an empty shell. + */ +export function isIssueCommentFeedbackDelivery( + value: unknown, +): value is IssueCommentFeedbackDelivery { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Partial; + return ( + typeof candidate.state === "string" + && FEEDBACK_DELIVERY_STATES.includes(candidate.state) + && typeof candidate.sourceCommentId === "string" + ); +} + +/** Anchor id for deep-linking the attention queue straight at the banner. */ +export function feedbackDeliveryAnchorId(commentId: string) { + return `feedback-delivery-${commentId}`; +} + +function shortRunId(runId: string) { + return runId.slice(0, 8); +} + +function runHref(delivery: IssueCommentFeedbackDelivery) { + if (!delivery.targetRunId || !delivery.targetRunAgentId) return null; + return `/agents/${delivery.targetRunAgentId}/runs/${delivery.targetRunId}`; +} + +function deliveredTimeLabel(iso: string) { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); +} + +/** + * Countdown copy for the next automatic attempt. Preferred over + * "Attempt 2 of 3" — one number is a lower cognitive load than two, and a + * countdown answers the only question the operator actually has. + */ +function nextAttemptLabel(nextAttemptAt: string | null, now: number) { + if (!nextAttemptAt) return null; + const target = new Date(nextAttemptAt).getTime(); + if (Number.isNaN(target)) return null; + const seconds = Math.round((target - now) / 1000); + if (seconds <= 0) return null; + if (seconds < 60) return `Next attempt in ${seconds}s`; + return `Next attempt in ${Math.ceil(seconds / 60)}m`; +} + +function attemptLabel(delivery: IssueCommentFeedbackDelivery) { + if (delivery.attempt === null || delivery.maxAttempts === null) return null; + // `attempt` counts completed generations; the in-flight one is the next. + return `Attempt ${delivery.attempt + 1} of ${delivery.maxAttempts + 1}`; +} + +/** + * Tracks the transition out of `retrying` so quiet transient recovery can be + * acknowledged without an attention item, a toast, or a thread event. + * + * The comment row owns this hook (not the status line) because the status line + * unmounts exactly when the state it needs to report on disappears. + */ +export function useFeedbackDeliveryTransientSuccess( + delivery: IssueCommentFeedbackDelivery | null | undefined, +) { + const [showTransientSuccess, setShowTransientSuccess] = useState(false); + const sawRetryingRef = useRef(false); + + const state = delivery?.state ?? null; + useEffect(() => { + if (state === "retrying") { + sawRetryingRef.current = true; + setShowTransientSuccess(false); + return; + } + // Only celebrate a recovery the operator actually watched happen. An + // exhausted promotion is not a success, and a delivery that was never + // visibly retrying should leave no trace at all. + if (state !== null || !sawRetryingRef.current) return; + sawRetryingRef.current = false; + setShowTransientSuccess(true); + const timer = setTimeout( + () => setShowTransientSuccess(false), + FEEDBACK_DELIVERY_TRANSIENT_SUCCESS_MS, + ); + return () => clearTimeout(timer); + }, [state]); + + return showTransientSuccess; +} + +/** + * Quiet single-line delivery status for the source comment's footer/metadata + * row. Renders nothing unless the comment is actually retrying (or just + * recovered from retrying inside this session). + */ +export function FeedbackDeliveryFooterStatus({ + delivery, + showTransientSuccess = false, + align = "start", + className, +}: { + delivery?: IssueCommentFeedbackDelivery | null; + showTransientSuccess?: boolean; + align?: "start" | "end"; + className?: string; +}) { + const isRetrying = delivery?.state === "retrying"; + const [revealed, setRevealed] = useState(false); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!isRetrying) { + setRevealed(false); + return; + } + const timer = setTimeout(() => setRevealed(true), FEEDBACK_DELIVERY_RETRYING_REVEAL_MS); + return () => clearTimeout(timer); + }, [isRetrying]); + + // Ticks the countdown copy. Cheap: one interval per retrying comment, and + // retrying comments are rare and short-lived by construction. + useEffect(() => { + if (!revealed || !delivery?.nextAttemptAt) return; + const interval = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(interval); + }, [revealed, delivery?.nextAttemptAt]); + + if (isRetrying && !revealed) return null; + if (!isRetrying && !showTransientSuccess) return null; + + const detail = isRetrying && delivery + ? nextAttemptLabel(delivery.nextAttemptAt, now) ?? attemptLabel(delivery) + : null; + const runId = isRetrying && delivery?.targetRunId ? delivery.targetRunId : null; + const href = isRetrying && delivery ? runHref(delivery) : null; + + return ( +
+ {isRetrying ? ( +
+ ); +} + +/** + * Persistent, actionable delivery notice rendered directly under the human + * comment it describes: the warning banner while delivery needs attention, and + * the historical recovered notice once it no longer does. + * + * Only ever one of the two renders — the operator never sees both. + */ +export function FeedbackDeliveryNotice({ + delivery, + onRetry, + retryPending = false, + retryError = false, + className, +}: { + delivery: IssueCommentFeedbackDelivery; + onRetry?: (commentId: string) => void; + retryPending?: boolean; + retryError?: boolean; + className?: string; +}) { + const anchorId = feedbackDeliveryAnchorId(delivery.sourceCommentId); + const headingRef = useRef(null); + const href = runHref(delivery); + const runId = delivery.targetRunId; + + // Arriving from the attention queue: `#feedback-delivery-` should land + // the operator on the banner heading, not merely scroll it into view. + useEffect(() => { + if (typeof window === "undefined") return; + if (window.location.hash !== `#${anchorId}`) return; + headingRef.current?.focus(); + }, [anchorId]); + + if (delivery.state === "recovered_after_attention") { + const deliveredAt = delivery.deliveredAt ? deliveredTimeLabel(delivery.deliveredAt) : null; + return ( +
+ + Recovered after delivery needed attention. + {deliveredAt ? <> Delivered {deliveredAt}. : null} + + } + source={ + runId + ? { label: `Run ${shortRunId(runId)}`, ...(href ? { href } : {}) } + : undefined + } + /> +
+ ); + } + + if (delivery.state !== "exhausted_needs_attention") return null; + + const retrying = retryPending || delivery.retryInFlight; + const lastAttempt = delivery.lastAttemptAt ? timeAgo(delivery.lastAttemptAt) : null; + const batchNote = delivery.batchedCommentCount > 1 + ? `Covers ${delivery.batchedCommentCount} comments.` + : null; + + return ( +
+ {/* Announced once on transition, not on every rerender of the banner. */} + + Feedback wasn't delivered. Paperclip stopped retrying. + + + Feedback wasn't delivered + + } + actions={ + <> + {delivery.canRetry || retrying ? ( + + ) : null} + {/* Omitted entirely when there is no authorized run route — never a + disabled mystery action. */} + {href ? ( + + ) : null} + + } + > +
+

Paperclip stopped retrying. The agent may not have seen this comment.

+ {delivery.safeFailureReason ?

{delivery.safeFailureReason}

: null} +

+ {lastAttempt ? Last attempt {lastAttempt} : null} + {lastAttempt && runId ? : null} + {runId ? Run {shortRunId(runId)} : null} + {batchNote ? : null} + {batchNote ? {batchNote} : null} +

+ {retryError ? ( +

+ Couldn't start another retry. Try again. +

+ ) : null} +
+
+
+ ); +} diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index d78ea7872d8..48e3c874352 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -3853,6 +3853,166 @@ describe("IssueChatThread", () => { })).toBe(false); }); + it("anchors an exhausted feedback delivery banner under its source comment", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + onRetryFeedbackDelivery={() => {}} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const banner = container.querySelector('[data-testid="feedback-delivery-exhausted"]'); + expect(banner).not.toBeNull(); + expect(banner?.id).toBe("feedback-delivery-comment-feedback"); + // Inside the same anchored block as the comment it describes, so the + // attention deep link lands on both at once. + expect(container.querySelector("#comment-comment-feedback")?.contains(banner!)).toBe(true); + expect(container.textContent).toContain("Feedback wasn't delivered"); + + act(() => { + root.unmount(); + }); + }); + + it("leaves comments with no delivery obligation completely undecorated", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(container.querySelector('[data-testid="feedback-delivery-exhausted"]')).toBeNull(); + expect(container.querySelector('[data-testid="feedback-delivery-footer-status"]')).toBeNull(); + + act(() => { + root.unmount(); + }); + }); + + it("represents an exhausted feedback delivery once, not also as a recovery card", () => { + const root = createRoot(container); + const feedbackDeliveryAction = { + id: "action-1", + companyId: "company-1", + sourceIssueId: "issue-1", + recoveryIssueId: null, + kind: "feedback_delivery" as const, + status: "active" as const, + ownerType: "agent" as const, + ownerAgentId: "agent-1", + ownerUserId: null, + previousOwnerAgentId: "agent-1", + returnOwnerAgentId: "agent-1", + cause: "feedback_delivery_exhausted", + fingerprint: "feedback_delivery:company-1:issue-1:agent-1:root-1", + evidence: { outstandingCommentIds: ["comment-feedback"] }, + nextAction: "Handle the outstanding human feedback comment(s).", + wakePolicy: null, + monitorPolicy: null, + attemptCount: 1, + maxAttempts: 1, + timeoutAt: null, + lastAttemptAt: null, + outcome: null, + resolutionNote: null, + resolvedAt: null, + createdAt: new Date("2026-04-06T11:59:00.000Z"), + updatedAt: new Date("2026-04-06T11:59:00.000Z"), + }; + + act(() => { + root.render( + + {}} + showComposer + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + // The comment-anchored banner is the single detailed representation; the + // generic recovery card would duplicate the same state in the same thread. + expect(container.textContent).not.toContain("RECOVERY NEEDED"); + expect(container.textContent).not.toContain("Feedback Delivery"); + + act(() => { + root.unmount(); + }); + }); + it("uses company profile data to distinguish the current user from other humans", () => { const userProfileMap = new Map([ ["user-1", { label: "Dotta", image: "/avatars/dotta.png" }], diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index abb3d149576..7ac0336ed08 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -185,6 +185,12 @@ import { type RecoveryReissueRequest, type RecoveryResolveOutcome, } from "./IssueRecoveryActionCard"; +import { + FeedbackDeliveryFooterStatus, + FeedbackDeliveryNotice, + isIssueCommentFeedbackDelivery, + useFeedbackDeliveryTransientSuccess, +} from "./FeedbackDeliveryState"; import { SourceTrustBadge } from "./SourceTrustBadge"; import { CommentAttributionChip } from "./CommentAttributionChip"; import { resolveCommentAttribution } from "../lib/comment-attribution"; @@ -209,6 +215,12 @@ interface IssueChatMessageContext { onInterruptQueued?: (runId: string) => Promise; onCancelQueued?: (commentId: string) => void; onDeleteComment?: (commentId: string) => Promise | void; + /** Explicit operator retry for a comment whose feedback delivery is exhausted. */ + onRetryFeedbackDelivery?: (commentId: string) => Promise | void; + /** Comment id whose retry request is currently in flight. */ + retryingFeedbackDeliveryCommentId?: string | null; + /** Comment id whose last retry request failed, so the banner can say so inline. */ + failedFeedbackDeliveryRetryCommentId?: string | null; onImageClick?: (src: string) => void; onAcceptInteraction?: ( interaction: @@ -531,6 +543,9 @@ interface IssueChatThreadProps { includeSucceededRunsWithoutOutput?: boolean; onInterruptQueued?: (runId: string) => Promise; onCancelQueued?: (commentId: string) => void; + onRetryFeedbackDelivery?: (commentId: string) => Promise | void; + retryingFeedbackDeliveryCommentId?: string | null; + failedFeedbackDeliveryRetryCommentId?: string | null; onDeleteComment?: (commentId: string) => Promise | void; interruptingQueuedRunId?: string | null; stoppingRunId?: string | null; @@ -1460,6 +1475,9 @@ function IssueChatUserMessage({ onInterruptQueued, onCancelQueued, onDeleteComment, + onRetryFeedbackDelivery, + retryingFeedbackDeliveryCommentId, + failedFeedbackDeliveryRetryCommentId, currentUserId, userProfileMap, } = useContext(IssueChatCtx); @@ -1476,6 +1494,10 @@ function IssueChatUserMessage({ const pending = custom.clientStatus === "pending"; const deleted = Boolean(custom.deletedAt); const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null; + const feedbackDelivery = isIssueCommentFeedbackDelivery(custom.feedbackDelivery) + ? custom.feedbackDelivery + : null; + const showTransientDeliverySuccess = useFeedbackDeliveryTransientSuccess(feedbackDelivery); const [copied, setCopied] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const toastActions = useOptionalToastActions(); @@ -1634,6 +1656,11 @@ function IssueChatUserMessage({ ) : null} )} + ); @@ -1653,6 +1680,15 @@ function IssueChatUserMessage({ )} + {feedbackDelivery ? ( + + ) : null} @@ -4400,6 +4436,9 @@ export function IssueChatThread({ onInterruptQueued, onCancelQueued, onDeleteComment, + onRetryFeedbackDelivery, + retryingFeedbackDeliveryCommentId = null, + failedFeedbackDeliveryRetryCommentId = null, interruptingQueuedRunId = null, stoppingRunId = null, onImageClick, @@ -4949,6 +4988,7 @@ export function IssueChatThread({ const stableOnInterruptQueued = useStableEvent(onInterruptQueued); const stableOnCancelQueued = useStableEvent(onCancelQueued); const stableOnDeleteComment = useStableEvent(onDeleteComment); + const stableOnRetryFeedbackDelivery = useStableEvent(onRetryFeedbackDelivery); const stableOnImageClick = useStableEvent(onImageClick); const stableOnAcceptInteraction = useStableEvent(onAcceptInteraction); const stableOnRejectInteraction = useStableEvent(onRejectInteraction); @@ -4974,6 +5014,9 @@ export function IssueChatThread({ onInterruptQueued: stableOnInterruptQueued, onCancelQueued: stableOnCancelQueued, onDeleteComment: stableOnDeleteComment, + onRetryFeedbackDelivery: stableOnRetryFeedbackDelivery, + retryingFeedbackDeliveryCommentId, + failedFeedbackDeliveryRetryCommentId, onImageClick: stableOnImageClick, onAcceptInteraction: stableOnAcceptInteraction, onRejectInteraction: stableOnRejectInteraction, @@ -5003,6 +5046,9 @@ export function IssueChatThread({ stableOnInterruptQueued, stableOnCancelQueued, stableOnDeleteComment, + stableOnRetryFeedbackDelivery, + retryingFeedbackDeliveryCommentId, + failedFeedbackDeliveryRetryCommentId, stableOnImageClick, stableOnAcceptInteraction, stableOnRejectInteraction, @@ -5102,7 +5148,11 @@ export function IssueChatThread({ onResume={onResumeFromBacklog} resuming={resumeFromBacklogPending} /> - {recoveryAction ? ( + {/* An exhausted feedback delivery is represented once, by the + compact banner under the comment it belongs to. Rendering + the generic card as well would duplicate the same state at + two densities in one thread. */} + {recoveryAction && recoveryAction.kind !== "feedback_delivery" ? ( = { + feedback_delivery: "Feedback Delivery", missing_disposition: "Missing Disposition", stranded_assigned_issue: "Stranded Task", workspace_validation: "Workspace Validation", @@ -124,6 +125,11 @@ const KIND_LABEL: Record = { }; const KIND_HEADLINE: Record = { + // Surfaces on the run page only. In the task thread the same action is + // rendered as a compact banner under the comment it belongs to, so the + // operator never sees this card and that banner at the same time. + feedback_delivery: + "Paperclip stopped retrying delivery of a human comment on this task. The agent may not have seen it. Retry delivery from the comment in the task thread, or choose another next step.", missing_disposition: "This task's run finished, but no next step was chosen. Choose what happens next — try the task again, mark it done, or send it for review.", stranded_assigned_issue: diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index f1aec74fe0e..734402f1332 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -526,6 +526,7 @@ function createCommentMessage(args: { queueReason: comment.queueReason ?? null, interruptedRunId: comment.interruptedRunId ?? null, followUpRequested: comment.followUpRequested === true, + feedbackDelivery: comment.feedbackDelivery ?? null, presentation: comment.presentation ?? null, commentMetadata: comment.metadata ?? null, deletedAt: comment.deletedAt ? toDate(comment.deletedAt).toISOString() : null, diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 13b8ecfbbff..eeb30ad41bf 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -959,6 +959,9 @@ type IssueDetailChatTabProps = { successfulRunHandoff: Issue["successfulRunHandoff"] | null; scheduledRetry: Issue["scheduledRetry"] | null; recoveryAction: Issue["activeRecoveryAction"]; + onRetryFeedbackDelivery?: (commentId: string) => void; + retryingFeedbackDeliveryCommentId?: string | null; + failedFeedbackDeliveryRetryCommentId?: string | null; onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void; onReissueIsolatedRecoveryAction?: (request: import("../components/IssueRecoveryActionCard").RecoveryReissueRequest) => void; reissueIsolatedRecoveryActionPending?: boolean; @@ -1064,6 +1067,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ blockerAttention, successfulRunHandoff, scheduledRetry, + onRetryFeedbackDelivery, + retryingFeedbackDeliveryCommentId, + failedFeedbackDeliveryRetryCommentId, recoveryAction, onResolveRecoveryAction, onReissueIsolatedRecoveryAction, @@ -1340,6 +1346,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ successfulRunHandoff={successfulRunHandoff} scheduledRetry={scheduledRetry} recoveryAction={recoveryAction ?? null} + onRetryFeedbackDelivery={onRetryFeedbackDelivery} + retryingFeedbackDeliveryCommentId={retryingFeedbackDeliveryCommentId ?? null} + failedFeedbackDeliveryRetryCommentId={failedFeedbackDeliveryRetryCommentId ?? null} onResolveRecoveryAction={onResolveRecoveryAction} onReissueIsolatedRecoveryAction={onReissueIsolatedRecoveryAction} reissueIsolatedRecoveryActionPending={reissueIsolatedRecoveryActionPending} @@ -4180,6 +4189,49 @@ export function IssueDetail() { }); }, [reconcileExecutionWorkspaceId, reconcileRecoveryAction.mutateAsync, pushToast]); + // Explicit operator retry for an exhausted feedback delivery. Errors surface + // inline inside the banner (not only as a toast) so the operator sees the + // failure next to the control they just pressed. + const [failedFeedbackDeliveryRetryCommentId, setFailedFeedbackDeliveryRetryCommentId] = + useState(null); + const [retryingFeedbackDeliveryCommentId, setRetryingFeedbackDeliveryCommentId] = + useState(null); + const retryFeedbackDelivery = useMutation({ + // The commentId is carried so the banner that was pressed owns the pending + // and error state; the request itself is scoped to the issue's one open + // delivery obligation. + mutationFn: (_input: { commentId: string }) => issuesApi.retryFeedbackDelivery(issueId!), + onMutate: (input) => { + setRetryingFeedbackDeliveryCommentId(input.commentId); + setFailedFeedbackDeliveryRetryCommentId(null); + }, + onSuccess: (result, variables) => { + // The delivery state lives on the comment, so the thread is the cache that + // has to move; the detail card carries the attention count. + void refetchComments(); + invalidateIssueDetail(); + if (result.outcome === "queued" || result.outcome === "already_queued") return; + setFailedFeedbackDeliveryRetryCommentId(variables.commentId); + pushToast({ title: "Couldn't retry delivery", body: result.message, tone: "error" }); + }, + onError: (err, variables) => { + setFailedFeedbackDeliveryRetryCommentId(variables.commentId); + pushToast({ + title: "Couldn't retry delivery", + body: err instanceof Error ? err.message : "Unable to retry feedback delivery.", + tone: "error", + }); + }, + onSettled: () => setRetryingFeedbackDeliveryCommentId(null), + }); + const handleRetryFeedbackDelivery = useCallback( + (commentId: string) => { + if (!issueId) return; + void retryFeedbackDelivery.mutateAsync({ commentId }).catch(() => {}); + }, + [issueId, retryFeedbackDelivery.mutateAsync], + ); + const treePreviewAffectedIssues = useMemo( () => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped), [treeControlPreview], @@ -5208,6 +5260,9 @@ export function IssueDetail() { successfulRunHandoff={issue.successfulRunHandoff ?? null} scheduledRetry={issue.scheduledRetry ?? null} recoveryAction={issue.activeRecoveryAction ?? null} + onRetryFeedbackDelivery={handleRetryFeedbackDelivery} + retryingFeedbackDeliveryCommentId={retryingFeedbackDeliveryCommentId} + failedFeedbackDeliveryRetryCommentId={failedFeedbackDeliveryRetryCommentId} onResolveRecoveryAction={handleResolveRecoveryAction} onReissueIsolatedRecoveryAction={handleReissueIsolatedRecoveryAction} reissueIsolatedRecoveryActionPending={reissueIsolatedRecoveryAction.isPending}