authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-06 21:16:36-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-06 18:16:36-07:00
log6da92244cc2a7438703be95a0fcfaca0b73502a7
tree9bfc46145e36da36979a1c2ecbe71335db85ac61
parentc471145e96fc2399b4df57ce78c72aa7fb7ccabf
signature Signed by PGP key B5690EEEBB952194

fix(web): show one toast when snoozing threads in bulk (#5560)


1 files changed, 106 insertions(+), 44 deletions(-)

apps/web/src/components/SidebarV2.tsx+106-44
...@@ -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 );
22392256
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 same2325 // 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 ],