diff --git a/screenshots/PAP-10535-live-run-menu-after.png b/screenshots/PAP-10535-live-run-menu-after.png new file mode 100644 index 00000000..e9d4adda Binary files /dev/null and b/screenshots/PAP-10535-live-run-menu-after.png differ diff --git a/screenshots/PAP-10535-live-run-menu-before.png b/screenshots/PAP-10535-live-run-menu-before.png new file mode 100644 index 00000000..93d3b398 Binary files /dev/null and b/screenshots/PAP-10535-live-run-menu-before.png differ diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index c426e50a..1635c982 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -157,6 +157,7 @@ interface IssueChatMessageContext { stopRunLabel?: string; stoppingRunLabel?: string; stopRunVariant?: "stop" | "pause"; + runFinalizationActions?: readonly IssueChatRunFinalizationAction[]; onInterruptQueued?: (runId: string) => Promise; onCancelQueued?: (commentId: string) => void; onDeleteComment?: (commentId: string) => Promise | void; @@ -194,6 +195,15 @@ const IssueChatCtx = createContext({ successfulRunHandoff: null, }); +export type IssueChatRunFinalizationAction = { + id: "cancel" | "done"; + label: string; + pendingLabel: string; + onSelect: (runId: string) => Promise | void; + isPending?: boolean; + disabled?: boolean; +}; + export function resolveAssistantMessageFoldedState(args: { messageId: string; currentFolded: boolean; @@ -342,6 +352,7 @@ interface IssueChatThreadProps { stopRunLabel?: string; stoppingRunLabel?: string; stopRunVariant?: "stop" | "pause"; + runFinalizationActions?: readonly IssueChatRunFinalizationAction[]; imageUploadHandler?: (file: File) => Promise; onAttachImage?: (file: File) => Promise; draftKey?: string; @@ -1513,6 +1524,7 @@ function IssueChatAssistantMessage({ stopRunLabel = "Stop run", stoppingRunLabel = "Stopping...", stopRunVariant = "stop", + runFinalizationActions = [], } = useContext(IssueChatCtx); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; @@ -1731,6 +1743,29 @@ function IssueChatAssistantMessage({ {isStoppingRun ? stoppingRunLabel : stopRunLabel} ) : null} + {canStopRun && runId + ? runFinalizationActions.map((action) => ( + { + void action.onSelect(runId); + }} + > + {action.id === "cancel" ? ( + + ) : ( + + )} + {action.isPending ? action.pendingLabel : action.label} + + )) + : null} {runHref ? ( @@ -3773,6 +3808,7 @@ export function IssueChatThread({ stopRunLabel, stoppingRunLabel, stopRunVariant, + runFinalizationActions, imageUploadHandler, onAttachImage, draftKey, @@ -4290,6 +4326,7 @@ export function IssueChatThread({ stopRunLabel, stoppingRunLabel, stopRunVariant, + runFinalizationActions, onInterruptQueued: stableOnInterruptQueued, onCancelQueued: stableOnCancelQueued, onDeleteComment: stableOnDeleteComment, @@ -4313,6 +4350,7 @@ export function IssueChatThread({ stopRunLabel, stoppingRunLabel, stopRunVariant, + runFinalizationActions, stableOnInterruptQueued, stableOnCancelQueued, stableOnDeleteComment, diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index ee18c1d8..f4fac0ee 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -219,6 +219,11 @@ vi.mock("../components/IssueChatThread", () => ({ onStopRun?: (runId: string) => Promise; stopRunLabel?: string; stoppingRunLabel?: string; + runFinalizationActions?: readonly { + id: string; + label: string; + onSelect: (runId: string) => Promise | void; + }[]; footer?: ReactNode; }) => { mockIssueChatThreadRender(props); @@ -230,6 +235,15 @@ vi.mock("../components/IssueChatThread", () => ({ {props.stopRunLabel ?? "Stop run"} ) : null} + {props.runFinalizationActions?.map((action) => ( + + ))} {props.footer} ); @@ -1436,6 +1450,103 @@ describe("IssueDetail", () => { expect(pauseMenuButton).toBeTruthy(); }); + it("routes live-run finalization actions through run cancellation before issue status update", async () => { + mockIssuesApi.get.mockResolvedValue(createIssue({ + status: "in_progress", + assigneeAgentId: "agent-1", + executionRunId: "run-active-1", + })); + mockIssuesApi.update.mockImplementation((_id, data) => + Promise.resolve(createIssue({ + status: data.status as Issue["status"], + assigneeAgentId: "agent-1", + })), + ); + mockHeartbeatsApi.cancel.mockResolvedValue(undefined); + mockAgentsApi.list.mockResolvedValue([createAgent()]); + mockAuthApi.getSession.mockResolvedValue({ + session: { userId: "user-1" }, + user: { id: "user-1" }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + const stopAndDoneButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Stop and done"); + expect(stopAndDoneButton).toBeTruthy(); + + await act(async () => { + stopAndDoneButton!.click(); + }); + await flushReact(); + + expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-active-1"); + expect(mockIssuesApi.update).toHaveBeenCalledWith("PAP-1", { status: "done" }); + expect(mockHeartbeatsApi.cancel.mock.invocationCallOrder[0]) + .toBeLessThan(mockIssuesApi.update.mock.invocationCallOrder[0]); + + const stopAndCancelButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Stop and cancel"); + expect(stopAndCancelButton).toBeTruthy(); + + await act(async () => { + stopAndCancelButton!.click(); + }); + await flushReact(); + + expect(mockIssuesApi.update).toHaveBeenLastCalledWith("PAP-1", { status: "cancelled" }); + expect(mockHeartbeatsApi.cancel).toHaveBeenCalledTimes(2); + expect(mockHeartbeatsApi.cancel.mock.invocationCallOrder[1]) + .toBeLessThan(mockIssuesApi.update.mock.invocationCallOrder[1]); + }); + + it("reports partial success when run finalization stops the run but task status update fails", async () => { + mockIssuesApi.get.mockResolvedValue(createIssue({ + status: "in_progress", + assigneeAgentId: "agent-1", + executionRunId: "run-active-1", + })); + mockIssuesApi.update.mockRejectedValue(new Error("Status write failed")); + mockHeartbeatsApi.cancel.mockResolvedValue(undefined); + mockAgentsApi.list.mockResolvedValue([createAgent()]); + mockAuthApi.getSession.mockResolvedValue({ + session: { userId: "user-1" }, + user: { id: "user-1" }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + const stopAndDoneButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Stop and done"); + expect(stopAndDoneButton).toBeTruthy(); + + await act(async () => { + stopAndDoneButton!.click(); + }); + await flushReact(); + + expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-active-1"); + expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ + title: "Run stopped; task update failed", + body: "Run was stopped, but updating the task failed: Status write failed", + tone: "error", + })); + }); + it("passes planning work mode to the issue chat thread", async () => { mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "planning" })); await act(async () => { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index d622bd81..a286121c 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -63,7 +63,11 @@ import { useProjectOrder } from "../hooks/useProjectOrder"; import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { ApprovalCard } from "../components/ApprovalCard"; import { InlineEditor } from "../components/InlineEditor"; -import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread"; +import { + IssueChatThread, + type IssueChatComposerHandle, + type IssueChatRunFinalizationAction, +} from "../components/IssueChatThread"; import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff"; import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; @@ -162,6 +166,24 @@ import { type IssueTreeControlMode, } from "@paperclipai/shared"; +type StopAndFinalizeRunError = Error & { + runCancelledBeforeStatusUpdateFailed?: boolean; +}; + +function createRunCancelledStatusUpdateError(err: unknown): StopAndFinalizeRunError { + const message = err instanceof Error + ? `Run was stopped, but updating the task failed: ${err.message}` + : "Run was stopped, but updating the task failed. Retry the task status update."; + const error = new Error(message) as StopAndFinalizeRunError; + error.runCancelledBeforeStatusUpdateFailed = true; + return error; +} + +function didRunCancelBeforeStatusUpdateFail(err: unknown): err is StopAndFinalizeRunError { + return err instanceof Error && + (err as StopAndFinalizeRunError).runCancelledBeforeStatusUpdateFailed === true; +} + type CommentReassignment = IssueCommentReassignment; type ActionableIssueThreadInteraction = | SuggestTasksInteraction @@ -671,6 +693,7 @@ type IssueDetailChatTabProps = { onInterruptQueued: (runId: string) => Promise; onDeleteComment?: (commentId: string) => Promise | void; onPauseWorkRun?: (runId: string) => Promise; + runFinalizationActions?: readonly IssueChatRunFinalizationAction[]; onCancelQueued: (commentId: string) => void; interruptingQueuedRunId: string | null; pausingWorkRunId: string | null; @@ -738,6 +761,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ onInterruptQueued, onDeleteComment, onPauseWorkRun, + runFinalizationActions, onCancelQueued, interruptingQueuedRunId, pausingWorkRunId, @@ -948,6 +972,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ stopRunLabel="Pause work" stoppingRunLabel="Pausing..." stopRunVariant="pause" + runFinalizationActions={runFinalizationActions} onAcceptInteraction={onAcceptInteraction} onRejectInteraction={onRejectInteraction} onSubmitInteractionAnswers={(interaction, answers) => @@ -1968,6 +1993,45 @@ export function IssueDetail() { }); }, }); + const stopAndFinalizeRun = useMutation({ + mutationFn: async ({ runId, status }: { runId: string; status: "cancelled" | "done" }) => { + await heartbeatsApi.cancel(runId); + try { + return await issuesApi.update(issueId!, { status }); + } catch (err) { + throw createRunCancelledStatusUpdateError(err); + } + }, + onSuccess: ({ comment: _comment, ...nextIssue }, { status }) => { + const issueRefs = new Set([issueId!, nextIssue.id]); + if (nextIssue.identifier) issueRefs.add(nextIssue.identifier); + mergeIssueResponseIntoCaches(issueRefs, nextIssue); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); + invalidateIssueRunState(); + invalidateIssueCollections(); + pushToast({ + title: status === "done" ? "Run stopped and task done" : "Run stopped and task cancelled", + tone: "success", + }); + }, + onError: (err, { status }) => { + const runWasStopped = didRunCancelBeforeStatusUpdateFail(err); + pushToast({ + title: runWasStopped + ? "Run stopped; task update failed" + : status === "done" ? "Stop and done failed" : "Stop and cancel failed", + body: err instanceof Error ? err.message : "Unable to stop the run and update the task", + tone: "error", + }); + }, + onSettled: (_data, err) => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); + if (err) invalidateIssueRunState(); + if (selectedCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); + } + }, + }); const handleIssuePropertiesUpdate = useCallback((data: Record) => { updateIssue.mutate(data); }, [updateIssue.mutate]); @@ -3087,6 +3151,34 @@ export function IssueDetail() { const handleInterruptQueuedRun = useCallback(async (runId: string) => { await interruptQueuedComment.mutateAsync(runId); }, [interruptQueuedComment]); + const runFinalizationActions = useMemo(() => [ + { + id: "cancel", + label: "Stop and cancel", + pendingLabel: "Stopping and cancelling...", + isPending: + stopAndFinalizeRun.isPending && + stopAndFinalizeRun.variables?.status === "cancelled", + disabled: stopAndFinalizeRun.isPending, + onSelect: (runId) => + stopAndFinalizeRun.mutateAsync({ runId, status: "cancelled" }).then(() => undefined, () => undefined), + }, + { + id: "done", + label: "Stop and done", + pendingLabel: "Stopping and marking done...", + isPending: + stopAndFinalizeRun.isPending && + stopAndFinalizeRun.variables?.status === "done", + disabled: stopAndFinalizeRun.isPending, + onSelect: (runId) => + stopAndFinalizeRun.mutateAsync({ runId, status: "done" }).then(() => undefined, () => undefined), + }, + ], [ + stopAndFinalizeRun.isPending, + stopAndFinalizeRun.mutateAsync, + stopAndFinalizeRun.variables?.status, + ]); const handleAcceptInteraction = useCallback(async ( interaction: ActionableIssueThreadInteraction, selectedClientKeys?: string[], @@ -3986,6 +4078,7 @@ export function IssueDetail() { onPauseWorkRun={canManageTreeControl ? (runId) => pauseIssueWorkRun.mutateAsync({ runId, scope: treeControlScope }).then(() => undefined) : undefined} + runFinalizationActions={runFinalizationActions} onWorkModeChange={(nextMode) => { const currentMode: IssueWorkMode = issue.workMode ?? "standard"; if (currentMode === nextMode) return;