mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Add live run stop finalization actions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8b85fdfa3c
commit
0b1cd42614
3 files changed
+175
-1
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,60 @@ 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" });
|
||||
});
|
||||
|
||||
it("passes planning work mode to the issue chat thread", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "planning" }));
|
||||
await act(async () => {
|
||||
|
||||
@@ -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";
|
||||
@@ -671,6 +675,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 +743,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
onInterruptQueued,
|
||||
onDeleteComment,
|
||||
onPauseWorkRun,
|
||||
runFinalizationActions,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId,
|
||||
pausingWorkRunId,
|
||||
@@ -948,6 +954,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
stopRunLabel="Pause work"
|
||||
stoppingRunLabel="Pausing..."
|
||||
stopRunVariant="pause"
|
||||
runFinalizationActions={runFinalizationActions}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onSubmitInteractionAnswers={(interaction, answers) =>
|
||||
@@ -1968,6 +1975,38 @@ export function IssueDetail() {
|
||||
});
|
||||
},
|
||||
});
|
||||
const stopAndFinalizeRun = useMutation({
|
||||
mutationFn: async ({ runId, status }: { runId: string; status: "cancelled" | "done" }) => {
|
||||
await heartbeatsApi.cancel(runId);
|
||||
return issuesApi.update(issueId!, { status });
|
||||
},
|
||||
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 }) => {
|
||||
pushToast({
|
||||
title: 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: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
invalidateIssueRunState();
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
const handleIssuePropertiesUpdate = useCallback((data: Record<string, unknown>) => {
|
||||
updateIssue.mutate(data);
|
||||
}, [updateIssue.mutate]);
|
||||
@@ -3087,6 +3126,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),
|
||||
},
|
||||
{
|
||||
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),
|
||||
},
|
||||
], [
|
||||
stopAndFinalizeRun.isPending,
|
||||
stopAndFinalizeRun.mutateAsync,
|
||||
stopAndFinalizeRun.variables?.status,
|
||||
]);
|
||||
const handleAcceptInteraction = useCallback(async (
|
||||
interaction: ActionableIssueThreadInteraction,
|
||||
selectedClientKeys?: string[],
|
||||
@@ -3986,6 +4053,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;
|
||||
|
||||
Reference in new issue
Block a user