From 2879b3560749e99b2dee394a4a35e13c942cddcd Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 16 Aug 2026 19:01:36 +0000 Subject: [PATCH] feat(ui): surface human feedback delivery states Show retrying, exhausted, and recovered delivery state in classic and default task threads, with attention links and explicit retry controls. 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 | 359 ++++++++++++++++ ui/src/components/IssueChatThread.test.tsx | 160 +++++++ ui/src/components/IssueChatThread.tsx | 58 ++- ui/src/components/IssueRecoveryActionCard.tsx | 6 + ui/src/components/TaskChatThread.test.tsx | 55 +++ ui/src/components/TaskChatThread.tsx | 6 + .../components/task-chat/TaskChatBubble.tsx | 37 +- .../task-chat/TaskChatThreadView.tsx | 15 + .../task-chat/task-chat-adapter.test.ts | 32 ++ .../components/task-chat/task-chat-adapter.ts | 1 + .../components/task-chat/task-chat-model.ts | 8 +- ui/src/lib/issue-chat-messages.ts | 1 + ui/src/pages/IssueDetail.tsx | 55 +++ 21 files changed, 1331 insertions(+), 10 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..6e29cabd496 --- /dev/null +++ b/ui/src/components/FeedbackDeliveryState.tsx @@ -0,0 +1,359 @@ +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 const FEEDBACK_DELIVERY_ANCHOR_PREFIX = "feedback-delivery-"; + +export function feedbackDeliveryAnchorId(commentId: string) { + return `${FEEDBACK_DELIVERY_ANCHOR_PREFIX}${commentId}`; +} + +/** + * Delay before claiming focus for a deep-linked banner. The thread's own + * hash handling makes up to three scroll attempts over ~250ms; focusing inside + * that window loses the focus to the thread viewport. + */ +const DEEP_LINK_FOCUS_SETTLE_MS = 350; + +/** + * Desktop constrains the notice to the same edge as the message body it belongs + * to, so ownership is unambiguous when consecutive comments each carry one. + * Mobile keeps the full thread width inside the thread's own padding. + */ +const FEEDBACK_DELIVERY_NOTICE_WIDTH = "w-full sm:max-w-(--pct-85)"; + +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; + const timer = setTimeout(() => headingRef.current?.focus(), DEEP_LINK_FOCUS_SETTLE_MS); + return () => clearTimeout(timer); + }, [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..7de1991593b 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -185,6 +185,13 @@ import { type RecoveryReissueRequest, type RecoveryResolveOutcome, } from "./IssueRecoveryActionCard"; +import { + FEEDBACK_DELIVERY_ANCHOR_PREFIX, + FeedbackDeliveryFooterStatus, + FeedbackDeliveryNotice, + isIssueCommentFeedbackDelivery, + useFeedbackDeliveryTransientSuccess, +} from "./FeedbackDeliveryState"; import { SourceTrustBadge } from "./SourceTrustBadge"; import { CommentAttributionChip } from "./CommentAttributionChip"; import { resolveCommentAttribution } from "../lib/comment-attribution"; @@ -209,6 +216,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 +544,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 +1476,9 @@ function IssueChatUserMessage({ onInterruptQueued, onCancelQueued, onDeleteComment, + onRetryFeedbackDelivery, + retryingFeedbackDeliveryCommentId, + failedFeedbackDeliveryRetryCommentId, currentUserId, userProfileMap, } = useContext(IssueChatCtx); @@ -1476,6 +1495,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 +1657,11 @@ function IssueChatUserMessage({ ) : null} )} + ); @@ -1653,6 +1681,15 @@ function IssueChatUserMessage({ )} + {feedbackDelivery ? ( + + ) : null} @@ -4400,6 +4437,9 @@ export function IssueChatThread({ onInterruptQueued, onCancelQueued, onDeleteComment, + onRetryFeedbackDelivery, + retryingFeedbackDeliveryCommentId = null, + failedFeedbackDeliveryRetryCommentId = null, interruptingQueuedRunId = null, stoppingRunId = null, onImageClick, @@ -4703,7 +4743,10 @@ export function IssueChatThread({ const isThreadHash = hash.startsWith("#comment-") || hash.startsWith("#activity-") || hash.startsWith("#run-") - || hash.startsWith("#interaction-"); + || hash.startsWith("#interaction-") + // The attention queue deep-links an exhausted feedback delivery straight + // at its banner, which lives inside the source comment's anchored block. + || hash.startsWith(`#${FEEDBACK_DELIVERY_ANCHOR_PREFIX}`); if (messages.length === 0) return; if (!isThreadHash) { if (!didInitialHashScrollDecisionRef.current) { @@ -4949,6 +4992,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 +5018,9 @@ export function IssueChatThread({ onInterruptQueued: stableOnInterruptQueued, onCancelQueued: stableOnCancelQueued, onDeleteComment: stableOnDeleteComment, + onRetryFeedbackDelivery: stableOnRetryFeedbackDelivery, + retryingFeedbackDeliveryCommentId, + failedFeedbackDeliveryRetryCommentId, onImageClick: stableOnImageClick, onAcceptInteraction: stableOnAcceptInteraction, onRejectInteraction: stableOnRejectInteraction, @@ -5003,6 +5050,9 @@ export function IssueChatThread({ stableOnInterruptQueued, stableOnCancelQueued, stableOnDeleteComment, + stableOnRetryFeedbackDelivery, + retryingFeedbackDeliveryCommentId, + failedFeedbackDeliveryRetryCommentId, stableOnImageClick, stableOnAcceptInteraction, stableOnRejectInteraction, @@ -5102,7 +5152,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/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 1f3ec7d5b87..87b67c98f9f 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -532,6 +532,61 @@ describe("TaskChatThread queued message actions", () => { }); }); +describe("TaskChatThread feedback delivery recovery", () => { + it("renders the exhausted notice, deep-link anchor, and Retry action on the default thread", () => { + const onRetryFeedbackDelivery = vi.fn(); + const comment = { + id: "comment-feedback", + companyId: "company-1", + issueId: "issue-1", + authorType: "user" as const, + authorAgentId: null, + authorUserId: "user-1", + body: "Please use the updated requirements.", + presentation: null, + metadata: null, + feedbackDelivery: { + state: "exhausted_needs_attention" as const, + sourceCommentId: "comment-feedback", + attempt: 3, + maxAttempts: 3, + nextAttemptAt: null, + lastAttemptAt: "2026-08-14T12:00:00.000Z", + deliveredAt: null, + targetRunId: null, + targetRunAgentId: null, + canRetry: true, + retryInFlight: false, + safeFailureReason: "The agent could not be reached.", + batchedCommentCount: 1, + }, + createdAt: new Date("2026-08-14T12:00:00.000Z"), + updatedAt: new Date("2026-08-14T12:00:00.000Z"), + }; + + render( + {}} + onRetryFeedbackDelivery={onRetryFeedbackDelivery} + />, + ); + + expect(container.querySelector('[data-testid="task-chat-thread"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="feedback-delivery-exhausted"]')).not.toBeNull(); + expect(container.querySelector("#feedback-delivery-comment-feedback")).not.toBeNull(); + expect(container.textContent).toContain("Feedback wasn't delivered"); + + const retry = container.querySelector( + '[data-testid="feedback-delivery-retry"]', + ); + expect(retry?.disabled).toBe(false); + flushSync(() => retry!.click()); + expect(onRetryFeedbackDelivery).toHaveBeenCalledOnce(); + expect(onRetryFeedbackDelivery).toHaveBeenCalledWith("comment-feedback"); + }); +}); + describe("TaskChatThread mobile composer dock (PAP-495)", () => { it("pins the composer to the nav-aware bottom offset so its action row clears the auto-hiding bottom nav", () => { sidebarState.isMobile = true; diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index fbbc444d70e..5bbfcc377d5 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -136,6 +136,9 @@ export function TaskChatThread(props: TaskChatThreadProps) { blockedBy = [], blockerAttention, liveIssueIds, + onRetryFeedbackDelivery, + retryingFeedbackDeliveryCommentId, + failedFeedbackDeliveryRetryCommentId, } = props; const liveWorkLinks = useMemo( @@ -681,6 +684,9 @@ export function TaskChatThread(props: TaskChatThreadProps) { renderBrief={issueBrief ? () => : undefined} renderMessageActions={renderMessageActions} renderQueuedAction={renderQueuedAction} + onRetryFeedbackDelivery={onRetryFeedbackDelivery} + retryingFeedbackDeliveryCommentId={retryingFeedbackDeliveryCommentId} + failedFeedbackDeliveryRetryCommentId={failedFeedbackDeliveryRetryCommentId} tail={tailRunId || bottomBlockerLinks ? ( <> {tailRunId ? ( diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 45268b73096..26af6d8e72c 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -5,6 +5,11 @@ import { ImageGalleryModal, type GalleryMediaItem } from "@/components/ImageGall import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { AgentIcon } from "@/components/AgentIconPicker"; import { CommentAttributionChip } from "@/components/CommentAttributionChip"; +import { + FeedbackDeliveryFooterStatus, + FeedbackDeliveryNotice, + useFeedbackDeliveryTransientSuccess, +} from "@/components/FeedbackDeliveryState"; import { Attachment, AttachmentContent, @@ -36,6 +41,9 @@ interface TaskChatBubbleProps { * so this bubble skips them. Human/system bubbles pass nothing. */ actions?: ReactNode; + onRetryFeedbackDelivery?: (commentId: string) => Promise | void; + retryingFeedbackDeliveryCommentId?: string | null; + failedFeedbackDeliveryRetryCommentId?: string | null; } function initialsForName(name: string) { @@ -63,10 +71,19 @@ function galleryItemForImage(src: string, name?: string): GalleryMediaItem { }; } -export function TaskChatBubble({ item, queuedAction, attachedTurn, actions }: TaskChatBubbleProps) { +export function TaskChatBubble({ + item, + queuedAction, + attachedTurn, + actions, + onRetryFeedbackDelivery, + retryingFeedbackDeliveryCommentId, + failedFeedbackDeliveryRetryCommentId, +}: TaskChatBubbleProps) { // Clicking an embedded image opens the full-screen lightbox (with download); // arrow keys walk across the other images in the same bubble. const [lightboxSrc, setLightboxSrc] = useState(null); + const showTransientDeliverySuccess = useFeedbackDeliveryTransientSuccess(item.feedbackDelivery); if (item.interstitial) { // Interstitial updates are ephemeral (PAP-361): while streaming the text // lives on the live parent row's line (TaskChatStatusItem.selfTalk), and @@ -203,6 +220,24 @@ export function TaskChatBubble({ item, queuedAction, attachedTurn, actions }: Ta {item.timestamp} ) : null} + + {item.feedbackDelivery ? ( + + ) : null} {lightboxSrc !== null && lightboxIndex >= 0 ? ( ReactNode; /** Renders an interrupt action beside a queued human message. */ renderQueuedAction?: (item: TaskChatMessageItem) => ReactNode; + onRetryFeedbackDelivery?: (commentId: string) => Promise | void; + retryingFeedbackDeliveryCommentId?: string | null; + failedFeedbackDeliveryRetryCommentId?: string | null; /** Content appended inside the transcript scroller after the settled thread. */ tail?: ReactNode; /** Optional streaming-aware key when `tail` changes without changing `items`. */ @@ -59,6 +62,9 @@ function renderItem( renderBrief?: () => ReactNode, renderMessageActions?: (item: TaskChatMessageItem) => ReactNode, renderQueuedAction?: (item: TaskChatMessageItem) => ReactNode, + onRetryFeedbackDelivery?: (commentId: string) => Promise | void, + retryingFeedbackDeliveryCommentId?: string | null, + failedFeedbackDeliveryRetryCommentId?: string | null, ) { switch (item.kind) { case "message": { @@ -73,6 +79,9 @@ function renderItem( item={item} actions={actions} queuedAction={renderQueuedAction?.(item)} + onRetryFeedbackDelivery={onRetryFeedbackDelivery} + retryingFeedbackDeliveryCommentId={retryingFeedbackDeliveryCommentId} + failedFeedbackDeliveryRetryCommentId={failedFeedbackDeliveryRetryCommentId} attachedTurn={ item.attachedTurn ? ( ))} diff --git a/ui/src/components/task-chat/task-chat-adapter.test.ts b/ui/src/components/task-chat/task-chat-adapter.test.ts index 27e0ad3b536..874c84a0d87 100644 --- a/ui/src/components/task-chat/task-chat-adapter.test.ts +++ b/ui/src/components/task-chat/task-chat-adapter.test.ts @@ -3,6 +3,38 @@ import type { IssueChatComment } from "@/lib/issue-chat-messages"; import { commentsToTaskChatItems } from "./task-chat-adapter"; describe("commentsToTaskChatItems", () => { + it("carries feedback delivery state from a human comment into the bubble model", () => { + const feedbackDelivery = { + state: "exhausted_needs_attention" as const, + sourceCommentId: "comment-feedback", + attempt: 3, + maxAttempts: 3, + nextAttemptAt: null, + lastAttemptAt: "2026-08-14T12:00:00.000Z", + deliveredAt: null, + targetRunId: null, + targetRunAgentId: null, + canRetry: true, + retryInFlight: false, + safeFailureReason: "The agent could not be reached.", + batchedCommentCount: 1, + }; + const comments = [{ + id: "comment-feedback", + body: "Please use the updated requirements.", + authorType: "user", + authorAgentId: null, + authorUserId: "user-1", + feedbackDelivery, + createdAt: "2026-08-14T12:00:00.000Z", + } as unknown as IssueChatComment]; + + const [item] = commentsToTaskChatItems(comments); + + if (item.kind !== "message") throw new Error("expected message item"); + expect(item.feedbackDelivery).toEqual(feedbackDelivery); + }); + it("never tags posted comments interstitial — the run's final reply keeps its bubble", () => { const comments = [ { diff --git a/ui/src/components/task-chat/task-chat-adapter.ts b/ui/src/components/task-chat/task-chat-adapter.ts index 62af3ca33e9..660895790e3 100644 --- a/ui/src/components/task-chat/task-chat-adapter.ts +++ b/ui/src/components/task-chat/task-chat-adapter.ts @@ -100,6 +100,7 @@ export function commentsToTaskChatItems( metadata: kind === "system" ? comment.metadata ?? null : undefined, runAgentId: kind === "system" ? comment.runAgentId ?? null : undefined, createdAtIso: kind === "system" ? createdAtIso : undefined, + feedbackDelivery: comment.feedbackDelivery ?? null, }); } return items; diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index f1cacc14757..35f7027a461 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -12,7 +12,11 @@ * inventory for the mapping. No timing/motion values live here — those are * CSS motion tokens in ui/src/index.css. */ -import type { IssueCommentMetadata, IssueCommentPresentation } from "@paperclipai/shared"; +import type { + IssueCommentFeedbackDelivery, + IssueCommentMetadata, + IssueCommentPresentation, +} from "@paperclipai/shared"; import type { IssueThreadInteraction } from "@/lib/issue-thread-interactions"; /** Who authored a thread row — the primary legibility signal. */ @@ -109,6 +113,8 @@ export interface TaskChatMessageItem { runAgentId?: string | null; /** Raw comment timestamp (ISO) — the collapsed system row shows relative time. */ createdAtIso?: string; + /** Delivery state projected onto a human comment by the feedback recovery flow. */ + feedbackDelivery?: IssueCommentFeedbackDelivery | null; } /** Collapsed chain-of-thought (ACP agent_thought_chunk). */ 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}