diff --git a/cli/src/commands/client/issue.ts b/cli/src/commands/client/issue.ts index 921c077e27e..afef1923b9c 100644 --- a/cli/src/commands/client/issue.ts +++ b/cli/src/commands/client/issue.ts @@ -61,6 +61,7 @@ interface IssueUpdateOptions extends BaseClientOptions { interface IssueCommentOptions extends BaseClientOptions { body: string; reopen?: boolean; + resume?: boolean; } interface IssueCheckoutOptions extends BaseClientOptions { @@ -241,12 +242,14 @@ export function registerIssueCommands(program: Command): void { .argument("", "Issue ID") .requiredOption("--body ", "Comment body") .option("--reopen", "Reopen if issue is done/cancelled") + .option("--resume", "Request explicit follow-up and wake the assignee when resumable") .action(async (issueId: string, opts: IssueCommentOptions) => { try { const ctx = resolveCommandContext(opts); const payload = addIssueCommentSchema.parse({ body: opts.body, reopen: opts.reopen, + resume: opts.resume, }); const comment = await ctx.api.post(`/api/issues/${issueId}/comments`, payload); printOutput(comment, { json: ctx.json }); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index a66ccdc08ea..297d57c74c6 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -92,6 +92,7 @@ export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [ "- If woken by a human comment on a dependency-blocked issue, respond or triage the comment without treating the blocked deliverable work as unblocked.", "- Create child issues directly when you know what needs to be done; use issue-thread interactions when the board/user must choose suggested tasks, answer structured questions, or confirm a proposal.", "- To ask for that input, create an interaction on the current issue with POST /api/issues/{issueId}/interactions using kind suggest_tasks, ask_user_questions, or request_confirmation. Use continuationPolicy wake_assignee when you need to resume after a response; for request_confirmation this resumes only after acceptance.", + "- When you intentionally restart follow-up work on a completed assigned issue, include structured `resume: true` with the POST /api/issues/{issueId}/comments or PATCH /api/issues/{issueId} comment payload. Generic agent comments on closed issues are inert by default.", "- For plan approval, update the plan document first, then create request_confirmation targeting the latest plan revision with idempotencyKey confirmation:{issueId}:plan:{revisionId}. Wait for acceptance before creating implementation subtasks, and create a fresh confirmation after superseding board/user comments if approval is still needed.", "- If blocked, mark the issue blocked and name the unblock owner and action.", "- Respect budget, pause/cancel, approval gates, and company boundaries.", diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index f101f3fe1bc..54ca904f0cb 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -450,7 +450,7 @@ export function createToolDefinitions(client: PaperclipApiClient): ToolDefinitio ), makeTool( "paperclipUpdateIssue", - "Patch an issue, optionally including a comment", + "Patch an issue, optionally including a comment; include resume=true when intentionally requesting follow-up on resumable closed work", updateIssueToolSchema, async ({ issueId, ...body }) => client.requestJson("PATCH", `/issues/${encodeURIComponent(issueId)}`, { body }), @@ -475,7 +475,7 @@ export function createToolDefinitions(client: PaperclipApiClient): ToolDefinitio ), makeTool( "paperclipAddComment", - "Add a comment to an issue", + "Add a comment to an issue; include resume=true when intentionally requesting follow-up on resumable closed work", addCommentToolSchema, async ({ issueId, ...body }) => client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/comments`, { body }), diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 40e2c78b1cb..7709274be5e 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -28,6 +28,7 @@ const mockHeartbeatService = vi.hoisted(() => ({ const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), + list: vi.fn(), resolveByReference: vi.fn(), })); @@ -175,7 +176,7 @@ async function normalizePolicy(input: { return normalizeIssueExecutionPolicy(input); } -function makeIssue(status: "todo" | "done" | "blocked") { +function makeIssue(status: "todo" | "done" | "blocked" | "cancelled" | "in_progress") { return { id: "11111111-1111-4111-8111-111111111111", companyId: "company-1", @@ -188,6 +189,16 @@ function makeIssue(status: "todo" | "done" | "blocked") { }; } +function agentActor(agentId = "22222222-2222-4222-8222-222222222222") { + return { + type: "agent", + agentId, + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }; +} + async function waitForWakeup(assertion: () => void) { await vi.waitFor(assertion); } @@ -211,6 +222,7 @@ describe.sequential("issue comment reopen routes", () => { mockHeartbeatService.getActiveRunForAgent.mockReset(); mockHeartbeatService.cancelRun.mockReset(); mockAgentService.getById.mockReset(); + mockAgentService.list.mockReset(); mockAgentService.resolveByReference.mockReset(); mockLogActivity.mockReset(); mockFeedbackService.listIssueVotesForUser.mockReset(); @@ -272,6 +284,18 @@ describe.sequential("issue comment reopen routes", () => { mockAccessService.canUser.mockResolvedValue(false); mockAccessService.hasPermission.mockResolvedValue(false); mockAgentService.getById.mockResolvedValue(null); + mockAgentService.list.mockResolvedValue([ + { + id: "22222222-2222-4222-8222-222222222222", + reportsTo: null, + permissions: { canCreateAgents: false }, + }, + { + id: "44444444-4444-4444-8444-444444444444", + reportsTo: null, + permissions: { canCreateAgents: false }, + }, + ]); mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, reference: string) => { if (reference === "ambiguous-codex") { return { ambiguous: true, agent: null }; @@ -739,6 +763,158 @@ describe.sequential("issue comment reopen routes", () => { ); }); + it("explicit same-agent resume works through the PATCH comment path", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("done")); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("done"), + ...patch, + })); + + const res = await request(await installActor(createApp(), agentActor())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "please validate the follow-up", resume: true }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + status: "todo", + actorAgentId: "22222222-2222-4222-8222-222222222222", + actorUserId: null, + }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.comment_added", + details: expect.objectContaining({ + commentId: "comment-1", + resumeIntent: true, + followUpRequested: true, + }), + }), + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + reason: "issue_reopened_via_comment", + payload: expect.objectContaining({ + commentId: "comment-1", + reopenedFrom: "done", + resumeIntent: true, + followUpRequested: true, + }), + }), + ); + }); + + it("keeps generic same-agent comments on closed issues inert", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("done")); + + const res = await request(await installActor(createApp(), agentActor())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "follow-up note without intent" }); + + expect(res.status).toBe(201); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + + it("explicit same-agent resume comments reopen closed issues and mark the wake payload", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("done")); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("done"), + ...patch, + })); + + const res = await request(await installActor(createApp(), agentActor())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "please validate the follow-up", resume: true }); + + expect(res.status).toBe(201); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + { status: "todo" }, + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.comment_added", + details: expect.objectContaining({ + commentId: "comment-1", + resumeIntent: true, + followUpRequested: true, + }), + }), + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + reason: "issue_reopened_via_comment", + payload: expect.objectContaining({ + commentId: "comment-1", + reopenedFrom: "done", + resumeIntent: true, + followUpRequested: true, + }), + contextSnapshot: expect.objectContaining({ + wakeReason: "issue_reopened_via_comment", + resumeIntent: true, + followUpRequested: true, + }), + }), + ); + }); + + it("rejects explicit agent resume intent from a non-assignee", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("done")); + + const res = await request(await installActor(createApp(), agentActor("44444444-4444-4444-8444-444444444444"))) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "restart someone else's work", resume: true }); + + expect(res.status).toBe(403); + expect(res.body.error).toBe("Agent cannot request follow-up for another agent's issue"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + + it("rejects explicit resume intent under an active pause hold", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("done")); + mockIssueTreeControlService.getActivePauseHoldGate.mockResolvedValue({ + holdId: "hold-1", + rootIssueId: "root-1", + issueId: "11111111-1111-4111-8111-111111111111", + isRoot: false, + mode: "pause", + reason: "reviewing", + releasePolicy: null, + }); + + const res = await request(await installActor(createApp(), agentActor())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "please resume", resume: true }); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("Issue follow-up blocked by active subtree pause hold"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + }); + + it("rejects explicit resume intent on cancelled issues", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("cancelled")); + + const res = await request(await installActor(createApp(), agentActor())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "please resume", resume: true }); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("Cancelled issues must be restored through the dedicated restore flow"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + }); + it("interrupts an active run before a combined comment update", async () => { const issue = { ...makeIssue("todo"), diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index cbe599f454d..795da2975ab 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -2404,6 +2404,7 @@ export function issueRoutes( issueId: issue.id, ...(comment ? { commentId: comment.id } : {}), mutation: "update", + ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, requestedByActorType: actor.actorType, @@ -2418,6 +2419,7 @@ export function issueRoutes( } : {}), source: "issue.update", + ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, }); @@ -2435,6 +2437,7 @@ export function issueRoutes( payload: { issueId: issue.id, mutation: "update", + ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, requestedByActorType: actor.actorType, @@ -2442,6 +2445,7 @@ export function issueRoutes( contextSnapshot: { issueId: issue.id, source: "issue.status_change", + ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, }); @@ -2463,6 +2467,7 @@ export function issueRoutes( commentId: comment.id, mutation: "comment", ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), + ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, requestedByActorType: actor.actorType, @@ -2475,6 +2480,7 @@ export function issueRoutes( source: reopened ? "issue.comment.reopen" : "issue.comment", wakeReason: reopened ? "issue_reopened_via_comment" : "issue_commented", ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), + ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, }); @@ -3306,6 +3312,7 @@ export function issueRoutes( reopened: true, reopenedFrom: reopenFromStatus, source: "comment", + ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), identifier: currentIssue.identifier, }, }); @@ -3412,6 +3419,7 @@ export function issueRoutes( commentId: comment.id, reopenedFrom: reopenFromStatus, mutation: "comment", + ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, requestedByActorType: actor.actorType, @@ -3424,6 +3432,7 @@ export function issueRoutes( source: "issue.comment.reopen", wakeReason: "issue_reopened_via_comment", reopenedFrom: reopenFromStatus, + ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, }); @@ -3436,6 +3445,7 @@ export function issueRoutes( issueId: currentIssue.id, commentId: comment.id, mutation: "comment", + ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, requestedByActorType: actor.actorType, @@ -3447,6 +3457,7 @@ export function issueRoutes( wakeCommentId: comment.id, source: "issue.comment", wakeReason: "issue_commented", + ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, }); diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx index 860ea0ad8f7..18e77a4328a 100644 --- a/ui/src/components/CommentThread.test.tsx +++ b/ui/src/components/CommentThread.test.tsx @@ -178,6 +178,46 @@ describe("CommentThread", () => { }); }); + it("shows follow-up badges on explicit follow-up comments and timeline rows", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + /> + , + ); + }); + + expect(container.textContent).toContain("Follow-up"); + expect(container.textContent).toContain("requested follow-up"); + + act(() => { + root.unmount(); + }); + }); + it("hides the reopen control and infers reopen for closed agent-assigned issues", async () => { const root = createRoot(container); const onAdd = vi.fn(async () => {}); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index cf2d30f1b1e..04156a9a5c9 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -9,6 +9,7 @@ import type { IssueComment, } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { ArrowRight, Check, Copy, Paperclip } from "lucide-react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Identity } from "./Identity"; @@ -32,6 +33,7 @@ interface CommentWithRunMeta extends IssueComment { clientStatus?: "pending" | "queued"; queueState?: "queued"; queueTargetRunId?: string | null; + followUpRequested?: boolean; } interface LinkedRunItem { @@ -315,6 +317,7 @@ function CommentCard({ const isHighlighted = highlightCommentId === comment.id; const isPending = comment.clientStatus === "pending"; const isQueued = queued || comment.queueState === "queued" || comment.clientStatus === "queued"; + const followUpRequested = comment.followUpRequested === true; return (
) : null} + {followUpRequested ? ( + + Follow-up + + ) : null} {companyId && !isPending ? ( @@ -462,7 +471,7 @@ function TimelineEventCard({
{actorName} - updated this task + {actionLabel} (() => { - const commentItems: TimelineItem[] = comments.map((comment) => ({ - kind: "comment", - id: comment.id, - createdAtMs: new Date(comment.createdAt).getTime(), - comment, - })); + const followUpCommentIds = new Set( + timelineEvents + .filter((event) => event.followUpRequested && event.commentId) + .map((event) => event.commentId as string), + ); + const commentItems: TimelineItem[] = comments.map((comment) => { + const followUpRequested = comment.followUpRequested === true || followUpCommentIds.has(comment.id); + return { + kind: "comment", + id: comment.id, + createdAtMs: new Date(comment.createdAt).getTime(), + comment: followUpRequested ? { ...comment, followUpRequested } : comment, + }; + }); const approvalItems: TimelineItem[] = linkedApprovals.map((approval) => ({ kind: "approval", id: approval.id, diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 47dc0c0a683..0527fefcdf3 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -318,6 +318,50 @@ describe("IssueChatThread", () => { }); }); + it("shows explicit follow-up badges and event copy", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(container.textContent).toContain("Follow-up"); + expect(container.textContent).toContain("requested follow-up"); + + act(() => { + root.unmount(); + }); + }); + it("shows unresolved blocker context above the composer", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 7eefffc7c17..7bb5b1c67d0 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -58,6 +58,7 @@ import { buildIssueThreadInteractionSummary, isIssueThreadInteraction } from ".. import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns"; import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Dialog, @@ -1196,6 +1197,7 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) { const authorName = typeof custom.authorName === "string" ? custom.authorName : null; const authorUserId = typeof custom.authorUserId === "string" ? custom.authorUserId : null; const queued = custom.queueState === "queued" || custom.clientStatus === "queued"; + const followUpRequested = custom.followUpRequested === true; const queueReason = typeof custom.queueReason === "string" ? custom.queueReason : null; const queueBadgeLabel = queueReason === "hold" ? "\u23f8 Deferred wake" : "Queued"; const pending = custom.clientStatus === "pending"; @@ -1221,6 +1223,11 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) {
{resolvedAuthorName} + {followUpRequested ? ( + + Follow-up + + ) : null}
@@ -1429,6 +1437,11 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) { ) : (
{authorName} + {followUpRequested ? ( + + Follow-up + + ) : null} {isRunning ? ( @@ -1944,7 +1957,9 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
{actorName} - updated this task + + {custom.followUpRequested === true ? "requested follow-up" : "updated this task"} + ${event.statusChange.to ?? "none"}`, @@ -388,6 +392,7 @@ function createTimelineEventMessage(args: { actorId: event.actorId, statusChange: event.statusChange ?? null, assigneeChange: event.assigneeChange ?? null, + followUpRequested: event.followUpRequested === true, }, }, }; diff --git a/ui/src/lib/issue-timeline-events.test.ts b/ui/src/lib/issue-timeline-events.test.ts index 07a385eba2b..ccd4d275c35 100644 --- a/ui/src/lib/issue-timeline-events.test.ts +++ b/ui/src/lib/issue-timeline-events.test.ts @@ -126,6 +126,80 @@ describe("extractIssueTimelineEvents", () => { ]); }); + it("marks explicit follow-up timeline updates", () => { + const events = extractIssueTimelineEvents([ + { + id: "evt-follow-up", + companyId: "company-1", + actorType: "agent", + actorId: "agent-1", + action: "issue.updated", + entityType: "issue", + entityId: "issue-1", + agentId: "agent-1", + runId: "run-1", + createdAt: new Date("2026-03-31T12:01:00.000Z"), + details: { + status: "todo", + reopened: true, + reopenedFrom: "done", + source: "comment", + commentId: "comment-1", + resumeIntent: true, + followUpRequested: true, + }, + }, + ] satisfies ActivityEvent[]); + + expect(events).toEqual([ + { + id: "evt-follow-up", + createdAt: new Date("2026-03-31T12:01:00.000Z"), + actorType: "agent", + actorId: "agent-1", + commentId: "comment-1", + followUpRequested: true, + statusChange: { + from: "done", + to: "todo", + }, + }, + ]); + }); + + it("synthesizes non-status follow-up rows from comment activity", () => { + const events = extractIssueTimelineEvents([ + { + id: "evt-comment-follow-up", + companyId: "company-1", + actorType: "agent", + actorId: "agent-1", + action: "issue.comment_added", + entityType: "issue", + entityId: "issue-1", + agentId: "agent-1", + runId: "run-1", + createdAt: new Date("2026-03-31T12:01:00.000Z"), + details: { + commentId: "comment-1", + resumeIntent: true, + followUpRequested: true, + }, + }, + ] satisfies ActivityEvent[]); + + expect(events).toEqual([ + { + id: "evt-comment-follow-up", + createdAt: new Date("2026-03-31T12:01:00.000Z"), + actorType: "agent", + actorId: "agent-1", + commentId: "comment-1", + followUpRequested: true, + }, + ]); + }); + it("ignores issue updates without visible status or assignee transitions", () => { const events = extractIssueTimelineEvents([ { diff --git a/ui/src/lib/issue-timeline-events.ts b/ui/src/lib/issue-timeline-events.ts index e7632b4c8db..9de445523a6 100644 --- a/ui/src/lib/issue-timeline-events.ts +++ b/ui/src/lib/issue-timeline-events.ts @@ -18,6 +18,8 @@ export interface IssueTimelineEvent { from: IssueTimelineAssignee; to: IssueTimelineAssignee; }; + commentId?: string | null; + followUpRequested?: boolean; } function asRecord(value: unknown): Record | null { @@ -53,11 +55,26 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un const events: IssueTimelineEvent[] = []; for (const event of activity ?? []) { - if (event.action !== "issue.updated") continue; - const details = asRecord(event.details); if (!details) continue; + if (event.action === "issue.comment_added") { + if (details.followUpRequested !== true && details.resumeIntent !== true) continue; + if (details.reopened === true) continue; + const commentId = nullableString(details.commentId); + events.push({ + id: event.id, + createdAt: event.createdAt, + actorType: event.actorType, + actorId: event.actorId, + commentId, + followUpRequested: true, + }); + continue; + } + + if (event.action !== "issue.updated") continue; + const previous = asRecord(details._previous); const timelineEvent: IssueTimelineEvent = { id: event.id, @@ -65,6 +82,10 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un actorType: event.actorType, actorId: event.actorId, }; + if (details.followUpRequested === true || details.resumeIntent === true) { + timelineEvent.followUpRequested = true; + timelineEvent.commentId = nullableString(details.commentId); + } if (hasOwn(details, "status")) { const from = nullableString(previous?.status) ?? nullableString(details.reopenedFrom); @@ -96,7 +117,7 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un } } - if (timelineEvent.statusChange || timelineEvent.assigneeChange) { + if (timelineEvent.statusChange || timelineEvent.assigneeChange || timelineEvent.followUpRequested) { events.push(timelineEvent); } } diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index d909bf7ebcf..ce511a69cae 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -692,6 +692,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ const commentsWithRunMeta = useMemo(() => { const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null; const runMetaByCommentId = new Map(); + const followUpCommentIds = new Set(); const agentIdByRunId = new Map(); for (const run of resolvedLinkedRuns) { @@ -710,10 +711,22 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ interruptedRunId, }); } + for (const evt of resolvedActivity) { + if (evt.action !== "issue.comment_added") continue; + const details = evt.details ?? {}; + const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null; + if (!commentId) continue; + if (details["followUpRequested"] === true || details["resumeIntent"] === true) { + followUpCommentIds.add(commentId); + } + } return comments.map((comment) => { const meta = runMetaByCommentId.get(comment.id); const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment }; + if (followUpCommentIds.has(comment.id)) { + nextComment.followUpRequested = true; + } const queuedTargetRunId = locallyQueuedCommentRunIds.get(comment.id) ?? null; const locallyQueuedComment = applyLocalQueuedIssueCommentState(nextComment, { queuedTargetRunId,