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