componentsfeat(linkedin): the profile mark wears LinkedIn blue at rest
It was muted until hovered. A glyph that only colours under the cursor reads as
decoration until you happen to sweep over it, and the whole job of this one is to
advertise at a glance that there is a profile behind the name.
The colour comes from CONTEXT_KIND_COLORS.linkedin_chat — the same constant the
context chips use — so the mark means the same thing here as everywhere else, and
there is no second copy of the hex to drift.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 9, 2026, 12:36 PMhooksfix: six review findings — tenant leaks, wrong seat, lost writes
The LinkedIn counterpart cache was GLOBAL. Keyed on `provider_urn` alone, one row served every
customer who had met that member: the second org's refresh overwrote the first org's row and its
`person_id` (a pointer into that org's own crm_person), and either org's read could hand back the
other's cache. The key is now (organization_id, provider_urn), and the read and the upsert both
say so. DDL applied as a targeted, idempotent ALTER — the old single-column PK is dropped only
where it is still the one in place, and the org index goes with it, being redundant the moment the
key leads with the org.
The refresh no longer takes a seat from its caller. A profile view is billed to one seat's 50/day
and reaches LinkedIn as that seat's identity, and the client was naming the org's first connected
account — so in an org with several seats the panel spent the wrong rep's budget under the wrong
rep's name, with nothing checking the seat had any relationship to the chat. `linkedin_chats`
already records which seat holds a chat, so that is where the answer comes from.
A remembered `budget` skip now expires. The client suppresses its auto-refresh entirely while one
is reported, so an unexpiring reason was a permanently cold panel: a chat opened once while the
day's views were down to the send reserve never refreshed on its own again, however many days
later it was reopened. `last_skip_at` stamps the reason and the read honours it only inside the
window it was measured in — windows read off the governor's own caps, not a number repeated here.
A range or paste over two linked cells of ONE deal now writes them in order. `crm.updateConversation`
holds an optimistic lock on the conversation's `updated_at`; fired together, both calls read the
same timestamp, the first commits and the second matches no row and comes back CONFLICT — so half
the gesture reverted with "please retry" on an edit nobody was competing for. Same deal, one at a
time; different deals still in parallel.
"Shared with me" is gone. It listed every row in the org owned by another PERSON, which is not a
sharing relationship — there is none between users in this product; `share.ts` is public token
links. `user/` is the one genuinely private scope and `documents.get` refuses to open another
person's row at all, so the view could only ever show teammates' private files (10,037 rows live,
titles, paths and a 300-character content preview included) and then fail to open them. A menu row
that cannot be implemented honestly is worse than no menu row.
The Deleted view now lists exactly what Restore accepts. It listed every org-scoped tombstone,
which put 52 live rows (`#agendas/…`, `organisation/wiki/…`, `organisation/tables/…`) on screen
wearing a button that `callerOwnsDocument` always refuses. The set predicate lives beside that row
check so the two cannot drift apart. One deliberate gap: a `thread/` row with no owner is left out
rather than listed, because its ownership lives on the thread and cannot be asked in the query.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 10, 2026, 12:03 PMindex.tsfeat(linkedin): the counterpart card — current role, past history, recent posts (design: linkedin-counterpart-profile phase 6)
The card reads free from cache on open and auto-refreshes once per chat, but only
while the seat's daily budget is above the reserve; past that it offers a button
instead, the same deliberate-action framing as "Find company".
Unipile frequently flags no role as current, so the role line resolves current →
the most recent role with no end date → the headline. That fallback is
display-only: splitWorkExperience stays strict on the server, because a wrong
current_company_id caches permanently while a wrong LINE just redraws.
Also stops persisting a 'fresh' skip. last_skip_reason exists so a turned-away
client can avoid retrying, and "everything was already cached" is not that — storing
it left a merely-refreshed chat reporting a skip forever.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 8, 2026, 3:50 PMtypes.tsfeat(linkedin): LinkedIn inbox UI — top-level nav + two-pane chat view
A first-class "LinkedIn" app (left-nav, /linkedin/inbox) mirroring the Mail inbox:
- ChatList (left): T1 chat index with counterpart, relevance badge (✓ In CRM / New),
unread counts, snippet, filters (All / In CRM / Unread / New), and a Sync button
(pulls live chats via Unipile → drains → promotes).
- ChatDetail (right): message history (T3 if promoted, else live Unipile), a composer
that sends DMs through the native pipeline, "Save to CRM" (promote) on new chats, and
a profile link.
Server prep: listChats now returns the resolved counterpart (name/headline/publicId);
new outbound.linkedin.messaging.sync tRPC route (sync → drain). All wired to the
already-live tRPC routes. Mail-app typecheck clean for the new module.
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jun 14, 2026, 5:23 PM