archive-from-any-folder.test.tsfix(review): archive from any tab, rename an agent in place, and eight bugs a review found
Three features that were sitting uncommitted, and the findings from reviewing the whole
branch against them.
The features. Archive from an open thread was a no-op on every tab whose slug is not
literally `inbox` — the Important and Other tabs, a custom inbox, All Mail: `moveThreadsTo`
resolved to no labels, logged "No labels to modify" and never made the request. Both the
toolbar button and `e` now go through one `useArchiveThread`, which closes the thread on the
same frame, puts up an Undo toast, and commits. An agent's title is editable where you are
looking at it, through the same `EditableText` a conversation's name uses — displayed as the
pretty form, saved as the slug the harness registers it under, and refused when the slug is
already taken, because the loser of a duplicate simply stops being reachable from chat.
The review's findings, in rough order of what they cost:
`chat_messages.citations` was still typed event-only while the frontend had already widened
to an event|web union, and four `as Citation[]` casts were the only thing hiding it — a
server reader of a persisted web citation got `undefined` for `quote` with no type error.
The column now carries the real union and the casts are a zod parse at the boundary.
Web citations never rendered while the answer streamed: `toolResultResponseProcessor` read
`citations` off the SSE frame and dropped them on the way into the message, so every `[N]`
fell through as grey text until the canonical reload swapped it — and never at all if the
user cancelled. And the per-turn carry never reset at a turn boundary, so turn five inherited
turn one's sources and any later "option [2]" became a live pill pointing at an unrelated
page.
`openWrittenDocumentIfPanelIdle` promised thread isolation and only checked whether the
ACTIVE thread's panel was empty. A background run's document landed in front of whoever was
reading a different chat, because `setSelectedArtifact` writes the active thread's slot. It
takes the writing thread now, and the test that was named for this case actually exercises it.
`mutateThreadContext` is a non-atomic read-modify-write of one JSON column, which was
harmless while attach-on-write was gated behind a flag no call site set. It is now
unconditional for every chat-turn write, and agents emit parallel tool_use blocks — so two
reads saw the same context and the second UPDATE dropped the first one's chip. Now one
transaction with the row locked.
Dropping the `is_list_field = false` filter from the CRM field read turned it into "every
value ever extracted for every deal on the page", on every table read, with eight of them
able to reach a cell. Bounded in SQL with `row_number()` per (conversation, field) rather
than a global LIMIT, which one talkative deal would have eaten.
`readCustomFrontmatterEntries` skipped block-list continuations by their missing colon, but a
grant item has one — `- roadmap: rw`, `- Bash(git status:*)` — so a hand-authored agent showed
its grants twice, once correctly and once as junk labelled "not acted on", which they are.
`getAgentOutputs` built its LIKE prefix from an unescaped `agentId`, and the new
`classifyAgentPath` THROWS on an over-matched row rather than misfiling it, so one `_` in an
id would 500 the whole Output tab. Escaped, and mapped with a guard.
`createBoard` scanned org-wide for a name collision, but board uniqueness is
(orgId, userId, path) — so one teammate's "New board" numbered itself off a set of boards
they cannot see. `agent.create` wrote `defaultFile` unvalidated while `setDefaultFile`
validated it, so a create-time `../other` was silently discarded on read. A run's notification
reported the Slack channel it ASKED for rather than the one it reached, which is exactly the
customer-visible-vs-private distinction the field exists to draw.
Plus: `AgentDocumentView` was imported eagerly by the panel that lazy-loads TipTap to keep it
out of the home bundle, and that same edge closed a real import cycle back through
`AgentOutputTab → FileBrowser`; the "Default file" radio could never match its own value, so
it rendered with nothing selected; two `as` casts where a `find` narrows; a settled mutation
that wiped the other textarea's in-progress draft; and `mergeCanonicalPage` threw away the
whole scrollback when one boundary row had no timestamp.
pnpm --filter @zero/mail: 2949 jest tests, tsc -b --force clean.
pnpm --filter @zero/server: 8999 vitest tests, tsc -b --force clean.
pnpm deps:check: no violations.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 3, 2026, 10:49 AMdelete-draft-flow.helpers.tsfeat: draft & slack v0May 22, 2026, 1:09 AMdelete-draft-flow.test.tsxfix(inbox): make Important mean Important on every channel
The channel badge and the inbox tab are orthogonal filters over one feed, and on
the unified feed each half was dropping the other. Reported by <email>
on 2026-08-26: 32 ARCHIVED Rippling receipts under Important, while the email-only
list — which always sends the tab's real query — showed none of them.
Email half: until settings/inboxes resolve, `inboxLayout` reads as the default
`inbox`, no stub owns the `important` slug, `compiledQuery` goes out undefined, and
the server fell back to `in:important` — a bare `label:IMPORTANT`, which is not "the
Important tab" but every thread Gmail ever flagged, archive included. The feed now
waits for the tab instead of asking the wrong question, and the server fallbacks may
only ever narrow the inbox.
Chat half: Important/Other are client-side stubs, so the record lookup always missed
them and answered "this tab has no rule", letting every LinkedIn/Slack conversation
contribute its whole unfiltered stream. They are mail-only by construction.
Also, from the same report:
- A draft row backed only by list-preview placeholders resolved no draft, so the
server guard was all-falsy and `drafts.delete` never fired at all — the row
vanished locally and returned on the next refetch, opening to a blank page.
- Opening an email from a chat forked a fresh thread every time: `openThread`
deliberately does not commit its artifact to `chatContext.items`, so the
"is this the chat's own context" test was false 100% of the time.
- A failed or skipped `mail.get` left the reading pane skeletoned forever with no
error and no way back.
And the reason none of this was visible: `cedar-prod` is at Axiom's 1025-column
ceiling, so every API log line carrying an unregistered field was rejected whole
while the worker's older field names kept landing. Logs get their own dataset via
AXIOM_LOGS_DATASET, and ingest failures — which reject asynchronously, past the
try/catch — are now reported instead of swallowed.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 26, 2026, 5:26 PMemail-tracking-indicator.test.tsxfix(mail): stop a tracked-email open from white-screening the whole app
EmailTrackingIndicator called useState + useTRPC, returned null early when
openCount/clickCount were 0, and only then called useQuery and three useMemos.
Those counts come off the thread query, so the moment a recipient opened a
tracked email the same mounted instance re-rendered past the guards and ran six
hooks where React had recorded two — React #310, caught by the route error
boundary, which replaced the app with the error screen.
Hit <email> on staging (2026-08-17/18) and, with the same
fingerprint, <email> and isabelle on production (2026-08-11); one
2026-08-05 session looped the crash 59 times.
All six hooks move above the guards; the two early returns collapse into one
`if (!isVisible) return null` after the last hook. Behaviour is unchanged — the
query was already gated by `enabled: isOpen`, and a disabled TanStack v5 query
still reports isLoading === false.
ESLint had flagged all four lines the whole time; CI runs oxlint, where
rules-of-hooks was off. Turned it on, scoped off for apps/server and packages
(server helpers named use* are not hooks), and renamed the CompanyGTMPanel click
handler `useTemplate` to `applyTemplate` so the repo lints clean. Verified
against a copy of the pre-fix file: the gate reproduces the four errors and
would have blocked this.
Report: apps/mail/docs/bug-email-tracking-indicator-conditional-hooks.md
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 17, 2026, 11:46 PMemptyPageKeepsPaging.test.tsxfix(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 PMinboxCreateRace.test.tsxfeat(onboarding): a setup flow that shows you the product while you configure it
Adds `/onboarding-setup` — a sidebar-driven flow over the four surfaces a new
user actually has to configure (Inbox, sub-inboxes, Tasks, Pipeline), each step
paired with a live preview of the real product rather than a screenshot of it.
Reachable from the profile menu as "Set up Cedar".
Two inbox-creation races surfaced by clicking through the template gallery are
fixed on the way, since the flow leans on that path hard:
- Per-mutation `invalidateQueries` lost rows. Create #1's refetch was issued
after #2 had written its optimistic row, so a server list predating #2 landed
last and overwrote it — the row vanished and the template card flipped back to
unselected. Now the last create to settle does the single refetch.
- Two adds fired back to back both read the cache before either had written to
it and claimed the same position, so a run of template clicks piled up at one
index and the tab strip reordered itself on refetch. Positions are now issued
from a carried-forward high-water mark.
A failed create drops only its own row instead of restoring a whole snapshot,
and `removeInbox` no longer swallows the rejection — callers holding their own
selected-state need to hear about it, or the row returns while the card stays
deselected.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 10:48 PMinboxScopeReady.test.tsxfix(drafts): stop the second normalize pass from eating the signature's blank line
prepareDraftBody runs twice over the same body — once at the tool boundary, then
again inside createDraftInProvider as a backstop — and the trailing-spacer strip
only ever saw the region BEFORE the gmail_signature marker. So the second pass
deleted the <div><br></div> applySignature had just inserted, and every
agent-created provider draft shipped with its signature butted against the last
line of the message. The strip now collapses that run to exactly one when a
signature or quote follows, and clears it only when nothing does — which is what
the doc comment already claimed the function did. Head elements go with their
CONTENTS too: stripping the <style>/<title> tags alone left the stylesheet text
and the document title opening the message as ordinary prose.
From the same review pass over the Important-tab work:
- useInboxes gains `scopeReady`. The feed gate read `isLoading`, but both of its
queries are gated on the session and a DISABLED react-query reports
`isLoading: false` — pending, but not fetching — so the gate stood open for
precisely the window it existed to close. `isPending` is the honest question,
and an error still settles it rather than spinning the feed forever.
- folderToCompiledQuery reads SYSTEM_SEGMENT_BASE_LABEL instead of a second copy
of the same rule. The bug being fixed WAS those two paths disagreeing, so there
is now only one place to change.
- getInboxFeedScope's doc block goes back onto getInboxFeedScope.
- "Try again" is hidden for a client-only compose key, where mail.get 400s by
construction and there is nothing to retry.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 26, 2026, 8:13 PMlabel-change-flow.test.tsxfix(types): close 205 typecheck errors by repairing real client/server drift
apps/mail typechecked against a stale apps/server/dist d.ts (the server's
package.json points "types" at dist), so a chunk of the reported drift was
phantom. Rebuilt it and fixed the four script errors that blocked `tsc -b`
in server, which is what regenerates it.
The rest was genuine contract drift, fixed at the source rather than cast
away — routes that dropped fields their callers still read (agentExecutions,
getUpcomingCalendarEvents' conversationId, createCanvas' description),
schema splits the UI never followed (conversationUsers vs
conversationContacts), and enums that grew server-side but not client-side
(TASK_TYPES, ActionStatus, DateFilterOperator).
Also deletes code that was already dead: an unreferenced sort popover whose
store API is gone, a panel importing a deleted module, the system-skill
metadata UI whose mutation was removed deliberately, and a test asserting a
store method that no longer exists.
apps/server: 4 -> 0. apps/mail: 228 -> 23, all remaining errors being
dependency resolution (zod v3/v4, react-router dev/runtime skew, tiptap
v2 via novel) rather than code.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 2, 2026, 7:45 PMlist-tracking-metadata.test.tsfix(mail): unstick orphaned task labels on send, and load open tracking with the list
Two defects found in the same send flow.
The Cedar/Task/* chip survived a send. applyCedarMailDraftLabel puts the
agent-draft marker and the task-type label on a thread together, but they do
not come off together: cleanupDraftForTask strips only the marker when a
task's draft is discarded or regenerated. removeCedarDraftLabels gated its
whole sweep behind hasAgentDraft, so a thread that reached send in that state
kept its task label forever. Prod telemetry confirms it — the sweep did its
threads.get and labels.list, then issued no threads.modify at all. The two
label families are now stripped independently; hasAgentDraft only gates the
INBOX removal, which is the part that actually needs the proof. Task cleanup
now removes the task-type label alongside the marker so the orphan state
stops being created in the first place.
The "opened" badge only rendered after a thread was selected, because
trackingData rode on mail.get alone. listThreads now carries the same records
in its preview payload (batched per page, best-effort so a lookup failure
degrades to the old behaviour), and the row resolves them against a preview
`latest`, whose id is the thread id rather than a message id.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 4, 2026, 5:29 PMmark-done-flow.test.tsxfix(threads): a thread marked done stays gone, whatever writes the list
Round 1 treated the optimistic removal as an edit and repaired the one
window it could name -- a listThreads response issued inside the archive
round-trip -- by re-applying the removal once, the instant the mutation
settled. That assumed a 227-380ms round-trip. Staging measures
mail.markDone at up to 7381ms, and all three resurrections prod ever
recorded landed 15.7-18.2s after the click, every one a fetch-response:
seconds after the one-shot repair had already run, with nothing left to
oppose them.
So the removal becomes an invariant instead of an edit. While a thread is
guarded, any write to a mail.listThreads query that reintroduces it is
repaired immediately -- late fetch response, IndexedDB restore, side-inbox
prefetch and fetchNextPage alike -- so we no longer have to name the
guilty write. The guard is time-boxed at 60s; markActive, undo and
mutation rollback all release first, so a legitimate return still lands.
Suppressions now report to the server, not PostHog. The previous round's
diagnostic was PostHog-only and the browser reporting the bug sends
PostHog nothing -- 66 archives on staging produced zero client events --
so the fix could never be verified. mail.reportListSuppression writes a
structured log readable in CloudWatch.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 24, 2026, 3:51 PMparseRecipientString.test.tsfix: single recipient boundary — validate + block with clear error, drop heuristic repair
Tests-first (per /fix-bug) for every recipient edge case from this investigation,
then the implementation that makes them green.
Design decision (supersedes the earlier auto-repair net): parse legitimate
mailbox forms to a bare addr-spec, and BLOCK genuinely-invalid recipients with a
clear, specific error naming the offending value — never silently ship an
undeliverable header, never silently guess which token is the address.
- extractAddrSpec: drop the whitespace "@-token" repair heuristic (guessing
risks misdelivery). It now only unwraps `<addr>` and trims.
- InvalidRecipientError (carries the offending value) + toMailbox(sender): the
single recipient boundary. Bare addr in, validated via isValidAddrSpec,
{name, addr} out. MimeText alone re-adds the angle brackets / quoting /
encoding, so the address reaching Gmail's header (and the SMTP envelope) is
always the bare addr-spec.
- google.ts normalizeRecipients (send) and parseRecipientField (createDraft)
both route through this boundary and throw the clear error.
- tRPC send blocks up front with TRPCError(BAD_REQUEST) naming the invalid
recipient(s), surfaced to the frontend.
Tests:
- apps/server/tests/mail/recipient-format.test.ts — isValidAddrSpec,
extractAddrSpec (incl. the jammed shape is NOT repaired), toMailbox throws on
invalid, and the serialized-header bare-addr delivery guarantee.
- apps/mail/.../parseRecipientString.test.ts — regression: the display name is
never jammed onto the address; recipientEmails returns bare addrs only.
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jun 7, 2026, 10:33 PMrouteInboxParity.test.tsxfeat(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 PMscheduled-send-layout.test.tsrefactor(mail): stop announcing success, and fix the qualified-date parser
Toasts now only fire when something went wrong. 416 success confirmations
across 151 files told the user that the thing they had just watched happen had
happened — the row was already gone, the draft already sent, the text already in
the clipboard. Removing them took the variables and callbacks that existed only
to build those messages with them.
Thirteen survive, minus the success styling: the ones carrying an Undo button,
where the toast is the affordance rather than a notification. Those now use the
neutral toast() so they read as "here is your undo", not congratulation.
toast.info / toast.warning are untouched — those report neutral or unwanted
outcomes, which is not the same thing as celebrating one.
Also fixes "mid august" resolving to August 1st in every date picker. chrono has
no notion of early/mid/late, so it finds the month, discards the qualifier and
answers the 1st — and near a month boundary forwardDate then pushed that past
date into NEXT YEAR, so scheduling a send three days out landed eleven months
away. resolveQualifiedPeriod runs ahead of chrono and takes the words it cannot
express: named months, relative months, weeks and years, each with early / mid /
late, rolling forward only when the resolved date has actually passed.
And the scheduled email in a thread now renders as an ordinary message rather
than a bespoke card: same MailDisplay, a Scheduled badge in the tracking-badge
slot, and Edit / Delete replacing reply / reply-all / forward — there is nothing
to reply to on mail that has not gone out yet. Edit unschedules before reopening,
because the send payload is a frozen snapshot and would otherwise fire the old
text alongside the edited one.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 12, 2026, 10:13 PMsearchFilterLifetime.test.tsxfix(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 PMSplitSettingsCreator.test.tsxfix(outbound/mail-ui): paint per-node validation ring + drop unsafe cast + dead code + tests
- setOutboundValidation folds errors onto node.data.validation by stage id, so offending
nodes light up on the canvas (the read in StageNodeShell was previously always undefined).
- AddToConversationPopover drops the 'as unknown as SearchResult' double-cast (tRPC infers
the type).
- delete dead handleForRelation; wire clearRunOverlay into run/upload so stale per-node
counts clear.
- add tests for the validation-ring fold and the SplitSettingsCreator query helpers.
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jun 25, 2026, 3:43 PMthread-telemetry.test.tsfeat(observability): join a browser action to its server trace
Diagnosing a lost send took a full day because the frontend emitted nothing the
server logs could be joined to. The client already minted an X-Client-Request-Id
per tRPC batch; the server read it only inside the malformed-body warning branch
and dropped it on every normal request.
Server: clientCorrelation() middleware puts the id on a request-scoped
AsyncLocalStorage context, so every createStructuredLog during that request
carries client_request_id, and both the request span and the auto-instrumented
HTTP span carry cedar.client_request_id. Ids are validated rather than mangled,
so nothing can inject a newline into a header or a log line.
Adds POST /api/client-telemetry, authenticated with the same session as tRPC, so
the browser can ship events without an Axiom token. Identity is stamped from the
resolved session — a client-supplied userId survives only as inert metadata.
Relays to its own dataset via AXIOM_CLIENT_DATASET, falling back to
AXIOM_LOGS_DATASET then AXIOM_DATASET, following the split axiom-logger.ts
already documents: a dataset holds 1025 columns and a rejected batch is rejected
whole, and client telemetry adds columns fast.
Client: every tRPC call is recorded against the same batch id. Aborted and
network-failed requests are the point — they leave no server row at all, which is
how a send that never left the browser stayed invisible. Those flush immediately
with keepalive, since an abort usually means teardown. Store capture is scoped to
threads.list / thread.get / thread.send only; anything else returns on one lookup.
Two redaction layers: meta is derived from counts and ids only, then sanitized to
scalars with a content-key denylist, so a draft body or recipient list cannot be
serialized even by a careless caller. Routine 2xx batches sample at 25%; failures,
non-2xx and any send are never sampled away.
AXIOM_CLIENT_DATASET is deliberately NOT added to the aws runtime contract —
that would make it a required Secrets Manager key and block ECS startup. Prod
falls back to AXIOM_LOGS_DATASET until the key is seeded.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 12, 2026, 12:05 PMunread-filter.test.tsfeat(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 PMuse-inboxes.test.tsfeat: migrate mail to query-first inboxes and drop categoriesMay 19, 2026, 9:37 AM