From b930ecf415883d2950931b408a0c185f564d1124 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 14 Aug 2026 18:42:32 +0000 Subject: [PATCH] feat(ui): surface feedback delivery recovery states on the source comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the approved UX spec. Delivery state renders on the human comment it belongs to, never as a new task status: - retrying: one quiet footer line beside the comment's timestamp, revealed only after the ~400ms Doherty window, polite live region, no operator control while automatic recovery still owns the problem, static icon under reduced motion. - exhausted: a persistent compact warning InlineBanner directly under the comment with Retry delivery, an authorized View run (omitted, never disabled, when no safe route exists), a one-shot alert announcement, and a focusable anchor for the attention deep link. Retry pending/error state stays on that one button and reports failure inline, not only via toast. - recovered: the warning is replaced in place by the existing success SystemNotice so the operator can see why attention disappeared. Quiet transient recovery morphs to "Delivered after retry" for 5s and leaves nothing behind. State is shown once: the generic recovery card is suppressed for feedback_delivery in the thread, and the attention queue gets one plain-language operator row per feedback batch deep-linked at the banner. That row is new — an exhausted delivery is agent-owned but carries manual_repair_required, so it had no inbox surface at all. 44 focused tests (29 component, 3 thread integration, 1 live-Postgres attention, plus the 18 projection tests) and pnpm check:token-gates pass. Co-Authored-By: Paperclip --- packages/shared/src/types/issue.ts | 2 + .../src/__tests__/attention-service.test.ts | 48 +++ server/src/services/attention.ts | 81 +++- .../feedback-delivery-comment-states.ts | 3 + .../feedback-delivery-projection.test.ts | 2 + .../recovery/feedback-delivery-projection.ts | 11 + ui/src/api/issues.ts | 3 + .../components/FeedbackDeliveryState.test.tsx | 398 ++++++++++++++++++ ui/src/components/FeedbackDeliveryState.tsx | 334 +++++++++++++++ ui/src/components/IssueChatThread.test.tsx | 160 +++++++ ui/src/components/IssueChatThread.tsx | 52 ++- ui/src/components/IssueRecoveryActionCard.tsx | 6 + ui/src/lib/issue-chat-messages.ts | 1 + ui/src/pages/IssueDetail.tsx | 55 +++ 14 files changed, 1149 insertions(+), 7 deletions(-) create mode 100644 ui/src/components/FeedbackDeliveryState.test.tsx create mode 100644 ui/src/components/FeedbackDeliveryState.tsx 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}