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() {...@@ -1622,6 +1622,7 @@ export default function Sidebar() {
1622 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);1622 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
1623 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);1623 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
1624 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);1624 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
1625 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
1625 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);1626 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
1626 const timestampFormat = useClientSettings((s) => s.timestampFormat);1627 const timestampFormat = useClientSettings((s) => s.timestampFormat);
1627 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);1628 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
...@@ -1633,6 +1634,7 @@ export default function Sidebar() {...@@ -1633,6 +1634,7 @@ export default function Sidebar() {
1633 pinThread,1634 pinThread,
1634 unpinThread,1635 unpinThread,
1635 reorderPinnedThread,1636 reorderPinnedThread,
1637 archiveThread,
1636 deleteThread,1638 deleteThread,
1637 } = useThreadActions();1639 } = useThreadActions();
1638 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {1640 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
...@@ -2990,6 +2992,8 @@ export default function Sidebar() {...@@ -2990,6 +2992,8 @@ export default function Sidebar() {
2990 isSnoozed,2992 isSnoozed,
2991 canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }),2993 canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }),
2992 isRegeneratingTitle,2994 isRegeneratingTitle,
2995 isRunning:
2996 thread.session?.status === "running" && thread.session.activeTurnId != null,
2993 supports: {2997 supports: {
2994 settlement: supportsSettlement,2998 settlement: supportsSettlement,
2995 snooze: supportsSnooze,2999 snooze: supportsSnooze,
...@@ -3093,6 +3097,34 @@ export default function Sidebar() {...@@ -3093,6 +3097,34 @@ export default function Sidebar() {
3093 case "copy-thread-id":3097 case "copy-thread-id":
3094 copyThreadIdToClipboard(thread.id, { threadId: thread.id });3098 copyThreadIdToClipboard(thread.id, { threadId: thread.id });
3095 return;3099 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 }
3096 case "delete": {3128 case "delete": {
3097 if (confirmThreadDelete) {3129 if (confirmThreadDelete) {
3098 const confirmed = await settlePromise(() =>3130 const confirmed = await settlePromise(() =>
...@@ -3126,12 +3158,14 @@ export default function Sidebar() {...@@ -3126,12 +3158,14 @@ export default function Sidebar() {
3126 })();3158 })();
3127 },3159 },
3128 [3160 [
3161 archiveThread,
3129 attemptPin,3162 attemptPin,
3130 attemptSettle,3163 attemptSettle,
3131 attemptSnooze,3164 attemptSnooze,
3132 attemptUnpin,3165 attemptUnpin,
3133 attemptUnsettle,3166 attemptUnsettle,
3134 attemptUnsnooze,3167 attemptUnsnooze,
3168 confirmThreadArchive,
3135 confirmThreadDelete,3169 confirmThreadDelete,
3136 copyBranchToClipboard,3170 copyBranchToClipboard,
3137 copyPathToClipboard,3171 copyPathToClipboard,
apps/web/src/components/threadActionMenu.logic.test.ts+26-1
...@@ -9,6 +9,7 @@ const baseState: ThreadActionMenuState = {...@@ -9,6 +9,7 @@ const baseState: ThreadActionMenuState = {
9 isSnoozed: false,9 isSnoozed: false,
10 canSnoozeNow: true,10 canSnoozeNow: true,
11 isRegeneratingTitle: false,11 isRegeneratingTitle: false,
12 isRunning: false,
12 supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true },13 supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true },
13 snoozePresets: [14 snoozePresets: [
14 { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" },15 { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" },
...@@ -26,7 +27,7 @@ describe("buildThreadActionMenuItems", () => {...@@ -26,7 +27,7 @@ describe("buildThreadActionMenuItems", () => {
26 ...baseState,27 ...baseState,
27 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },28 supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
28 }),29 }),
29 ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]);30 ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "archive", "delete"]);
30 });31 });
3132
32 it("includes branch items only for threads with a branch", () => {33 it("includes branch items only for threads with a branch", () => {
...@@ -63,4 +64,28 @@ describe("buildThreadActionMenuItems", () => {...@@ -63,4 +64,28 @@ describe("buildThreadActionMenuItems", () => {
63 const items = buildThreadActionMenuItems({ ...baseState, branch: "main" });64 const items = buildThreadActionMenuItems({ ...baseState, branch: "main" });
64 expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true });65 expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true });
65 });66 });
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 });
66});91});
apps/web/src/components/threadActionMenu.logic.ts+9
...@@ -21,6 +21,7 @@ export type ThreadActionMenuId =...@@ -21,6 +21,7 @@ export type ThreadActionMenuId =
21 | "copy-path"21 | "copy-path"
22 | "copy-branch"22 | "copy-branch"
23 | "copy-thread-id"23 | "copy-thread-id"
24 | "archive"
24 | "delete";25 | "delete";
2526
26export interface ThreadActionMenuState {27export interface ThreadActionMenuState {
...@@ -30,6 +31,8 @@ export interface ThreadActionMenuState {...@@ -30,6 +31,8 @@ export interface ThreadActionMenuState {
30 readonly isSnoozed: boolean;31 readonly isSnoozed: boolean;
31 readonly canSnoozeNow: boolean;32 readonly canSnoozeNow: boolean;
32 readonly isRegeneratingTitle: boolean;33 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;
33 readonly supports: {36 readonly supports: {
34 readonly settlement: boolean;37 readonly settlement: boolean;
35 readonly snooze: boolean;38 readonly snooze: boolean;
...@@ -102,6 +105,12 @@ export function buildThreadActionMenuItems(...@@ -102,6 +105,12 @@ export function buildThreadActionMenuItems(
102 { id: "copy-path", label: "Copy path", icon: "copy" },105 { id: "copy-path", label: "Copy path", icon: "copy" },
103 ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []),106 ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []),
104 { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" },107 { 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 },
105 { id: "delete", label: "Delete", destructive: true, icon: "trash" },114 { id: "delete", label: "Delete", destructive: true, icon: "trash" },
106 ];115 ];
107}116}
apps/web/src/hooks/useThreadActionMenu.ts+26
...@@ -72,6 +72,7 @@ export function useThreadActionMenu(input: {...@@ -72,6 +72,7 @@ export function useThreadActionMenu(input: {
72 unsnoozeThread,72 unsnoozeThread,
73 pinThread,73 pinThread,
74 unpinThread,74 unpinThread,
75 archiveThread,
75 deleteThread,76 deleteThread,
76 } = useThreadActions();77 } = useThreadActions();
77 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {78 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
...@@ -82,6 +83,7 @@ export function useThreadActionMenu(input: {...@@ -82,6 +83,7 @@ export function useThreadActionMenu(input: {
82 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);83 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
83 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);84 const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
84 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);85 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
86 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
85 const timestampFormat = useClientSettings((s) => s.timestampFormat);87 const timestampFormat = useClientSettings((s) => s.timestampFormat);
86 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({88 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
87 onCopy: ({ path }) => {89 onCopy: ({ path }) => {
...@@ -139,6 +141,7 @@ export function useThreadActionMenu(input: {...@@ -139,6 +141,7 @@ export function useThreadActionMenu(input: {
139 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),141 isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
140 canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),142 canSnoozeNow: canSnooze(thread, { now: now.toISOString() }),
141 isRegeneratingTitle,143 isRegeneratingTitle,
144 isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null,
142 supports,145 supports,
143 snoozePresets,146 snoozePresets,
144 });147 });
...@@ -253,6 +256,27 @@ export function useThreadActionMenu(input: {...@@ -253,6 +256,27 @@ export function useThreadActionMenu(input: {
253 case "copy-thread-id":256 case "copy-thread-id":
254 copyThreadIdToClipboard(thread.id, { threadId: thread.id });257 copyThreadIdToClipboard(thread.id, { threadId: thread.id });
255 return;258 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 }
256 case "delete": {280 case "delete": {
257 if (confirmThreadDelete) {281 if (confirmThreadDelete) {
258 const confirmed = await settlePromise(() =>282 const confirmed = await settlePromise(() =>
...@@ -285,9 +309,11 @@ export function useThreadActionMenu(input: {...@@ -285,9 +309,11 @@ export function useThreadActionMenu(input: {
285 })();309 })();
286 },310 },
287 [311 [
312 archiveThread,
288 autoSettleAfterDays,313 autoSettleAfterDays,
289 autoSettleOnMerge,314 autoSettleOnMerge,
290 changeRequestState,315 changeRequestState,
316 confirmThreadArchive,
291 confirmThreadDelete,317 confirmThreadDelete,
292 copyBranchToClipboard,318 copyBranchToClipboard,
293 copyPathToClipboard,319 copyPathToClipboard,