fix(ux): cap the in-app toast stack; stable "Unread First" room sort

- Toast queue: a burst of notifications appended unboundedly and could cover the
  viewport. Cap at 5 in the atom writer, dropping the OLDEST non-sticky toast
  (sticky = action toasts requiring a click, never dropped). The drop scan
  excludes the just-appended newest (`length - 1` bound) so a fresh toast is
  never the one eaten when the cap is full of stickies — it stretches instead.
  Container gains a maxHeight + overflowY safety net and scrolls the newest
  (bottom) toast into view if the stack ever overflows. +4 unit tests incl. the
  cap-full-of-stickies boundary.

- "Unread First" room sort left the entire read tail (all counts tie at 0) in
  arbitrary Map order. factoryRoomIdByUnread now breaks ties by recent activity.
  Relocated from Home.tsx (module-private) to utils/sort.ts (exported, pure) and
  unit-tested (equal-count and read-tail cases fall back to activity).

Bug-hunt findings from LOTUS_TODO. Three review passes: the second caught that
the cap could silently drop the newest notification when full of stickies (real
bug, untested boundary) — fixed and covered; a third traced the corrected loop.
Gate-green (tsc, eslint, prettier, 920 tests, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 17:22:23 -04:00
co-authored by Claude Opus 4.8
parent d07f16586a
commit 1963222d1e
6 changed files with 136 additions and 17 deletions
+14 -1
View File
@@ -222,6 +222,15 @@ export function LotusToastContainer() {
const toasts = useAtomValue(toastQueueAtom);
const isMobile = useScreenSize() === ScreenSize.Mobile;
const listRef = useRef<HTMLDivElement>(null);
// The newest toast is the last (bottom) child; if the stack ever overflows its
// max-height (many sticky action toasts), keep that newest one in view instead
// of leaving it scrolled below the fold.
useEffect(() => {
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [toasts.length]);
if (toasts.length === 0) return null;
@@ -237,10 +246,14 @@ export function LotusToastContainer() {
flexDirection: 'column',
gap: config.space.S200,
pointerEvents: 'auto',
// Safety net beyond the queue cap: if many sticky action toasts pile up they
// scroll within a bounded height instead of covering the whole screen.
maxHeight: isMobile ? '70vh' : '80vh',
overflowY: 'auto',
};
return (
<div style={containerStyle} aria-live="polite" aria-label="Notifications">
<div ref={listRef} style={containerStyle} aria-live="polite" aria-label="Notifications">
{toasts.map((toast) => (
<ToastCard key={toast.id} toast={toast} />
))}