Skip to content

feat: non-destructive local takeover (read-only viewer + bidirectional takeover)#9746

Merged
kirangadhave merged 16 commits into
mainfrom
kg/kiosk-non-destructive-takeover
Jun 3, 2026
Merged

feat: non-destructive local takeover (read-only viewer + bidirectional takeover)#9746
kirangadhave merged 16 commits into
mainfrom
kg/kiosk-non-destructive-takeover

Conversation

@kirangadhave
Copy link
Copy Markdown
Member

@kirangadhave kirangadhave commented Jun 1, 2026

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_CONNECTED and the user is shown a destructive modal whose only escape is to forcibly disconnect the other tab and reload.

How

  • Per-consumer ConsumerCapabilities (edit, interact) on the wire, carried on kernel-ready and via a new targeted consumer-capabilities-changed notification.
  • A second EDIT connection auto-routes into the existing viewer path; the refusal path is fully retired.
  • "Do I hold edit" is now derived from the room's live editor slot, so message filtering follows a takeover without a reload.
  • Takeover is a symmetric Room.transfer_edit reassigning the single editor slot, emitting a non-recorded capability notification to the two affected tabs.
  • Frontend: live kiosk-mode flip on 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 1 editor n viewers 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

@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 1, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
marimo-docs Ready Ready Preview, Comment Jun 3, 2026 7:10pm

Request Review

@github-actions github-actions Bot added the bash-focus Area to focus on during release bug bash label Jun 1, 2026
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread marimo/_server/api/endpoints/execution.py
Comment thread marimo/_server/api/endpoints/execution.py Outdated
Comment thread marimo/_server/api/endpoints/execution.py
Comment thread marimo/_session/consumer_policy.py
@kirangadhave kirangadhave added the enhancement New feature or request label Jun 1, 2026
@kirangadhave kirangadhave marked this pull request as ready for review June 1, 2026 22:36
Copilot AI review requested due to automatic review settings June 1, 2026 22:36
@kirangadhave kirangadhave requested a review from mscolnick June 1, 2026 22:37
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) to kernel-ready plus a new targeted consumer-capabilities-changed notification, and implement editor-slot transfer via Room.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.

Comment thread marimo/_server/api/endpoints/ws_endpoint.py Outdated
Comment thread marimo/_messaging/notification.py
Comment thread marimo/_messaging/notification.py Outdated


class ConsumerCapabilitiesChangedNotification(
Notification, tag="consumer-capabilities-changed"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im going back and forth if this should just be "consumer-capabilities" vs "consumer-capabilities-changed".

Copy link
Copy Markdown
Member Author

@kirangadhave kirangadhave Jun 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
mscolnick previously approved these changes Jun 3, 2026
Copy link
Copy Markdown
Contributor

@mscolnick mscolnick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@kirangadhave
Copy link
Copy Markdown
Member Author

fixed the sidebar panel overlap

Screen.Recording.2026-06-03.at.11.00.10.AM.mov

@kirangadhave kirangadhave requested a review from mscolnick June 3, 2026 18:01
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.
@kirangadhave kirangadhave merged commit 6dcaff7 into main Jun 3, 2026
53 checks passed
@kirangadhave kirangadhave deleted the kg/kiosk-non-destructive-takeover branch June 3, 2026 19:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bash-focus Area to focus on during release bug bash enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants