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