Merge pull request #7554 from paperclipai/codex/pap-10343-comment-redaction

[codex] Redact deleted issue comments
This commit is contained in:
Dotta
2026-06-05 05:32:08 -10:00
committed by GitHub
30 changed files with 40631 additions and 98 deletions
+22 -8
View File
@@ -343,6 +343,7 @@ function CommentCard({
const isHighlighted = highlightCommentId === comment.id;
const isPending = comment.clientStatus === "pending";
const isQueued = queued || comment.queueState === "queued" || comment.clientStatus === "queued";
const isDeleted = Boolean(comment.deletedAt);
const followUpRequested = comment.followUpRequested === true;
return (
@@ -352,10 +353,10 @@ function CommentCard({
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${
isQueued
? "border-amber-300/70 bg-amber-50/70 dark:border-amber-500/40 dark:bg-amber-500/10"
: isHighlighted
: isHighlighted && !isDeleted
? "border-primary/50 bg-primary/5"
: "border-border"
} ${isPending ? "opacity-80" : ""}`}
} ${isPending ? "opacity-80" : ""} ${isDeleted ? "bg-muted/30 text-muted-foreground" : ""}`}
>
<div className="flex items-center justify-between mb-1">
{comment.authorAgentId ? (
@@ -379,7 +380,7 @@ function CommentCard({
Follow-up
</Badge>
) : null}
{companyId && !isPending ? (
{companyId && !isPending && !isDeleted ? (
<PluginSlotOutlet
slotTypes={["commentContextMenuItem"]}
entityType="comment"
@@ -405,11 +406,15 @@ function CommentCard({
{formatDateTime(comment.createdAt)}
</a>
)}
<CopyMarkdownButton text={comment.body} />
{!isDeleted ? <CopyMarkdownButton text={comment.body} /> : null}
</span>
</div>
<MarkdownBody className="text-sm" softBreaks>{comment.body}</MarkdownBody>
{companyId && !isPending ? (
{isDeleted ? (
<div className="text-sm italic text-muted-foreground">Comment deleted</div>
) : (
<MarkdownBody className="text-sm" softBreaks>{comment.body}</MarkdownBody>
)}
{companyId && !isPending && !isDeleted ? (
<div className="mt-2 space-y-2">
<PluginSlotOutlet
slotTypes={["commentAnnotation"]}
@@ -427,7 +432,7 @@ function CommentCard({
/>
</div>
) : null}
{comment.authorAgentId && onVote && !isQueued && !isPending ? (
{comment.authorAgentId && onVote && !isQueued && !isPending && !isDeleted ? (
<OutputFeedbackButtons
activeVote={feedbackVote}
disabled={voting}
@@ -450,7 +455,7 @@ function CommentCard({
) : undefined}
/>
) : null}
{comment.runId && !isPending && !(comment.authorAgentId && onVote && !isQueued) ? (
{comment.runId && !isPending && !isDeleted && !(comment.authorAgentId && onVote && !isQueued) ? (
<div className="mt-3 pt-3 border-t border-border/60">
{comment.runAgentId ? (
<Link
@@ -863,6 +868,15 @@ export function CommentThread({
const hash = location.hash;
if (!hash.startsWith("#comment-") || comments.length + queuedComments.length === 0) return;
const commentId = hash.slice("#comment-".length);
const targetComment = [...comments, ...queuedComments].find((comment) => comment.id === commentId);
if (targetComment?.deletedAt) {
setHighlightCommentId(null);
hasScrolledRef.current = false;
if (typeof window !== "undefined") {
window.history.replaceState(null, "", `${location.pathname}${location.search}`);
}
return;
}
// Only scroll once per hash
if (hasScrolledRef.current) return;
const el = document.getElementById(`comment-${commentId}`);
+171
View File
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { act, createRef, forwardRef, useImperativeHandle, useState } from "react";
import { flushSync } from "react-dom";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
@@ -33,6 +34,14 @@ import type {
IssueChatTranscriptEntry,
} from "../lib/issue-chat-messages";
function flushAct<T>(callback: () => T): T {
let result: T | undefined;
flushSync(() => {
result = callback();
});
return result as T;
}
function hasSmoothScrollBehavior(arg: unknown) {
return typeof arg === "object"
&& arg !== null
@@ -1258,6 +1267,168 @@ describe("IssueChatThread", () => {
});
});
it("confirms and invokes delete only for the current user's normal comments", async () => {
const root = createRoot(container);
const onDeleteComment = vi.fn(async () => {});
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[
{
id: "comment-owned",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "user-board",
body: "Delete me",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
},
{
id: "comment-other",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "user-other",
body: "Do not delete",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:01:00.000Z"),
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
},
]}
currentUserId="user-board"
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
onDeleteComment={onDeleteComment}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const deleteButtons = Array.from(container.querySelectorAll("button[aria-label='Delete comment']"));
expect(deleteButtons).toHaveLength(1);
await act(async () => {
deleteButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(document.body.textContent).toContain("Delete comment?");
const confirmButton = Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent === "Delete comment");
expect(confirmButton).toBeTruthy();
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onDeleteComment).toHaveBeenCalledWith("comment-owned");
act(() => {
root.unmount();
});
});
it("renders deleted comments as tombstones without the original body", () => {
const root = createRoot(container);
flushAct(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[{
id: "comment-deleted",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "user-board",
body: "Sensitive deleted body",
authorType: "user",
presentation: null,
metadata: null,
deletedAt: new Date("2026-04-06T12:05:00.000Z"),
deletedByType: "user",
deletedByUserId: "user-board",
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:05:00.000Z"),
}]}
currentUserId="user-board"
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
onDeleteComment={async () => {}}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
expect(container.textContent).toContain("You deleted this comment");
expect(container.textContent).not.toContain("Sensitive deleted body");
expect(container.querySelector("button[aria-label='Delete comment']")).toBeNull();
expect(container.querySelector("button[aria-label='Copy message']")).toBeNull();
flushAct(() => {
root.unmount();
});
});
it("clears a deleted comment deep-link hash instead of highlighting it", () => {
const root = createRoot(container);
const replaceStateSpy = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
flushAct(() => {
root.render(
<MemoryRouter initialEntries={["/issues/PAP-1#comment-comment-deleted"]}>
<IssueChatThread
comments={[{
id: "comment-deleted",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "user-board",
body: "Sensitive deleted body",
authorType: "user",
presentation: null,
metadata: null,
deletedAt: new Date("2026-04-06T12:05:00.000Z"),
deletedByType: "user",
deletedByUserId: "user-board",
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:05:00.000Z"),
}]}
currentUserId="user-board"
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "/issues/PAP-1");
replaceStateSpy.mockRestore();
flushAct(() => {
root.unmount();
});
});
it("shows explicit follow-up badges and event copy", () => {
const root = createRoot(container);
+152 -39
View File
@@ -133,7 +133,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, ClipboardList, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react";
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, ClipboardList, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice";
import { IssueRecoveryActionCard, type RecoveryResolveOutcome } from "./IssueRecoveryActionCard";
@@ -156,6 +156,7 @@ interface IssueChatMessageContext {
stopRunVariant?: "stop" | "pause";
onInterruptQueued?: (runId: string) => Promise<void>;
onCancelQueued?: (commentId: string) => void;
onDeleteComment?: (commentId: string) => Promise<void> | void;
onImageClick?: (src: string) => void;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
@@ -353,6 +354,7 @@ interface IssueChatThreadProps {
includeSucceededRunsWithoutOutput?: boolean;
onInterruptQueued?: (runId: string) => Promise<void>;
onCancelQueued?: (commentId: string) => void;
onDeleteComment?: (commentId: string) => Promise<void> | void;
interruptingQueuedRunId?: string | null;
stoppingRunId?: string | null;
onImageClick?: (src: string) => void;
@@ -1271,6 +1273,7 @@ function IssueChatUserMessage({
const {
onInterruptQueued,
onCancelQueued,
onDeleteComment,
currentUserId,
userProfileMap,
} = useContext(IssueChatCtx);
@@ -1284,8 +1287,10 @@ function IssueChatUserMessage({
const queueReason = typeof custom.queueReason === "string" ? custom.queueReason : null;
const queueBadgeLabel = queueReason === "hold" ? "\u23f8 Deferred wake" : "Queued";
const pending = custom.clientStatus === "pending";
const deleted = Boolean(custom.deletedAt);
const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null;
const [copied, setCopied] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const {
isCurrentUser,
authorName: resolvedAuthorName,
@@ -1302,6 +1307,16 @@ function IssueChatUserMessage({
<AvatarFallback>{initialsForName(resolvedAuthorName)}</AvatarFallback>
</Avatar>
);
const canDeleteComment = Boolean(onDeleteComment && isCurrentUser && !queued && !pending && !deleted);
const handleDeleteComment = () => {
if (!canDeleteComment) return;
setDeleteDialogOpen(true);
};
const confirmDeleteComment = () => {
if (!canDeleteComment) return;
setDeleteDialogOpen(false);
void onDeleteComment?.(commentId);
};
const messageBody = (
<div className={cn("flex min-w-0 max-w-[85%] flex-col", isCurrentUser && "items-end")}>
<div className={cn("mb-1 flex items-center gap-2 px-1", isCurrentUser ? "justify-end" : "justify-start")}>
@@ -1317,6 +1332,8 @@ function IssueChatUserMessage({
"min-w-0 max-w-full overflow-hidden break-all rounded-2xl px-4 py-2.5",
queued
? "bg-amber-50/80 dark:bg-amber-500/10"
: deleted
? "bg-muted/50 text-muted-foreground"
: "bg-muted",
pending && "opacity-80",
)}
@@ -1349,9 +1366,13 @@ function IssueChatUserMessage({
) : null}
</div>
) : null}
<div className="min-w-0 max-w-full space-y-3">
<IssueChatTextParts message={message} />
</div>
{deleted ? (
<div className="text-sm italic text-muted-foreground">Comment deleted</div>
) : (
<div className="min-w-0 max-w-full space-y-3">
<IssueChatTextParts message={message} />
</div>
)}
</div>
{pending ? (
@@ -1378,45 +1399,78 @@ function IssueChatUserMessage({
{message.createdAt ? formatDateTime(message.createdAt) : ""}
</TooltipContent>
</Tooltip>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy message"
aria-label="Copy message"
onClick={() => {
const text = message.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n\n");
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
{!deleted ? (
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy message"
aria-label="Copy message"
onClick={() => {
const text = message.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n\n");
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
) : null}
{canDeleteComment ? (
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-destructive"
title="Delete comment"
aria-label="Delete comment"
onClick={handleDeleteComment}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
</div>
)}
</div>
);
return (
<div id={anchorId}>
<div className={cn("group flex items-start gap-2.5", isCurrentUser && "justify-end")}>
{isCurrentUser ? (
<>
{messageBody}
{authorAvatar}
</>
) : (
<>
{authorAvatar}
{messageBody}
</>
)}
<>
<div id={anchorId}>
<div className={cn("group flex items-start gap-2.5", isCurrentUser && "justify-end")}>
{isCurrentUser ? (
<>
{messageBody}
{authorAvatar}
</>
) : (
<>
{authorAvatar}
{messageBody}
</>
)}
</div>
</div>
</div>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete comment?</DialogTitle>
<DialogDescription>
This will replace the comment with a deleted-comment marker.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDeleteComment}>
Delete comment
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1464,11 +1518,12 @@ function IssueChatAssistantMessage({
const canStopRun = Boolean(runId) && (isRunActive || runStatus === "queued" || runStatus === "running");
const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null;
const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call");
const deleted = Boolean(custom.deletedAt);
const isFoldable = !isRunning && !!chainOfThoughtLabel;
const [folded, setFolded] = useState(isFoldable);
const [prevFoldKey, setPrevFoldKey] = useState({ messageId: message.id, isFoldable });
const [copied, setCopied] = useState(false);
const copyText = getThreadMessageCopyText(message);
const copyText = deleted ? "" : getThreadMessageCopyText(message);
// Derive fold state synchronously during render (not in useEffect) so the
// browser never paints the un-folded intermediate state — prevents the
@@ -1543,7 +1598,11 @@ function IssueChatAssistantMessage({
</div>
)}
{!folded ? (
{deleted ? (
<div className="rounded-sm bg-muted/40 px-3 py-2 text-sm italic text-muted-foreground">
Comment deleted
</div>
) : !folded ? (
<>
<div className="space-y-3">
<IssueChatAssistantParts message={message} hasCoT={hasCoT} />
@@ -2614,6 +2673,16 @@ function issueChatMessageQueuedRunIsInterrupting(
return Boolean(queueTargetRunId && interruptingQueuedRunId === queueTargetRunId);
}
function issueChatMessageIsDeleted(message: ThreadMessage): boolean {
const custom = issueChatMessageCustom(message);
return Boolean(custom.deletedAt);
}
function issueChatMessageDeletedAt(message: ThreadMessage): string | null {
const custom = issueChatMessageCustom(message);
return typeof custom.deletedAt === "string" ? custom.deletedAt : null;
}
// Above ~150 merged rows the direct render path forces React to mount and
// re-render hundreds of Markdown bodies, feedback controls, and avatars on
// unrelated parent updates. Above this threshold we switch to a windowed
@@ -3045,6 +3114,33 @@ interface IssueChatMessageRowProps {
interruptingQueuedRunId?: string | null;
}
function IssueChatDeletedComment({
message,
deletedAt,
}: {
message: ThreadMessage;
deletedAt: string;
}) {
const custom = issueChatMessageCustom(message);
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
const authorName = typeof custom.authorName === "string" ? custom.authorName : "Comment";
const deletedDate = new Date(deletedAt);
const deletedDateLabel = Number.isNaN(deletedDate.getTime()) ? "" : formatDateTime(deletedDate);
return (
<div id={anchorId} className="flex items-start gap-2.5 py-1.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border bg-muted/40 text-muted-foreground">
<Trash2 className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 rounded-md border border-dashed border-border bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground/80">{authorName}</span>
<span> deleted this comment</span>
{deletedDateLabel ? <span className="text-xs"> · {deletedDateLabel}</span> : null}
</div>
</div>
);
}
const IssueChatMessageRow = memo(function IssueChatMessageRow({
message,
feedbackVoteByTargetId,
@@ -3053,11 +3149,14 @@ const IssueChatMessageRow = memo(function IssueChatMessageRow({
interruptingQueuedRunId,
}: IssueChatMessageRowProps) {
const kind = issueChatMessageKind(message);
const deletedAt = issueChatMessageDeletedAt(message);
const activeVote = issueChatMessageActiveVote(message, feedbackVoteByTargetId);
const isRunActive = issueChatMessageRunIsActive(message, activeRunIds);
const isStoppingRun = issueChatMessageRunIsStopping(message, stoppingRunId);
const isInterruptingQueuedRun = issueChatMessageQueuedRunIsInterrupting(message, interruptingQueuedRunId);
const renderedMessage = message.role === "user"
const renderedMessage = deletedAt
? <IssueChatDeletedComment message={message} deletedAt={deletedAt} />
: message.role === "user"
? (
<IssueChatUserMessage
message={message}
@@ -3664,6 +3763,7 @@ export function IssueChatThread({
includeSucceededRunsWithoutOutput = false,
onInterruptQueued,
onCancelQueued,
onDeleteComment,
interruptingQueuedRunId = null,
stoppingRunId = null,
onImageClick,
@@ -3937,6 +4037,16 @@ export function IssueChatThread({
) return;
if (messages.length === 0 || lastScrolledHashRef.current === hash) return;
const targetId = hash.slice(1);
if (targetId.startsWith("comment-")) {
const targetMessage = messages.find((message) => issueChatMessageAnchorId(message) === targetId);
if (targetMessage && issueChatMessageIsDeleted(targetMessage)) {
lastScrolledHashRef.current = hash;
if (typeof window !== "undefined") {
window.history.replaceState(null, "", `${location.pathname}${location.search}`);
}
return;
}
}
let cancelled = false;
const attemptScroll = (finalAttempt = false) => {
if (cancelled || lastScrolledHashRef.current === hash) return;
@@ -4129,6 +4239,7 @@ export function IssueChatThread({
const stableOnStopRun = useStableEvent(onStopRun);
const stableOnInterruptQueued = useStableEvent(onInterruptQueued);
const stableOnCancelQueued = useStableEvent(onCancelQueued);
const stableOnDeleteComment = useStableEvent(onDeleteComment);
const stableOnImageClick = useStableEvent(onImageClick);
const stableOnAcceptInteraction = useStableEvent(onAcceptInteraction);
const stableOnRejectInteraction = useStableEvent(onRejectInteraction);
@@ -4150,6 +4261,7 @@ export function IssueChatThread({
stopRunVariant,
onInterruptQueued: stableOnInterruptQueued,
onCancelQueued: stableOnCancelQueued,
onDeleteComment: stableOnDeleteComment,
onImageClick: stableOnImageClick,
onAcceptInteraction: stableOnAcceptInteraction,
onRejectInteraction: stableOnRejectInteraction,
@@ -4172,6 +4284,7 @@ export function IssueChatThread({
stopRunVariant,
stableOnInterruptQueued,
stableOnCancelQueued,
stableOnDeleteComment,
stableOnImageClick,
stableOnAcceptInteraction,
stableOnRejectInteraction,