feat(desktop): attach to sessions on other devices — Live section + remote dial (Phase 2b/2)#139
Merged
Merged
Conversation
…tion + remote dial (channels Phase 2b/2) The first user-visible cross-device slice: a session discovered on another device's gateway can be opened, viewed, and chatted in from this desktop, with correct sender attribution. - store/remote-sessions.ts (new): $remoteSessions derives attachable remote sessions from presence records — must advertise a dialable endpoint (Phase 2a) and not collide with a local row (by id or lineage root); deduped per session (freshest record wins), newest first. remoteSessionEndpoint(sessionId) is the O(1) resume-routing lookup. Empty on single-device setups, so the section never renders there. - chat/sidebar/remote-sessions-section.tsx (new): 'Live on other devices' section (i18n'd in en/zh/ja/zh-hant) — title + host line + working dot; clicking a row resumes it. - use-session-actions.resumeSession: remote sessions dial their endpoint via ensureGatewayForEndpoint instead of swapping the local profile backend; every later call (session.resume hydration included) flows through activeGateway() so the rest of resume is backend-agnostic. The local getSessionMessages REST miss stays non-fatal; sessionProfile stays undefined for remote (a local profile is meaningless to the peer). - Sender identity: gateway.ready now carries the gateway's resolved device_name (tui_gateway/ws.py _ready_payload, failure-proof); the desktop keeps the FIRST ready frame (the boot-time local gateway) as its own identity ($localDeviceName, first-wins so a remote backend's ready can't overwrite it). prompt.submit attaches sender_device only when the active backend is remote (activeBackendIsRemote) — local prompts keep relying on the local auto-stamp. - Prune protection: a remote session that is still working/needs-input keeps its endpoint backend in the keep-set so its stream keeps painting after the user switches away; idle remotes are pruned like idle profiles. - tests: +8 remote-sessions registry, +1 activeBackendIsRemote flip, +2 gateway.ready first-wins capture, +2 server _ready_payload (device name present; resolver failure never breaks the handshake). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-import |
1 |
First entries
tests/tui_gateway/test_ws_ready_payload.py:11: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
✅ Fixed issues: none
Unchanged: 5543 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
This was referenced Jun 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Channels Phase 2b/2 (
docs/channels-design.md) — the first user-visible cross-device slice. Phase 2a made gateways advertise dialable endpoints in presence records and accept asender_deviceon prompts; 2b/1 gave the renderer pool remote-endpoint backends. Nothing yet surfaced a remote session or dialed one. After this PR: a session live on another device shows up in a "Live on other devices" sidebar section; clicking it dials that device's gateway, hydrates the transcript, streams it live, and prompts typed here are attributed to this device.What changed
store/remote-sessions.ts(new):$remoteSessionsderives attachable remote sessions from presence records — must carry anendpointand not collide with a local row (id or lineage root); deduped per session (freshest record wins), newest first.remoteSessionEndpoint(sessionId)is the O(1) routing lookup. Empty on single-device setups → the feature is invisible there.chat/sidebar/remote-sessions-section.tsx(new): the section UI (title + host sub-line + working dot), localized in en/zh/ja/zh-hant (sidebar.liveElsewhere). Renders nothing when the registry is empty.use-session-actions.resumeSession: a remote session dials its endpoint viaensureGatewayForEndpointinstead of swapping the local profile backend. Everything downstream (the authoritativesession.resumehydration included) flows throughactiveGateway(), so resume is backend-agnostic. The localgetSessionMessagesREST miss is already non-fatal;sessionProfilestays undefined for remote.gateway.readynow carries the gateway's resolveddevice_name(tui_gateway/ws.py::_ready_payload, resolver failure can never break the handshake). The desktop keeps the FIRST ready frame — the boot-time local gateway — as$localDeviceName(first-wins, so a later remote backend's ready can't overwrite it).prompt.submitattachessender_deviceonly whenactiveBackendIsRemote(); local prompts keep relying on the gateway-side auto-stamp.recomputeKeptGatewayskeeps a remote endpoint alive while its session is working/needs-input (the 2b/1 PR's flagged follow-up); idle remotes prune like idle profiles.How to review
store/remote-sessions.ts+ its 8 tests — the local-twin exclusion is the correctness core (never show a duplicate of a local row).resumeSessionbranch — confirm the local path is untouched in theelseandsessionProfilescoping is unchanged for local sessions.ws.py::_ready_payload+ the first-wins capture inuse-message-stream.ts— the identity model.remoteSenderParams()inuse-prompt-actions.tsand its two spread sites.Evidence
excludes a record whose session is already a local row (by id or key)pins the no-duplicate invariant;captures the first ready frame as this device (first-wins)pins the identity model against remote overwrite;test_ready_payload_survives_resolver_failurepins the handshake safety.Verification
tests/tui_gateway/test_ws_ready_payload.py+test_concurrent_attach.py— 10 passed;uvx ruff checkclean on both touched files.tsc -b0 errors;vitest src/store src/app/session src/app/chat— 157 passed / 3 failed; the 3 failures (use-prompt-actionsfile-attach + sleep-wake recovery) reproduce byte-identically on cleanorigin/mainwith this diff stashed — pre-existing, untouched surface.Risks / gaps
F-003-multi-participant-channels.?token=only andensureGatewayForEndpointis currently called without a token (LAN--insecureconsent model) — the listen-auth hardening decision is still open with the operator, tracked onF-003-multi-participant-channels.$localDeviceNamefirst-wins assumes the primary connects before any remote backend — guaranteed today (remote backends only exist after a user clicks a remote row, post-boot); low risk, noted in code comments.Collaborators