mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
fix(ui): surface feedback delivery in default task chat
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6d210a995c
commit
7245d259b3
7 files changed
+152
-2
No files matched your search
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user