Merge pull request #7554 from paperclipai/codex/pap-10343-comment-redaction
[codex] Redact deleted issue comments
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user