sectionsrefactor(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 PMAnimatedCheckmark.tsxStyling for tasks and buttons updatedFeb 27, 2026, 2:12 PMChannelIcon.tsxfix(tasks): a Slack task opens its channel, not a draft card in the ticket A task that PRODUCED something should open that output where the output lives. Email already did — a draft opened its thread. Slack did not: it fell through to the task ticket, which rendered the drafted message as a card. Same message, in a surface that cannot send it in context and looks nothing like every other Slack thread in the app. It now opens the channel through `?slack=<channelId>` — the same address and the same ChannelThreadView a Slack row in the unibox opens, so it collapses and fills the column the way that view does. A Slack task's `taskActionData.channelId` IS that key. The view is mounted on /mail, so this is a navigation rather than an artifact write, which is why openTask gained a dep for it rather than reusing openTaskOutput. The ticket keeps its draft card for the one case that still lands there: a Slack task that names no channel, where there is nothing to address. ChannelIcon gave Slack a grey MessageSquare while Gmail, LinkedIn and WhatsApp all kept their brand marks. It gets the four-colour Slack glyph, the one the inbox uses. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 24, 2026, 8:56 PMNewTaskDialog.tsxfeat(tasks,ui): Linear-style new-task modal and one breadcrumb for the app Creating a task was an inline card that walked three cmd+k steps, so you could not see what you had already chosen or skip what you did not need. NewTaskDialog is Linear's "New issue" instead: one modal, title first, properties as chips. Enter chains title -> deal -> due date -> description, and picking a deal defaults the due date to that deal's next_step_date, which is why crm.searchConversationsMinimal now returns it. Dismissing keeps the draft in localStorage rather than asking to discard it. The breadcrumb is rebuilt on Notion's, measured off the live DOM: a dim "/" instead of a chevron, icon slot, and overflow that collapses to an ellipsis which expands INLINE rather than into a menu. Every real trail now renders through it. Geometry and the deliberate departures are written down in apps/mail/docs/new-task-modal-linear-spec.md. Co-Authored-By: Claude Opus 5 (1M context) <<email>> Claude-Session: https://claude.ai/code/session_014EsERRcTWLVbRAWZjM9MbCSep 1, 2026, 10:33 PMOpenTaskExecutionCard.tsxfix(tasks): make the board drag keep up with the cursor The gesture computed the right arrangement; it arrived late. Four costs paid per pointer event on a board rendering up to 500 tasks — a memo that never bailed (a fresh callback per card, per render), a forced layout per lane per move, no coalescing to a frame, and the hover prefetch firing for every card the drag swept over. Plus the one that is literally a delay: DragOverlay's default drop animation flying the card back to its old column for 250ms after an optimistic write that had already put it in the new one. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 2:21 PMTaskCommandBar.tsxfeat(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 PMTaskExecutionList.tsxfix(tasks): write the completion on the click, and clean up the draft on every check-mark Two defects on the task-completion path, reported independently by Zach (#cedar-concentrate) and Mihir (#cedar-invoicebutler). **Completion was lost on a reload within 5s.** The server write sat inside a `setTimeout` behind the undo toast, so a reload, tab close or crash in that window took it down with the JS context: `completeTask` never fired, the mask did not survive either, and the row came back on the next fetch with no `completed_at` and no server-side trace the user had ever closed it. The deferral was guarding a side effect that did not exist — `completeTask` writes a status and cancels a KV entry; draft deletion lives on the delete path. The write now goes out on the click. Undo becomes a real reopen (`updateTask status: 'todo'`), which means the server has to restore what completing cancelled: `updateTask` now re-schedules the agent run on a reopen (gated on `shouldScheduleTask`, so a past due date is left alone) and clears `completedAt`. Without that, tick-then-undo silently killed an agent task's execution. The pending-resolution mask is kept, not deleted. The write is immediate but not instantaneous, and a refetch landing mid-flight would still re-hydrate the row — the window shrinks from five seconds to a round trip rather than closing. **Ticking a task off now deletes its Gmail draft, the way deleting it always has.** 9,754 `done` tasks across 79 users still pointed at a live draft. All five check-off surfaces are wired, not just the one with an undo toast: - conversation Overview / timeline / CRM canvas — cleanup deferred behind the undo window, since it is the one irreversible half - task list, kanban (checkbox, `e`, drag-to-Done), execution list — no undo window, so cleanup rides with the status write - agenda checkbox and conversation-inbox row, which call the mutation directly `optimisticCompleteTask` served both check-offs AND next-steps TaskBlock, which closes a task right after SENDING its draft. It now takes a required cause rather than a default, because that ambiguity is what let this get missed. `completeTask` gains an opt-in `cleanupDraft` for the same reason: the send path reaches it via `completeTaskByDraftId`, where the draft is already a sent message. Deleting the draft also clears the draft pointer, keeping the threadId. `deriveAgendaRightSlot` renders "Open draft" off `draftId` alone and already withholds it when there is none, precisely so users do not click and find nothing — a surviving pointer recreated that state, newly visible because done tasks render and can be reopened. Written as a paired axes write (`taskOutput: producedOutput(...)`, stripped to the bare kind rather than nulled) per TASK_CLEANUP_DESIGN.md 1.2. Also: the delete path was missing its `crm.getConversation` invalidation, so the Overview's Due Tasks list could serve a deleted task back once the mask lifted. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 4, 2026, 12:28 AMTaskExecutionUrlSync.tsxfix(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 PMTaskFilterMenu.tsxfix(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 AMTaskGroupOrderPopover.tsxfix(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 PMTaskGroupsPage.tsxremove: unused task-group hard-pin feature (zero production usage) task_group_conversations let a user pin a CRM conversation to a task group, bypassing the AI router. A direct production query found zero rows in this table, ever, for any user, despite it being a shipped, reachable UI feature. Removes the schema, tRPC procedures, UI, router step, CLI verbs, and headless smoke-test coverage, and ships a DROP TABLE migration to run through the normal deploy pipeline. Co-Authored-By: Claude Sonnet 5 <<email>> Claude-Session: https://claude.ai/code/session_019jTe7TuDHKYaFL6Ny2Z5h6Sep 20, 2026, 1:04 PMTaskKanbanBoard.tsxfeat(tasks): add Type facet to task board filters, backed by playbook (AOP) Weiver is splitting their pipeline into two playbooks (Deals, Deals - Brazil) and needs to filter their task board by playbook the same way the CRM pipeline view already does. Adds a fifth "Type" facet to the task filter menu alongside Task group / Channel / Deal stage / CRM sync, following the existing Deal-stage precedent: dynamic per-user options (AOPs, like task groups) resolved via crmConversations.aopId on the task's hydrated conversation. - user-tasks.ts: hydrateTaskConversations resolves each conversation's aopId/aopName in the same batched-query style as priorityOptions/openTasks - task-filters.ts: new `types` facet + NO_TYPE_KEY, generic FACET_DEFS/ TASK_FACETS machinery needed no changes - TaskFilterMenu.tsx: Type submenu sourced from the same useAOPs() hook CRMFilterBar already uses, avoiding a second AOP-list fetch - use-task-list-view-options.ts: filterTypes round-trips through the URL like the other four facets Co-Authored-By: Claude Sonnet 5 <<email>> Claude-Session: https://claude.ai/code/session_019jTe7TuDHKYaFL6Ny2Z5h6Sep 16, 2026, 6:37 AMTaskKanbanCard.tsxrefactor(tasks): drop the "updating" shimmer from kanban cards The deal-level updating cue stays on the task list row and the conversation's Next Steps card; a board of cards flickering "updating" in the corner of every row belonging to one deal said the same thing many times over. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 2, 2026, 3:18 PMTaskKanbanColumn.tsxfix(tasks): one answer about where a dragged card lands Two systems were arranging the lane and only one was asked about the write. The board renders the card at the slot the pointer resolved and plans the drop off that same list; every lane ALSO ran verticalListSortingStrategy, which displaces by activeIndex vs overIndex — indices into the list the board has already rearranged, against an `over` from dnd-kit's own collision detection. Crossing a card's midpoint moved the board's gap one way and dnd-kit's displacement the other, so the card settled into a slot the drop was never going to write and snapped back on release. Lanes now displace nothing; the parting is the board's DOM order alone. `arrangeForDrag` is the single arrangement both the render and the drop derive from, and the drop reads it from refs the pointer writes synchronously rather than from state a throttled render may not have committed. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 2:28 PMTaskLine.tsxfeat(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 PMTaskListRow.tsxfeat(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 PMTaskListView.tsxfeat(tasks): add Type facet to task board filters, backed by playbook (AOP) Weiver is splitting their pipeline into two playbooks (Deals, Deals - Brazil) and needs to filter their task board by playbook the same way the CRM pipeline view already does. Adds a fifth "Type" facet to the task filter menu alongside Task group / Channel / Deal stage / CRM sync, following the existing Deal-stage precedent: dynamic per-user options (AOPs, like task groups) resolved via crmConversations.aopId on the task's hydrated conversation. - user-tasks.ts: hydrateTaskConversations resolves each conversation's aopId/aopName in the same batched-query style as priorityOptions/openTasks - task-filters.ts: new `types` facet + NO_TYPE_KEY, generic FACET_DEFS/ TASK_FACETS machinery needed no changes - TaskFilterMenu.tsx: Type submenu sourced from the same useAOPs() hook CRMFilterBar already uses, avoiding a second AOP-list fetch - use-task-list-view-options.ts: filterTypes round-trips through the URL like the other four facets Co-Authored-By: Claude Sonnet 5 <<email>> Claude-Session: https://claude.ai/code/session_019jTe7TuDHKYaFL6Ny2Z5h6Sep 16, 2026, 6:37 AMTaskOverflowCleanup.tsxfix(chat): a button that fills the composer leaves the caret in it Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 10:57 PMTaskOwnerBadge.tsxfeat(tasks): the rail stops unfolding, and other people's tasks go last The Tasks entry in the left rail carried a chevron that expanded the task groups inline. With execution mode gone those rows led nowhere the /tasks surface doesn't already show, so Tasks is now a plain nav row like every other page and the rail never expands anything. A conversation's task list is org-shared, so it renders teammates' follow-ups next to yours. Interleaved, the list stops answering "what's on me": a task only its owner can run (every mutation is owner-scoped server side) reads as your own work, forgotten. One shared partition — failing closed the way the owner badge does — now sinks them to a section of their own at the bottom of the Overview checklists, the chat panel's tasks card, and the Next Steps list. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 30, 2026, 11:07 PMTasksLayoutToggle.tsxfeat: task execution mode — list view, context-driven empty chat, output colour Adds the task-execution surface (TaskOutputPanel, per-task chat threads, tasks as a context item), the flat List layout with a Linear-style toolbar (view switcher + grouping/filters), context-driven empty-chat surfaces (task/thread ThreadContextCards), an --output green token, and thinner default/sm button heights to match the composer Send. Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jul 26, 2026, 10:45 AMTasksToolbar.tsxfeat(ui): one geometry for every panel, and a select that stops closing its popover Menus, submenus, selects and popover row forms were four authors' work. The geometry here is measured off Linear with getComputedStyle and written down in apps/mail/docs/crystallized.md, so the next one does not have to be guessed at. The two defects nobody could see: - DropdownMenuSubTrigger rendered no chevron. Four rows of the tasks filter opened whole submenus with nothing saying so; you found out by hovering. - DropdownMenuSeparator was bg-muted, which in dark mode resolves to --surface-raised — the popover's own colour. Every separator in every menu was invisible. And one that looked like an event bug and was not: Popover came from @radix-ui/react-popover (dismissable-layer 1.1.11) while Select came from radix-ui (1.1.9). Radix picks the layer that owns an Escape out of a registry held in that module, so two copies meant two registries, and a popover that believed it was always topmost — one Escape aimed at a select closed the popover under it. Dialog had the identical split. Every floating primitive now comes from one package; no call-site handler could have fixed it. Four tokens for what the surface trio cannot say — bg-control, bg-hover, bg-selected, bg-seam — because a row under the pointer is not bg-sunken, which on a #2a2a33 popover is a hole punched through the panel. Sized against our surfaces rather than copied from Linear's: their 9-unit step is invisible up here, which is how the off-state chip first shipped looking like plain text. The row highlight is now a pill inset 6px from the panel edge with its content 14px in, every menu level past ~10 options gets its own filter field, and a submenu's first row lands on the row that opened it. /tasks/kanban is the first surface on it. The four hand-rolled filter submenus (three widths, none typeable) are OptionPicker now, and the Display popover is one FieldPopover instead of six parts held together correctly — 448px wide with a gulf between label and control, before. Co-Authored-By: Claude Opus 5 (1M context) <<email>> Claude-Session: https://claude.ai/code/session_014EsERRcTWLVbRAWZjM9MbCSep 1, 2026, 11:41 PMTaskTicketProperties.tsxfeat(mail): one properties rail, and a tree that reads as a tree **The properties rail is now literally the task ticket's.** It was a lookalike — a `grid-cols-[5.5rem_1fr]` with a fixed label column, its own caption constant, its own row height — which is why it read as a different UI for the same job. The task rail is the one built against Linear and the one that reads right, so it was extracted rather than averaged: `components/ui/property-rows.tsx` owns the row geometry, the hover chrome, the caption type and the section rhythm, and both surfaces compose it. `TaskTicketProperties` keeps only what is a fact about a task. One deliberate difference, and it is not cosmetic: a document's keys are ARBITRARY. `stance` and `seniority` are both selects, so `propertyIcon` gives them the same glyph, and a task rail's icon-only row would leave a column of identical dots beside three bare values. So the field's name rides in the row, muted, ahead of the value — same height, same type, same hover. It does not get a column of its own, which was the thing that made the old rail read as a form. **Siblings stopped disagreeing.** Floating anchors were picked by comparing card CENTRES, and two people on one row of a chart have different centres whenever their cards differ in height. When their shared boss's centre fell between the two, one child connected upward and the other downward, and a line looped around a card for no reason a reader could see. A rank is a shared TOP edge — exactly equal for siblings however tall either card grows — so tops are what is compared now. **The axis relation draws orthogonally.** `getSmoothStepPath` with `offset` at half the layout's own rank gap, so every child of one parent turns on the same y and they share one horizontal run: the screenshot's shape, and what makes a tree read as a tree rather than as a fan of curves that happen to converge. Every other relation stays a bezier, so an `influences` overlay can never be mistaken for the reporting line it crosses. The `hierarchy` flag comes off the same predicate that chose `layoutEdges`, so what ranks the chart and what draws as its skeleton cannot be two different sets. **The legend moved to the bottom.** It is read once on arrival and then never again, which is the whole lifecycle a legend should have — and that is not a reason to put it across the top of the drawing it explains. Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 5:50 PMTaskTicketView.tsxfeat(tasks,ui): Linear-style new-task modal and one breadcrumb for the app Creating a task was an inline card that walked three cmd+k steps, so you could not see what you had already chosen or skip what you did not need. NewTaskDialog is Linear's "New issue" instead: one modal, title first, properties as chips. Enter chains title -> deal -> due date -> description, and picking a deal defaults the due date to that deal's next_step_date, which is why crm.searchConversationsMinimal now returns it. Dismissing keeps the draft in localStorage rather than asking to discard it. The breadcrumb is rebuilt on Notion's, measured off the live DOM: a dim "/" instead of a chevron, icon slot, and overflow that collapses to an ellipsis which expands INLINE rather than into a menu. Every real trail now renders through it. Geometry and the deliberate departures are written down in apps/mail/docs/new-task-modal-linear-spec.md. Co-Authored-By: Claude Opus 5 (1M context) <<email>> Claude-Session: https://claude.ai/code/session_014EsERRcTWLVbRAWZjM9MbCSep 1, 2026, 10:33 PM