Skip to content

Commit 1524c5e

Browse files
committed
fix(web): disable archive on running threads and fix misleading failure toast
Macroscope and Cursor Bugbot flagged two real issues in the single-thread archive path (sidebar row menu and chat header menu): the menu item stayed enabled while a thread had an active turn, so it always failed against archiveThread's ThreadArchiveBlockedError guard, and any failure after a successful archive (e.g. the post-archive navigation to a new thread) was reported as "Failed to archive thread" even though the thread had already been archived. Both are already handled correctly in the bulk-archive path (buildMultiSelectThreadContextMenuItems disables on hasRunningThread, and archiveSelectedThreadEntries distinguishes a post-archive navigation failure); this brings the single-thread path in line with that pattern.
1 parent e2cc16e commit 1524c5e

4 files changed

Lines changed: 35 additions & 4 deletions

File tree

apps/web/src/components/Sidebar.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2964,6 +2964,8 @@ export default function Sidebar() {
29642964
isSnoozed,
29652965
canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }),
29662966
isRegeneratingTitle,
2967+
isRunning:
2968+
thread.session?.status === "running" && thread.session.activeTurnId != null,
29672969
supports: {
29682970
settlement: supportsSettlement,
29692971
snooze: supportsSnooze,
@@ -3074,13 +3076,20 @@ export default function Sidebar() {
30743076
);
30753077
if (confirmed._tag === "Failure" || !confirmed.value) return;
30763078
}
3077-
const result = await archiveThread(threadRef);
3079+
let didArchive = false;
3080+
const result = await archiveThread(threadRef, {
3081+
onArchived: () => {
3082+
didArchive = true;
3083+
},
3084+
});
30783085
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
30793086
const error = squashAtomCommandFailure(result);
30803087
toastManager.add(
30813088
stackedThreadToast({
30823089
type: "error",
3083-
title: "Failed to archive thread",
3090+
title: didArchive
3091+
? "Thread archived, but navigation failed"
3092+
: "Failed to archive thread",
30843093
description: error instanceof Error ? error.message : "An error occurred.",
30853094
}),
30863095
);

apps/web/src/components/threadActionMenu.logic.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const baseState: ThreadActionMenuState = {
99
isSnoozed: false,
1010
canSnoozeNow: true,
1111
isRegeneratingTitle: false,
12+
isRunning: false,
1213
supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true },
1314
snoozePresets: [
1415
{ id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" },
@@ -80,4 +81,11 @@ describe("buildThreadActionMenuItems", () => {
8081
}),
8182
).toContain("archive");
8283
});
84+
85+
it("disables archive while the thread is running", () => {
86+
const archiveItem = buildThreadActionMenuItems({ ...baseState, isRunning: true }).find(
87+
(item) => item.id === "archive",
88+
);
89+
expect(archiveItem?.disabled).toBe(true);
90+
});
8391
});

apps/web/src/components/threadActionMenu.logic.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export interface ThreadActionMenuState {
3131
readonly isSnoozed: boolean;
3232
readonly canSnoozeNow: boolean;
3333
readonly isRegeneratingTitle: boolean;
34+
/** Archive rejects a thread with an active turn, so disable it here rather than let the action fail. */
35+
readonly isRunning: boolean;
3436
readonly supports: {
3537
readonly settlement: boolean;
3638
readonly snooze: boolean;
@@ -108,7 +110,7 @@ export function buildThreadActionMenuItems(
108110
// (stays visible in the Settled shelf) and Delete (clears history for
109111
// good), so it sits beside Delete without borrowing its destructive
110112
// styling.
111-
{ id: "archive", label: "Archive thread" },
113+
{ id: "archive", label: "Archive thread", disabled: state.isRunning },
112114
{ id: "delete", label: "Delete", destructive: true, icon: "trash" },
113115
];
114116
}

apps/web/src/hooks/useThreadActionMenu.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export function useThreadActionMenu(input: {
139139
isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
140140
canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),
141141
isRegeneratingTitle,
142+
isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null,
142143
supports,
143144
snoozePresets,
144145
});
@@ -260,7 +261,18 @@ export function useThreadActionMenu(input: {
260261
);
261262
if (confirmed._tag === "Failure" || !confirmed.value) return;
262263
}
263-
await reportFailure("Failed to archive thread", () => archiveThread(threadRef));
264+
let didArchive = false;
265+
const result = await archiveThread(threadRef, {
266+
onArchived: () => {
267+
didArchive = true;
268+
},
269+
});
270+
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
271+
failureToast(
272+
didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread",
273+
squashAtomCommandFailure(result),
274+
);
275+
}
264276
return;
265277
}
266278
case "delete": {

0 commit comments

Comments
 (0)