Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e583115
Let a member pin a channel and soft-delete it, from a right-click menu
guidovizoso Aug 25, 2026
9a79d7d
Land the caret in the composer once a coworker is chosen
guidovizoso Aug 25, 2026
0c18b1a
Calm the screen panel down and make the full-size view a card
guidovizoso Aug 25, 2026
c573b17
Let a Bot's message take the whole transcript column
guidovizoso Aug 25, 2026
9b78da9
Put Notion in the catalogue, and let a vendor register its OAuth clie…
guidovizoso Aug 25, 2026
d1e129b
Rotate refresh tokens in place, serialised per connection, and recove…
guidovizoso Aug 25, 2026
56b3c52
Introduce the deployment to a dynamic vendor on first connect
guidovizoso Aug 25, 2026
bec0949
Show Notion in the plugin screens, without a client form it does not …
guidovizoso Aug 25, 2026
5cad1b9
Say what the Notion connector is, everywhere the catalogue is described
guidovizoso Aug 25, 2026
9e4fdb4
Grant a batch of tools to Bots from the vendor page
guidovizoso Aug 25, 2026
79b814c
Merge main, keeping the soft delete this branch chose
guidovizoso Aug 25, 2026
abd75b3
Hold the vault row while a rotating token is spent, so replicas take …
guidovizoso Aug 25, 2026
0e9d2a3
Refuse to mint a second client inside the re-registration window
guidovizoso Aug 25, 2026
d6f9da0
Tell every member's roster when a channel is deleted, again
guidovizoso Aug 25, 2026
32c4d34
Leave a who-and-when behind a soft delete, again
guidovizoso Aug 25, 2026
e6b9c1b
Carry a pin across one person's own tabs
guidovizoso Aug 25, 2026
3352d18
Say the classification direction right everywhere a person reads it
guidovizoso Aug 25, 2026
ea82c9d
Refuse to whisper into a deleted channel
guidovizoso Aug 25, 2026
571d9fc
Hold a pinned channel at the top of the roster, not the page
guidovizoso Aug 25, 2026
542649a
Read a vendor's garbage as a refusal, not a crash
guidovizoso Aug 25, 2026
27ca82e
Keep the wheel reachable when the screen has nothing to show
guidovizoso Aug 25, 2026
603f044
Answer pin and delete failures where they happened
guidovizoso Aug 25, 2026
b8d9648
Grant a batch with one refetch and a progress count
guidovizoso Aug 25, 2026
b0f4b2f
Sweep the code the screen rework orphaned
guidovizoso Aug 25, 2026
9655553
Stop pretending a new client can spend an old grant
guidovizoso Aug 25, 2026
a8094ce
Let two first connects race to one client
guidovizoso Aug 25, 2026
7a56538
Cap, revoke and say what refresh saw
guidovizoso Aug 25, 2026
e5e1495
Seal the consent state, not just sign it
guidovizoso Aug 25, 2026
14ee19b
Refuse a consent that outlived the person's access
guidovizoso Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 46 additions & 23 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,34 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### Notion joins the connector catalogue

Notion is now a governed MCP connector, reached through Notion's own hosted server on the
catalogue's default transport, as the person asking — the same grant, policy and audit machinery
Google Drive already runs through. Unlike Drive, it ships both read and write tools from the start;
the writing ones are named in the catalogue, and an advertised tool absent from that list classifies
as a read — so reconciling the write-tool names against what Notion's hosted server actually calls
them, on the first Refresh tools, is required, not cosmetic. A tool the server never advertised at
all still classifies as a write, same as any other connector.

There is no client to register: this deployment introduces itself to Notion on first connect. That
shortens setup but does not finish it — unlike Drive, whose tool list is this codebase's own code,
Notion's tool list is an answer from Notion's hosted server, so a deployment has recorded none of it
until Refresh tools has run at least once; and, like every other connector, a Bot gets nothing until
its tools are granted to it. Setup is enable at `/admin/plugins/notion`, connect an account at
`/settings/connected-accounts`, refresh tools, then grant — a bulk **Grant tools…** dialog on
`/admin/plugins/notion` grants a batch of tools to a batch of Bots in one pass, one grant and one
audit row per Bot per tool. No migration.

### Refresh tokens rotate in place, and replicas take turns spending them

A vendor that rotates refresh tokens invalidates the one it just handed out, so two replicas racing
to use a stale token would have the loser refused, or worse: a rotating vendor's reuse detection can
read that as a stolen token and revoke the whole connection. Every plugin call that mints an access
token now locks the credential's vault row for the length of the exchange, so a second replica waits
rather than races, and the rotated token is written back in the same transaction that held the lock.
Nothing to configure; a connection just stops going stale under concurrent traffic.

### Knowledge searches instead of guessing

A package can say which of its skills each coworker gets, and the fintech example gives Knowledge the
Expand Down Expand Up @@ -233,6 +261,24 @@ without a word, because the input path looks for a viewer before it looks for an

A close now stops casting only when the socket closing is the one that was casting.

### A sidebar channel row can be pinned or deleted

Right-click on a channel in the sidebar and a menu opens with two entries: Pin channel and Delete
channel.

Pin is held per member rather than per channel, so pinning one holds it at the top of your own
roster — newest first among pinned channels — and leaves every other member's roster unaffected.

Delete is confirmed in a dialog first, and it is soft. The channel disappears from every member's
roster and from a direct fetch of it, while the row, its transcript, and its Intelligence thread all
survive. That disappearance is live, not just on next load: every member's open tabs drop the row as
the delete lands, and a tab parked on the channel itself is sent home. The deletion is audited as its
own `channel.deleted` row. A channel the deployment package defines is refused, with the reason
named. Recovery today is clearing `channels.deleted_at` in the database directly; there is no restore
control in the product.

The deployment gains two nullable columns, via migration `0016`.

### An MCP server address that points inside the deployment is refused in three more spellings

Adding an MCP server by URL is checked before the address is stored, because that form is otherwise a
Expand Down Expand Up @@ -287,29 +333,6 @@ one they are, so those match too.
No configuration changes and nothing is stored differently; a deployment that was already on the
light theme sees no difference at all.

### A conversation can be deleted

Nothing removed a channel. Starting one was the only lever the product gave a person, and every
conversation with every coworker sat in the roster forever, growing on every message the way
`DEFAULT_CHANNEL_PAGE`'s own note already described: a page that was instant in a demo returns
thousands of rows for anybody who has actually been using the product a while, one that never shrinks
again.

Deleting a channel now removes it for good. The channel row goes, and its memberships, its linked
coworkers, and its Intelligence thread mapping go with it through the same foreign-key cascades that
already existed for them — no migration needed, only a query that finally uses them. The deployment
also asks Intelligence to permanently delete the thread itself, so the message history is not just
unlisted, it is gone from the platform too.

A thread the platform refuses to delete does not hold the channel hostage. The local removal already
committed by the time that call runs, so a rejected or unreachable upstream delete leaves the channel
gone from the roster regardless, with an audit row (`channel.deleted`) naming the thread and whether
Intelligence actually forgot it. A channel that is gone locally with an orphaned thread still on the
platform is a smaller, more honest failure than a channel sitting in the roster with its history
silently wiped out from under it, and the audit trail is where an administrator finds the one that
did not clean up completely. `DELETE /api/channels/:channelId` answers with `historyLeftBehind`, so a
screen showing the outcome does not have to guess which of the two happened.

## 0.0.4

### A click citing a ref this deployment cannot resolve is refused
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,6 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you
| `/bot` | Direct chat with a Bot; `?agent=<id>` selects one. |
| `/skills` | Create and enable personal skills. |
| `/settings` | User preferences. |
| `/admin/connectors` | Configure deployment knowledge sources. |
| `/admin/credentials` | Store write-only encrypted credentials. |
| `/admin/computers` | View, stop, and reset Bot computers. |
| `/admin/boundaries` | Configure browser/file/MCP action policy. |
Expand All @@ -147,7 +146,7 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you
- **Secrets never enter the transcript**: the trail records that a secret was requested and how long it was, not what it said.
- **Bring your own agent**: any AG-UI endpoint is a Bot, on a framework or hand-written. Endpoints are validated with the same target checks used for browser navigation, and an auth header is stored write-only.
- **Components instead of prose**: compiled React components live in `app/src/components/gallery/`, sandboxed ones are authored in `/admin/playground` and published with no deployment. Every call asks the server whether the component exists, is published, and is not withheld from that Bot. Data functions are granted per component.
- **Governed MCP**: Google Drive ships in the catalogue, reached as the person asking. The catalogue carries only vendors this deployment stands behind, so adding one is a review of that vendor. Custom servers must pass URL checks, and any tool not positively classified as a read is treated as a write. A Bot is told which connectors exist here and which it holds, so it says it has not been granted one rather than browsing to the vendor's website.
- **Governed MCP**: Google Drive and Notion ship in the catalogue, reached as the person asking. The catalogue carries only vendors this deployment stands behind, so adding one is a review of that vendor. Custom servers must pass URL checks; unknown tools and custom-server tools are treated as writes, and a catalogue tool the server advertises but does not name as a write classifies as a read. A Bot is told which connectors exist here and which it holds, so it says it has not been granted one rather than browsing to the vendor's website.
- **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer.
- **Sign in with what your company already has**: Google, Microsoft or Okta from the environment, or a company's own SAML or OpenID Connect provider registered while the deployment runs and routed by email domain. Any one turns sign-in on; several may be configured at once.
- **Decide who gets in**: `/admin/people` lists everybody who has signed in, promotes and demotes them, and removes access, which ends the session they are using and stops the next sign-in. Every change is on the audit trail.
Expand Down
17 changes: 16 additions & 1 deletion app/src/components/app-sidebar/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,20 @@ function matchingChannels(
);
}

/**
* Pinned channels first, everything else after, newest activity first within each group.
*
* The mirror of a server rule, not the rule itself: the roster query orders pinned-first and its
* cursor carries the pin, so a pinned channel arrives on page one however long ago it was last
* spoken in. Sorting here as well is for what happens between refetches — the socket patches a pin
* onto a loaded row without moving it, and re-sorts a page by recency alone — which is the same
* reason `byRecency` in use-channel-events.ts mirrors the recency rule. A stable partition, so the
* recency order inside each group is whatever arrived.
*/
export function pinnedFirst(channels: ChannelSummary[]): ChannelSummary[] {
return [...channels].sort((a, b) => Number(b.pinned) - Number(a.pinned));
}

/**
* A roster row that can animate.
*
Expand Down Expand Up @@ -145,6 +159,7 @@ function ChannelRow({
? relativeTime(channel.lastMessageAt)
: undefined
}
pinned={channel.pinned}
/>
</motion.div>
);
Expand All @@ -160,7 +175,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
useChannelEvents();
const [search, setSearch] = useState("");
const searching = search.trim().length > 0;
const visibleChannels = matchingChannels(channels.data, search);
const visibleChannels = pinnedFirst(matchingChannels(channels.data, search));
/*
* FILTERING DOES NOT ANIMATE. Rows exit and relayout on every keystroke otherwise, which is a
* list thrashing under somebody who is still typing — and the moving target is the very thing
Expand Down
Loading