Integrate runner recovery through work-folder PR 13002

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-12 20:00:14 -05:00
commit b779478ae3
96 files changed
+3085 -474

No files matched your search

+63 -25
View File
@@ -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();
+79 -23
View File
@@ -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 = `![${safeName}](${url})`;
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);
+19 -4
View File
@@ -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
+16 -3
View File
@@ -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"
+154 -28
View File
@@ -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 />);
+48 -23
View File
@@ -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"