Skip to content

Add remote-session daemon indicator and toggle to Studio top bar#3450

Merged
gcsecsey merged 33 commits into
trunkfrom
gcsecsey/remote-session-indicator
May 20, 2026
Merged

Add remote-session daemon indicator and toggle to Studio top bar#3450
gcsecsey merged 33 commits into
trunkfrom
gcsecsey/remote-session-indicator

Conversation

@gcsecsey
Copy link
Copy Markdown
Member

@gcsecsey gcsecsey commented May 12, 2026

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-work skills.

Proposed Changes

The whole remote-session surface is gated behind a new remoteSession beta 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:

  • Reflects the live daemon status (green / on when running, white / off when paused).
  • Starts or stops the daemon when clicked.
  • Stays in lockstep via a shared module-level state inside useRemoteSessionStatus.

Architecture

  • Shared daemon types and PID-file helpers moved to @studio/common/lib/remote-session so the desktop main process and the CLI workspace share a single vocabulary.
  • Studio's main process treats the CLI as an external program (matching every other CLI-backed feature in the codebase): the startRemoteSessionDaemon and stopRemoteSessionDaemon IPC handlers fork cli code remote-session start|stop via executeCliCommand, passing STUDIO_ENABLE_REMOTE_SESSION=true so the CLI's command tree registers. Status reads are a cheap PID-file read via the shared helper.
  • executeCliCommand now accepts an optional env so 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

  • Three IPC handlers (getRemoteSessionDaemonStatus, startRemoteSessionDaemon, stopRemoteSessionDaemon) wire the renderer to that subprocess pipeline.
  • Studio's main process polls the PID file every 5 seconds and pushes state transitions to the renderer via a new remote-session-status IPC event. Status is also re-fetched after every click, so the visual updates immediately rather than waiting for the next tick.
  • Errors surface via the existing showErrorMessageBox dialog; the daemon's Telegram "attached" message fires automatically via the existing CLI helper when chat_id is pinned in ~/.studio/remote-session.json.

Testing Instructions

  1. Enable the beta feature. Open Studio → WordPress Studio menu → Beta Features → check Remote Session. Confirm the bolt appears in the top-bar right cluster (next to Settings and Help) and stays there.
  2. Tooltip copy. Hover the bolt while off → "Start remote session". Hover while on → "Stop remote session".
  3. External state changes propagate. With the bolt visible, run studio code remote-session start from a terminal. Within ~5s, the bolt should turn green (it's reading the PID file the CLI just wrote). Run studio code remote-session stop — within ~5s, the bolt should go white.
  4. Gating: beta feature off. Uncheck Remote Session in the Beta Features menu. The bolt should disappear from the top bar.
  5. Gating: signed out. With the beta feature on, sign out. The bolt should disappear (daemon needs auth tokens). Sign back in — the bolt reappears with whatever state the daemon is in.
State Main window
Beta feature off — no bolt in toolbar CleanShot 2026-05-15 at 16 50 01@2x
Beta feature on, signed out — bolt hidden CleanShot 2026-05-15 at 16 51 31@2x
Beta feature on, signed in, daemon stopped — bolt is white CleanShot 2026-05-15 at 16 53 21@2x
Beta feature on, signed in, daemon running — bolt is green CleanShot 2026-05-15 at 16 53 41@2x

Mid-transition pulse — bolt animates while start/stop is in flight:
CleanShot 2026-05-15 at 16 54 22

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

  • Have you checked for TypeScript, React or other console errors?

@gcsecsey gcsecsey changed the title Add remote-session daemon indicator and toggle to Studio top bar (STU-1717) Add remote-session daemon indicator and toggle to Studio top bar May 13, 2026
gcsecsey added 10 commits May 13, 2026 16:57
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.
@gcsecsey gcsecsey requested a review from epeicher May 15, 2026 16:00
@gcsecsey gcsecsey requested a review from Copilot May 15, 2026 16:00
@gcsecsey gcsecsey marked this pull request as ready for review May 15, 2026 16:00
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

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-session so the CLI and the desktop main process share one vocabulary; CLI daemon.ts re-exports them for backward compatibility.
  • Adds three IPC handlers (getRemoteSessionDaemonStatus, startRemoteSessionDaemon, stopRemoteSessionDaemon) that fork the CLI as a subprocess (with a new optional env on executeCliCommand), plus a 5-second main-process PID-file poller that pushes remote-session-status events to the renderer.
  • Introduces a renderer hook (useRemoteSessionStatus) backed by module-level shared state for cross-consumer optimistic updates, plus a top-bar RemoteSessionIndicator and a RemoteSessionToggle in the Preferences tab, both gated behind a new remoteSession beta 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.

Comment thread apps/studio/src/ipc-handlers.ts Outdated
Comment thread apps/studio/tsconfig.json Outdated
gcsecsey added 2 commits May 15, 2026 17:11
- 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.
@gcsecsey gcsecsey requested a review from shaunandrews May 15, 2026 16:21
@wpmobilebot
Copy link
Copy Markdown
Collaborator

wpmobilebot commented May 15, 2026

📊 Performance Test Results

Comparing 5be086b vs trunk

app-size

Metric trunk 5be086b Diff Change
App Size (Mac) 1376.63 MB 1376.65 MB +0.02 MB ⚪ 0.0%

site-editor

Metric trunk 5be086b Diff Change
load 1503 ms 1467 ms 36 ms ⚪ 0.0%

site-startup

Metric trunk 5be086b Diff Change
siteCreation 8597 ms 8622 ms +25 ms ⚪ 0.0%
siteStartup 4934 ms 4941 ms +7 ms ⚪ 0.0%

Results are median values from multiple test runs.

Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff)

Copy link
Copy Markdown
Contributor

@epeicher epeicher left a comment

Choose a reason for hiding this comment

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

Thanks @gcsecsey, this functionality is great for handling the remote session functionality! I have tested it, and it works perfectly

Stopped Started
Image Image

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' ),
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.

Is removing this required as part of these changes?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch, this was unintentional, thanks for highlighting! I added it back in 6b1744c.

@gcsecsey
Copy link
Copy Markdown
Member Author

gcsecsey commented May 18, 2026

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? 🤔

@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.

@epeicher
Copy link
Copy Markdown
Contributor

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.

Yes, I agree with this, we could remove it as the bolt icon has all the functionality, so the additional toggle can be confusing.

gcsecsey added 7 commits May 19, 2026 13:32
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.
@gcsecsey
Copy link
Copy Markdown
Member Author

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.

Yes, I agree with this, we could remove it as the bolt icon has all the functionality, so the additional toggle can be confusing.

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.

Copy link
Copy Markdown
Contributor

@epeicher epeicher left a comment

Choose a reason for hiding this comment

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

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.

  1. Listener re-subscribes every render (use-remote-session-status.tsx:56-67). The inline arrow passed to useIpcListener is a fresh function each render, and useIpcListener lists listener in 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 in useCallback. Project-wide pattern though (see use-theme-details.tsx), so I'd leave it for a separate sweep rather than fix here.

  2. isMounted guard is inconsistent. The initial fetch on mount (use-remote-session-status.tsx:38-54) guards setStatus with isMounted, but refreshStatus (called after every transition) doesn't. After unmount, refreshStatus will still try to setStatus. React 18 silently no-ops, so this is harmless — just inconsistent. Either drop the isMounted flag on initial fetch (rely on React's silent no-op everywhere) or apply it in refreshStatus too.

  3. Nested try/finally in runTransition (lines 86-95) is defensive but a touch awkward. It guarantees the loading flags clear even if refreshStatus() rejects. Acceptable as written; just noting it's the only spot in the file that needs a second glance.

  4. useIpcListener reconciliation 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-transition refreshStatus will catch up. This is correct, and there's a test for it (hooks/tests/use-remote-session-status.test.tsx:167).

gcsecsey and others added 4 commits May 19, 2026 17:01
@gcsecsey gcsecsey enabled auto-merge (squash) May 20, 2026 13:19
gcsecsey added 2 commits May 20, 2026 14:32
…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
@gcsecsey gcsecsey merged commit b2ea0ae into trunk May 20, 2026
10 checks passed
@gcsecsey gcsecsey deleted the gcsecsey/remote-session-indicator branch May 20, 2026 15:08
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.

4 participants