componentsfix(mail): a search that leaves the screen stops filtering the list Isabelle's Email tab showed a full page of older mail with almost nothing from the past week, while the same screen's All-channels badge showed her whole inbox. It was not missing mail: the list was still filtered by a `thiago` search she had run three hours earlier, and mail.listThreads re-ran it 52 times on the 5-minute refetch interval with no search box on screen to say so. SearchInput held the only writer that resets searchState.value, and mail.tsx mounts it only while `isSearchOpen || !!searchQuery`. isSearchOpen resets to false on remount and ?search= is dropped by navigation, so the input unmounts in the same commit as the param change and its effect never runs for the new value — while useCedarStore, a module singleton, keeps the compiled query. The mirror moves into useSearchQuerySync(), called unconditionally next to the list it filters: the URL owns the filter, the input only edits the URL. Second defect from the same logs: her Starred split returned 0 rows on every read (over-fetch-cap truncation) and stayed there. The server hands back a short page plus a usable cursor for exactly that case, but MailList renders the inbox-zero state when the list is empty, so the VList never mounts and the auto-load effect bailed on its null-ref guard before it could page. An empty list with hasNextPage now pages ahead of that guard, and spins rather than announcing an inbox zero it cannot yet claim. Report: apps/mail/docs/bug-email-tab-shows-few-threads.md Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 8, 2026, 12:47 PMhooksfeat(inbox): retire the server-merged feed, and make one email read serve both lists (design: inbox-triage phases 8 + 0) Finishes Phase 8 and lands Phase 0. RETIRED — `inbox.listItems` and `assembleInboxFeed`, with `fetchEmailItems`, `inboxCompiledQuery`, the folder→query fallbacks and `resolveEmailLabelTerms`. feed.ts drops 875→500 lines and no longer reads mail at all. Its 4 test files are RETARGETED, not deleted: scope onto `participatingChannels` and the three sources' SQL, and the real-DB files onto the live `inbox.listChannelItems` route. TWO PAGING BUGS the server-merged feed was hiding, both found by retargeting those tests onto a single source — three others had been covering for each one: * `mergeAndPage` truncated a single-source feed at page two: the sources resume at `<= cursorSortedAt`, so every page re-read its boundary row and spent the `limit + 1` that proves "more remains". It now takes a REQUIRED `sourcesSaturated` — required so a new caller cannot silently reintroduce it. * The sources ordered by `last_message_at` alone while the pager tiebreaks by id, so a LIMIT landing mid-tie-band took an arbitrary subset and the skipped rows sorted ABOVE the minted cursor — gone for good. All six source queries now order `(last_message_at DESC, <itemId> COLLATE "C" DESC)`; the collation is load-bearing, since the default one folds case and ignores punctuation and would disagree with `compareItemsDesc`. Measured: three rows vanished from a paged walk of a real feed. PHASE 0 — one email read, one route resolver: * The `headChanged` latch is per-surface. Phase 8 gave `mail.listThreads` a second caller, so a single per-connection flag meant whichever surface read first consumed the signal. `markPendingHeadChange` fans out to every surface, because the reconcile dedup key carries none and the second reader routinely joins the first's in-flight run. * `use-route-inbox.ts` resolves the active inbox from the URL for BOTH lists; the persisted `settings.activeInboxId` fallback is gone. Closes the important/other and system-folder divergences as well. * `drafts.create` awaits `surfaceDraftInInbox`, and the label join row is born with its ordering key instead of being backfilled by a second statement. compose-feed becomes self-verifying now that its diff target is gone: ordered, unique, stable-across-paging, and watermark-respecting, checked over real paging rounds and each probed against a deliberately broken history. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 9:56 PMstorefeat(chat): a document you can open, and sources you can click Writing a document is the whole gesture now. It attaches to the thread as a chip and opens beside the transcript when nothing else is open there; if the user is already reading something, it stays put and the chip is the way in. `display-document` is gone — asking the model to remember to reshape someone else's screen meant it usually didn't, and when it did it took the panel regardless of what was in it. `attachToContext` goes with it: no caller set it and the `document` family never declared it, so on that surface it could not be reached at all. Guards on what the doc IS (no thread, a buffer path, a fan-out row scope) do the work the flag was pretending to. The card was also dead on arrival on the family surface: those tools nest their payload under the action key and only the SDK harness un-nested it, so `result.id` was undefined and the row rendered an inert "Document written" with no filename. Same shim now runs on the Mastra path. `getDisplayArtifact()` returns null when nothing is open. Its `agenda` default was a lie about the app — the panel resolves its own resting content and never read that branch — and its only real effect was making "nothing is open" un-expressible. Web claims carry a source badge instead of a link in the prose. The research tools number every page they find, stable for the run, and the answer's `[N]` resolves against the sources that existed when it was written — not a thread-wide list, which would relabel every badge on screen the moment a second search ran. Co-Authored-By: Claude Opus 5 <<email>>Sep 3, 2026, 12:08 AM2 defectsthreadItemfix(mail): calendar split catches RSVP mail, and unibox rows are selectable The Calendar split matched only two sender addresses plus `filename:ics`, and both halves leaked. `filename:ics` needs the invite's text/calendar part to be a NAMED attachment, which Google Calendar invites often are not; and the sender terms miss RSVP notices and the human/auto replies on invite threads, which arrive from the attendee's own address. A rep enabled the split and still had `Invitation:` and `Re: Declined:` mail in Important. Match the subject shapes calendar systems emit instead — they work identically on the Gmail `q:` path and the mirror's subject interpreter. Every term keeps its trailing colon so product mail ("Your invitation to the Claude Console") stays in the inbox. Checkbox selection resolved a row's index against `getCurrentThreadList()` — the email-only list — and bailed on -1. In the unibox that miss is the normal case, so clicking an email row's checkbox did nothing, silently. Keyboard nav already walked `getActiveListItems()`; selection now walks the same list. A split whose rule is a Gmail query alone (no CRM rule) could not be evaluated by the chat sources, and each one answered with its whole unfiltered stream: 32 LinkedIn chats and 4 Slack threads on a Calendar tab holding 24 emails. A source that cannot evaluate the rule now contributes nothing, which is what feed.ts already promised for CRM rules. Co-Authored-By: Claude Opus 5 <<email>>Aug 19, 2026, 10:50 AMutilsfix(unibox): a chat row that answers the keyboard, and an R that actually drafts R reported "No emails to select" on a LinkedIn or Slack row, and on an email row it opened the thread and left no composer. Two bugs behind one key. The list handlers resolved their target from a ref that only ever holds an EMAIL id, so a hovered chat row was invisible to every one of them — and anything reached through the bulk selection instead handed `li:<chatId>` to the Gmail driver, which issued modifies for thread ids Gmail has never heard of (x, u, i, h, #). So the handlers now resolve ONE target set and split it: Gmail ids down the optimistic email path, chat rows to the channel path, both halves every time. That deletes actOnSingleInboxId / handleHoveredInboxItem / bulkActOnSelectedInbox along with their dropFromFeed calls for email, which stopped matching anything when the feed moved to the client. r/⇧R open the chat, which is where its composer takes focus. The opener moved to module state because MailListHotkeys is mounted at the root and can never be handed openChannel as a prop; the row click goes through the same function. Spam/trash/archive collapse into Mark done, i stars (a chat row has one flag), h snoozes, and f says it cannot rather than doing something adjacent. The email half: createDraftInThread needs the thread's messages, and a row you press R on has not fetched its body yet — so the first pass fails by design and the whole thing rested on a retry that did not exist. The effect depended on threadId and a callback built from stable store actions, so nothing changed when mail.get landed. It now retries on the messages arriving, attempts once, and clears either way so a stuck action cannot fire in the next thread opened. Also: markChannelRead clears the `u` override as well as marking the source read, or a row marked unread by hand re-bolds on the refetch that call itself schedules. And the seven `as 'linkedin' | 'whatsapp' | 'slack'` casts became a real narrowing. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 7, 2026, 2:12 PMindex.tsfeat: [tasks] remove organised viewDec 28, 2025, 9:45 PM