feat: non-destructive local takeover (read-only viewer + bidirectional takeover)#9746
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
4 issues found across 32 files
Architecture diagram
sequenceDiagram
participant T1 as Tab 1 (Editor)
participant T2 as Tab 2 (Viewer)
participant WS as WebSocket Endpoint
participant SC as SessionConnector
participant RM as Room
participant CP as ConsumerPolicy
participant ML as MessageLoop
participant FE as Frontend State (kioskModeAtom)
participant API as /api/kernel/takeover
Note over T1,API: Second tab connection flow (no refusal)
T2->>WS: WebSocket connect (EDIT params)
WS->>SC: connect()
SC->>SC: existing_by_file found, RTC enabled, EDIT mode
SC->>CP: initial_capabilities(session, params)
CP->>CP: session.connection_state() == OPEN → edit=False
CP-->>SC: ConsumerCapabilities(edit=False, interact=False)
SC->>WS: route as kiosk/viewer (existing_by_file)
WS->>T2: kernel-ready (kiosk=true, consumer_capabilities:edit=false)
T2->>FE: set kioskModeAtom=true → ViewerBanner rendered
Note over T1,API: Message filtering follows editor slot (not static flag)
ML->>ML: is_kiosk = lambda: room.main_consumer is not self
RM->>ML: Consumer A is main → is_kiosk()=false (editor)
RM->>ML: Consumer B is not main → is_kiosk()=true (viewer)
ML->>ML: filter: KIOSK_EXCLUDED ops blocked for viewer
ML->>ML: filter: KIOSK_ONLY ops blocked for editor
Note over T1,API: Takeover via API endpoint
T2->>API: POST /api/kernel/takeover (Marimo-Session-Id: vw1)
API->>CP: can_take_over_editing(session, caller_id)
CP-->>API: ALLOW (local policy)
API->>RM: promote_consumer_to_main(consumer B)
RM->>RM: previous_main = consumer A
RM->>RM: main_consumer = consumer B
RM->>T1: consumer-capabilities-changed (edit=false, interact=false)
RM->>T2: consumer-capabilities-changed (edit=true, interact=true)
T1->>FE: set kioskModeAtom=true → ViewerBanner appears
T2->>FE: set kioskModeAtom=false → ViewerBanner hidden
Note over T1,API: Bidirectional takeover (reverse)
T1->>API: POST /api/kernel/takeover (Marimo-Session-Id: ed1)
API->>CP: can_take_over_editing(session, caller_id)
CP-->>API: ALLOW
API->>RM: promote_consumer_to_main(consumer A)
RM->>RM: previous_main = consumer B
RM->>RM: main_consumer = consumer A
RM->>T2: consumer-capabilities-changed (edit=false, interact=false)
RM->>T1: consumer-capabilities-changed (edit=true, interact=true)
T2->>FE: set kioskModeAtom=true
T1->>FE: set kioskModeAtom=false
Note over T1,API: Third viewer survives takeover
T2->>API: POST /api/kernel/takeover (Marimo-Session-Id: vw1)
API->>RM: promote_consumer_to_main(consumer B)
RM->>RM: notifications to A and B only
RM->>T1: consumer-capabilities-changed
RM->>T2: consumer-capabilities-changed
Note over T2: T3 (third viewer, not shown) receives no notification
T2->>API: POST /api/document/transaction
API->>RM: broadcast to all consumers (including T3)
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
There was a problem hiding this comment.
Pull request overview
This PR improves the multi-tab local editing experience by replacing the “second tab is refused” flow with a non-destructive viewer + takeover model, and introduces per-consumer capabilities to support live role switching without reloads.
Changes:
- Add
ConsumerCapabilities(edit,interact) tokernel-readyplus a new targetedconsumer-capabilities-changednotification, and implement editor-slot transfer viaRoom.promote_consumer_to_main. - Update websocket connection routing so a second edit connection joins as a viewer (kiosk) instead of being closed with
MARIMO_ALREADY_CONNECTED. - Frontend: react to capability changes by flipping kiosk mode live and show a compact read-only banner with a Take over action; remove the old destructive modal/locked-state handling.
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/_session/test_room.py | Adds unit tests for room consumer lookup, capability derivation, and promotion notifications. |
| tests/_session/test_consumer_policy.py | Adds tests for initial capability decisions and takeover policy behavior. |
| tests/_server/api/endpoints/ws_helpers.py | Updates kernel-ready test helpers to include consumer_capabilities in responses/assertions. |
| tests/_server/api/endpoints/test_ws.py | Updates websocket endpoint tests to expect viewer routing instead of disconnect on second connection. |
| tests/_server/api/endpoints/test_ws_message_loop.py | Adds tests for message filtering based on a derived/live kiosk flag. |
| tests/_server/api/endpoints/test_kiosk.py | Expands kiosk + takeover integration tests and validates capability-change semantics. |
| tests/_server/api/endpoints/test_execution.py | Adds integration test ensuring takeover transfers edit without disconnecting sockets. |
| packages/openapi/src/api.ts | Extends generated OpenAPI TS types with consumer capability schemas/notifications. |
| packages/openapi/api.yaml | Extends OpenAPI spec with consumer capability schemas and adds to kernel-ready + notification union. |
| marimo/_session/types.py | Updates Session protocol to include room. |
| marimo/_session/room.py | Implements role promotion, per-consumer capability derivation, and targeted capability-change notifications. |
| marimo/_session/consumer_policy.py | Introduces initial capability policy and takeover decision primitive. |
| marimo/_server/api/endpoints/ws/ws_session_connector.py | Routes second edit connections into kiosk/viewer mode based on initial capabilities. |
| marimo/_server/api/endpoints/ws/ws_message_loop.py | Switches message filtering to use a live is_kiosk() callback (supports takeover without reload). |
| marimo/_server/api/endpoints/ws/ws_kernel_ready.py | Includes consumer_capabilities in kernel-ready payload. |
| marimo/_server/api/endpoints/ws_endpoint.py | Removes “already connected” refusal path and derives kiosk-ness for filtering from live room state. |
| marimo/_server/api/endpoints/execution.py | Reworks takeover endpoint to transfer the editor slot rather than disconnecting other consumers. |
| marimo/_pyodide/bootstrap.py | Adds required consumer_capabilities field to pyodide kernel-ready. |
| marimo/_messaging/notification.py | Adds ConsumerCapabilities, ConsumerCapabilitiesChangedNotification, and extends KernelReadyNotification. |
| marimo/_cli/development/commands.py | Adds the new notification to the schema generation list. |
| frontend/src/core/websocket/useMarimoKernelConnection.tsx | Handles consumer-capabilities-changed to flip kiosk mode live; removes already-connected close handling. |
| frontend/src/core/websocket/types.ts | Removes ALREADY_RUNNING/canTakeover status shape now that the refusal path is retired. |
| frontend/src/core/websocket/tests/useMarimoKernelConnection.test.ts | Removes tests for the MARIMO_ALREADY_CONNECTED close classification. |
| frontend/src/core/kernel/tests/handlers.test.ts | Updates kernel-ready fixtures to include consumer_capabilities. |
| frontend/src/core/islands/bootstrap.ts | Ignores consumer-capabilities-changed to avoid unknown-op handling in islands context. |
| frontend/src/core/edit-app.tsx | Renders the new viewer banner in the editor shell. |
| frontend/src/components/editor/viewer-banner.tsx | Adds a floating viewer/read-only banner with a Take over button (no reload). |
| frontend/src/components/editor/header/status.tsx | Removes locked UI branch; always shows disconnected overlay when closed. |
| frontend/src/components/editor/header/app-header.tsx | Simplifies disconnected component usage after removing takeover-specific UI. |
| frontend/src/components/editor/header/tests/status.test.tsx | Removes locked-indicator test tied to the old refusal flow. |
| frontend/src/components/editor/Disconnected.tsx | Removes the takeover modal UI; leaves a simple reason display. |
| frontend/src/components/editor/tests/viewer-banner.test.tsx | Adds tests for viewer banner rendering and takeover POST behavior. |
c488e4a to
094e2bc
Compare
|
|
||
|
|
||
| class ConsumerCapabilitiesChangedNotification( | ||
| Notification, tag="consumer-capabilities-changed" |
There was a problem hiding this comment.
im going back and forth if this should just be "consumer-capabilities" vs "consumer-capabilities-changed".
There was a problem hiding this comment.
switched to consumer-capabilites. better to read and in line with other notification names.
the notification broadcasts the entire capability object and not just the delta, so makes sense to skip changed
mscolnick
left a comment
There was a problem hiding this comment.
looks really good! (pending, overlapping on the sidebar panel if that issue is still present)
Add ConsumerCapabilities struct and consumer-capabilities-changed notification, carry consumer_capabilities on kernel-ready, and regenerate the OpenAPI/TS types.
…ity notify Reassign the single editor slot without disconnecting the previous editor, deriving each consumer's capabilities from the slot and notifying only the two affected tabs over the targeted, non-recorded channel. Add get_consumer and get_capabilities helpers.
Handle the new notification in the kernel message switch so a takeover flips read-only state live without a reload. Backfill the now-required consumer_capabilities field at the remaining kernel-ready sites.
Return 400 instead of 500 when /takeover is missing the Marimo-Session-Id header, and stop classifying RTC collaborators as read-only viewers so their completions are no longer filtered.
…er-capabilities The notification carries a full capabilities snapshot the consumer adopts wholesale, so the noun tag matches the wire convention (variables, cache-info) better than the lone noun-changed form.
094e2bc to
92efa7d
Compare
|
fixed the sidebar panel overlap Screen.Recording.2026-06-03.at.11.00.10.AM.mov |
Gate the take-over banner on !hideControls and the absence of ?kiosk=true so it no longer leaks into intentional kiosk clients (embeds, dashboards, reveal slide previews), where clicking take over would steal the parent tab's editor slot.
Move the banner's gating into the component: only offer takeover in the default vertical layout and outside present mode, where an editing affordance fits. Grid/slides layouts and the app/present view get no banner. Drops the now-redundant hideControls gate in edit-app.
Anchor the banner to the body panel (absolute) instead of the viewport (fixed) so it tucks beside an open sidebar/helper panel instead of overlapping it.
92efa7d to
124f936
Compare
Tip
Can be reviewed commit-by-commit. i have tried to arrange commits in logical order
Why
Today a second browser tab on the same notebook is refused outright: the new connection is closed with
ALREADY_CONNECTEDand the user is shown a destructive modal whose only escape is to forcibly disconnect the other tab and reload.How
ConsumerCapabilities(edit,interact) on the wire, carried onkernel-readyand via a new targetedconsumer-capabilities-changednotification.Room.transfer_editreassigning the single editor slot, emitting a non-recorded capability notification to the two affected tabs.consumer-capabilities-changed; a compact floating read-only banner with a Take over button; the old destructive modal removed.Scope
PR1 of a series. Read-only is client-side only here (server-side enforcement is a follow-up). Terminology rename, server-side read-only, capability primitive, session-id unification, and pluggable policy are later PRs.
We would have a larger RFC/discussion later around how we want to do multiple consumers to a session (RTC or
1editornviewers on different machines). The scope of this and follow PRs is limited to improving experience with multiple tabs locally and lay groundwork for future work.Video
Before
Screen.Recording.2026-06-01.at.3.26.57.PM.mov
After
Screen.Recording.2026-06-01.at.3.31.17.PM.mov