Add remote-session daemon indicator and toggle to Studio top bar#3450
Conversation
…propagate instantly
The settings toggle now writes a dedicated `showRemoteSessionInToolbar` preference instead of mutating the beta feature itself. The beta-features menu remains the path to opt into the feature; once enabled, the settings toggle controls whether the bolt appears in the toolbar.
…rocess Studio's main process used to directly import the CLI's daemon module via a `cli:` path alias in both vite configs, which coupled the desktop bundle to the CLI source tree. Other CLI-backed features (sites, exports, snapshots) instead spawn the CLI as a child process and listen for its IPC events. Shared types and PID-file helpers (DaemonStatus, getDaemonStatus, errors) now live in `@studio/common/lib/remote-session`. The CLI's daemon module re-exports them for backward compatibility with internal CLI callers and tests. Studio's start/stop IPC handlers fork `cli code remote-session start|stop` via `executeCliCommand`, then read status from the shared helper. The `cli:` alias is removed from both vite configs. `executeCliCommand` now accepts an optional `env` so callers can pass through CLI feature-flag env vars like `STUDIO_ENABLE_REMOTE_SESSION`.
Without the env var, the CLI doesn't register the `code remote-session` subcommand tree and the spawned child fails with `Unknown argument: stop`. Mirrors what `startRemoteSessionDaemon` already does.
There was a problem hiding this comment.
Pull request overview
Adds a beta-feature-gated remote-session daemon control surface to Studio: a lightning-bolt indicator in the top bar and a matching toggle in the Preferences settings tab, both reflecting and driving the CLI-managed daemon's start/stop lifecycle.
Changes:
- Extracts shared daemon types and PID-file helpers into
@studio/common/lib/remote-sessionso the CLI and the desktop main process share one vocabulary; CLIdaemon.tsre-exports them for backward compatibility. - Adds three IPC handlers (
getRemoteSessionDaemonStatus,startRemoteSessionDaemon,stopRemoteSessionDaemon) that fork the CLI as a subprocess (with a new optionalenvonexecuteCliCommand), plus a 5-second main-process PID-file poller that pushesremote-session-statusevents to the renderer. - Introduces a renderer hook (
useRemoteSessionStatus) backed by module-level shared state for cross-consumer optimistic updates, plus a top-barRemoteSessionIndicatorand aRemoteSessionTogglein the Preferences tab, both gated behind a newremoteSessionbeta feature and authentication.
Reviewed changes
Copilot reviewed 26 out of 27 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/common/lib/remote-session.ts | Shared daemon types/PID-file helpers consumed by both CLI and Studio. |
| apps/cli/remote-session/daemon.ts | Re-exports moved-out helpers/types from the shared module. |
| apps/studio/src/ipc-handlers.ts | New IPC handlers that fork the CLI for start/stop and read daemon status. |
| apps/studio/src/modules/cli/lib/execute-command.ts | Adds optional env so callers can inject feature-flag env vars to the CLI subprocess. |
| apps/studio/src/modules/remote-session/daemon-status-poller.ts | Main-process poller that pushes daemon transitions to the renderer. |
| apps/studio/src/modules/remote-session/tests/daemon-status-poller.test.ts | Unit tests for the poller (initial tick, transitions, error tolerance, stop). |
| apps/studio/src/index.ts | Starts/stops the daemon-status poller during app lifecycle. |
| apps/studio/src/ipc-utils.ts | Adds remote-session-status IPC event and threads anchor through user-settings. |
| apps/studio/src/ipc-types.d.ts | Adds remoteSession: boolean to BetaFeatures. |
| apps/studio/src/preload.ts | Exposes the three new IPC channels and the optional anchor arg. |
| apps/studio/src/lib/beta-features.ts | Registers the remoteSession beta feature definition and default. |
| apps/studio/src/menu.ts | Enables the Beta Features menu when at least one feature is registered. |
| apps/studio/src/stores/beta-features-slice.ts | Initializes remoteSession: false in the slice default state. |
| apps/studio/src/hooks/use-beta-features.ts | New hook returning the beta-features map. |
| apps/studio/src/hooks/use-remote-session-status.tsx | Shared-state hook that drives optimistic UI and IPC start/stop. |
| apps/studio/src/hooks/tests/use-remote-session-status.test.tsx | Unit tests for shared state, optimistic flips, error reconciliation, debounce. |
| apps/studio/src/components/remote-session-indicator.tsx | Top-bar lightning-bolt button reflecting/driving daemon state. |
| apps/studio/src/components/top-bar.tsx | Mounts the new indicator. |
| apps/studio/src/components/tests/remote-session-indicator.test.tsx | Component tests for indicator gating, click behavior, pulse state. |
| apps/studio/src/modules/user-settings/components/remote-session-toggle.tsx | Settings-tab toggle bound to the same hook. |
| apps/studio/src/modules/user-settings/components/preferences-tab.tsx | Renders the toggle and supports anchor-based auto-scroll. |
| apps/studio/src/modules/user-settings/components/user-settings.tsx | Threads anchor from the IPC event into PreferencesTab. |
| apps/studio/src/modules/user-settings/lib/ipc-handlers.ts | Adds optional anchor to showUserSettings. |
| apps/studio/src/components/tests/content-tab-settings.test.tsx | Updates preloaded beta-features state shape in tests. |
| apps/studio/src/tests/ipc-handlers-remote-session.test.ts | New tests for the three IPC handlers (env flag, success/timeout/failure paths). |
| apps/studio/tsconfig.json | Adds a cli/* path mapping (no current consumers). |
| apps/studio/electron.vite.config.ts | Removes the unused cli Vite alias. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- sendIpcEventToRenderer now null-checks the main window before calling
isDestroyed(), matching sendIpcEventToRendererWithWindow. The daemon
status poller fires its initial tick during appBoot, which raced the
partial getMainWindow mock used in index.test and surfaced as an
unhandled rejection on Linux CI (vitest exits non-zero on unhandled
rejections).
- index.test now mocks src/modules/remote-session/daemon-status-poller so
the poller does not fire during app-boot bookkeeping tests. Matches the
existing pattern for other platform-specific modules.
- stopRemoteSessionDaemon no longer returns the bogus alreadyStopped flag.
After a successful CLI stop, the CLI removes the PID file and
getDaemonStatus returns staleFileRemoved undefined, so the previous
'! status.staleFileRemoved' always evaluated to true. The renderer
never reads the field; we now just resolve with { stopped: true }.
- Removed the dead cli/* path mapping from apps/studio/tsconfig.json. No
studio source imports from cli/*; the matching vite alias was removed
earlier in this PR. Keeps the tsconfig in sync with the runtime config.
…r' into gcsecsey/remote-session-indicator
📊 Performance Test ResultsComparing 5be086b vs trunk app-size
site-editor
site-startup
Results are median values from multiple test runs. Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff) |
epeicher
left a comment
There was a problem hiding this comment.
Thanks @gcsecsey, this functionality is great for handling the remote session functionality! I have tested it, and it works perfectly
| Stopped | Started |
|---|---|
![]() |
![]() |
One feature about the toggle is that it behaves slightly different than the Studio CLI toggle when opening the settings. Do you think that both should behave similarly? For this scenario, should we apply the On or Off functionality when the user clicks on Save? Additonally, what happens if the user disables the Studio CLI? Should we disable (and turn off) the remote session toggle? 🤔
| alias: { | ||
| src: resolve( __dirname, 'src' ), | ||
| '@studio/common': resolve( __dirname, '../../tools/common' ), | ||
| cli: resolve( __dirname, '../cli' ), |
There was a problem hiding this comment.
Is removing this required as part of these changes?
There was a problem hiding this comment.
Good catch, this was unintentional, thanks for highlighting! I added it back in 6b1744c.
@epeicher good points, yes, I think from a UX perspective, both toggles should behave similarly. However, the remote-session toggle uses the same state as the bolt icon in the toolbar. If we made the toggle only apply on save, there would be a visible difference between the two until the modal is saved. Overall, I start to think it'd be the most straightforward if we only had the bolt icon in the toolbar, and removed the toggle from the settings menu, because the toggle feels a bit redundant to me. The remote session toggle is actually independent from the CLI, even when the CLI is not exposed to the user, they can still start/stop the remote session. So I wouldn't make the toggle dependent on the CLI toggle. |
Yes, I agree with this, we could remove it as the bolt icon has all the functionality, so the additional toggle can be confusing. |
The hand-rolled module-level store (sharedState + listeners Set + useReducer force-update + _resetRemoteSessionStatusStateForTests) was reinventing what beta-features-slice and the rest of the codebase already do with Redux. This PR replaces it with stores/remote-session-slice.ts following the same shape: - Slice owns the cache of `DaemonStatus`, the optimistic-running flag, and the in-flight guard. - Thunks for start / stop / load handle the IPC plumbing; `condition` callback covers the concurrent-click debounce; `extraReducers` cover the pending/fulfilled/rejected lifecycle including the optimistic flip on pending and reconciliation on fulfilled. - Selectors expose status, isRunning (optimistic-aware), and isLoading. - useRemoteSessionStatus is now a 50-line thin wrapper around `useSelector` + dispatched thunks. IPC bridge subscription (`remote-session-status` → dispatch `applyIncomingStatus`) lives in stores/index.ts next to the configured store rather than inside the slice. That avoids a circular-import deadlock when consumers import the slice directly (test → hook → slice → stores) — leaving `remoteSessionReducer` undefined when combineReducers runs in stores. Beta-features-slice doesn't hit this because its consumers always enter via stores first. Net change: ~150 lines of subscription machinery replaced with a standard slice and a thin hook.
Mirrors the existing pattern from offline-icon.tsx: a one-off icon lives in its own small file with the path-design rationale next to the SVG, and the consumer just imports it. RemoteSessionIndicator drops to ~50 lines and stays focused on behavior.
`/* translators: … */` block immediately above the `__()` call so the POT extractor picks it up. Without context, translators wouldn't know that "Dolly" is a proper noun (the WordPress.com Telegram bot's display name) and that "@wordpress_com_bot" is its handle and shouldn't be translated.
The desktop renderer only ever read `running` from the IPC payload, but
the full `DaemonStatus` (`running`, `pid?`, `pidFile`, `staleFileRemoved?`)
was crossing the process boundary. The pid/pidFile fields aren't sensitive
(`pidFile` is a fixed `~/.studio/` path), but there's no reason to ship
data the UI doesn't read.
- Added `RemoteSessionStatus = { running: boolean }` and a
`toRemoteSessionStatus()` projector in `@studio/common/lib/remote-session`.
- IPC handlers (`getRemoteSessionDaemonStatus` + the
`remote-session-status` event) now return / carry the projected shape.
- Slice's cached `status` field and the hook's `status` return type are
now `RemoteSessionStatus`. Selectors / consumers unchanged.
- The internal `DaemonStatus` stays on the main-process and CLI side
(still needs `pid` for kill, `pidFile` for log paths, etc.).
Coverage was previously implicit via the hook tests. This 5-case suite closes that gap by exercising the toggle directly: checked/unchecked based on isRunning, click invokes start/stop, and disabled while a transition is in flight. Hook is mocked (matches the indicator test's pattern).
`apps/studio/src/modules/remote-session/daemon-status-poller.ts` and `apps/cli/ai/daemon-status-poll.ts` were structurally the same poller — synchronous first tick, setInterval, timer.unref(), try/catch per read. The new file even cited the old one as its model in its docstring. Extracted that skeleton into `pollDaemonStatus<T>` in `@studio/common/lib/remote-session.ts`. Generic over the status shape; optional `isEnabled` callback (the CLI uses it for the env-flag gate, Studio doesn't); optional `shouldPush(current, lastPushed)` filter (Studio dedupes on `running`, the CLI pushes every tick). Both callers become thin sink-and-filter wrappers. Public APIs of both `startRemoteSessionStatusPolling` and `startDaemonStatusPolling` are unchanged, so their existing test suites pass without modification.
…the sole control Per discussion with Roberto: the duplicate toggle in Preferences was adding more confusion than convenience now that the toolbar bolt does the same job. Drop the settings toggle entirely and let the bolt be the single entry point. Visibility is still gated on the beta feature + auth. - Deleted `apps/studio/src/modules/user-settings/components/remote-session-toggle.tsx` and its test. - Pruned `PreferencesTab`: dropped the toggle render, the wrapping `<div id="remote-session">`, and the now-unused `useAuth` / `useBetaFeatures` imports + locals. - Removed the dead anchor-jump pipeline that only existed to land the bolt's click on the settings toggle: dropped the `anchor?` prop + `scrollIntoView` effect on `PreferencesTab`, the `anchor` field on the `'user-settings'` IPC payload, the `anchor` parameter on the `showUserSettings` IPC handler, and the matching `preload.ts` forwarding. `UserSettings` no longer tracks a pending anchor.
I've removed the toggle from the modal for now, we can add it back later if needed. I'll update the PR description and testing steps to reflect this. |
epeicher
left a comment
There was a problem hiding this comment.
Thanks @gcsecsey, changes LGTM!
I have also used a local agent for review and it has just reported minor non-blocking changes so I will approve the PR and leave the comments in case you consider helpful:
New observations on this version
These are minor — nothing blocking.
-
Listener re-subscribes every render (
use-remote-session-status.tsx:56-67). The inline arrow passed touseIpcListeneris a fresh function each render, anduseIpcListenerlistslistenerin its deps. The previous subscription gets cleaned up via the effect's return so it's not buggy, but it's wasteful. Easy fix: wrap inuseCallback. Project-wide pattern though (seeuse-theme-details.tsx), so I'd leave it for a separate sweep rather than fix here. -
isMountedguard is inconsistent. The initial fetch on mount (use-remote-session-status.tsx:38-54) guardssetStatuswithisMounted, butrefreshStatus(called after every transition) doesn't. After unmount,refreshStatuswill still try tosetStatus. React 18 silently no-ops, so this is harmless — just inconsistent. Either drop theisMountedflag on initial fetch (rely on React's silent no-op everywhere) or apply it inrefreshStatustoo. -
Nested
try/finallyinrunTransition(lines 86-95) is defensive but a touch awkward. It guarantees the loading flags clear even ifrefreshStatus()rejects. Acceptable as written; just noting it's the only spot in the file that needs a second glance. -
useIpcListenerreconciliation gate: when an incoming poll event contradicts the pending optimistic flip, the event is dropped entirely (line 60:if (pendingRunning !== null && pendingRunning !== incomingStatus.running) return;). The post-transitionrefreshStatuswill catch up. This is correct, and there's a test for it (hooks/tests/use-remote-session-status.test.tsx:167).
…ion-indicator # Conflicts: # apps/cli/ai/daemon-status-poll.ts
…ion-indicator # Conflicts: # apps/studio/src/modules/user-settings/components/preferences-tab.tsx
…ion-indicator # Conflicts: # apps/studio/src/components/tests/content-tab-settings.test.tsx # apps/studio/src/ipc-types.d.ts # apps/studio/src/lib/beta-features.ts
…ion-indicator # Conflicts: # apps/studio/src/tests/index.test.ts


Related issues
How AI was used in this PR
I tried out the Compound Engineering flow with this issue, using
/ce-brainstorm,/ce-plan,/ce-workskills.Proposed Changes
The whole remote-session surface is gated behind a new
remoteSessionbeta feature. Once a user enables the beta feature, the lightning-bolt control is added to the top bar. If the user is not logged in, these controls are also hidden, because we need the authentication for the remote session.The top-bar lightning bolt:
useRemoteSessionStatus.Architecture
@studio/common/lib/remote-sessionso the desktop main process and the CLI workspace share a single vocabulary.startRemoteSessionDaemonandstopRemoteSessionDaemonIPC handlers forkcli code remote-session start|stopviaexecuteCliCommand, passingSTUDIO_ENABLE_REMOTE_SESSION=trueso the CLI's command tree registers. Status reads are a cheap PID-file read via the shared helper.executeCliCommandnow accepts an optionalenvso callers can pass CLI feature-flag env vars through to the forked subprocess.Important
We'll need to clean up this new optional param once we removed the env var for the CLI
getRemoteSessionDaemonStatus,startRemoteSessionDaemon,stopRemoteSessionDaemon) wire the renderer to that subprocess pipeline.remote-session-statusIPC event. Status is also re-fetched after every click, so the visual updates immediately rather than waiting for the next tick.showErrorMessageBoxdialog; the daemon's Telegram "attached" message fires automatically via the existing CLI helper whenchat_idis pinned in~/.studio/remote-session.json.Testing Instructions
studio code remote-session startfrom a terminal. Within ~5s, the bolt should turn green (it's reading the PID file the CLI just wrote). Runstudio code remote-session stop— within ~5s, the bolt should go white.Mid-transition pulse — bolt animates while start/stop is in flight:

External CLI flip propagates back — run
studio code remote-session start --detach, the bolt turns green within ~5 s:CleanShot.2026-05-15.at.16.58.51.mp4
Pre-merge Checklist