calendarFollowUpUtils.tsfeat: v0 commandBarApr 9, 2026, 3:22 PMconferencing.tsfix(calendar,ui): a Zoom connect dialog, and four things the review found
The Zoom work lands its last piece: picking Zoom on an unconnected account used
to open a bare browser window with no explanation and no way back. ZoomConnectDialog
owns everything around the consent screen — why Cedar is asking, the wait, and the
four different reasons a popup ends without a connection — and resolves back to the
composer so one click on "Change to Zoom" still ends with Zoom on the event. The
OAuth callback URL now falls back to the server's own origin, so an unset env var
is no longer the difference between a working integration and a message telling a
user to edit a file they have never heard of.
Agent avatars move to components/icons/agent-avatar.tsx as one component, and the
conversation/agent reading column narrows from 100ch to 80ch across all four places
that hold the measure.
Review fixes:
- Breadcrumb decided "you are here" positionally, so it rendered the last crumb as
an inert span and silently DISCARDED any href it was handed. A task's bar is
`Tasks / <lane>` above the task's own title — the h1 below is the page, and the
lane is somewhere to go — so its link had become dead markup. The current crumb is
now the last one with nowhere to go, which every other call site already is.
- modifyCalendarEventTool passed a start time but no duration to Zoom, which
silently books 60 minutes: an AI-updated 25-minute event held an hour on the
host's own account.
- schema.ts declared two_factor_required without .notNull(), drifting from the
NOT NULL the migration actually applied.
- HomeAgentsWidget cast a tRPC result that was already typed — the redundant-cast
shape that took down the home rail once before.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 11:54 PMdraftEvent.tsfix(calendar): let an untouched event composer be dismissed by clicking away
Clicking Create opened a composer whose block then followed the cursor: every
click on the grid re-timed the blank event instead of abandoning it, and Cancel
was the only way out. An empty composer now behaves like a modal with a
transparent backdrop — the first click anywhere else means "not this" and
nothing else, the way an existing event's details popover already reads. The
moment anything is typed the backdrop goes and the calendar is live again, so a
half-written event is still never lost to a stray click.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 10:03 PMdragDebug.tsfeat(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 PMis-own-event.tsfeat(home): the day is one card, and the agenda only shows meetings that are mine
The home hero stacked a next-meeting card over a collapsible agenda, which fitted
exactly one meeting on screen and pushed the tasks under the fold. The day is now a
single "Daily agenda" card: today's tasks beside every one of today's meetings, with
the title row itself as the expand control and a clamp (not a fold) when collapsed.
Meetings render as bare rows — the calendar colour survives as a left rule and nothing
else — because a list of bordered tiles beside the tasks read as a second set of tasks.
Everything the row drops comes back in the event popover, now opened by HOVER and
anchored right. Hover, not click, because the row's click belongs to the conversation:
that needs a PopoverAnchor rather than a Trigger, and the portaled panel has to keep its
own hover alive. A meeting with no conversation stays pressable and says so.
The popover gains a bottom action row — Reschedule (No show while the meeting is
running) and Schedule Follow-Up, moved down from the top controls — both wearing the
composer's Send pill, since each one drafts and sends a mail.
Agents become cards under a per-folder toggle, each with one of the twenty animated
faces (resolution extracted from AgentsEditor so both surfaces draw the same face for
the same agent, hashed from the id rather than list position). Search overrides the
fold: a search that only looks in the open folder reports "no matches" for an agent
that is right there.
The filter that took the longest to get right is is-own-event: the daily agenda shows
PARTICIPATION, not access. Two traps, both of which leaked before being pinned in
tests — attendees[].self is relative to the calendar the event was READ from, so on a
teammate's calendar it flags the teammate; and "a calendar you own" is not "your
calendar", so accessRole 'owner' re-admitted every meeting on any secondary calendar.
Only the primary calendar counts, and the literal fetch alias 'primary' counts as it.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 26, 2026, 10:42 PMjoinMeeting.tsfeat(meetings): frontend for the Meetings tab (design: meeting-tab phases 2-10)
The deal's Meetings tab mounts the SAME `AgentWorkspace` component
`/agents/:agentId` mounts — conversation-scoped, embedded chrome — so a meeting
looks like an agent because it IS one. Anything rebuilt here instead of reused
would drift the first time either surface was touched.
Above it, a strip naming the meeting and stating whether prep has run for it.
Inside its Output tab, in order: the rep's NOTES for that meeting (the surface
you use during the call), the prep BRIEF (folded away once the call starts),
"Past Meeting Preps" as date chips over the agent's archives/, and a paged
"Previous meetings" log — then the agent's ordinary file tree.
The notes editor carries note markers (`- [pain] … <!--m:1f0c-->`), real
AgendaTaskNode task lines bound to the deal, @-mentions of the attendees, and a
slash command per marker kind plus /template, /ask-cedar, /draft-followup.
The prep agent is RELOCATED, not hidden: dropped from the conversation's agent
rows and from the Files tab's Agents folder, but still built and still
resolvable, because `resolveOpenDoc` resolves the `__meeting_prep__` sentinel
through that list. Legacy `{convId}/files/__meeting_prep__` links now route to
the Meetings tab instead of a dead end.
Post-meeting triage is wired into the real pipeline, two ways: the notes go into
the orchestrator's prompt as context (with the rule that they outrank the
transcript on what was committed to), and `triageMeetingNotes` runs
deterministically after the meeting so a marker becomes a task whether or not the
model cooperated. Idempotent, so a replay and the manual button cannot double-create.
Six corrections the build forced, each recorded in the doc:
- The prep pane must NOT resolve the brief with `documents.getDoc` —
find-or-create would mint an empty brief and make "prep never ran"
permanently unobservable, killing the Run-prep affordance.
- `agent.getOutputs` returned no `metadata`, so the archive stamp was landing
and nothing could read it; every archive fell back to date-matching.
- `documents.list` hard-filters userId, so org templates were invisible —
hence `meetings.listNoteTemplates`.
- A marker inside a bullet list serialized as `- - [pain] …`, which the
contract regex rejects: the rep's line vanished from triage silently.
- Pasted markers must be re-minted at PASTE time, not de-duped afterwards, or
pasting above the original re-mints the ORIGINAL and strands a task's
source_marker_id.
- `follow-up` and `next-meeting` were the same action rendered twice.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 27, 2026, 1:26 AMmeetingEmailPrompts.tsfeat(meetings): frontend for the Meetings tab (design: meeting-tab phases 2-10)
The deal's Meetings tab mounts the SAME `AgentWorkspace` component
`/agents/:agentId` mounts — conversation-scoped, embedded chrome — so a meeting
looks like an agent because it IS one. Anything rebuilt here instead of reused
would drift the first time either surface was touched.
Above it, a strip naming the meeting and stating whether prep has run for it.
Inside its Output tab, in order: the rep's NOTES for that meeting (the surface
you use during the call), the prep BRIEF (folded away once the call starts),
"Past Meeting Preps" as date chips over the agent's archives/, and a paged
"Previous meetings" log — then the agent's ordinary file tree.
The notes editor carries note markers (`- [pain] … <!--m:1f0c-->`), real
AgendaTaskNode task lines bound to the deal, @-mentions of the attendees, and a
slash command per marker kind plus /template, /ask-cedar, /draft-followup.
The prep agent is RELOCATED, not hidden: dropped from the conversation's agent
rows and from the Files tab's Agents folder, but still built and still
resolvable, because `resolveOpenDoc` resolves the `__meeting_prep__` sentinel
through that list. Legacy `{convId}/files/__meeting_prep__` links now route to
the Meetings tab instead of a dead end.
Post-meeting triage is wired into the real pipeline, two ways: the notes go into
the orchestrator's prompt as context (with the rule that they outrank the
transcript on what was committed to), and `triageMeetingNotes` runs
deterministically after the meeting so a marker becomes a task whether or not the
model cooperated. Idempotent, so a replay and the manual button cannot double-create.
Six corrections the build forced, each recorded in the doc:
- The prep pane must NOT resolve the brief with `documents.getDoc` —
find-or-create would mint an empty brief and make "prep never ran"
permanently unobservable, killing the Run-prep affordance.
- `agent.getOutputs` returned no `metadata`, so the archive stamp was landing
and nothing could read it; every archive fell back to date-matching.
- `documents.list` hard-filters userId, so org templates were invisible —
hence `meetings.listNoteTemplates`.
- A marker inside a bullet list serialized as `- - [pain] …`, which the
contract regex rejects: the rep's line vanished from triage silently.
- Pasted markers must be re-minted at PASTE time, not de-duped afterwards, or
pasting above the original re-mints the ORIGINAL and strands a task's
source_marker_id.
- `follow-up` and `next-meeting` were the same action rendered twice.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 27, 2026, 1:26 AMpendingMovePreview.tsfeat(calendar): the date expands where you clicked it, and the grid answers before you save
Clicking the date on an existing event opened a second panel over the first: a
month grid beside a natural-language field, which was a third way in that nobody
reached for and made an existing event's "when" a different shape from a new
one's. The row now expands in place into the SAME two rows the composer uses —
what you type on top, what it resolved to underneath — with the caret already in
the field. The month grid is gone; all-day, time zone and repeat came with it.
A time typed there is now answered on the grid immediately rather than only after
Save: the block keeps its saved slot, faded, and a ghost paints the slot it would
move to. Same pair of states a drag leaves while the pointer is down, and for the
same reason — where it was and where it is going are both things you need to see.
The panel stays anchored to the ORIGINAL block, which is what keeps it from
unmounting mid-edit, so the grid never navigates for an unsaved change.
Two bugs behind the two features:
- Committing a natural-language date went through `handleStartTimeChange`, which
derives the end DATE from the start date it closed over — the old day. So
"next tuesday" left the event ending on the day it used to be on, and Save
failed on "end must be after start". It now moves both ends itself, keeping the
duration, and honours `hasExplicitTime` so a day-only input stops resetting the
clock to 9am.
- The hovered panel (the meetings widget) closes when the pointer leaves it, and
the time picker and date suggestions are portaled OUT of it — so reaching for
the control that made an edit is what threw the edit away. It now pins itself
while it holds one, and closes the way every other panel does. Its X did
nothing at all: nine close controls called an `onOpenChange` a hover-driven
panel does not have, and now route through `requestOpenChange`.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 12:38 AMrecurrence.tsfeat(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 PM