componentsfix(mail): a link in a chat thread is a link
LinkedIn and WhatsApp send their words as plain text, so a pasted URL
rendered as characters. Slack linkified only its own <url|label> tokens —
which its composer emits and a bot, an unfurl field or pasted text do not.
Bare URLs are now linkified in both, before the mrkdwn formatting pass so
the parser cannot read a URL's underscores as italics. The splitter is
lifted out of the calendar's LinkifiedEventText into lib/linkify.
Links are painted with the action ramp, not primary: primary is near-black
on light and near-white on dark, the colour of the words around them.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 18, 2026, 9:38 AM1 defecthooksfix(inbox): give every channel chat an artifact, and an address
Clicking a LinkedIn row left the URL untouched and the panel beside it empty. One
cause: openChannelChat set a display artifact only for Slack. The artifact is not
just the address a URL is projected from — it is what artifactToContext reads to
pick the surface for the chat column — so LinkedIn and WhatsApp opened with the
mail surface beside them and the counterpart card had no slot to render into.
All three channels now set one, and LayoutUrlSync's Slack block is extracted into
a hook called three times: ?slack=, ?linkedin=, ?whatsapp=. One copy of the ref
handshake instead of three.
This makes a LinkedIn chat take the single display slot and close on the back
button, exactly as Slack always has. Three tests pinned the old asymmetry and now
pin the new symmetry.
Presentation, from looking at real profiles:
- the unlinked "No conversation linked" block is suppressed here; for a DM that
will never be a deal it is a permanent no above the only thing this surface has
to say
- the role shown as current no longer repeats as the first past role
- Past is a timeline, with consecutive roles at one company grouped, so a
promotion reads as one job rather than three
- posts are a carousel, fitting all five in one row's height
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 8, 2026, 4:26 PMlibfix(unibox): a chat id the feed forgot still isn't a Gmail thread
`splitHotkeyTargets` decided what a selection id was by asking the loaded feed, and read a
lookup MISS as "email". But selection and hover state outlive a feed replacement, so a miss
is an ordinary event — and on one, a `li:`/`wa:`/`slack:` id went to the Gmail driver, which
is precisely the modify-a-thread-that-does-not-exist this module exists to prevent.
The prefix is structural (`services/inbox/feed-core.ts` mints it) and an email row's id is a
bare Gmail thread id, which is hex and cannot hold a colon — so the prefix answers the
question without the feed. An id that is a chat but resolves to nothing is now dropped from
both halves: there is no row to act on, and no driver that could act on it.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 7, 2026, 4:40 PMstorefeat(tables): sort by a column, and LinkedIn/WhatsApp output cells
Two additions to table documents.
SORTING. `schema.sort` orders the rows the way the column's TYPE orders — currency
numerically, a date by the instant (in the column's own `format`), a select by its
DECLARED option order, `_status` by the fan-out lifecycle. One comparator in
`table-sort.ts`, shared by the grid and by `read`, so a sorted read and the sorted
table cannot disagree.
The sort is a VIEW: `tableRows` keeps document order, so `_id` addressing, the ordinal
forms and every row's CRDT identity are untouched. Two consequences fall out of that —
the row drag handle is withdrawn while sorted, and `read` reports rather than silently
adopts a declared sort, because an ordinal resolves against document order on every
backend.
`table-sort.ts` also absorbs the tolerant cell readers, which existed twice (the Excel
writer's and the grid's) and had already drifted on the checkbox vocabulary.
LINKEDIN / WHATSAPP OUTPUT CELLS. Both kinds were declared but their payload fell into
`GenericCellOutput`, which holds the words and nothing that says who they are for. Real
payloads now, addressable by an existing chat OR by a person with no chat yet — which is
the lead-list case the column is for. `canSendCellOutput` becomes `sendableCellOutput`,
which RESOLVES a cell into a payload whose fields are required, removing three non-null
assertions from the send site.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 10, 2026, 8:17 PMtypes.tsfeat(inbox): retire the server-merged feed, and make one email read serve both lists (design: inbox-triage phases 8 + 0)
Finishes Phase 8 and lands Phase 0.
RETIRED — `inbox.listItems` and `assembleInboxFeed`, with `fetchEmailItems`,
`inboxCompiledQuery`, the folder→query fallbacks and `resolveEmailLabelTerms`. feed.ts
drops 875→500 lines and no longer reads mail at all. Its 4 test files are RETARGETED,
not deleted: scope onto `participatingChannels` and the three sources' SQL, and the
real-DB files onto the live `inbox.listChannelItems` route.
TWO PAGING BUGS the server-merged feed was hiding, both found by retargeting those tests
onto a single source — three others had been covering for each one:
* `mergeAndPage` truncated a single-source feed at page two: the sources resume at
`<= cursorSortedAt`, so every page re-read its boundary row and spent the `limit + 1`
that proves "more remains". It now takes a REQUIRED `sourcesSaturated` — required so a
new caller cannot silently reintroduce it.
* The sources ordered by `last_message_at` alone while the pager tiebreaks by id, so a
LIMIT landing mid-tie-band took an arbitrary subset and the skipped rows sorted ABOVE
the minted cursor — gone for good. All six source queries now order
`(last_message_at DESC, <itemId> COLLATE "C" DESC)`; the collation is load-bearing,
since the default one folds case and ignores punctuation and would disagree with
`compareItemsDesc`. Measured: three rows vanished from a paged walk of a real feed.
PHASE 0 — one email read, one route resolver:
* The `headChanged` latch is per-surface. Phase 8 gave `mail.listThreads` a second
caller, so a single per-connection flag meant whichever surface read first consumed
the signal. `markPendingHeadChange` fans out to every surface, because the reconcile
dedup key carries none and the second reader routinely joins the first's in-flight run.
* `use-route-inbox.ts` resolves the active inbox from the URL for BOTH lists; the
persisted `settings.activeInboxId` fallback is gone. Closes the important/other and
system-folder divergences as well.
* `drafts.create` awaits `surfaceDraftInInbox`, and the label join row is born with its
ordering key instead of being backfilled by a second statement.
compose-feed becomes self-verifying now that its diff target is gone: ordered, unique,
stable-across-paging, and watermark-respecting, checked over real paging rounds and each
probed against a deliberately broken history.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 9:56 PM