[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.  After: the live-run menu includes `Stop and cancel` and `Stop and done`.  ## 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:
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
@@ -157,6 +157,7 @@ interface IssueChatMessageContext {
|
|||||||
stopRunLabel?: string;
|
stopRunLabel?: string;
|
||||||
stoppingRunLabel?: string;
|
stoppingRunLabel?: string;
|
||||||
stopRunVariant?: "stop" | "pause";
|
stopRunVariant?: "stop" | "pause";
|
||||||
|
runFinalizationActions?: readonly IssueChatRunFinalizationAction[];
|
||||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||||
onCancelQueued?: (commentId: string) => void;
|
onCancelQueued?: (commentId: string) => void;
|
||||||
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
||||||
@@ -194,6 +195,15 @@ const IssueChatCtx = createContext<IssueChatMessageContext>({
|
|||||||
successfulRunHandoff: null,
|
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: {
|
export function resolveAssistantMessageFoldedState(args: {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
currentFolded: boolean;
|
currentFolded: boolean;
|
||||||
@@ -342,6 +352,7 @@ interface IssueChatThreadProps {
|
|||||||
stopRunLabel?: string;
|
stopRunLabel?: string;
|
||||||
stoppingRunLabel?: string;
|
stoppingRunLabel?: string;
|
||||||
stopRunVariant?: "stop" | "pause";
|
stopRunVariant?: "stop" | "pause";
|
||||||
|
runFinalizationActions?: readonly IssueChatRunFinalizationAction[];
|
||||||
imageUploadHandler?: (file: File) => Promise<string>;
|
imageUploadHandler?: (file: File) => Promise<string>;
|
||||||
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
|
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
|
||||||
draftKey?: string;
|
draftKey?: string;
|
||||||
@@ -1513,6 +1524,7 @@ function IssueChatAssistantMessage({
|
|||||||
stopRunLabel = "Stop run",
|
stopRunLabel = "Stop run",
|
||||||
stoppingRunLabel = "Stopping...",
|
stoppingRunLabel = "Stopping...",
|
||||||
stopRunVariant = "stop",
|
stopRunVariant = "stop",
|
||||||
|
runFinalizationActions = [],
|
||||||
} = useContext(IssueChatCtx);
|
} = useContext(IssueChatCtx);
|
||||||
const custom = message.metadata.custom as Record<string, unknown>;
|
const custom = message.metadata.custom as Record<string, unknown>;
|
||||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||||
@@ -1731,6 +1743,29 @@ function IssueChatAssistantMessage({
|
|||||||
{isStoppingRun ? stoppingRunLabel : stopRunLabel}
|
{isStoppingRun ? stoppingRunLabel : stopRunLabel}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : 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 ? (
|
{runHref ? (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={runHref} target="_blank" rel="noreferrer noopener">
|
<Link to={runHref} target="_blank" rel="noreferrer noopener">
|
||||||
@@ -3773,6 +3808,7 @@ export function IssueChatThread({
|
|||||||
stopRunLabel,
|
stopRunLabel,
|
||||||
stoppingRunLabel,
|
stoppingRunLabel,
|
||||||
stopRunVariant,
|
stopRunVariant,
|
||||||
|
runFinalizationActions,
|
||||||
imageUploadHandler,
|
imageUploadHandler,
|
||||||
onAttachImage,
|
onAttachImage,
|
||||||
draftKey,
|
draftKey,
|
||||||
@@ -4290,6 +4326,7 @@ export function IssueChatThread({
|
|||||||
stopRunLabel,
|
stopRunLabel,
|
||||||
stoppingRunLabel,
|
stoppingRunLabel,
|
||||||
stopRunVariant,
|
stopRunVariant,
|
||||||
|
runFinalizationActions,
|
||||||
onInterruptQueued: stableOnInterruptQueued,
|
onInterruptQueued: stableOnInterruptQueued,
|
||||||
onCancelQueued: stableOnCancelQueued,
|
onCancelQueued: stableOnCancelQueued,
|
||||||
onDeleteComment: stableOnDeleteComment,
|
onDeleteComment: stableOnDeleteComment,
|
||||||
@@ -4313,6 +4350,7 @@ export function IssueChatThread({
|
|||||||
stopRunLabel,
|
stopRunLabel,
|
||||||
stoppingRunLabel,
|
stoppingRunLabel,
|
||||||
stopRunVariant,
|
stopRunVariant,
|
||||||
|
runFinalizationActions,
|
||||||
stableOnInterruptQueued,
|
stableOnInterruptQueued,
|
||||||
stableOnCancelQueued,
|
stableOnCancelQueued,
|
||||||
stableOnDeleteComment,
|
stableOnDeleteComment,
|
||||||
|
|||||||
@@ -219,6 +219,11 @@ vi.mock("../components/IssueChatThread", () => ({
|
|||||||
onStopRun?: (runId: string) => Promise<void>;
|
onStopRun?: (runId: string) => Promise<void>;
|
||||||
stopRunLabel?: string;
|
stopRunLabel?: string;
|
||||||
stoppingRunLabel?: string;
|
stoppingRunLabel?: string;
|
||||||
|
runFinalizationActions?: readonly {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
onSelect: (runId: string) => Promise<void> | void;
|
||||||
|
}[];
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
mockIssueChatThreadRender(props);
|
mockIssueChatThreadRender(props);
|
||||||
@@ -230,6 +235,15 @@ vi.mock("../components/IssueChatThread", () => ({
|
|||||||
{props.stopRunLabel ?? "Stop run"}
|
{props.stopRunLabel ?? "Stop run"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{props.runFinalizationActions?.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void action.onSelect("run-active-1")}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
{props.footer}
|
{props.footer}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1436,6 +1450,103 @@ describe("IssueDetail", () => {
|
|||||||
expect(pauseMenuButton).toBeTruthy();
|
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 () => {
|
it("passes planning work mode to the issue chat thread", async () => {
|
||||||
mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "planning" }));
|
mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "planning" }));
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ import { useProjectOrder } from "../hooks/useProjectOrder";
|
|||||||
import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||||
import { ApprovalCard } from "../components/ApprovalCard";
|
import { ApprovalCard } from "../components/ApprovalCard";
|
||||||
import { InlineEditor } from "../components/InlineEditor";
|
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 { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
||||||
import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection";
|
import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection";
|
||||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||||
@@ -162,6 +166,24 @@ import {
|
|||||||
type IssueTreeControlMode,
|
type IssueTreeControlMode,
|
||||||
} from "@paperclipai/shared";
|
} 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 CommentReassignment = IssueCommentReassignment;
|
||||||
type ActionableIssueThreadInteraction =
|
type ActionableIssueThreadInteraction =
|
||||||
| SuggestTasksInteraction
|
| SuggestTasksInteraction
|
||||||
@@ -671,6 +693,7 @@ type IssueDetailChatTabProps = {
|
|||||||
onInterruptQueued: (runId: string) => Promise<void>;
|
onInterruptQueued: (runId: string) => Promise<void>;
|
||||||
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
||||||
onPauseWorkRun?: (runId: string) => Promise<void>;
|
onPauseWorkRun?: (runId: string) => Promise<void>;
|
||||||
|
runFinalizationActions?: readonly IssueChatRunFinalizationAction[];
|
||||||
onCancelQueued: (commentId: string) => void;
|
onCancelQueued: (commentId: string) => void;
|
||||||
interruptingQueuedRunId: string | null;
|
interruptingQueuedRunId: string | null;
|
||||||
pausingWorkRunId: string | null;
|
pausingWorkRunId: string | null;
|
||||||
@@ -738,6 +761,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
onDeleteComment,
|
onDeleteComment,
|
||||||
onPauseWorkRun,
|
onPauseWorkRun,
|
||||||
|
runFinalizationActions,
|
||||||
onCancelQueued,
|
onCancelQueued,
|
||||||
interruptingQueuedRunId,
|
interruptingQueuedRunId,
|
||||||
pausingWorkRunId,
|
pausingWorkRunId,
|
||||||
@@ -948,6 +972,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||||||
stopRunLabel="Pause work"
|
stopRunLabel="Pause work"
|
||||||
stoppingRunLabel="Pausing..."
|
stoppingRunLabel="Pausing..."
|
||||||
stopRunVariant="pause"
|
stopRunVariant="pause"
|
||||||
|
runFinalizationActions={runFinalizationActions}
|
||||||
onAcceptInteraction={onAcceptInteraction}
|
onAcceptInteraction={onAcceptInteraction}
|
||||||
onRejectInteraction={onRejectInteraction}
|
onRejectInteraction={onRejectInteraction}
|
||||||
onSubmitInteractionAnswers={(interaction, answers) =>
|
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>) => {
|
const handleIssuePropertiesUpdate = useCallback((data: Record<string, unknown>) => {
|
||||||
updateIssue.mutate(data);
|
updateIssue.mutate(data);
|
||||||
}, [updateIssue.mutate]);
|
}, [updateIssue.mutate]);
|
||||||
@@ -3087,6 +3151,34 @@ export function IssueDetail() {
|
|||||||
const handleInterruptQueuedRun = useCallback(async (runId: string) => {
|
const handleInterruptQueuedRun = useCallback(async (runId: string) => {
|
||||||
await interruptQueuedComment.mutateAsync(runId);
|
await interruptQueuedComment.mutateAsync(runId);
|
||||||
}, [interruptQueuedComment]);
|
}, [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 (
|
const handleAcceptInteraction = useCallback(async (
|
||||||
interaction: ActionableIssueThreadInteraction,
|
interaction: ActionableIssueThreadInteraction,
|
||||||
selectedClientKeys?: string[],
|
selectedClientKeys?: string[],
|
||||||
@@ -3986,6 +4078,7 @@ export function IssueDetail() {
|
|||||||
onPauseWorkRun={canManageTreeControl
|
onPauseWorkRun={canManageTreeControl
|
||||||
? (runId) => pauseIssueWorkRun.mutateAsync({ runId, scope: treeControlScope }).then(() => undefined)
|
? (runId) => pauseIssueWorkRun.mutateAsync({ runId, scope: treeControlScope }).then(() => undefined)
|
||||||
: undefined}
|
: undefined}
|
||||||
|
runFinalizationActions={runFinalizationActions}
|
||||||
onWorkModeChange={(nextMode) => {
|
onWorkModeChange={(nextMode) => {
|
||||||
const currentMode: IssueWorkMode = issue.workMode ?? "standard";
|
const currentMode: IssueWorkMode = issue.workMode ?? "standard";
|
||||||
if (currentMode === nextMode) return;
|
if (currentMode === nextMode) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user