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