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() {
21822182 );
21832183 // One snooze per thread at a time — same double-dispatch guard as settle.
21842184 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 );
21852219 const attemptSnooze = useCallback(
21862220 (
21872221 threadRef: ScopedThreadRef,
......@@ -2189,52 +2223,35 @@ export default function SidebarV2() {
21892223 opts: { coSnoozingKeys?: ReadonlySet<string> } = {},
21902224 ) => {
21912225 void (async () => {
2192 const threadKey = scopedThreadKey(threadRef);
2193 if (snoozingThreadKeysRef.current.has(threadKey)) return;
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.
2226 const outcome = await performSnooze(threadRef, preset, opts);
2227 if (outcome.status === "failure") {
22162228 toastManager.add(
22172229 stackedThreadToast({
2218 type: "success",
2219 title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`,
2220 timeout: 5_000,
2221 actionProps: {
2222 children: "Undo",
2223 onClick: () => attemptUnsnooze(threadRef),
2224 },
2230 type: "error",
2231 title: "Failed to snooze thread",
2232 description:
2233 outcome.error instanceof Error ? outcome.error.message : "An error occurred.",
22252234 }),
22262235 );
2227 // Only move forward if the user is still on the snoozed thread —
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);
2236 return;
22342237 }
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 );
22352252 })();
22362253 },
2237 [attemptUnsnooze, planForwardNavigation, snoozeThread, timestampFormat],
2254 [attemptUnsnooze, performSnooze, timestampFormat],
22382255 );
22392256
22402257 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
......@@ -2308,12 +2325,55 @@ export default function SidebarV2() {
23082325 // Post-snooze navigation must skip threads snoozing in this same
23092326 // batch — they are all leaving the card block together.
23102327 const coSnoozingKeys = new Set(threadKeys);
2311 for (const thread of selectedThreads) {
2312 attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, {
2313 coSnoozingKeys,
2314 });
2315 }
23162328 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 }
23172377 }
23182378 return;
23192379 }
......@@ -2409,8 +2469,10 @@ export default function SidebarV2() {
24092469 confirmThreadDelete,
24102470 deleteThread,
24112471 markThreadUnread,
2472 performSnooze,
24122473 removeFromSelection,
24132474 serverConfigs,
2475 attemptUnsnooze,
24142476 updateThreadMetadata,
24152477 timestampFormat,
24162478 ],