Skip to content

feat(desktop): attach to sessions on other devices — Live section + remote dial (Phase 2b/2)#139

Merged
OmarB97 merged 1 commit into
mainfrom
feat/desktop-remote-attach-ui
Jun 10, 2026
Merged

feat(desktop): attach to sessions on other devices — Live section + remote dial (Phase 2b/2)#139
OmarB97 merged 1 commit into
mainfrom
feat/desktop-remote-attach-ui

Conversation

@OmarB97

@OmarB97 OmarB97 commented Jun 10, 2026

Copy link
Copy Markdown
Owner

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 a sender_device on 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): $remoteSessions derives attachable remote sessions from presence records — must carry an endpoint and 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 via ensureGatewayForEndpoint instead of swapping the local profile backend. Everything downstream (the authoritative session.resume hydration included) flows through activeGateway(), so resume is backend-agnostic. The local getSessionMessages REST miss is already non-fatal; sessionProfile stays undefined for remote.
  • Sender identity handshake: gateway.ready now carries the gateway's resolved device_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.submit attaches sender_device only when activeBackendIsRemote(); local prompts keep relying on the gateway-side auto-stamp.
  • Prune protection: recomputeKeptGateways keeps 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

  1. store/remote-sessions.ts + its 8 tests — the local-twin exclusion is the correctness core (never show a duplicate of a local row).
  2. The resumeSession branch — confirm the local path is untouched in the else and sessionProfile scoping is unchanged for local sessions.
  3. ws.py::_ready_payload + the first-wins capture in use-message-stream.ts — the identity model.
  4. remoteSenderParams() in use-prompt-actions.ts and 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_failure pins the handshake safety.

Verification

  • Python: tests/tui_gateway/test_ws_ready_payload.py + test_concurrent_attach.py — 10 passed; uvx ruff check clean on both touched files.
  • Desktop: tsc -b 0 errors; vitest src/store src/app/session src/app/chat — 157 passed / 3 failed; the 3 failures (use-prompt-actions file-attach + sleep-wake recovery) reproduce byte-identically on clean origin/main with this diff stashed — pre-existing, untouched surface.

Risks / gaps

  • End-to-end cross-device flow (two real machines) is not yet exercised — accepted scope, it needs a second device with presence-folder sync + a non-loopback bind; live canary tracked on F-003-multi-participant-channels.
  • Remote auth rides the advertised endpoint's ?token= only and ensureGatewayForEndpoint is currently called without a token (LAN --insecure consent model) — the listen-auth hardening decision is still open with the operator, tracked on F-003-multi-participant-channels.
  • A remote session's row lives in the presence-derived section, not the main session list (no local DB row), so pin/archive/delete don't apply to it — accepted scope for this slice, channel UX is Phase 3.
  • $localDeviceName first-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

  • @OmarB97 (operator)
  • Claude Fable 5 (Claude Code)

…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>
@github-actions

Copy link
Copy Markdown

🔎 Lint report: feat/desktop-remote-attach-ui vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 10554 on HEAD, 10553 on base (🆕 +1)

🆕 New issues (1):

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.

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