From 7245d259b34a5cc3b1b7532962d51dbead47e3e6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 14 Aug 2026 19:45:38 +0000 Subject: [PATCH] fix(ui): surface feedback delivery in default task chat Co-Authored-By: Paperclip --- 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 ++- 7 files changed, 152 insertions(+), 2 deletions(-) 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). */