ClipSpacesClipSpaces Docs
Back to app

All releases

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 borderborder-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.