authorgravatar for 35393046+lnieuwenhuis@users.noreply.github.comLars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> 2026-08-15 12:44:19+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:44:19+02:00
log48cba7d93c8c63508f31cce2544d480ace86f929
treea2a1c01b58c8903294770a73eb6f7fefb5963cec
parent71c6f8248775066ebaf4bfc6680d3e2acb4bb2d1
signature Signed by PGP key B5690EEEBB952194

fix(web): restore the Archive action in the default sidebar thread menu (#6526)


4 files changed, 95 insertions(+), 1 deletions(-)

apps/web/src/components/Sidebar.tsx+34
......@@ -1622,6 +1622,7 @@ export default function Sidebar() {
16221622 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
16231623 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
16241624 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
1625 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
16251626 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
16261627 const timestampFormat = useClientSettings((s) => s.timestampFormat);
16271628 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
......@@ -1633,6 +1634,7 @@ export default function Sidebar() {
16331634 pinThread,
16341635 unpinThread,
16351636 reorderPinnedThread,
1637 archiveThread,
16361638 deleteThread,
16371639 } = useThreadActions();
16381640 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
......@@ -2990,6 +2992,8 @@ export default function Sidebar() {
29902992 isSnoozed,
29912993 canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }),
29922994 isRegeneratingTitle,
2995 isRunning:
2996 thread.session?.status === "running" && thread.session.activeTurnId != null,
29932997 supports: {
29942998 settlement: supportsSettlement,
29952999 snooze: supportsSnooze,
......@@ -3093,6 +3097,34 @@ export default function Sidebar() {
30933097 case "copy-thread-id":
30943098 copyThreadIdToClipboard(thread.id, { threadId: thread.id });
30953099 return;
3100 case "archive": {
3101 if (confirmThreadArchive) {
3102 const confirmed = await settlePromise(() =>
3103 api.dialogs.confirm(`Archive thread "${thread.title}"?`),
3104 );
3105 if (confirmed._tag === "Failure" || !confirmed.value) return;
3106 }
3107 let didArchive = false;
3108 const result = await archiveThread(threadRef, {
3109 onArchived: () => {
3110 didArchive = true;
3111 },
3112 });
3113 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
3114 const error = squashAtomCommandFailure(result);
3115 toastManager.add(
3116 stackedThreadToast({
3117 type: "error",
3118 title: didArchive
3119 ? "Thread archived, but navigation failed"
3120 : "Failed to archive thread",
3121 description: error instanceof Error ? error.message : "An error occurred.",
3122 }),
3123 );
3124 return;
3125 }
3126 return;
3127 }
30963128 case "delete": {
30973129 if (confirmThreadDelete) {
30983130 const confirmed = await settlePromise(() =>
......@@ -3126,12 +3158,14 @@ export default function Sidebar() {
31263158 })();
31273159 },
31283160 [
3161 archiveThread,
31293162 attemptPin,
31303163 attemptSettle,
31313164 attemptSnooze,
31323165 attemptUnpin,
31333166 attemptUnsettle,
31343167 attemptUnsnooze,
3168 confirmThreadArchive,
31353169 confirmThreadDelete,
31363170 copyBranchToClipboard,
31373171 copyPathToClipboard,
apps/web/src/components/threadActionMenu.logic.test.ts+26-1
......@@ -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" },
......@@ -26,7 +27,7 @@ describe("buildThreadActionMenuItems", () => {
2627 ...baseState,
2728 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
2829 }),
29 ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]);
30 ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "archive", "delete"]);
3031 });
3132
3233 it("includes branch items only for threads with a branch", () => {
......@@ -63,4 +64,28 @@ describe("buildThreadActionMenuItems", () => {
6364 const items = buildThreadActionMenuItems({ ...baseState, branch: "main" });
6465 expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true });
6566 });
67
68 it("offers archive as a non-destructive action right before delete", () => {
69 const items = buildThreadActionMenuItems(baseState);
70 const archiveItem = items.at(-2);
71 expect(archiveItem?.id).toBe("archive");
72 expect(archiveItem?.destructive).toBeFalsy();
73 expect(items.at(-1)?.id).toBe("delete");
74 });
75
76 it("keeps archive available even when the environment lacks every other capability", () => {
77 expect(
78 ids({
79 ...baseState,
80 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
81 }),
82 ).toContain("archive");
83 });
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 });
6691});
apps/web/src/components/threadActionMenu.logic.ts+9
......@@ -21,6 +21,7 @@ export type ThreadActionMenuId =
2121 | "copy-path"
2222 | "copy-branch"
2323 | "copy-thread-id"
24 | "archive"
2425 | "delete";
2526
2627export interface ThreadActionMenuState {
......@@ -30,6 +31,8 @@ export interface ThreadActionMenuState {
3031 readonly isSnoozed: boolean;
3132 readonly canSnoozeNow: boolean;
3233 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;
3336 readonly supports: {
3437 readonly settlement: boolean;
3538 readonly snooze: boolean;
......@@ -102,6 +105,12 @@ export function buildThreadActionMenuItems(
102105 { id: "copy-path", label: "Copy path", icon: "copy" },
103106 ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []),
104107 { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" },
108 // Archive removes the thread from the sidebar while keeping its
109 // conversation under Settings > Archived threads — distinct from Settle
110 // (stays visible in the Settled shelf) and Delete (clears history for
111 // good), so it sits beside Delete without borrowing its destructive
112 // styling.
113 { id: "archive", label: "Archive thread", disabled: state.isRunning },
105114 { id: "delete", label: "Delete", destructive: true, icon: "trash" },
106115 ];
107116}
apps/web/src/hooks/useThreadActionMenu.ts+26
......@@ -72,6 +72,7 @@ export function useThreadActionMenu(input: {
7272 unsnoozeThread,
7373 pinThread,
7474 unpinThread,
75 archiveThread,
7576 deleteThread,
7677 } = useThreadActions();
7778 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
......@@ -82,6 +83,7 @@ export function useThreadActionMenu(input: {
8283 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
8384 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
8485 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
86 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
8587 const timestampFormat = useClientSettings((s) => s.timestampFormat);
8688 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
8789 onCopy: ({ path }) => {
......@@ -139,6 +141,7 @@ export function useThreadActionMenu(input: {
139141 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
140142 canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),
141143 isRegeneratingTitle,
144 isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null,
142145 supports,
143146 snoozePresets,
144147 });
......@@ -253,6 +256,27 @@ export function useThreadActionMenu(input: {
253256 case "copy-thread-id":
254257 copyThreadIdToClipboard(thread.id, { threadId: thread.id });
255258 return;
259 case "archive": {
260 if (confirmThreadArchive) {
261 const confirmed = await settlePromise(() =>
262 api.dialogs.confirm(`Archive thread "${thread.title}"?`),
263 );
264 if (confirmed._tag === "Failure" || !confirmed.value) return;
265 }
266 let didArchive = false;
267 const result = await archiveThread(threadRef, {
268 onArchived: () => {
269 didArchive = true;
270 },
271 });
272 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
273 failureToast(
274 didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread",
275 squashAtomCommandFailure(result),
276 );
277 }
278 return;
279 }
256280 case "delete": {
257281 if (confirmThreadDelete) {
258282 const confirmed = await settlePromise(() =>
......@@ -285,9 +309,11 @@ export function useThreadActionMenu(input: {
285309 })();
286310 },
287311 [
312 archiveThread,
288313 autoSettleAfterDays,
289314 autoSettleOnMerge,
290315 changeRequestState,
316 confirmThreadArchive,
291317 confirmThreadDelete,
292318 copyBranchToClipboard,
293319 copyPathToClipboard,