fix(ui): surface feedback delivery in default task chat

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-16 19:23:57 +00:00
1 parent 6d210a995c
commit 7245d259b3
7 files changed
+152 -2

No files matched your search

+55
View File
@@ -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(
<TaskChatThread
comments={[comment]}
onAdd={async () => {}}
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<HTMLButtonElement>(
'[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;
+6
View File
@@ -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 ? () => <TaskChatDescriptionBubble brief={issueBrief} /> : undefined}
renderMessageActions={renderMessageActions}
renderQueuedAction={renderQueuedAction}
onRetryFeedbackDelivery={onRetryFeedbackDelivery}
retryingFeedbackDeliveryCommentId={retryingFeedbackDeliveryCommentId}
failedFeedbackDeliveryRetryCommentId={failedFeedbackDeliveryRetryCommentId}
tail={tailRunId || bottomBlockerLinks ? (
<>
{tailRunId ? (
+36 -1
View File
@@ -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> | 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<string | null>(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}
</span>
) : null}
<FeedbackDeliveryFooterStatus
delivery={item.feedbackDelivery}
showTransientSuccess={showTransientDeliverySuccess}
align={isHuman ? "end" : "start"}
/>
{item.feedbackDelivery ? (
<FeedbackDeliveryNotice
className="mt-1"
delivery={item.feedbackDelivery}
onRetry={onRetryFeedbackDelivery}
retryPending={
retryingFeedbackDeliveryCommentId === item.feedbackDelivery.sourceCommentId
}
retryError={
failedFeedbackDeliveryRetryCommentId === item.feedbackDelivery.sourceCommentId
}
/>
) : null}
{lightboxSrc !== null && lightboxIndex >= 0 ? (
<ImageGalleryModal
items={galleryItems}
@@ -43,6 +43,9 @@ interface TaskChatThreadViewProps {
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode;
/** Renders an interrupt action beside a queued human message. */
renderQueuedAction?: (item: TaskChatMessageItem) => ReactNode;
onRetryFeedbackDelivery?: (commentId: string) => Promise<void> | 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> | 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 ? (
<TaskChatTurn
@@ -140,6 +149,9 @@ export function TaskChatThreadView({
renderBrief,
renderMessageActions,
renderQueuedAction,
onRetryFeedbackDelivery,
retryingFeedbackDeliveryCommentId,
failedFeedbackDeliveryRetryCommentId,
tail,
contentKey,
className,
@@ -161,6 +173,9 @@ export function TaskChatThreadView({
renderBrief,
renderMessageActions,
renderQueuedAction,
onRetryFeedbackDelivery,
retryingFeedbackDeliveryCommentId,
failedFeedbackDeliveryRetryCommentId,
)}
</div>
))}
@@ -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 = [
{
@@ -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;
@@ -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). */