AgentPickerDialog.tsxfix(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 AMformat.tsfeat(home): widget rail replaces the agents list and collapsible sections
Pre-existing work committed under its own message before starting the
meetings-tab design run, so each design phase lands as one commit.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 27, 2026, 12:08 AMHomeAgentsWidget.tsxfeat(agents): a face that blinks, glances, and gets to work
`AgentAvatar` gains two moods. `idle` blinks and glances; `thinking` bobs, and blinks on
a quicker cadence — a busy face is a more awake one. The timings are measured frame by
frame off a reference clip rather than eyeballed, because what makes that motion read as
alive is the intervals, not the poses: the body holds perfectly still, the eye pair is
rigid and orbits about the point between the eyes, a blink is a ~70ms vertical collapse
that reopens ~18% too tall before settling, and every move is followed by real stillness.
Amplitudes are sized for the smallest place they run — the 16px rail icon — where a
tasteful 2° sway is a third of a pixel and therefore not an animation at all.
The sidebar's chat rows drop `TabBotIcon` for the agent's own face, so a row wears the
thing it is about; the finished-dot overlays whichever icon the row is wearing.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 7, 2026, 3:28 PMHomeMeetingsWidget.tsxfeat(board): the board agent tool, the kanban, and the typed ticket (phases 3-4)
Answers "can a board be used exactly like a table" with yes on all three
surfaces: headless, agent, and UI.
**The extraction that makes the rest honest.** Every board operation moved to
`services/documents/board/board-ops.ts`. Three callers now wrap it —
`admin.boards.*` (the CLI), a new session-scoped `boards.*` (the browser), and
`boardTool` (the agent) — differing in exactly one thing: how they answer "who
is asking". A kanban drag and `cedar-cli board card move --before` are the same
function call, so the headless proof is evidence about what the UI does.
**Phase 3 — the agent tool.** `boardTool` with all fourteen actions, a flat
object with an `action` enum (a top-level union advertises `{properties: {}}`
to the MCP SDK). `boards:` grant enforcement at the CALL SITE: a read-only
grant refuses set_schema, an ungranted board is refused by name, and
`list_boards`/cross-board `cards` are CLIPPED rather than errored. `grantsBoard`
is deliberately not `grantsFile` — a prefix rule would make a grant on
`organisation/boards` silently grant every board in the org.
**Phase 4 — the UI.** A kanban (`BoardDocumentView`) drawn from one SQL query
with dnd-kit drag ordering, and a ticket (`CardDocumentView`) in the same
body+rail geometry `TaskTicketView` already uses. The rail renders every field
the BOARD declares, typed — select pills, checkboxes, dates, URLs, `[[type: id]]`
chips — by importing the table's chip renderer, option colours and type
vocabulary rather than forking them. A field whose type Cedar has never heard of
renders as text and says so.
Two things the UI does that keep the schema open: it renders values whose keys
the board does not declare under their own heading, and `BoardSchemaMenu` never
serialises a fresh object from form state — every action reads the current
schema out of the Y.Doc and merges. Those were the two remaining ways to
silently close an open schema; the other two are on the server and were already
closed.
The tool description had to be cut from 2467 to 2044 chars — the
tool-description-length guard catches exactly this, and text past 2048 is
silently dropped for every caller.
1551 server tests and 217 frontend tests pass; both packages typecheck; oxlint
clean.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 30, 2026, 9:33 PMHomeWidgetRail.tsxfix(review): the gates staging added, and the trap under the cron editor
Staging's new CI typechecks and lints every PR, and this branch had never been
held to either. Three oxlint errors (a `filter(Boolean).pop()` that is
`findLast`, two dead imports) and one failing suite: FileArtifactPanel now
reaches `useOpenCard` on every render, so its test needs the NuqsAdapter the
shell has always wrapped it in.
`buildCron` still wrote `*` — every day — for a weekly rule with no days. The
last commit fixed both call sites; this fixes the primitive, so the recurrence
asked for and the cron written can no longer disagree, and build → parse round
trips for every recurrence. A test file now holds that invariant.
StatTile has been exported and imported nowhere since the commit that added it,
and the rail still claimed five metric widgets — both leftovers of a design that
was built as Pipeline + Statistics instead.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 1:27 AMmetric-widgets.tsxfix(statistics): follow-up time is silence, and a meeting row admits it is hoverable
Follow-up time counted a REPLY as a follow-up. It walked the outbound emails
alone and measured every gap between them, so "you write, they answer next
morning, you answer back within the minute" scored as a ~20h follow-up made
entirely of THEIR reply latency. On a real 30-day window 25 of 29 gaps were
that shape, holding the median at 21h52m while the true chase-into-silence
median was 41 minutes — the metric moved with how fast prospects wrote back,
not with anything the rep did.
Now a gap counts only between TWO OUTBOUND EMAILS IN A ROW: the window carries
both directions and an inbound reply between two sends breaks the pair. Same
rule in all four places that computed it — the shared helper, the cadence
samples the reasoning layer reads, the per-conversation column and the per-rep
team view — so no surface disagrees with another. A meeting between two emails
leaves the pair intact; meeting→email is its own metric. The number gets
honest and much smaller (13m median on the same window), and its sample gets
thin, which is the metric telling the truth rather than padding itself.
The rail's Statistics tile: "21h 52m" wrapped inside the shared fixed-width
number column and read as two figures, so the duration column now sizes to its
content, and demo mode swaps in fixed values instead of showing real figures
on a shared screen.
The meetings widget lost its hover fill because `hover:bg-accent/50` IS
`--surface-raised` in dark mode — the exact colour of the card behind it. It
is a sunken well now. The row also had `w-full` fighting its own `-mx-2`: a
negative margin cannot widen a box pinned at 100%, so the row shifted 8px left
instead of bleeding both ways, leaving 4px of fill on one side and 20px on the
other.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 12:25 AMPipelineFiltersDialog.tsxfeat(home): the meetings widget owns one day, and the pipeline widget knows which one it counts
Two widgets in the home rail were quietly lying, in different ways.
The pipeline tile counted every crm_conversation the user owns. computePipelineStats
only filters by AOP when an aopId is passed, and nothing passed one — so on a real
account it read 788 "deals" against an actual pipeline of 124, with cold inbound,
vendors, recruiting and spam as stages. Worse, those carry no deal_value, so each
added 1 to the count and 0 to the ACV. It now defaults to the fuzzy-matched deals AOP
— the same match /statistics already used, extracted to modules/aop/utils/deals-aop
so the two surfaces cannot drift onto different pipelines — and a modal behind the
header's pencil carries the rest of the filters (which pipeline, whose, which stages).
The scope is resolved at the rail so it still feeds ONE getOverview call. An unscoped
count stays available, but only under a label that says what it is.
The meetings tile was a maze of its own making: when today ran out it drew TOMORROW
while the arrows still pointed at today, so Next went to tomorrow again (identical,
minus the now-line) and Back went to yesterday. The list now only ever renders the day
it is handed; it reports that today is spent and the widget moves the day, once,
ref-guarded so Back onto a spent today does not bounce straight forward again. It also
waits for the last meeting to END rather than to start, so the day cannot move out
from under the meeting you are in.
The rest is the shape those two changes exposed: the four stat tiles collapse into one
Statistics table (four bordered cards to say four numbers), meeting rows put the time
in a left-aligned gutter outside the colour rule so the times, the header icon and the
now-line dot all land on one edge, and per-tile remove gives way to one Edit widgets
button — adding and removing are the same act.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 29, 2026, 3:45 PMregistry.tsxfeat(home): the rail everyone starts with, and a tile that stays where it is
A new user's rail was meetings + agents, and the agents surface is unfinished —
that tile is its only entry point. Defaults become meetings + pipeline +
statistics, the three that are actually ready to be looked at.
Removing the tile outright would take it off the rails already holding it, so
`unlisted` withholds a widget from the picker only: it still resolves, and the
rails that name it go on rendering it. TEMPORARY — shipping the agents surface
is this flag going away.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 1:14 AMWidgetFrame.tsxfix(home): the rail's hover fills were the same colour as the card behind them
`--accent` and `--muted` both resolve to `--surface-raised` in dark mode, so an
agent row hovering to `bg-accent` painted itself the exact colour of the widget
card it sits on — the fill was there, it just could not be seen. The event rows
already used `bg-sunken` for this reason; this brings the agent rows, the widget
frame's edit button and the agenda's day arrows onto the same well.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 10:09 PM