mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
Integrate runner recovery through work-folder PR 13002
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
b779478ae3
96 files changed
+3085
-474
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}
|
||||
|
||||
@@ -2642,7 +2642,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"
|
||||
|
||||
Reference in new issue
Block a user