mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
a6c28fa414
commit
2879b35607
21 files changed
+1331
-10
No files matched your search
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
: {};
|
||||
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),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<IssueRetryNowResponse>(`/issues/${id}/scheduled-retry/retry-now`, {}),
|
||||
retryFeedbackDelivery: (id: string) =>
|
||||
api.post<IssueFeedbackDeliveryRetryResponse>(`/issues/${id}/feedback-delivery/retry`, {}),
|
||||
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
|
||||
checkout: (id: string, agentId: string) =>
|
||||
api.post<Issue>(`/issues/${id}/checkout`, {
|
||||
|
||||
@@ -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<T>(callback: () => T): T {
|
||||
let result: T | undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
return result as T;
|
||||
}
|
||||
|
||||
let root: ReturnType<typeof createRoot> | 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(<MemoryRouter>{element}</MemoryRouter>));
|
||||
return container;
|
||||
}
|
||||
|
||||
function rerender(element: ReactElement) {
|
||||
act(() => root?.render(<MemoryRouter>{element}</MemoryRouter>));
|
||||
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<void>((resolve) => {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.onmessage = () => resolve();
|
||||
channel.port2.postMessage(null);
|
||||
});
|
||||
act(() => {});
|
||||
}
|
||||
|
||||
function delivery(
|
||||
overrides: Partial<IssueCommentFeedbackDelivery> = {},
|
||||
): 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(
|
||||
<FeedbackDeliveryFooterStatus delivery={delivery({ state: "retrying" })} />,
|
||||
);
|
||||
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(
|
||||
<FeedbackDeliveryFooterStatus delivery={delivery({ state: "retrying" })} />,
|
||||
);
|
||||
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(
|
||||
<FeedbackDeliveryFooterStatus
|
||||
delivery={delivery({
|
||||
state: "retrying",
|
||||
nextAttemptAt: "2026-08-14T18:00:12.000Z",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<FeedbackDeliveryFooterStatus delivery={delivery({ state: "retrying", attempt: 0 })} />,
|
||||
);
|
||||
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(
|
||||
<FeedbackDeliveryFooterStatus delivery={delivery({ state: "retrying" })} />,
|
||||
);
|
||||
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(<FeedbackDeliveryFooterStatus delivery={null} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(FEEDBACK_DELIVERY_RETRYING_REVEAL_MS);
|
||||
});
|
||||
expect(node.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("shows the transient success line only when asked", () => {
|
||||
const node = render(<FeedbackDeliveryFooterStatus delivery={null} showTransientSuccess />);
|
||||
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(
|
||||
<FeedbackDeliveryFooterStatus
|
||||
delivery={delivery({ state: "retrying", targetRunId: null, targetRunAgentId: null })}
|
||||
/>,
|
||||
);
|
||||
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 <FeedbackDeliveryFooterStatus delivery={value} showTransientSuccess={show} />;
|
||||
}
|
||||
|
||||
describe("useFeedbackDeliveryTransientSuccess", () => {
|
||||
it("confirms a watched recovery, then removes itself", async () => {
|
||||
const node = render(<TransientProbe delivery={delivery({ state: "retrying" })} />);
|
||||
rerender(<TransientProbe delivery={null} />);
|
||||
// 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(<TransientProbe delivery={null} />);
|
||||
rerender(<TransientProbe delivery={null} />);
|
||||
await flushEffects();
|
||||
expect(node.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("does not celebrate a promotion to needs-attention", async () => {
|
||||
const node = render(<TransientProbe delivery={delivery({ state: "retrying" })} />);
|
||||
rerender(<TransientProbe delivery={delivery({ state: "exhausted_needs_attention" })} />);
|
||||
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(<FeedbackDeliveryNotice delivery={delivery()} onRetry={() => {}} />);
|
||||
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(<FeedbackDeliveryNotice delivery={delivery()} onRetry={() => {}} />);
|
||||
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(<FeedbackDeliveryNotice delivery={delivery()} />);
|
||||
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(
|
||||
<FeedbackDeliveryNotice
|
||||
delivery={delivery({ targetRunId: null, targetRunAgentId: null })}
|
||||
onRetry={() => {}}
|
||||
/>,
|
||||
);
|
||||
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(<FeedbackDeliveryNotice delivery={delivery()} onRetry={onRetry} />);
|
||||
const retry = node.querySelector<HTMLButtonElement>(
|
||||
"[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(
|
||||
<FeedbackDeliveryNotice delivery={delivery()} onRetry={() => {}} retryPending />,
|
||||
);
|
||||
const retry = node.querySelector<HTMLButtonElement>(
|
||||
"[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(
|
||||
<FeedbackDeliveryNotice
|
||||
delivery={delivery({ retryInFlight: true, canRetry: false })}
|
||||
onRetry={() => {}}
|
||||
/>,
|
||||
);
|
||||
const retry = node.querySelector<HTMLButtonElement>(
|
||||
"[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(
|
||||
<FeedbackDeliveryNotice delivery={delivery()} onRetry={() => {}} 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<HTMLButtonElement>("[data-testid='feedback-delivery-retry']")?.disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides retry from a viewer without the authority to trigger it", () => {
|
||||
const node = render(<FeedbackDeliveryNotice delivery={delivery({ canRetry: false })} />);
|
||||
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(
|
||||
<FeedbackDeliveryNotice
|
||||
delivery={delivery({ safeFailureReason: "The agent is over its budget." })}
|
||||
/>,
|
||||
);
|
||||
expect(node.textContent).toContain("The agent is over its budget.");
|
||||
});
|
||||
|
||||
it("rolls a batched obligation up in the banner copy", () => {
|
||||
const node = render(<FeedbackDeliveryNotice delivery={delivery({ batchedCommentCount: 3 })} />);
|
||||
expect(node.textContent).toContain("Covers 3 comments.");
|
||||
});
|
||||
|
||||
it("exposes a focusable anchor for the attention deep link", () => {
|
||||
const node = render(<FeedbackDeliveryNotice delivery={delivery()} />);
|
||||
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(<FeedbackDeliveryNotice delivery={recovered} />);
|
||||
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(<FeedbackDeliveryNotice delivery={recovered} onRetry={() => {}} />);
|
||||
expect(node.querySelector("[data-testid='feedback-delivery-retry']")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the authorized run reference for auditability", () => {
|
||||
const node = render(<FeedbackDeliveryNotice delivery={recovered} />);
|
||||
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(
|
||||
<FeedbackDeliveryNotice
|
||||
delivery={{ ...recovered, state: "retrying" }}
|
||||
/>,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<IssueCommentFeedbackDelivery>;
|
||||
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 (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="feedback-delivery-footer-status"
|
||||
data-state={isRetrying ? "retrying" : "delivered_after_retry"}
|
||||
className={cn(
|
||||
"mt-1 flex flex-wrap items-center gap-1.5 px-1 text-(length:--text-micro) text-muted-foreground",
|
||||
align === "end" ? "justify-end" : "justify-start",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isRetrying ? (
|
||||
<RefreshCw
|
||||
className="h-3 w-3 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<CircleCheck className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
<span>{isRetrying ? "Retrying feedback delivery…" : "Delivered after retry"}</span>
|
||||
{detail ? <span aria-hidden="true">·</span> : null}
|
||||
{detail ? <span>{detail}</span> : null}
|
||||
{runId ? <span aria-hidden="true">·</span> : null}
|
||||
{runId ? (
|
||||
href ? (
|
||||
<Link
|
||||
to={href}
|
||||
className="font-mono underline-offset-2 hover:text-foreground hover:underline focus-visible:underline"
|
||||
>
|
||||
Run {shortRunId(runId)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-mono">Run {shortRunId(runId)}</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLSpanElement>(null);
|
||||
const href = runHref(delivery);
|
||||
const runId = delivery.targetRunId;
|
||||
|
||||
// Arriving from the attention queue: `#feedback-delivery-<id>` 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 (
|
||||
<div
|
||||
id={anchorId}
|
||||
data-testid="feedback-delivery-recovered"
|
||||
className={cn(FEEDBACK_DELIVERY_NOTICE_WIDTH, className)}
|
||||
>
|
||||
<SystemNotice
|
||||
tone="success"
|
||||
label="Feedback delivered"
|
||||
body={
|
||||
<span>
|
||||
Recovered after delivery needed attention.
|
||||
{deliveredAt ? <> Delivered {deliveredAt}.</> : null}
|
||||
</span>
|
||||
}
|
||||
source={
|
||||
runId
|
||||
? { label: `Run ${shortRunId(runId)}`, ...(href ? { href } : {}) }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
id={anchorId}
|
||||
data-testid="feedback-delivery-exhausted"
|
||||
className={cn(FEEDBACK_DELIVERY_NOTICE_WIDTH, className)}
|
||||
>
|
||||
{/* Announced once on transition, not on every rerender of the banner. */}
|
||||
<span role="alert" className="sr-only">
|
||||
Feedback wasn't delivered. Paperclip stopped retrying.
|
||||
</span>
|
||||
<InlineBanner
|
||||
compact
|
||||
tone="warning"
|
||||
title={
|
||||
<span ref={headingRef} tabIndex={-1}>
|
||||
Feedback wasn't delivered
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{delivery.canRetry || retrying ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
data-testid="feedback-delivery-retry"
|
||||
className="min-h-11 w-full sm:min-h-0 sm:w-auto"
|
||||
disabled={retrying || !onRetry}
|
||||
onClick={() => onRetry?.(delivery.sourceCommentId)}
|
||||
>
|
||||
{retrying ? "Retrying…" : "Retry delivery"}
|
||||
</Button>
|
||||
) : null}
|
||||
{/* Omitted entirely when there is no authorized run route — never a
|
||||
disabled mystery action. */}
|
||||
{href ? (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="min-h-11 w-full sm:min-h-0 sm:w-auto"
|
||||
>
|
||||
<Link to={href}>View run</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p>Paperclip stopped retrying. The agent may not have seen this comment.</p>
|
||||
{delivery.safeFailureReason ? <p>{delivery.safeFailureReason}</p> : null}
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-(length:--text-micro)">
|
||||
{lastAttempt ? <span>Last attempt {lastAttempt}</span> : null}
|
||||
{lastAttempt && runId ? <span aria-hidden="true">·</span> : null}
|
||||
{runId ? <span className="font-mono">Run {shortRunId(runId)}</span> : null}
|
||||
{batchNote ? <span aria-hidden="true">·</span> : null}
|
||||
{batchNote ? <span>{batchNote}</span> : null}
|
||||
</p>
|
||||
{retryError ? (
|
||||
<p data-testid="feedback-delivery-retry-error" className="font-medium">
|
||||
Couldn't start another retry. Try again.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</InlineBanner>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[{
|
||||
id: "comment-feedback",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-board",
|
||||
authorType: "user",
|
||||
body: "Please pick this up again",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
feedbackDelivery: {
|
||||
state: "exhausted_needs_attention",
|
||||
sourceCommentId: "comment-feedback",
|
||||
attempt: 1,
|
||||
maxAttempts: 1,
|
||||
nextAttemptAt: null,
|
||||
lastAttemptAt: "2026-04-06T11:58:00.000Z",
|
||||
deliveredAt: null,
|
||||
targetRunId: "a1b2c3d4e5f6",
|
||||
targetRunAgentId: "agent-1",
|
||||
canRetry: true,
|
||||
retryInFlight: false,
|
||||
safeFailureReason: null,
|
||||
batchedCommentCount: 1,
|
||||
},
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
}]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
currentUserId="user-board"
|
||||
onAdd={async () => {}}
|
||||
onRetryFeedbackDelivery={() => {}}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[{
|
||||
id: "comment-plain",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-board",
|
||||
authorType: "user",
|
||||
body: "Delivered on the first attempt",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
}]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
currentUserId="user-board"
|
||||
onAdd={async () => {}}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
issueId="issue-1"
|
||||
recoveryAction={feedbackDeliveryAction}
|
||||
onAdd={async () => {}}
|
||||
showComposer
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
// 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" }],
|
||||
|
||||
@@ -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<void>;
|
||||
onCancelQueued?: (commentId: string) => void;
|
||||
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
||||
/** Explicit operator retry for a comment whose feedback delivery is exhausted. */
|
||||
onRetryFeedbackDelivery?: (commentId: string) => Promise<void> | 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<void>;
|
||||
onCancelQueued?: (commentId: string) => void;
|
||||
onRetryFeedbackDelivery?: (commentId: string) => Promise<void> | void;
|
||||
retryingFeedbackDeliveryCommentId?: string | null;
|
||||
failedFeedbackDeliveryRetryCommentId?: string | null;
|
||||
onDeleteComment?: (commentId: string) => Promise<void> | 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}
|
||||
</div>
|
||||
)}
|
||||
<FeedbackDeliveryFooterStatus
|
||||
delivery={feedbackDelivery}
|
||||
showTransientSuccess={showTransientDeliverySuccess}
|
||||
align={isCurrentUser ? "end" : "start"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1653,6 +1681,15 @@ function IssueChatUserMessage({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{feedbackDelivery ? (
|
||||
<FeedbackDeliveryNotice
|
||||
className={cn("mt-2", isCurrentUser && "sm:ml-auto")}
|
||||
delivery={feedbackDelivery}
|
||||
onRetry={onRetryFeedbackDelivery}
|
||||
retryPending={retryingFeedbackDeliveryCommentId === feedbackDelivery.sourceCommentId}
|
||||
retryError={failedFeedbackDeliveryRetryCommentId === feedbackDelivery.sourceCommentId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
@@ -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" ? (
|
||||
<IssueRecoveryActionCard
|
||||
action={recoveryAction}
|
||||
agentMap={agentMap}
|
||||
|
||||
@@ -115,6 +115,7 @@ export interface IssueRecoveryActionCardProps {
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<IssueRecoveryActionKind, string> = {
|
||||
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<IssueRecoveryActionKind, string> = {
|
||||
};
|
||||
|
||||
const KIND_HEADLINE: Record<IssueRecoveryActionKind, string> = {
|
||||
// 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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [retryingFeedbackDeliveryCommentId, setRetryingFeedbackDeliveryCommentId] =
|
||||
useState<string | null>(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}
|
||||
|
||||
Reference in new issue
Block a user