Skip to content

Support shared conversations in the V2 interface - #1394

Merged
Paul Lizer (paullizer) merged 3 commits into
paullizer-react-v2-uifrom
paullizer-shared-conversations-v2
Sep 3, 2026
Merged

Support shared conversations in the V2 interface#1394
Paul Lizer (paullizer) merged 3 commits into
paullizer-react-v2-uifrom
paullizer-shared-conversations-v2

Conversation

@paullizer

@paullizer Paul Lizer (paullizer) commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

The bug

Opening a shared conversation in the V2 interface showed an empty thread.

V2 loaded every thread with GET /api/get_messages, which reads only the personal messages container. For a conversation that is not in it, _authorize_personal_conversation_read raises LookupError and the route converts that into {'messages': []} with a 200:

except LookupError:
    return jsonify({'messages': []})

So a shared conversation did not fail to open — it opened successfully, with nothing in it, and remained the target of the next message sent.

Shared conversations live in cosmos_collaboration_conversations_container / cosmos_collaboration_messages_container, behind the existing /api/collaboration/* API that the classic interface already drives from chat-collaboration.js. The conversation feed already merged them, which is why the rows appeared in the rail at all — only the per-conversation operations were missing.

Front-end only. No Flask route, Cosmos container or server behaviour is changed. Every endpoint used already existed.

What V2 gains

Routing is decided by conversation_kind, which the feed already returns on every row — never by trying one endpoint and falling back, because the personal endpoint does not fail for a shared conversation.

  • Reading a shared thread, with sender attribution, the file display role, and reply quoting. Another participant's message sits on the left rather than the reader's own side.
  • Live updates over the conversation's event stream: other people's messages, deletions, masks, membership changes and typing.
  • Sending, under the classic rule: the assistant answers only when the message @-mentions a model or agent, or an assistant-implying option is set (agent, document search, web search, image generation, deep research, URL access, or a saved prompt). Otherwise the message goes to the participants and the model never sees it. Taken from buildCollaborativeInvocationTarget so the two interfaces cannot disagree about what a given message does.
  • An @ mention menu offering participants, then AI targets, then people who could be invited — and inviting them when chosen.
  • A participants panel behind a People button and a Share item in the rail: invitations, roles, removal, and leaving versus deleting.
  • Pending generated-file approvals.

Retry, edit, attempt navigation and fork are hidden in a shared conversation. Those endpoints read the personal messages container and have no collaboration counterpart, so hiding them is the parity rather than a reduction. Stream recovery is likewise off — /api/chat/stream/reattach is keyed on a hidden source conversation whose id the browser is never given.

Gated on enable_collaborative_conversations; with it off, no sharing controls appear at all.

Three things that were easy to get wrong, and were

Caught in review and fixed:

  1. Event payloads carry the permissions of whoever triggered the event. Every publishing route serializes the conversation for the acting user and broadcasts that one document. Applying it verbatim disabled every other participant's composer when somebody left, and offered every member a "Delete for everyone" button when an owner acted. conversationFactsOnly now strips the viewer-scoped fields, and a membership change is treated as a reason to re-read rather than a payload to trust.
  2. The participants panel evicted the open conversation's membership, and absent flags read as permission. It now has its own slot, capability flags are deny-by-default, and the collaboration store refuses a membership write for any conversation that is not the open one.
  3. The "Add to this conversation" mention rows were dead — they typed a name and invited nobody.

Also fixed along the way: mentions now consume their matched span, so writing @Ada Lovelace no longer also notifies somebody called Ada.

Tests

File Covers
test_v2_shared_conversations.py Endpoint wiring, capability gating, hidden operations, share routing, replay handling, broadcast-permission stripping, membership isolation
test_v2_shared_conversation_logic.mjs The send rule, the mention grammar, event dispatch and attribution — executed against the real modules, not a copy
  • tsc -b --noEmit and npm run build clean
  • test_v2_shared_conversations.py 14/14, test_v2_shared_conversation_logic.mjs 45/45
  • All 29 test_v2_*.py, docs coverage, docs quality and version-guardrail suites pass
  • No regressions: ran the full 928-test suite against the pre-change commit in a temporary worktree — the failures are byte-identical to baseline (pre-existing Windows console-encoding and missing-Azure-dependency errors)
  • Confirmed the new "every activeConversationId write is mirrored" assertion actually fails when a mirror call is removed

Three existing tests were updated where they pinned source deliberately changed here; their intent is preserved and, in two cases, strengthened.

Merges with the base

The base moved twice while this was in progress, and both merges are in the history with their resolutions explained:

  • buildSelectionFields() now enforces that an agent wins over a model identity. The @model / @agent tag overrides feed that function rather than bypassing it, and a tagged model additionally clears the agent selection — without that, the exclusivity rule would silently discard the model the reader had just named.
  • MessageBubble was memoised and renamed to MessageBubbleInner. The rename is kept; ReplyQuote and FileMessage are used from inside the bubble, so neither is affected by the memo boundary.

Renumbered to 0.261.038.

Known limitation

Deep research, URL access and source review make a message a request to the assistant, but _build_collaboration_stream_request_payload does not forward deep_research_enabled, source_review_enabled or url_access_enabled to the chat stream it bridges to. That is existing server behaviour affecting the classic interface identically, so it is documented rather than changed here.

Documentation

  • docs/explanation/features/V2_SHARED_CONVERSATIONS.md
  • A V2 section in docs/guides/collaborate-in-a-conversation.md
  • Release notes under v0.261.038

Opening a shared conversation in V2 showed an empty thread. V2 loaded every
thread with GET /api/get_messages, which reads only the personal messages
container: for a conversation that is not in it, _authorize_personal_conversation_read
raises LookupError and the route converts that into {'messages': []} with a 200.
So a shared conversation did not fail to open -- it opened successfully, with
nothing in it, and remained the target of the next message sent.

Shared conversations live in their own Cosmos containers behind the existing
/api/collaboration/* API, which the classic interface already drives from
chat-collaboration.js. This routes V2 through it. Front-end only: no Flask
route, schema or server behaviour is changed.

Routing is decided by conversation_kind, which the feed already returns on every
row, rather than by trying one endpoint and falling back -- the personal endpoint
does not fail for a shared conversation.

What V2 gains:

- Reading a shared thread, with sender attribution, the `file` display role, and
  reply quoting. Another participant's message sits on the left rather than the
  reader's own side.
- Live updates over the conversation's event stream: other people's messages,
  deletions, masks, membership changes and typing.
- Sending, under the classic rule: the assistant answers only when the message
  @-mentions a model or agent, or an assistant-implying option is set. Otherwise
  the message goes to the participants and the model never sees it.
- An @ mention menu offering participants, then AI targets, then people who could
  be invited -- and inviting them when chosen.
- A participants panel behind a People button and a Share item in the rail,
  covering invitations, roles, removal, and leaving versus deleting.
- Pending generated-file approvals.

Retry, edit, attempt navigation and fork are hidden in a shared conversation.
Those endpoints read the personal messages container and have no collaboration
counterpart, so hiding them is the parity rather than a reduction. Stream
recovery is likewise off: /api/chat/stream/reattach is keyed on a hidden source
conversation whose id the browser is never given.

Three things worth knowing, each of which is easy to get wrong and was:

- Event payloads carry the permissions of whoever *triggered* the event, not the
  reader's, because every publishing route serializes the conversation for the
  acting user and broadcasts that one document. Applying it verbatim disabled
  every other participant's composer when somebody left, and offered every member
  a "Delete for everyone" button when an owner acted. conversationFactsOnly
  strips the viewer-scoped fields; a membership change is a reason to re-read.
- The event stream replays its whole history on every attach, and EventSource
  reconnects by itself, so events are both replay-guarded and de-duplicated.
- Capability flags are deny-by-default while unknown, and the collaboration store
  refuses a membership write for any conversation that is not the open one.

Mentions now consume their matched span, so writing "@ada Lovelace" no longer
also notifies somebody called "Ada".

Tests: test_v2_shared_conversations.py asserts the wiring, the gating and the
hidden operations; test_v2_shared_conversation_logic.mjs executes the send rule,
the mention grammar and the event handling against the real modules.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Conflicts, and how each was resolved:

- config.py / release_notes.md: the base renumbered to 0.261.036 while this
  branch was on 0.261.034, so the shared-conversation entries move to a new
  0.261.037 section above it and the base's 0.261.034 agent-picker entry is
  kept as it stands.

- chatStore.ts: the base replaced the separate model / agent / reasoning
  assignments with buildSelectionFields(), which enforces that an agent wins
  because a model identity sent alongside agent_info reads to the server as an
  override of it. The @model and @agent tag overrides added here now feed that
  function rather than bypassing it. A tagged model additionally clears the
  agent selection: without that, the exclusivity rule would let a picked agent
  silently discard the model the reader had just named.

- Composer.tsx: both branches added an import; kept both.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Conflicts, and how each was resolved:

- config.py / release_notes.md: the base took 0.261.037 while this branch was on
  it, so the shared-conversation entries move to 0.261.038 above the base's
  diagram section, which is kept as it stands.

- MessageList.tsx: the base memoised the message bubble, renaming MessageBubble
  to MessageBubbleInner. Kept that rename alongside the ReplyQuote and
  FileMessage components added here; both are used from inside the bubble, so
  neither is affected by the memo boundary.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@paullizer
Paul Lizer (paullizer) merged commit ada67e5 into paullizer-react-v2-ui Sep 3, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant