commonfix: review pass — keep the ping out of the timeline, stop a rename escaping to root
Three behavioural fixes found reviewing this branch, plus the deduplication the
review turned up.
The ambiguity ping's audit row is a dedupe key and a measurement surface, but both
conversation reads returned it unfiltered and the timeline renders any action_type
it does not recognise by de-underscoring it — so every pinged conversation would
have shown the user a pill reading "Agent aop ambiguity ping" the moment the flag
was turned on. Excluded from both reads; the ping's own dedupe query hits the table
directly and is untouched.
moveNode's new pure-rename path fell through to `null` when the parent row could not
be loaded, and `null` there means "move to the scope root" — the same teleport the
commit above it fixes, reached by a different door. It refuses now.
The select-aop tool call is deliberately deferred so `conversationId` is the
post-merge surviving id, which meant a throw out of setupConversationForAop lost the
record of what the classifier decided — exactly the run you would open the log to
diagnose. It flushes on the way out.
Deduplication, all of it drift that had already caused a bug once:
- resolveOrgId/getSessionOrgId now live in trpc/session-org.ts. user-tasks.ts had
copied one and not the other, which is why it scoped conversation lookups to the
owner while crm.ts scoped the same rows to the org — the "Untitled" task header.
- The backfill script had its own copy of the LinkedIn cold-reach classifier. A
backfill that classifies differently from ingest is how the folder=INMAIL check
survived unnoticed; it imports the real one now.
- One MAX_AOP_AMBIGUITY_ALTERNATIVES, owned by the Slack module where the
four-buttons-per-row constraint actually comes from.
Also: dropped the ping button's `aopId` arg, which agent_prompt's zod schema strips
before the agent ever sees it (the id travels in the prompt, which is what is read);
lazy-loaded TranscriptViewerModal off the root render — 10 fewer preloaded chunks
and 85 KB off the critical path, though react-markdown stays on it via a second,
pre-existing path through messagesSlice's eager renderer registry; and cleaned up
two comments orphaned by the extractions, a sideEffects note the same PR falsified,
a runbook query naming a table that does not exist, and three no-op eslint-disables.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 17, 2026, 11:58 PMcomponentsfeat(calendar,tasks): mint real Zoom meetings, and two fixes for surfaces that lied
Zoom reaches a Google Calendar event through the vendor's Workspace Add-on running
INSIDE Google, never through an external API client. Probed live against two accounts:
all 17 calendars report `allowedConferenceSolutionTypes: ["hangoutsMeet"]`, so the
`addOn` option added in ead6b26a4 was unreachable code — and had it rendered, Google
would have answered "Invalid conference type value". Cedar now holds a Zoom OAuth token
per user, mints the meeting through Zoom's own API, and writes the join details onto the
event. That is what Superhuman does, and there is no Calendar-API shortcut they know
that we do not.
The driver implements the existing OAuth interface, which buys the sealed AES-GCM state,
`initiateOAuth`'s URL builder, and `refreshMcpConnection`. The last is load-bearing
rather than convenient: Zoom rotates the refresh token on every refresh and kills the old
one, so two concurrent refreshes presenting the same stored token leave it dead — exactly
the failure the single-flight coalescing there already exists for. The row is written
`direct_oauth`, never `external_mcp`, because every MCP surface selects on that column and
an agent enumerating tools from api.zoom.us should not be possible.
Attachment goes to `location` (the only field that renders on every Google client),
`description` (a fenced, idempotent dial-in block — recreating the meeting REPLACES it
rather than appending beside it, which is what a "does it already contain this URL?"
check gets wrong), and `conferenceData` best-effort. `addOn` is gone from both
`createRequest` enums, the agent tool, the chat route, and `CalendarEvent.conferenceType`;
describing an existing conference is still allowed, because that is what a Zoom meeting is
by the time it reaches the event body. Inert until ZOOM_CLIENT_ID is set.
Two unrelated fixes, both reported by the same customer:
- "Search a deal…" searched every conversation the user owned. Cedar mints one per
unrecognised correspondent, so on the reporting account 117 conversations held 6 deals
and typing "gmail" returned a column of recruiters' addresses. `dealsOnly` narrows to
the AOPs the user actually works — `isNoOp = false` being their own declaration that
Cedar does work there — and falls back to today's behaviour for the 18 of 109 active
accounts that have no workable AOP.
- Every task surface splits due-now from upcoming at `endOfToday()`, read inside memos
whose dependencies are all data. Nothing in them moves when the day does, so a tab left
open past midnight files everything due TODAY under Upcoming, out of its group column,
which then collapses into the hidden-columns rail for being empty. `useDayKey` is the
missing dependency.
Staged whole-tree, not session-scoped: this branch is shared with a concurrent session
whose in-flight work (the board's Done lane, the calendar drag-settle animation, the
onboarding setup flow, the task-groups CLI) is entangled with these changes both within
files and across them — CalendarView passes props DayColumn only accepts in its
uncommitted form — so a scoped commit would not have compiled. That session likewise
swept this change's crm.ts and NewTaskDialog edits into a5fc7b295.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 10:35 PMconversation-inboxfix(tasks): a tick on an already-resolved task stops looping (design: zach-sept10-bugs phase 1)
Zach, 2026-09-10: "i keep checking this off and it comes back."
It was a closed loop. The Overview renders Due Tasks off the crm.getConversation
cache and filters status === 'todo', so a row the server has already resolved can
still be on screen. Ticking it reached completeTask, which refused anything not
`todo`; the tRPC route THREW that refusal; the client's onError rolled the row
back — restoring the very row the user was clearing. Every attempt re-rendered
it, so the tick could never converge.
Measured: tasks ad0fd8a4 and 5fbee194 went status='deleted' at 17:07:32-34 UTC,
and the screenshot 8 seconds later still lists both as open.
Two halves, because they fail independently:
- completeTask treats `deleted`/`agent_deleted` as a terminal SUCCESS, the way
`done` always was, and reports which state via `alreadyResolved`. The status is
never rewritten — a deleted task must not come back as done — and the draft
cleanup does not re-run, since deleteTask already tidied it.
- The client refetches before it restores, so a row that is genuinely gone stays
gone. `staleTime: 0` on that refetch is load-bearing: this same function has
already optimistically written `done` into that cache, so a fetch allowed to
serve cached data would read back its own write and drop tasks that are still
open. A test caught exactly that.
ConversationTaskRow refreshed only conversationInbox.list, leaving the other
three userTasks-carrying reads stale; it now invalidates all four like the main
path, and refetches on error.
Headless proof, all five statuses against the real service:
pnpm --filter @zero/server task-complete-probe
PASS todo → completed | done → done | deleted → deleted |
agent_deleted → agent_deleted | recommended → refused
Two pre-existing tests asserted the old contract; the deleted one was pinning the
bug itself, and is rewritten to keep the half that still holds (no draft cleanup).
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 10, 2026, 8:18 PMhooksfix(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 PMlibfeat(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 PMrenderingMerge origin/staging into fix/dm-container-kind
Both branches built org-shared MCP connections independently, so the merge
picks ONE mechanism and layers the other work on top of it.
KEPT (staging) `metadata.scope` / `organizationId` + callability.ts as the
single visibility rule for every MCP surface. It resolves
name collisions deterministically, narrows in SQL and
enforces in JS, and it is what the discovery-based OAuth
callback and the credential vault already write.
DROPPED (ours) `metadata.orgShared` and the peer-scan in
visible-connections.ts. That module survives as the
agent-facing ADAPTER over findMcpConnections — `ownedByMe`
and the explicit-grant name set — so nothing re-implements
a cross-tenant WHERE.
KEPT (ours) `toolPolicy` (the per-tool ceiling) and `requiresAgentGrant`
(which of a person's agents reach a connection). Staging has
no equivalent; they are a different axis from scope, and the
types now say so.
Settings loses mcp-integration-card.tsx to staging's Systems and credentials
section, and the tool-permissions checklist is ported onto system-row.tsx so
the ceiling is still editable. The agent's own picker keeps its shape and now
reads staging's KNOWN_MCP_PROVIDERS, resolving a provider id through discovery
at click time instead of from a second hard-coded catalogue. `probeMcpServer`
became discovery, so the pre-save tools/list probe is `probeMcpServerTools`.
Also reconciled outside MCP: the Slack Connect webhook scoping (staging's, which
handles externally shared channels) keeping this branch's stronger workspace
resolver; the agent-archive and subagent-write metadata stamps, chained rather
than chosen between; buildAgentDocs' generic folder model with this branch's
`listed` on top; and the conferencing-aware event popover with the extracted
RSVP hook.
Fixes carried by the merge: coach-meeting and coach-weekly had no folder and
would have landed in Active; a duplicate source_workspace_id column; a duplicate
import; the dead org-admin catalogue bypass.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 30, 2026, 4:15 PM1 defectsmart-inboxrefactor(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 PMthreadfix(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 PMthreadListfix(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 PM2 defectsdata.tsxrefactor: mail & drafting final movesOct 12, 2025, 6:03 PMmail.tsxfeat(tables): sort by a column, and LinkedIn/WhatsApp output cells
Two additions to table documents.
SORTING. `schema.sort` orders the rows the way the column's TYPE orders — currency
numerically, a date by the instant (in the column's own `format`), a select by its
DECLARED option order, `_status` by the fan-out lifecycle. One comparator in
`table-sort.ts`, shared by the grid and by `read`, so a sorted read and the sorted
table cannot disagree.
The sort is a VIEW: `tableRows` keeps document order, so `_id` addressing, the ordinal
forms and every row's CRDT identity are untouched. Two consequences fall out of that —
the row drag handle is withdrawn while sorted, and `read` reports rather than silently
adopts a declared sort, because an ordinal resolves against document order on every
backend.
`table-sort.ts` also absorbs the tolerant cell readers, which existed twice (the Excel
writer's and the grid's) and had already drifted on the checkbox vocabulary.
LINKEDIN / WHATSAPP OUTPUT CELLS. Both kinds were declared but their payload fell into
`GenericCellOutput`, which holds the words and nothing that says who they are for. Real
payloads now, addressable by an existing chat OR by a person with no chat yet — which is
the lead-list case the column is for. `canSendCellOutput` becomes `sendableCellOutput`,
which RESOLVES a cell into a payload whose fields are required, removing three non-null
assertions from the send site.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 10, 2026, 8:17 PM1 defectnavbar.tsxrefactor: mail & drafting final movesOct 12, 2025, 6:03 PM