[codex] Add live-run stop finalization actions (#7679)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators supervise live agent runs from the issue detail chat
surface.
> - The existing run menu can pause/stop work, but operators sometimes
need to stop the active run and immediately finalize the task outcome.
> - Doing those as separate actions is slower and easier to leave
half-finished.
> - This pull request adds explicit live-run finalization actions to the
issue chat run menu.
> - The benefit is a clearer operator path for stopping a live run and
marking the task done or cancelled in one ordered flow.

## Linked Issues or Issue Description

No public GitHub issue exists for this internal Paperclip task. Internal
task: PAP-10535.

## Subsystem affected

ui/ — React + Vite board UI.

## Problem or motivation

Operators can stop an active run from the issue detail chat, but
finalizing the issue outcome requires a separate status action after the
run is stopped. That extra step makes live-run finalization slower and
easier to leave incomplete.

## Proposed solution

Add explicit issue chat run-menu actions for `Stop and cancel` and `Stop
and done`, where each action cancels the active heartbeat run before
updating the issue status.

## Alternatives considered

Keep the existing two-step flow of cancelling the run first and then
changing issue status separately. That preserves current behavior but
does not solve the operator workflow gap.

## Roadmap alignment

This is a small targeted UI control-plane improvement for supervising
live agent work. It does not duplicate a planned core roadmap item found
in `ROADMAP.md`.

This PR was split from the local `master` branch on June 7, 2026. It
covers the UI-only live-run finalization action. I searched GitHub for
duplicate/related PRs; the results were broader run-control PRs, not
this exact issue-detail menu action.

## What Changed

- Added optional `runFinalizationActions` support to `IssueChatThread`
assistant message run menus.
- Added `Stop and cancel` and `Stop and done` actions on the issue
detail chat tab.
- Each action cancels the active heartbeat run before updating the issue
status.
- Added focused UI coverage to assert cancellation happens before the
status update.
- Addressed Greptile feedback for partial-failure messaging and
duplicate run-state invalidation.

## Verification

- `git diff --check origin/master..HEAD`
- `git diff --check`
- `NODE_ENV=test pnpm exec vitest run ui/src/pages/IssueDetail.test.tsx`
- Storybook screenshot capture for the live-run menu before and after
the finalization actions.

## Screenshots

Before: existing live-run menu only offered the normal stop action.

![Before live-run
menu](https://raw.githubusercontent.com/paperclipai/paperclip/codex/live-run-stop-finalization/screenshots/PAP-10535-live-run-menu-before.png)

After: the live-run menu includes `Stop and cancel` and `Stop and done`.

![After live-run
menu](https://raw.githubusercontent.com/paperclipai/paperclip/codex/live-run-stop-finalization/screenshots/PAP-10535-live-run-menu-after.png)

## Risks

- Medium UI behavior risk: the new actions expose faster finalization
controls from the live-run menu. They are gated through the existing
issue detail management surface and still use the existing run cancel
and issue update APIs.
- Low migration risk: no schema, API contract, or dependency changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex coding agent based on GPT-5, with shell, git, GitHub CLI,
local test execution, and Playwright browser screenshot capture. Exact
hosted model variant and context-window size were not exposed by the
runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-06-07 06:26:17 -05:00
committed by GitHub
parent 4da79a88c6
commit 35c31ca63f
5 changed files with 243 additions and 1 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+38
View File
@@ -157,6 +157,7 @@ interface IssueChatMessageContext {
stopRunLabel?: string;
stoppingRunLabel?: string;
stopRunVariant?: "stop" | "pause";
runFinalizationActions?: readonly IssueChatRunFinalizationAction[];
onInterruptQueued?: (runId: string) => Promise<void>;
onCancelQueued?: (commentId: string) => void;
onDeleteComment?: (commentId: string) => Promise<void> | void;
@@ -194,6 +195,15 @@ const IssueChatCtx = createContext<IssueChatMessageContext>({
successfulRunHandoff: null,
});
export type IssueChatRunFinalizationAction = {
id: "cancel" | "done";
label: string;
pendingLabel: string;
onSelect: (runId: string) => Promise<void> | 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<string>;
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
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<string, unknown>;
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
@@ -1731,6 +1743,29 @@ function IssueChatAssistantMessage({
{isStoppingRun ? stoppingRunLabel : stopRunLabel}
</DropdownMenuItem>
) : null}
{canStopRun && runId
? runFinalizationActions.map((action) => (
<DropdownMenuItem
key={action.id}
disabled={isStoppingRun || action.isPending || action.disabled}
className={cn(
action.id === "cancel"
? "text-red-700 focus:text-red-800 dark:text-red-300 dark:focus:text-red-200"
: "text-green-700 focus:text-green-800 dark:text-green-300 dark:focus:text-green-200",
)}
onSelect={() => {
void action.onSelect(runId);
}}
>
{action.id === "cancel" ? (
<Square className="mr-2 h-3.5 w-3.5 fill-current" />
) : (
<Check className="mr-2 h-3.5 w-3.5" />
)}
{action.isPending ? action.pendingLabel : action.label}
</DropdownMenuItem>
))
: null}
{runHref ? (
<DropdownMenuItem asChild>
<Link to={runHref} target="_blank" rel="noreferrer noopener">
@@ -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,
+111
View File
@@ -219,6 +219,11 @@ vi.mock("../components/IssueChatThread", () => ({
onStopRun?: (runId: string) => Promise<void>;
stopRunLabel?: string;
stoppingRunLabel?: string;
runFinalizationActions?: readonly {
id: string;
label: string;
onSelect: (runId: string) => Promise<void> | void;
}[];
footer?: ReactNode;
}) => {
mockIssueChatThreadRender(props);
@@ -230,6 +235,15 @@ vi.mock("../components/IssueChatThread", () => ({
{props.stopRunLabel ?? "Stop run"}
</button>
) : null}
{props.runFinalizationActions?.map((action) => (
<button
key={action.id}
type="button"
onClick={() => void action.onSelect("run-active-1")}
>
{action.label}
</button>
))}
{props.footer}
</div>
);
@@ -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(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
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 () => {
+94 -1
View File
@@ -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<void>;
onDeleteComment?: (commentId: string) => Promise<void> | void;
onPauseWorkRun?: (runId: string) => Promise<void>;
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<string>([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<string, unknown>) => {
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<readonly IssueChatRunFinalizationAction[]>(() => [
{
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;