| ... | @@ -2182,6 +2182,40 @@ export default function SidebarV2() { | ... | @@ -2182,6 +2182,40 @@ export default function SidebarV2() { |
| 2182 | ); | 2182 | ); |
| 2183 | // One snooze per thread at a time — same double-dispatch guard as settle. | 2183 | // One snooze per thread at a time — same double-dispatch guard as settle. |
| 2184 | const snoozingThreadKeysRef = useRef(new Set<string>()); | 2184 | const snoozingThreadKeysRef = useRef(new Set<string>()); |
| | 2185 | const performSnooze = useCallback( |
| | 2186 | async ( |
| | 2187 | threadRef: ScopedThreadRef, |
| | 2188 | preset: SnoozePreset, |
| | 2189 | opts: { coSnoozingKeys?: ReadonlySet<string> } = {}, |
| | 2190 | ) => { |
| | 2191 | const threadKey = scopedThreadKey(threadRef); |
| | 2192 | if (snoozingThreadKeysRef.current.has(threadKey)) { |
| | 2193 | return { status: "skipped" } as const; |
| | 2194 | } |
| | 2195 | snoozingThreadKeysRef.current.add(threadKey); |
| | 2196 | try { |
| | 2197 | // Snoozing the open thread moves you forward, same as settle — |
| | 2198 | // both park the thread you're done with for now. |
| | 2199 | const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); |
| | 2200 | const result = await snoozeThread(threadRef, preset.snoozedUntil); |
| | 2201 | if (result._tag === "Failure") { |
| | 2202 | // Never navigate away from a thread that did not snooze. |
| | 2203 | return isAtomCommandInterrupted(result) |
| | 2204 | ? ({ status: "interrupted" } as const) |
| | 2205 | : ({ status: "failure", error: squashAtomCommandFailure(result) } as const); |
| | 2206 | } |
| | 2207 | // Only move forward if the user is still on the snoozed thread — |
| | 2208 | // a navigation made during the await wins over ours. |
| | 2209 | if (routeThreadKeyRef.current === threadKey) { |
| | 2210 | navigateAfterSnooze?.(); |
| | 2211 | } |
| | 2212 | return { status: "success" } as const; |
| | 2213 | } finally { |
| | 2214 | snoozingThreadKeysRef.current.delete(threadKey); |
| | 2215 | } |
| | 2216 | }, |
| | 2217 | [planForwardNavigation, snoozeThread], |
| | 2218 | ); |
| 2185 | const attemptSnooze = useCallback( | 2219 | const attemptSnooze = useCallback( |
| 2186 | ( | 2220 | ( |
| 2187 | threadRef: ScopedThreadRef, | 2221 | threadRef: ScopedThreadRef, |
| ... | @@ -2189,52 +2223,35 @@ export default function SidebarV2() { | ... | @@ -2189,52 +2223,35 @@ export default function SidebarV2() { |
| 2189 | opts: { coSnoozingKeys?: ReadonlySet<string> } = {}, | 2223 | opts: { coSnoozingKeys?: ReadonlySet<string> } = {}, |
| 2190 | ) => { | 2224 | ) => { |
| 2191 | void (async () => { | 2225 | void (async () => { |
| 2192 | const threadKey = scopedThreadKey(threadRef); | 2226 | const outcome = await performSnooze(threadRef, preset, opts); |
| 2193 | if (snoozingThreadKeysRef.current.has(threadKey)) return; | 2227 | if (outcome.status === "failure") { |
| 2194 | snoozingThreadKeysRef.current.add(threadKey); | | |
| 2195 | try { | | |
| 2196 | // Snoozing the open thread moves you forward, same as settle — | | |
| 2197 | // both park the thread you're done with for now. | | |
| 2198 | const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); | | |
| 2199 | const result = await snoozeThread(threadRef, preset.snoozedUntil); | | |
| 2200 | if (result._tag === "Failure") { | | |
| 2201 | // Never navigate away from a thread that did not snooze. | | |
| 2202 | if (!isAtomCommandInterrupted(result)) { | | |
| 2203 | const error = squashAtomCommandFailure(result); | | |
| 2204 | toastManager.add( | | |
| 2205 | stackedThreadToast({ | | |
| 2206 | type: "error", | | |
| 2207 | title: "Failed to snooze thread", | | |
| 2208 | description: error instanceof Error ? error.message : "An error occurred.", | | |
| 2209 | }), | | |
| 2210 | ); | | |
| 2211 | } | | |
| 2212 | return; | | |
| 2213 | } | | |
| 2214 | // Snooze hides the row, so the toast is the only confirmation — | | |
| 2215 | // and the Undo is the escape hatch for a mis-click. | | |
| 2216 | toastManager.add( | 2228 | toastManager.add( |
| 2217 | stackedThreadToast({ | 2229 | stackedThreadToast({ |
| 2218 | type: "success", | 2230 | type: "error", |
| 2219 | title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, | 2231 | title: "Failed to snooze thread", |
| 2220 | timeout: 5_000, | 2232 | description: |
| 2221 | actionProps: { | 2233 | outcome.error instanceof Error ? outcome.error.message : "An error occurred.", |
| 2222 | children: "Undo", | | |
| 2223 | onClick: () => attemptUnsnooze(threadRef), | | |
| 2224 | }, | | |
| 2225 | }), | 2234 | }), |
| 2226 | ); | 2235 | ); |
| 2227 | // Only move forward if the user is still on the snoozed thread — | 2236 | return; |
| 2228 | // a navigation made during the await wins over ours. | | |
| 2229 | if (routeThreadKeyRef.current === threadKey) { | | |
| 2230 | navigateAfterSnooze?.(); | | |
| 2231 | } | | |
| 2232 | } finally { | | |
| 2233 | snoozingThreadKeysRef.current.delete(threadKey); | | |
| 2234 | } | 2237 | } |
| | 2238 | if (outcome.status !== "success") return; |
| | 2239 | // Snooze hides the row, so the toast is the only confirmation — |
| | 2240 | // and the Undo is the escape hatch for a mis-click. |
| | 2241 | toastManager.add( |
| | 2242 | stackedThreadToast({ |
| | 2243 | type: "success", |
| | 2244 | title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, |
| | 2245 | timeout: 5_000, |
| | 2246 | actionProps: { |
| | 2247 | children: "Undo", |
| | 2248 | onClick: () => attemptUnsnooze(threadRef), |
| | 2249 | }, |
| | 2250 | }), |
| | 2251 | ); |
| 2235 | })(); | 2252 | })(); |
| 2236 | }, | 2253 | }, |
| 2237 | [attemptUnsnooze, planForwardNavigation, snoozeThread, timestampFormat], | 2254 | [attemptUnsnooze, performSnooze, timestampFormat], |
| 2238 | ); | 2255 | ); |
| 2239 | | 2256 | |
| 2240 | const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); | 2257 | const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); |
| ... | @@ -2308,12 +2325,55 @@ export default function SidebarV2() { | ... | @@ -2308,12 +2325,55 @@ export default function SidebarV2() { |
| 2308 | // Post-snooze navigation must skip threads snoozing in this same | 2325 | // Post-snooze navigation must skip threads snoozing in this same |
| 2309 | // batch — they are all leaving the card block together. | 2326 | // batch — they are all leaving the card block together. |
| 2310 | const coSnoozingKeys = new Set(threadKeys); | 2327 | const coSnoozingKeys = new Set(threadKeys); |
| 2311 | for (const thread of selectedThreads) { | | |
| 2312 | attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, { | | |
| 2313 | coSnoozingKeys, | | |
| 2314 | }); | | |
| 2315 | } | | |
| 2316 | clearSelection(); | 2328 | clearSelection(); |
| | 2329 | const outcomes = await Promise.all( |
| | 2330 | selectedThreads.map(async (thread) => { |
| | 2331 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); |
| | 2332 | const outcome = await performSnooze(threadRef, preset, { coSnoozingKeys }); |
| | 2333 | return { outcome, threadRef }; |
| | 2334 | }), |
| | 2335 | ); |
| | 2336 | const snoozedThreadRefs = outcomes.flatMap(({ outcome, threadRef }) => |
| | 2337 | outcome.status === "success" ? [threadRef] : [], |
| | 2338 | ); |
| | 2339 | const failures = outcomes.flatMap(({ outcome }) => |
| | 2340 | outcome.status === "failure" ? [outcome.error] : [], |
| | 2341 | ); |
| | 2342 | |
| | 2343 | if (snoozedThreadRefs.length > 0) { |
| | 2344 | const snoozedCount = snoozedThreadRefs.length; |
| | 2345 | const failedCount = failures.length; |
| | 2346 | toastManager.add( |
| | 2347 | stackedThreadToast({ |
| | 2348 | type: failedCount > 0 ? "warning" : "success", |
| | 2349 | title: |
| | 2350 | failedCount > 0 |
| | 2351 | ? `Snoozed ${snoozedCount} of ${selectedThreads.length} threads` |
| | 2352 | : `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`, |
| | 2353 | description: |
| | 2354 | failedCount > 0 |
| | 2355 | ? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.` |
| | 2356 | : undefined, |
| | 2357 | timeout: 5_000, |
| | 2358 | actionProps: { |
| | 2359 | children: "Undo", |
| | 2360 | onClick: () => { |
| | 2361 | for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef); |
| | 2362 | }, |
| | 2363 | }, |
| | 2364 | }), |
| | 2365 | ); |
| | 2366 | } else if (failures.length > 0) { |
| | 2367 | const firstError = failures[0]; |
| | 2368 | toastManager.add( |
| | 2369 | stackedThreadToast({ |
| | 2370 | type: "error", |
| | 2371 | title: "Failed to snooze threads", |
| | 2372 | description: |
| | 2373 | firstError instanceof Error ? firstError.message : "An error occurred.", |
| | 2374 | }), |
| | 2375 | ); |
| | 2376 | } |
| 2317 | } | 2377 | } |
| 2318 | return; | 2378 | return; |
| 2319 | } | 2379 | } |
| ... | @@ -2409,8 +2469,10 @@ export default function SidebarV2() { | ... | @@ -2409,8 +2469,10 @@ export default function SidebarV2() { |
| 2409 | confirmThreadDelete, | 2469 | confirmThreadDelete, |
| 2410 | deleteThread, | 2470 | deleteThread, |
| 2411 | markThreadUnread, | 2471 | markThreadUnread, |
| | 2472 | performSnooze, |
| 2412 | removeFromSelection, | 2473 | removeFromSelection, |
| 2413 | serverConfigs, | 2474 | serverConfigs, |
| | 2475 | attemptUnsnooze, |
| 2414 | updateThreadMetadata, | 2476 | updateThreadMetadata, |
| 2415 | timestampFormat, | 2477 | timestampFormat, |
| 2416 | ], | 2478 | ], |