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