Feat/persistant chat - #41657
Conversation
… bar Move the conference chrome from a vertical side rail to a control bar along the bottom of the call — the position third-party providers put their own toolbar in, so an embedded provider and the future native conference read the same. `SideRail`/`SideRailPanel` are renamed and split into `CallBar` (+ `CallBarActions`, `CallBarAction`) and `CallPanel`, so both names describe their position and can be reused by the native video conference. The page is now a column — a row holding the call and the panel, then the bar — so `CallPanel` is a sibling of the call area rather than a child of the bar. Toggling the chat animates its own width without ever reflowing the bar, by construction instead of by careful sizing. The panel moves to the inline end to match the chat action, which puts its close button at the far end of its header. `CallBarActions placement='end'` is taken out of flow and anchored to the inline end, so adding or removing end actions never pulls the centred controls off-centre — the shape the native conference needs once it owns mic/camera/ screen-share/hang-up. Add `useProviderCallBridge`, letting a provider that renders its own toolbar hide ours and drive the chat panel (`set-call-bar-visible`, `set-chat-visible`, `toggle-chat`) so the user never sees two competing sets of controls. The iframe is cross-origin, so `event.origin` cannot be allow-listed against our own origin; instead every message must come from the exact window we embedded, which no other frame or tab can forge. Unknown commands and malformed payloads are rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A call belongs in its own window rather than a tab in the user's strip — it mirrors what the desktop app already does with its dedicated video window, and keeps the call visible while the user works in the main app. `useVideoConfOpenCall` now tries a popout (1280x800, capped to the available screen and centred) and falls back to an ordinary tab when it is refused; some browsers and extensions block popup-shaped windows while still allowing a plain one. Only when both are blocked does `VideoConfBlockModal` ask the user to allow it. A window that comes back already closed counts as blocked too. `noopener` is deliberately absent from the features string: the conference page posts navigation requests back to its opener, and `noopener` would both sever that link and make `window.open` return null — indistinguishable from a blocked popup, so the popout would silently degrade to a tab forever. There is a test pinning that. Reusing the shared conference window is unchanged, and now covered: already showing this conference focuses it without features, so a window the user has arranged is not resized or recentred; a different conference navigates it; a closed one is reopened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…loading `UsernameCheck` shows `HomeSkeleton` — a sidebar list, room and composer skeleton — while it resolves the user. It sits in the shared authentication chain, so the conference, which renders standalone, was flashing a whole fake app shell it never goes on to show. This is the same shape of problem as the navigation chrome itself: the chrome moved to `MainLayout`, but its loading counterpart stayed behind in the auth chain. `AuthenticationCheck` and `UsernameCheck` now take an optional `loading` node. It still defaults to `HomeSkeleton`, so no existing route changes, and the conference route passes `PageLoading` instead — which also matches what the conference shows while joining, making startup one continuous state rather than a skeleton followed by a spinner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chat panel is 400px, but it renders the full room UI, which is spaced for the main app. The default message start padding plus the avatar gutter's left margin spent more of that width on empty space than the panel can spare, and the composer sat flush against the edges — opting into the embedded layout zeroes its inline padding, which is sized for the tiny `?layout=embedded` iframe where every pixel counts. `narrowRoomStyle` restores the composer's padding and trims the message start padding and gutter margin, scoped to this subtree so the room's normal full-width appearance and every external embed are untouched. Only the start padding is trimmed; the message toolbar and timestamp column sit against the end padding and need the room. The panel header is condensed to match. The call iframe is named with `aria-label` rather than `title`: a `title` on a full-viewport iframe also renders as a hover tooltip, floating a label over the call for as long as the pointer is inside it. Also documents the popout ladder and the standalone loading placeholder from the two preceding commits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Documents the verified add-participants behaviour per room type, and the agreed design for separating "being in the call" from "being able to read the chat". Adding someone to a conference currently puts them in a room — the conference's room, or a fresh discussion — and join authorization is then derived from room membership. That conflates two different things. The planned model makes the existing `users[]` the membership list with a per-entry `joined` flag, authorizes on room access *or* conference membership, rings the added users, and surfaces members who can't see the chat with a remedy instead of forcing that choice up-front. The section is fenced as planned-not-implemented and doubles as the progress tracker. It records the decisions behind the design, and the traps each phase has to clear — notably that `addUserById` uses `$addToSet` with a whole document (so marking a member joined later would append a duplicate rather than update), and that every `users[]` entry already stored predates the flag and represents someone who joined, so a reader filtering on it would show historical conferences as empty. Also documents the verified per-room-type matrix for the current flow, including the divergence where a repeated "don't keep history" add rebuilds from the original room and drops earlier invitees. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding someone to a conference used to put them in a room — either the conference's room, keeping its history, or a fresh discussion — because joining was authorized purely from room membership. That conflated being in the call with being able to read its chat. `users[]` becomes the conference's membership list, with a per-entry `joined` flag and `joinedAt`; `ts` keeps meaning "became a member". `join`/`info` now authorize on conference membership *or* access to the conference's room or discussion, so someone added from outside the room can join the call without being handed the room's history. `add-participants` registers members, touches no room, drops `keepHistory`, and returns the ids it added. Two traps this had to clear. `addUserById` used `$addToSet` with a whole document, which compares entire objects — once an entry can be mutated, a second write would append a duplicate instead of updating. It is replaced by `addMemberById` (a `$push` guarded on `users._id` in the query, atomic and idempotent, which also removes a racy in-memory dedup) and `setUserJoinedById` (`$set` through `arrayFilters`). And every `users[]` entry already stored predates the flag while representing someone who joined, so `hasJoinedVideoConference` reads an absent flag as joined and is the only place that decides — no reader tests the field, and no backfill or deploy ordering is required. Every reader that means "who is in the call" now filters through that helper: the message block's counts, name list, avatar stacks and empty state, the call history list's avatars, and the direct-call ring-stop check. The `video-conference` stream's `allowRead` deliberately keeps using full membership, so someone added can follow `discussionUpdated` before joining. `assignDiscussionToConference` now subscribes the union of the conference's members and the original room's members, rather than only those who had joined. Ringing is decided per call event against the list being rung, via `shouldRingVideoConference`. Starting a call rings the room, so a large room still rings nobody; an add rings only the people added and is capped at the same limit, so an add always rings. This also makes server-originated ringing work at all. The server has long set `ringing: true` on group conferences and broadcast a `ring` action, but no client ever handled it — the only match for `'ring'` in the client was a word in the E2EE wordlist. Real DM ringing is driven client-side by the caller repeating `call` on an interval. `VideoConfManager` now handles `ring`, which means group conferences that were silently not ringing will start to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Declining a conference is now stored on the member's `users[]` entry as `declined` / `declinedAt`, through `POST /v1/video-conference.decline`. The endpoint takes no target user and writes only against the caller's own membership, so declining can never be a way to end someone else's conference. The client-published `rejected` message stays, because the 1:1 flow depends on it — the caller's client is waiting on it to stop dialling — but it is a claim one client makes about another user's call, so it is no longer what gets stored. Someone rung as a member of the conference's room has no entry yet, so declining creates one; without it there is nowhere to record the decline. Declining is not exclusive with joining — a member can decline and then join later. Adds a regression test pinning that a rejection for a call we are not placing never reaches `video-conference.cancel`. That currently holds only because `onDirectCallRejected` bails when the callId doesn't match `currentCallData`, and the teardown sits behind that same guard, so widening it would silently let one person's decline end everybody's call. Also extracts `mapVideoConfUserFromApi`: membership now carries three optional timestamps, each arriving as a string over REST, and deserializing them one at a time meant every new field surfaced as a type error at a distant consumer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
These assert the shape of the update document rather than its effect, which is the level the bug they exist to prevent lives at: `$addToSet` on a whole document stops de-duplicating the moment an entry can be mutated, so the guard has to be in the query. A regression would reintroduce duplicate member entries, which is invisible until someone counts participants. Also pins that declining does not clear `joined` — a member can dismiss the ring and join later — and that `joinedAt` is omitted rather than stored as undefined. `BaseRaw` is stubbed because it participates in a circular import that leaves it uninitialized when this module is loaded directly by jest; only its constructor matters here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The call-history page could only ever record VoIP calls: `CallHistoryItem` was a union of two `type: 'media-call'` variants, both 1:1-contact-shaped. A conference is room-and-many-participants shaped, so it joins the union as a sibling variant, `IVideoConferenceHistoryItem`, carrying the room, the conference title, and how many members actually joined. Ending a group conference writes one item per member, since history is per-user. `buildConferenceCallHistoryItems` is kept pure and DB-free so the per-member semantics are unit-testable: `direction` is outbound for the creator and inbound for everyone else, and `state` is `ended` for a member who joined, `not-answered` for one who did not. Only members get an item and a member either joined or was rung and didn't, so not joining is not answering — whether they declined or just ignored it. Reporting an ignored ring as a normal ended call would hide a missed conference. `setStatus(ENDED)` is public and reachable more than once for the same call, so the write is guarded on the call not already having ended; without that, every member collects a duplicate entry. A history failure is logged and swallowed rather than failing the end of the call. Every item type shares one collection, so the existing direction/state filters needed no change; only the free-text search gained a branch matching the conference title. The table dispatches conference items to a new row showing the room and joined count instead of a contact. Two type holes that widening the union opened are closed here too: `MediaCallHistoryExternal` typed its payload as `Exclude<CallHistoryItem, IInternalMediaCallHistoryItem>`, which silently began including conference items and would have broken `item.duration`, and its `isExternalCallHistoryItem` guard tested `type !== 'media-call'` — previously dead, now wrong. Both are narrowed to the external media-call variant they mean. Deep-linking to a conference item's detail route still falls through to the generic "call info could not be loaded" panel; a conference detail view is out of scope and noted in the docs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conference membership deliberately grants no room access, so a member can be in the call while unable to read its chat. Rather than forcing that choice on whoever adds a participant, it is surfaced once it actually matters. `video-conference.info` reports `membersWithoutChatAccess`, and `POST /v1/video-conference.share-chat` fixes it. Access is asked per member with `canAccessRoomIdAsync` rather than derived from subscriptions, because reading a room doesn't always require one — a public channel is readable by anyone unless it belongs to a private team. Conferences are small enough that getting that case wrong matters more than the extra reads. The remedy asks the room whether it can take new members — `allowMemberAction(room, RoomMemberActions.INVITE)` — instead of testing for a DM. The room type owns that rule, and it covers cases a `t === 'd'` check misses, such as a federated DM that can grow. A room that can take members is joined; one that can't has its chat moved to a discussion carrying everyone. `ChatAccessNotice` renders in the chat panel, where the remedy is in context, and moves up to the conference page while the panel is closed — so it can't be missed, and is never shown twice. This also closes the divergence documented earlier: `createConferenceDiscussionWithParticipants` now builds from `discussionRid || rid` rather than always the original room, so a second discussion no longer silently drops everyone added since the first. It is reached as a remedy now, which is why the fix matters again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Captures what isn't visible from the code: that group ringing was dead code before this (the server broadcast `ring` and no client handled it, so enabling it makes conferences that were silently not ringing start to), that declining creates a membership entry because there is nowhere else to record it, where the test coverage lives and why those runners, and the gaps left open — no members panel, no conference detail view in call history, one access check per member on info, and the 10-user cap per add that guarantees an add always rings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Looks like this PR is not ready to merge, because of the following issues:
Please fix the issues and try again If you have any trouble, please check the PR guidelines |
🦋 Changeset detectedLatest commit: 9e8f454 The changes in this PR will be included in the next version bump. This PR includes changesets to release 18 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## develop #41657 +/- ##
===========================================
+ Coverage 68.73% 69.28% +0.55%
===========================================
Files 4155 4212 +57
Lines 159672 163491 +3819
Branches 28010 28911 +901
===========================================
+ Hits 109743 113281 +3538
- Misses 44753 45045 +292
+ Partials 5176 5165 -11
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
Ringing on add is the first case where a call rings someone with no access to the room it belongs to, and the incoming popup was built entirely around that room: it read it from the client store and returned null when it wasn't there, while still calling focusManager.focusFirst() — which then looked for the parent of a node the focus scope never got. Incoming popups now render without a room, describing the call from the conference record the popup already fetches. The popups that act on a room, starting or placing a call, still require one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1:1 accept is a handshake: the callee publishes `accepted` and waits for the caller's client to reply `confirmed`, giving up after 5s. A server-originated ring has no caller waiting, so running that handshake left a user who had just been added staring at "No response from remote user". Incoming calls now carry a `handshake` flag. Without it, accepting joins the conference outright — membership is what authorizes joining — and declining records the decline without publishing `rejected` to whoever added them, which their client would read as their own call being turned down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… chat The notice for members who can't read the chat applied the only remedy the room allowed, behind a "Give access" button, on a hand-rolled banner with unreadable contrast. Both ways out give something away, in different directions: inviting exposes the room's whole history to an outsider, while moving the chat to a discussion leaves the earlier history behind for everyone already there. So the notice now opens a modal that names the members and spells out each consequence next to its own button. Which action leads is a privacy judgement — private rooms and DMs lead with the discussion, public rooms with the invite — and `share-chat` takes a `mode`, re-deriving `canInvite` server-side rather than trusting it. The banner is now the one rooms use for announcements, so it inherits readable contrast. `AnnouncementBanner`'s className was too narrow to accept css output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…age not found" A conference member with no access to the call's room got the generic not-found panel — "The page does not exist or you may not have access permission" — for a situation that is neither an error nor theirs to fix. The server already reports who can't read the chat, so the panel checks for the current user and says what's going on, rather than letting the room fetch fail and interpreting that. The notice offering to share the chat now hides itself from those same members: they are who it would be shared with, and share-chat can't work from their side anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Inviting the missing members leaves the conference record untouched — only who can read the chat changed — so nothing told the participants to look again and the notice stayed up until a reload. Only the discussion path broadcast anything, because moving the chat is a change to the record. Adds `chatAccessUpdated` alongside `discussionUpdated`: the two ways the chat can change under a participant, both answered by reading the conference again. The participant who acted also invalidates locally, rather than waiting on the round trip to see their own notice go away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The modal reads the conference's room from the client store to exclude people already in it. That room is only seeded for participants who can read the chat, and the autocomplete was gated on having it — so it stayed permanently empty for exactly the members the membership model exists to serve. The exclusion list is best-effort now: no room means nobody is excluded, and adding still works, since the endpoint only needs the call id and usernames. Also stops the modal reporting success for a no-op. Users already associated with the call are skipped server-side, and `add-participants` says which ones it actually added — selecting only existing members rang nobody. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Being added rings you, but the ring only reaches a client that is on screen and it fires once — someone on another tab, or with no client open, had no signal at all beyond finding the call in their history afterwards. The secondary "you can see the chat now" path had a desktop notification; the primary one didn't. It deliberately carries no room name, which is what stops the click from navigating: membership grants no room access, so the room behind the call may be one they can't open. Clicking focuses the app, where the ring is; the "Join call" action joins the conference itself. The payload's room fields become optional for that reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stream authorized only against the conference's membership list, so a room member who opened the conference before their join landed was refused — and a refused subscription is never retried, leaving them deaf to the call's updates for the rest of the session. It only worked because joining happens to precede the window opening. Now it accepts membership or access to the room the chat lives in, the same pair `video-conference.info` accepts. Both halves are needed: members may have no access to the room the call started in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Which way of sharing the chat leads was written twice — as the modal's primary button and as the server's default — and they had already drifted: the server defaulted to inviting whenever the room allowed it, including for private rooms the modal deliberately leads away from. `chatAccessLeadsWithDiscussion` and `resolveChatAccessMode` are pure functions both sides now use, so the rule is stated once and tested directly. The service itself resists unit testing — proxyquiring it means stubbing some thirty modules, one of which opens a Mongo driver at import time — which is the reason to move the decisions out of it rather than to leave them untested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pins the behaviour that had none, and that the last few fixes turned on: - accepting a server ring joins outright while accepting a 1:1 call negotiates, and declining a ring records the decline without telling the adder their own call was rejected; - the chat-access notice is shown to participants who can act on it and hidden from the members it is about; - both conference stream events refetch the conference, and the chat panel follows it into a discussion. Each was checked against the bug it guards: the stream test fails with either subscription removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ntation The "Adding Participants" section still described the keep-history choice and its per-room-type behaviour, which the membership model removed — the most misleading kind of stale, since it read as current. Rewritten around what adding does now, with the old flow kept as history because the divergence it had is worth remembering. Also corrects the access-control section (every endpoint shares one check that accepts membership, so add-participants no longer disagrees with join), the endpoint and stream tables, and records what the audit turned up that is not worth fixing yet under Improvement suggestions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Membership without room access is the feature's premise, so it is worth recording that it was confirmed in a real database and not only in tests — and worth being explicit that the decline path has not been observed there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Group conferences left no call-history entry in practice. The write hung off `endCall`, which runs when something tells Rocket.Chat the call is over — an app provider posting ENDED, or a direct call hanging up. Nothing in the Jitsi app does that, so its conferences sit at STARTED until `videoConferencesCron` expires them past their 24h TTL, and `expireCall` wrote no history at all. Expiry isn't an edge case, it's how a conference normally finishes with a provider that doesn't report the end back — so both paths write now. The repeat guard moves into `shouldWriteConferenceHistory`, which both share: each path can fire more than once for one call, so a call that already carries `endedAt` is refused. Conferences expired before this cannot be backfilled — they already carry `endedAt`, indistinguishable from ones already written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The membership model has been invisible until now: a decline was recorded, a member added from outside the room was counted in aggregate, and neither could be seen against a name. A members panel opens in the side panel — by default, because on arriving in a call the useful question is who else is here, and for the caller of a call still ringing it is the only place that answers it. The panel and the chat share that space, so only one is open at a time and a new bar button switches between them; the provider bridge's chat commands act on the chat and leave another panel alone rather than closing whatever happens to be open. Adding people moves here, next to the list of who is already here. Each member carries one status. Beyond invited/joined/declined there is **left** — we track `leftAt` now, and without it someone who joined and left would read as still on the call. The entry accumulates rather than replaces, so the fields are read in order of what happened last: being in the call beats everything, having left beats an earlier decline since they did answer. Anyone not currently in the call can be rung back, one at a time, so `video-conference.ring` takes an optional `users`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Leaving is reported on `pagehide`, which fires on a reload exactly as on a close — indistinguishable from it — so refreshing the call window ended the call when nobody else was in it. The emptiness is now confirmed after a grace period instead, which coming back cancels by simply being in the call again. That also absorbs a network blip taking the window down for a moment. Joining also invalidates the conference read: the broadcast announcing it can beat the stream subscription being established, which left the members list showing the user themselves as absent until something else moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… it can be `getChatAccess` asked `canAccessRoomIdAsync` once per member, on every read of the conference — which is now also refetched whenever the membership or the chat changes, so the cost is paid often. A plain public channel and a plain private room are answered from a single `Subscriptions` query for all the member ids: the public one is free for everyone not explicitly banned, the private one needs a real subscription. The per-member path stays for the cases where access does not reduce to a subscription, and those are not exotic: a public channel in a team grants access through team membership, and only `teamDefault` channels auto-subscribe, so a team member routinely has no subscription while still being able to read. A discussion delegates to its parent room's rules, recursively, and an ABAC room can bypass subscriptions entirely. Reimplementing any of that here would be a second, drifting copy of the authorization rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`addMembers` and `declineCall` were exercised only through the UI, and the endpoints only by hand — the latter being where the interesting cases live: authorization by membership, the real Mongo writes, the room-mutating paths. Unit specs pin what the two methods must not do as much as what they do: an add must skip a user who already has an entry rather than overwrite their state, and must ring nobody when it added nobody; a decline must never end the conference, which is what separates it from rejecting a 1:1 call. The desktop notification is asserted silent and room-less, so a call can't announce itself with the new-message sound or offer to open a room the member may not be able to see. The REST suite follows the provider-app harness and is EE-gated. It has not run locally — the API suite authenticates as a fixture admin a real development workspace doesn't have — so it is type-checked only and its first CI run is the real one. Said so in the docs rather than implying otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The document had grown a completed roadmap, suggestions that had since been implemented, and gaps that had since been closed — all of which read as if they were still true. The phase-by-phase plan is gone; it is git history, and what mattered about it was the reasoning, which stays. "Known gaps" becomes "deliberate limits", since what was left in it were choices rather than oversights. The suggestions list now holds only what is genuinely unfinished, and the members panel, the panel switching and the newly cheap chat-access reads are described where the rest of the behaviour is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…access banner Six changes to the call chrome. The members panel now reads like the room's own members list — avatar, name, `@username`, presence — and splits into **in the call** and **not in it**, because the halves answer different questions. A section nobody is in isn't shown, and members in the call need no label beyond the section they are in. No search box: fine at conference scale, and noted as the shape to copy if that changes. A member's ring button appears only when there is something to ask for: not while they are in the call, and not while their phone is already ringing. Knowing which required recording the ring — `ringingAt` on the entry, written by every ring including the one that starts a direct call — so it is knowable to everyone rather than only to whoever pressed the button. A ring stops on its own with nothing to announce it, so each row wakes itself when its window is up. The chat-access banner counts only members who have **joined**. Someone merely invited may never turn up, and a banner about a person who isn't there asks everyone else to fix a situation that hasn't happened. It also moves above the call and both panels: it is about the call rather than about whichever panel is open, and one that moved as panels changed would read as a different message each time. The bar gains two counts — how many people are in the call, and what is unread while the chat is closed. The unread one goes through the sidebar's own `useUnreadDisplay`, so a mention reads as urgent in both places and a muted room stays quiet in both; the members count is `secondary`, because a count of who is here is information and a red badge would read as a problem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… an icon The unread badge never appeared. Both the subscription seeding and the `subscriptions-changed` watcher lived inside the chat panel, and that panel stopped being mounted when it stopped being the only one — so with the members list open by default, the badge had no data and nothing to refresh it. Keeping that subscription is the page's business now: the badge exists precisely when the panel is closed, and nothing else on the page would do it, since the conference renders outside the main app and the sidebar's watcher never starts. The "no chat access" tag becomes an icon beside the member's name. It qualifies who that person is in the call, and a second line pushed every row apart for something most members never carry. Verified live: a member added from outside the room shows exactly one icon and no banner — he hasn't joined — and a room with unread renders both badges, the count of people in the call and the unread count, with the tab title agreeing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`AuthenticationCheck` decided from `useUser()` alone, which is null while a stored session is still being resumed — so it read "no user yet" as "not logged in" and rendered the login form. In the main app that window is invisible, because the session is already in memory. A call popout is a fresh window: it showed a login form for a few hundred milliseconds to someone who never asked for one, then replaced it with the conference. It now waits on either signal that a resume is under way: `isLoggingIn` once Meteor has started one, or a stored login token for the instant before that, where nothing is in flight yet but a session plainly exists. The placeholder is the one the route already supplies — the conference passes its own, so it still never flashes an app skeleton — and defaults to what the chain showed before. The two ways out stay reachable: a stale token is cleared when the resume fails, landing as an ordinary logged-out visitor, and a forced login goes straight to the form regardless of what is in storage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two requested features recorded before either starts: ongoing calls listed in the sidebar with join and decline, and getting back a call you declined from the call history. Records why call history can't source the "calls I can join" list as it stands — a history row is written when a conference *stops*, so an ongoing call has none — and that the concern behind the suggestion, not inventing another persisted concept, is better served by asking the conference collection, which already stores membership and liveness. That leaves the history side as a presentation question rather than a data one. The four open questions are now settled, and two of the answers do real work: hiding calls nobody is in is what keeps a channel-wide audience bearable and what makes a call abandoned for another disappear on its own; and declining hides the call from the sidebar while the history keeps it, which is what makes the history the route back rather than a second copy of the same list. The sharpest consequence is that "one call at a time" is not the same as the shared window replacing a page: the abandoned call has to be left explicitly, or its participant stays in it and it never empties. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ringing was the only route into a call someone didn't start, and a poor one: it is one-shot, it lasts seconds, and a conference started in a room with more than ten subscribers rings nobody at all. So there were calls a user was entitled to join with no way to reach them, and a call they declined was gone for good. A sidebar section now lists the calls running that this user may join, with join and decline; the call-history page lists the same calls above the past ones, and unlike the sidebar it keeps the declined ones — which is what makes it the way back to a call that was turned down. Nothing new is stored to support it. `listJoinableCalls` asks the conference records, which already hold membership, liveness and the room, and scans running conferences rather than the user's rooms, so its cost follows how many calls are in progress. A sparse index on `endedAt` keeps that to the live handful. Calls nobody is in are left out, or a conference nobody ended would be advertised for a day. Three choices worth naming. The audience is room *membership*, not room access: a public channel is readable by anyone, and a call in a channel the user never joined has no business in their sidebar. A direct call is named from the reader's own subscription, since a DM has no name of its own and is named after the other person. And joining another call posts the leave **explicitly** — the shared window already replaces the first call's page, but replacing a page is not leaving a call, and without the leave the abandoned call keeps counting its participant and never empties. Liveness is polled every 20s rather than pushed. Announcing a call to everyone who could join it means a broadcast to every subscriber of its room, which is the same fan-out that makes ringing a large room impossible — the problem this works around. Documented as a trade, with the cheaper push noted as follow-up. Verified live end to end: the sidebar listed running calls by name with the right actions, declining removed it there, and it then appeared in the call history marked declined with a way back in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sidebar listed the call the reader was already in, which read as "in call" and offered nothing to do about it. Those rows are gone: the list is only calls to join or turn down, newest first, three at a time with a toggle for the rest. Joining now also reconciles presence server-side. A window that dies without reporting its departure left its user counted as present forever — five running calls each thought they still had them — so `addUserToCall` leaves every other call the joiner is still counted as being in. Also renders the leave-and-join confirmation through `Trans`, so the name of the call being left is emphasised rather than showing its markup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two reports, both about a call window that never got to speak for itself. The join is posted by the main app, before the window opens, so accepting a call and closing the window while it was still loading left the user listed as present in a call they never saw — holding it open. The opener now watches the window it opened and reports the leave when it goes. And "Start a call" stayed on screen behind the call it had started: a group call closed it via `calling/ended`, but a direct one keeps ringing and never emits that. It now closes itself once the call has its own window. `MockedAppRootBuilder` grows inert video-conf capability/preference reads — every popup does them just by rendering, so throwing failed such a test on the render rather than on anything it meant to assert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The call window opens the moment someone asks for it — it has to, or the browser is entitled to refuse it — but being in the call is a separate decision. It now shows a preflight first: a self-view, what the call is called, the devices you will arrive with, and a name field for whoever started a group call. That is what makes the join honest. `joinCall` posts nothing under persistent chat; the conference page joins from the preflight, so mic and camera reach the provider's URL and presence is recorded when the user actually enters. The room's popups stop asking about devices, since the question is now asked where the user can see themselves. Naming a running conference is new (`video-conference.rename`, the creator of a group call only). It is what the provider is told to call the meeting, read at join time, and what the sidebar and history list it as. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Creating a direct call was asking the other side to answer it, while the caller was still on the preflight choosing a camera — answering into an empty room. Now `startDirect` rings nobody and sends no push; the ring goes out when the caller actually arrives in the call, to members who have never been rung, so a rejoin rings nobody and a second attempt stays the call window's own "ring again". The preflight says so before it happens — "Alice will be notified when you start the call", and the button is the call rather than a join. With persistent chat off there is no preflight to wait for, so the caller's client keeps doing the 1:1 handshake ring from the room, unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lick Clicking call posted a message in the room, rang people and wrote a call into everyone's history — for a call the user might still walk away from. Now the click only opens the window, at /conference/new?rid=…, and ConferenceStartPage runs the preflight against the room: the name comes from the reader's own subscription, the devices from the capabilities endpoint. Confirming is what starts the call, and cancelling leaves no trace. Two bugs found while testing this live: The context still pointed `startCall` at VideoConfManager — the callback that opens the window instead sat unused, so nothing changed for the user. Covered by VideoConfProvider.spec now. And `useConfinedNavigation` treated the move from /conference/new to /conference/:callId as an escape attempt, shipping the started call to a new tab and leaving the preflight spinning. It now allows a conference and nothing else. Naming is editable again on the way in: the field carries its value to `start` for a new call, or to `rename` for one that already exists. Cancel sits beside the confirm button and closes the window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A self-view promises a choice the preflight cannot make: all a provider can
be told is whether to start with camera and microphone on, never which
devices to use — so the preview could show a camera the call never ends up
using. The tile now states what will happen ("your camera is turned off" /
"your camera will be on") and says device selection happens inside the call.
Drops `getUserMedia` entirely with it: no permission prompt, and no camera
held open moments before the provider asks for the same one. The tile is
where a real preview goes once a native provider can take a device per
stream.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The history page had two lists of calls: a section of ongoing ones above
the table of past ones, each with its own idea of what a row looks like. A
call in progress is a call that happened — it just hasn't finished — so it
now belongs to the same list, told apart by an `ongoing` status and by
having a Join button where the others have their menu.
That needs the history written when a call starts rather than when it ends.
`recordConferenceInHistory` upserts an item per member as the conference is
created, again whenever membership moves, and once more at the end, where
each member's own outcome (`ended` / `not-answered`) is finally known.
`{ uid, callId }` is already unique, so the write is an upsert and repeating
it is harmless — which is what let the old `endedAt` duplicate guard go.
Two things fall out: someone who was rung and never answered has a row
while the call is still running, so turning a call down is not the end of
it; and the client reads one source again, with `ongoing` a state like any
other in its filters.
`ongoing` joins the shared `CallHistoryItemState`, so the VoIP renderers
that switch over every state render it too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An incoming call took over the screen with a popup that had to be dealt with before anything else could happen. It is now the first item of the list the other calls are already in — under its own heading, bigger than the rest, with accept then decline below it in the same order as the join and dismiss underneath. The ring still sounds, and silencing it stops that sound without answering: the call stays where it is until the user decides. Two knock-on changes make the list the one place calls live: The room's call button opens the call window directly. It used to open a popup to confirm and pick devices, which the preflight now does with the user able to see what they are joining. `navbar/NavBarItemOngoingCalls` stands in for the sidebar's dock when there is no sidebar on screen: the same list in a dropdown, blue for calls that are running and red while something is ringing, opening itself on a ring — a ringing call the user has to go looking for is a missed call. Each item now shows who is in the call rather than how many: three avatars and a `+N`, from participants the joinable list carries. The list scrolls instead of growing without end, and the show-all toggle names its total. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The screen serves four situations and looked the same in all of them. It now leads with a title — "Start a new conference" / "Start conference with Alice" when nothing exists yet, "Join the conference" / "Join conference with Alice" when it does — which took replacing the single `confirm` prop with an `action` and an `isDirect`, since a call *with someone* reads differently from one *in a room*. The rest follows from having a title: the room's name is no longer repeated under the camera tile, and the name field lost its caption, since the field is its own label. It moves above the tile — it is the one thing here about the call rather than about how the user arrives in it — and a conference that doesn't exist yet is offered as "Meeting in <room>" rather than the room's bare name. The mic and camera toggles move inside the tile, bottom-centre, where a preview's own controls belong; the preflight's copy of `CallBar` existed only to hold them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing here changes what the feature does. It removes code that no longer earns its keep after several rounds of feedback added a mechanism each: one event where there were three, one prologue where there were six, one notification builder where there were two. - Three stream events (`discussionUpdated`, `chatAccessUpdated`, `membersUpdated`) become one `video-conference.updated`. The only subscriber registered the same callback for all three and never read the one payload, so the answer was always "read the conference again". That also collapses `notifyVideoConference`'s type gymnastics, which existed to keep three argument tuples apart. - The six conference endpoints shared a verbatim load-and-authorize prologue; `loadAccessibleConference` now owns it, and returning the caller's id with the call retires the dead `|| !userId` narrowing. - The two desktop-notification builders differed in four values, so they take four values. Whether a **room** travels is the real distinction: that is what lets the click navigate, and someone merely added to a call can't be sent to a room they may not read. - `VideoConfDeclineProps` and `VideoConfLeaveProps` were character-for-character copies of `VideoConfCancelProps`; one `VideoConfCallIdProps` serves all three, keeping the published name as an alias. - `JoinableVideoConference.rid`/`.type` had no reader: a list needs enough to decide whether to walk in, and joining goes by `callId`. - `VIDEO_CONF_RINGING_LIMIT` existed twice with comments requiring the two to be equal, and "has this member been asked yet" was spelled out on both sides of the ring. Both now live in one place. - `addMemberById` stops taking `joined`/`joinedAt`: being a member is not being in the call, and `setUserJoinedById` ran unconditionally next to every caller that passed them. `addAbsentMember` names the two paths that need a member who isn't present. - `leaveOtherCalls` asks Mongo for "still in it" instead of reading every membership and sifting in JS; `shareChatWithMembers` no longer re-reads the conference for usernames `getChatAccess` already had in hand. Two deliberate behaviour changes, both small: the invited notification had `audioNotificationValue: ''`, which played the *new message* sound for a call — the bug already fixed in its sibling — and its payload `_id` is now the call rather than the room, matching the other path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The call window grew its own versions of things the app had already solved. This puts them back on the shared ones and deletes the copies. - `AddParticipantsModal` hand-rolled an autocomplete, an options list, selected rows and remove buttons. `UserAutoCompleteMultiple` — what the room's own "add users" flow uses — does all of it; it only lacked a way to leave people out, so it takes `exceptions` now. Picking people in a call looks like picking people anywhere else, and the modal is a third of its former size. - `ConferenceRoomPreload` fetched the room, stored it, and marked the cached stores ready — but `useOpenRoomById` fetches that same room a moment later, so the panel issued two `rooms.info` for one room. Only the ready-marking was ever unique, which is all `ConferenceStoresReady` does now. (The room UI's own not-subscribed rule now applies to the chat panel instead of being bypassed by the seeding; every ordinary user has `preview-c-room`, so this shows up only where it was revoked.) - The chat and members panels had byte-identical headers → `CallPanelHeader`. - The two call rows had the same head — icon, name, who's in it → `CallSummary`. - "Is this ringing still ringing?" was two copies of a throwaway state plus a `setTimeout` → `useRingingExpiry`. - "Can this user read the chat?" was computed three ways from two prop shapes → `hasConferenceChatAccess`, and the members panel takes the whole `chatAccess` like its sibling instead of a bare id array. - The containers around the calls list mounted the *full* hook — decline mutation, silence state and all — to read two lengths. Split into `useOngoingCallsList` for them and `useOngoingCalls` for whoever renders it. - `useMessageBlockContextValue` hand-rolled a route subscription; `useCurrentRouteName` now joins the family of one-line router hooks next to `useCurrentRoutePath` and `useLocationHash`. - `CallBarActions` folded into `CallBar`: one call site, always at the inline end, and its centred variant existed only in a story. The native conference can claim the centre when it has controls to put there. - Dropped: three barrel files for two importers, a 466-line snapshot of a static bar (replaced by asserting its actions are reachable), an unused `videoCallWindow.close` declaration, two returned-but-never-read hook values, two orphan i18n keys, and a `useCallback` with one caller. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The service specs were written one at a time, and the shared harness
arrived partway through. Each still assembled its own copy of the same
arrangement, so the suite grew a maintenance cost proportional to the
number of files rather than to what they test.
`testHarness.ts` now owns `createService` (the ~25 inert module stubs, the
do-nothing core-services trio, and a models map defaulted to inert),
`cloneFixture`, `resetAll` and `ringedUserIds`. `leaveCall.spec.ts` —
which never adopted the harness at all and re-listed every stub plus all
four fixture builders verbatim — goes from 413 lines to 285.
`ringMembers`, `addMembers` and `ringOnArrival` become one `ringing.spec.ts`:
they all answer "who gets rung", all stub `setUsersRingingById`, all read
the answer out of the same broadcast, and each separately loaded the
3000-line service.
Specs that restated a lower layer are trimmed to what only they can show:
`conferenceHistory` keeps *when* history is written and drops the
member-state mapping that `callHistory.spec.ts` pins on the pure
function; `membership.spec.ts` folds into `memberStatus.spec.ts`, where
the legacy-flag rule is now stated once instead of four times; the
ringing cap is asserted on `shouldRingVideoConference` plus one
service-level wiring case, not three.
Boilerplate becomes tables: 13 near-identical e2e validation cases
("unknown callId" ×7, "unauthenticated" ×6) and seven "does not
intercept" cases in `useConfinedNavigation.spec.ts`. Client fixtures
duplicated two and three times move into `views/conference/testFixtures.ts`.
Dropped four cases that asserted nothing usable: a `marginInlineStart >= 0`
check that is true for every possible value, a second copy of the lead-action
rule asserted through a Fuselage class name, a repeated post-`startCall`
`isCalling()`, and three ajv-testing-ajv cases.
Bite-checked: with the ringing cap removed from `shouldRingVideoConference`,
the merged spec fails. Behaviours covered are unchanged — every deleted
assertion has a named owner a layer down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…epeating
The doc grew alongside the feature and in places describes decisions the
branch itself reversed. Five things it said were wrong:
- Call-history items are **not** written once when a group conference
stops. They are written from the conference's creation onward, and
direct calls are logged too — the accurate account was already further
down the page, so the stale section now defers to it.
`shouldWriteConferenceHistory`, which it explained at length, no longer
exists; the `{uid, callId}` unique index and `upsertMany` are what make
repeat writes harmless.
- The preflight was described as showing "a self-view", two paragraphs
above the section explaining why it deliberately shows no such thing.
- Leaving said a call ends when the last person leaves, never mentioning
the 10s grace period that makes a reload survivable.
- `Key Files` pointed at two files that don't exist.
- The test-coverage table listed five specs two or three times, pointed at
a spec that had moved, and closed by asserting `VideoConfService` "is
not unit-tested" directly above the table listing its nine specs.
Also brought in line with this pass's code: one `video-conference.updated`
stream event where there were three, one `VIDEO_CONF_RINGING_LIMIT`,
`ConferenceStoresReady` in place of the room-fetching preload, the shared
call-bar and panel headers, and the picker the add-participants modal now
uses.
Reversed decisions retired: decision 5 kept "the current floating overlay
for every case", and the future-work item was to dock it — both now
describe the opposite of what shipped. Sections that said the same thing
two or three times are merged: the ringing cap was explained five times,
`Deliberate limits` restated two decisions and a paragraph, the
chat-access lead rule was stated twice, and one paragraph appeared
verbatim twice. Three scattered "Verified …" blockquotes become one
section, which now also records the decline that had been listed as never
observed live.
Restructured so behaviour comes first: "Reaching a call without a ring"
was buried after the rationale sections, and its own subsections mixed the
list, the navbar stand-in and the server endpoint together.
993 → 898 lines. Every internal anchor still resolves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four entries described one feature at `minor` over an overlapping package set — and three described behaviour later commits on this branch reversed, which would have shipped a release note contradicting itself: - the participants entry advertised dial-out numbers (removed rather than left as dead UI) and the keep-history room-vs-discussion choice, which the membership entry then said the endpoint no longer takes; - the call-history entry said **group** conferences, written when a conference **ends** — since changed to direct calls too, written from the moment a call starts; - the chat-access entry said the server picks the sharing mechanism, which became the user's choice with the server only refusing an invite it can't honour. None of the four mentioned the call window, the preflight, the ongoing calls list, the outcome modal, or the `ring`/`rename`/`leave` endpoints — all of it user-visible. One entry now covers the union of the packages, written from the code as it stands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Matrix comparison The doc had grown past the point where prose alone conveys the flows, so it becomes a folder — `README.md`, following `docs/proposals/apps-converters-zod` — with four diagrams covering a call's life: starting one, being called, adding people and what they can read, and how a call ends. The diagrams carry their own styling as presentation attributes rather than CSS classes, with light fills and dark text, so they render in either GitHub theme and in an editor preview. Alongside them, an analysis of how this implementation compares to MatrixRTC on the two questions it also has to answer — who is in a call, and who may join it. The short version: Matrix has no call object at all (a session is the temporal overlap of `m.rtc.member` events), and almost every difference follows from that. Three things there are worth borrowing and are noted as such: - a presence lease, replacing our four liveness-recovery mechanisms with one that also covers the case none of them cover — a client that stops responding without ever reporting a departure; - an explicit expiry carried on the ring, instead of the three constants clients must currently agree on; - enforcement at the provider, so a leaked conference URL stops being a capability. The MSCs it cites are all still open and MatrixRTC has changed shape more than once, which the document says rather than presenting them as settled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The branch carried several things that were separable from the feature it exists to deliver, and one that nothing used at all. Taking them out now means the first PR argues for one idea rather than six. Removed outright: - the provider -> parent postMessage bridge. Nothing in the repo speaks `rocketchat:conference`, including the bundled Jitsi app; it was an API built for a consumer that does not exist. - participant faces in the ongoing-call list, back to the count they already fell back to. This also drops `participants` from the joinable payload, so the API contract carries one fewer public field. - two Storybook stories for components with no states worth browsing. Deferred to follow-ups, each a complete improvement the feature works without: the call-outcome modal, the room call-list regrouping, and the navbar stand-in for a collapsed sidebar. Confined navigation keeps the interception -- without it a link in the chat panel tears down the call -- but the three delivery strategies collapse to one: a `noopener` tab. The desktop bridge that went with them leaves `desktop-api` untouched by this branch entirely. The membership e2e suite moves to its own PR. It has never run locally -- it authenticates as a fixture admin a dev workspace does not have -- so carrying it here would let an unvalidated suite redden a PR for reasons unrelated to the code under review. Folding the small single-caller files into their callers was only partly possible: `react/no-multi-comp` forbids two components in one file, which is three of the five. Those stay as they are; `useConferenceCallUrl` became a helper in its one consumer. Deferrals are recorded in the feature doc with the SHA to restore from, and the changeset now claims only what ships. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Several pieces of the call window were hand-rolled against `Box` when the design system already ships the same thing: - `CallPanel` is now `Contextualbar`. Fuselage's is a `Box` carrying exactly the props this file set by hand -- flex column, no shrink, the inline-start border, full height, `width`/`position` as props. What is left here is the part that is genuinely new: the open/close width animation, and floating over the call on viewports too narrow to split. - `CallPanelHeader` is the contextual bar's own header, title, actions and close, so these panels agree with every other closable surface about where a title sits and where dismissal is -- and inherit its focus ring, its `aria-label` and its `data-qa` hook rather than reimplementing them. - `AddParticipantsModal` is a `GenericModal`. It was always that shape: a title, one field, confirm and cancel. - `ConferenceMemberRow` uses the `Option` primitives that `CallMemberItem` two files over already uses. - The preflight's mic and camera toggles are `VideoConfController`, which is what the room's start-call popup drives the same two toggles with. A user who sets them in one place and later meets the other now reads the same control, not a second one that happens to look similar. `ConferencePageError` and `ConferenceUnauthorizedPage` were the same page with different words, so they became one `ConferenceStatePage` with props. One deliberate exception: the panel keeps `surface-light` rather than taking the contextual bar's `surface-room` default, so this changes no colours. Not changed: `ChatAccessModal` offers two different confirmations with their own consequences, and `GenericModal`'s footer is one confirm plus a cancel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five things in the diff were not the feature. The room kebab's Calls item had been renamed to *Conference call history* with a history icon. That rename belonged to the call-list regrouping, which was deferred two commits ago -- so what shipped was a rename with nothing behind it, changing what every existing workspace sees for no reason. It goes back, and takes its i18n key with it. Two drive-by fixes ride along that are worth having and worth reviewing on their own terms: a `defaut` -> `default` typo in the room members divider, and hoisting `useVideoConfList`'s `select` so react-query can memoize it. Neither has anything to do with conferences; both are a one-line PR that merges in a day rather than a line buried in a hundred and fifty files. Disabling join/call-back on message blocks inside the call window reached across `ui-contexts` (a whole new `useCurrentRouteName` hook) and `fuselage-ui-kit` to prevent something that isn't broken -- clicking join in there now opens a second call window, which is odd rather than harmful. It is deferred, and `@rocket.chat/ui-contexts` leaves the changeset with it. `VideoConferenceBlock` keeps the part that is the feature: `users` is the membership list, so it has to be filtered to those who actually joined before any of it is counted or shown. The *Ongoing* call-history filter takes an existing icon colour rather than teaching `MultiSelectCustom` and its list a new one, which is two files in a shared package for a single green phone. Two comments named consumers that no longer exist -- the navbar dropdown and the call-history page -- and now describe the code that is there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`addMemberById` writes `joined: false` on purpose: this branch made `users[]` a membership list, so being a member and being in the call became two different things, and arriving is `setUserJoinedById`'s job. The method's signature doesn't even accept `joined`. This test asked for the behaviour that replaced -- that passing `joined: true` would be stored as-is -- and contradicted the test directly above it, which asserts a new member defaults to not joined. Nothing passes those fields: the join path calls `addMemberById` and then `setUserJoinedById`, which has its own tests for marking someone present. It was failing CI on this branch and on everything stacked above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Proposed changes (including videos or screenshots)
Issue(s)
Steps to test or reproduce
Further comments