Channel pin and soft delete, and a Notion connector over hosted MCP (#242)
* Let a member pin a channel and soft-delete it, from a right-click menu * Land the caret in the composer once a coworker is chosen * Calm the screen panel down and make the full-size view a card * Let a Bot's message take the whole transcript column * Put Notion in the catalogue, and let a vendor register its OAuth client dynamically * Rotate refresh tokens in place, serialised per connection, and recover an evicted client * Introduce the deployment to a dynamic vendor on first connect * Show Notion in the plugin screens, without a client form it does not need * Say what the Notion connector is, everywhere the catalogue is described * Grant a batch of tools to Bots from the vendor page * Hold the vault row while a rotating token is spent, so replicas take turns * Refuse to mint a second client inside the re-registration window * Tell every member's roster when a channel is deleted, again * Leave a who-and-when behind a soft delete, again * Carry a pin across one person's own tabs * Say the classification direction right everywhere a person reads it * Refuse to whisper into a deleted channel `get` and `list` filter on `deleted_at`; `recordActivity` and `setPinned` did not. Activity POSTed to a soft-deleted channel returned 204, bumped `last_message`, and announced it to every member, each of whom then refetched a roster for a row it cannot show; a pin on one succeeded the same way. Both now join the channel and require it undeleted, throwing ChannelNotFoundError to match `get`, which also keeps the notify off the refused path since it is written inside the transaction. The roster's second query repeats the same filter. It selects the page and then joins the agents to it in a separate statement on a separate snapshot, so a delete committing between the two would hand back a channel this person can no longer see. * Hold a pinned channel at the top of the roster, not the page The roster ordered by recency alone and the client lifted pinned rows at render, so a pin only reached the top of whatever pages were loaded: a channel somebody pinned and then did not talk to for a month sat on page three and never appeared above anything. The promise is about the roster, so the ordering belongs in the query. The page now orders by the pin first and the cursor carries it as the leading element. Every part of the sort descends — a pin is 1 and no pin is 0 — which keeps the keyset predicate a single row comparison rather than a nest of ORs, and a cursor minted before the pin existed reads as the first page, like any other cursor describing an ordering this query no longer has. `pinnedFirst` stays in the sidebar as the render-level mirror, for the window between refetches: the socket patches a pin onto a loaded row without moving it, and re-sorts a page by recency alone. Its comment now says that is what it is for, rather than claiming to be where the rule lives. * Read a vendor's garbage as a refusal, not a crash * Keep the wheel reachable when the screen has nothing to show Take control and Hand back live in the full-size view, and the only way in was disabled unless there was a picture to open. So a blank browser, a screenshot that had not arrived, or a computer that could not be reached left a person with no way to take the wheel at all - the three states where they most want it. The frame now opens whatever is in it, and with nothing to draw the full-size view reserves the same shape and says the same words the card does, with the wheel underneath them. Somebody already driving keeps the live socket, whatever is on the page: once a person holds the wheel the stream is the truth about it. The Bot ASKING for the wheel comes back to the card as its own amber row with the reason on it, which is what the rework dropped. It is not the persistent footer that was deliberately removed - it is there only while the request is, next to the credential form, which is the other thing a stuck Bot needs. * Answer pin and delete failures where they happened Three things this row did quietly. A refused delete stayed on the mutation, so reopening the confirm showed a stale 409 about an attempt nobody had made yet; the menu resets it on the way in. A failed pin said nothing at all - the menu closed, the pin did not move, and that reads as the app ignoring the click - so the sentence now lands on the row, there being no toast in this app. And a delete of the channel on screen navigated home after the write. The roster invalidates the moment it lands, which unmounts this row and the dialog inside it, so the navigate belonged to a component that was already gone. Leaving first is safe in the other direction: a refusal puts them on the roster with the channel still in it, and says why. * Grant a batch with one refetch and a progress count Two Bots and twelve tools is twenty-four writes, and every one of them went through the grant mutation - which invalidates every plugin query and waits for the refetch. Most of the wait was re-reading a list hidden behind the dialog. The write is now its own function with no refetch attached, and the dialog invalidates once when the loop is done, including after a refusal, because the grants before it landed. The button says which of the N is in flight rather than only "Granting", so a slow batch can be told from a stuck one, and each set of tickboxes is a fieldset named by the heading already above it - "Changes things" is the whole warning on those tools, and a listener would otherwise never hear it. * Sweep the code the screen rework orphaned `hasBrowsed` had no callers left once the screen and the activity log stopped being tabs that had to guess which one to open, and the placeholder artwork went with the blank-browser strip it decorated. The note itself stays: the tool handler is the only place the fact exists, and a screenshot cannot answer it. The composer's autofocus is a mount-time courtesy, claimed once. Keyed off the editor becoming interactive, it re-fired on every disabled or busy transition, so a completed turn yanked the caret back from wherever the person had moved it. A send of their own still returns it - that one they asked for. * Stop pretending a new client can spend an old grant * Let two first connects race to one client * Cap, revoke and say what refresh saw * Seal the consent state, not just sign it * Refuse a consent that outlived the person's access
G
Guido Vizoso committed
a6bada954e4a2bc696c6617d3f4c7a1512230990
Parent: d293f23
Committed by GitHub <noreply@github.com>
on 8/25/2026, 6:59:48 PM