mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
Integrate runner recovery through work-folder PR 13048
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
557f6fd054
95 files changed
+3079
-473
No files matched your search
@@ -3573,13 +3573,7 @@ describe("IssueChatThread", () => {
|
||||
expect(send().disabled).toBe(false);
|
||||
await act(async () => send().click());
|
||||
const expectedBody = `Inspect the file\n\n[fresh.txt](/api/attachments/${id}/content)`;
|
||||
expect(onAdd).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expectedBody,
|
||||
undefined,
|
||||
undefined,
|
||||
[id],
|
||||
);
|
||||
expect(onAdd).toHaveBeenNthCalledWith(1, expectedBody, undefined, undefined, [id], expect.any(String));
|
||||
expect(appendMock).not.toHaveBeenCalled();
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
@@ -3591,13 +3585,7 @@ describe("IssueChatThread", () => {
|
||||
).toBe("Inspect the file");
|
||||
expect(container.textContent).toContain("fresh.txt");
|
||||
await act(async () => send().click());
|
||||
expect(onAdd).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expectedBody,
|
||||
undefined,
|
||||
undefined,
|
||||
[id],
|
||||
);
|
||||
expect(onAdd).toHaveBeenNthCalledWith(2, expectedBody, undefined, undefined, [id], expect.any(String));
|
||||
expect(onAttachImage).toHaveBeenCalledTimes(1);
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
@@ -3668,6 +3656,64 @@ describe("IssueChatThread", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["late receipt", "reload receipt", "navigation success"])(
|
||||
"preserves a newer legacy draft after %s",
|
||||
async (outcome) => {
|
||||
const key = `legacy-next-draft-${outcome}`;
|
||||
let resolveSend!: () => void;
|
||||
let rejectSend!: (error: Error) => void;
|
||||
const onAdd = vi.fn().mockReturnValue(new Promise<void>((resolve, reject) => {
|
||||
resolveSend = resolve;
|
||||
rejectSend = reject;
|
||||
}));
|
||||
const attachmentId = "aaf8228f-0be7-45ae-a104-6fbe0af6f1d3";
|
||||
const onAttachImage = vi.fn().mockResolvedValue({
|
||||
id: attachmentId, contentPath: `/api/attachments/${attachmentId}/content`, originalFilename: "next-draft.txt",
|
||||
});
|
||||
let root = createRoot(container);
|
||||
const element = (requestId?: string) => (
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={requestId ? [{
|
||||
...issueChatLongThreadComments[0]!,
|
||||
id: "confirmed-legacy-comment", body: "Earlier message",
|
||||
authorAgentId: null, authorUserId: "user-1", clientRequestId: requestId,
|
||||
}] : []}
|
||||
currentUserId="user-1"
|
||||
linkedRuns={[]} timelineEvents={[]} liveRuns={[]}
|
||||
onAdd={onAdd} onAttachImage={onAttachImage} draftKey={key} enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const editor = () => container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Issue chat editor"]')!;
|
||||
const type = (value: string) => act(() => {
|
||||
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!.call(editor(), value);
|
||||
editor().dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => root.render(element()));
|
||||
type("Earlier message");
|
||||
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Send")!.click());
|
||||
const requestId = onAdd.mock.calls[0]![4] as string;
|
||||
type("Newer unsent draft");
|
||||
await act(async () => container.querySelector('[data-testid="issue-chat-composer"]')!
|
||||
.dispatchEvent(createFileDragEvent("drop", [new File(["next"], "next-draft.txt", { type: "text/plain" })])));
|
||||
if (outcome !== "late receipt") await act(async () => root.unmount());
|
||||
if (outcome === "navigation success") await act(async () => resolveSend());
|
||||
else if (outcome === "late receipt") await act(async () => rejectSend(new CommentSubmissionUnknownError()));
|
||||
if (outcome !== "late receipt") root = createRoot(container);
|
||||
await act(async () => root.render(element(requestId)));
|
||||
expect(editor().value).toBe("Newer unsent draft");
|
||||
expect(localStorage.getItem(key)).toBe("Newer unsent draft");
|
||||
expect(localStorage.getItem(`${key}:submission:v1`)).toBeNull();
|
||||
expect(localStorage.getItem(`${key}:attachments:v1`)).toContain(attachmentId);
|
||||
expect(container.textContent).toContain("next-draft.txt");
|
||||
expect(container.textContent).not.toContain("We couldn’t confirm");
|
||||
await act(async () => root.unmount());
|
||||
await act(async () => resolveSend());
|
||||
expect(localStorage.getItem(key)).toBe("Newer unsent draft");
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a reassigned legacy comment pending until its actual mutation promise settles", async () => {
|
||||
let resolveSend!: () => void;
|
||||
const onAdd = vi.fn().mockReturnValue(
|
||||
@@ -3716,7 +3762,7 @@ describe("IssueChatThread", () => {
|
||||
expect(onAdd).toHaveBeenCalledWith("Please review the result", undefined, {
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "reviewer",
|
||||
});
|
||||
}, undefined, expect.any(String));
|
||||
expect(appendMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Posting...");
|
||||
expect(localStorage.getItem("legacy-awaited-reassignment")).toBe(
|
||||
@@ -3967,11 +4013,7 @@ describe("IssueChatThread", () => {
|
||||
submitButton?.click();
|
||||
});
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith(
|
||||
"Please pick this back up",
|
||||
true,
|
||||
undefined,
|
||||
);
|
||||
expect(onAdd).toHaveBeenCalledWith("Please pick this back up", true, undefined, undefined, expect.any(String));
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
@@ -4044,11 +4086,7 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
expect(onAdd).toHaveBeenCalledWith(
|
||||
"Reply without assignee",
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(onAdd).toHaveBeenCalledWith("Reply without assignee", undefined, undefined, undefined, expect.any(String));
|
||||
expect(
|
||||
document.querySelector('[data-testid="issue-chat-no-assignee-dialog"]'),
|
||||
).toBeNull();
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
loadDraftSubmission,
|
||||
saveDraftSubmission,
|
||||
clearDraftSubmission,
|
||||
settleDraftSubmission,
|
||||
type ComposerDraftSubmission,
|
||||
} from "../lib/composer-draft";
|
||||
import { CommentSubmissionUnknownError } from "../lib/comment-submit-result";
|
||||
@@ -502,6 +503,7 @@ export interface IssueChatComposerHandle {
|
||||
|
||||
interface IssueChatComposerProps {
|
||||
onSend: IssueChatThreadProps["onAdd"];
|
||||
confirmedSubmissionIds: ReadonlySet<string>;
|
||||
onReviewConversation?: () => Promise<void>;
|
||||
onStop?: () => Promise<void>;
|
||||
stopPending?: boolean;
|
||||
@@ -4668,6 +4670,7 @@ const IssueChatComposer = forwardRef<
|
||||
>(function IssueChatComposer(
|
||||
{
|
||||
onSend,
|
||||
confirmedSubmissionIds,
|
||||
onReviewConversation,
|
||||
onStop,
|
||||
stopPending,
|
||||
@@ -4713,6 +4716,30 @@ const IssueChatComposer = forwardRef<
|
||||
}, [draftKey]);
|
||||
const bodyRef = useRef(body);
|
||||
bodyRef.current = body;
|
||||
const pendingDraftRef = useRef<{
|
||||
draftKey: string;
|
||||
attemptId: string;
|
||||
submittedBody: string;
|
||||
submittedAttachmentIds: string[];
|
||||
} | null>(null);
|
||||
function changeBody(update: string | ((current: string) => string)) {
|
||||
const value = typeof update === "function" ? update(bodyRef.current) : update;
|
||||
bodyRef.current = value;
|
||||
setBody(value);
|
||||
const pending = pendingDraftRef.current;
|
||||
if (!pending || pending.draftKey !== draftKey ||
|
||||
loadDraftSubmission(pending.draftKey)?.attemptId !== pending.attemptId) return;
|
||||
// Persist the next draft while delivery is pending, before navigation or a
|
||||
// lost response can turn the original submission into an uncertain one.
|
||||
saveDraft(pending.draftKey,
|
||||
value ? `${pending.submittedBody}\n\n${value}` : pending.submittedBody,
|
||||
pending.attemptId);
|
||||
saveDraftSubmission(pending.draftKey, {
|
||||
attemptId: pending.attemptId, reviewed: false,
|
||||
nextDraftOffset: pending.submittedBody.length + (value ? 2 : 0),
|
||||
submittedAttachmentIds: pending.submittedAttachmentIds,
|
||||
});
|
||||
}
|
||||
const submittingRef = useRef(submitting);
|
||||
submittingRef.current = submitting;
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
@@ -4741,6 +4768,12 @@ const IssueChatComposer = forwardRef<
|
||||
: update;
|
||||
composerAttachmentsRef.current = next;
|
||||
setComposerAttachmentState(next);
|
||||
const pending = pendingDraftRef.current;
|
||||
if (pending && pending.draftKey === draftKey) {
|
||||
saveDraftAttachments(pending.draftKey, next
|
||||
.filter(item => item.status === "attached" && item.attachmentId)
|
||||
.map(item => ({ ...item, inline: item.inline === true })), pending.attemptId);
|
||||
}
|
||||
}
|
||||
const dragDepthRef = useRef(0);
|
||||
const effectiveSuggestedAssigneeValue =
|
||||
@@ -4810,6 +4843,22 @@ const IssueChatComposer = forwardRef<
|
||||
);
|
||||
}, [draftKey]);
|
||||
|
||||
// A server receipt for this exact request settles a restored submission.
|
||||
// Text equality is not delivery proof: users may intentionally repeat text.
|
||||
useEffect(() => {
|
||||
if (!uncertainSubmission || !confirmedSubmissionIds.has(uncertainSubmission.attemptId)) return;
|
||||
const nextDraft = uncertainSubmission.nextDraftOffset === undefined
|
||||
? "" : bodyRef.current.slice(uncertainSubmission.nextDraftOffset);
|
||||
if (draftKey) settleDraftSubmission(draftKey, uncertainSubmission.attemptId, nextDraft);
|
||||
setUncertainSubmission(null);
|
||||
setBody(nextDraft);
|
||||
bodyRef.current = nextDraft;
|
||||
const submittedIds = uncertainSubmission.submittedAttachmentIds;
|
||||
setComposerAttachments(current => submittedIds
|
||||
? current.filter(item => !item.attachmentId || !submittedIds.includes(item.attachmentId))
|
||||
: []);
|
||||
}, [confirmedSubmissionIds, draftKey, uncertainSubmission]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!draftKey ||
|
||||
@@ -4960,6 +5009,7 @@ const IssueChatComposer = forwardRef<
|
||||
const workModeChanged = pendingWorkMode !== resolvedIssueWorkMode;
|
||||
if (draftKey) saveDraft(draftKey, trimmed);
|
||||
setSubmitting(true);
|
||||
bodyRef.current = "";
|
||||
setBody("");
|
||||
let attemptId: string | null = null;
|
||||
try {
|
||||
@@ -4976,37 +5026,45 @@ const IssueChatComposer = forwardRef<
|
||||
if (draftKey) {
|
||||
saveDraft(draftKey, trimmed);
|
||||
saveDraftSubmission(draftKey, { attemptId, reviewed: false });
|
||||
pendingDraftRef.current = { draftKey, attemptId, submittedBody: trimmed, submittedAttachmentIds: attachmentIds };
|
||||
changeBody(bodyRef.current);
|
||||
}
|
||||
// assistant-ui thread.append is fire-and-forget. Await the actual Board
|
||||
// mutation; it already owns optimistic echo and durable error handling.
|
||||
const sendPromise = attachmentIds.length
|
||||
? onSend(submittedBody, reopen, reassignment, attachmentIds)
|
||||
: onSend(submittedBody, reopen, reassignment);
|
||||
const sendPromise = onSend(
|
||||
submittedBody, reopen, reassignment,
|
||||
attachmentIds.length ? attachmentIds : undefined, attemptId,
|
||||
);
|
||||
queueViewportRestore(viewportSnapshot);
|
||||
await sendPromise;
|
||||
// Settle the captured task even if the user navigated away. The exact
|
||||
// attempt guard preserves any newer submission in this or another tab.
|
||||
if (draftKey) settleDraftSubmission(draftKey, attemptId,
|
||||
mountedTaskKey.current === draftKey ? bodyRef.current : undefined);
|
||||
if (mountedTaskKey.current !== draftKey) return;
|
||||
if (draftKey) clearDraftSubmission(draftKey, attemptId);
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
setComposerAttachments((current) =>
|
||||
current.filter((item) => !submittedAttachmentKeys.has(item.id)),
|
||||
);
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
} catch (error) {
|
||||
if (mountedTaskKey.current !== draftKey) return;
|
||||
const nextDraft = bodyRef.current;
|
||||
if (attemptId && error instanceof CommentSubmissionUnknownError) {
|
||||
const uncertain = { attemptId, reviewed: false };
|
||||
const uncertain = {
|
||||
attemptId, reviewed: false,
|
||||
nextDraftOffset: trimmed.length + (nextDraft ? 2 : 0),
|
||||
submittedAttachmentIds: attachmentIds,
|
||||
};
|
||||
setUncertainSubmission(uncertain);
|
||||
if (draftKey && loadDraftSubmission(draftKey)?.attemptId === attemptId)
|
||||
saveDraftSubmission(draftKey, uncertain);
|
||||
} else if (draftKey && attemptId)
|
||||
clearDraftSubmission(draftKey, attemptId);
|
||||
const restoredBody = restoreSubmittedCommentDraft({
|
||||
currentBody: bodyRef.current,
|
||||
submittedBody: trimmed,
|
||||
});
|
||||
const restoredBody = nextDraft ? `${trimmed}\n\n${nextDraft}` : trimmed;
|
||||
if (draftKey) saveDraft(draftKey, restoredBody, attemptId ?? undefined);
|
||||
setBody(restoredBody);
|
||||
} finally {
|
||||
if (pendingDraftRef.current?.attemptId === attemptId) pendingDraftRef.current = null;
|
||||
setSubmitting(false);
|
||||
queueViewportRestore(viewportSnapshot);
|
||||
}
|
||||
@@ -5041,7 +5099,7 @@ const IssueChatComposer = forwardRef<
|
||||
const safeName = file.name.replace(/[[\]]/g, "\\$&");
|
||||
const markdown = ``;
|
||||
if (insertInline)
|
||||
setBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown));
|
||||
changeBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown));
|
||||
setComposerAttachments((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === attachmentId
|
||||
@@ -5062,7 +5120,7 @@ const IssueChatComposer = forwardRef<
|
||||
return undefined;
|
||||
if (inline && insertInline) {
|
||||
const markdown = `![${file.name.replace(/[[\]]/g, "\\$&")}](${attachment.contentPath})`;
|
||||
setBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown));
|
||||
changeBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown));
|
||||
}
|
||||
setComposerAttachments((prev) =>
|
||||
prev.map((item) =>
|
||||
@@ -5243,7 +5301,7 @@ const IssueChatComposer = forwardRef<
|
||||
`(?<![\\w@/])${plainNameCandidate.matchedText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/])`,
|
||||
"i",
|
||||
);
|
||||
setBody((current) => {
|
||||
changeBody((current) => {
|
||||
if (tokenRe.test(current))
|
||||
return current.replace(tokenRe, markdown.trimEnd());
|
||||
return current ? `${current} ${markdown}` : markdown;
|
||||
@@ -5385,7 +5443,7 @@ const IssueChatComposer = forwardRef<
|
||||
ref={editorRef}
|
||||
readOnly={!!uncertainSubmission}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
onChange={changeBody}
|
||||
placeholder="Reply"
|
||||
mentions={mentions}
|
||||
onSubmit={handleSubmit}
|
||||
@@ -5641,11 +5699,7 @@ const IssueChatComposer = forwardRef<
|
||||
disabled={stopControl.stopping}
|
||||
onClick={() => void stopControl.stop()}
|
||||
aria-label={stopControl.stopping ? "Stopping…" : "Stop"}
|
||||
title={
|
||||
stopScope === "subtree"
|
||||
? "Stop and pause subtree"
|
||||
: "Stop and pause task"
|
||||
}
|
||||
title="Stop response"
|
||||
>
|
||||
{stopControl.stopping ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
@@ -6053,11 +6107,9 @@ export function IssueChatThread({
|
||||
}
|
||||
|
||||
const sendComposerComment = useCallback<IssueChatThreadProps["onAdd"]>(
|
||||
(body, reopen, reassignment, attachmentIds) => {
|
||||
(body, reopen, reassignment, attachmentIds, clientRequestId) => {
|
||||
pendingSubmitScrollRef.current = true;
|
||||
return attachmentIds?.length
|
||||
? onAdd(body, reopen, reassignment, attachmentIds)
|
||||
: onAdd(body, reopen, reassignment);
|
||||
return onAdd(body, reopen, reassignment, attachmentIds, clientRequestId);
|
||||
},
|
||||
[onAdd],
|
||||
);
|
||||
@@ -6723,6 +6775,10 @@ export function IssueChatThread({
|
||||
onImageUpload={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
confirmedSubmissionIds={new Set(comments.filter((comment) =>
|
||||
comment.authorUserId === currentUserId && comment.clientRequestId &&
|
||||
!("clientStatus" in comment && comment.clientStatus)
|
||||
).map((comment) => comment.clientRequestId!))}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
|
||||
@@ -29,6 +29,16 @@ function derived(overrides: Partial<DerivedMonitorState> & { state: DerivedMonit
|
||||
}
|
||||
|
||||
describe("buildMonitorSurfaceCopy", () => {
|
||||
it.each(["retrying", "due-now", "overdue"] as const)("keeps workspace contention neutral when %s", (state) => {
|
||||
const copy = buildMonitorSurfaceCopy(derived({
|
||||
state, source: "scheduled-retry", nextCheckAt: NOW.toISOString(), attemptCount: 4,
|
||||
}), NOW, "workspace_busy");
|
||||
expect(copy!.bannerTitle).toBe("Waiting for workspace");
|
||||
expect(copy!.stripTitle).toBe("Waiting for workspace");
|
||||
expect(copy!.tone).toBe("info");
|
||||
expect(copy!.workspaceWait).toBe(true);
|
||||
expect(copy!.bannerMeta.join(" ")).not.toMatch(/Attempt|overdue|retry/i);
|
||||
});
|
||||
it("leads with two-unit relative time while scheduled", () => {
|
||||
const copy = buildMonitorSurfaceCopy(
|
||||
derived({
|
||||
@@ -135,6 +145,19 @@ describe("IssueMonitorBanner / IssueMonitorComposerStrip rendering", () => {
|
||||
} as unknown as Issue;
|
||||
}
|
||||
|
||||
it("explains automatic workspace waiting without promising that a reply bypasses the lock", () => {
|
||||
const issue = {
|
||||
status: "todo", scheduledRetry: { status: "scheduled_retry", scheduledRetryReason: "workspace_busy", scheduledRetryAt: NOW.toISOString() },
|
||||
} as Issue;
|
||||
const root = createRoot(container);
|
||||
flushSync(() => root.render(<><IssueMonitorBanner issue={issue} onCheckNow={vi.fn()} /><IssueMonitorComposerStrip issue={issue} onCheckNow={vi.fn()} /></>));
|
||||
expect(container.textContent).toContain("Waiting for workspace");
|
||||
expect(container.textContent).toContain("You can keep sending instructions while the agent waits.");
|
||||
expect(container.textContent).not.toContain("wakes the agent now");
|
||||
expect(container.querySelector("button")).toBeNull();
|
||||
flushSync(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders the banner with a working Check now button while waiting", () => {
|
||||
const onCheckNow = vi.fn();
|
||||
expect(hasVisibleMonitorSurface(issueWithMonitor(new Date(NOW.getTime() + 2 * 60 * 60_000).toISOString()))).toBe(true);
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface MonitorSurfaceCopy {
|
||||
stripMeta: string[];
|
||||
/** `warning` (amber) once overdue, `info` (blue) while still on schedule. */
|
||||
tone: "info" | "warning";
|
||||
workspaceWait?: boolean;
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
@@ -63,9 +64,21 @@ function capitalize(value: string): string {
|
||||
export function buildMonitorSurfaceCopy(
|
||||
derived: DerivedMonitorState,
|
||||
now: MonitorDate,
|
||||
scheduledRetryReason?: string | null,
|
||||
): MonitorSurfaceCopy | null {
|
||||
if (!isWaitingMonitorState(derived.state) || !derived.nextCheckAt) return null;
|
||||
|
||||
if (derived.source === "scheduled-retry" && scheduledRetryReason === "workspace_busy") {
|
||||
return {
|
||||
bannerTitle: "Waiting for workspace",
|
||||
stripTitle: "Waiting for workspace",
|
||||
bannerMeta: ["Another task is using this workspace. Work starts automatically when it is available."],
|
||||
stripMeta: ["Work starts automatically when the workspace is available."],
|
||||
tone: "info",
|
||||
workspaceWait: true,
|
||||
};
|
||||
}
|
||||
|
||||
const eta = formatMonitorEta(derived.nextCheckAt, now); // "in 2h 12m" | "due now" | "overdue by 18m"
|
||||
const absolute = formatMonitorAbsolute(derived.nextCheckAt, {}, now); // local time, e.g. "Today, 4:08 PM"
|
||||
const isScheduledRetryOnly = derived.source === "scheduled-retry";
|
||||
@@ -117,7 +130,7 @@ function useMonitorSurfaceCopy(issue: Issue): MonitorSurfaceCopy | null {
|
||||
// roll scheduled → due → overdue on their own.
|
||||
const nextCheckAt = useMemo(() => deriveMonitorState(issue).nextCheckAt, [issue]);
|
||||
const now = useMonitorCountdown(nextCheckAt);
|
||||
return useMemo(() => buildMonitorSurfaceCopy(deriveMonitorState(issue, now), now), [issue, now]);
|
||||
return useMemo(() => buildMonitorSurfaceCopy(deriveMonitorState(issue, now), now, issue.scheduledRetry?.scheduledRetryReason), [issue, now]);
|
||||
}
|
||||
|
||||
function CheckNowButton({
|
||||
@@ -166,7 +179,7 @@ export function IssueMonitorBanner({
|
||||
icon={Clock}
|
||||
title={copy.bannerTitle}
|
||||
className="my-3"
|
||||
actions={onCheckNow ? <CheckNowButton onCheckNow={onCheckNow} checkingNow={checkingNow} /> : null}
|
||||
actions={onCheckNow && !copy.workspaceWait ? <CheckNowButton onCheckNow={onCheckNow} checkingNow={checkingNow} /> : null}
|
||||
>
|
||||
<span>{copy.bannerMeta.join(" · ")}</span>
|
||||
</InlineBanner>
|
||||
@@ -201,10 +214,12 @@ export function IssueMonitorComposerStrip({
|
||||
<div className="text-xs text-muted-foreground">{copy.stripMeta.join(" · ")}</div>
|
||||
</div>
|
||||
</div>
|
||||
{onCheckNow ? <CheckNowButton onCheckNow={onCheckNow} checkingNow={checkingNow} /> : null}
|
||||
{onCheckNow && !copy.workspaceWait ? <CheckNowButton onCheckNow={onCheckNow} checkingNow={checkingNow} /> : null}
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Sending a reply wakes the agent now — before the scheduled check.
|
||||
{copy.workspaceWait
|
||||
? "You can keep sending instructions while the agent waits."
|
||||
: "Sending a reply wakes the agent now — before the scheduled check."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -121,6 +121,13 @@ function getRetryNowButton() {
|
||||
}
|
||||
|
||||
describe("IssueScheduledRetryCard", () => {
|
||||
it("shows workspace contention as an automatic wait without failure or retry controls", () => {
|
||||
renderWithProviders(<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={{ ...baseRetry, scheduledRetryReason: "workspace_busy" }} />);
|
||||
expect(container.textContent).toContain("Waiting for workspace");
|
||||
expect(container.textContent).toContain("Work starts automatically");
|
||||
expect(container.textContent).not.toMatch(/failed|Retry|Attempt|Replaces run/);
|
||||
expect(getRetryNowButton()).toBeNull();
|
||||
});
|
||||
it("renders nothing when there is no scheduled retry", () => {
|
||||
renderWithProviders(<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={null} />);
|
||||
expect(getCard()).toBeNull();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { formatRetryReason } from "@/lib/runRetryState";
|
||||
import type { IssueScheduledRetry } from "@paperclipai/shared";
|
||||
import { useRetryNowMutation, type RetryNowError } from "../hooks/useRetryNowMutation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
|
||||
const MAX_TURN_CONTINUATION = "max_turns_continuation";
|
||||
|
||||
@@ -32,6 +33,14 @@ export function IssueScheduledRetryCard({
|
||||
if (!scheduledRetry || !issueId) return null;
|
||||
if (scheduledRetry.status !== "scheduled_retry") return null;
|
||||
|
||||
if (scheduledRetry.scheduledRetryReason === "workspace_busy") {
|
||||
return (
|
||||
<InlineBanner tone="info" icon={Clock} title="Waiting for workspace" className="mb-3">
|
||||
Another task is using this workspace. Work starts automatically when it is available.
|
||||
</InlineBanner>
|
||||
);
|
||||
}
|
||||
|
||||
const continuation = isContinuationReason(scheduledRetry.scheduledRetryReason);
|
||||
const dueAtIso = scheduledRetry.scheduledRetryAt
|
||||
? new Date(scheduledRetry.scheduledRetryAt).toISOString()
|
||||
|
||||
@@ -2012,6 +2012,52 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
return { root, queryClient };
|
||||
}
|
||||
|
||||
it.each([
|
||||
["claude_local", "anthropic", /Claude/, "claude-session-1", "claude-setup-token-status"],
|
||||
["codex_local", "openai", /OpenAI/, "codex-session-1", "adapter-login-status"],
|
||||
] as const)("finishes %s sign-in when its connection becomes visible before the completion poll", async (adapterType, provider, label, sessionId, statusKey) => {
|
||||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||||
mockAgentsApi.hire.mockRejectedValueOnce(new Error("Temporary hire failure"));
|
||||
const { root, queryClient } = await openStep4({ adapterType });
|
||||
await pickSource(label);
|
||||
for (let i = 0; i < 6; i++) await flushReact();
|
||||
try {
|
||||
// The connection activity event arrives before the login poll. It must
|
||||
// not replace/unmount the controller that still owns the completion.
|
||||
await act(async () => {
|
||||
queryClient.setQueryData(["ai-connections", "company-new"], {
|
||||
currentUserId: "user-1",
|
||||
connections: [{ id: "managed-connection", grantId: "managed-grant", companyId: "company-new", provider, method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
|
||||
});
|
||||
});
|
||||
for (let i = 0; i < 4; i++) await flushReact();
|
||||
expect(document.body.textContent).toContain(adapterType === "claude_local" ? "authorization code" : "Q2RJ-E1YIF");
|
||||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
queryClient.setQueryData(
|
||||
adapterType === "claude_local" ? [statusKey, "company-new", sessionId] : [statusKey, "company-new", adapterType, sessionId],
|
||||
{ sessionId, status: "authenticated", expiresAt: new Date(Date.now() + 600_000).toISOString() },
|
||||
);
|
||||
});
|
||||
for (let i = 0; i < 120 && !mockAgentsApi.hire.mock.calls.length; i++) {
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 25)); });
|
||||
}
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalledWith("company-new", expect.objectContaining({ runtimeConfig: expect.objectContaining({ aiConnection: { provider, method: "subscription", mode: "responsible_user" } }) }));
|
||||
for (let i = 0; i < 4; i++) await flushReact();
|
||||
expect(document.body.textContent).toContain("Temporary hire failure");
|
||||
const retry = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Connect");
|
||||
expect(retry).toBeTruthy();
|
||||
expect(retry!.disabled).toBe(false);
|
||||
await act(async () => { retry!.click(); });
|
||||
for (let i = 0; i < 6; i++) await flushReact();
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(2);
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin.mock.calls.length + mockAgentsApi.startAdapterAuthLogin.mock.calls.length).toBe(1);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
it("names the tiles for the provider, not the adapter type", async () => {
|
||||
// `MODEL_SOURCE_NAMES` exists so this row says "Claude" and "OpenAI" —
|
||||
// which provider you are signing in to, the question the step's heading
|
||||
|
||||
@@ -1150,9 +1150,14 @@ function OnboardingWizardInner({
|
||||
*/
|
||||
const connectStepNeedsLogin = Boolean(
|
||||
credentialMode !== "api" &&
|
||||
(showAdapterLoginPanel || (canShowAdapterLogin && adapterType === "codex_local" && subscriptionId?.companyId === createdCompanyId && subscriptionId.id === "")) &&
|
||||
!savedSubscription &&
|
||||
!(adapterType === "claude_local" && savedKeys.storedLogin.data) &&
|
||||
// Connection-list invalidation can arrive before the login's completion
|
||||
// poll. Keep its controller mounted until it reports success; otherwise
|
||||
// the saved account replaces the panel and "Connecting" never finishes.
|
||||
(connectAuthUrl || (
|
||||
(showAdapterLoginPanel || (canShowAdapterLogin && adapterType === "codex_local" && subscriptionId?.companyId === createdCompanyId && subscriptionId.id === "")) &&
|
||||
!savedSubscription &&
|
||||
!(adapterType === "claude_local" && savedKeys.storedLogin.data)
|
||||
)) &&
|
||||
!savedKeys.loading &&
|
||||
createdCompanyId &&
|
||||
resolvedLoginEnvironmentId,
|
||||
@@ -2154,6 +2159,13 @@ function OnboardingWizardInner({
|
||||
} finally {
|
||||
hiringAgentRef.current = false;
|
||||
setLoading(false);
|
||||
// Authentication is already saved. A failed probe or hire must offer a
|
||||
// retry with that account, rather than keep the completed login busy.
|
||||
if (connectCredentialStored && stillTheSameCompany(createdCompanyId)) {
|
||||
connectingSinceRef.current = null;
|
||||
setConnectAuthUrl(null);
|
||||
setConnectPhase((phase) => phase === "connecting" ? "ready" : phase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2740,6 +2752,7 @@ function OnboardingWizardInner({
|
||||
}}
|
||||
onConnected={() => {
|
||||
if (managedProvider) managedSubscriptionRef.current = { companyId: createdCompanyId, binding: { provider: managedProvider, method: "subscription", mode: "responsible_user" } };
|
||||
setConnectAuthUrl(null);
|
||||
// Not into a card the customer has left. The panel is
|
||||
// still mounted through Back's exit, and a login that
|
||||
// finished there pulled the step back into "Connecting"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import type { ComponentProps, ReactElement } from "react";
|
||||
import { act, forwardRef, useImperativeHandle, type ForwardedRef } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
@@ -1247,6 +1247,17 @@ describe("TaskChatThread runtime transcript selection", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps workspace contention out of the conversation's cancellation markers", () => {
|
||||
render(<TaskChatThread comments={[]} onAdd={async () => {}} linkedRuns={[{
|
||||
runId: "workspace-wait", runtimeMode: "native", status: "cancelled", errorCode: "workspace_busy",
|
||||
agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", startedAt: null,
|
||||
createdAt: "2026-09-12T18:00:00.000Z", finishedAt: "2026-09-12T18:00:01.000Z",
|
||||
}]} />);
|
||||
expect(container.textContent).not.toContain("Run cancelled");
|
||||
expect(container.textContent).not.toContain("Run failed");
|
||||
expect(container.textContent).not.toContain("before returning an answer");
|
||||
});
|
||||
|
||||
it("does not render an empty response notice for a conversation reset", () => {
|
||||
render(<TaskChatThread comments={[]} onAdd={async () => {}} linkedRuns={[{
|
||||
runId: "chat-reset", status: "succeeded", startedAt: null, resultJson: { conversationReset: true },
|
||||
@@ -1986,41 +1997,156 @@ describe("TaskChatThread runtime transcript selection", () => {
|
||||
expect(container.textContent).not.toContain("transcript withheld");
|
||||
});
|
||||
|
||||
it("keeps an empty failed direct run on its legacy failure surface", () => {
|
||||
it.each(["failed", "timed_out", "cancelled"] as const)(
|
||||
"keeps an empty legacy %s actionable without turning Stop into Retry",
|
||||
async (status) => {
|
||||
const onRetryFailedRun = vi.fn();
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[]}
|
||||
onAdd={async () => {}}
|
||||
issueStatus="blocked"
|
||||
onRetryFailedRun={onRetryFailedRun}
|
||||
linkedRuns={[
|
||||
{
|
||||
runId: "legacy-failed",
|
||||
runtimeMode: "legacy",
|
||||
status,
|
||||
errorCode: "legacy_process_exited",
|
||||
agentId: "agent-1",
|
||||
agentName: "Direct Codex",
|
||||
adapterType: "codex_local",
|
||||
createdAt: "2026-08-25T18:00:00.000Z",
|
||||
startedAt: "2026-08-25T18:00:00.000Z",
|
||||
finishedAt: "2026-08-25T18:00:02.000Z",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain(status === "cancelled" ? "Stopped" : "Run failed");
|
||||
if (status !== "cancelled") {
|
||||
expect(container.textContent).toContain("You can retry this message now.");
|
||||
}
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-collapsible-marker"]'),
|
||||
).toBeNull();
|
||||
const retry = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-run-failed-try-again"]');
|
||||
if (status === "cancelled") {
|
||||
expect(retry).toBeNull();
|
||||
} else {
|
||||
expect(retry).not.toBeNull();
|
||||
flushSync(() => retry!.click());
|
||||
await Promise.resolve();
|
||||
expect(onRetryFailedRun).toHaveBeenCalledExactlyOnceWith("legacy-failed");
|
||||
}
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-runner-turn"]'),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["active execution", "pending decision", "recovery hold"] as const)(
|
||||
"does not promise legacy Retry during %s and restores it when the gate clears",
|
||||
(gate) => {
|
||||
const onRetryFailedRun = vi.fn();
|
||||
const failedRun = {
|
||||
runId: "legacy-failed",
|
||||
runtimeMode: "legacy" as const,
|
||||
status: "failed",
|
||||
errorCode: "legacy_process_exited",
|
||||
agentId: "agent-1",
|
||||
agentName: "Direct Codex",
|
||||
adapterType: "codex_local",
|
||||
createdAt: "2026-08-25T18:00:00.000Z",
|
||||
startedAt: "2026-08-25T18:00:00.000Z",
|
||||
finishedAt: "2026-08-25T18:00:02.000Z",
|
||||
};
|
||||
const gateProps: Partial<ComponentProps<typeof TaskChatThread>> =
|
||||
gate === "pending decision"
|
||||
? { interactions: [planReviewInteraction()] }
|
||||
: {
|
||||
linkedRuns: [{
|
||||
...failedRun,
|
||||
execution: {
|
||||
phase: gate === "active execution" ? "working" : "recovery_needed",
|
||||
label: "Waiting",
|
||||
cause: null,
|
||||
lastConfirmedActivityAt: null,
|
||||
retryAt: null,
|
||||
attempt: 1,
|
||||
maxAttempts: 3,
|
||||
recoveryOwner: null,
|
||||
nextAction: null,
|
||||
permittedActions: [],
|
||||
predecessorRunId: null,
|
||||
successorRunId: null,
|
||||
},
|
||||
}],
|
||||
};
|
||||
const renderRun = (held: boolean) => render(
|
||||
<TaskChatThread
|
||||
comments={[]}
|
||||
onAdd={async () => {}}
|
||||
issueStatus="blocked"
|
||||
onRetryFailedRun={onRetryFailedRun}
|
||||
linkedRuns={[failedRun]}
|
||||
{...(held ? gateProps : {})}
|
||||
/>,
|
||||
);
|
||||
renderRun(true);
|
||||
expect(container.textContent).toContain("Your message is preserved.");
|
||||
expect(container.textContent).not.toContain("You can retry this message now.");
|
||||
expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).toBeNull();
|
||||
renderRun(false);
|
||||
expect(container.textContent).toContain("You can retry this message now.");
|
||||
expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).not.toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
...["claude_local", "codex_local", "cursor", "gemini_local", "opencode_local",
|
||||
"pi_local", "grok_local", "kimi_local", "hermes_local"].flatMap((adapterType) =>
|
||||
["failed", "timed_out"].map((status) => ({ adapterType, status, runtimeMode: "legacy" as const,
|
||||
cause: "legacy_execution_requires_reconciliation", canRetry: true }))),
|
||||
{ adapterType: "paperclip_runner", status: "failed", runtimeMode: "native" as const,
|
||||
cause: "legacy_execution_requires_reconciliation", canRetry: false },
|
||||
{ adapterType: "process", status: "failed", runtimeMode: "legacy" as const,
|
||||
cause: "legacy_execution_requires_reconciliation", canRetry: false },
|
||||
{ adapterType: "claude_local", status: "failed", runtimeMode: "legacy" as const,
|
||||
cause: "uncertain_provider_action", canRetry: false },
|
||||
{ adapterType: "claude_local", status: "cancelled", runtimeMode: "legacy" as const,
|
||||
cause: "legacy_execution_requires_reconciliation", canRetry: false },
|
||||
])("offers only explicit conversation retries: $runtimeMode/$adapterType/$status/$cause", (testCase) => {
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[]}
|
||||
onAdd={async () => {}}
|
||||
issueStatus="blocked"
|
||||
onRetryFailedRun={vi.fn()}
|
||||
linkedRuns={[
|
||||
{
|
||||
runId: "legacy-failed",
|
||||
runtimeMode: "legacy",
|
||||
status: "failed",
|
||||
errorCode: "legacy_process_exited",
|
||||
agentId: "agent-1",
|
||||
agentName: "Direct Codex",
|
||||
adapterType: "codex_local",
|
||||
createdAt: "2026-08-25T18:00:00.000Z",
|
||||
startedAt: "2026-08-25T18:00:00.000Z",
|
||||
finishedAt: "2026-08-25T18:00:02.000Z",
|
||||
linkedRuns={[{
|
||||
runId: "stopped-run",
|
||||
runtimeMode: testCase.runtimeMode,
|
||||
adapterType: testCase.adapterType,
|
||||
status: testCase.status,
|
||||
agentId: "agent-1",
|
||||
agentName: "Stopped agent",
|
||||
createdAt: "2026-08-25T18:00:00.000Z",
|
||||
startedAt: "2026-08-25T18:00:00.000Z",
|
||||
finishedAt: "2026-08-25T18:00:02.000Z",
|
||||
execution: {
|
||||
phase: "recovery_needed", label: "Stopped", cause: testCase.cause,
|
||||
lastConfirmedActivityAt: null, retryAt: null, attempt: 1, maxAttempts: 3,
|
||||
recoveryOwner: null, nextAction: null, permittedActions: [],
|
||||
predecessorRunId: null, successorRunId: null,
|
||||
},
|
||||
]}
|
||||
}]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Run failed");
|
||||
expect(container.textContent).toContain("You can retry this message now.");
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-collapsible-marker"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-run-failed-try-again"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-runner-turn"]'),
|
||||
).toBeNull();
|
||||
expect(Boolean(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')))
|
||||
.toBe(testCase.canRetry);
|
||||
if (testCase.canRetry) expect(container.textContent).toContain("You can retry this message now.");
|
||||
else expect(container.textContent).not.toContain("You can retry this message now.");
|
||||
});
|
||||
|
||||
it.each(DIRECT_ADAPTER_TYPES)(
|
||||
@@ -3581,7 +3707,7 @@ describe("TaskChatThread composer execution controls", () => {
|
||||
};
|
||||
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueStatus="in_progress" activeRun={run} onCancelRun={onStop} stopScope="subtree" />);
|
||||
const button = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-stop"]')!;
|
||||
expect(button.title).toBe("Stop and pause subtree");
|
||||
expect(button.title).toBe("Stop response");
|
||||
await act(async () => { button.click(); });
|
||||
expect(onStop).toHaveBeenCalledOnce();
|
||||
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueStatus="in_progress" activeRun={run} onCancelRun={onStop} stopPending />);
|
||||
|
||||
@@ -540,6 +540,38 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
onResumeAssignee,
|
||||
resumeAssigneePending = false,
|
||||
} = props;
|
||||
const retryFailedRunHandler =
|
||||
isTerminalIssueStatus(issueStatus) ||
|
||||
interactions?.some((interaction) => interaction.status === "pending") ||
|
||||
requiresExecutionReconciliation(props.recoveryAction?.cause) ||
|
||||
props.scheduledRetry ||
|
||||
linkedRuns?.some((run) => {
|
||||
// The server accepts explicit new attempts for these stopped legacy
|
||||
// conversations. It still proves process/lease termination and ownership;
|
||||
// offering Retry does not certify prior action outcomes or resume them.
|
||||
// Keep this set aligned with conversation-continuation.ts on the server.
|
||||
if (
|
||||
run.runtimeMode === "legacy" &&
|
||||
(run.status === "failed" || run.status === "timed_out") &&
|
||||
run.execution?.phase === "recovery_needed" &&
|
||||
run.execution.cause === "legacy_execution_requires_reconciliation" &&
|
||||
[
|
||||
"claude_local", "codex_local", "cursor", "gemini_local", "opencode_local",
|
||||
"pi_local", "grok_local", "kimi_local", "hermes_local",
|
||||
].includes(run.adapterType ?? "")
|
||||
) return false;
|
||||
return [
|
||||
"working",
|
||||
"retry_scheduled",
|
||||
"reconnecting",
|
||||
"finishing",
|
||||
"queued",
|
||||
"recovery_needed",
|
||||
].includes(run.execution?.phase ?? "");
|
||||
})
|
||||
? undefined
|
||||
: onRetryFailedRun;
|
||||
const canRetryFailedRun = Boolean(retryFailedRunHandler);
|
||||
const queryClient = useQueryClient();
|
||||
const createdProjectItems = useProjectCreatedItems(props.creationActivity ?? [], companyId);
|
||||
const [pendingComposerAssignee, setPendingComposerAssignee] = useState<
|
||||
@@ -1397,6 +1429,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
if (liveRun && source.id === liveRun.id) continue;
|
||||
const entries = transcriptByRun.get(source.id) ?? [];
|
||||
const meta = linkedRunMetaById.get(source.id);
|
||||
// A workspace admission attempt never started provider work. Its live
|
||||
// successor owns the waiting indicator; retain this attempt in the run log.
|
||||
if (source.status === "cancelled" && meta?.errorCode === "workspace_busy") {
|
||||
settledRunIds.add(source.id);
|
||||
continue;
|
||||
}
|
||||
// /new is represented by its durable comment boundary, not an empty
|
||||
// model response or a completed-run notice.
|
||||
if (meta?.resultJson?.conversationReset === true) { settledRunIds.add(source.id); continue; }
|
||||
@@ -1632,7 +1670,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const code = meta?.errorCode ?? "native_runner_process_exited";
|
||||
const retryDetail = meta?.scheduledRetryAt
|
||||
? "Retry scheduled automatically."
|
||||
: "You can retry this message now.";
|
||||
: canRetryFailedRun
|
||||
? "You can retry this message now."
|
||||
: "Your message is preserved.";
|
||||
const aiRequest = interactions?.find((interaction) => interaction.kind === "connection_intent" && interaction.payload.purpose === "ai" && interaction.sourceRunId === source.id);
|
||||
const detail = aiRequest
|
||||
? aiRequest.status === "pending"
|
||||
@@ -1655,6 +1695,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
kind: "marker",
|
||||
variant: "interrupted",
|
||||
label: source.status === "cancelled" ? (meta?.startedAt ? "Stopped" : "Couldn't start") : "Run failed",
|
||||
runId: source.status === "cancelled" ? undefined : source.id,
|
||||
tone: source.status === "cancelled" ? "neutral" : "error",
|
||||
detail,
|
||||
},
|
||||
@@ -1975,6 +2016,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
};
|
||||
}, [
|
||||
orderedEntries,
|
||||
canRetryFailedRun,
|
||||
interactions,
|
||||
runs,
|
||||
liveRun,
|
||||
@@ -2782,28 +2824,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
tryAgainNoLiveExecutionPathPending={
|
||||
tryAgainNoLiveExecutionPathPending
|
||||
}
|
||||
onRetryFailedRun={
|
||||
isTerminalIssueStatus(issueStatus) ||
|
||||
interactions?.some(
|
||||
(interaction) => interaction.status === "pending",
|
||||
) ||
|
||||
requiresExecutionReconciliation(
|
||||
props.recoveryAction?.cause,
|
||||
) ||
|
||||
props.scheduledRetry ||
|
||||
linkedRuns?.some((run) =>
|
||||
[
|
||||
"working",
|
||||
"retry_scheduled",
|
||||
"reconnecting",
|
||||
"finishing",
|
||||
"queued",
|
||||
"recovery_needed",
|
||||
].includes(run.execution?.phase ?? ""),
|
||||
)
|
||||
? undefined
|
||||
: onRetryFailedRun
|
||||
}
|
||||
onRetryFailedRun={retryFailedRunHandler}
|
||||
retryFailedRunId={retryFailedRunId}
|
||||
tail={
|
||||
tailRunId ||
|
||||
@@ -2969,6 +2990,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
<div className="relative z-10">
|
||||
<TaskChatComposer
|
||||
onAdd={handleThreadAdd}
|
||||
confirmedSubmissionIds={new Set(comments.filter((comment) =>
|
||||
comment.authorUserId === currentUserId && comment.clientRequestId &&
|
||||
!("clientStatus" in comment && comment.clientStatus)
|
||||
).map((comment) => comment.clientRequestId!))}
|
||||
onReviewConversation={onReviewConversation}
|
||||
onStop={liveRun ? onCancelRun : undefined}
|
||||
stopPending={stopPending}
|
||||
|
||||
@@ -2643,7 +2643,11 @@ export function IssueProperties({
|
||||
</PropertyRow>
|
||||
)}
|
||||
|
||||
{showScheduledRetryRow && scheduledRetryContent ? (
|
||||
{showScheduledRetryRow && scheduledRetry?.scheduledRetryReason === "workspace_busy" ? (
|
||||
<PropertyRow label="Workspace">
|
||||
<span className="text-sm text-muted-foreground">Waiting for workspace</span>
|
||||
</PropertyRow>
|
||||
) : showScheduledRetryRow && scheduledRetryContent ? (
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Scheduled retry"
|
||||
|
||||
@@ -290,6 +290,108 @@ function autocompleteOption(matchText: string) {
|
||||
}
|
||||
|
||||
describe("TaskChatComposer", () => {
|
||||
it("settles an acknowledged submission after navigating away", async () => {
|
||||
const key = "navigate-before-save";
|
||||
let resolveSend!: () => void;
|
||||
const onAdd = vi.fn().mockReturnValue(new Promise<void>(resolve => { resolveSend = resolve; }));
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />);
|
||||
typeText("Please write a motto");
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
const intent = loadDraftSubmission(key)!;
|
||||
expect(onAdd.mock.calls[0]?.[4]).toBe(intent.attemptId);
|
||||
render(<div>Another task</div>);
|
||||
resolveSend();
|
||||
await flushAsync();
|
||||
expect(loadDraftSubmission(key)).toBeNull();
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />);
|
||||
expect(container.textContent).not.toContain("couldn’t confirm");
|
||||
expect(editable().textContent).toBe("");
|
||||
});
|
||||
|
||||
it("automatically reconciles a restored submission by its server receipt, not its text", async () => {
|
||||
const key = "saved-receipt";
|
||||
const attemptId = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
|
||||
saveDraft(key, "Already answered");
|
||||
saveDraftSubmission(key, { attemptId, reviewed: false });
|
||||
const onAdd = vi.fn();
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key}
|
||||
confirmedSubmissionIds={new Set(["another-request"])} />);
|
||||
expect(loadDraftSubmission(key)).not.toBeNull();
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key}
|
||||
confirmedSubmissionIds={new Set([attemptId])} />);
|
||||
await flushAsync();
|
||||
expect(loadDraftSubmission(key)).toBeNull();
|
||||
expect(editable().textContent).toBe("");
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the next draft when an uncertain submission is later confirmed after reload", async () => {
|
||||
const key = "uncertain-with-next-draft";
|
||||
let rejectSend!: (error: Error) => void;
|
||||
const onAdd = vi.fn().mockReturnValue(new Promise<void>((_resolve, reject) => { rejectSend = reject; }));
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />);
|
||||
typeText("First request");
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
const attemptId = loadDraftSubmission(key)!.attemptId;
|
||||
typeText("Keep this next draft");
|
||||
rejectSend(new CommentSubmissionUnknownError());
|
||||
await flushAsync();
|
||||
render(<div>Another task</div>);
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key}
|
||||
confirmedSubmissionIds={new Set([attemptId])} />);
|
||||
await flushAsync();
|
||||
expect(editable().textContent).toBe("Keep this next draft");
|
||||
expect(localStorage.getItem(key)).toBe("Keep this next draft");
|
||||
expect(loadDraftSubmission(key)).toBeNull();
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(["acknowledgment", "receipt after reload"])("preserves a next draft across navigation before %s", async (confirmation) => {
|
||||
const key = "navigate-with-next-draft";
|
||||
let resolveSend!: () => void;
|
||||
const onAdd = vi.fn().mockReturnValue(new Promise<void>(resolve => { resolveSend = resolve; }));
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />);
|
||||
typeText("First request");
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
const attemptId = loadDraftSubmission(key)!.attemptId;
|
||||
typeText("Keep the unsent next request");
|
||||
render(<div>Another task</div>);
|
||||
if (confirmation === "acknowledgment") {
|
||||
resolveSend();
|
||||
await flushAsync();
|
||||
}
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key}
|
||||
confirmedSubmissionIds={new Set([attemptId])} />);
|
||||
await flushAsync();
|
||||
expect(editable().textContent).toBe("Keep the unsent next request");
|
||||
expect(localStorage.getItem(key)).toBe("Keep the unsent next request");
|
||||
expect(loadDraftSubmission(key)).toBeNull();
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
resolveSend();
|
||||
await flushAsync();
|
||||
expect(localStorage.getItem(key)).toBe("Keep the unsent next request");
|
||||
});
|
||||
|
||||
it("does not copy another task's draft into a late acknowledged submission", async () => {
|
||||
let resolveSend!: () => void;
|
||||
const onAdd = vi.fn().mockReturnValue(new Promise<void>(resolve => { resolveSend = resolve; }));
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey="first-task" />);
|
||||
typeText("First request");
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey="second-task" />);
|
||||
typeText("Second task draft");
|
||||
resolveSend();
|
||||
await flushAsync();
|
||||
expect(loadDraftSubmission("first-task")).toBeNull();
|
||||
expect(localStorage.getItem("first-task")).toBeNull();
|
||||
expect(editable().textContent).toBe("Second task draft");
|
||||
});
|
||||
|
||||
it.each(["success", "unknown"])(
|
||||
"does not overwrite another retained attempt after an older %s",
|
||||
async (outcome) => {
|
||||
@@ -497,7 +599,7 @@ describe("TaskChatComposer", () => {
|
||||
);
|
||||
flushSync(() => sendButton().click());
|
||||
await flushAsync();
|
||||
expect(onAdd.mock.calls[0]).toHaveLength(3);
|
||||
expect(onAdd.mock.calls[0]?.[3]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("submits multiple retained file and image receipts but not removed selections", async () => {
|
||||
@@ -632,7 +734,7 @@ describe("TaskChatComposer", () => {
|
||||
expect(localStorage.getItem("removed-pending:attachments:v1")).toBeNull();
|
||||
flushSync(() => sendButton().click());
|
||||
await flushAsync();
|
||||
expect(onAdd.mock.calls[0]).toHaveLength(3);
|
||||
expect(onAdd.mock.calls[0]?.[3]).toBeUndefined();
|
||||
});
|
||||
it("adds 10px to the composer's original 8px interior padding", () => {
|
||||
render(<TaskChatComposer onAdd={async () => {}} workMode="standard" />);
|
||||
@@ -724,7 +826,7 @@ describe("TaskChatComposer", () => {
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith("hello there", undefined, undefined);
|
||||
expect(onAdd).toHaveBeenCalledWith("hello there", undefined, undefined, undefined, expect.any(String));
|
||||
expect(editable().textContent).toBe("");
|
||||
});
|
||||
|
||||
@@ -736,7 +838,7 @@ describe("TaskChatComposer", () => {
|
||||
pressKey("Enter", { ctrlKey: true });
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith("hello", undefined, undefined);
|
||||
expect(onAdd).toHaveBeenCalledWith("hello", undefined, undefined, undefined, expect.any(String));
|
||||
});
|
||||
|
||||
it("does not submit on plain Enter or Shift+Enter (newline stays with the editor)", async () => {
|
||||
@@ -778,7 +880,7 @@ describe("TaskChatComposer", () => {
|
||||
await flushAsync();
|
||||
|
||||
expect(onWorkModeChange).toHaveBeenCalledWith("planning");
|
||||
expect(onAdd).toHaveBeenCalledWith("do the plan", undefined, undefined);
|
||||
expect(onAdd).toHaveBeenCalledWith("do the plan", undefined, undefined, undefined, expect.any(String));
|
||||
});
|
||||
|
||||
it("cycles Auto, Plan, and Ask modes with Cmd+Period while focused", () => {
|
||||
@@ -872,7 +974,7 @@ describe("TaskChatComposer", () => {
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith("wake up", true, undefined);
|
||||
expect(onAdd).toHaveBeenCalledWith("wake up", true, undefined, undefined, expect.any(String));
|
||||
});
|
||||
|
||||
it("hides the attach button without an upload handler and shows it with one", () => {
|
||||
@@ -965,11 +1067,7 @@ describe("TaskChatComposer", () => {
|
||||
expect(send.disabled).toBe(false);
|
||||
flushSync(() => send.click());
|
||||
await flushAsync();
|
||||
expect(onAdd).toHaveBeenCalledWith(
|
||||
"[notes.txt](/attachments/notes.txt)",
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(onAdd).toHaveBeenCalledWith("[notes.txt](/attachments/notes.txt)", undefined, undefined, undefined, expect.any(String));
|
||||
// Chips clear once the message posts.
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-composer-attachments"]'),
|
||||
@@ -999,11 +1097,7 @@ describe("TaskChatComposer", () => {
|
||||
)!;
|
||||
flushSync(() => send.click());
|
||||
await flushAsync();
|
||||
expect(onAdd).toHaveBeenCalledWith(
|
||||
"Please review this.\n\n[notes.txt](/attachments/notes.txt)",
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(onAdd).toHaveBeenCalledWith("Please review this.\n\n[notes.txt](/attachments/notes.txt)", undefined, undefined, undefined, expect.any(String));
|
||||
});
|
||||
|
||||
it("blocks send while a file upload is pending, then includes the file once it lands", async () => {
|
||||
@@ -1046,11 +1140,7 @@ describe("TaskChatComposer", () => {
|
||||
expect(sendButton().disabled).toBe(false);
|
||||
flushSync(() => sendButton().click());
|
||||
await flushAsync();
|
||||
expect(onAdd).toHaveBeenCalledWith(
|
||||
"Here is the file.\n\n[notes.txt](/attachments/notes.txt)",
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(onAdd).toHaveBeenCalledWith("Here is the file.\n\n[notes.txt](/attachments/notes.txt)", undefined, undefined, undefined, expect.any(String));
|
||||
});
|
||||
|
||||
it("blocks send while a failed attachment chip remains, then sends after it is removed", async () => {
|
||||
@@ -1082,11 +1172,7 @@ describe("TaskChatComposer", () => {
|
||||
expect(sendButton().disabled).toBe(false);
|
||||
flushSync(() => sendButton().click());
|
||||
await flushAsync();
|
||||
expect(onAdd).toHaveBeenCalledWith(
|
||||
"Here is the file.",
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(onAdd).toHaveBeenCalledWith("Here is the file.", undefined, undefined, undefined, expect.any(String));
|
||||
});
|
||||
|
||||
it("removes an attachment chip via its remove button", async () => {
|
||||
@@ -1196,7 +1282,7 @@ describe("TaskChatComposer", () => {
|
||||
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
expect(onAdd).toHaveBeenCalledWith(expected.trim(), undefined, undefined);
|
||||
expect(onAdd).toHaveBeenCalledWith(expected.trim(), undefined, undefined, undefined, expect.any(String));
|
||||
});
|
||||
|
||||
it("inserts a /-command from the autocomplete menu", async () => {
|
||||
@@ -1596,11 +1682,7 @@ describe("TaskChatComposer", () => {
|
||||
pressKey("Enter", { metaKey: true });
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith(
|
||||
"queued message",
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(onAdd).toHaveBeenCalledWith("queued message", undefined, undefined, undefined, expect.any(String));
|
||||
expect(editable().textContent).toBe("");
|
||||
expect(localStorage.getItem(draftKey)).toBe("queued message");
|
||||
expect(localStorage.getItem(`${draftKey}:submission:v1`)).toContain(
|
||||
@@ -1631,7 +1713,7 @@ describe("TaskChatComposer", () => {
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined);
|
||||
expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined, undefined, expect.any(String));
|
||||
expect(editable().textContent).toBe("next message");
|
||||
expect(localStorage.getItem(draftKey)).toBe("next message");
|
||||
});
|
||||
@@ -1665,7 +1747,7 @@ describe("TaskChatComposer", () => {
|
||||
await flushAsync();
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined);
|
||||
expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined, undefined, expect.any(String));
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="task-chat-composer-attachments"]',
|
||||
@@ -1757,7 +1839,7 @@ describe("TaskChatComposer", () => {
|
||||
act(() => root!.render(<TaskChatComposer {...props} />));
|
||||
expect(editable().textContent).toBe("Please check mobile too.");
|
||||
await act(async () => sendButton().click());
|
||||
expect(onAdd).toHaveBeenCalledWith("Please check mobile too.", undefined, undefined);
|
||||
expect(onAdd).toHaveBeenCalledWith("Please check mobile too.", undefined, undefined, undefined, expect.any(String));
|
||||
});
|
||||
|
||||
it("takes precedence over pending questions and queued-message edits", () => {
|
||||
@@ -2608,7 +2690,7 @@ describe("composer Stop", () => {
|
||||
const onStop = vi.fn(async () => {});
|
||||
const onAdd = vi.fn(async () => {});
|
||||
render(<TaskChatComposer workMode="standard" onAdd={onAdd} onStop={onStop} stopScope="subtree" />);
|
||||
expect(stopButton()?.title).toBe("Stop and pause subtree");
|
||||
expect(stopButton()?.title).toBe("Stop response");
|
||||
pressKey("Enter", { metaKey: true });
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
@@ -2617,7 +2699,7 @@ describe("composer Stop", () => {
|
||||
expect(sendButton().disabled).toBe(false);
|
||||
flushSync(() => sendButton().click());
|
||||
await flushAsync();
|
||||
expect(onAdd).toHaveBeenCalledWith("Check mobile too.", undefined, undefined);
|
||||
expect(onAdd).toHaveBeenCalledWith("Check mobile too.", undefined, undefined, undefined, expect.any(String));
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
typeText(" \n ");
|
||||
expect(stopButton()?.disabled).toBe(false);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
loadDraftSubmission,
|
||||
saveDraftSubmission,
|
||||
clearDraftSubmission,
|
||||
settleDraftSubmission,
|
||||
type ComposerDraftSubmission,
|
||||
} from "@/lib/composer-draft";
|
||||
import { CommentSubmissionUnknownError } from "@/lib/comment-submit-result";
|
||||
@@ -104,6 +105,7 @@ interface TaskChatComposerProps {
|
||||
attachmentIds?: string[],
|
||||
clientRequestId?: string,
|
||||
) => Promise<void> | void;
|
||||
confirmedSubmissionIds?: ReadonlySet<string>;
|
||||
onStop?: () => Promise<void>;
|
||||
stopPending?: boolean;
|
||||
stopScope?: "leaf" | "subtree";
|
||||
@@ -381,9 +383,9 @@ function escapeMarkdownLabel(name: string): string {
|
||||
*/
|
||||
export function TaskChatComposer({
|
||||
onAdd,
|
||||
confirmedSubmissionIds,
|
||||
onStop,
|
||||
stopPending = false,
|
||||
stopScope = "leaf",
|
||||
workMode,
|
||||
onWorkModeChange,
|
||||
disabled = false,
|
||||
@@ -460,6 +462,12 @@ export function TaskChatComposer({
|
||||
typeof update === "function" ? update(attachmentsRef.current) : update;
|
||||
attachmentsRef.current = next;
|
||||
setAttachmentState(next);
|
||||
const pending = pendingDraftRef.current;
|
||||
if (pending && pending.draftKey === draftKey) {
|
||||
saveDraftAttachments(pending.draftKey, next
|
||||
.filter(item => item.status === "attached" && item.attachmentId)
|
||||
.map(item => ({ ...item, inline: item.inline === true })), pending.attemptId);
|
||||
}
|
||||
}
|
||||
const submittingRef = useRef(submitting);
|
||||
submittingRef.current = submitting;
|
||||
@@ -469,6 +477,29 @@ export function TaskChatComposer({
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const bodyRef = useRef(body);
|
||||
bodyRef.current = body;
|
||||
const pendingDraftRef = useRef<{
|
||||
draftKey: string;
|
||||
attemptId: string;
|
||||
submittedBody: string;
|
||||
submittedAttachmentIds: string[];
|
||||
} | null>(null);
|
||||
function changeBody(value: string) {
|
||||
bodyRef.current = value;
|
||||
setBody(value);
|
||||
const pending = pendingDraftRef.current;
|
||||
if (!pending || pending.draftKey !== draftKey ||
|
||||
loadDraftSubmission(pending.draftKey)?.attemptId !== pending.attemptId) return;
|
||||
// Keep text typed during delivery durable too. Navigation may happen before
|
||||
// either the request promise or the matching live server receipt arrives.
|
||||
saveDraft(pending.draftKey,
|
||||
value ? `${pending.submittedBody}\n\n${value}` : pending.submittedBody,
|
||||
pending.attemptId);
|
||||
saveDraftSubmission(pending.draftKey, {
|
||||
attemptId: pending.attemptId, reviewed: false,
|
||||
nextDraftOffset: pending.submittedBody.length + (value ? 2 : 0),
|
||||
submittedAttachmentIds: pending.submittedAttachmentIds,
|
||||
});
|
||||
}
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const queuedEditRef = useRef(queuedEdit);
|
||||
queuedEditRef.current = queuedEdit;
|
||||
@@ -971,20 +1002,16 @@ export function TaskChatComposer({
|
||||
.map((item) => item.attachmentId!),
|
||||
),
|
||||
];
|
||||
if (conversationMode)
|
||||
await onAdd(fullBody, reopen, reassignment, attachmentIds.length ? attachmentIds : undefined, attemptId);
|
||||
else if (attachmentIds.length > 0)
|
||||
await onAdd(fullBody, reopen, reassignment, attachmentIds);
|
||||
else await onAdd(fullBody, reopen, reassignment);
|
||||
if (mountedTaskKey.current !== draftKey) return;
|
||||
if (draftKey) clearDraftSubmission(draftKey, attemptId);
|
||||
if (draftKey && bodyRef.current) {
|
||||
// The editor stays writable while the request is pending. Preserve
|
||||
// text entered after this submission started as the next draft.
|
||||
saveDraft(draftKey, bodyRef.current);
|
||||
} else if (draftKey) {
|
||||
clearDraft(draftKey);
|
||||
if (draftKey) {
|
||||
pendingDraftRef.current = { draftKey, attemptId, submittedBody, submittedAttachmentIds: attachmentIds };
|
||||
changeBody(bodyRef.current);
|
||||
}
|
||||
await onAdd(fullBody, reopen, reassignment, attachmentIds.length ? attachmentIds : undefined, attemptId);
|
||||
// Navigation does not invalidate the server receipt. Settle the captured
|
||||
// task before checking whether this composer is still on screen.
|
||||
if (draftKey) settleDraftSubmission(draftKey, attemptId,
|
||||
mountedTaskKey.current === draftKey ? bodyRef.current : undefined);
|
||||
if (mountedTaskKey.current !== draftKey) return;
|
||||
const submittedIds = new Set(submittedAttachments.map((item) => item.id));
|
||||
setAttachments((current) =>
|
||||
current.filter((item) => !submittedIds.has(item.id)),
|
||||
@@ -994,8 +1021,13 @@ export function TaskChatComposer({
|
||||
}
|
||||
} catch (error) {
|
||||
if (mountedTaskKey.current !== draftKey) return;
|
||||
const nextDraft = bodyRef.current;
|
||||
if (attemptId && error instanceof CommentSubmissionUnknownError) {
|
||||
const uncertain = { attemptId, reviewed: false };
|
||||
const uncertain = {
|
||||
attemptId, reviewed: false,
|
||||
nextDraftOffset: submittedBody.length + (nextDraft ? 2 : 0),
|
||||
submittedAttachmentIds: submittedAttachments.flatMap(item => item.attachmentId ? [item.attachmentId] : []),
|
||||
};
|
||||
setUncertainSubmission(uncertain);
|
||||
if (draftKey && loadDraftSubmission(draftKey)?.attemptId === attemptId)
|
||||
saveDraftSubmission(draftKey, uncertain);
|
||||
@@ -1003,7 +1035,6 @@ export function TaskChatComposer({
|
||||
clearDraftSubmission(draftKey, attemptId);
|
||||
// Restore the failed message for retry without discarding a next draft
|
||||
// that was entered while the request was pending.
|
||||
const nextDraft = bodyRef.current;
|
||||
const restoredBody = nextDraft
|
||||
? `${submittedBody}\n\n${nextDraft}`
|
||||
: submittedBody;
|
||||
@@ -1015,10 +1046,25 @@ export function TaskChatComposer({
|
||||
if (draftKey) saveDraft(draftKey, restoredBody, attemptId ?? undefined);
|
||||
setBody(restoredBody);
|
||||
} finally {
|
||||
if (pendingDraftRef.current?.attemptId === attemptId) pendingDraftRef.current = null;
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!uncertainSubmission || !confirmedSubmissionIds?.has(uncertainSubmission.attemptId)) return;
|
||||
const nextDraft = uncertainSubmission.nextDraftOffset === undefined
|
||||
? "" : bodyRef.current.slice(uncertainSubmission.nextDraftOffset);
|
||||
if (draftKey) settleDraftSubmission(draftKey, uncertainSubmission.attemptId, nextDraft);
|
||||
setUncertainSubmission(null);
|
||||
bodyRef.current = nextDraft;
|
||||
setBody(nextDraft);
|
||||
const submittedIds = uncertainSubmission.submittedAttachmentIds;
|
||||
setAttachments(current => submittedIds
|
||||
? current.filter(item => !item.attachmentId || !submittedIds.includes(item.attachmentId))
|
||||
: []);
|
||||
}, [confirmedSubmissionIds, draftKey, uncertainSubmission]);
|
||||
|
||||
async function reviewUncertainSubmission() {
|
||||
if (!uncertainSubmission) return;
|
||||
setReviewError(false);
|
||||
@@ -1265,7 +1311,7 @@ export function TaskChatComposer({
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
onChange={changeBody}
|
||||
placeholder={
|
||||
disabled
|
||||
? (disabledReason ?? "Composer disabled")
|
||||
@@ -1537,9 +1583,7 @@ export function TaskChatComposer({
|
||||
showStop
|
||||
? stopControl.stopping
|
||||
? "Stopping…"
|
||||
: stopScope === "subtree"
|
||||
? "Stop and pause subtree"
|
||||
: "Stop and pause task"
|
||||
: "Stop response"
|
||||
: queuedEdit
|
||||
? queuedEdit.stale
|
||||
? "Queue as new message"
|
||||
|
||||
@@ -16,6 +16,17 @@ import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
it.each(["issue.attachment_added", "issue.attachment_removed", "issue.work_product_created", "issue.work_product_updated"])("refreshes visible delivered files for %s", action => {
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(queryKeys.issues.detail("issue-1"), { id: "issue-1", companyId: "company-1", identifier: "PAP-1" });
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", {
|
||||
entityType: "issue", entityId: "issue-1", actorType: "agent", actorId: "agent-1", action,
|
||||
}, { userId: "user-1", agentId: null }, { pathname: "/PAP/issues/PAP-1", isForegrounded: true });
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.attachments("issue-1") });
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.workProducts("issue-1") });
|
||||
client.clear();
|
||||
});
|
||||
it("connects trusted local boards without admitting signed-out authenticated users", () => {
|
||||
const canConnect = __liveUpdatesTestUtils.canUseLiveSession;
|
||||
expect(canConnect("success", false, "local_trusted")).toBe(true);
|
||||
|
||||
@@ -527,6 +527,8 @@ function invalidateVisibleIssueRunQueries(
|
||||
// A final comment can race the last in-flight history fetch. Reconcile
|
||||
// persisted messages after the turn settles.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(issueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", issueRef] });
|
||||
}
|
||||
}
|
||||
@@ -1075,6 +1077,8 @@ function buildRunStatusToast(
|
||||
// Interrupt is an intentional conversation control. Its caller gives
|
||||
// feedback; the terminal event must not announce a cancelled/failed run.
|
||||
if (errorCode === "operator_interrupted") return null;
|
||||
// Workspace contention is ordinary scheduling, not a failed user action.
|
||||
if (errorCode === "workspace_busy") return null;
|
||||
const contextSource = readString(payload.contextSource);
|
||||
const triggerDetail = readString(payload.triggerDetail);
|
||||
const name = nameOf(agentId) ?? "Agent";
|
||||
@@ -1342,6 +1346,12 @@ function invalidateActivityQueries(
|
||||
if (action === "issue.comment_added" || action === "issue.conversation_session_started") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref), ...invalidationOptions });
|
||||
}
|
||||
if (action?.startsWith("issue.attachment_") || action?.startsWith("issue.work_product_")) {
|
||||
// These cards are durable API objects, not streamed text. Refresh the
|
||||
// visible task too, including attachments bound to an existing comment.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(ref) });
|
||||
}
|
||||
if (action === "issue.conversation_session_started") {
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", ref] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref) });
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
loadDraftSubmission,
|
||||
saveDraftSubmission,
|
||||
clearDraftSubmission,
|
||||
settleDraftSubmission,
|
||||
} from "./composer-draft";
|
||||
|
||||
describe("task draft upload receipts", () => {
|
||||
@@ -22,6 +23,21 @@ describe("task draft upload receipts", () => {
|
||||
contentPath: `/api/attachments/${id}/content`,
|
||||
};
|
||||
beforeEach(() => localStorage.clear());
|
||||
it("settles only submitted text and attachments while preserving the next draft", () => {
|
||||
const nextId = "aaf8228f-0be7-45ae-a104-6fbe0af6f1d3";
|
||||
const nextReceipt = { ...receipt, attachmentId: nextId, contentPath: `/api/attachments/${nextId}/content` };
|
||||
saveDraft(key, "Sent\n\nNext");
|
||||
saveDraftAttachments(key, [receipt]);
|
||||
saveDraftSubmission(key, { attemptId: id, reviewed: false, nextDraftOffset: 6, submittedAttachmentIds: [id] });
|
||||
saveDraftAttachments(key, [receipt, nextReceipt], nextId);
|
||||
expect(loadDraftAttachments(key)).toEqual([receipt]);
|
||||
saveDraftAttachments(key, [receipt, nextReceipt], id);
|
||||
expect(settleDraftSubmission(key, nextId)).toBe(false);
|
||||
expect(settleDraftSubmission(key, id)).toBe(true);
|
||||
expect(loadDraft(key)).toBe("Next");
|
||||
expect(loadDraftAttachments(key)).toEqual([nextReceipt]);
|
||||
expect(loadDraftSubmission(key)).toBeNull();
|
||||
});
|
||||
it("keeps chat drafts and pending submission fences within the current tab", () => {
|
||||
sessionStorage.clear();
|
||||
const chatKey = "paperclip:agent-chat-draft:company:user:agent";
|
||||
|
||||
@@ -57,24 +57,32 @@ export function clearDraft(draftKey: string, attemptId?: string) {
|
||||
export interface ComposerDraftSubmission {
|
||||
attemptId: string;
|
||||
reviewed: boolean;
|
||||
/** Start of text typed after the submitted body in a restored uncertain draft. */
|
||||
nextDraftOffset?: number;
|
||||
submittedAttachmentIds?: string[];
|
||||
}
|
||||
|
||||
/** Local uncertainty fence, not a server idempotency key or proof of delivery.
|
||||
* Any retained in-flight intent is uncertain after a reload. */
|
||||
/** Retained client request ID. It is not delivery proof until a matching
|
||||
* server receipt is observed; use the same ID for submission and reconciliation. */
|
||||
export function loadDraftSubmission(
|
||||
draftKey: string,
|
||||
): ComposerDraftSubmission | null {
|
||||
try {
|
||||
const raw = draftStorage(draftKey).getItem(`${draftKey}:submission:v1`);
|
||||
if (!raw || raw.length > 2_048) return null;
|
||||
if (!raw || raw.length > 16_384) return null;
|
||||
const record = JSON.parse(raw);
|
||||
return record?.version === 1 &&
|
||||
record.draftKey === draftKey &&
|
||||
Object.keys(record).length === 4 &&
|
||||
Object.keys(record).every((key) => ["version", "draftKey", "attemptId", "reviewed", "nextDraftOffset", "submittedAttachmentIds"].includes(key)) &&
|
||||
typeof record.attemptId === "string" &&
|
||||
/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(record.attemptId) &&
|
||||
typeof record.reviewed === "boolean"
|
||||
? { attemptId: record.attemptId, reviewed: record.reviewed }
|
||||
typeof record.reviewed === "boolean" &&
|
||||
(record.nextDraftOffset === undefined || (Number.isSafeInteger(record.nextDraftOffset) && record.nextDraftOffset >= 0)) &&
|
||||
(record.submittedAttachmentIds === undefined || (Array.isArray(record.submittedAttachmentIds) && record.submittedAttachmentIds.length <= 256 && record.submittedAttachmentIds.every((id: unknown) => typeof id === "string" && id.length <= 128)))
|
||||
? { attemptId: record.attemptId, reviewed: record.reviewed,
|
||||
...(record.nextDraftOffset !== undefined ? { nextDraftOffset: record.nextDraftOffset } : {}),
|
||||
...(record.submittedAttachmentIds !== undefined ? { submittedAttachmentIds: record.submittedAttachmentIds } : {}),
|
||||
}
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -107,6 +115,20 @@ export function clearDraftSubmission(draftKey: string, attemptId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A late response can settle only its own retained intent, never a newer draft. */
|
||||
export function settleDraftSubmission(draftKey: string, attemptId: string, nextDraft?: string): boolean {
|
||||
const submission = loadDraftSubmission(draftKey);
|
||||
if (submission?.attemptId !== attemptId) return false;
|
||||
nextDraft ??= submission.nextDraftOffset === undefined
|
||||
? "" : loadDraft(draftKey).slice(submission.nextDraftOffset);
|
||||
const nextAttachments = submission.submittedAttachmentIds === undefined ? []
|
||||
: loadDraftAttachments(draftKey).filter(item => !submission.submittedAttachmentIds!.includes(item.attachmentId));
|
||||
clearDraft(draftKey, attemptId);
|
||||
if (nextDraft) saveDraft(draftKey, nextDraft);
|
||||
if (nextAttachments.length) saveDraftAttachments(draftKey, nextAttachments);
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface ComposerDraftAttachment {
|
||||
attachmentId: string;
|
||||
name: string;
|
||||
@@ -178,11 +200,11 @@ export function loadDraftAttachments(
|
||||
}
|
||||
}
|
||||
|
||||
export function saveDraftAttachments(draftKey: string, attachments: unknown) {
|
||||
export function saveDraftAttachments(draftKey: string, attachments: unknown, attemptId?: string) {
|
||||
try {
|
||||
// In-flight/unknown receipts were saved before the intent. Generic effects
|
||||
// (including a stale composer's cleanup) must not rewrite that snapshot.
|
||||
if (!mayWriteDraft(draftKey)) return;
|
||||
// Only the owning pending request may persist newly uploaded receipts.
|
||||
// Generic effects and stale composer cleanups must not rewrite its snapshot.
|
||||
if (!mayWriteDraft(draftKey, attemptId)) return;
|
||||
const selected = draftAttachments(attachments);
|
||||
if (selected.length)
|
||||
draftStorage(draftKey).setItem(
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("stop confirmation", () => {
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
const assertion = expect(result).rejects.toThrow(
|
||||
"pause was saved, but work is still stopping",
|
||||
"stop was requested, but work is still stopping",
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await assertion;
|
||||
@@ -78,7 +78,7 @@ describe("stop confirmation", () => {
|
||||
waitForStoppedRuns(["active"], {
|
||||
getRun: vi.fn().mockRejectedValue(new Error("offline")),
|
||||
}),
|
||||
).rejects.toThrow("pause was saved, but stopping could not be verified");
|
||||
).rejects.toThrow("stop was requested, but stopping could not be verified");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function waitForStoppedRuns(
|
||||
]);
|
||||
} catch {
|
||||
throw new Error(
|
||||
"The pause was saved, but stopping could not be verified. Refresh and try Stop again if work is still running.",
|
||||
"The stop was requested, but stopping could not be verified. Refresh and try Stop again if work is still running.",
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
@@ -54,7 +54,7 @@ export async function waitForStoppedRuns(
|
||||
if (remaining.length === 0) return;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(
|
||||
"The pause was saved, but work is still stopping. Try Stop again if it continues.",
|
||||
"The stop was requested, but work is still stopping. Try Stop again if it continues.",
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) =>
|
||||
|
||||
@@ -84,6 +84,7 @@ const mockActivityApi = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockHeartbeatsApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
liveRunsForIssue: vi.fn(),
|
||||
activeRunForIssue: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
@@ -4571,8 +4572,11 @@ describe("IssueDetail", () => {
|
||||
});
|
||||
|
||||
it.each(["active-run", "composer"])(
|
||||
"routes %s Stop and the menu through the same pause operation",
|
||||
"keeps %s run controls distinct from pausing future work",
|
||||
async (control) => {
|
||||
mockIssuesApi.createTreeHold.mockClear();
|
||||
mockHeartbeatsApi.cancel.mockClear();
|
||||
mockHeartbeatsApi.get.mockReset();
|
||||
const pausePreview = createPausePreview();
|
||||
pausePreview.totals = {
|
||||
...pausePreview.totals,
|
||||
@@ -4614,6 +4618,7 @@ describe("IssueDetail", () => {
|
||||
adapterType: "process",
|
||||
},
|
||||
]);
|
||||
mockHeartbeatsApi.get.mockResolvedValue({ id: "run-active-1", status: "cancelled", runtimeMode: "legacy" });
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { userId: "user-1" },
|
||||
user: { id: "user-1" },
|
||||
@@ -4650,7 +4655,11 @@ describe("IssueDetail", () => {
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
|
||||
if (control === "composer") {
|
||||
expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-active-1");
|
||||
expect(mockHeartbeatsApi.get).toHaveBeenCalledWith("run-active-1");
|
||||
expect(mockIssuesApi.createTreeHold).not.toHaveBeenCalled();
|
||||
} else expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
|
||||
mode: "pause",
|
||||
reason: null,
|
||||
releasePolicy: { strategy: "manual", note: "leaf_pause" },
|
||||
@@ -4699,6 +4708,7 @@ describe("IssueDetail", () => {
|
||||
);
|
||||
|
||||
it("routes live-run finalization actions through run cancellation before issue status update", async () => {
|
||||
mockHeartbeatsApi.cancel.mockClear();
|
||||
mockIssuesApi.get.mockResolvedValue(
|
||||
createIssue({
|
||||
status: "in_progress",
|
||||
|
||||
@@ -1281,6 +1281,8 @@ type IssueDetailChatTabProps = {
|
||||
onInterruptQueued: (runId: string | null) => Promise<void>;
|
||||
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
||||
onPauseWorkRun?: (runId: string, feedback?: "composer") => Promise<void>;
|
||||
onStopResponse?: (runId: string) => Promise<void>;
|
||||
stopResponsePending?: boolean;
|
||||
pauseWorkPending?: boolean;
|
||||
pauseWorkScope?: "leaf" | "subtree";
|
||||
runFinalizationActions?: readonly IssueChatRunFinalizationAction[];
|
||||
@@ -1395,6 +1397,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
onInterruptQueued,
|
||||
onDeleteComment,
|
||||
onPauseWorkRun,
|
||||
onStopResponse,
|
||||
stopResponsePending,
|
||||
pauseWorkPending,
|
||||
pauseWorkScope,
|
||||
runFinalizationActions,
|
||||
@@ -2442,13 +2446,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
|
||||
issueWorkMode={issueWorkMode}
|
||||
onWorkModeChange={onWorkModeChange}
|
||||
stopPending={pauseWorkPending}
|
||||
stopScope={pauseWorkScope}
|
||||
stopPending={stopResponsePending}
|
||||
onCancelRun={
|
||||
interruptibleIssueRun && onPauseWorkRun
|
||||
? async () => {
|
||||
await onPauseWorkRun(interruptibleIssueRun.id, "composer");
|
||||
}
|
||||
interruptibleIssueRun && onStopResponse
|
||||
? () => onStopResponse(interruptibleIssueRun.id)
|
||||
: undefined
|
||||
}
|
||||
onImageClick={onImageClick}
|
||||
@@ -4175,6 +4176,16 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
}
|
||||
},
|
||||
});
|
||||
const stopResponse = useMutation({
|
||||
mutationFn: async (runId: string) => {
|
||||
await heartbeatsApi.cancel(runId);
|
||||
await waitForStoppedRuns([runId]);
|
||||
},
|
||||
onSettled: () => Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }),
|
||||
]),
|
||||
});
|
||||
const stopAndFinalizeRun = useMutation({
|
||||
mutationFn: async ({
|
||||
runId,
|
||||
@@ -4772,15 +4783,18 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
interrupt,
|
||||
reassignment,
|
||||
attachmentIds,
|
||||
clientRequestId,
|
||||
}: {
|
||||
body: string;
|
||||
reopen?: boolean;
|
||||
interrupt?: boolean;
|
||||
reassignment: CommentReassignment;
|
||||
attachmentIds?: string[];
|
||||
clientRequestId?: string;
|
||||
}) =>
|
||||
issuesApi.update(issueId!, {
|
||||
comment: body,
|
||||
commentClientRequestId: clientRequestId,
|
||||
...(attachmentIds?.length ? { attachmentIds } : {}),
|
||||
assigneeAgentId: reassignment.assigneeAgentId,
|
||||
assigneeUserId: reassignment.assigneeUserId,
|
||||
@@ -6125,6 +6139,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
reopen,
|
||||
reassignment,
|
||||
attachmentIds,
|
||||
clientRequestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -7781,6 +7796,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
.mutateAsync({ commentId })
|
||||
.then(() => undefined)
|
||||
}
|
||||
onStopResponse={canManageTreeControl
|
||||
? (runId) => stopResponse.mutateAsync(runId)
|
||||
: undefined}
|
||||
stopResponsePending={stopResponse.isPending}
|
||||
pauseWorkPending={
|
||||
executeTreeControl.isPending &&
|
||||
executeTreeControl.variables?.mode === "pause"
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getAppStoreDefinition } from "@paperclipai/shared";
|
||||
import { AppDetail } from "./AppDetail";
|
||||
import { APP_TABS } from "./app-tabs";
|
||||
|
||||
@@ -1172,6 +1173,30 @@ describe("AppDetail", () => {
|
||||
expect(container.textContent).toContain("Which agents can use this connection?");
|
||||
});
|
||||
|
||||
it.each(["permissions", "review"])("offers a supported replacement for an obsolete Anthropic connection on %s", async (tab) => {
|
||||
mockParams.tab = tab;
|
||||
listApplicationsMock.mockResolvedValue({ applications: [] });
|
||||
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("anthropic")!] });
|
||||
getConnectionMock.mockResolvedValue(connection({
|
||||
name: "Anthropic",
|
||||
transport: "rest_api",
|
||||
authKind: "api_key",
|
||||
config: { sourceTemplateKey: "anthropic", connectionMethodKey: "api-key" },
|
||||
healthStatus: "error",
|
||||
healthMessage: "This connection has no supported tool integration.",
|
||||
}));
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.querySelector('input[type="password"]')).toBeNull();
|
||||
expect(findButton("Check & reconnect")).toBeUndefined();
|
||||
expect(findButton("Reconnect")).toBeUndefined();
|
||||
expect(container.textContent).toContain("Connection no longer supported");
|
||||
expect(container.textContent).toContain("then remove this connection");
|
||||
expect(container.querySelector('a[href="/apps/connect?source=anthropic"]')?.textContent)
|
||||
.toBe("Add supported connection");
|
||||
});
|
||||
|
||||
it("offers retry for a transient GitHub error without asking for another login", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
getConnectionMock.mockResolvedValue(connection({
|
||||
|
||||
@@ -6,13 +6,18 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { CONNECTABLE_APP_DEFINITIONS, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, getAppStoreDefinition } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
|
||||
import { AppsConnect } from "./AppsConnect";
|
||||
|
||||
const listGalleryMock = vi.hoisted(() => vi.fn());
|
||||
const experimentalMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: experimentalMock } }));
|
||||
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: {
|
||||
getExperimental: experimentalMock,
|
||||
get: async () => ({ defaultEnvironmentId: "local-env" }),
|
||||
getGeneral: async () => ({}),
|
||||
} }));
|
||||
const listApplicationsMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionsMock = vi.hoisted(() => vi.fn());
|
||||
const getConnectionMock = vi.hoisted(() => vi.fn());
|
||||
@@ -438,24 +443,43 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
// credential is entered.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("keeps the existing Anthropic tool method reachable alongside AI authentication", async () => {
|
||||
it("offers supported Anthropic AI authentication without the obsolete REST tool method", async () => {
|
||||
const createAiAccount = vi.spyOn(aiConnectionsApi, "create").mockResolvedValue({
|
||||
connectionId: "anthropic-ai-account", grantId: "anthropic-ai-grant",
|
||||
});
|
||||
mockParams.appKey = "anthropic";
|
||||
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("anthropic")] });
|
||||
await render();
|
||||
const client = new QueryClient({ defaultOptions: { queries: {
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
} } });
|
||||
client.setQueryData(queryKeys.environments.list("company-1"), [
|
||||
{ id: "local-env", name: "Local", driver: "local", status: "active", config: {} },
|
||||
]);
|
||||
client.setQueryData(queryKeys.environments.capabilities("company-1"), {});
|
||||
client.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: "local-env" });
|
||||
client.setQueryData(queryKeys.instance.generalSettings, {});
|
||||
client.setQueryData(queryKeys.health, { deploymentMode: "authenticated", localAiLoginSupported: false });
|
||||
await render(client);
|
||||
await passAccessStep();
|
||||
expect(container.textContent).toContain("How do you want to connect?");
|
||||
expect(radioContaining("Claude subscription")).toBeTruthy();
|
||||
expect(radioContaining("Claude API key")).toBeTruthy();
|
||||
await act(async () => radioContaining("Use an API key")!.click());
|
||||
expect(container.textContent).toContain("Connect account");
|
||||
expect(container.textContent).toContain("Connection name");
|
||||
expect(container.textContent).not.toContain("How do you want to connect?");
|
||||
expect(radioContaining("Use an API key")).toBeUndefined();
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
await act(async () => buttonContaining("Use API key instead")!.click());
|
||||
await act(async () => buttonContaining("Claude")!.click());
|
||||
await flushReact();
|
||||
const key = container.querySelector<HTMLInputElement>('input[type="password"]');
|
||||
expect(key).toBeTruthy();
|
||||
await act(async () => setInputValue(key!, "fixture-anthropic-tool-key"));
|
||||
await act(async () => setInputValue(key!, "fixture-anthropic-ai-key"));
|
||||
await act(async () => buttonByText("Connect")!.click());
|
||||
await flushReact();
|
||||
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
galleryKey: "anthropic", connectionMethodKey: "api-key",
|
||||
expect(createAiAccount).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
provider: "anthropic", method: "api_key", apiKey: "fixture-anthropic-ai-key",
|
||||
}));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/anthropic-ai-account/permissions");
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).not.toContain("Connect for tool access instead");
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getAppStoreDefinition } from "@paperclipai/shared";
|
||||
import { DangerZone } from "./AdvancedPanel";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -50,6 +51,54 @@ function expandDangerZone(node: HTMLDivElement) {
|
||||
* operator commits.
|
||||
*/
|
||||
describe("DangerZone", () => {
|
||||
it("keeps removal available without reconnecting an obsolete Anthropic method", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const onRemove = vi.fn();
|
||||
act(() => root.render(
|
||||
<DangerZone
|
||||
appName="Anthropic"
|
||||
connection={{
|
||||
id: "obsolete-anthropic",
|
||||
uid: "obsolete-anthropic",
|
||||
companyId: "company-1",
|
||||
applicationId: "app-1",
|
||||
name: "Anthropic",
|
||||
connectionKind: "managed",
|
||||
connectionPurpose: "tool",
|
||||
ownership: "customer",
|
||||
transport: "rest_api",
|
||||
authKind: "api_key",
|
||||
credentialSource: "paperclip_vault",
|
||||
credentialPolicy: "shared",
|
||||
transportConfig: {},
|
||||
config: { sourceTemplateKey: "anthropic", connectionMethodKey: "api-key" },
|
||||
credentialSecretRefs: [],
|
||||
healthStatus: "error",
|
||||
healthCheckedAt: null,
|
||||
lastError: "This connection has no supported tool integration.",
|
||||
enabled: true,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
createdAt: new Date("2026-09-12T00:00:00Z"),
|
||||
updatedAt: new Date("2026-09-12T00:00:00Z"),
|
||||
}}
|
||||
galleryEntry={getAppStoreDefinition("anthropic")!}
|
||||
removing={false}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
));
|
||||
expandDangerZone(container);
|
||||
const button = (label: string) => Array.from(container!.querySelectorAll("button"))
|
||||
.find((candidate) => candidate.textContent?.trim() === label);
|
||||
expect(button("Reconnect")).toBeUndefined();
|
||||
act(() => button("Remove app")!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
act(() => button("Yes, remove it")!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onRemove).toHaveBeenCalledOnce();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps dangerous actions folded by default", () => {
|
||||
const node = renderDangerZone();
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { redactUrlSecrets } from "@/lib/redact-url-secrets";
|
||||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||||
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Link } from "@/lib/router";
|
||||
import type { AppDetailSectionProps } from "./types";
|
||||
import { RevokeGrantDialog } from "./IdentitiesSection";
|
||||
|
||||
@@ -88,6 +89,15 @@ export function AdvancedPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function connectionMethodUnavailable(connection: ToolConnection, galleryEntry: AppDefinition | null): boolean {
|
||||
const methodKey = connection.config?.connectionMethodKey;
|
||||
return typeof methodKey === "string"
|
||||
&& methodKey.length > 0
|
||||
&& !!galleryEntry
|
||||
&& Array.isArray(galleryEntry.methods)
|
||||
&& !getAvailableConnectionMethod(galleryEntry, methodKey);
|
||||
}
|
||||
|
||||
function KeySection({
|
||||
connection,
|
||||
galleryEntry,
|
||||
@@ -200,15 +210,18 @@ export function ReconnectCard({
|
||||
});
|
||||
const oauth = connection.authKind === "oauth";
|
||||
const managedByVercel = connection.credentialSource === "vercel_connect";
|
||||
const methodUnavailable = connectionMethodUnavailable(connection, galleryEntry);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-100">
|
||||
{oauth ? "Reconnect required" : "This app needs reconnecting"}
|
||||
{methodUnavailable ? "Connection no longer supported" : oauth ? "Reconnect required" : "This app needs reconnecting"}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm text-amber-800 dark:text-amber-200">
|
||||
{connection.healthMessage?.trim() || (oauth
|
||||
{methodUnavailable
|
||||
? "Add a supported connection from Connectors, then remove this connection."
|
||||
: connection.healthMessage?.trim() || (oauth
|
||||
? "Authorization expired or was revoked. Sign in again to restore access."
|
||||
: "The key stopped working. Paste a new one to get it back online.")}
|
||||
</p>
|
||||
@@ -218,6 +231,12 @@ export function ReconnectCard({
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
{reconnectUnavailableMessage ?? "You don't have permission to reconnect this identity."}
|
||||
</p>
|
||||
) : methodUnavailable ? (
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to={`/apps/connect?source=${encodeURIComponent(galleryEntry!.slug)}`}>
|
||||
Add supported connection
|
||||
</Link>
|
||||
</Button>
|
||||
) : onReconnect ? (
|
||||
<Button size="sm" variant="outline" onClick={onReconnect}>Reconnect</Button>
|
||||
) : managedByVercel && !oauth ? (
|
||||
@@ -449,6 +468,7 @@ export function DangerZone({
|
||||
const paused = connection
|
||||
? connection.enabled === false || connection.status === "disabled"
|
||||
: false;
|
||||
const methodUnavailable = connection ? connectionMethodUnavailable(connection, galleryEntry) : false;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
@@ -487,7 +507,7 @@ export function DangerZone({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{connection && connection.authKind !== "oauth" ? (
|
||||
{connection && !methodUnavailable && connection.authKind !== "oauth" ? (
|
||||
<div className="py-4">
|
||||
<KeySection
|
||||
connection={connection}
|
||||
@@ -499,7 +519,7 @@ export function DangerZone({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{connection?.authKind === "oauth" && (onReconnectIdentity || !canReplaceCredential) ? (
|
||||
{connection?.authKind === "oauth" && !methodUnavailable && (onReconnectIdentity || !canReplaceCredential) ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Reconnect</p>
|
||||
|
||||
Reference in new issue
Block a user