execution-rail.tsfeat(tasks): a task is worked like an email, and a new one opens itself
Three changes to how tasks are entered, from customer feedback that the task
surface is the most confusing thing in the product.
The execution rail is off. Opening a task used to replace the left rail with
the list of every OTHER task in its lane, on top of a centre column already
carrying the task's own context and Execute button — two lists, neither
obviously the thing to act on. A task now behaves like an email: click in,
work it, go back for the next. Gated on TASK_EXECUTION_RAIL_ENABLED with the
two decisions extracted as predicates, so the list and its j/k navigation are
intact and restoring the surface is one constant.
Its lane switcher is deleted outright rather than gated. With the rail closed
a control for picking which lane it shows had nothing left to do.
Creating a task opens it. The board's "+" card and the three conversation
timeline surfaces land on the new task instead of on the list it went into,
where it read as having vanished. Keyed on the server's real id — the
optimistic temp id resolves to nothing — and opt-in on the shared optimistic
hook, so Next Steps and the strategic-overview row stay put.
Also adds the design doc for the full-screen ticket that surface will become.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 24, 2026, 2:27 PMgroup-cache.tsfix(tasks): count only what's due, and give the sidebar its own Upcoming
The board and the execution sidebar rendered the same task set through two
different bucketings, so a lane's contents disagreed with its column. Anything
due after today was pulled into the board's Upcoming column but left inside its
group lane in the sidebar — 18 of 44 open tasks on a real account — which read
as the board silently losing work.
Both surfaces now split on one boundary, exported once. A lane's count is DUE
work only, everywhere it appears: the sidebar pill, the group switcher, and the
rail's group badges, which needed a `dueTaskCount` from listGroups computed
against a cutoff the client supplies (the server has no idea what timezone the
user is in — a UTC day misfiles anything due this evening). Not-yet-due tasks
keep their lane but sit under their own Upcoming heading, counted nowhere.
The `listGroups` input is shared by every caller: five surfaces render from that
one cache entry and the optimistic rename/reorder helpers patch it in place, so
a lone caller passing something else would have split the cache and made those
writes move half the app.
Also folds the board's "Add column" and "Hidden columns" into a single rail
instead of two side-by-side ones. Carries a concurrent session's in-progress
work on TaskKanbanBoard and its test, which could not be separated from the
file.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 12, 2026, 11:37 PMhumanize-status.tsfix(review): address pre-merge review nits across live-channel tools + unibox
- unibox: bulk toast now reflects the real action (deleted / marked as spam /
marked as done) instead of always "marked as done"
- unibox: clear stale hoveredEmailId in the email branch so the next keypress
can't re-target a gone thread
- inbox slice: delete unused removeInboxItems / patchInboxItem (no consumers)
- slack tool: wrap the sync-thread ownership pre-check in try/catch so the
write path keeps the structured-error contract like the read paths
- slack schema: drop the never-populated userName field from read-thread output
- mail tool: add an exhaustiveness default branch (mirrors executeLinkedinAction)
- linkedin tool: sync-chat returns attached = eventsAttached > 0, not hardcoded
- LeftSidebarContent / InboxDebuggerTab: replace arbitrary text sizes with text-xs
- fix two stale doc comments (executingTaskIds, humanizeStatus)
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jul 29, 2026, 11:42 PMinvalidate-task-lists.tsfix(mail): resolve agent notification deep links to the agent's document
An agent's Slack/iMessage link addresses its output by AGENT id
(?conversationId={conv}/agent/{agentId}), because the document id isn't knowable
when the prompt that builds the link is assembled. That id was routed into
pendingExpandAgent -> expandedSections, which nothing has rendered since the
Agents tab was folded into the Files tab (that tab reads conversationOpenFile).
So every "View Prep" link landed on the bare Files list with the agent silently
dropped.
An agent id now becomes an __agent__{agentId} open-file sentinel -- the same
lazy-resolution pattern __meeting_prep__ already used -- which the Files tab
maps to conversation/{conv}/agent-{agentId}/overview once docs load. This
repairs links already sitting in customers' message history, which emitting doc
ids server-side could not. Fixes the same dead mechanism in the other two call
sites (openConversation's agent/ section, openConversationFromAgenda).
Also carries in-flight work across task execution/kanban, chat input queueing,
per-thread chat routing, and the headless chat request context.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 4, 2026, 11:33 AMopen-slack-channel-from-task.tsfix(tasks): a Slack task's draft reaches the channel that actually opens
Opening a Slack task seeded `openSlackDraftInChannel`, which fills the per-deal
timeline composer — but the surface that opens is the unibox's ChannelThreadView,
which reads `pending-channel-draft`. Two composers, two feed mechanisms, neither
erroring on the wrong one: the drafted message went to a surface the click never
navigated to, and the channel you landed in had an empty box. The one definition
now lives in `open-slack-channel-from-task.ts`, which also collapses the closure
that was copied into both call sites.
The stand-in feed row for a channel the feed has not loaded put `#channel` in
`counterpart.name`, but ChannelThreadView reads a Slack channel's title off
`counterpart.subtitle` (`name` is the last SENDER on a real row) — so every
hinted channel rendered a header reading "Slack channel". Built to
`slackMessageToItem`'s real shape now, which retires the `as unknown as` cast
that was hiding it.
Sending a Slack draft completes the task but leaves the text on `taskActionData`,
and there is no `sentAt` on the output — so re-opening a done task dropped an
already-posted message into the composer, one Enter from a duplicate. Completion
is the sent signal; `openTask` carries `status` and skips the seed.
Three more enumerations still listed three artifact kinds out of four, each
silent in its own direction:
- `setNavigationPage` cleared the artifact but not `isSlackThreadOpen`, leaving
a flag with nothing behind it — which every route gate reads as "open", hides
the page for, and ActiveViewDisplay has nothing to draw for.
- `useExitTaskExecutionOnClose` never saw a Slack channel CLOSING, so
`?task=`/`?group=` outlived it and the rail stayed a task list over one.
- ConversationInboxLayout and mail.tsx omitted `isTaskOutputOpen` (and the
former `isSlackThreadOpen`); `?task=` is routed from the root layout, so both
kinds reach those routes and were set but never drawn.
And when no feed row and no hint resolve, ActiveViewDisplay returned null while
its hosts had already hidden everything else — a blank page with no way off it.
It now says so and offers Back, as the ticket does for a missing task.
EditableText's Escape called preventDefault but not stopPropagation, so the
native event still reached TaskTicketView's window listener. With the title and
notes now always-mounted fields, backing out of a word closed the whole ticket.
The invariant tests covered one of the four enumerations; they now cover the exit
hook and `setNavigationPage` too, and a new suite pins which composer is seeded.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 24, 2026, 11:10 PMopen-task.tsfix(tasks): a Slack task's draft reaches the channel that actually opens
Opening a Slack task seeded `openSlackDraftInChannel`, which fills the per-deal
timeline composer — but the surface that opens is the unibox's ChannelThreadView,
which reads `pending-channel-draft`. Two composers, two feed mechanisms, neither
erroring on the wrong one: the drafted message went to a surface the click never
navigated to, and the channel you landed in had an empty box. The one definition
now lives in `open-slack-channel-from-task.ts`, which also collapses the closure
that was copied into both call sites.
The stand-in feed row for a channel the feed has not loaded put `#channel` in
`counterpart.name`, but ChannelThreadView reads a Slack channel's title off
`counterpart.subtitle` (`name` is the last SENDER on a real row) — so every
hinted channel rendered a header reading "Slack channel". Built to
`slackMessageToItem`'s real shape now, which retires the `as unknown as` cast
that was hiding it.
Sending a Slack draft completes the task but leaves the text on `taskActionData`,
and there is no `sentAt` on the output — so re-opening a done task dropped an
already-posted message into the composer, one Enter from a duplicate. Completion
is the sent signal; `openTask` carries `status` and skips the seed.
Three more enumerations still listed three artifact kinds out of four, each
silent in its own direction:
- `setNavigationPage` cleared the artifact but not `isSlackThreadOpen`, leaving
a flag with nothing behind it — which every route gate reads as "open", hides
the page for, and ActiveViewDisplay has nothing to draw for.
- `useExitTaskExecutionOnClose` never saw a Slack channel CLOSING, so
`?task=`/`?group=` outlived it and the rail stayed a task list over one.
- ConversationInboxLayout and mail.tsx omitted `isTaskOutputOpen` (and the
former `isSlackThreadOpen`); `?task=` is routed from the root layout, so both
kinds reach those routes and were set but never drawn.
And when no feed row and no hint resolve, ActiveViewDisplay returned null while
its hosts had already hidden everything else — a blank page with no way off it.
It now says so and offers Back, as the ticket does for a missing task.
EditableText's Escape called preventDefault but not stopPropagation, so the
native event still reached TaskTicketView's window listener. With the title and
notes now always-mounted fields, backing out of a word closed the whole ticket.
The invariant tests covered one of the four enumerations; they now cover the exit
hook and `setNavigationPage` too, and a new suite pins which composer is seeded.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 24, 2026, 11:10 PMsendAnimations.tsrefactor: delete bridge & merge mailStore into cedarStoreFeb 26, 2026, 9:45 PMtask-drop-plan.tsfix: close the review's findings — a leaked drag, a red suite, and an erased fingerprint
Four things the branch got wrong, and the dead code left behind by the drag rewrite.
The drop path stopped calling `endDrag`. Every successful drop therefore left `draggingId`
set, and the pointer listener is keyed on it — so the board went on re-arranging itself
under a mouse with no button held. Dropping onto Upcoming was worse: the board stayed live
while the date picker was open.
`putDraftSession` replaced the record instead of merging it. The two writers each hold half
of it and neither can supply the other's half: `createDraftInProvider` knows the fingerprint
of the body it wrote, `createDraft` knows the composer's session id. So the first time a rep
saved an agent draft the fingerprint was erased — and `matchesRecordedAgentBody` fails open
on a record with no hash, so the next run read their edited draft as untouched and rewrote it
in place. That is the exact loss the store was built to prevent, so it is now asserted in
both writer orders.
Eight tests were failing. `user-tasks.ts` newly imports `cedarAdminProcedure`, which three
`vi.mock('@/trpc/trpc')` factories do not export, so those modules could not load; the mock
now mirrors the real staff check rather than aliasing `privateProcedure`, since a mock that
waves admin routes through is the wrong thing to lean on. And `ComposerActionButton` was
rewritten to plain SVG attributes — `motion/react` is gone from it entirely — while the old
suite still mocked framer-motion and asserted `attrX`/`transformBox`. Its four behavioural
tests move to the geometry suite that replaced it; the stale file goes.
`resolveDropColumn` and `placeDroppedCard` no longer have a caller now that the drop resolves
from the pointer, and `sortOrderForDrop` is covered by `taskOrder.test.ts`, so they and their
suite go too. Also: `restampSortOrder`'s doc comment was orphaned three hundred lines above
it, and `upcomingDrop.test.ts` carried a duplicated block attached to nothing.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 1:36 AMtask-filters.tsfix(tasks): fix task-filters test fixture and teammate AOP types in filter menu
Add filterTypes to the TaskFilters test fixture and cover the types facet
(makeTaskFilter + taskFacetCounts) — the missing required field was breaking
tsc and the whole Jest suite (Mail app tests CI failure on #2801).
Also fix the Type filter submenu to include teammate-owned AOPs: it only
listed the caller's own useAOPs() result, so a task filed against a
teammate's deal (hydrateTaskConversations is org-wide) could carry an
aopId/aopName that never appeared as a selectable option, even though it
was already counted by FACET_DEFS.types.keyOf. The submenu now unions
useAOPs() with the distinct aopId/aopName pairs observed on the loaded
tasks, deduped by aopId. Requires threading aopId/aopName through
HydratedTaskConversation, which the server already returns but the client
type didn't declare.
Co-Authored-By: Claude Sonnet 5 <<email>>
Claude-Session: https://claude.ai/code/session_019jTe7TuDHKYaFL6Ny2Z5h6Sep 16, 2026, 7:20 AMtask-group-icons.tsfeat(sky): the sunset peaks at the horizon, not at civil twilight
The ramp hung full orange on civil twilight, so the backdrop was still blazing
sunset once it had gone dark outside. Stops are now placed as fractions of the
ramp with a new `blush` between the gold and the burnt orange, peak warmth sits
at the horizon, and both halves share the ramp below the blush so the light/dark
flip stays continuous without either half knowing where the hinge is. The
locationless fallback sun solves its offset instead of eyeballing it, and a theme
that isn't following the sun (a pinned theme, a hard-coded page) rests flat
rather than being clamped onto a twilight sky indefinitely.
Also carries unrelated in-tree work: the "NEVER cast" rule in CLAUDE.md and a
regression test for `resolveDealsAopId` — both from the PipelineWidget cast that
took down the home rail — plus the Users icon for task groups.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 29, 2026, 3:46 PMtask-line.tsfeat(tasks): one plain-text task line everywhere, no cards, no agenda groups
A task's description is now two parts — `<headline> — <detail>` — and every
surface paints the same split: headline bold, deal as an inline badge, detail
muted. `parseTaskLine` derives it from the string, so descriptions that already
carried a dash render in the new shape with no migration.
The agenda's conversation card is gone (rows are plain text; the deal is a badge
in the sentence, deferring to an `@[id]` chip the row already carries), and so
are its task-group sections: grouping and ordering belong to the daily-agenda
agent alone. Legacy `taskGroupSection` wrappers are unwrapped on read — deep
copied by hand, since `Y.XmlElement.clone()` drops non-string attrs and would
have unticked every completed row.
The agents write only the description now: no `reason`, no `flags`, neither of
which rendered anywhere. The digest email reads the detail half instead.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 26, 2026, 8:31 PMtask-order.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 PMtask-output.tsrefactor(tasks): retire task_channel onto task_output.kind (design: task-cleanup phase 5)
`multi-action` was never a channel. It meant "this task has several possible
actions", which is not a place a message is sent — and TASK_OUTPUT_KINDS omits
it deliberately for that reason. Only two sites wrote it, both CRM approvals
that already pass an explicit task_output, so the value was legacy filler.
Removing it forced the question the column had been dodging: task_channel was
NOT NULL DEFAULT 'email', so those rows had to become either a lie or nothing. A
CRM approval sends nothing. Migration 0059 makes the column nullable, drops the
default, narrows the CHECK to real channels, and nulls the 7,200 multi-action
rows. Relaxing the constraint is itself a step toward the drop — constraints
come off before columns do, so a newer server can stop writing it first.
Moving the readers is NOT a rename. The two axes disagree on ~12,000 rows and
the output axis is the correct one, so each site is a decision: does it want the
declared channel, or what the task actually produces? It is the artifact, and
that changes which rows match. On open tasks, 49 stop matching the email filter
and 9 start — a calendar task or a pure reminder no longer auto-completes
because an email thread synced, and a task whose channel says slack but whose
payload holds an email draft now does.
Two traps worth recording:
- The migration ordered `UPDATE ... SET NULL` before `DROP NOT NULL` and was
rejected by the constraint it had not yet dropped.
- createUserTaskWithExecutionUpdate had `taskChannel || 'email'`. Left alone it
would have silently converted every "no channel" back into "email" on write,
undoing the migration for new rows while backfilled ones stayed correct.
Two latent bugs surfaced by the sweep:
- The crm/conversations.ts task aggregates omitted task_output entirely despite
HydratedConversation.userTasks being typed as the full row, so nothing
downstream could read the output axis off that path.
- The task board's `keyOf` defaulted a null output to 'email', so an undecided
task would have vanished from the board when grouping by channel. There is now
an explicit Undecided bucket.
Frontend gets a shared task-output module mirroring the server kinds; the
Multi-action column, badge and icon are replaced by real output kinds.
Co-Authored-By: Claude Opus 5 <<email>>Aug 15, 2026, 6:28 PMtask-ownership.tsfix(admin-view): an agent's files open for the user being viewed, not the staff member
Under Cedar-staff admin view every tRPC request carries X-Admin-View-User and the
server swaps ctx.sessionUser to the impersonated user. The session is the one thing
that does not move with it: better-auth talks to /auth/get-session, which the
impersonation middleware explicitly skips. So a `user` file scope built from
useSession() asked, AS the impersonated user, for the staff member's own workspace —
a cross-user request assertAuthorizedScope correctly refuses. The refusal is a tRPC
FORBIDDEN that nothing logs, so the symptom was an agent's Files tab that was simply
empty against a clean server.
Replaces the eight hand-rolled answers to "who am I" with useViewerUserId. It
deliberately does not read targetUserId: admin view is a property of the whole
client, so reading it ambiently is safe, while the org-admin picker is a property of
one surface and an ambient read of it is the documented failure on useScopedInput.
Fixes the same class where the viewer id was not merely cosmetic — it also drives
isOtherPersonsAgent on the deal Files tab and the task-ownership split, so the viewed
user's own agents and follow-ups all read as a teammate's.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 10, 2026, 12:20 PMtask-threads.tsfeat(tasks): preload a task's thread on hover and after the list loads
A task is a link to a thread — an email task opens its draft's thread, every
other task opens the ticket, which renders the thread the task is about — and
it was the only such link in the product that fetched nothing until the click.
The thread list beside it has preloaded its bodies on hover and on load for as
long as it has existed.
Same treatment, same queries: `taskThreadIds` names the threads a task can put
in front of you (output axis first, legacy `taskActionData` second, then
`sourceThreadId`), and `usePrefetchTaskThreads` / `usePreloadTaskThreads` feed
them to `mail.get` on hover and in an idle sweep over the first 20 tasks,
writing the bodies into the thread slice the way `useThreads` does.
The sweep hangs off `useHydrateTasksSlice` — the one place a task list enters
the slice — so the board, the list and the execution rail all get it; the
agenda fetches its own day-ranged list and asks for it directly. Hover is wired
into the list row, the kanban card and the agenda row, and also warms
`getTaskById`, since the ticket blocks on that before it renders anything.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 24, 2026, 8:54 PM