| 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,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 | }); |
| 31 | 32 | ||
| 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"; |
| 25 | 26 | ||
| 26 | export interface ThreadActionMenuState { | 27 | export 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, |