From e2cc16ecc23c628d837d1755956649fcbf6c3c95 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:39:23 +0200 Subject: [PATCH 1/2] fix(web): restore archive action in default sidebar thread menu Archive was dropped from the new sidebar's per-thread menu when it became the default, leaving Delete as the only way to remove a thread from the sidebar even though Delete permanently clears history. Add Archive back to buildThreadActionMenuItems (the shared source for the sidebar row menu and chat header menu) and wire it through both surfaces, reusing the existing archiveThread mutation and confirmThreadArchive setting the legacy sidebar already relies on. --- apps/web/src/components/Sidebar.tsx | 25 +++++++++++++++++++ .../components/threadActionMenu.logic.test.ts | 19 +++++++++++++- .../src/components/threadActionMenu.logic.ts | 7 ++++++ apps/web/src/hooks/useThreadActionMenu.ts | 14 +++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f054b3deedc..5ef2657e84f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1600,6 +1600,7 @@ export default function Sidebar() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -1611,6 +1612,7 @@ export default function Sidebar() { pinThread, unpinThread, reorderPinnedThread, + archiveThread, deleteThread, } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -3065,6 +3067,27 @@ export default function Sidebar() { case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; + case "archive": { + if (confirmThreadArchive) { + const confirmed = await settlePromise(() => + api.dialogs.confirm(`Archive thread "${thread.title}"?`), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + const result = await archiveThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + return; + } case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -3098,12 +3121,14 @@ export default function Sidebar() { })(); }, [ + archiveThread, attemptPin, attemptSettle, attemptSnooze, attemptUnpin, attemptUnsettle, attemptUnsnooze, + confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 93dc653e7c0..5eac98f7188 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -26,7 +26,7 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); + ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "archive", "delete"]); }); it("includes branch items only for threads with a branch", () => { @@ -63,4 +63,21 @@ describe("buildThreadActionMenuItems", () => { const items = buildThreadActionMenuItems({ ...baseState, branch: "main" }); expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true }); }); + + it("offers archive as a non-destructive action right before delete", () => { + const items = buildThreadActionMenuItems(baseState); + const archiveItem = items.at(-2); + expect(archiveItem?.id).toBe("archive"); + expect(archiveItem?.destructive).toBeFalsy(); + expect(items.at(-1)?.id).toBe("delete"); + }); + + it("keeps archive available even when the environment lacks every other capability", () => { + expect( + ids({ + ...baseState, + supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, + }), + ).toContain("archive"); + }); }); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index ef4b38dcdac..dcb225e6638 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -21,6 +21,7 @@ export type ThreadActionMenuId = | "copy-path" | "copy-branch" | "copy-thread-id" + | "archive" | "delete"; export interface ThreadActionMenuState { @@ -102,6 +103,12 @@ export function buildThreadActionMenuItems( { id: "copy-path", label: "Copy path", icon: "copy" }, ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, + // Archive removes the thread from the sidebar while keeping its + // conversation under Settings > Archived threads — distinct from Settle + // (stays visible in the Settled shelf) and Delete (clears history for + // good), so it sits beside Delete without borrowing its destructive + // styling. + { id: "archive", label: "Archive thread" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ]; } diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 4eac13fddb3..5023ad26653 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -72,6 +72,7 @@ export function useThreadActionMenu(input: { unsnoozeThread, pinThread, unpinThread, + archiveThread, deleteThread, } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -81,6 +82,7 @@ export function useThreadActionMenu(input: { const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { @@ -251,6 +253,16 @@ export function useThreadActionMenu(input: { case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; + case "archive": { + if (confirmThreadArchive) { + const confirmed = await settlePromise(() => + api.dialogs.confirm(`Archive thread "${thread.title}"?`), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + await reportFailure("Failed to archive thread", () => archiveThread(threadRef)); + return; + } case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -283,8 +295,10 @@ export function useThreadActionMenu(input: { })(); }, [ + archiveThread, autoSettleAfterDays, changeRequestState, + confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, From 1524c5e9b38698b7159b4664e3951264c98e6cb1 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:57:35 +0200 Subject: [PATCH 2/2] 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. --- apps/web/src/components/Sidebar.tsx | 13 +++++++++++-- .../src/components/threadActionMenu.logic.test.ts | 8 ++++++++ apps/web/src/components/threadActionMenu.logic.ts | 4 +++- apps/web/src/hooks/useThreadActionMenu.ts | 14 +++++++++++++- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5ef2657e84f..e71ba2afe0f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2964,6 +2964,8 @@ export default function Sidebar() { isSnoozed, canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), isRegeneratingTitle, + isRunning: + thread.session?.status === "running" && thread.session.activeTurnId != null, supports: { settlement: supportsSettlement, snooze: supportsSnooze, @@ -3074,13 +3076,20 @@ export default function Sidebar() { ); if (confirmed._tag === "Failure" || !confirmed.value) return; } - const result = await archiveThread(threadRef); + let didArchive = false; + const result = await archiveThread(threadRef, { + onArchived: () => { + didArchive = true; + }, + }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to archive thread", + title: didArchive + ? "Thread archived, but navigation failed" + : "Failed to archive thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 5eac98f7188..c839ddc3be7 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -9,6 +9,7 @@ const baseState: ThreadActionMenuState = { isSnoozed: false, canSnoozeNow: true, isRegeneratingTitle: false, + isRunning: false, supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true }, snoozePresets: [ { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" }, @@ -80,4 +81,11 @@ describe("buildThreadActionMenuItems", () => { }), ).toContain("archive"); }); + + it("disables archive while the thread is running", () => { + const archiveItem = buildThreadActionMenuItems({ ...baseState, isRunning: true }).find( + (item) => item.id === "archive", + ); + expect(archiveItem?.disabled).toBe(true); + }); }); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index dcb225e6638..44c2e907ca5 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -31,6 +31,8 @@ export interface ThreadActionMenuState { readonly isSnoozed: boolean; readonly canSnoozeNow: boolean; readonly isRegeneratingTitle: boolean; + /** Archive rejects a thread with an active turn, so disable it here rather than let the action fail. */ + readonly isRunning: boolean; readonly supports: { readonly settlement: boolean; readonly snooze: boolean; @@ -108,7 +110,7 @@ export function buildThreadActionMenuItems( // (stays visible in the Settled shelf) and Delete (clears history for // good), so it sits beside Delete without borrowing its destructive // styling. - { id: "archive", label: "Archive thread" }, + { id: "archive", label: "Archive thread", disabled: state.isRunning }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ]; } diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 5023ad26653..7ebc634f074 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -139,6 +139,7 @@ export function useThreadActionMenu(input: { isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, + isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null, supports, snoozePresets, }); @@ -260,7 +261,18 @@ export function useThreadActionMenu(input: { ); if (confirmed._tag === "Failure" || !confirmed.value) return; } - await reportFailure("Failed to archive thread", () => archiveThread(threadRef)); + let didArchive = false; + const result = await archiveThread(threadRef, { + onArchived: () => { + didArchive = true; + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast( + didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread", + squashAtomCommandFailure(result), + ); + } return; } case "delete": {