Skip to content

Add electric-agents:// deep links to open sessions in desktop & mobile apps#4586

Open
balegas wants to merge 22 commits into
mainfrom
worktree-agents-deep-links
Open

Add electric-agents:// deep links to open sessions in desktop & mobile apps#4586
balegas wants to merge 22 commits into
mainfrom
worktree-agents-deep-links

Conversation

@balegas

@balegas balegas commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Closes #4584

Summary

Adds deep links that open a specific agent session directly in the Electric Agents desktop (Electron) and mobile (Expo/React Native) apps.

Link format (self-contained — a session is identified by server + entityUrl, so both are carried):

electric-agents://open-session?server=<url-encoded server base URL>&entity=<url-encoded entityUrl>
  • Scheme reuses electric-agents (already registered for OAuth on mobile); desktop now registers it too.
  • Host is open-session (not session) so expo-router doesn't auto-route it to the internal /session screen — a dedicated landing route handles it. The same link works on both platforms.

Desktop (Electron)

  • Registers the electric-agents scheme (setAsDefaultProtocolClient + electron-builder.yml protocols).
  • Handles all three OS delivery paths: macOS open-url (with a pre-ready queue), Windows/Linux cold-start argv, and warm-start second-instance.
  • New desktop:open-session IPC channel → controller resolves the link's server against saved servers → renderer switches to that server and navigates to the session. Unknown server → a toast ("you haven't added this server").

Mobile (Expo/RN)

  • Captures incoming links (cold start via initial URL, warm via a global Linking listener) as a pendingSessionLink.
  • A dedicated app/open-session.tsx landing route switches to the link's server when needed (self-hosted; Cloud servers need sign-in and are not silently switched), then opens the session.
  • Routed after the onboarding/server-setup gates, so a link arriving mid-onboarding opens once setup completes.

Sharing (generate)

  • Mobile ShareSessionScreen: the Session link now emits the electric-agents://open-session app link (replacing the web link), per the issue's "custom protocol only".
  • Desktop ShareEntityDialog: new Copy session link action.

Docs

  • website/docs/agents/usage/sharing-and-deep-links.md (+ sidebar entry).

Out of scope (v1)

Web/https session links & server-side redirects; browser fallback when the app isn't installed; auto-adding an unknown server from a link; deep links for non-session entity types (the format leaves room).

Design / plan

Committed under docs/superpowers/specs/ and docs/superpowers/plans/ for reviewers.

Test plan

  • Unit tests for the link build/parse/match helpers — mobile (agents-mobile, vitest, in CI), desktop renderer builder (agents-server-ui, vitest, in CI), desktop main parser (agents-desktop, node:test — matches the package's existing convention; typechecked in CI).
  • pnpm typecheck green across agents-mobile, agents-server-ui, agents-desktop; full mobile (92) and server-ui (104) suites pass.
  • Manual platform matrix (not CI-automatable): app running / backgrounded / closed × macOS / Windows / iOS / Android:
    • open "electric-agents://open-session?server=…&entity=…" (macOS)
    • xcrun simctl openurl booted "electric-agents://open-session?…" (iOS)
    • adb shell am start -a android.intent.action.VIEW -d "electric-agents://open-session?…" (Android)
    • Verify: known server opens the session; unknown server shows the inform path; the share UIs produce a link that round-trips.

🤖 Generated with Claude Code

balegas and others added 18 commits June 15, 2026 23:25
Adds sessionAppUrl, isSessionDeepLink, and parseSessionDeepLink to
sessionLinks.ts, with a vitest config and expo-linking stub so that
tests can run without react-native's Flow-typed source.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Electric Agents Desktop Builds

Build artifacts for commit e565705.

Platform Status Artifact
macOS Apple Silicon Passed DMG
macOS Intel Passed DMG
Windows x64 Passed Installer
Linux x64 Passed AppImage / deb

Workflow run

@codecov

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 64.60177% with 40 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.87%. Comparing base (c1310b9) to head (e565705).
⚠️ Report is 7 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
packages/agents-server-ui/src/router.tsx 0.00% 24 Missing ⚠️
...nts-server-ui/src/components/ShareEntityDialog.tsx 0.00% 8 Missing ⚠️
...s-server-ui/src/components/workspace/SplitMenu.tsx 0.00% 5 Missing ⚠️
packages/agents-runtime/src/session-links.ts 96.61% 2 Missing ⚠️
packages/agents-mobile/src/lib/serverHost.ts 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4586      +/-   ##
==========================================
+ Coverage   57.31%   58.87%   +1.55%     
==========================================
  Files         341      388      +47     
  Lines       39765    42657    +2892     
  Branches    11559    12227     +668     
==========================================
+ Hits        22791    25113    +2322     
- Misses      16934    17469     +535     
- Partials       40       75      +35     
Flag Coverage Δ
packages/agents 72.82% <ø> (ø)
packages/agents-mcp 77.70% <ø> (?)
packages/agents-mobile 81.04% <94.11%> (+2.22%) ⬆️
packages/agents-runtime 82.66% <96.61%> (-0.01%) ⬇️
packages/agents-server 75.29% <ø> (ø)
packages/agents-server-ui 7.47% <0.00%> (+0.09%) ⬆️
packages/electric-ax 46.42% <ø> (ø)
packages/experimental 87.73% <ø> (?)
packages/react-hooks 86.48% <ø> (?)
packages/start 82.83% <ø> (?)
packages/typescript-client 91.83% <ø> (?)
packages/y-electric 56.05% <ø> (?)
typescript 58.87% <64.60%> (+1.55%) ⬆️
unit-tests 58.87% <64.60%> (+1.55%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@netlify

netlify Bot commented Jun 16, 2026

Copy link
Copy Markdown

Deploy Preview for electric-next ready!

Name Link
🔨 Latest commit e565705
🔍 Latest deploy log https://app.netlify.com/projects/electric-next/deploys/6a3157946f052a000867496c
😎 Deploy Preview https://deploy-preview-4586--electric-next.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Electric Agents Mobile Build

Local mobile checks ran for commit e565705.

The EAS Android preview build was skipped because the mobile-eas-build label is not present.
Add the mobile-eas-build label to this PR to produce an installable preview build.

Workflow run

@balegas balegas added the claude label Jun 16, 2026
@claude

claude Bot commented Jun 16, 2026

Copy link
Copy Markdown

Claude Code Review

Summary

This PR adds electric-agents://open-session?server=…&entity=… deep links that open a specific session in the Electric Agents desktop (Electron) and mobile (Expo/RN) apps, plus share affordances and docs. Iteration 2 (e565705) addresses every issue from my previous review — the desktop cold-start race, the mobile unknown-server behavior, the loose host match, and the duplicated link format — and adds the dispatch-layer tests I requested. The implementation is now in good shape; I have no remaining Critical or Important issues.

What is Working Well

  • Shared wire format — the encode/decode contract now lives in one module (@electric-ax/agents-runtime/session-links) re-exported by desktop, mobile, and web, with the build alias wired in four places (desktop/server-ui vite, mobile metro, server-ui tsconfig paths) and a package exports entry. The drift risk I flagged is structurally eliminated, and there is a round-trip test suite (incl. Cloud tenant-prefix and Android single-slash cases).
  • Custom string parser over new URL/expo-linking is a sound call, well-justified in the module header (RN URL mangles custom schemes), and it transparently handles the single-slash Android variant. The +-to-space plus decodeURIComponent reading is documented to match the prior URLSearchParams behavior.
  • Cold-start race fixed by a pull model — main stashes a single pendingOpenSession; the renderer pulls via desktop:get-pending-session on mount (cold start) and on each onOpenSession wake-up (warm start), and a separate effect only navigates once servers actually contains the target. This closes both halves of my previous Issue 1 (lost message on did-finish-load, and routing against the wrong server before servers hydrated). The comments explaining why the pull-not-push design is necessary are excellent.
  • Logic extracted into pure, testable functionsresolveOpenSessionPayload (desktop) and decideOpenSession (mobile state machine) take the branching out of the components, and both have full-branch unit tests. This directly answers my dispatch-layer-coverage suggestion.
  • Host-match boundary check added (open-session must be followed by ?, /, or end) with a regression test, so open-sessionfoo cannot match.
  • hostOf collapsed into serverHost.ts, removing the three duplicated copies.
  • Planning/spec docs removed from the tree (~1500 lines).

Issues Found

Critical (Must Fix): None.

Important (Should Fix): None.

Suggestions (Nice to Have):

  • Desktop stuck-pending edge (router.tsx:386-402): when payload.serverId is non-null but that server never appears in the renderer useServerConnection().servers, the resolve effect returns early forever — no navigation, no toast, no self-heal. In practice both sides read from the same desktop settings so the server should hydrate, but a server matched in settings.servers and filtered out of the renderer list would silently strand the link. Consider a fallback once servers has settled (e.g. fall through to the same "you have not added this server" toast) so the link cannot hang indefinitely.
  • Single pendingOpenSession slot is last-wins: two deep links arriving before the renderer pulls would drop the first (main overwrites). Almost certainly fine for this feature, just noting the intentional behavior.
  • Component-level tests still absent: the pure cores are now well-covered, but OpenSessionRoute and the RootShell effect wiring (the pull-on-mount / wait-for-servers sequencing — the part that was actually buggy) are still only exercised by the manual matrix. Not blocking given the logic extraction, but a render test asserting pull, wait, then navigate once servers populates would lock in the fix.

Issue Conformance

Unchanged from iteration 1 and still good: the PR closes #4584 with one intentional, documented scheme/format deviation (electric-agents://open-session?server=&entity= instead of electric://session/{id}), justified in the description and reflected in the docs page. The "app not running, route to session" acceptance criterion was my main worry last time; the cold-start pull model now addresses it in code, and it remains worth confirming on the manual "app closed" matrix rows per platform. The changeset is present (desktop/mobile/server-ui patch).

Previous Review Status

All four items from iteration 1 resolved:

  1. Desktop cold-start drop / wrong-server routing — replaced push with a pull model (desktop:get-pending-session) and a wait-for-servers resolve effect.
  2. Mobile silently added/switched to an unknown self-hosted server — now refuses with a "you have not added this server" screen, mirroring desktop.
  3. Loose host match — tightened to a boundary check, with a test.
  4. Duplicated helpers — unified into the shared session-links module; hostOf deduped into serverHost.ts.

Plus the dispatch-layer tests and doc-removal suggestions were taken on.


Review iteration: 2 | 2026-06-16

Resolve the review on PR #4586:

- Desktop cold start no longer drops the deep link or routes it to the
  wrong server. The payload flows through a single pull
  (desktop:get-pending-session); the renderer pulls on mount and on an
  onOpenSession wake-up signal, and only navigates once `servers` has
  hydrated the target server.
- Mobile refuses an unknown self-hosted server from a link instead of
  silently adding + switching to it, mirroring the desktop. Routing logic
  extracted into a pure, unit-tested decideOpenSession state machine.
- Tighten the open-session host match to a boundary check so
  `open-sessionfoo` can't match.
- Dedup the link format into one shared module
  (@electric-ax/agents-runtime/session-links) re-exported by desktop,
  mobile and web; collapse the three hostOf copies into serverHost.ts.
- Add dispatch-layer tests (resolveOpenSessionPayload, decideOpenSession)
  and a shared round-trip suite.
- Remove the large planning/spec docs from the tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@balegas

balegas commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — addressed all of it in e565705.

Important

1. Desktop cold-start delivery + wrong-server routing
The open-session payload now flows through a single pull (desktop:get-pending-session) rather than a pushed payload that races React mounting its listener. Main stashes the payload; the renderer pulls it on mount (cold start) and on an onOpenSession wake-up signal (warm start), and pulling clears it so it's consumed exactly once. While here I also caught that main.ts runs createWindow() before openSessionFromDeepLink, so the "existing window" branch was actually taken on cold start too — the signal+pull design covers both. The renderer also now waits for servers to hydrate the target id before setActiveServer + navigate, so it never routes against whatever server happened to be active.

2. Mobile silently adding an unknown server
Mobile now refuses like desktop: it only switches to servers the user has already added; an unknown self-hosted server gets a "you haven't added this server" message instead of a silent addSavedServer + switch. The route's branching is extracted into a pure decideOpenSession state machine with unit tests (cloud-abandon / activeMatches / known-switch / unknown-refuse / trailing-slash).

Suggestions

  • Loose host match — tightened to a boundary check (open-session must be followed by ?, /, or end); open-sessionfoo no longer matches. Covered by tests.
  • Duplicated helpers — extracted one shared module, @electric-ax/agents-runtime/session-links (build/parse/match/argv), re-exported by desktop, mobile and web. The parser is now plain string ops so it behaves identically across Node/browser/RN and transparently handles the single-slash Android variant. hostOf (3 copies) collapsed into serverHost.ts. Added a shared round-trip/fixtures suite plus per-package wiring tests.
  • Dispatch-layer coverage — added tests for resolveOpenSessionPayload (desktop server-matching) and decideOpenSession (mobile state machine).
  • Large planning docs — removed from the tree.

Also updated the docs page so the mobile "unknown server" description matches the new refuse behavior.

All four packages typecheck and lint clean, and the new/updated unit suites pass (48 tests).

@balegas balegas removed the shepherd label Jun 16, 2026

@msfstef msfstef left a comment

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.

Reviewed the deep-links work end to end — overall it's a careful, well-tested implementation. The pull-not-push IPC on desktop (stash in main, renderer pulls + clears via getPendingSession) correctly sidesteps the cold-start listener race, and extracting the pure helpers (resolveOpenSessionPayload, decideOpenSession, session-links) so the branchy parts are unit-testable is the right call.

Two things flagged inline — one should-fix (mobile Cloud links), one non-blocking discussion point (mobile share link format) — plus one housekeeping note:

Rebase + regenerate the lockfile. pnpm-lock.yaml is ~830 lines smaller and includes a batch of unrelated babel/transitive downgrades (e.g. @babel/compat-data 7.29.7 → 7.29.3, several @babel/plugin-* 7.29.7 → 7.27.1/7.28.x). That looks like a pnpm install against a stale tree rather than anything this feature needs. Could you rebase on main and regenerate the lockfile with a fresh pnpm install so the PR doesn't carry an accidental dependency rollback?


if (activeMatches) return { kind: `route`, entityUrl: target.entityUrl }
// A Cloud server we're not already signed into can't be switched to silently.
if (isCloudServer(targetServer)) return { kind: `abandon` }

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.

Should-fix: mobile silently drops links to saved Cloud servers, and diverges from desktop here.

Because isCloudServer is checked before isSavedServer, any non-active Cloud server returns abandon even when the user has already added and signed into it. And abandon redirects to / with no message (open-session.tsx) — strictly worse feedback than the explanatory refuse screen an unknown self-hosted server gets. Desktop, meanwhile, resolves saved Cloud servers via findSavedServerForUrl (no source filter, unlike findCloudServerForUrl) and does switch + open them. So the same shared link opens on desktop but silently no-ops on mobile.

The "Cloud needs interactive sign-in" rationale doesn't hold for an already-saved server: HomeMenu.selectServer switches to a Cloud server with just prepareServerHeaders(url) + saveServerUrl(url) — no prompt; the token is derived from the URL service-id + the stored dashboard JWT, which is exactly what the switch decision triggers. An expired token just 401s in-session, same as a manual switch.

Suggested reorder so the saved-server check handles cloud + self-hosted uniformly:

if (activeMatches) return { kind: `route`, entityUrl: target.entityUrl }
if (isSavedServer(targetServer)) return { kind: `switch`, serverUrl: targetServer }
// Only an *unsaved* Cloud server needs an interactive sign-in we can't do from a link.
if (isCloudServer(targetServer)) return { kind: `abandon` } // ideally surface a "sign in first" message instead of a silent redirect
return { kind: `refuse`, host: hostOf(targetServer) }

This makes mobile match desktop. Note the cloud-abandon unit test (openSessionDecision.test.ts:38-45, which hardcodes isSavedServer: () => false) and the comments at openSessionDecision.ts:9 / :47 encode the old premise and would need updating alongside.


const shareLink = async (): Promise<void> => {
const url = sessionWebUrl(serverUrl, entityUrl)
const url = sessionAppUrl(serverUrl, entityUrl)

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.

Non-blocking — flagging for discussion, not requesting a change.

This swaps the mobile share link from an https web link (sessionWebUrl) to the custom-scheme electric-agents:// link. Worth being deliberate about the trade-off, because custom-scheme links are a known weak spot for sharing specifically: many clients won't make them clickable (Gmail strips them outright; Slack/Notion/etc. render them as inert text), and if the recipient doesn't have the app installed there's no fallback — vs. an https/universal link that can open a web page. Desktop's custom scheme is fine (that's the standard Electron pattern); it's the mobile share path where this narrows where a pasted link actually works.

Deferring full Universal/App Links (which need hosted apple-app-site-association / assetlinks.json + domain control) to a later pass is totally reasonable. The low-effort middle ground, if we want to keep paste-into-chat working now, is to have the share sheet emit an https link to a small redirect page that attempts the electric-agents:// handoff and falls back gracefully — and those same https links upgrade into seamless universal links later. Not blocking; just want it to be a conscious call rather than a side effect of "custom protocol only."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support deep links for opening sessions directly in Electric Agents desktop & mobile apps

2 participants