Shared AI chats become real rooms, transactional email, and a Usage tab
Changed
A shared AI chat's thread is an append-only log, not a document. The client treated a thread as
a whole object: load it, cache it, and reconcile the two copies by walking them in step and
comparing position i to position i. That holds for exactly one writer. With two people typing,
the same index is a different message on each side, and a length guard added to cope with the
mismatch made it worse — whenever the local cache held more rows than the server, the server's copy
was discarded wholesale, so a single unsaved turn left that device permanently unable to see
anything anyone else said in that room. Silently. Messages now carry a client-minted uuid from
before the send, reconciliation is a union keyed on that id (mergeById), and the merge keeps the
distinction the length check never could: an unconfirmed message survives, a pre-id legacy row
defers to the server's copy instead of duplicating.
Realtime folds in the row it was handed. Every insert used to trigger a full refetch of the
entire conversation — per message, per client, with a profiles join — while ignoring the payload it
had already received. That refetch is where the flicker, the mid-turn races, and the busyRef
deferral machinery built to paper over them all came from; all three are gone. UPDATE is
subscribed alongside INSERT, and a reconnect does one catch-up read, which is the only full reload
left.
Clip AI takes a visible seat while it's working. A reply used to be generated inside the asker's
browser and written only once complete, so everyone else in the room watched an unanswered question
and couldn't tell "thinking" from "ignored" — and if the asker closed their tab, the answer never
existed at all. The route now writes the question before calling the model, opens an empty
pending assistant row the whole room sees as Thinking…, and completes it when the reply lands. A
run that dies leaves a visible error turn instead of a question that looks skipped; a placeholder
orphaned by a deploy or crash is repaired at read time rather than spinning forever.
Added
The assistant answers to the name its owner gave it. soul.md has had a name field
("what the assistant calls itself") since it shipped, but it only ever reached the model — as a
Refer to yourself as… line in the prompt. The interface went on saying Clip AI regardless, so
an assistant its owner had named Nova would introduce itself as Nova and then appear in every
bubble, every mention chip and the @ picker as somebody else. The name now comes from the space
owner's soul.md across the whole surface, with "Clip AI" as the fallback for a space whose owner
never named one. The owner's, not each viewer's, because the name is wire format: a mention is
stored as the literal @Nova token and the addressing rule decides whether the assistant replies by
matching it — resolved per-viewer, a mention typed by one member would not merely look wrong on
someone else's screen, it would fail to reach the assistant at all. Renaming is additive:
@Clip AI stays a permanent alias, so every message sent before the rename still highlights and
still reads as addressed to it. One field of soul.md is exposed to co-members by a
membership-checked function; tone, focus and avoid never leave the owner's own session.
The assistant has a face instead of a glyph. Its avatar was a Sparkles icon, which reads as
"AI feature" — but in a room the avatar column is something you scan, the way you scan for a
colleague's face down a thread. It's the ClipSpaces mark now, in the bubbles, the agent view and the
@ picker. (Not the paperclip: that is already the "Briefing" badge in the same thread, and one
symbol meaning two things in one view is how a UI stops being readable.)
Agent mode speaks the room's language too. Every group-chat feature was built into the compact
popover and none of it reached the full-screen agent view, so the same shared chat was two different
products depending on which one you were in: agent mode had a plain textarea that only understood
Enter — / did nothing, @ offered nobody, a staged reply vanished when you switched views — and a
thread that rendered four people as one anonymous voice. It now has the @ picker, the slash palette,
the reply strip, typing pings, faces and names on other people's turns, mention chips, quoted
replies, run grouping, delivery state, a members chip in the header, and "…is typing" at the foot of
the thread. Agent mode keeps its own look — the wide prose column, the diagnostics line, the mini
space card are all untouched; the room chrome was styled into that language rather than replacing it
with the popover's. Composer handlers and the reply strip are now literally shared between the two
views, so the next feature can't land in one and miss the other.
Unread badges on the chat list. A room you have to remember to check is a room you stop
checking. A per-person, per-conversation read watermark (ai_reads) backs one RPC that returns
unread and mention counts for every chat in a space in a single round trip. A mention fills the
badge solid — someone needs you is a different message from there's more to read, and shouldn't
need counting to notice.
Delivery state on your own messages. Sent-but-unconfirmed reads Sending…; a write the server
positively rejected reads Not delivered with a Retry, which re-sends under a fresh id rather
than re-attempting a primary key that may since have landed. A thrown request is deliberately not
marked failed: the question is stored before the model is called, so a failed round trip says
nothing about whether the message arrived, and the next reconcile settles it either way. Previously
the only signal was a toast that vanished in four seconds while the message stayed on screen looking
exactly like a delivered one.
Fixed
Room messages are no longer stored as "You: …". In a shared chat the client prefixes each turn
with its speaker so the model can tell four people apart — and the route persisted that prefixed
string, because it read the new turn out of the model history rather than being told what the user
typed. Everyone else in the room read back "You: how do I…" on someone else's message, and the
corruption compounded into the next turn's context. The text sent to the model and the text that is
the message are now separate fields.
An arriving message no longer yanks you to the bottom. The thread followed every new turn
unconditionally, which is right in a solo chat — where each one answers something you just did — and
rude in a room, where it jumps you out of the history you were reading because someone else typed.
Your own turns still always scroll into view.
A delivered message is no longer marked "Not delivered". The route reported one saved flag for
a turn that is two independent writes, so when the assistant's row failed to persist, the user's
successfully-delivered question was flagged — and tapping Retry posted a second copy of a message
the room had already received. The two are now reported separately: only a failed question flags the
bubble and offers a retry; a failed answer says so on its own terms, because retrying the question
would not fix it.
A history read that fails now says so.GET /api/ai returned { messages: [] } on any error,
which is indistinguishable from an empty chat — so a broken query left each device quietly serving
its own stale cache while both looked complete, and two people in one room stopped converging with
nothing anywhere reporting it. It now logs the Postgres error and tells the client the history is
unavailable, which surfaces in the thread rather than being absorbed. This is the same class of
silence as the unread insert() error above, on the read path.
A space invite is now accepted, not merely announced. The email's button said Open the space
and pointed at the app root — a promise it couldn't keep, because the recipient isn't a member until
the invite is claimed, and claiming lived behind a notifications bell they had to find for
themselves. It now reads Accept invite and carries the invite (?space=…&invite=…): the link
raises a prompt naming the space and the role it grants, and accepting pulls the space in and lands
them on it. No new endpoint and no new capability — the same accept_space_invite RPC, which still
refuses unless the signed-in address is the one invited, so a forwarded link is inert in someone
else's inbox. Signed out, the app's one sign-in surface goes up first and the prompt resumes once a
session lands; the invite is never touched on that pass. Decline is deliberately not on the
prompt — dismissing leaves it pending in the bell, where declining lives, so a mis-click in an
inbox can't destroy an invite. A link clicked twice finds no invite the second time and simply opens
the space, which is why ?space= rides along.
Toasts stack from the top. At the bottom they shared an edge with the toolbar and the AI panel —
on a phone, the two surfaces most likely to be under a thumb — while the strip below the 48px bar
sits empty. They now drop in from above and retreat the same way; a notification anchored to the top
that slid up from the bottom reads as the wrong thing moving.
Fixed
A chat that stops saving now says so.supabase.from(…).insert() doesn't throw on a rejected
write — it resolves with { error } — so the try/catch around the AI route's persist() caught
nothing and the error object was never read. A turn could fail to store and leave no trace anywhere:
not in the browser, not in the server log, not in the response. It hid especially well in a shared
chat, because the author's own screen still looked right (a longer local cache outranks a shorter
server thread) and both rows of a turn go in one statement, so the question and the answer
disappeared together rather than leaving a gap. The only symptom was a teammate opening the room and
finding a fraction of the conversation. The error is now read and logged with its Postgres code, and
the response carries saved: false so the panel can tell you plainly — "the others in this chat
won't see it" — instead of letting you find out from someone else. A skipped save (local space,
signed-out, no conversation row) is still not an error, but it's no longer indistinguishable from a
failed one.
The comments sheet's close button no longer lands on the sort pills. The sheet floats its own ✕
at right-4, above whatever the header puts there, so the title row now stops short of it rather
than running underneath — the ✕ was sitting on Newest.
Added
A shared Clip AI chat is now a group chat.20260724000200 made chats owned and private
precisely to stop this — "writing is the author's alone: that is the whole conflicting-prompt
fix" — because a space-wide thread let two people prompt at once and interleave into one history.
The missing piece was never the locking, it was authorship: ai_messages had no author column,
so "who said this" genuinely could not be answered. With author_id (plus the avatars already
shipped) an interleaved thread isn't a mess, it's a room. Private chats are untouched — one
participant, nothing to interleave.
@ brings up the space's people, exactly like / brings up commands, using the same
picker and the same highlighter as comment threads (components/mentions.tsx, promoted out of
components/comments/ so there's one implementation rather than two that drift). Mentions are
chipped in the message and in the input as you type. Three weights, because they answer different
questions at a glance: a solid fill for you, a distinct tint for the assistant, a quiet chip
for everyone else.
Clip AI is a participant you can address.@Clip AI sits at the top of the picker with its
own mark rather than a generated avatar — the list's whole job is telling people apart, and a
fake face for the AI works against that.
The addressing rule: no @ → the assistant answers, as everywhere else. @Clip AI → it
answers. @Ada alone → it stays out of it, because that's a message to Ada. @Ada @Clip AI
→ it answers, since it was addressed too. The asymmetry is deliberate: a false silence costs one
retype, a false answer means you can't say a word to a colleague without the assistant talking
over you. A human-to-human message never reaches the model at all — it's written straight to the
table, so it costs no tokens.
The model is told it's in a room, and each human turn is prefixed with its speaker. Without that,
four people read as one person changing their mind, and it answers "you said earlier…" to the
wrong person.
Replies, Slack-style and flat: a quoted strip above the message, clickable to scroll to the
original, which flashes so you can see where you landed. reply_to is ON DELETE SET NULL, not
cascade — deleting a quoted message must not delete the replies to it; they survive and stop
quoting.
A burst from one person collapses into one block, the way every group chat does it: the face
moves out of the message and into an avatar column beside it, drawn once at the foot of the run;
the name appears once at the top; the clock once at the end, beside the last bubble rather than
under every single one. Same speaker, under five minutes, same day — and never across a reply,
whose quoted strip needs a header to sit under. Before this a four-line thought from one person
repeated their face, their name and the time four times, which read as a transcript of four
strangers rather than one person talking. Solo chats are untouched: with one participant every
message is its own run, so the old layout comes out byte for byte.
Each person's name is tinted with their avatar hue, so the eye can follow one voice down a
busy thread. Mixed 72% toward --text-1 rather than used raw — the palette is chosen for white
text on a coloured disc, and several of those hues are unreadable as text on a light background.
The header says who's in the room — faces and a member count in place of the card count, which
is the wrong fact to lead with once a chat has people in it.
"Ada is typing…", broadcast-only on the channel the messages already ride: a keystroke isn't a
fact worth storing, so nothing touches the database. A draft pings at most once every 2s and a
ping is believed for 5s, so a tab closed mid-sentence can't strand a phantom typist on everyone
else's screen.
Own messages sit right, everyone else's sit left with a face and a name. Realtime now covers
every shared chat rather than only ones you can't write to — that condition made sense for a
read-only window, but in a room the chat's own author was the one participant who couldn't see
anyone else typing.
Two invariants RLS enforces rather than trusting the client for: author_id must equal
auth.uid() on a user turn (otherwise a member could post as a colleague, with their avatar
beside it — a far more convincing forgery than a wrong name), and assistant turns must carry no
author, so nobody can put words in the AI's mouth. The route stamps authorship from the session
and never from the request body, making it the inner of two locks.
Added
Clip AI can read comments, and take you to one. Comments were the last board stream invisible
to the assistant: cards, connections and the content behind links all reached the model, threads did
not — so "what's unresolved here?" answered from the cards and confidently missed the entire
conversation happening on top of them. Threads now travel in the context with their authors, replies
and resolved state, and CHAT_TASK asks it to name whoever wrote a comment it draws on.
Read through the user's own Supabase client, never the admin one, so RLS decides what the
assistant sees — it is shown exactly what the person talking to it can see and nothing more. A
local board has no comments and a failed read degrades to none; the assistant answering without
them beats the assistant not answering.
Every body is fenced in wrapUntrusted. On a shared board a comment is the most directly
attacker-supplied text in the whole prompt — ahead of scraped link descriptions — because anyone
with access types it straight in.
A pin carries coordinates, not a card reference, so "the comment on the pricing card" is a
spatial question. Canvas-anchored threads are matched to the nearest card centre within 320
units; measuring from the top-left corner would hand a pin in the middle of a wide frame to
whatever happened to sit up and to the left of it.
Appended after the cards, and internally capped at 25 threads: fitText truncates from the end,
so a board too big for the model loses its comment threads before it loses the cards they're about.
focus_comment — "where is that comment?" A new action that scrolls the board to a thread and
opens it. Deliberately a separate verb from focus rather than more ids on it: card ids and
comment ids are different address spaces validated against different sets, and comments aren't
React Flow nodes, so there is nothing for fitView to frame. It routes back through the same
focusId path the all-comments Sheet already uses, so "go to this comment" has one definition
instead of two that drift.
Both verbs now animate at one shared FOCUS_DURATION_MS (420ms, in lib/motion.ts). It's long
on purpose — the board is an infinite plane, and cutting to a new position leaves you with no
idea where you went; watching the canvas travel is what preserves the sense of place. A pin and a
card are the same request and must not move at different speeds.
The model is told navigation is the one case where an actions block answers a question rather
than an edit, since the existing rule ("purely informational → prose only") would otherwise
suppress a focus-only reply.
validateOps grew an optional comment-id set that defaults to empty, so focus_comment is
dropped for any caller that doesn't supply one — MCP has no viewport to scroll and exposes no
comments, and a silent no-op there would look like it had worked. A card id can't authorise a
comment focus either.
Fixed
The white outline around every dialog and dropdown in dark mode.tailwind.config.ts mapped
colors.border to var(--border) but never set borderColor.DEFAULT — and Tailwind's preflight
already emits *, ::before, ::after { border-color: … }, which falls back to stock gray-200
(#e5e7eb). That is the colour every bare border utility drew, and Dialog, Popover,
DropdownMenu, ContextMenu and Select all use one. On white it passed for a hairline; on the
#161616 dark surface it was a bright ring around every floating surface in the app. Pointing the
default at the themed var fixes all of them at once and makes border ≡ border-border. The
config comment claiming there was "no global * { border-color } apply" was the mistaken premise;
it now says what preflight actually does.
Added
Comment pins are draggable. A pin was fixed wherever it was first dropped, so a comment about a
card stayed behind when the card moved and there was nothing to do about it but delete the thread
and retype it. Press and drag moves it; the anchor persists and syncs over Realtime like any other
board change. Click and drag are told apart by 4px of travel, so a pin still opens on tap — without
that slop a finger always moves a pixel or two before it lifts and a pin would be nearly impossible
to open on a touchscreen. Pointer capture (not window listeners) keeps mouse, pen and touch on one
code path and holds the gesture when the cursor leaves the pin. Pins already rendered above the
board in their own fixed layer, so they stay on top of every card while being dragged through them;
the one under the cursor is raised above its neighbours so a drag through a cluster doesn't slide
behind the pins it passes. Who may drag mirrors the comments UPDATE policy exactly — author or
space editor — so the UI never offers a gesture the database will refuse, and a write that is
refused anyway puts the pin back where it started.
MCP: create_space, and a spaces:write scope to go with it. An external assistant could edit
the boards you ticked but had nowhere to start a new one — "make me a space for this" meant leaving
the conversation, creating it by hand, and reconnecting to grant it. The new tool makes an empty
space in your account and returns an id edit_board accepts immediately.
The scope is orthogonal to board:write, not a superset, and that is the whole design.
Implying it would mean ticking "let it make new spaces" silently handed the connection edit
rights over every existing space you shared. So: board:write → write the spaces you picked;
spaces:write → make new spaces and write the ones it made. A new
mcp_grant_spaces.created_by_grant column is what distinguishes the two, defaulting to false so
every existing row keeps meaning exactly what it meant, and an unmigrated deploy reads as the
narrower answer.
This is the one place outside the consent screen that may add to a grant's space list, and it
widens by exactly one row for a space that did not exist a moment ago. The owner is always taken
from the token and never from the arguments, and the plan cap still bites — spaces_enforce_quota
is a before-insert trigger keyed on owner_id, so a user at their limit gets the same refusal
here as in the browser with nothing re-implemented.
Consent grew a second checkbox by generalising the first rather than adding a bespoke one:
opt-in scopes and their form fields now live in one OPT_IN_SCOPE_FIELDS map that the page, the
POST handler and the Allow button all read, so a scope the form renders can't be a tick the
server forgets. A connection allowed to make spaces can now be approved with no space ticked
— it has somewhere to work — which is exactly the friction the scope removes.
initialize instructions are now written from the token instead of the fixed string claiming
every connection was read-only, which was already untrue for one holding board:write.
Transactional email — invites, first-run welcome, and a batched comment digest. Three emails
the app sends itself, in the same table-based ink shell as the existing auth templates
(lib/email/shell.ts), so the sign-in code email and the invite are provably one design rather
than two that resemble each other. Delivery is Resend's REST API over plain fetch — the whole
client is one POST with a bearer token, which is not worth a dependency. Everything degrades to
silence without RESEND_API_KEY, the way the rest of the app treats an absent integration.
Invite — sending an invite wrote a row and told nobody; unless the person happened to sign in
and check their notifications bell, a share simply never arrived. POST /api/invites/notify
trusts the caller for the invite id and nothing else: role, space name, recipient and inviter are
all re-read from the database, so it can't be used to mail arbitrary text to an arbitrary address
in ClipSpaces branding. Deliberately separate from creating the invite, which stays a browser
write under RLS — a mail outage must not make a perfectly good invite report failure.
Welcome — sent once, on the first SIGNED_IN. profiles.welcome_email_sent_at is claimed
before the email is composed, with the null check inside the UPDATE's own WHERE clause, so two
tabs racing produce one winner at the database. The migration back-stamps every existing account,
because the alternative is mailing "Welcome to ClipSpaces" to the entire user base.
Comment digest — Figma's model: a thread with five replies is one notification, not five. A
comments_fan_out trigger writes one outbox row per (comment, recipient); /api/cron/comment-digest
sweeps rows that have been quiet for five minutes and sends at most one email per person per
space. @-mentions lead the body and take over the subject line, because being named is a
question addressed to you and being copied on activity is not. Rows are marked sent only after
the provider accepts, so a mail outage retries into a fuller digest rather than a duplicate.
Every value that came from a person — space name, display name, comment body — is escaped before
it reaches markup, and the tests assert a tag-breakout payload survives as inert text. A space
called </td><td> is otherwise how you rearrange someone else's inbox.
profiles.comment_emails (default true) turns digests off per person and is honoured by the
sweeper. The Settings toggle for it is not built — the column and the server-side respect for
it are, so wiring a switch to it is a one-liner wherever you decide it belongs.
Settings → Usage: you can finally see how much of your plan you've used. Every cap was already
priced (lib/billing/plans.ts) and enforced (the triggers in 20260725000100_plan_enforcement.sql),
but nothing ever showed the user where they stood — the first sign of a full plan was a write that
failed, with no way to find out you were at 3 of 3 spaces or 240 MB of 250 before it happened. The
new tab meters cloud spaces, storage, vault cards and Clip AI tokens against the plan in force.
Each allowance is one row — an 18px dial, a label, and a figure — on hairline-separated groups
rather than a card apiece: four bordered boxes read as four separate concerns, and this is one
concern (your plan) measured four ways. The header carries the single closest limit as a
percentage, since that's the one that will stop you first, and one action beside it that becomes
Get more room once anything crosses 80%.
GET /api/billing/usage — the numbers behind it, counted the same way each cap is enforced, so
the meter and the wall agree: spaces by owner_id, vault cards across every space you own, storage
as the sum of metadata.size under your own folder of the cards bucket (the exact field the
storage trigger meters). Read-only and advisory — nothing gates on it, enforcement stays in Postgres.
The dial has three faces, because there are three things to say: an arc for a real proportion
of a real cap, a broken ring for an allowance with no cap at all (a full ring would read as at
the limit, an empty one as none used), and a bare track when the read failed — which says
Unavailable in words and admits your limit still applies rather than implying you're at zero.
Signed out, there is nothing to meter at all: local boards live in the browser and cost nothing.
Changed
The Clip AI token meter moved out of the Clip AI tab and into Usage, so every allowance reads in
one place instead of one meter per tab. Its bring-your-own-key case is unchanged: on your own key
you're unmetered, and it says so rather than showing 0%.
Storage figures use two formatters on purpose — a consumed value keeps its decimal (22.4 MB)
while the cap stays a clean whole unit (250 MB), matching the wording of the limit errors the
same numbers produce. The figure is set in JetBrains Mono with tabular figures so the digits
line up down the column; its unit and the rest of the phrase stay in the UI font, because setting
the whole line in mono turns a sentence into a terminal readout.
Meter colour is a three-step status scale — --live green with room, amber from 80%, red at the
wall — reusing the tokens the app already reads as good/warning/stop rather than giving each
metric a decorative hue of its own.