Redact deleted issue comments
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "deleted_by_type" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "deleted_by_agent_id" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "deleted_by_user_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "deleted_by_run_id" uuid;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_deleted_by_agent_id_agents_id_fk" FOREIGN KEY ("deleted_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_deleted_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("deleted_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE "document_annotation_comments" ADD COLUMN IF NOT EXISTS "issue_comment_id" uuid;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_issue_comment_id_issue_comments_id_fk" FOREIGN KEY ("issue_comment_id") REFERENCES "public"."issue_comments"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "document_annotation_comments_issue_comment_idx" ON "document_annotation_comments" USING btree ("issue_comment_id");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -666,6 +666,20 @@
|
|||||||
"when": 1780533900000,
|
"when": 1780533900000,
|
||||||
"tag": "0094_backfill_archived_company_agent_pauses",
|
"tag": "0094_backfill_archived_company_agent_pauses",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 95,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780507597454,
|
||||||
|
"tag": "0095_issue_comment_tombstones",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 96,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780507597455,
|
||||||
|
"tag": "0096_document_annotation_issue_comment_links",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { companies } from "./companies.js";
|
|||||||
import { documentAnnotationThreads } from "./document_annotation_threads.js";
|
import { documentAnnotationThreads } from "./document_annotation_threads.js";
|
||||||
import { documents } from "./documents.js";
|
import { documents } from "./documents.js";
|
||||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||||
|
import { issueComments } from "./issue_comments.js";
|
||||||
import { issues } from "./issues.js";
|
import { issues } from "./issues.js";
|
||||||
|
|
||||||
export const documentAnnotationComments = pgTable(
|
export const documentAnnotationComments = pgTable(
|
||||||
@@ -20,6 +21,7 @@ export const documentAnnotationComments = pgTable(
|
|||||||
authorAgentId: uuid("author_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
authorAgentId: uuid("author_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
authorUserId: text("author_user_id"),
|
authorUserId: text("author_user_id"),
|
||||||
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||||
|
issueCommentId: uuid("issue_comment_id").references(() => issueComments.id, { onDelete: "set null" }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
@@ -39,6 +41,7 @@ export const documentAnnotationComments = pgTable(
|
|||||||
table.documentId,
|
table.documentId,
|
||||||
table.createdAt,
|
table.createdAt,
|
||||||
),
|
),
|
||||||
|
issueCommentIdx: index("document_annotation_comments_issue_comment_idx").on(table.issueCommentId),
|
||||||
bodySearchIdx: index("document_annotation_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")),
|
bodySearchIdx: index("document_annotation_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const issueComments = pgTable(
|
|||||||
body: text("body").notNull(),
|
body: text("body").notNull(),
|
||||||
presentation: jsonb("presentation").$type<IssueCommentPresentation | null>(),
|
presentation: jsonb("presentation").$type<IssueCommentPresentation | null>(),
|
||||||
metadata: jsonb("metadata").$type<IssueCommentMetadata | null>(),
|
metadata: jsonb("metadata").$type<IssueCommentMetadata | null>(),
|
||||||
|
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||||
|
deletedByType: text("deleted_by_type").$type<"agent" | "user">(),
|
||||||
|
deletedByAgentId: uuid("deleted_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
|
deletedByUserId: text("deleted_by_user_id"),
|
||||||
|
deletedByRunId: uuid("deleted_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1607,7 +1607,9 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
async listComments(issueId, companyId) {
|
async listComments(issueId, companyId) {
|
||||||
requireCapability(manifest, capabilitySet, "issue.comments.read");
|
requireCapability(manifest, capabilitySet, "issue.comments.read");
|
||||||
if (!isInCompany(issues.get(issueId), companyId)) return [];
|
if (!isInCompany(issues.get(issueId), companyId)) return [];
|
||||||
return issueComments.get(issueId) ?? [];
|
return (issueComments.get(issueId) ?? []).map((comment) =>
|
||||||
|
comment.deletedAt ? { ...comment, body: "", presentation: null, metadata: null } : comment
|
||||||
|
);
|
||||||
},
|
},
|
||||||
async createComment(issueId, body, companyId, options) {
|
async createComment(issueId, body, companyId, options) {
|
||||||
requireCapability(manifest, capabilitySet, "issue.comments.create");
|
requireCapability(manifest, capabilitySet, "issue.comments.create");
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export interface DocumentAnnotationComment {
|
|||||||
authorAgentId: string | null;
|
authorAgentId: string | null;
|
||||||
authorUserId: string | null;
|
authorUserId: string | null;
|
||||||
createdByRunId: string | null;
|
createdByRunId: string | null;
|
||||||
|
issueCommentId?: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -123,10 +124,12 @@ export interface CreateDocumentAnnotationThreadRequest {
|
|||||||
baseRevisionNumber: number;
|
baseRevisionNumber: number;
|
||||||
selector: DocumentAnnotationAnchorSelector;
|
selector: DocumentAnnotationAnchorSelector;
|
||||||
body: string;
|
body: string;
|
||||||
|
issueCommentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateDocumentAnnotationCommentRequest {
|
export interface CreateDocumentAnnotationCommentRequest {
|
||||||
body: string;
|
body: string;
|
||||||
|
issueCommentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateDocumentAnnotationThreadRequest {
|
export interface UpdateDocumentAnnotationThreadRequest {
|
||||||
|
|||||||
@@ -579,6 +579,11 @@ export interface IssueComment {
|
|||||||
body: string;
|
body: string;
|
||||||
presentation: IssueCommentPresentation | null;
|
presentation: IssueCommentPresentation | null;
|
||||||
metadata: IssueCommentMetadata | null;
|
metadata: IssueCommentMetadata | null;
|
||||||
|
deletedAt?: Date | null;
|
||||||
|
deletedByType?: "agent" | "user" | null;
|
||||||
|
deletedByAgentId?: string | null;
|
||||||
|
deletedByUserId?: string | null;
|
||||||
|
deletedByRunId?: string | null;
|
||||||
followUpRequested?: boolean;
|
followUpRequested?: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|||||||
@@ -48,10 +48,12 @@ export const createDocumentAnnotationThreadSchema = z.object({
|
|||||||
baseRevisionNumber: z.number().int().positive(),
|
baseRevisionNumber: z.number().int().positive(),
|
||||||
selector: documentAnnotationAnchorSelectorSchema,
|
selector: documentAnnotationAnchorSelectorSchema,
|
||||||
body: multilineTextSchema.pipe(z.string().min(1).max(20_000)),
|
body: multilineTextSchema.pipe(z.string().min(1).max(20_000)),
|
||||||
|
issueCommentId: z.string().uuid().nullable().optional(),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
export const createDocumentAnnotationCommentSchema = z.object({
|
export const createDocumentAnnotationCommentSchema = z.object({
|
||||||
body: multilineTextSchema.pipe(z.string().min(1).max(20_000)),
|
body: multilineTextSchema.pipe(z.string().min(1).max(20_000)),
|
||||||
|
issueCommentId: z.string().uuid().nullable().optional(),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
export const updateDocumentAnnotationThreadSchema = z.object({
|
export const updateDocumentAnnotationThreadSchema = z.object({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const mockIssueService = vi.hoisted(() => ({
|
|||||||
assertCheckoutOwner: vi.fn(),
|
assertCheckoutOwner: vi.fn(),
|
||||||
getComment: vi.fn(),
|
getComment: vi.fn(),
|
||||||
removeComment: vi.fn(),
|
removeComment: vi.fn(),
|
||||||
|
tombstoneComment: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockAccessService = vi.hoisted(() => ({
|
const mockAccessService = vi.hoisted(() => ({
|
||||||
@@ -38,6 +39,24 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
|
|||||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||||
}));
|
}));
|
||||||
|
const mockDocumentAnnotationService = vi.hoisted(() => ({
|
||||||
|
cleanupForIssueCommentDeletion: vi.fn(async () => ({ deletedCommentIds: [], resolvedThreadIds: [] })),
|
||||||
|
remapOpenThreadsForDocument: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
const mockIssueReferenceService = vi.hoisted(() => ({
|
||||||
|
deleteCommentSource: vi.fn(async () => undefined),
|
||||||
|
deleteDocumentSource: vi.fn(async () => undefined),
|
||||||
|
diffIssueReferenceSummary: vi.fn(() => ({
|
||||||
|
addedReferencedIssues: [],
|
||||||
|
removedReferencedIssues: [],
|
||||||
|
currentReferencedIssues: [],
|
||||||
|
})),
|
||||||
|
emptySummary: vi.fn(() => ({ outbound: [], inbound: [] })),
|
||||||
|
listIssueReferenceSummary: vi.fn(async () => ({ outbound: [], inbound: [] })),
|
||||||
|
syncComment: vi.fn(async () => undefined),
|
||||||
|
syncDocument: vi.fn(async () => undefined),
|
||||||
|
syncIssue: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
function registerModuleMocks() {
|
function registerModuleMocks() {
|
||||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||||
@@ -79,7 +98,7 @@ function registerModuleMocks() {
|
|||||||
}),
|
}),
|
||||||
accessService: () => mockAccessService,
|
accessService: () => mockAccessService,
|
||||||
agentService: () => ({ getById: vi.fn(async () => null) }),
|
agentService: () => ({ getById: vi.fn(async () => null) }),
|
||||||
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
documentAnnotationService: () => mockDocumentAnnotationService,
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
executionWorkspaceService: () => ({}),
|
executionWorkspaceService: () => ({}),
|
||||||
feedbackService: () => mockFeedbackService,
|
feedbackService: () => mockFeedbackService,
|
||||||
@@ -91,19 +110,7 @@ function registerModuleMocks() {
|
|||||||
getActiveForIssue: vi.fn(async () => null),
|
getActiveForIssue: vi.fn(async () => null),
|
||||||
listActiveForIssues: vi.fn(async () => new Map()),
|
listActiveForIssues: vi.fn(async () => new Map()),
|
||||||
}),
|
}),
|
||||||
issueReferenceService: () => ({
|
issueReferenceService: () => mockIssueReferenceService,
|
||||||
deleteDocumentSource: async () => undefined,
|
|
||||||
diffIssueReferenceSummary: () => ({
|
|
||||||
addedReferencedIssues: [],
|
|
||||||
removedReferencedIssues: [],
|
|
||||||
currentReferencedIssues: [],
|
|
||||||
}),
|
|
||||||
emptySummary: () => ({ outbound: [], inbound: [] }),
|
|
||||||
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
|
|
||||||
syncComment: async () => undefined,
|
|
||||||
syncDocument: async () => undefined,
|
|
||||||
syncIssue: async () => undefined,
|
|
||||||
}),
|
|
||||||
issueService: () => mockIssueService,
|
issueService: () => mockIssueService,
|
||||||
issueThreadInteractionService: () => mockIssueThreadInteractionService,
|
issueThreadInteractionService: () => mockIssueThreadInteractionService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
@@ -188,6 +195,15 @@ describe.sequential("issue comment cancel routes", () => {
|
|||||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||||
mockIssueService.getComment.mockResolvedValue(makeComment());
|
mockIssueService.getComment.mockResolvedValue(makeComment());
|
||||||
mockIssueService.removeComment.mockResolvedValue(makeComment());
|
mockIssueService.removeComment.mockResolvedValue(makeComment());
|
||||||
|
mockIssueService.tombstoneComment.mockResolvedValue(makeComment({
|
||||||
|
body: "",
|
||||||
|
metadata: null,
|
||||||
|
deletedAt: new Date("2026-04-11T15:05:00.000Z"),
|
||||||
|
deletedByType: "user",
|
||||||
|
deletedByAgentId: null,
|
||||||
|
deletedByUserId: "local-board",
|
||||||
|
deletedByRunId: null,
|
||||||
|
}));
|
||||||
mockAccessService.canUser.mockResolvedValue(false);
|
mockAccessService.canUser.mockResolvedValue(false);
|
||||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||||
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
|
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
|
||||||
@@ -214,13 +230,23 @@ describe.sequential("issue comment cancel routes", () => {
|
|||||||
});
|
});
|
||||||
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
|
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
|
||||||
mockLogActivity.mockResolvedValue(undefined);
|
mockLogActivity.mockResolvedValue(undefined);
|
||||||
|
mockDocumentAnnotationService.cleanupForIssueCommentDeletion.mockResolvedValue({
|
||||||
|
deletedCommentIds: [],
|
||||||
|
resolvedThreadIds: [],
|
||||||
|
});
|
||||||
|
mockIssueReferenceService.deleteCommentSource.mockResolvedValue(undefined);
|
||||||
|
mockIssueReferenceService.syncComment.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cancels a queued comment from its author and restores the deleted body", async () => {
|
it("cancels a queued comment from its author and restores the deleted body", async () => {
|
||||||
const res = await request(await installActor(createApp()))
|
const res = await request(await installActor(createApp()))
|
||||||
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
|
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status, JSON.stringify({
|
||||||
|
body: res.body,
|
||||||
|
tombstoneCalls: mockIssueService.tombstoneComment.mock.calls,
|
||||||
|
activityCalls: mockLogActivity.mock.calls,
|
||||||
|
})).toBe(200);
|
||||||
expect(res.body).toMatchObject({
|
expect(res.body).toMatchObject({
|
||||||
id: "comment-1",
|
id: "comment-1",
|
||||||
body: "Queued follow-up",
|
body: "Queued follow-up",
|
||||||
@@ -239,6 +265,18 @@ describe.sequential("issue comment cancel routes", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects stale queued cancellation after the active run is gone", async () => {
|
||||||
|
mockHeartbeatService.getRun.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await request(await installActor(createApp()))
|
||||||
|
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.error).toBe("Queued comment can no longer be canceled");
|
||||||
|
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
|
||||||
|
expect(mockIssueService.tombstoneComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects canceling comments that are no longer queued", async () => {
|
it("rejects canceling comments that are no longer queued", async () => {
|
||||||
mockIssueService.getComment.mockResolvedValue(
|
mockIssueService.getComment.mockResolvedValue(
|
||||||
makeComment({
|
makeComment({
|
||||||
@@ -248,11 +286,12 @@ describe.sequential("issue comment cancel routes", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const res = await request(await installActor(createApp()))
|
const res = await request(await installActor(createApp()))
|
||||||
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
|
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
|
||||||
|
|
||||||
expect(res.status).toBe(409);
|
expect(res.status).toBe(409);
|
||||||
expect(res.body.error).toBe("Only queued comments can be canceled");
|
expect(res.body.error).toBe("Only queued comments can be canceled");
|
||||||
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
|
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
|
||||||
|
expect(mockIssueService.tombstoneComment).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects canceling another actor's queued comment", async () => {
|
it("rejects canceling another actor's queued comment", async () => {
|
||||||
@@ -263,10 +302,87 @@ describe.sequential("issue comment cancel routes", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const res = await request(await installActor(createApp()))
|
const res = await request(await installActor(createApp()))
|
||||||
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
|
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
|
||||||
|
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.body.error).toBe("Only the comment author can cancel queued comments");
|
expect(res.body.error).toBe("Only the comment author can cancel queued comments");
|
||||||
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
|
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("deletes a normal authored comment as a tombstone without returning the original body", async () => {
|
||||||
|
mockIssueService.getComment.mockResolvedValue(
|
||||||
|
makeComment({
|
||||||
|
body: "Sensitive original comment body",
|
||||||
|
metadata: { version: 1, sections: [{ rows: [{ type: "text", text: "Sensitive metadata copy" }] }] },
|
||||||
|
createdAt: new Date("2026-04-11T14:58:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-11T14:58:00.000Z"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mockDocumentAnnotationService.cleanupForIssueCommentDeletion.mockResolvedValue({
|
||||||
|
deletedCommentIds: ["annotation-comment-1"],
|
||||||
|
resolvedThreadIds: ["annotation-thread-1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(await installActor(createApp()))
|
||||||
|
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
|
||||||
|
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({
|
||||||
|
id: "comment-1",
|
||||||
|
body: "",
|
||||||
|
metadata: null,
|
||||||
|
deletedByType: "user",
|
||||||
|
deletedByUserId: "local-board",
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain("Sensitive original comment body");
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain("Sensitive metadata copy");
|
||||||
|
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
|
||||||
|
expect(mockIssueService.tombstoneComment).toHaveBeenCalledWith("comment-1", {
|
||||||
|
actorType: "user",
|
||||||
|
agentId: null,
|
||||||
|
userId: "local-board",
|
||||||
|
runId: null,
|
||||||
|
});
|
||||||
|
expect(mockIssueReferenceService.syncComment).toHaveBeenCalledWith("comment-1");
|
||||||
|
expect(mockDocumentAnnotationService.cleanupForIssueCommentDeletion).toHaveBeenCalledWith(
|
||||||
|
"11111111-1111-4111-8111-111111111111",
|
||||||
|
"comment-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
actorType: "user",
|
||||||
|
userId: "local-board",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockIssueReferenceService.deleteCommentSource).toHaveBeenCalledWith("annotation-comment-1");
|
||||||
|
const deletedActivity = mockLogActivity.mock.calls.find((call) => call[1]?.action === "issue.comment_deleted")?.[1];
|
||||||
|
expect(deletedActivity).toEqual(expect.objectContaining({
|
||||||
|
action: "issue.comment_deleted",
|
||||||
|
details: expect.objectContaining({
|
||||||
|
deletedAnnotationCommentIds: ["annotation-comment-1"],
|
||||||
|
resolvedAnnotationThreadIds: ["annotation-thread-1"],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(deletedActivity?.details).toEqual(expect.not.objectContaining({
|
||||||
|
bodySnippet: expect.anything(),
|
||||||
|
}));
|
||||||
|
expect(JSON.stringify(deletedActivity?.details ?? {})).not.toContain("Sensitive original comment body");
|
||||||
|
expect(JSON.stringify(deletedActivity?.details ?? {})).not.toContain("Sensitive metadata copy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects deleting another actor's normal comment", async () => {
|
||||||
|
mockIssueService.getComment.mockResolvedValue(
|
||||||
|
makeComment({
|
||||||
|
authorUserId: "someone-else",
|
||||||
|
createdAt: new Date("2026-04-11T14:58:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-11T14:58:00.000Z"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(await installActor(createApp()))
|
||||||
|
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toBe("Only the comment author can delete comments");
|
||||||
|
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
|
||||||
|
expect(mockIssueService.tombstoneComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
companies,
|
||||||
|
createDb,
|
||||||
|
issueComments,
|
||||||
|
issueReferenceMentions,
|
||||||
|
issues,
|
||||||
|
} from "@paperclipai/db";
|
||||||
|
import { companySearchQuerySchema } from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
getEmbeddedPostgresTestSupport,
|
||||||
|
startEmbeddedPostgresTestDatabase,
|
||||||
|
} from "./helpers/embedded-postgres.js";
|
||||||
|
import { errorHandler } from "../middleware/index.js";
|
||||||
|
import { issueRoutes } from "../routes/issues.js";
|
||||||
|
import { companySearchService } from "../services/company-search.js";
|
||||||
|
import { buildPaperclipWakePayload } from "../services/heartbeat.js";
|
||||||
|
import { issueReferenceService } from "../services/issue-references.js";
|
||||||
|
import { issueService } from "../services/issues.js";
|
||||||
|
import type { StorageService } from "../storage/types.js";
|
||||||
|
|
||||||
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||||
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
|
||||||
|
|
||||||
|
if (!embeddedPostgresSupport.supported) {
|
||||||
|
console.warn(
|
||||||
|
`Skipping embedded Postgres issue comment redaction tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describeEmbeddedPostgres("deleted issue comment redaction", () => {
|
||||||
|
let db!: ReturnType<typeof createDb>;
|
||||||
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-comment-redaction-");
|
||||||
|
db = createDb(tempDb.connectionString);
|
||||||
|
await db.execute(sql.raw("CREATE EXTENSION IF NOT EXISTS pg_trgm"));
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.delete(issueReferenceMentions);
|
||||||
|
await db.delete(issueComments);
|
||||||
|
await db.delete(issues);
|
||||||
|
await db.delete(companies);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await tempDb?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedIssue() {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const issueId = randomUUID();
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Comment Redaction Co",
|
||||||
|
issuePrefix: `R${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
await db.insert(issues).values({
|
||||||
|
id: issueId,
|
||||||
|
companyId,
|
||||||
|
identifier: "RED-1",
|
||||||
|
title: "Deleted comment redaction",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
});
|
||||||
|
return { companyId, issueId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createApp(companyId: string) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as any).actor = {
|
||||||
|
type: "board",
|
||||||
|
userId: "board-user-1",
|
||||||
|
companyIds: [companyId],
|
||||||
|
memberships: [{ companyId, membershipRole: "owner", status: "active" }],
|
||||||
|
source: "cloud_tenant",
|
||||||
|
isInstanceAdmin: true,
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
const storage: StorageService = {
|
||||||
|
provider: "local_disk",
|
||||||
|
putFile: vi.fn(async () => {
|
||||||
|
throw new Error("Unexpected storage.putFile call");
|
||||||
|
}),
|
||||||
|
getObject: vi.fn(async () => {
|
||||||
|
throw new Error("Unexpected storage.getObject call");
|
||||||
|
}),
|
||||||
|
headObject: vi.fn(async () => ({ exists: false })),
|
||||||
|
deleteObject: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
app.use("/api", issueRoutes(db, storage));
|
||||||
|
app.use(errorHandler);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("redacts deleted comment bodies from ordinary reads, heartbeat context, and wake payloads", async () => {
|
||||||
|
const { companyId, issueId } = await seedIssue();
|
||||||
|
const commentId = randomUUID();
|
||||||
|
const deletedAt = new Date("2026-06-03T12:00:00.000Z");
|
||||||
|
await db.insert(issueComments).values({
|
||||||
|
id: commentId,
|
||||||
|
companyId,
|
||||||
|
issueId,
|
||||||
|
authorUserId: "board-user-1",
|
||||||
|
body: "secret deleted body",
|
||||||
|
presentation: { kind: "system_notice", tone: "warning" },
|
||||||
|
metadata: { version: 1, sections: [{ rows: [{ type: "text", text: "secret metadata" }] }] },
|
||||||
|
deletedAt,
|
||||||
|
deletedByType: "user",
|
||||||
|
deletedByUserId: "board-user-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const comments = await issueService(db).listComments(issueId, { order: "asc" });
|
||||||
|
expect(comments).toHaveLength(1);
|
||||||
|
expect(comments[0]).toMatchObject({
|
||||||
|
id: commentId,
|
||||||
|
body: "",
|
||||||
|
presentation: null,
|
||||||
|
metadata: null,
|
||||||
|
deletedAt,
|
||||||
|
deletedByType: "user",
|
||||||
|
deletedByUserId: "board-user-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const exactComment = await issueService(db).getComment(commentId);
|
||||||
|
expect(exactComment?.body).toBe("");
|
||||||
|
expect(exactComment?.metadata).toBeNull();
|
||||||
|
|
||||||
|
const heartbeatContext = await request(createApp(companyId))
|
||||||
|
.get(`/api/issues/${issueId}/heartbeat-context`)
|
||||||
|
.query({ wakeCommentId: commentId });
|
||||||
|
expect(heartbeatContext.status, JSON.stringify(heartbeatContext.body)).toBe(200);
|
||||||
|
expect(heartbeatContext.body.wakeComment).toMatchObject({
|
||||||
|
id: commentId,
|
||||||
|
body: "",
|
||||||
|
metadata: null,
|
||||||
|
deletedByUserId: "board-user-1",
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(heartbeatContext.body)).not.toContain("secret deleted body");
|
||||||
|
expect(JSON.stringify(heartbeatContext.body)).not.toContain("secret metadata");
|
||||||
|
|
||||||
|
const wakePayload = await buildPaperclipWakePayload({
|
||||||
|
db,
|
||||||
|
companyId,
|
||||||
|
contextSnapshot: {
|
||||||
|
issueId,
|
||||||
|
commentId,
|
||||||
|
wakeCommentIds: [commentId],
|
||||||
|
wakeReason: "issue_commented",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wakePayload?.comments).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: commentId,
|
||||||
|
body: "",
|
||||||
|
bodyTruncated: false,
|
||||||
|
presentation: null,
|
||||||
|
metadata: null,
|
||||||
|
deletedAt: deletedAt.toISOString(),
|
||||||
|
deletedByUserId: "board-user-1",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(wakePayload)).not.toContain("secret deleted body");
|
||||||
|
expect(JSON.stringify(wakePayload)).not.toContain("secret metadata");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes deleted comment bodies from company search", async () => {
|
||||||
|
const { companyId, issueId } = await seedIssue();
|
||||||
|
await db.insert(issueComments).values({
|
||||||
|
companyId,
|
||||||
|
issueId,
|
||||||
|
body: "vanished-search-needle",
|
||||||
|
deletedAt: new Date("2026-06-03T12:00:00.000Z"),
|
||||||
|
deletedByType: "user",
|
||||||
|
deletedByUserId: "board-user-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await companySearchService(db).search(
|
||||||
|
companyId,
|
||||||
|
companySearchQuerySchema.parse({ q: "vanished-search-needle", scope: "comments" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.results).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears issue references sourced from deleted comment bodies", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const sourceIssueId = randomUUID();
|
||||||
|
const targetIssueId = randomUUID();
|
||||||
|
const commentId = randomUUID();
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Reference Redaction Co",
|
||||||
|
issuePrefix: "REF",
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
await db.insert(issues).values([
|
||||||
|
{
|
||||||
|
id: sourceIssueId,
|
||||||
|
companyId,
|
||||||
|
identifier: "REF-1",
|
||||||
|
title: "Source issue",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: targetIssueId,
|
||||||
|
companyId,
|
||||||
|
identifier: "REF-2",
|
||||||
|
title: "Target issue",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await db.insert(issueComments).values({
|
||||||
|
id: commentId,
|
||||||
|
companyId,
|
||||||
|
issueId: sourceIssueId,
|
||||||
|
body: "Follow up in REF-2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const refs = issueReferenceService(db);
|
||||||
|
await refs.syncComment(commentId);
|
||||||
|
expect((await refs.listIssueReferenceSummary(sourceIssueId)).outbound.map((item) => item.issue.id)).toEqual([
|
||||||
|
targetIssueId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.update(issueComments).set({
|
||||||
|
deletedAt: new Date("2026-06-03T12:00:00.000Z"),
|
||||||
|
deletedByType: "user",
|
||||||
|
deletedByUserId: "board-user-1",
|
||||||
|
});
|
||||||
|
await refs.syncComment(commentId);
|
||||||
|
|
||||||
|
expect((await refs.listIssueReferenceSummary(sourceIssueId)).outbound).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -107,4 +107,57 @@ describe("plugin SDK test harness", () => {
|
|||||||
"missing required capability 'authorization.audit.read'",
|
"missing required capability 'authorization.audit.read'",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns tombstone-safe deleted comments from the in-memory issue helper", async () => {
|
||||||
|
const manifest: PaperclipPluginManifestV1 = {
|
||||||
|
id: "paperclip.test-comment-redaction",
|
||||||
|
apiVersion: 1,
|
||||||
|
version: "0.1.0",
|
||||||
|
displayName: "Comment Redaction",
|
||||||
|
description: "Test plugin",
|
||||||
|
author: "Paperclip",
|
||||||
|
categories: ["automation"],
|
||||||
|
capabilities: ["issue.comments.read"],
|
||||||
|
entrypoints: { worker: "./dist/worker.js" },
|
||||||
|
};
|
||||||
|
const harness = createTestHarness({ manifest });
|
||||||
|
harness.seed({
|
||||||
|
issues: [{
|
||||||
|
id: "issue-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
title: "Comment redaction",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
}],
|
||||||
|
issueComments: [{
|
||||||
|
id: "comment-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
authorType: "user",
|
||||||
|
authorAgentId: null,
|
||||||
|
authorUserId: "user-1",
|
||||||
|
body: "secret plugin-visible body",
|
||||||
|
presentation: { kind: "system_notice", tone: "warning" },
|
||||||
|
metadata: { version: 1, sections: [{ rows: [{ type: "text", text: "secret plugin metadata" }] }] },
|
||||||
|
deletedAt: new Date("2026-06-03T12:00:00.000Z"),
|
||||||
|
deletedByType: "user",
|
||||||
|
deletedByUserId: "user-1",
|
||||||
|
createdAt: new Date("2026-06-03T11:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-06-03T12:00:00.000Z"),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const comments = await harness.ctx.issues.listComments("issue-1", "company-1");
|
||||||
|
|
||||||
|
expect(comments).toHaveLength(1);
|
||||||
|
expect(comments[0]).toMatchObject({
|
||||||
|
id: "comment-1",
|
||||||
|
body: "",
|
||||||
|
presentation: null,
|
||||||
|
metadata: null,
|
||||||
|
deletedByUserId: "user-1",
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(comments)).not.toContain("secret plugin-visible body");
|
||||||
|
expect(JSON.stringify(comments)).not.toContain("secret plugin metadata");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5615,18 +5615,22 @@ export function issueRoutes(
|
|||||||
actor.actorType === "agent"
|
actor.actorType === "agent"
|
||||||
? comment.authorAgentId === actor.agentId
|
? comment.authorAgentId === actor.agentId
|
||||||
: comment.authorUserId === actor.actorId;
|
: comment.authorUserId === actor.actorId;
|
||||||
|
const deleteMode = req.query.mode === "cancel" ? "cancel" : "delete";
|
||||||
|
|
||||||
|
const activeRun = await resolveActiveIssueRun(issue);
|
||||||
|
const isQueuedComment = activeRun ? isQueuedIssueCommentForActiveRun({ comment, activeRun }) : false;
|
||||||
|
if (deleteMode === "cancel" || isQueuedComment) {
|
||||||
if (!actorOwnsComment) {
|
if (!actorOwnsComment) {
|
||||||
res.status(403).json({ error: "Only the comment author can cancel queued comments" });
|
res.status(403).json({ error: "Only the comment author can cancel queued comments" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeRun = await resolveActiveIssueRun(issue);
|
|
||||||
if (!activeRun) {
|
if (!activeRun) {
|
||||||
res.status(409).json({ error: "Queued comment can no longer be canceled" });
|
res.status(409).json({ error: "Queued comment can no longer be canceled" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isQueuedIssueCommentForActiveRun({ comment, activeRun })) {
|
if (!isQueuedComment) {
|
||||||
res.status(409).json({ error: "Only queued comments can be canceled" });
|
res.status(409).json({ error: "Only queued comments can be canceled" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5657,6 +5661,68 @@ export function issueRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.json(removed);
|
res.json(removed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actorOwnsComment) {
|
||||||
|
res.status(403).json({ error: "Only the comment author can delete comments" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comment.deletedAt) {
|
||||||
|
res.json(comment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await svc.tombstoneComment(commentId, {
|
||||||
|
actorType: actor.actorType,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||||
|
runId: actor.runId,
|
||||||
|
});
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: "Comment not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await issueReferencesSvc.syncComment(deleted.id);
|
||||||
|
const annotationCleanup = await documentAnnotationsSvc.cleanupForIssueCommentDeletion(issue.id, deleted.id, {
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||||
|
runId: actor.runId,
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
annotationCleanup.deletedCommentIds.map((annotationCommentId) =>
|
||||||
|
issueReferencesSvc.deleteCommentSource(annotationCommentId)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: issue.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "issue.comment_deleted",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: {
|
||||||
|
commentId: deleted.id,
|
||||||
|
identifier: issue.identifier,
|
||||||
|
issueTitle: issue.title,
|
||||||
|
source: "author_delete",
|
||||||
|
deletedByType: actor.actorType,
|
||||||
|
deletedByAgentId: actor.actorType === "agent" ? actor.agentId : null,
|
||||||
|
deletedByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||||
|
deletedByRunId: actor.runId,
|
||||||
|
deletedAt: deleted.deletedAt,
|
||||||
|
deletedAnnotationCommentIds: annotationCleanup.deletedCommentIds,
|
||||||
|
resolvedAnnotationThreadIds: annotationCleanup.resolvedThreadIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(deleted);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/issues/:id/feedback-votes", async (req, res) => {
|
router.get("/issues/:id/feedback-votes", async (req, res) => {
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ export function companySearchService(db: Db) {
|
|||||||
FROM issue_comments search_comments
|
FROM issue_comments search_comments
|
||||||
WHERE search_comments.company_id = ${companyId}
|
WHERE search_comments.company_id = ${companyId}
|
||||||
AND search_comments.issue_id = issues.id
|
AND search_comments.issue_id = issues.id
|
||||||
|
AND search_comments.deleted_at IS NULL
|
||||||
AND (
|
AND (
|
||||||
lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\'
|
lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\'
|
||||||
OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)}
|
OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)}
|
||||||
@@ -428,6 +429,7 @@ export function companySearchService(db: Db) {
|
|||||||
FROM issue_comments coverage_comments
|
FROM issue_comments coverage_comments
|
||||||
WHERE coverage_comments.company_id = ${companyId}
|
WHERE coverage_comments.company_id = ${companyId}
|
||||||
AND coverage_comments.issue_id = issues.id
|
AND coverage_comments.issue_id = issues.id
|
||||||
|
AND coverage_comments.deleted_at IS NULL
|
||||||
AND lower(coverage_comments.body) LIKE '%' || search_token.value || '%' ESCAPE '\\'
|
AND lower(coverage_comments.body) LIKE '%' || search_token.value || '%' ESCAPE '\\'
|
||||||
)
|
)
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
@@ -497,6 +499,7 @@ export function companySearchService(db: Db) {
|
|||||||
FROM issue_comments search_comments
|
FROM issue_comments search_comments
|
||||||
WHERE search_comments.company_id = ${companyId}
|
WHERE search_comments.company_id = ${companyId}
|
||||||
AND search_comments.issue_id = issues.id
|
AND search_comments.issue_id = issues.id
|
||||||
|
AND search_comments.deleted_at IS NULL
|
||||||
AND (
|
AND (
|
||||||
lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\'
|
lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\'
|
||||||
OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)}
|
OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)}
|
||||||
@@ -514,6 +517,7 @@ export function companySearchService(db: Db) {
|
|||||||
FROM issue_comments search_comments
|
FROM issue_comments search_comments
|
||||||
WHERE search_comments.company_id = ${companyId}
|
WHERE search_comments.company_id = ${companyId}
|
||||||
AND search_comments.issue_id = issues.id
|
AND search_comments.issue_id = issues.id
|
||||||
|
AND search_comments.deleted_at IS NULL
|
||||||
AND (
|
AND (
|
||||||
lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\'
|
lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\'
|
||||||
OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)}
|
OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
documentAnnotationComments,
|
documentAnnotationComments,
|
||||||
documentAnnotationThreads,
|
documentAnnotationThreads,
|
||||||
documents,
|
documents,
|
||||||
|
issueComments,
|
||||||
issueDocuments,
|
issueDocuments,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
@@ -80,6 +81,7 @@ const commentSelect = {
|
|||||||
authorAgentId: documentAnnotationComments.authorAgentId,
|
authorAgentId: documentAnnotationComments.authorAgentId,
|
||||||
authorUserId: documentAnnotationComments.authorUserId,
|
authorUserId: documentAnnotationComments.authorUserId,
|
||||||
createdByRunId: documentAnnotationComments.createdByRunId,
|
createdByRunId: documentAnnotationComments.createdByRunId,
|
||||||
|
issueCommentId: documentAnnotationComments.issueCommentId,
|
||||||
createdAt: documentAnnotationComments.createdAt,
|
createdAt: documentAnnotationComments.createdAt,
|
||||||
updatedAt: documentAnnotationComments.updatedAt,
|
updatedAt: documentAnnotationComments.updatedAt,
|
||||||
};
|
};
|
||||||
@@ -140,6 +142,27 @@ export function documentAnnotationService(db: Db) {
|
|||||||
.orderBy(asc(documentAnnotationComments.createdAt), asc(documentAnnotationComments.id));
|
.orderBy(asc(documentAnnotationComments.createdAt), asc(documentAnnotationComments.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertLinkedIssueComment(
|
||||||
|
issueId: string,
|
||||||
|
commentId: string | null | undefined,
|
||||||
|
dbOrTx: any = db,
|
||||||
|
) {
|
||||||
|
if (!commentId) return null;
|
||||||
|
const comment = await dbOrTx
|
||||||
|
.select({
|
||||||
|
id: issueComments.id,
|
||||||
|
companyId: issueComments.companyId,
|
||||||
|
issueId: issueComments.issueId,
|
||||||
|
})
|
||||||
|
.from(issueComments)
|
||||||
|
.where(eq(issueComments.id, commentId))
|
||||||
|
.then((rows: Array<{ id: string; companyId: string; issueId: string }>) => rows[0] ?? null);
|
||||||
|
if (!comment || comment.issueId !== issueId) {
|
||||||
|
throw unprocessable("Linked issue comment must belong to this issue");
|
||||||
|
}
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
listThreadsForIssueDocument: async (
|
listThreadsForIssueDocument: async (
|
||||||
issueId: string,
|
issueId: string,
|
||||||
@@ -217,6 +240,7 @@ export function documentAnnotationService(db: Db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const linkedIssueComment = await assertLinkedIssueComment(issueId, input.issueCommentId, tx);
|
||||||
const [thread] = await tx
|
const [thread] = await tx
|
||||||
.insert(documentAnnotationThreads)
|
.insert(documentAnnotationThreads)
|
||||||
.values({
|
.values({
|
||||||
@@ -258,6 +282,7 @@ export function documentAnnotationService(db: Db) {
|
|||||||
authorAgentId: actor.agentId ?? null,
|
authorAgentId: actor.agentId ?? null,
|
||||||
authorUserId: actor.userId ?? null,
|
authorUserId: actor.userId ?? null,
|
||||||
createdByRunId: actor.runId ?? null,
|
createdByRunId: actor.runId ?? null,
|
||||||
|
issueCommentId: linkedIssueComment?.id ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
@@ -276,6 +301,7 @@ export function documentAnnotationService(db: Db) {
|
|||||||
const thread = await getThreadForIssue(issueId, key, threadId, tx);
|
const thread = await getThreadForIssue(issueId, key, threadId, tx);
|
||||||
if (!thread) throw notFound("Annotation thread not found");
|
if (!thread) throw notFound("Annotation thread not found");
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const linkedIssueComment = await assertLinkedIssueComment(issueId, input.issueCommentId, tx);
|
||||||
const [comment] = await tx
|
const [comment] = await tx
|
||||||
.insert(documentAnnotationComments)
|
.insert(documentAnnotationComments)
|
||||||
.values({
|
.values({
|
||||||
@@ -288,6 +314,7 @@ export function documentAnnotationService(db: Db) {
|
|||||||
authorAgentId: actor.agentId ?? null,
|
authorAgentId: actor.agentId ?? null,
|
||||||
authorUserId: actor.userId ?? null,
|
authorUserId: actor.userId ?? null,
|
||||||
createdByRunId: actor.runId ?? null,
|
createdByRunId: actor.runId ?? null,
|
||||||
|
issueCommentId: linkedIssueComment?.id ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
@@ -299,6 +326,63 @@ export function documentAnnotationService(db: Db) {
|
|||||||
return comment;
|
return comment;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
cleanupForIssueCommentDeletion: async (
|
||||||
|
issueId: string,
|
||||||
|
issueCommentId: string,
|
||||||
|
actor: ActorInput,
|
||||||
|
) => db.transaction(async (tx) => {
|
||||||
|
const linkedComments: Array<Pick<DocumentAnnotationComment, "id" | "threadId">> = await tx
|
||||||
|
.select({
|
||||||
|
id: documentAnnotationComments.id,
|
||||||
|
threadId: documentAnnotationComments.threadId,
|
||||||
|
})
|
||||||
|
.from(documentAnnotationComments)
|
||||||
|
.where(and(
|
||||||
|
eq(documentAnnotationComments.issueId, issueId),
|
||||||
|
eq(documentAnnotationComments.issueCommentId, issueCommentId),
|
||||||
|
));
|
||||||
|
if (linkedComments.length === 0) {
|
||||||
|
return { deletedCommentIds: [], resolvedThreadIds: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedCommentIds = linkedComments.map((comment) => comment.id);
|
||||||
|
const threadIds = [...new Set(linkedComments.map((comment) => comment.threadId))];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.delete(documentAnnotationComments)
|
||||||
|
.where(inArray(documentAnnotationComments.id, deletedCommentIds));
|
||||||
|
|
||||||
|
const remainingRows: Array<{ threadId: string; count: number }> = await tx
|
||||||
|
.select({
|
||||||
|
threadId: documentAnnotationComments.threadId,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(documentAnnotationComments)
|
||||||
|
.where(inArray(documentAnnotationComments.threadId, threadIds))
|
||||||
|
.groupBy(documentAnnotationComments.threadId);
|
||||||
|
const remainingByThreadId = new Map(remainingRows.map((row) => [row.threadId, Number(row.count)]));
|
||||||
|
const emptyThreadIds = threadIds.filter((threadId) => (remainingByThreadId.get(threadId) ?? 0) === 0);
|
||||||
|
|
||||||
|
if (emptyThreadIds.length > 0) {
|
||||||
|
await tx
|
||||||
|
.update(documentAnnotationThreads)
|
||||||
|
.set({
|
||||||
|
status: "resolved",
|
||||||
|
resolvedByAgentId: actor.actorType === "agent" ? actor.agentId ?? null : null,
|
||||||
|
resolvedByUserId: actor.actorType === "user" ? actor.userId ?? null : null,
|
||||||
|
resolvedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
inArray(documentAnnotationThreads.id, emptyThreadIds),
|
||||||
|
eq(documentAnnotationThreads.status, "open"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deletedCommentIds, resolvedThreadIds: emptyThreadIds };
|
||||||
|
}),
|
||||||
|
|
||||||
updateThread: async (
|
updateThread: async (
|
||||||
issueId: string,
|
issueId: string,
|
||||||
key: string,
|
key: string,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readFile, readdir } from "node:fs/promises";
|
import { readFile, readdir } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { and, asc, desc, eq, getTableColumns, gte, lte, ne, or } from "drizzle-orm";
|
import { and, asc, desc, eq, getTableColumns, gte, isNull, lte, ne, or } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
agents,
|
agents,
|
||||||
@@ -805,6 +805,7 @@ async function resolveFeedbackTarget(
|
|||||||
metadata: issueComments.metadata,
|
metadata: issueComments.metadata,
|
||||||
createdByRunId: issueComments.createdByRunId,
|
createdByRunId: issueComments.createdByRunId,
|
||||||
body: issueComments.body,
|
body: issueComments.body,
|
||||||
|
deletedAt: issueComments.deletedAt,
|
||||||
createdAt: issueComments.createdAt,
|
createdAt: issueComments.createdAt,
|
||||||
})
|
})
|
||||||
.from(issueComments)
|
.from(issueComments)
|
||||||
@@ -814,6 +815,9 @@ async function resolveFeedbackTarget(
|
|||||||
if (!targetComment || targetComment.issueId !== issue.id || targetComment.companyId !== issue.companyId) {
|
if (!targetComment || targetComment.issueId !== issue.id || targetComment.companyId !== issue.companyId) {
|
||||||
throw notFound("Feedback target not found");
|
throw notFound("Feedback target not found");
|
||||||
}
|
}
|
||||||
|
if (targetComment.deletedAt) {
|
||||||
|
throw notFound("Feedback target not found");
|
||||||
|
}
|
||||||
if (!targetComment.authorAgentId) {
|
if (!targetComment.authorAgentId) {
|
||||||
throw unprocessable("Feedback voting is only available on agent-authored issue comments");
|
throw unprocessable("Feedback voting is only available on agent-authored issue comments");
|
||||||
}
|
}
|
||||||
@@ -934,9 +938,14 @@ async function listIssueContextItems(
|
|||||||
presentation: issueComments.presentation,
|
presentation: issueComments.presentation,
|
||||||
metadata: issueComments.metadata,
|
metadata: issueComments.metadata,
|
||||||
createdByRunId: issueComments.createdByRunId,
|
createdByRunId: issueComments.createdByRunId,
|
||||||
|
deletedAt: issueComments.deletedAt,
|
||||||
})
|
})
|
||||||
.from(issueComments)
|
.from(issueComments)
|
||||||
.where(and(eq(issueComments.companyId, issue.companyId), eq(issueComments.issueId, issue.id))),
|
.where(and(
|
||||||
|
eq(issueComments.companyId, issue.companyId),
|
||||||
|
eq(issueComments.issueId, issue.id),
|
||||||
|
isNull(issueComments.deletedAt),
|
||||||
|
)),
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
targetId: documentRevisions.id,
|
targetId: documentRevisions.id,
|
||||||
|
|||||||
@@ -523,6 +523,7 @@ async function resolveRunScopedMentionedSkillKeys(input: {
|
|||||||
and(
|
and(
|
||||||
eq(issueComments.issueId, input.issueId),
|
eq(issueComments.issueId, input.issueId),
|
||||||
eq(issueComments.companyId, input.companyId),
|
eq(issueComments.companyId, input.companyId),
|
||||||
|
isNull(issueComments.deletedAt),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const mentionedSkillIds = extractMentionedSkillIdsFromSources([
|
const mentionedSkillIds = extractMentionedSkillIdsFromSources([
|
||||||
@@ -2013,7 +2014,7 @@ export function mergeCoalescedContextSnapshot(
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildPaperclipWakePayload(input: {
|
export async function buildPaperclipWakePayload(input: {
|
||||||
db: Db;
|
db: Db;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
contextSnapshot: Record<string, unknown>;
|
contextSnapshot: Record<string, unknown>;
|
||||||
@@ -2072,6 +2073,11 @@ async function buildPaperclipWakePayload(input: {
|
|||||||
authorUserId: issueComments.authorUserId,
|
authorUserId: issueComments.authorUserId,
|
||||||
presentation: issueComments.presentation,
|
presentation: issueComments.presentation,
|
||||||
metadata: issueComments.metadata,
|
metadata: issueComments.metadata,
|
||||||
|
deletedAt: issueComments.deletedAt,
|
||||||
|
deletedByType: issueComments.deletedByType,
|
||||||
|
deletedByAgentId: issueComments.deletedByAgentId,
|
||||||
|
deletedByUserId: issueComments.deletedByUserId,
|
||||||
|
deletedByRunId: issueComments.deletedByRunId,
|
||||||
createdAt: issueComments.createdAt,
|
createdAt: issueComments.createdAt,
|
||||||
})
|
})
|
||||||
.from(issueComments)
|
.from(issueComments)
|
||||||
@@ -2100,7 +2106,8 @@ async function buildPaperclipWakePayload(input: {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullBody = row.body;
|
const deletedAt = row.deletedAt ?? null;
|
||||||
|
const fullBody = deletedAt ? "" : row.body;
|
||||||
const allowedBodyChars = Math.min(MAX_INLINE_WAKE_COMMENT_BODY_CHARS, remainingBodyChars);
|
const allowedBodyChars = Math.min(MAX_INLINE_WAKE_COMMENT_BODY_CHARS, remainingBodyChars);
|
||||||
if (allowedBodyChars <= 0) {
|
if (allowedBodyChars <= 0) {
|
||||||
truncated = true;
|
truncated = true;
|
||||||
@@ -2118,8 +2125,13 @@ async function buildPaperclipWakePayload(input: {
|
|||||||
authorType: row.authorType ?? (row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"),
|
authorType: row.authorType ?? (row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"),
|
||||||
body,
|
body,
|
||||||
bodyTruncated,
|
bodyTruncated,
|
||||||
presentation: row.presentation ?? null,
|
presentation: deletedAt ? null : row.presentation ?? null,
|
||||||
metadata: row.metadata ?? null,
|
metadata: deletedAt ? null : row.metadata ?? null,
|
||||||
|
deletedAt: deletedAt ? deletedAt.toISOString() : null,
|
||||||
|
deletedByType: deletedAt ? row.deletedByType ?? null : null,
|
||||||
|
deletedByAgentId: deletedAt ? row.deletedByAgentId ?? null : null,
|
||||||
|
deletedByUserId: deletedAt ? row.deletedByUserId ?? null : null,
|
||||||
|
deletedByRunId: deletedAt ? row.deletedByRunId ?? null : null,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
author: row.authorAgentId
|
author: row.authorAgentId
|
||||||
? { type: "agent", id: row.authorAgentId }
|
? { type: "agent", id: row.authorAgentId }
|
||||||
@@ -6559,6 +6571,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
eq(issueComments.companyId, run.companyId),
|
eq(issueComments.companyId, run.companyId),
|
||||||
eq(issueComments.issueId, contextIssueId),
|
eq(issueComments.issueId, contextIssueId),
|
||||||
eq(issueComments.createdByRunId, run.id),
|
eq(issueComments.createdByRunId, run.id),
|
||||||
|
isNull(issueComments.deletedAt),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: [{ count: 0, latestAt: null }];
|
: [{ count: 0, latestAt: null }];
|
||||||
@@ -7094,6 +7107,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
authorUserId: issueComments.authorUserId,
|
authorUserId: issueComments.authorUserId,
|
||||||
presentation: issueComments.presentation,
|
presentation: issueComments.presentation,
|
||||||
metadata: issueComments.metadata,
|
metadata: issueComments.metadata,
|
||||||
|
deletedAt: issueComments.deletedAt,
|
||||||
|
deletedByType: issueComments.deletedByType,
|
||||||
|
deletedByAgentId: issueComments.deletedByAgentId,
|
||||||
|
deletedByUserId: issueComments.deletedByUserId,
|
||||||
|
deletedByRunId: issueComments.deletedByRunId,
|
||||||
})
|
})
|
||||||
.from(issueComments)
|
.from(issueComments)
|
||||||
.where(and(
|
.where(and(
|
||||||
@@ -7101,7 +7119,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
eq(issueComments.issueId, issueContext.id),
|
eq(issueComments.issueId, issueContext.id),
|
||||||
eq(issueComments.companyId, agent.companyId),
|
eq(issueComments.companyId, agent.companyId),
|
||||||
))
|
))
|
||||||
.then((rows) => rows[0] ?? null)
|
.then((rows) => {
|
||||||
|
const row = rows[0] ?? null;
|
||||||
|
return row?.deletedAt
|
||||||
|
? {
|
||||||
|
...row,
|
||||||
|
body: "",
|
||||||
|
presentation: null,
|
||||||
|
metadata: null,
|
||||||
|
}
|
||||||
|
: row;
|
||||||
|
})
|
||||||
: null;
|
: null;
|
||||||
const issueAssigneeOverrides =
|
const issueAssigneeOverrides =
|
||||||
issueContext && issueContext.assigneeAgentId === agent.id
|
issueContext && issueContext.assigneeAgentId === agent.id
|
||||||
|
|||||||
@@ -221,10 +221,11 @@ export function issueReferenceService(db: Db) {
|
|||||||
companyId: issueComments.companyId,
|
companyId: issueComments.companyId,
|
||||||
issueId: issueComments.issueId,
|
issueId: issueComments.issueId,
|
||||||
body: issueComments.body,
|
body: issueComments.body,
|
||||||
|
deletedAt: issueComments.deletedAt,
|
||||||
})
|
})
|
||||||
.from(issueComments)
|
.from(issueComments)
|
||||||
.where(eq(issueComments.id, commentId))
|
.where(eq(issueComments.id, commentId))
|
||||||
.then((rows: Array<{ id: string; companyId: string; issueId: string; body: string }>) => rows[0] ?? null);
|
.then((rows: Array<{ id: string; companyId: string; issueId: string; body: string; deletedAt: Date | null }>) => rows[0] ?? null);
|
||||||
if (!comment) throw notFound("Issue comment not found");
|
if (!comment) throw notFound("Issue comment not found");
|
||||||
|
|
||||||
await replaceSourceMentions({
|
await replaceSourceMentions({
|
||||||
@@ -233,7 +234,7 @@ export function issueReferenceService(db: Db) {
|
|||||||
sourceKind: "comment",
|
sourceKind: "comment",
|
||||||
sourceRecordId: comment.id,
|
sourceRecordId: comment.id,
|
||||||
documentKey: null,
|
documentKey: null,
|
||||||
text: comment.body,
|
text: comment.deletedAt ? null : comment.body,
|
||||||
}, dbOrTx);
|
}, dbOrTx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +298,12 @@ export function issueReferenceService(db: Db) {
|
|||||||
.where(and(eq(issueReferenceMentions.sourceKind, "document"), eq(issueReferenceMentions.sourceRecordId, documentId)));
|
.where(and(eq(issueReferenceMentions.sourceKind, "document"), eq(issueReferenceMentions.sourceRecordId, documentId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteCommentSource(commentId: string, dbOrTx: any = db) {
|
||||||
|
await dbOrTx
|
||||||
|
.delete(issueReferenceMentions)
|
||||||
|
.where(and(eq(issueReferenceMentions.sourceKind, "comment"), eq(issueReferenceMentions.sourceRecordId, commentId)));
|
||||||
|
}
|
||||||
|
|
||||||
async function syncAllForIssue(issueId: string, dbOrTx: any = db) {
|
async function syncAllForIssue(issueId: string, dbOrTx: any = db) {
|
||||||
const issue = await issueById(issueId, dbOrTx);
|
const issue = await issueById(issueId, dbOrTx);
|
||||||
if (!issue) throw notFound("Issue not found");
|
if (!issue) throw notFound("Issue not found");
|
||||||
@@ -429,6 +436,7 @@ export function issueReferenceService(db: Db) {
|
|||||||
syncAnnotationComment,
|
syncAnnotationComment,
|
||||||
syncDocument,
|
syncDocument,
|
||||||
deleteDocumentSource,
|
deleteDocumentSource,
|
||||||
|
deleteCommentSource,
|
||||||
syncAllForIssue,
|
syncAllForIssue,
|
||||||
syncAllForCompany,
|
syncAllForCompany,
|
||||||
listIssueReferenceSummary,
|
listIssueReferenceSummary,
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES = 2_000_000;
|
|||||||
const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000;
|
const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000;
|
||||||
const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000;
|
const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000;
|
||||||
const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS = 8;
|
const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS = 8;
|
||||||
|
const DELETED_ISSUE_COMMENT_BODY = "";
|
||||||
function assertTransition(from: string, to: string) {
|
function assertTransition(from: string, to: string) {
|
||||||
if (from === to) return;
|
if (from === to) return;
|
||||||
if (!ALL_ISSUE_STATUSES.includes(to)) {
|
if (!ALL_ISSUE_STATUSES.includes(to)) {
|
||||||
@@ -2957,6 +2958,7 @@ async function listBlockedInboxIssues(
|
|||||||
.where(and(
|
.where(and(
|
||||||
eq(issueComments.companyId, companyId),
|
eq(issueComments.companyId, companyId),
|
||||||
inArray(issueComments.issueId, issueIdChunk),
|
inArray(issueComments.issueId, issueIdChunk),
|
||||||
|
isNull(issueComments.deletedAt),
|
||||||
sql<boolean>`${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'`,
|
sql<boolean>`${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'`,
|
||||||
));
|
));
|
||||||
for (const row of rows as Array<{ issueId: string }>) commentSearchMatchIssueIds.add(row.issueId);
|
for (const row of rows as Array<{ issueId: string }>) commentSearchMatchIssueIds.add(row.issueId);
|
||||||
@@ -3032,6 +3034,7 @@ async function countBlockedInboxIssues(dbOrTx: any, companyId: string, filters?:
|
|||||||
.where(and(
|
.where(and(
|
||||||
eq(issueComments.companyId, companyId),
|
eq(issueComments.companyId, companyId),
|
||||||
inArray(issueComments.issueId, issueIdChunk),
|
inArray(issueComments.issueId, issueIdChunk),
|
||||||
|
isNull(issueComments.deletedAt),
|
||||||
sql<boolean>`${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'`,
|
sql<boolean>`${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'`,
|
||||||
));
|
));
|
||||||
for (const row of commentRows as Array<{ issueId: string }>) commentSearchMatchIssueIds.add(row.issueId);
|
for (const row of commentRows as Array<{ issueId: string }>) commentSearchMatchIssueIds.add(row.issueId);
|
||||||
@@ -3133,7 +3136,19 @@ export function issueService(db: Db) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function redactIssueComment<T extends { body: string; authorType?: string | null; authorAgentId?: string | null; authorUserId?: string | null; presentation?: unknown; metadata?: unknown }>(
|
function redactIssueComment<T extends {
|
||||||
|
body: string;
|
||||||
|
authorType?: string | null;
|
||||||
|
authorAgentId?: string | null;
|
||||||
|
authorUserId?: string | null;
|
||||||
|
presentation?: unknown;
|
||||||
|
metadata?: unknown;
|
||||||
|
deletedAt?: Date | string | null;
|
||||||
|
deletedByType?: "agent" | "user" | null;
|
||||||
|
deletedByAgentId?: string | null;
|
||||||
|
deletedByUserId?: string | null;
|
||||||
|
deletedByRunId?: string | null;
|
||||||
|
}>(
|
||||||
comment: T,
|
comment: T,
|
||||||
censorUsernameInLogs: boolean,
|
censorUsernameInLogs: boolean,
|
||||||
): T & {
|
): T & {
|
||||||
@@ -3141,6 +3156,22 @@ export function issueService(db: Db) {
|
|||||||
presentation: IssueCommentPresentation | null;
|
presentation: IssueCommentPresentation | null;
|
||||||
metadata: IssueCommentMetadata | null;
|
metadata: IssueCommentMetadata | null;
|
||||||
} {
|
} {
|
||||||
|
const deletedAt = comment.deletedAt ?? null;
|
||||||
|
if (deletedAt) {
|
||||||
|
return {
|
||||||
|
...comment,
|
||||||
|
authorType: deriveIssueCommentAuthorType(comment),
|
||||||
|
body: "",
|
||||||
|
presentation: null,
|
||||||
|
metadata: null,
|
||||||
|
deletedAt,
|
||||||
|
deletedByType: comment.deletedByType ?? null,
|
||||||
|
deletedByAgentId: comment.deletedByAgentId ?? null,
|
||||||
|
deletedByUserId: comment.deletedByUserId ?? null,
|
||||||
|
deletedByRunId: comment.deletedByRunId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...comment,
|
...comment,
|
||||||
authorType: deriveIssueCommentAuthorType(comment),
|
authorType: deriveIssueCommentAuthorType(comment),
|
||||||
@@ -3769,6 +3800,7 @@ export function issueService(db: Db) {
|
|||||||
FROM ${issueComments}
|
FROM ${issueComments}
|
||||||
WHERE ${issueComments.issueId} = ${issues.id}
|
WHERE ${issueComments.issueId} = ${issues.id}
|
||||||
AND ${issueComments.companyId} = ${companyId}
|
AND ${issueComments.companyId} = ${companyId}
|
||||||
|
AND ${issueComments.deletedAt} IS NULL
|
||||||
AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'
|
AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
@@ -4257,7 +4289,11 @@ export function issueService(db: Db) {
|
|||||||
createdAt: issueComments.createdAt,
|
createdAt: issueComments.createdAt,
|
||||||
})
|
})
|
||||||
.from(issueComments)
|
.from(issueComments)
|
||||||
.where(and(eq(issueComments.companyId, parent.companyId), inArray(issueComments.issueId, childIdsForSummaries)))
|
.where(and(
|
||||||
|
eq(issueComments.companyId, parent.companyId),
|
||||||
|
inArray(issueComments.issueId, childIdsForSummaries),
|
||||||
|
isNull(issueComments.deletedAt),
|
||||||
|
))
|
||||||
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
|
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
|
||||||
: [];
|
: [];
|
||||||
const latestCommentByIssueId = new Map<string, string>();
|
const latestCommentByIssueId = new Map<string, string>();
|
||||||
@@ -5689,6 +5725,48 @@ export function issueService(db: Db) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
tombstoneComment: async (
|
||||||
|
commentId: string,
|
||||||
|
actor: {
|
||||||
|
actorType: "agent" | "user";
|
||||||
|
agentId?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
|
runId?: string | null;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const currentUserRedactionOptions = {
|
||||||
|
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
||||||
|
};
|
||||||
|
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
const now = new Date();
|
||||||
|
const [comment] = await tx
|
||||||
|
.update(issueComments)
|
||||||
|
.set({
|
||||||
|
body: DELETED_ISSUE_COMMENT_BODY,
|
||||||
|
presentation: null,
|
||||||
|
metadata: null,
|
||||||
|
deletedAt: now,
|
||||||
|
deletedByType: actor.actorType,
|
||||||
|
deletedByAgentId: actor.actorType === "agent" ? actor.agentId ?? null : null,
|
||||||
|
deletedByUserId: actor.actorType === "user" ? actor.userId ?? null : null,
|
||||||
|
deletedByRunId: actor.runId ?? null,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(and(eq(issueComments.id, commentId), isNull(issueComments.deletedAt)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!comment) return null;
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(issues)
|
||||||
|
.set({ updatedAt: now })
|
||||||
|
.where(eq(issues.id, comment.issueId));
|
||||||
|
|
||||||
|
return redactIssueComment(comment, currentUserRedactionOptions.enabled);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
addComment: async (
|
addComment: async (
|
||||||
issueId: string,
|
issueId: string,
|
||||||
body: string,
|
body: string,
|
||||||
@@ -5948,7 +6026,7 @@ export function issueService(db: Db) {
|
|||||||
const comments = await db
|
const comments = await db
|
||||||
.select({ body: issueComments.body })
|
.select({ body: issueComments.body })
|
||||||
.from(issueComments)
|
.from(issueComments)
|
||||||
.where(eq(issueComments.issueId, issueId));
|
.where(and(eq(issueComments.issueId, issueId), isNull(issueComments.deletedAt)));
|
||||||
|
|
||||||
for (const comment of comments) {
|
for (const comment of comments) {
|
||||||
for (const projectId of extractProjectMentionIds(comment.body)) {
|
for (const projectId of extractProjectMentionIds(comment.body)) {
|
||||||
|
|||||||
@@ -258,6 +258,8 @@ export const issuesApi = {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
cancelComment: (id: string, commentId: string) =>
|
cancelComment: (id: string, commentId: string) =>
|
||||||
|
api.delete<IssueComment>(`/issues/${id}/comments/${commentId}?mode=cancel`),
|
||||||
|
deleteComment: (id: string, commentId: string) =>
|
||||||
api.delete<IssueComment>(`/issues/${id}/comments/${commentId}`),
|
api.delete<IssueComment>(`/issues/${id}/comments/${commentId}`),
|
||||||
listDocuments: (id: string, options?: { includeSystem?: boolean }) =>
|
listDocuments: (id: string, options?: { includeSystem?: boolean }) =>
|
||||||
api.get<IssueDocument[]>(
|
api.get<IssueDocument[]>(
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ function CommentCard({
|
|||||||
const isHighlighted = highlightCommentId === comment.id;
|
const isHighlighted = highlightCommentId === comment.id;
|
||||||
const isPending = comment.clientStatus === "pending";
|
const isPending = comment.clientStatus === "pending";
|
||||||
const isQueued = queued || comment.queueState === "queued" || comment.clientStatus === "queued";
|
const isQueued = queued || comment.queueState === "queued" || comment.clientStatus === "queued";
|
||||||
|
const isDeleted = Boolean(comment.deletedAt);
|
||||||
const followUpRequested = comment.followUpRequested === true;
|
const followUpRequested = comment.followUpRequested === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -352,10 +353,10 @@ function CommentCard({
|
|||||||
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${
|
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${
|
||||||
isQueued
|
isQueued
|
||||||
? "border-amber-300/70 bg-amber-50/70 dark:border-amber-500/40 dark:bg-amber-500/10"
|
? "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-primary/50 bg-primary/5"
|
||||||
: "border-border"
|
: "border-border"
|
||||||
} ${isPending ? "opacity-80" : ""}`}
|
} ${isPending ? "opacity-80" : ""} ${isDeleted ? "bg-muted/30 text-muted-foreground" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
{comment.authorAgentId ? (
|
{comment.authorAgentId ? (
|
||||||
@@ -379,7 +380,7 @@ function CommentCard({
|
|||||||
Follow-up
|
Follow-up
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{companyId && !isPending ? (
|
{companyId && !isPending && !isDeleted ? (
|
||||||
<PluginSlotOutlet
|
<PluginSlotOutlet
|
||||||
slotTypes={["commentContextMenuItem"]}
|
slotTypes={["commentContextMenuItem"]}
|
||||||
entityType="comment"
|
entityType="comment"
|
||||||
@@ -405,11 +406,15 @@ function CommentCard({
|
|||||||
{formatDateTime(comment.createdAt)}
|
{formatDateTime(comment.createdAt)}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<CopyMarkdownButton text={comment.body} />
|
{!isDeleted ? <CopyMarkdownButton text={comment.body} /> : null}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{isDeleted ? (
|
||||||
|
<div className="text-sm italic text-muted-foreground">Comment deleted</div>
|
||||||
|
) : (
|
||||||
<MarkdownBody className="text-sm" softBreaks>{comment.body}</MarkdownBody>
|
<MarkdownBody className="text-sm" softBreaks>{comment.body}</MarkdownBody>
|
||||||
{companyId && !isPending ? (
|
)}
|
||||||
|
{companyId && !isPending && !isDeleted ? (
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
<PluginSlotOutlet
|
<PluginSlotOutlet
|
||||||
slotTypes={["commentAnnotation"]}
|
slotTypes={["commentAnnotation"]}
|
||||||
@@ -427,7 +432,7 @@ function CommentCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{comment.authorAgentId && onVote && !isQueued && !isPending ? (
|
{comment.authorAgentId && onVote && !isQueued && !isPending && !isDeleted ? (
|
||||||
<OutputFeedbackButtons
|
<OutputFeedbackButtons
|
||||||
activeVote={feedbackVote}
|
activeVote={feedbackVote}
|
||||||
disabled={voting}
|
disabled={voting}
|
||||||
@@ -450,7 +455,7 @@ function CommentCard({
|
|||||||
) : undefined}
|
) : undefined}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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">
|
<div className="mt-3 pt-3 border-t border-border/60">
|
||||||
{comment.runAgentId ? (
|
{comment.runAgentId ? (
|
||||||
<Link
|
<Link
|
||||||
@@ -863,6 +868,15 @@ export function CommentThread({
|
|||||||
const hash = location.hash;
|
const hash = location.hash;
|
||||||
if (!hash.startsWith("#comment-") || comments.length + queuedComments.length === 0) return;
|
if (!hash.startsWith("#comment-") || comments.length + queuedComments.length === 0) return;
|
||||||
const commentId = hash.slice("#comment-".length);
|
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
|
// Only scroll once per hash
|
||||||
if (hasScrolledRef.current) return;
|
if (hasScrolledRef.current) return;
|
||||||
const el = document.getElementById(`comment-${commentId}`);
|
const el = document.getElementById(`comment-${commentId}`);
|
||||||
|
|||||||
@@ -1258,6 +1258,118 @@ 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 () => {});
|
||||||
|
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
|
||||||
|
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(confirmSpy).toHaveBeenCalled();
|
||||||
|
expect(onDeleteComment).toHaveBeenCalledWith("comment-owned");
|
||||||
|
|
||||||
|
confirmSpy.mockRestore();
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders deleted comments as tombstones without the original body", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
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("Comment deleted");
|
||||||
|
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();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("shows explicit follow-up badges and event copy", () => {
|
it("shows explicit follow-up badges and event copy", () => {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 { IssueBlockedNotice } from "./IssueBlockedNotice";
|
||||||
import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice";
|
import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice";
|
||||||
import { IssueRecoveryActionCard, type RecoveryResolveOutcome } from "./IssueRecoveryActionCard";
|
import { IssueRecoveryActionCard, type RecoveryResolveOutcome } from "./IssueRecoveryActionCard";
|
||||||
@@ -156,6 +156,7 @@ interface IssueChatMessageContext {
|
|||||||
stopRunVariant?: "stop" | "pause";
|
stopRunVariant?: "stop" | "pause";
|
||||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||||
onCancelQueued?: (commentId: string) => void;
|
onCancelQueued?: (commentId: string) => void;
|
||||||
|
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
||||||
onImageClick?: (src: string) => void;
|
onImageClick?: (src: string) => void;
|
||||||
onAcceptInteraction?: (
|
onAcceptInteraction?: (
|
||||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||||
@@ -353,6 +354,7 @@ interface IssueChatThreadProps {
|
|||||||
includeSucceededRunsWithoutOutput?: boolean;
|
includeSucceededRunsWithoutOutput?: boolean;
|
||||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||||
onCancelQueued?: (commentId: string) => void;
|
onCancelQueued?: (commentId: string) => void;
|
||||||
|
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
||||||
interruptingQueuedRunId?: string | null;
|
interruptingQueuedRunId?: string | null;
|
||||||
stoppingRunId?: string | null;
|
stoppingRunId?: string | null;
|
||||||
onImageClick?: (src: string) => void;
|
onImageClick?: (src: string) => void;
|
||||||
@@ -1271,6 +1273,7 @@ function IssueChatUserMessage({
|
|||||||
const {
|
const {
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
onCancelQueued,
|
onCancelQueued,
|
||||||
|
onDeleteComment,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
userProfileMap,
|
userProfileMap,
|
||||||
} = useContext(IssueChatCtx);
|
} = useContext(IssueChatCtx);
|
||||||
@@ -1284,6 +1287,7 @@ function IssueChatUserMessage({
|
|||||||
const queueReason = typeof custom.queueReason === "string" ? custom.queueReason : null;
|
const queueReason = typeof custom.queueReason === "string" ? custom.queueReason : null;
|
||||||
const queueBadgeLabel = queueReason === "hold" ? "\u23f8 Deferred wake" : "Queued";
|
const queueBadgeLabel = queueReason === "hold" ? "\u23f8 Deferred wake" : "Queued";
|
||||||
const pending = custom.clientStatus === "pending";
|
const pending = custom.clientStatus === "pending";
|
||||||
|
const deleted = Boolean(custom.deletedAt);
|
||||||
const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null;
|
const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null;
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const {
|
const {
|
||||||
@@ -1302,6 +1306,13 @@ function IssueChatUserMessage({
|
|||||||
<AvatarFallback>{initialsForName(resolvedAuthorName)}</AvatarFallback>
|
<AvatarFallback>{initialsForName(resolvedAuthorName)}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
|
const canDeleteComment = Boolean(onDeleteComment && isCurrentUser && !queued && !pending && !deleted);
|
||||||
|
const handleDeleteComment = () => {
|
||||||
|
if (!canDeleteComment) return;
|
||||||
|
const confirmed = window.confirm("Delete this comment? This will replace it with a deleted-comment marker.");
|
||||||
|
if (!confirmed) return;
|
||||||
|
void onDeleteComment?.(commentId);
|
||||||
|
};
|
||||||
const messageBody = (
|
const messageBody = (
|
||||||
<div className={cn("flex min-w-0 max-w-[85%] flex-col", isCurrentUser && "items-end")}>
|
<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")}>
|
<div className={cn("mb-1 flex items-center gap-2 px-1", isCurrentUser ? "justify-end" : "justify-start")}>
|
||||||
@@ -1317,6 +1328,8 @@ function IssueChatUserMessage({
|
|||||||
"min-w-0 max-w-full overflow-hidden break-all rounded-2xl px-4 py-2.5",
|
"min-w-0 max-w-full overflow-hidden break-all rounded-2xl px-4 py-2.5",
|
||||||
queued
|
queued
|
||||||
? "bg-amber-50/80 dark:bg-amber-500/10"
|
? "bg-amber-50/80 dark:bg-amber-500/10"
|
||||||
|
: deleted
|
||||||
|
? "bg-muted/50 text-muted-foreground"
|
||||||
: "bg-muted",
|
: "bg-muted",
|
||||||
pending && "opacity-80",
|
pending && "opacity-80",
|
||||||
)}
|
)}
|
||||||
@@ -1349,9 +1362,13 @@ function IssueChatUserMessage({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{deleted ? (
|
||||||
|
<div className="text-sm italic text-muted-foreground">Comment deleted</div>
|
||||||
|
) : (
|
||||||
<div className="min-w-0 max-w-full space-y-3">
|
<div className="min-w-0 max-w-full space-y-3">
|
||||||
<IssueChatTextParts message={message} />
|
<IssueChatTextParts message={message} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pending ? (
|
{pending ? (
|
||||||
@@ -1378,6 +1395,7 @@ function IssueChatUserMessage({
|
|||||||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{!deleted ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||||
@@ -1396,6 +1414,18 @@ function IssueChatUserMessage({
|
|||||||
>
|
>
|
||||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1464,11 +1494,12 @@ function IssueChatAssistantMessage({
|
|||||||
const canStopRun = Boolean(runId) && (isRunActive || runStatus === "queued" || runStatus === "running");
|
const canStopRun = Boolean(runId) && (isRunActive || runStatus === "queued" || runStatus === "running");
|
||||||
const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null;
|
const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null;
|
||||||
const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call");
|
const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call");
|
||||||
|
const deleted = Boolean(custom.deletedAt);
|
||||||
const isFoldable = !isRunning && !!chainOfThoughtLabel;
|
const isFoldable = !isRunning && !!chainOfThoughtLabel;
|
||||||
const [folded, setFolded] = useState(isFoldable);
|
const [folded, setFolded] = useState(isFoldable);
|
||||||
const [prevFoldKey, setPrevFoldKey] = useState({ messageId: message.id, isFoldable });
|
const [prevFoldKey, setPrevFoldKey] = useState({ messageId: message.id, isFoldable });
|
||||||
const [copied, setCopied] = useState(false);
|
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
|
// Derive fold state synchronously during render (not in useEffect) so the
|
||||||
// browser never paints the un-folded intermediate state — prevents the
|
// browser never paints the un-folded intermediate state — prevents the
|
||||||
@@ -1543,7 +1574,11 @@ function IssueChatAssistantMessage({
|
|||||||
</div>
|
</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">
|
<div className="space-y-3">
|
||||||
<IssueChatAssistantParts message={message} hasCoT={hasCoT} />
|
<IssueChatAssistantParts message={message} hasCoT={hasCoT} />
|
||||||
@@ -2614,6 +2649,16 @@ function issueChatMessageQueuedRunIsInterrupting(
|
|||||||
return Boolean(queueTargetRunId && interruptingQueuedRunId === queueTargetRunId);
|
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
|
// Above ~150 merged rows the direct render path forces React to mount and
|
||||||
// re-render hundreds of Markdown bodies, feedback controls, and avatars on
|
// re-render hundreds of Markdown bodies, feedback controls, and avatars on
|
||||||
// unrelated parent updates. Above this threshold we switch to a windowed
|
// unrelated parent updates. Above this threshold we switch to a windowed
|
||||||
@@ -3045,6 +3090,33 @@ interface IssueChatMessageRowProps {
|
|||||||
interruptingQueuedRunId?: string | null;
|
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({
|
const IssueChatMessageRow = memo(function IssueChatMessageRow({
|
||||||
message,
|
message,
|
||||||
feedbackVoteByTargetId,
|
feedbackVoteByTargetId,
|
||||||
@@ -3053,11 +3125,14 @@ const IssueChatMessageRow = memo(function IssueChatMessageRow({
|
|||||||
interruptingQueuedRunId,
|
interruptingQueuedRunId,
|
||||||
}: IssueChatMessageRowProps) {
|
}: IssueChatMessageRowProps) {
|
||||||
const kind = issueChatMessageKind(message);
|
const kind = issueChatMessageKind(message);
|
||||||
|
const deletedAt = issueChatMessageDeletedAt(message);
|
||||||
const activeVote = issueChatMessageActiveVote(message, feedbackVoteByTargetId);
|
const activeVote = issueChatMessageActiveVote(message, feedbackVoteByTargetId);
|
||||||
const isRunActive = issueChatMessageRunIsActive(message, activeRunIds);
|
const isRunActive = issueChatMessageRunIsActive(message, activeRunIds);
|
||||||
const isStoppingRun = issueChatMessageRunIsStopping(message, stoppingRunId);
|
const isStoppingRun = issueChatMessageRunIsStopping(message, stoppingRunId);
|
||||||
const isInterruptingQueuedRun = issueChatMessageQueuedRunIsInterrupting(message, interruptingQueuedRunId);
|
const isInterruptingQueuedRun = issueChatMessageQueuedRunIsInterrupting(message, interruptingQueuedRunId);
|
||||||
const renderedMessage = message.role === "user"
|
const renderedMessage = deletedAt
|
||||||
|
? <IssueChatDeletedComment message={message} deletedAt={deletedAt} />
|
||||||
|
: message.role === "user"
|
||||||
? (
|
? (
|
||||||
<IssueChatUserMessage
|
<IssueChatUserMessage
|
||||||
message={message}
|
message={message}
|
||||||
@@ -3664,6 +3739,7 @@ export function IssueChatThread({
|
|||||||
includeSucceededRunsWithoutOutput = false,
|
includeSucceededRunsWithoutOutput = false,
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
onCancelQueued,
|
onCancelQueued,
|
||||||
|
onDeleteComment,
|
||||||
interruptingQueuedRunId = null,
|
interruptingQueuedRunId = null,
|
||||||
stoppingRunId = null,
|
stoppingRunId = null,
|
||||||
onImageClick,
|
onImageClick,
|
||||||
@@ -3937,6 +4013,16 @@ export function IssueChatThread({
|
|||||||
) return;
|
) return;
|
||||||
if (messages.length === 0 || lastScrolledHashRef.current === hash) return;
|
if (messages.length === 0 || lastScrolledHashRef.current === hash) return;
|
||||||
const targetId = hash.slice(1);
|
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;
|
let cancelled = false;
|
||||||
const attemptScroll = (finalAttempt = false) => {
|
const attemptScroll = (finalAttempt = false) => {
|
||||||
if (cancelled || lastScrolledHashRef.current === hash) return;
|
if (cancelled || lastScrolledHashRef.current === hash) return;
|
||||||
@@ -4129,6 +4215,7 @@ export function IssueChatThread({
|
|||||||
const stableOnStopRun = useStableEvent(onStopRun);
|
const stableOnStopRun = useStableEvent(onStopRun);
|
||||||
const stableOnInterruptQueued = useStableEvent(onInterruptQueued);
|
const stableOnInterruptQueued = useStableEvent(onInterruptQueued);
|
||||||
const stableOnCancelQueued = useStableEvent(onCancelQueued);
|
const stableOnCancelQueued = useStableEvent(onCancelQueued);
|
||||||
|
const stableOnDeleteComment = useStableEvent(onDeleteComment);
|
||||||
const stableOnImageClick = useStableEvent(onImageClick);
|
const stableOnImageClick = useStableEvent(onImageClick);
|
||||||
const stableOnAcceptInteraction = useStableEvent(onAcceptInteraction);
|
const stableOnAcceptInteraction = useStableEvent(onAcceptInteraction);
|
||||||
const stableOnRejectInteraction = useStableEvent(onRejectInteraction);
|
const stableOnRejectInteraction = useStableEvent(onRejectInteraction);
|
||||||
@@ -4150,6 +4237,7 @@ export function IssueChatThread({
|
|||||||
stopRunVariant,
|
stopRunVariant,
|
||||||
onInterruptQueued: stableOnInterruptQueued,
|
onInterruptQueued: stableOnInterruptQueued,
|
||||||
onCancelQueued: stableOnCancelQueued,
|
onCancelQueued: stableOnCancelQueued,
|
||||||
|
onDeleteComment: stableOnDeleteComment,
|
||||||
onImageClick: stableOnImageClick,
|
onImageClick: stableOnImageClick,
|
||||||
onAcceptInteraction: stableOnAcceptInteraction,
|
onAcceptInteraction: stableOnAcceptInteraction,
|
||||||
onRejectInteraction: stableOnRejectInteraction,
|
onRejectInteraction: stableOnRejectInteraction,
|
||||||
@@ -4172,6 +4260,7 @@ export function IssueChatThread({
|
|||||||
stopRunVariant,
|
stopRunVariant,
|
||||||
stableOnInterruptQueued,
|
stableOnInterruptQueued,
|
||||||
stableOnCancelQueued,
|
stableOnCancelQueued,
|
||||||
|
stableOnDeleteComment,
|
||||||
stableOnImageClick,
|
stableOnImageClick,
|
||||||
stableOnAcceptInteraction,
|
stableOnAcceptInteraction,
|
||||||
stableOnRejectInteraction,
|
stableOnRejectInteraction,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
|||||||
"issue.released": "released",
|
"issue.released": "released",
|
||||||
"issue.comment_added": "commented on",
|
"issue.comment_added": "commented on",
|
||||||
"issue.comment_cancelled": "cancelled a queued comment on",
|
"issue.comment_cancelled": "cancelled a queued comment on",
|
||||||
|
"issue.comment_deleted": "deleted a comment on",
|
||||||
"issue.attachment_added": "attached file to",
|
"issue.attachment_added": "attached file to",
|
||||||
"issue.attachment_removed": "removed attachment from",
|
"issue.attachment_removed": "removed attachment from",
|
||||||
"issue.document_created": "created document for",
|
"issue.document_created": "created document for",
|
||||||
@@ -89,6 +90,7 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
|||||||
"issue.released": "released the issue",
|
"issue.released": "released the issue",
|
||||||
"issue.comment_added": "added a comment",
|
"issue.comment_added": "added a comment",
|
||||||
"issue.comment_cancelled": "cancelled a queued comment",
|
"issue.comment_cancelled": "cancelled a queued comment",
|
||||||
|
"issue.comment_deleted": "deleted a comment",
|
||||||
"issue.feedback_vote_saved": "saved feedback on an AI output",
|
"issue.feedback_vote_saved": "saved feedback on an AI output",
|
||||||
"issue.attachment_added": "added an attachment",
|
"issue.attachment_added": "added an attachment",
|
||||||
"issue.attachment_removed": "removed an attachment",
|
"issue.attachment_removed": "removed an attachment",
|
||||||
|
|||||||
@@ -408,14 +408,20 @@ function createCommentMessage(args: {
|
|||||||
followUpRequested: comment.followUpRequested === true,
|
followUpRequested: comment.followUpRequested === true,
|
||||||
presentation: comment.presentation ?? null,
|
presentation: comment.presentation ?? null,
|
||||||
commentMetadata: comment.metadata ?? null,
|
commentMetadata: comment.metadata ?? null,
|
||||||
|
deletedAt: comment.deletedAt ? toDate(comment.deletedAt).toISOString() : null,
|
||||||
|
deletedByType: comment.deletedByType ?? null,
|
||||||
|
deletedByAgentId: comment.deletedByAgentId ?? null,
|
||||||
|
deletedByUserId: comment.deletedByUserId ?? null,
|
||||||
|
deletedByRunId: comment.deletedByRunId ?? null,
|
||||||
};
|
};
|
||||||
|
const contentText = comment.deletedAt ? "" : comment.body;
|
||||||
|
|
||||||
if (isSystemNotice) {
|
if (isSystemNotice) {
|
||||||
const message: ThreadSystemMessage = {
|
const message: ThreadSystemMessage = {
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
role: "system",
|
role: "system",
|
||||||
createdAt,
|
createdAt,
|
||||||
content: [{ type: "text", text: comment.body }],
|
content: [{ type: "text", text: contentText }],
|
||||||
metadata: { custom },
|
metadata: { custom },
|
||||||
};
|
};
|
||||||
return message;
|
return message;
|
||||||
@@ -426,7 +432,7 @@ function createCommentMessage(args: {
|
|||||||
id: comment.id,
|
id: comment.id,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
createdAt,
|
createdAt,
|
||||||
content: [{ type: "text", text: comment.body }],
|
content: [{ type: "text", text: contentText }],
|
||||||
status: { type: "complete", reason: "stop" },
|
status: { type: "complete", reason: "stop" },
|
||||||
metadata: createAssistantMetadata(custom),
|
metadata: createAssistantMetadata(custom),
|
||||||
};
|
};
|
||||||
@@ -437,7 +443,7 @@ function createCommentMessage(args: {
|
|||||||
id: comment.id,
|
id: comment.id,
|
||||||
role: "user",
|
role: "user",
|
||||||
createdAt,
|
createdAt,
|
||||||
content: [{ type: "text", text: comment.body }],
|
content: [{ type: "text", text: contentText }],
|
||||||
attachments: [],
|
attachments: [],
|
||||||
metadata: { custom },
|
metadata: { custom },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -664,6 +664,7 @@ type IssueDetailChatTabProps = {
|
|||||||
onImageUpload: (file: File) => Promise<string>;
|
onImageUpload: (file: File) => Promise<string>;
|
||||||
onAttachImage: (file: File) => Promise<IssueAttachment | void>;
|
onAttachImage: (file: File) => Promise<IssueAttachment | void>;
|
||||||
onInterruptQueued: (runId: string) => Promise<void>;
|
onInterruptQueued: (runId: string) => Promise<void>;
|
||||||
|
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
||||||
onPauseWorkRun?: (runId: string) => Promise<void>;
|
onPauseWorkRun?: (runId: string) => Promise<void>;
|
||||||
onCancelQueued: (commentId: string) => void;
|
onCancelQueued: (commentId: string) => void;
|
||||||
interruptingQueuedRunId: string | null;
|
interruptingQueuedRunId: string | null;
|
||||||
@@ -729,6 +730,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||||||
onImageUpload,
|
onImageUpload,
|
||||||
onAttachImage,
|
onAttachImage,
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
|
onDeleteComment,
|
||||||
onPauseWorkRun,
|
onPauseWorkRun,
|
||||||
onCancelQueued,
|
onCancelQueued,
|
||||||
interruptingQueuedRunId,
|
interruptingQueuedRunId,
|
||||||
@@ -932,6 +934,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||||||
imageUploadHandler={onImageUpload}
|
imageUploadHandler={onImageUpload}
|
||||||
onAttachImage={onAttachImage}
|
onAttachImage={onAttachImage}
|
||||||
onInterruptQueued={onInterruptQueued}
|
onInterruptQueued={onInterruptQueued}
|
||||||
|
onDeleteComment={onDeleteComment}
|
||||||
onCancelQueued={onCancelQueued}
|
onCancelQueued={onCancelQueued}
|
||||||
interruptingQueuedRunId={interruptingQueuedRunId}
|
interruptingQueuedRunId={interruptingQueuedRunId}
|
||||||
stoppingRunId={pausingWorkRunId}
|
stoppingRunId={pausingWorkRunId}
|
||||||
@@ -1656,6 +1659,11 @@ export function IssueDetail() {
|
|||||||
}
|
}
|
||||||
}, [issueCacheRefs, queryClient]);
|
}, [issueCacheRefs, queryClient]);
|
||||||
|
|
||||||
|
const invalidateIssueDocumentAnnotationState = useCallback(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["issues", "document-annotations", issueId!] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId!) });
|
||||||
|
}, [issueId, queryClient]);
|
||||||
|
|
||||||
const removeCommentFromCache = useCallback((commentId: string) => {
|
const removeCommentFromCache = useCallback((commentId: string) => {
|
||||||
queryClient.setQueryData<InfiniteData<IssueComment[], string | null> | undefined>(
|
queryClient.setQueryData<InfiniteData<IssueComment[], string | null> | undefined>(
|
||||||
queryKeys.issues.comments(issueId!),
|
queryKeys.issues.comments(issueId!),
|
||||||
@@ -1669,6 +1677,24 @@ export function IssueDetail() {
|
|||||||
);
|
);
|
||||||
}, [issueId, queryClient]);
|
}, [issueId, queryClient]);
|
||||||
|
|
||||||
|
const clearCommentHashIfCurrent = useCallback((commentId: string) => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (window.location.hash !== `#comment-${commentId}`) return;
|
||||||
|
window.history.replaceState(null, "", `${location.pathname}${location.search}`);
|
||||||
|
}, [location.pathname, location.search]);
|
||||||
|
|
||||||
|
const upsertCommentInCache = useCallback((comment: IssueComment) => {
|
||||||
|
for (const ref of issueCacheRefs) {
|
||||||
|
queryClient.setQueryData<InfiniteData<IssueComment[], string | null> | undefined>(
|
||||||
|
queryKeys.issues.comments(ref),
|
||||||
|
(current) => current ? {
|
||||||
|
...current,
|
||||||
|
pages: upsertIssueCommentInPages(current.pages, comment),
|
||||||
|
} : current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [issueCacheRefs, queryClient]);
|
||||||
|
|
||||||
const restoreQueuedCommentDraft = useCallback((body: string) => {
|
const restoreQueuedCommentDraft = useCallback((body: string) => {
|
||||||
commentComposerRef.current?.restoreDraft(body);
|
commentComposerRef.current?.restoreDraft(body);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -2435,6 +2461,20 @@ export function IssueDetail() {
|
|||||||
const cancelQueuedComment = useMutation({
|
const cancelQueuedComment = useMutation({
|
||||||
mutationFn: async ({ commentId }: { commentId: string }) => issuesApi.cancelComment(issueId!, commentId),
|
mutationFn: async ({ commentId }: { commentId: string }) => issuesApi.cancelComment(issueId!, commentId),
|
||||||
onSuccess: (comment) => {
|
onSuccess: (comment) => {
|
||||||
|
if (comment.deletedAt) {
|
||||||
|
upsertCommentInCache(comment);
|
||||||
|
clearCommentHashIfCurrent(comment.id);
|
||||||
|
invalidateIssueDetail();
|
||||||
|
invalidateIssueThreadLazily();
|
||||||
|
invalidateIssueCollections();
|
||||||
|
invalidateIssueDocumentAnnotationState();
|
||||||
|
pushToast({
|
||||||
|
title: "Comment deleted",
|
||||||
|
body: "The comment was replaced with a deleted marker.",
|
||||||
|
tone: "success",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLocallyQueuedCommentRunIds((current) => {
|
setLocallyQueuedCommentRunIds((current) => {
|
||||||
if (!current.has(comment.id)) return current;
|
if (!current.has(comment.id)) return current;
|
||||||
const next = new Map(current);
|
const next = new Map(current);
|
||||||
@@ -2461,6 +2501,30 @@ export function IssueDetail() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteComment = useMutation({
|
||||||
|
mutationFn: async ({ commentId }: { commentId: string }) => issuesApi.deleteComment(issueId!, commentId),
|
||||||
|
onSuccess: (comment) => {
|
||||||
|
upsertCommentInCache(comment);
|
||||||
|
clearCommentHashIfCurrent(comment.id);
|
||||||
|
invalidateIssueDetail();
|
||||||
|
invalidateIssueThreadLazily();
|
||||||
|
invalidateIssueCollections();
|
||||||
|
invalidateIssueDocumentAnnotationState();
|
||||||
|
pushToast({
|
||||||
|
title: "Comment deleted",
|
||||||
|
body: "The thread now shows a deleted-comment marker.",
|
||||||
|
tone: "success",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
pushToast({
|
||||||
|
title: "Delete failed",
|
||||||
|
body: err instanceof Error ? err.message : "Unable to delete the comment",
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleCancelQueuedComment = useCallback((commentId: string) => {
|
const handleCancelQueuedComment = useCallback((commentId: string) => {
|
||||||
if (commentId.startsWith("optimistic-")) {
|
if (commentId.startsWith("optimistic-")) {
|
||||||
cancelledQueuedOptimisticCommentIdsRef.current.add(commentId);
|
cancelledQueuedOptimisticCommentIdsRef.current.add(commentId);
|
||||||
@@ -3886,6 +3950,7 @@ export function IssueDetail() {
|
|||||||
onImageUpload={handleCommentImageUpload}
|
onImageUpload={handleCommentImageUpload}
|
||||||
onAttachImage={handleCommentAttachImage}
|
onAttachImage={handleCommentAttachImage}
|
||||||
onInterruptQueued={handleInterruptQueuedRun}
|
onInterruptQueued={handleInterruptQueuedRun}
|
||||||
|
onDeleteComment={(commentId) => deleteComment.mutateAsync({ commentId }).then(() => undefined)}
|
||||||
onPauseWorkRun={canManageTreeControl
|
onPauseWorkRun={canManageTreeControl
|
||||||
? (runId) => pauseIssueWorkRun.mutateAsync({ runId, scope: treeControlScope }).then(() => undefined)
|
? (runId) => pauseIssueWorkRun.mutateAsync({ runId, scope: treeControlScope }).then(() => undefined)
|
||||||
: undefined}
|
: undefined}
|
||||||
|
|||||||
Reference in New Issue
Block a user