diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 02235f39afc..1d2029ea458 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -12,7 +12,7 @@ import { stringifyPaperclipWakePayload, } from "@paperclipai/adapter-utils/server-utils"; import crypto, { randomUUID } from "node:crypto"; -import { WebSocket } from "ws"; +import WebSocket from "ws"; type SessionKeyStrategy = "fixed" | "issue" | "run"; diff --git a/packages/adapters/openclaw-gateway/src/server/test.ts b/packages/adapters/openclaw-gateway/src/server/test.ts index af4c74d197f..63978ed8c82 100644 --- a/packages/adapters/openclaw-gateway/src/server/test.ts +++ b/packages/adapters/openclaw-gateway/src/server/test.ts @@ -5,7 +5,7 @@ import type { } from "@paperclipai/adapter-utils"; import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; import { randomUUID } from "node:crypto"; -import { WebSocket } from "ws"; +import WebSocket from "ws"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { if (checks.some((check) => check.level === "error")) return "fail"; diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 1a4d2f9661d..b95fb875067 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -424,6 +424,185 @@ describe("heartbeat comment wake batching", () => { } }, 120_000); + it("promotes deferred comment wakes after the active run closes the issue", async () => { + const gateway = await createControlledGatewayServer(); + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const heartbeat = heartbeatService(db); + + try { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Gateway Agent", + role: "engineer", + status: "idle", + adapterType: "openclaw_gateway", + adapterConfig: { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2_000, + }, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Reopen after deferred comment", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + + const comment1 = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "First comment", + }) + .returning() + .then((rows) => rows[0]); + + const firstRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId, commentId: comment1.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: comment1.id, + wakeReason: "issue_commented", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + + expect(firstRun).not.toBeNull(); + await waitFor(() => gateway.getAgentPayloads().length === 1); + + const comment2 = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "Please handle this follow-up after you finish", + }) + .returning() + .then((rows) => rows[0]); + + const deferredRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId, commentId: comment2.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: comment2.id, + wakeReason: "issue_commented", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + + expect(deferredRun).toBeNull(); + + await waitFor(async () => { + const deferred = await db + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.agentId, agentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + ), + ) + .then((rows) => rows[0] ?? null); + return Boolean(deferred); + }); + + await db + .update(issues) + .set({ + status: "done", + completedAt: new Date(), + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: new Date(), + }) + .where(eq(issues.id, issueId)); + + gateway.releaseFirstWait(); + + await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000); + await waitFor(async () => { + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + return runs.length === 2 && runs.every((run) => run.status === "succeeded"); + }, 90_000); + + const reopenedIssue = await db + .select({ + status: issues.status, + completedAt: issues.completedAt, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + + expect(reopenedIssue).toMatchObject({ + status: "todo", + completedAt: null, + }); + + const secondPayload = gateway.getAgentPayloads()[1] ?? {}; + expect(secondPayload.paperclip).toMatchObject({ + wake: { + reason: "issue_commented", + commentIds: [comment2.id], + latestCommentId: comment2.id, + issue: { + id: issueId, + identifier: `${issuePrefix}-1`, + title: "Reopen after deferred comment", + status: "todo", + priority: "medium", + }, + }, + }); + expect(String(secondPayload.message ?? "")).toContain("Please handle this follow-up after you finish"); + } finally { + gateway.releaseFirstWait(); + await gateway.close(); + } + }, 120_000); + it("queues exactly one follow-up run when an issue-bound run exits without a comment", async () => { const gateway = await createControlledGatewayServer(); const companyId = randomUUID(); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 78b75d3a974..dabde637d7b 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -4,9 +4,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), + getComment: vi.fn(), assertCheckoutOwner: vi.fn(), update: vi.fn(), addComment: vi.fn(), + deleteComment: vi.fn(), findMentionedAgents: vi.fn(), listWakeableBlockedDependents: vi.fn(), getWakeableParentAfterChildCompletion: vi.fn(), @@ -137,9 +139,11 @@ describe("issue comment reopen routes", () => { vi.resetModules(); vi.resetAllMocks(); mockIssueService.getById.mockReset(); + mockIssueService.getComment.mockReset(); mockIssueService.assertCheckoutOwner.mockReset(); mockIssueService.update.mockReset(); mockIssueService.addComment.mockReset(); + mockIssueService.deleteComment.mockReset(); mockIssueService.findMentionedAgents.mockReset(); mockIssueService.listWakeableBlockedDependents.mockReset(); mockIssueService.getWakeableParentAfterChildCompletion.mockReset(); @@ -194,6 +198,26 @@ describe("issue comment reopen routes", () => { authorAgentId: null, authorUserId: "local-board", }); + mockIssueService.getComment.mockResolvedValue({ + id: "comment-1", + issueId: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + body: "hello", + createdAt: new Date(), + updatedAt: new Date(), + authorAgentId: null, + authorUserId: "local-board", + }); + mockIssueService.deleteComment.mockResolvedValue({ + id: "comment-1", + issueId: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + body: "hello", + createdAt: new Date(), + updatedAt: new Date(), + authorAgentId: null, + authorUserId: "local-board", + }); mockIssueService.findMentionedAgents.mockResolvedValue([]); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); @@ -301,6 +325,27 @@ describe("issue comment reopen routes", () => { ); }); + it("lets board users delete their own queued comments", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("todo")); + + const res = await request(await installActor(createApp())) + .delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1"); + + expect(res.status).toBe(200); + expect(mockIssueService.getComment).toHaveBeenCalledWith("comment-1"); + expect(mockIssueService.deleteComment).toHaveBeenCalledWith("comment-1"); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.comment_deleted", + details: expect.objectContaining({ + commentId: "comment-1", + identifier: "PAP-580", + }), + }), + ); + }); + it("writes decision ids into executionState and inserts the decision inside the transaction", async () => { const policy = await normalizePolicy({ stages: [ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 6ffd524e660..39779dba19b 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -2069,6 +2069,58 @@ export function issueRoutes( res.json(comment); }); + router.delete("/issues/:id/comments/:commentId", async (req, res) => { + const id = req.params.id as string; + const commentId = req.params.commentId as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (req.actor.type !== "board") { + res.status(403).json({ error: "Only board users can delete comments" }); + return; + } + + const comment = await svc.getComment(commentId); + if (!comment || comment.issueId !== id) { + res.status(404).json({ error: "Comment not found" }); + return; + } + + const actor = getActorInfo(req); + if (comment.authorUserId !== actor.actorId) { + res.status(403).json({ error: "You can only delete your own comments" }); + return; + } + + const deletedComment = await svc.deleteComment(commentId); + if (!deletedComment) { + res.status(404).json({ error: "Comment not found" }); + return; + } + + 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: deletedComment.id, + bodySnippet: deletedComment.body.slice(0, 120), + identifier: issue.identifier, + issueTitle: issue.title, + }, + }); + + res.json(deletedComment); + }); + router.get("/issues/:id/feedback-votes", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 20cca465fed..f6c8dd81b96 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -6,6 +6,7 @@ import { and, asc, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import type { BillingType, ExecutionWorkspace, ExecutionWorkspaceConfig } from "@paperclipai/shared"; import { + activityLog, agents, agentRuntimeState, agentTaskSessions, @@ -3579,31 +3580,50 @@ export function heartbeatService(db: Db) { } async function releaseIssueExecutionAndPromote(run: typeof heartbeatRuns.$inferSelect) { + const runContext = parseObject(run.contextSnapshot); + const contextIssueId = readNonEmptyString(runContext.issueId); const promotedRun = await db.transaction(async (tx) => { - await tx.execute( - sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`, - ); + if (contextIssueId) { + await tx.execute( + sql`select id from issues where company_id = ${run.companyId} and id = ${contextIssueId} for update`, + ); + } else { + await tx.execute( + sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`, + ); + } - const issue = await tx + let issue = await tx .select({ id: issues.id, companyId: issues.companyId, + identifier: issues.identifier, + status: issues.status, + executionRunId: issues.executionRunId, }) .from(issues) - .where(and(eq(issues.companyId, run.companyId), eq(issues.executionRunId, run.id))) + .where( + and( + eq(issues.companyId, run.companyId), + contextIssueId ? eq(issues.id, contextIssueId) : eq(issues.executionRunId, run.id), + ), + ) .then((rows) => rows[0] ?? null); if (!issue) return; + if (issue.executionRunId && issue.executionRunId !== run.id) return null; - await tx - .update(issues) - .set({ - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - updatedAt: new Date(), - }) - .where(eq(issues.id, issue.id)); + if (issue.executionRunId === run.id) { + await tx + .update(issues) + .set({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: new Date(), + }) + .where(eq(issues.id, issue.id)); + } while (true) { const deferred = await tx @@ -3650,6 +3670,50 @@ export function heartbeatService(db: Db) { const deferredPayload = parseObject(deferred.payload); const deferredContextSeed = parseObject(deferredPayload[DEFERRED_WAKE_CONTEXT_KEY]); const promotedContextSeed: Record = { ...deferredContextSeed }; + const deferredCommentIds = extractWakeCommentIds(deferredContextSeed); + const shouldReopenDeferredCommentWake = + deferredCommentIds.length > 0 && (issue.status === "done" || issue.status === "cancelled"); + + if (shouldReopenDeferredCommentWake) { + const reopenedFromStatus = issue.status; + const reopenedIssue = await issuesSvc.update( + issue.id, + { + status: "todo", + executionState: null, + }, + tx, + ); + if (reopenedIssue) { + issue = { + ...issue, + identifier: reopenedIssue.identifier, + status: reopenedIssue.status, + executionRunId: reopenedIssue.executionRunId, + }; + if (!readNonEmptyString(promotedContextSeed.reopenedFrom)) { + promotedContextSeed.reopenedFrom = reopenedFromStatus; + } + await tx.insert(activityLog).values({ + companyId: issue.companyId, + actorType: "system", + actorId: "heartbeat", + agentId: deferred.agentId, + runId: run.id, + action: "issue.updated", + entityType: "issue", + entityId: issue.id, + details: { + status: "todo", + reopened: true, + reopenedFrom: reopenedFromStatus, + source: "deferred_comment_wake", + identifier: issue.identifier, + }, + }); + } + } + const promotedReason = readNonEmptyString(deferred.reason) ?? "issue_execution_promoted"; const promotedSource = (readNonEmptyString(deferred.source) as WakeupOptions["source"]) ?? "automation"; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index f7ac19da0df..30918983653 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -2112,6 +2112,25 @@ export function issueService(db: Db) { return comment ? redactIssueComment(comment, censorUsernameInLogs) : null; })), + deleteComment: async (commentId: string) => { + const currentUserRedactionOptions = { + enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, + }; + const [comment] = await db + .delete(issueComments) + .where(eq(issueComments.id, commentId)) + .returning(); + + if (!comment) return null; + + await db + .update(issues) + .set({ updatedAt: new Date() }) + .where(eq(issues.id, comment.issueId)); + + return redactIssueComment(comment, currentUserRedactionOptions.enabled); + }, + addComment: async ( issueId: string, body: string, diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index bd604af9cdf..2ac3744f828 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -126,6 +126,8 @@ export const issuesApi = { ...(interrupt === undefined ? {} : { interrupt }), }, ), + cancelComment: (id: string, commentId: string) => + api.delete(`/issues/${id}/comments/${commentId}`), listDocuments: (id: string) => api.get(`/issues/${id}/documents`), getDocument: (id: string, key: string) => api.get(`/issues/${id}/documents/${encodeURIComponent(key)}`), upsertDocument: (id: string, key: string, data: UpsertIssueDocument) => diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 5ba66f14278..e54f7c8e02d 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -340,7 +340,7 @@ describe("IssueChatThread", () => { it("exposes a composer focus handle that forwards to the editor", () => { const root = createRoot(container); - const composerRef = createRef<{ focus: () => void }>(); + const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>(); const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {}); const requestAnimationFrameMock = vi .spyOn(window, "requestAnimationFrame") @@ -387,6 +387,51 @@ describe("IssueChatThread", () => { }); }); + it("restores a cancelled queued draft into the composer handle", () => { + const root = createRoot(container); + const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>(); + const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {}); + const requestAnimationFrameMock = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + + act(() => { + root.render( + + {}} + composerRef={composerRef} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null; + expect(editor).not.toBeNull(); + + act(() => { + composerRef.current?.restoreDraft("Queued message"); + }); + + expect(editor?.value).toBe("Queued message"); + expect(markdownEditorFocusMock).toHaveBeenCalledTimes(1); + expect(scrollByMock).toHaveBeenCalledWith({ top: 96, behavior: "smooth" }); + + scrollByMock.mockRestore(); + requestAnimationFrameMock.mockRestore(); + act(() => { + root.unmount(); + }); + }); + it("folds chain-of-thought when the same message transitions from running to complete", () => { expect(resolveAssistantMessageFoldedState({ messageId: "message-1", diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 00d3df61241..547660283a7 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -94,6 +94,7 @@ interface IssueChatMessageContext { options?: { allowSharing?: boolean; reason?: string }, ) => Promise; onInterruptQueued?: (runId: string) => Promise; + onCancelQueued?: (commentId: string) => void; interruptingQueuedRunId?: string | null; onImageClick?: (src: string) => void; } @@ -162,6 +163,7 @@ interface CommentReassignment { export interface IssueChatComposerHandle { focus: () => void; + restoreDraft: (submittedBody: string) => void; } interface IssueChatComposerProps { @@ -217,6 +219,7 @@ interface IssueChatThreadProps { hasOutputForRun?: (runId: string) => boolean; includeSucceededRunsWithoutOutput?: boolean; onInterruptQueued?: (runId: string) => Promise; + onCancelQueued?: (commentId: string) => void; interruptingQueuedRunId?: string | null; onImageClick?: (src: string) => void; composerRef?: Ref; @@ -873,10 +876,11 @@ function IssueChatToolPart({ } function IssueChatUserMessage() { - const { onInterruptQueued, interruptingQueuedRunId } = useContext(IssueChatCtx); + const { onInterruptQueued, onCancelQueued, interruptingQueuedRunId } = useContext(IssueChatCtx); const message = useMessage(); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; + const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id; const queued = custom.queueState === "queued" || custom.clientStatus === "queued"; const pending = custom.clientStatus === "pending"; const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null; @@ -911,6 +915,16 @@ function IssueChatUserMessage() { {interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"} ) : null} + {onCancelQueued ? ( + + ) : null} ) : null}
@@ -1567,6 +1581,14 @@ const IssueChatComposer = forwardRef(null); const draftTimer = useRef | null>(null); + function focusComposer() { + composerContainerRef.current?.scrollIntoView?.({ behavior: "smooth", block: "end" }); + requestAnimationFrame(() => { + window.scrollBy({ top: COMPOSER_FOCUS_SCROLL_PADDING_PX, behavior: "smooth" }); + editorRef.current?.focus(); + }); + } + useEffect(() => { if (!draftKey) return; setBody(loadDraft(draftKey)); @@ -1591,12 +1613,15 @@ const IssueChatComposer = forwardRef ({ - focus: () => { - composerContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); - requestAnimationFrame(() => { - window.scrollBy({ top: COMPOSER_FOCUS_SCROLL_PADDING_PX, behavior: "smooth" }); - editorRef.current?.focus(); - }); + focus: focusComposer, + restoreDraft: (submittedBody: string) => { + setBody((current) => + restoreSubmittedCommentDraft({ + currentBody: current, + submittedBody, + }), + ); + focusComposer(); }, }), []); @@ -1799,6 +1824,7 @@ export function IssueChatThread({ hasOutputForRun: hasOutputForRunOverride, includeSucceededRunsWithoutOutput = false, onInterruptQueued, + onCancelQueued, interruptingQueuedRunId = null, onImageClick, composerRef, @@ -1914,6 +1940,7 @@ export function IssueChatThread({ currentUserId, onVote, onInterruptQueued, + onCancelQueued, interruptingQueuedRunId, onImageClick, }), @@ -1925,6 +1952,7 @@ export function IssueChatThread({ currentUserId, onVote, onInterruptQueued, + onCancelQueued, interruptingQueuedRunId, onImageClick, ], diff --git a/ui/src/lib/optimistic-issue-comments.test.ts b/ui/src/lib/optimistic-issue-comments.test.ts index e977ba0dd97..669f72825d9 100644 --- a/ui/src/lib/optimistic-issue-comments.test.ts +++ b/ui/src/lib/optimistic-issue-comments.test.ts @@ -10,6 +10,8 @@ import { isQueuedIssueComment, matchesIssueRef, mergeIssueComments, + removeIssueCommentFromPages, + takeOptimisticIssueComment, upsertIssueComment, upsertIssueCommentInPages, } from "./optimistic-issue-comments"; @@ -101,6 +103,30 @@ describe("optimistic issue comments", () => { expect(merged.map((comment) => comment.id)).toEqual(["optimistic-1", "comment-2"]); }); + it("can take one optimistic comment back out of the queue by client id", () => { + const first = createOptimisticIssueComment({ + companyId: "company-1", + issueId: "issue-1", + body: "First", + authorUserId: "board-1", + clientStatus: "queued", + queueTargetRunId: "run-1", + }); + const second = createOptimisticIssueComment({ + companyId: "company-1", + issueId: "issue-1", + body: "Second", + authorUserId: "board-1", + clientStatus: "queued", + queueTargetRunId: "run-1", + }); + + const result = takeOptimisticIssueComment([first, second], first.clientId); + + expect(result.comment?.body).toBe("First"); + expect(result.comments.map((comment) => comment.clientId)).toEqual([second.clientId]); + }); + it("upserts confirmed comments without creating duplicates", () => { const next = upsertIssueComment( [ @@ -250,6 +276,52 @@ describe("optimistic issue comments", () => { expect(nextPages[1]?.map((comment) => comment.id)).toEqual(["comment-1"]); }); + it("removes one confirmed comment from paged caches", () => { + const nextPages = removeIssueCommentFromPages( + [ + [ + { + id: "comment-3", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Newest", + createdAt: new Date("2026-03-28T14:00:03.000Z"), + updatedAt: new Date("2026-03-28T14:00:03.000Z"), + }, + ], + [ + { + id: "comment-2", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Middle", + createdAt: new Date("2026-03-28T14:00:02.000Z"), + updatedAt: new Date("2026-03-28T14:00:02.000Z"), + }, + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Oldest", + createdAt: new Date("2026-03-28T14:00:01.000Z"), + updatedAt: new Date("2026-03-28T14:00:01.000Z"), + }, + ], + ], + "comment-2", + ); + + expect(nextPages).toHaveLength(2); + expect(nextPages[0]?.map((comment) => comment.id)).toEqual(["comment-3"]); + expect(nextPages[1]?.map((comment) => comment.id)).toEqual(["comment-1"]); + }); + it("applies optimistic reopen and reassignment updates to the issue cache", () => { const next = applyOptimisticIssueCommentUpdate( { diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts index 9acd4954de3..d756d820a6b 100644 --- a/ui/src/lib/optimistic-issue-comments.ts +++ b/ui/src/lib/optimistic-issue-comments.ts @@ -96,6 +96,21 @@ export function mergeIssueComments( return sortIssueComments(merged); } +export function takeOptimisticIssueComment( + comments: OptimisticIssueComment[], + clientId: string, +): { comments: OptimisticIssueComment[]; comment: OptimisticIssueComment | null } { + const index = comments.findIndex((comment) => comment.clientId === clientId); + if (index === -1) { + return { comments, comment: null }; + } + + return { + comments: comments.filter((comment) => comment.clientId !== clientId), + comment: comments[index] ?? null, + }; +} + export function flattenIssueCommentPages( pages: ReadonlyArray> | undefined, ): IssueComment[] { @@ -254,3 +269,16 @@ export function upsertIssueCommentInPages( nextPages[0] = sortIssueCommentsDesc([...nextPages[0]!, nextComment]); return nextPages; } + +export function removeIssueCommentFromPages( + pages: ReadonlyArray> | undefined, + commentId: string, +): IssueComment[][] { + if (!pages || pages.length === 0) { + return []; + } + + return pages + .map((page) => page.filter((comment) => comment.id !== commentId)) + .filter((page) => page.length > 0); +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index fb3159799fb..c70905709d4 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -41,6 +41,8 @@ import { isQueuedIssueComment, matchesIssueRef, mergeIssueComments, + removeIssueCommentFromPages, + takeOptimisticIssueComment, upsertIssueCommentInPages, type IssueCommentReassignment, type OptimisticIssueComment, @@ -391,6 +393,7 @@ export function IssueDetail() { const fileInputRef = useRef(null); const lastMarkedReadIssueIdRef = useRef(null); const commentComposerRef = useRef(null); + const cancelledQueuedOptimisticCommentIdsRef = useRef(new Set()); useEffect(() => { setIssueChatInitialTranscriptReady(false); @@ -809,6 +812,23 @@ export function IssueDetail() { queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }); }, [issueId, queryClient]); + const removeCommentFromCache = useCallback((commentId: string) => { + queryClient.setQueryData | undefined>( + queryKeys.issues.comments(issueId!), + (current) => { + if (!current) return current; + return { + ...current, + pages: removeIssueCommentFromPages(current.pages, commentId), + }; + }, + ); + }, [issueId, queryClient]); + + const restoreQueuedCommentDraft = useCallback((body: string) => { + commentComposerRef.current?.restoreDraft(body); + }, []); + const invalidateIssueCollections = useCallback(() => { if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); @@ -982,12 +1002,32 @@ export function IssueDetail() { previousIssue, }; }, - onSuccess: (comment, _variables, context) => { + onSuccess: async (comment, _variables, context) => { if (context?.optimisticCommentId) { setOptimisticComments((current) => current.filter((entry) => entry.clientId !== context.optimisticCommentId), ); } + if (context?.optimisticCommentId && cancelledQueuedOptimisticCommentIdsRef.current.has(context.optimisticCommentId)) { + cancelledQueuedOptimisticCommentIdsRef.current.delete(context.optimisticCommentId); + try { + await issuesApi.cancelComment(issueId!, comment.id); + restoreQueuedCommentDraft(comment.body); + pushToast({ + title: "Queued comment canceled", + body: "The queued message was restored to the composer.", + tone: "success", + }); + invalidateIssueThreadLazily(); + return; + } catch (err) { + pushToast({ + title: "Cancel failed", + body: err instanceof Error ? err.message : "Unable to cancel the queued comment", + tone: "error", + }); + } + } queryClient.setQueryData( queryKeys.issues.detail(issueId!), (current) => current ? { ...current, updatedAt: comment.createdAt } : current, @@ -1080,7 +1120,7 @@ export function IssueDetail() { previousIssue, }; }, - onSuccess: (result, _variables, context) => { + onSuccess: async (result, _variables, context) => { if (context?.optimisticCommentId) { setOptimisticComments((current) => current.filter((entry) => entry.clientId !== context.optimisticCommentId), @@ -1089,6 +1129,26 @@ export function IssueDetail() { const { comment, ...nextIssue } = result; queryClient.setQueryData(queryKeys.issues.detail(issueId!), nextIssue); + if (comment && context?.optimisticCommentId && cancelledQueuedOptimisticCommentIdsRef.current.has(context.optimisticCommentId)) { + cancelledQueuedOptimisticCommentIdsRef.current.delete(context.optimisticCommentId); + try { + await issuesApi.cancelComment(issueId!, comment.id); + restoreQueuedCommentDraft(comment.body); + pushToast({ + title: "Queued comment canceled", + body: "The queued message was restored to the composer.", + tone: "success", + }); + invalidateIssueThreadLazily(); + return; + } catch (err) { + pushToast({ + title: "Cancel failed", + body: err instanceof Error ? err.message : "Unable to cancel the queued comment", + tone: "error", + }); + } + } if (comment) { queryClient.setQueryData>( queryKeys.issues.comments(issueId!), @@ -1187,6 +1247,51 @@ export function IssueDetail() { }, }); + const cancelQueuedComment = useMutation({ + mutationFn: async ({ commentId }: { commentId: string }) => issuesApi.cancelComment(issueId!, commentId), + onSuccess: (comment) => { + removeCommentFromCache(comment.id); + restoreQueuedCommentDraft(comment.body); + invalidateIssueDetail(); + invalidateIssueThreadLazily(); + pushToast({ + title: "Queued comment canceled", + body: "The queued message was restored to the composer.", + tone: "success", + }); + }, + onError: (err) => { + pushToast({ + title: "Cancel failed", + body: err instanceof Error ? err.message : "Unable to cancel the queued comment", + tone: "error", + }); + }, + }); + + const handleCancelQueuedComment = useCallback((commentId: string) => { + if (commentId.startsWith("optimistic-")) { + cancelledQueuedOptimisticCommentIdsRef.current.add(commentId); + let cancelledBody: string | null = null; + setOptimisticComments((current) => { + const next = takeOptimisticIssueComment(current, commentId); + cancelledBody = next.comment?.body ?? null; + return next.comments; + }); + if (cancelledBody) { + restoreQueuedCommentDraft(cancelledBody); + pushToast({ + title: "Queued comment canceled", + body: "The queued message was restored to the composer.", + tone: "success", + }); + } + return; + } + + void cancelQueuedComment.mutateAsync({ commentId }); + }, [cancelQueuedComment, restoreQueuedCommentDraft, setOptimisticComments, pushToast]); + const feedbackVoteMutation = useMutation({ mutationFn: (variables: { targetType: "issue_comment" | "issue_document_revision"; @@ -2180,6 +2285,7 @@ export function IssueDetail() { onInterruptQueued={async (runId) => { await interruptQueuedComment.mutateAsync(runId); }} + onCancelQueued={handleCancelQueuedComment} interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} onCancelRun={runningIssueRun ? async () => {