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