Skip to content

feat(web): web-desktop bundle Encore Feature + responsive mobile UX#991

Open
ronaldeddings wants to merge 6 commits into
RunMaestro:rcfrom
ronaldeddings:feat/web-desktop-bundle
Open

feat(web): web-desktop bundle Encore Feature + responsive mobile UX#991
ronaldeddings wants to merge 6 commits into
RunMaestro:rcfrom
ronaldeddings:feat/web-desktop-bundle

Conversation

@ronaldeddings
Copy link
Copy Markdown
Contributor

@ronaldeddings ronaldeddings commented May 13, 2026

Summary

Ships the full Maestro desktop UI through the browser by mirroring every ipcMain handler over a WebSocket bridge. Renderer code runs unchanged in the browser via a Vite-aliased electron shim. Gated behind a new webDesktopBundle Encore Feature so the IPC surface stays sealed by default.

Architecture — bridge IPC mirror (1:1)

Server (src/main/web-server/handlers/bridgeHandlers.ts)

  • Dispatches bridge.invoke messages against ipcMain._invokeHandlers
  • Broadcasts WebContents.send calls as bridge.event to all web clients
  • Empty/unknown channels reject loudly; handler exceptions surface their messages

Web shim (src/web-desktop/electron-shim.ts)

  • Vite alias replaces the electron module at build time
  • WebSocket-backed ipcRenderer, contextBridge, reconnect-aware BridgeClient
  • Preload code runs unchanged in the browser

Bootstrap & build

  • src/web-desktop/bootstrap.ts polyfills window.process, loads preload then renderer
  • @sentry/electron/renderer aliased to a no-op shim
  • vite.config.web-desktop.mts outputs dist/web-desktop/, served at /<token>/desktop/

Encore Feature gate (4 enforcement points)

  • WebServer constructor takes getWebDesktopBundleEnabled callback
  • web-server-factory.ts wires it to settingsStore.encoreFeatures.webDesktopBundle
  • Gated: route registration, static asset mount, bridge hook install, bridge.invoke dispatch
  • Toggle via CLI: maestro-cli settings set encoreFeatures.webDesktopBundle true

Responsive mobile UX

  • BreakpointsuseViewportBreakpoint writes data-bp (xs/sm/md/lg/xl) on <html> so CSS targets without re-renders
  • Tri-state sidebar — open → collapsed strip → hidden; drawer-style overlay below md
  • Header — inline hamburger when sidebar hidden, context shown as {N}%, secondary buttons hide below 480px
  • Input toolbar — pills collapse behind on narrow viewports, nowrap to prevent wrap
  • File picker — touch-aware single-tap open, 500ms long-press → context menu, capture-phase mousedown swallower for iOS
  • Git diff — counter + side-by-side toggle hide below md, close becomes × with aria-label
  • Tab popover — edge-flip so options don't clip at right edge
  • Shell CSS100dvh + env(safe-area-inset-bottom) for iOS safe areas

iOS Safari fixes

  • generateId — feature-detects crypto.randomUUID (requires secure context on Safari) and falls back to generateUUID from shared/uuid.ts
  • File open resiliencePromise.allSettled so a failed fs.stat doesn't drop the file content from preview

Tests

  • New: bridgeHandlers.test.ts — 5 cases (empty channel, unknown channel, success, handler error, arg pass-through). Uses vi.hoisted to stub ipcMain._invokeHandlers without a real Electron runtime
  • Updated: GitDiffViewer, MainPanel, MainPanelHeader, SessionList, SidebarActions tests for renamed labels/icons

Validation

  • npm run lint (tsc all configs): clean
  • npm run lint:eslint: clean
  • npm run test: 27,844 passed / 108 skipped / 0 failed
  • npm run build: main + renderer + web + web-desktop + cli all built
  • Pre-push hook (validate:push): passed

How to test

maestro-cli settings set webInterfaceUseCustomPort true
maestro-cli settings set webInterfaceCustomPort 31999
maestro-cli settings set persistentWebLink true
maestro-cli settings set encoreFeatures.webDesktopBundle true

Then open http://<host>:31999/<token>/desktop/ from your browser — Safari, Chrome, mobile over Tailscale all supported.

Out-of-scope notes

Three files included at Ron's request (personal workstreams, separate from the bundle):

  • PRD-CY-CONTEXT-RUNTIME.md — Cy runtime integration PRD
  • scripts/import-vram-sessions.ts — session import utility
  • scripts/populate-real-conversations.ts — conversation data utility

Summary by CodeRabbit

  • New Features

    • Web-desktop bundle served at a secure token-scoped endpoint; desktop build/dev targets added
    • Responsive narrow-viewport UI: auto-collapsing drawers, mobile backdrop, optimized headers and toolbars
    • Touch support: long-press file actions and single-tap opens on touch devices
    • Left-sidebar cycles: expanded → collapsed strip → hidden
    • Improved viewport-aware controls and accessible close buttons
  • Bug Fixes

    • More resilient remote file loading with safer fallbacks
    • Safer ID generation when crypto APIs are unavailable
  • Tests

    • Added/updated tests for IPC bridge and responsive UI behavior

Review Change Stack

Ships the full Maestro desktop UI through the browser by mirroring every
ipcMain handler over a WebSocket bridge. Renderer code runs unchanged in
the browser via a Vite-aliased electron shim. Gated behind a new
`webDesktopBundle` Encore Feature so the IPC surface stays sealed by
default.

## Bridge architecture (1:1 IPC mirror)
- **Server (`src/main/web-server/handlers/bridgeHandlers.ts`)**: dispatches
  `bridge.invoke` messages against Electron's `ipcMain._invokeHandlers`
  Map and broadcasts `WebContents.send` calls as `bridge.event` to all
  connected web clients. Empty/unknown channels reject loudly; handler
  exceptions surface their messages to the caller.
- **Web shim (`src/web-desktop/electron-shim.ts`)**: replaces the
  `electron` module at build time with a WebSocket-backed `ipcRenderer`,
  `contextBridge`, and reconnect-aware `BridgeClient`. Preload code runs
  unchanged in the browser.
- **Bootstrap (`src/web-desktop/bootstrap.ts`)**: polyfills `window.process`,
  loads preload first, then the renderer. `@sentry/electron/renderer` is
  aliased to a no-op shim.
- **Vite config (`vite.config.web-desktop.mts`)**: outputs `dist/web-desktop/`,
  served by `WebServer` at `/<token>/desktop/` when the Encore Feature
  is on.

## Encore Feature gate (4 enforcement points)
- `WebServer` constructor takes `getWebDesktopBundleEnabled` callback
- `web-server-factory.ts` wires it to `settingsStore.encoreFeatures.webDesktopBundle`
- Gated: route registration, static asset mount, bridge hook install,
  and `bridge.invoke` dispatch in `messageHandlers.ts`
- Type: `EncoreFeatureFlags.webDesktopBundle: boolean` in `renderer/types/index.ts`

## Responsive mobile UX
- **Breakpoints (`useViewportBreakpoint.ts`)**: xs/sm/md/lg/xl tracked via
  `data-bp` on `<html>` so CSS can target without re-renders.
- **Tri-state left sidebar (`uiStore.cycleLeftSidebar`)**: open → collapsed
  strip → hidden, drawer-style overlay below `md`.
- **Header (`MainPanelHeader.tsx`)**: inline hamburger when sidebar hidden,
  context usage shown as `{N}%` (was full-width gauge), secondary buttons
  hide below 480px.
- **Input toolbar (`InputArea.tsx`)**: collapses History/Plan/Thinking
  pills behind a `…` toggle on narrow viewports; nowrap to prevent wrap.
- **File picker (`FileExplorerPanel.tsx`)**: touch-aware single-tap open,
  500ms long-press → context menu, capture-phase mousedown swallower so
  iOS Safari's synthetic mousedown doesn't dismiss the menu.
- **Git diff (`GitDiffViewer.tsx`)**: hides counter + side-by-side toggle
  below `md`, close button becomes × icon with `aria-label`.
- **Tab popover (`NewTabPopover.tsx`)**: edge-flip so options don't get
  clipped at right viewport edge.
- **App shell CSS**: `100dvh` + `env(safe-area-inset-bottom)` for iOS;
  `select-none` discipline preserved.

## iOS Safari fixes
- **`generateId` (`utils/ids.ts`)**: feature-detects `crypto.randomUUID`
  (Safari requires secure context) and falls back to `generateUUID()`
  from `shared/uuid.ts` over plain HTTP/Tailscale.
- **File open resilience (`useAppHandlers.ts`)**: `Promise.allSettled` so
  a failed `fs.stat` doesn't drop the file content from preview.

## Tests
- New: `bridgeHandlers.test.ts` — 5 cases covering empty channel rejection,
  unknown channel, success path, handler errors, and arg pass-through.
  Uses `vi.hoisted` to stub `ipcMain._invokeHandlers` without a real
  Electron runtime.
- Updated: GitDiffViewer, MainPanel, MainPanelHeader, SessionList,
  SidebarActions tests adjusted for renamed labels/icons.

## Validation
- `npm run lint` (tsc all configs): clean
- `npm run lint:eslint`: clean
- `npm run test`: 27,844 passed / 108 skipped / 0 failed
- `npm run build`: main + renderer + web + web-desktop + cli all built
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a web-desktop bundle (Vite build, entry HTML/bootstrap), browser Electron/Sentry shims, a WebSocket↔IPC bridge with server-side handlers and lifecycle hooks gated by a runtime feature flag, responsive breakpoint hook/CSS and app-level narrow-viewport behaviors, tri-state sidebar and touch support, tests, and resilience fixes.

Changes

Web-Desktop Bundle Implementation

Layer / File(s) Summary
Web-Desktop Build Configuration and Scripts
package.json, vite.config.web-desktop.mts
Vite configuration for web-desktop bundle with version injection, React optimization, Electron/Sentry import aliases, and npm dev:web-desktop and build:web-desktop scripts integrated into the main build pipeline.
Web-Desktop Entry Point and Runtime Setup
src/web-desktop/bootstrap.ts, src/web-desktop/index.html
Bootstrap entry point with Node/Electron compatibility shims, sequential preload→renderer imports, and error rendering for web environment initialization.
Electron and Sentry Browser Shims
src/web-desktop/electron-shim.ts, src/web-desktop/sentry-shim.ts
Browser-side implementations of Electron APIs (ipcRenderer, contextBridge, shell, webFrame) via WebSocket bridge client, and no-op Sentry stubs matching renderer API shape.

IPC Bridge and Server

Layer / File(s) Summary
Bridge handlers and messages
src/main/web-server/handlers/bridgeHandlers.ts, src/__tests__/main/web-server/handlers/bridgeHandlers.test.ts
Defines bridge.invoke/bridge.response/bridge.event wire formats, installs/uninstalls WebContents send hook to broadcast bridge.event, and implements handleBridgeInvoke with validation and handler dispatch; tests cover success/error/argument forwarding.
Message routing & feature gate
src/main/web-server/handlers/messageHandlers.ts, src/main/web-server/WebServer.ts, src/main/web-server/web-server-factory.ts
Adds bridge.invoke handling in message dispatcher, WebServer constructor accepts runtime getWebDesktopBundleEnabled callback, conditionally mounts desktop assets and token-scoped /desktop routes, injects runtime config, and installs/uninstalls the bridge hook during start/stop.

Responsive UI and Mobile Layout

Layer / File(s) Summary
Viewport Breakpoint Classification and CSS
src/renderer/hooks/ui/useViewportBreakpoint.ts, src/renderer/hooks/ui/index.ts, src/renderer/index.css
React hook deriving responsive breakpoints (xs/sm/md/lg/xl) from viewport width, setting document data-bp attribute, with CSS styling for narrow-viewport overlays, fixed-position drawers, mobile safe-area padding, and responsive visibility.
Tri-State Sidebar Store and State Attributes
src/renderer/stores/uiStore.ts, src/renderer/components/SessionList/SessionList.tsx
UIStore tracks leftSidebarHidden alongside leftSidebarOpen for tri-state (expanded, collapsed-strip, hidden) with cycleLeftSidebar action; SessionList exposes sidebar state via data-panel, data-collapsed, and data-hidden attributes.
Sidebar Toggle UI and Header Integration
src/renderer/components/SessionList/SidebarActions.tsx, src/renderer/components/MainPanel/MainPanelHeader.tsx
SidebarActions uses cycleLeftSidebar from store instead of prop callback; MainPanelHeader conditionally renders hamburger menu when sidebar hidden; SessionList no longer passes setLeftSidebarOpen.
App-Level Responsive Behavior and Mobile Backdrop
src/renderer/App.tsx
App.tsx integrates useViewportBreakpoint to auto-collapse drawers on narrow viewports, enforce drawer mutual exclusion, adjust top padding and title bar visibility, and render mobile backdrop for closing drawers on outside tap.
Responsive Layout Updates Across Components
src/renderer/components/InputArea.tsx, src/renderer/components/GitDiffViewer.tsx, src/renderer/components/TabBar/NewTabPopover.tsx, src/renderer/components/RightPanel.tsx
InputArea collapses toolbar controls behind a MoreHorizontal button; GitDiffViewer hides file counter and layout toggle on small screens and adds accessible close labels; NewTabPopover prevents viewport overflow via right-alignment; RightPanel exposes open state attribute.
Touch Device Support and Accessibility Enhancements
src/renderer/components/FileExplorerPanel.tsx, src/renderer/components/GitDiffViewer.tsx, src/renderer/components/MainPanel/MainPanelHeader.tsx
FileExplorerPanel adds long-press context menu and single-tap file open for touch devices; GitDiffViewer close buttons include aria-label and responsive "×" icon on small screens; MainPanelHeader simplifies context widget to percentage readout.
Test Updates for Responsive and Sidebar Changes
src/__tests__/renderer/components/*, src/__tests__/renderer/components/SessionList/*
Tests updated to query by data-testid/accessible name instead of text, verify tri-state sidebar cycling via store state assertions, assert DOM state attributes (data-collapsed, data-hidden), and validate tooltip hover/mouseleave sequences.

Error Handling and Resilience Improvements

Layer / File(s) Summary
Crypto Fallback and Parallel File Operations
src/renderer/utils/ids.ts, src/renderer/hooks/ui/useAppHandlers.ts
generateId uses crypto.randomUUID with fallback to generateUUID when unavailable; file loading operations use Promise.allSettled for parallel stat and content reads with independent error handling and tab cleanup on read failure.

Sequence Diagram(s)

sequenceDiagram
  participant WebClient
  participant WebServer
  participant MessageHandler
  participant ipcMain
  participant WebContents
  WebClient->>WebServer: send "bridge.invoke" (requestId, channel, args)
  WebServer->>MessageHandler: handleMessage -> detects "bridge.invoke"
  MessageHandler->>ipcMain: dispatch to registered handler (channel, event, ...args)
  ipcMain->>MessageHandler: handler result / throws
  MessageHandler->>WebClient: send "bridge.response" (ok/result|error)
  WebContents->>WebServer: webContents.send(channel,args)
  WebServer->>WebClient: broadcast "bridge.event" (channel,args,timestamp)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

ready to merge

"A rabbit hops through code both wide and small,
Sidebars cycle, touch events answer the call,
Web shims bridge worlds with a cheerful ping,
Narrow viewports dance — hooray, let's bring
Maestro to the web and watch it enthrall."

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.74% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main changes: introducing the web-desktop bundle Encore Feature and adding responsive mobile UX improvements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 18

🧹 Nitpick comments (9)
src/renderer/components/FileExplorerPanel.tsx (1)

1178-1200: 💤 Low value

Consider also gating folder expand/collapse on isTouchPointer.

On a touch device, the user expects a single tap on a folder row to expand it (which already works), but a single tap on a file opens it (new behavior, good). The current onClick is fine, but onDoubleClick at Line 1201-1205 is still wired up on touch devices where it will essentially never fire reliably — and on iOS, the synthesized second click after a double-tap may also be hijacked by the browser's zoom gesture. Not a defect, just dead code on touch. Worth a quick isTouchPointer ? undefined : onDoubleClick to make intent obvious, but feel free to skip.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/components/FileExplorerPanel.tsx` around lines 1178 - 1200, The
double-click handler should be disabled on touch devices: update the JSX where
the row's onDoubleClick is set (the same component that uses isTouchPointer and
handleFileClick in the onClick block) to conditionally attach the handler only
when !isTouchPointer (e.g. onDoubleClick={isTouchPointer ? undefined : (e)=>{ /*
existing dblclick logic that calls handleFileClick/toggleFolder... */ }}), so
touch devices won't receive a never-fired or synthesized second click while
desktop mice still get double-click behavior.
src/renderer/hooks/ui/useAppHandlers.ts (1)

322-372: 💤 Low value

Promise.allSettled split looks correct and matches the bridge-failure scenario described in the PR.

The decoupling of readFile from stat is the right fix — over the WebSocket bridge a stat rejection should not throw away successfully-read content. A couple of small notes you may want to address:

  • Line 354-359: content === null is reachable when contentResult.status === 'fulfilled' and the value is null. That's still handled correctly, but the comment "either the file is missing or the SSH read was cancelled" is now slightly misleading because read-failure is no longer routed here (it's the rejected branch above). Consider tightening the comment to "cancelled or returned null".
  • Line 361: statResult.value is typed as Awaited<ReturnType<typeof window.maestro.fs.stat>>, which depending on its declaration may itself be nullable; the stat?.modifiedAt chain on Line 373 already guards this, so functionally fine.

No functional changes required.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/ui/useAppHandlers.ts` around lines 322 - 372, The comment
above the content-null check is slightly misleading: update the comment near the
`content === null` check (in the block handling `contentResult` from
`Promise.allSettled` for `window.maestro.fs.readFile`) to say the null case
means the read was cancelled or the API returned null (e.g., "cancelled or
returned null") instead of implying a read failure; no code logic changes
needed—leave the `rejected` branch, `closeLoadingTabIfStillLoading`, and the
`statResult` handling (`window.maestro.fs.stat` / `stat?.modifiedAt`) as-is.
src/renderer/components/MainPanel/MainPanelHeader.tsx (1)

115-132: 💤 Low value

Consider extracting stable store action references.

The onClick handler directly calls useUIStore.getState() twice. For consistency with the rest of the component (which extracts stable store actions like setActiveFocus, setBookmarksCollapsed, etc.), consider defining stable references at the component level:

// Near other stable store actions
const setLeftSidebarHidden = useUIStore.getState().setLeftSidebarHidden;
const setLeftSidebarOpen = useUIStore.getState().setLeftSidebarOpen;

Then use them in the handler:

onClick={() => {
    setLeftSidebarHidden(false);
    setLeftSidebarOpen(true);
}}

This avoids creating new closures on each render and matches the established pattern in this file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/components/MainPanel/MainPanelHeader.tsx` around lines 115 -
132, The onClick handler for the inline hamburger calls useUIStore.getState()
twice instead of using stable action references; extract the actions at
component scope by assigning const setLeftSidebarHidden =
useUIStore.getState().setLeftSidebarHidden and const setLeftSidebarOpen =
useUIStore.getState().setLeftSidebarOpen (near the other stable store actions),
then update the button onClick to call setLeftSidebarHidden(false) and
setLeftSidebarOpen(true) so you avoid repeated getState() calls and match the
file's established pattern (the conditional rendering stays controlled by
leftSidebarHidden).
scripts/import-vram-sessions.ts (2)

173-173: Arbitrary auto-starring threshold.

Files >= 10MB are automatically starred. While this is a utility script and the threshold is reasonable, consider making it configurable or documenting the rationale.

You could add a constant at the top:

const AUTO_STAR_THRESHOLD = 10 * 1024 * 1024; // 10MB

Then use file.size >= AUTO_STAR_THRESHOLD on line 173.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/import-vram-sessions.ts` at line 173, The auto-starring threshold is
hard-coded as a magic number; introduce a named constant (e.g.,
AUTO_STAR_THRESHOLD) at the top of scripts/import-vram-sessions.ts and replace
the literal 10 * 1024 * 1024 used in the starred assignment (file.size >= 10 *
1024 * 1024) with file.size >= AUTO_STAR_THRESHOLD so the threshold is
configurable and self-documenting; optionally allow overriding via env/config
and add a brief comment explaining the value.

72-74: ⚡ Quick win

Empty catch block silently swallows parsing errors.

When JSONL parsing fails, the error is silently ignored. This makes it difficult to diagnose malformed data or detect if important conversation entries are being skipped.

🔍 Proposed improvement to log parsing failures
 		} catch (e) {
-			// Skip invalid lines
+			// Skip invalid lines but log for debugging
+			if (process.env.DEBUG) {
+				console.warn(`  Warning: Failed to parse line in ${jsonlPath}: ${e}`);
+			}
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/import-vram-sessions.ts` around lines 72 - 74, The empty catch block
that swallows JSONL parsing errors (the catch (e) in the JSONL parsing loop)
should be changed to log the parsing failure and context instead of silently
skipping: capture the exception and log a warning or error including the thrown
error message, the offending line content, and its index/line number (then
continue to next line). Update the catch (e) block to call the project logger or
console (e.g., processLogger.warn or console.warn) with a clear message
referencing the failed line and include e.stack or e.message so malformed
entries are visible during import.
scripts/populate-real-conversations.ts (3)

118-122: Document the reprocessing logic.

The condition for detecting placeholder content is specific but lacks explanation. A brief comment would help future maintainers understand when tabs are reprocessed.

+		// Reprocess tabs with corrupted data ([object Object]) or only system placeholders
 		const needsReprocess = tab.logs.some(
 			(log) =>
 				log.text.includes('[object Object]') ||
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/populate-real-conversations.ts` around lines 118 - 122, Add a brief
inline comment above the needsReprocess calculation explaining why those
specific checks trigger reprocessing: that any log whose text contains the
literal "[object Object]" indicates a malformed/serialized object placeholder
and that a system log with text "Session loaded from Claude Code" denotes an
import/resume marker requiring reprocessing; reference the needsReprocess
variable and the tab.logs predicate (the includes('[object Object]') check and
the (log.source === 'system' && log.text === 'Session loaded from Claude Code')
check) so future maintainers understand the intent and can update the conditions
safely.

88-90: ⚡ Quick win

Empty catch block silently swallows parsing errors.

Same pattern as the other script. Consider logging parse failures when debugging.

🔍 Proposed improvement
 		} catch (e) {
-			// Skip invalid JSON lines
+			// Skip invalid JSON lines but log for debugging
+			if (process.env.DEBUG) {
+				console.warn(`  Warning: Failed to parse JSONL line: ${e}`);
+			}
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/populate-real-conversations.ts` around lines 88 - 90, The empty catch
block after the JSON.parse in scripts/populate-real-conversations.ts is silently
swallowing parse errors; update the catch(e) block that currently only comments
"Skip invalid JSON lines" to log the error and the raw line (or line index)
being parsed (e.g., use console.warn or the module's logger) so you still skip
invalid lines but retain useful debug info; keep behavior unchanged otherwise
(do not rethrow).

133-140: Hard-coded project paths reduce portability.

The script includes six hard-coded project path patterns specific to one developer's directory structure. This makes the script non-portable and brittle if directory names change.

Consider:

  1. Reading project directories dynamically from CLAUDE_PROJECTS_PATH
  2. Moving these paths to a configuration file
  3. Adding a command-line argument to specify additional search paths

Example of dynamic approach:

// Read all subdirectories in Claude projects
const projectPaths = readdirSync(CLAUDE_PROJECTS_PATH, { withFileTypes: true })
	.filter(dirent => dirent.isDirectory())
	.map(dirent => dirent.name);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/populate-real-conversations.ts` around lines 133 - 140, The
hard-coded array projectPaths in scripts/populate-real-conversations.ts makes
the script non-portable; replace the static list by dynamically discovering
project directories (and/or accepting overrides) using the existing
CLAUDE_PROJECTS_PATH environment variable and a CLI option: read subdirectories
from CLAUDE_PROJECTS_PATH (e.g., via readdirSync with withFileTypes) to build
projectPaths at runtime, fall back to a small config/default list if
CLAUDE_PROJECTS_PATH is unset, and add a command-line flag to accept additional
paths that are appended to the discovered list so callers can override or extend
behavior.
src/web-desktop/bootstrap.ts (1)

46-54: ⚡ Quick win

Prefer textContent over innerHTML for error display.

Using innerHTML to render error messages can introduce XSS risk if the error text contains special characters. While the risk is low here (errors come from module loading, not user input), using textContent is safer and simpler.

♻️ Safer error rendering
 void boot().catch((err) => {
 	const root = document.getElementById('root');
 	if (root) {
-		root.innerHTML = `<div style="padding:24px;font-family:monospace;color:`#f88`;background:`#111`;min-height:100vh"><h2>Web-Desktop bootstrap failed</h2><pre>${
-			(err && (err.stack || err.message)) || String(err)
-		}</pre></div>`;
+		const container = document.createElement('div');
+		container.style.cssText = 'padding:24px;font-family:monospace;color:`#f88`;background:`#111`;min-height:100vh';
+		const heading = document.createElement('h2');
+		heading.textContent = 'Web-Desktop bootstrap failed';
+		const pre = document.createElement('pre');
+		pre.textContent = (err && (err.stack || err.message)) || String(err);
+		container.appendChild(heading);
+		container.appendChild(pre);
+		root.innerHTML = '';
+		root.appendChild(container);
 	}
 	console.error('[bootstrap] boot failed', err);
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/web-desktop/bootstrap.ts` around lines 46 - 54, The error rendering
currently sets root.innerHTML with interpolated error text (in the boot().catch
handler), which risks XSS; instead create the container elements and set their
text via element.textContent (or createTextNode) so the error string is not
interpreted as HTML. Update the catch block that finds
document.getElementById('root') to build a wrapper div and a pre (or text node)
programmatically, apply the same inline styles via element.style or className,
and assign the error payload using (err && (err.stack || err.message)) ||
String(err) to pre.textContent; keep the console.error call unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@package.json`:
- Around line 27-30: The root "build" npm script omits the new
"build:web-desktop" target so the web-desktop bundle isn't produced; update the
"build" script to include "npm run build:web-desktop" in the chain (e.g., add &&
npm run build:web-desktop alongside build:main, build:preload, build:renderer,
build:web and build:cli) so dist/web-desktop is generated during the primary
build pipeline.

In `@scripts/import-vram-sessions.ts`:
- Line 131: The two writeFileSync calls (e.g., writeFileSync(GROUPS_PATH,
JSON.stringify(groupsData, ...)) and the final writeFileSync that persists
session data) lack error handling and can cause data loss; wrap these filesystem
writes in try/catch, write to a temp file first (e.g., GROUPS_PATH + '.tmp' /
sessions tmp), fsync/close, then rename to the real path to make the write
atomic, and on catch log the error (include the caught error) and abort/rollback
so the in-memory removal of the VRAM session is not persisted when the disk
write failed; also consider creating a backup copy of the original file before
overwriting so you can restore on failure.
- Line 65: The id fallback uses Date.now() + Math.random(), creating
inconsistent IDs; change the id assignment for the object (where it currently
reads id: entry.uuid || `${Date.now()}-${Math.random().toString(36).slice(2,
11)}`) to use the same randomUUID() approach used earlier (the randomUUID usage
on line 49) so both branches produce the same UUID format and strength; ensure
you call the same randomUUID() symbol (or import it the same way as the earlier
usage) when entry.uuid is falsy.
- Around line 116-119: Wrap the JSON parsing of SESSIONS_PATH and GROUPS_PATH in
try/catch to validate and handle corrupted JSON: when reading sessionsData and
groupsData (JSON.parse of readFileSync for SESSIONS_PATH and conditional parse
for GROUPS_PATH), catch any SyntaxError, log a clear error via console.error or
processLogger including the path (SESSIONS_PATH / GROUPS_PATH) and the parse
error message, and exit the process (process.exit(1)) so the script doesn't
continue with invalid data; optionally recommend creating a backup or skipping
overwrite only after successful validation.
- Around line 89-103: The directory scanning loop using readdirSync(JSONL_DIR)
and statSync(fullPath) can throw on missing directories or permission errors;
wrap the outer readdirSync call in a try-catch to surface a clear error
(including JSONL_DIR) and abort/exit gracefully, and wrap the per-file
statSync(fullPath) in its own try-catch so a single bad file won't crash the
whole import—use the existing files array push logic unchanged when stat
succeeds, and log or rethrow errors with context referencing JSONL_DIR,
fullPath, MIN_SIZE, and cutoff so failures are easy to diagnose.
- Around line 13-16: The code assumes HOME and the derived constants HOME,
SESSIONS_PATH, GROUPS_PATH and JSONL_DIR are valid but doesn't validate them;
add early validation after those constants are defined: check that HOME is
non-empty and that SESSIONS_PATH, GROUPS_PATH and JSONL_DIR exist and are
readable (using fs.existsSync or fs.statSync), and if any check fails throw or
log a clear error that includes which path failed and why so file operations in
functions like the import routine fail fast with actionable messages.

In `@scripts/populate-real-conversations.ts`:
- Line 171: Wrap the writeFileSync(SESSIONS_PATH, JSON.stringify(sessionsData,
null, '\t')) call in a try/catch and handle failures: before overwriting, back
up the current SESSIONS_PATH (e.g., write existing file to SESSIONS_PATH +
'.bak'), then write the new sessionsData atomically by writing to a temp file
and fs.rename to SESSIONS_PATH; if any step throws, log the error (including the
thrown error object) and exit non‑zero so failures are visible. Ensure you
reference the same symbols (writeFileSync / SESSIONS_PATH / sessionsData and
note that tab.logs was mutated earlier) so the change is applied at the correct
spot.
- Line 98: The JSON.parse call that creates sessionsData from
readFileSync(SESSIONS_PATH, 'utf-8') lacks error handling; wrap the read+parse
in a try/catch (around the JSON.parse and/or readFileSync used to populate
sessionsData) and on failure log a clear error including SESSIONS_PATH and the
parse exception (or exit gracefully), so the script reports a helpful message
instead of crashing.
- Line 81: Fallback ID generation for entries uses a Date.now()+Math.random()
string; replace the fallback in the object where you set "id: entry.uuid ||
`${Date.now()}-${Math.random()...}`" with a proper UUID (e.g.,
crypto.randomUUID()) to match other scripts, and add the necessary import or use
the global crypto API so the code compiles (update the line that constructs the
id and ensure crypto.randomUUID is available).
- Around line 10-15: The script assumes process.env.HOME and the constructed
SESSIONS_PATH and CLAUDE_PROJECTS_PATH exist before any file operations; add
early validation that process.env.HOME is defined and that both paths exist and
are readable (use fs.existsSync or fs.statSync/try-catch) before proceeding, and
if any check fails log a clear error via console.error or processLogger and exit
(process.exit(1)) so downstream code that uses SESSIONS_PATH or
CLAUDE_PROJECTS_PATH (the constants defined at top) does not attempt file
operations on missing paths.

In `@src/__tests__/renderer/components/MainPanel.test.tsx`:
- Around line 1108-1109: Update the negative assertions in MainPanel.test.tsx to
check for absence of the context widget by using
queryByTestId('header-context-widget') instead of asserting missing 'Context
Window' text; locate the assertions around the existing expects that reference
getByTestId/getByText and replace them with expectations that
queryByTestId('header-context-widget') returns null (or
toBeNull()/not.toBeInTheDocument()) so the test fails when the widget DOM node
is present even if its label text changes.

In `@src/main/web-server/handlers/bridgeHandlers.ts`:
- Around line 82-86: The try/catch around the bridge fanout (the call to
broadcastSink?.(channel, args) currently logging via logger.warn) is swallowing
errors without reporting them to Sentry; import and call the Sentry utilities
(captureException or captureMessage) from src/utils/sentry.ts in those catch
blocks (the one around broadcastSink and the other block around lines 136-143)
and pass the caught error plus contextual info (channel, args and LOG_CONTEXT or
other relevant identifiers) so exceptions are recorded in production diagnostics
while preserving the existing logger.warn behavior.

In `@src/main/web-server/web-server-factory.ts`:
- Around line 111-117: The code assumes settingsStore.get('encoreFeatures')
returns an object and directly casts to Record<string, unknown>, which can throw
if the stored value is null or a non-object; update the WebServer feature-check
callback to first retrieve the raw value (e.g., const efRaw =
settingsStore.get('encoreFeatures')), then guard its shape with a runtime check
(typeof efRaw === 'object' && efRaw !== null) before casting to Record<string,
unknown> or accessing webDesktopBundle, and finally return (ef.webDesktopBundle
=== true) only when the guard passes (otherwise return false); reference the
WebServer constructor callback, settingsStore.get, ef/efRaw, and
webDesktopBundle.

In `@src/renderer/App.tsx`:
- Around line 577-582: The mutual-exclusion effect currently always closes the
right panel whenever both drawers are open, which causes the right panel to
immediately close when the user attempts to open it; change the logic to act on
the transition that opened a drawer instead of the static both-open state: when
isNarrowViewport and leftSidebarOpen just transitioned to true while
rightPanelOpen is true, call useUIStore.getState().setRightPanelOpen(false);
likewise, when isNarrowViewport and rightPanelOpen just transitioned to true
while leftSidebarOpen is true, call
useUIStore.getState().setLeftSidebarOpen(false); implement this by tracking
previous values with refs (e.g., prevLeftSidebarOpen / prevRightPanelOpen) or by
splitting into two effects that depend on each drawer flag to ensure the newly
opened drawer remains open and the other drawer is closed.

In `@src/renderer/components/FileExplorerPanel.tsx`:
- Around line 1134-1164: The long-press timer and swallow listeners started in
onTouchStart can fire after FileExplorerPanelInner unmounts or after the context
menu is opened elsewhere; cancel the pending timer and remove any attached
document listeners during unmount and whenever setContextMenu opens/changes to
avoid calling setSelectedFileIndex/setContextMenu on an unmounted component and
prevent stray swallow listeners; implement this by tracking the timer id in
longPressTimerRef and the swallow handler, adding a useEffect cleanup in
FileExplorerPanelInner that clears longPressTimerRef.current, resets
longPressFiredRef.current = false, and removes the swallow listeners, and also
ensure any code-path that opens the context menu clears an existing
longPressTimerRef before setting a new menu.

In `@src/renderer/components/SessionList/SidebarActions.tsx`:
- Around line 53-59: The tooltip logic for the sidebar toggle uses
leftSidebarOpen and shows "Hide sidebar" even when leftSidebarHidden is true
(fully hidden) but leftSidebarOpen is false; update the title computation in
SidebarActions to reflect the next action (show/expand) when leftSidebarHidden
=== true by branching on leftSidebarHidden first (in addition to leftSidebarOpen
and hasNoSessions) and use a label like "Show sidebar" or "Expand sidebar"
combined with toggleSidebarShortcutLabel so the title matches the actual toggle
behavior governed by the uiStore cycle logic.

In `@src/renderer/components/TabBar/NewTabPopover.tsx`:
- Line 79: The overflow check uses a hardcoded POPOVER_WIDTH = 200 but the
popover's style sets minWidth: 180, causing incorrect alignment calculations;
update the NewTabPopover component to use a single shared constant for the
popover width (e.g., POPOVER_MIN_WIDTH) and reference that constant both in the
overflow calculation (where POPOVER_WIDTH is used) and in the style minWidth so
the logic and rendered size match (or compute the measured width dynamically
from the popover element for the overflow check if variable sizing is required);
make the change around the POPOVER_WIDTH definition and the style assignment
that sets minWidth.

In `@src/web-desktop/sentry-shim.ts`:
- Around line 7-8: The no-op implementations of captureException and
captureMessage drop all reports; update these functions in sentry-shim.ts to
delegate to the actual Sentry utilities from src/utils/sentry.ts (import the
real captureException/captureMessage there) and forward the incoming parameters
(error/message, level, ctx) so events and context are preserved; keep the
existing signatures (captureException(_err: unknown, _ctx?: unknown): void and
captureMessage(_msg: unknown, _level?: unknown, _ctx?: unknown): void) but call
the corresponding exports from src/utils/sentry.ts and ensure any exceptions
during forwarding are not swallowed silently (let them propagate or log).

---

Nitpick comments:
In `@scripts/import-vram-sessions.ts`:
- Line 173: The auto-starring threshold is hard-coded as a magic number;
introduce a named constant (e.g., AUTO_STAR_THRESHOLD) at the top of
scripts/import-vram-sessions.ts and replace the literal 10 * 1024 * 1024 used in
the starred assignment (file.size >= 10 * 1024 * 1024) with file.size >=
AUTO_STAR_THRESHOLD so the threshold is configurable and self-documenting;
optionally allow overriding via env/config and add a brief comment explaining
the value.
- Around line 72-74: The empty catch block that swallows JSONL parsing errors
(the catch (e) in the JSONL parsing loop) should be changed to log the parsing
failure and context instead of silently skipping: capture the exception and log
a warning or error including the thrown error message, the offending line
content, and its index/line number (then continue to next line). Update the
catch (e) block to call the project logger or console (e.g., processLogger.warn
or console.warn) with a clear message referencing the failed line and include
e.stack or e.message so malformed entries are visible during import.

In `@scripts/populate-real-conversations.ts`:
- Around line 118-122: Add a brief inline comment above the needsReprocess
calculation explaining why those specific checks trigger reprocessing: that any
log whose text contains the literal "[object Object]" indicates a
malformed/serialized object placeholder and that a system log with text "Session
loaded from Claude Code" denotes an import/resume marker requiring reprocessing;
reference the needsReprocess variable and the tab.logs predicate (the
includes('[object Object]') check and the (log.source === 'system' && log.text
=== 'Session loaded from Claude Code') check) so future maintainers understand
the intent and can update the conditions safely.
- Around line 88-90: The empty catch block after the JSON.parse in
scripts/populate-real-conversations.ts is silently swallowing parse errors;
update the catch(e) block that currently only comments "Skip invalid JSON lines"
to log the error and the raw line (or line index) being parsed (e.g., use
console.warn or the module's logger) so you still skip invalid lines but retain
useful debug info; keep behavior unchanged otherwise (do not rethrow).
- Around line 133-140: The hard-coded array projectPaths in
scripts/populate-real-conversations.ts makes the script non-portable; replace
the static list by dynamically discovering project directories (and/or accepting
overrides) using the existing CLAUDE_PROJECTS_PATH environment variable and a
CLI option: read subdirectories from CLAUDE_PROJECTS_PATH (e.g., via readdirSync
with withFileTypes) to build projectPaths at runtime, fall back to a small
config/default list if CLAUDE_PROJECTS_PATH is unset, and add a command-line
flag to accept additional paths that are appended to the discovered list so
callers can override or extend behavior.

In `@src/renderer/components/FileExplorerPanel.tsx`:
- Around line 1178-1200: The double-click handler should be disabled on touch
devices: update the JSX where the row's onDoubleClick is set (the same component
that uses isTouchPointer and handleFileClick in the onClick block) to
conditionally attach the handler only when !isTouchPointer (e.g.
onDoubleClick={isTouchPointer ? undefined : (e)=>{ /* existing dblclick logic
that calls handleFileClick/toggleFolder... */ }}), so touch devices won't
receive a never-fired or synthesized second click while desktop mice still get
double-click behavior.

In `@src/renderer/components/MainPanel/MainPanelHeader.tsx`:
- Around line 115-132: The onClick handler for the inline hamburger calls
useUIStore.getState() twice instead of using stable action references; extract
the actions at component scope by assigning const setLeftSidebarHidden =
useUIStore.getState().setLeftSidebarHidden and const setLeftSidebarOpen =
useUIStore.getState().setLeftSidebarOpen (near the other stable store actions),
then update the button onClick to call setLeftSidebarHidden(false) and
setLeftSidebarOpen(true) so you avoid repeated getState() calls and match the
file's established pattern (the conditional rendering stays controlled by
leftSidebarHidden).

In `@src/renderer/hooks/ui/useAppHandlers.ts`:
- Around line 322-372: The comment above the content-null check is slightly
misleading: update the comment near the `content === null` check (in the block
handling `contentResult` from `Promise.allSettled` for
`window.maestro.fs.readFile`) to say the null case means the read was cancelled
or the API returned null (e.g., "cancelled or returned null") instead of
implying a read failure; no code logic changes needed—leave the `rejected`
branch, `closeLoadingTabIfStillLoading`, and the `statResult` handling
(`window.maestro.fs.stat` / `stat?.modifiedAt`) as-is.

In `@src/web-desktop/bootstrap.ts`:
- Around line 46-54: The error rendering currently sets root.innerHTML with
interpolated error text (in the boot().catch handler), which risks XSS; instead
create the container elements and set their text via element.textContent (or
createTextNode) so the error string is not interpreted as HTML. Update the catch
block that finds document.getElementById('root') to build a wrapper div and a
pre (or text node) programmatically, apply the same inline styles via
element.style or className, and assign the error payload using (err &&
(err.stack || err.message)) || String(err) to pre.textContent; keep the
console.error call unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c274c450-6611-4a59-adcf-00b0e6980491

📥 Commits

Reviewing files that changed from the base of the PR and between 339574d and 3b67299.

📒 Files selected for processing (36)
  • PRD-CY-CONTEXT-RUNTIME.md
  • package.json
  • scripts/import-vram-sessions.ts
  • scripts/populate-real-conversations.ts
  • src/__tests__/main/web-server/handlers/bridgeHandlers.test.ts
  • src/__tests__/renderer/components/GitDiffViewer.test.tsx
  • src/__tests__/renderer/components/MainPanel.test.tsx
  • src/__tests__/renderer/components/MainPanel/MainPanelHeader.test.tsx
  • src/__tests__/renderer/components/SessionList.test.tsx
  • src/__tests__/renderer/components/SessionList/SidebarActions.test.tsx
  • src/main/web-server/WebServer.ts
  • src/main/web-server/handlers/bridgeHandlers.ts
  • src/main/web-server/handlers/messageHandlers.ts
  • src/main/web-server/web-server-factory.ts
  • src/renderer/App.tsx
  • src/renderer/components/FileExplorerPanel.tsx
  • src/renderer/components/GitDiffViewer.tsx
  • src/renderer/components/InputArea.tsx
  • src/renderer/components/MainPanel/MainPanelHeader.tsx
  • src/renderer/components/RightPanel.tsx
  • src/renderer/components/SessionList/SessionList.tsx
  • src/renderer/components/SessionList/SidebarActions.tsx
  • src/renderer/components/TabBar/NewTabPopover.tsx
  • src/renderer/hooks/ui/index.ts
  • src/renderer/hooks/ui/useAppHandlers.ts
  • src/renderer/hooks/ui/useViewportBreakpoint.ts
  • src/renderer/index.css
  • src/renderer/stores/settingsStore.ts
  • src/renderer/stores/uiStore.ts
  • src/renderer/types/index.ts
  • src/renderer/utils/ids.ts
  • src/web-desktop/bootstrap.ts
  • src/web-desktop/electron-shim.ts
  • src/web-desktop/index.html
  • src/web-desktop/sentry-shim.ts
  • vite.config.web-desktop.mts

Comment thread package.json
Comment thread scripts/import-vram-sessions.ts Outdated
Comment on lines +13 to +16
const HOME = process.env.HOME || '';
const SESSIONS_PATH = join(HOME, 'Library/Application Support/maestro-dev/maestro-sessions.json');
const GROUPS_PATH = join(HOME, 'Library/Application Support/maestro-dev/maestro-groups.json');
const JSONL_DIR = join(HOME, '.claude/projects/-Volumes-VRAM');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add validation for critical paths before use.

The script assumes HOME is set and that all paths exist, but provides no validation. If HOME is unset or paths don't exist, subsequent file operations will fail with unclear errors.

🛡️ Proposed fix to validate paths early
 const HOME = process.env.HOME || '';
+if (!HOME) {
+  console.error('ERROR: HOME environment variable is not set');
+  process.exit(1);
+}
+
 const SESSIONS_PATH = join(HOME, 'Library/Application Support/maestro-dev/maestro-sessions.json');
 const GROUPS_PATH = join(HOME, 'Library/Application Support/maestro-dev/maestro-groups.json');
 const JSONL_DIR = join(HOME, '.claude/projects/-Volumes-VRAM');
+
+if (!existsSync(JSONL_DIR)) {
+  console.error(`ERROR: JSONL directory does not exist: ${JSONL_DIR}`);
+  process.exit(1);
+}
+if (!existsSync(SESSIONS_PATH)) {
+  console.error(`ERROR: Sessions file does not exist: ${SESSIONS_PATH}`);
+  process.exit(1);
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/import-vram-sessions.ts` around lines 13 - 16, The code assumes HOME
and the derived constants HOME, SESSIONS_PATH, GROUPS_PATH and JSONL_DIR are
valid but doesn't validate them; add early validation after those constants are
defined: check that HOME is non-empty and that SESSIONS_PATH, GROUPS_PATH and
JSONL_DIR exist and are readable (using fs.existsSync or fs.statSync), and if
any check fails throw or log a clear error that includes which path failed and
why so file operations in functions like the import routine fail fast with
actionable messages.

Comment thread scripts/import-vram-sessions.ts Outdated
Comment thread scripts/import-vram-sessions.ts Outdated
Comment thread scripts/import-vram-sessions.ts Outdated
Comment thread src/renderer/App.tsx Outdated
Comment on lines +1134 to +1164
onTouchStart={(e) => {
longPressFiredRef.current = false;
if (longPressTimerRef.current) {
window.clearTimeout(longPressTimerRef.current);
}
const touch = e.touches[0];
const x = touch.clientX;
const y = touch.clientY;
longPressTimerRef.current = window.setTimeout(() => {
longPressFiredRef.current = true;
setSelectedFileIndex(globalIndex);
setContextMenu({ x, y, node, path: fullPath });
// iOS dispatches a synthetic mousedown after the long-press
// touchend at the original touch coords (on the file row,
// NOT the menu). useClickOutside would catch it and close
// the menu before the user can tap any item. Intercept it
// in the capture phase so the click-outside handler never
// sees it. One-shot; auto-cleans after 1s if nothing fires.
const swallow = (ev: Event) => {
ev.stopPropagation();
document.removeEventListener('mousedown', swallow, true);
document.removeEventListener('click', swallow, true);
};
document.addEventListener('mousedown', swallow, true);
document.addEventListener('click', swallow, true);
window.setTimeout(() => {
document.removeEventListener('mousedown', swallow, true);
document.removeEventListener('click', swallow, true);
}, 1000);
}, 500);
}}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Cancel pending long-press timer on unmount and on context-menu open.

longPressTimerRef is set inside onTouchStart but never cleared when FileExplorerPanelInner unmounts. If the user starts a touch and the panel unmounts within the 500 ms window (e.g., switching tabs/sessions, closing the panel on a narrow viewport), the timer still fires and calls setSelectedFileIndex / setContextMenu on an unmounted component, plus the two document listeners (swallow) get attached and only self-clean up to 1 s later. Same issue if the context menu opens through some other path — the pending long-press will reopen it.

Also worth noting that longPressFiredRef is left at true after a successful long-press; only the next onTouchStart resets it. If the menu is opened by long-press and the user then clicks (via mouse on a hybrid device) without an intervening touchstart, that click is silently dropped at Line 1180-1183.

Suggested cleanup at the hook level:

🧹 Proposed unmount-safe cleanup
 	const longPressTimerRef = useRef<number | null>(null);
 	const longPressFiredRef = useRef<boolean>(false);
+
+	// Clear any pending long-press timer if the panel unmounts mid-hold.
+	useEffect(() => {
+		return () => {
+			if (longPressTimerRef.current) {
+				window.clearTimeout(longPressTimerRef.current);
+				longPressTimerRef.current = null;
+			}
+		};
+	}, []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/components/FileExplorerPanel.tsx` around lines 1134 - 1164, The
long-press timer and swallow listeners started in onTouchStart can fire after
FileExplorerPanelInner unmounts or after the context menu is opened elsewhere;
cancel the pending timer and remove any attached document listeners during
unmount and whenever setContextMenu opens/changes to avoid calling
setSelectedFileIndex/setContextMenu on an unmounted component and prevent stray
swallow listeners; implement this by tracking the timer id in longPressTimerRef
and the swallow handler, adding a useEffect cleanup in FileExplorerPanelInner
that clears longPressTimerRef.current, resets longPressFiredRef.current = false,
and removes the swallow listeners, and also ensure any code-path that opens the
context menu clears an existing longPressTimerRef before setting a new menu.

Comment on lines 53 to 59
title={
hasNoSessions && leftSidebarOpen
? 'Add an agent first to collapse sidebar'
: `${leftSidebarOpen ? 'Collapse' : 'Expand'} Sidebar${toggleSidebarShortcutLabel}`
: leftSidebarOpen
? `Collapse to status strip${toggleSidebarShortcutLabel}`
: `Hide sidebar${toggleSidebarShortcutLabel}`
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Title text is incorrect when sidebar is fully hidden.

When leftSidebarHidden=true and leftSidebarOpen=false (fully hidden state), the button is still rendered and the title on line 58 will say "Hide sidebar", but clicking will actually restore the sidebar to full visibility (per the cycle logic in uiStore.ts line 175).

The title should reflect the next state: when hidden, the next action is "Show sidebar" or "Expand sidebar", not "Hide sidebar".

🔧 Proposed fix
+const leftSidebarHidden = useUIStore((s) => s.leftSidebarHidden);

 <button
   type="button"
   disabled={hasNoSessions && leftSidebarOpen}
   onClick={() => cycleLeftSidebar()}
   className={...}
   title={
     hasNoSessions && leftSidebarOpen
       ? 'Add an agent first to collapse sidebar'
-      : leftSidebarOpen
-        ? `Collapse to status strip${toggleSidebarShortcutLabel}`
-        : `Hide sidebar${toggleSidebarShortcutLabel}`
+      : leftSidebarHidden
+        ? `Show sidebar${toggleSidebarShortcutLabel}`
+        : leftSidebarOpen
+          ? `Collapse to status strip${toggleSidebarShortcutLabel}`
+          : `Hide sidebar${toggleSidebarShortcutLabel}`
   }
 >
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/components/SessionList/SidebarActions.tsx` around lines 53 - 59,
The tooltip logic for the sidebar toggle uses leftSidebarOpen and shows "Hide
sidebar" even when leftSidebarHidden is true (fully hidden) but leftSidebarOpen
is false; update the title computation in SidebarActions to reflect the next
action (show/expand) when leftSidebarHidden === true by branching on
leftSidebarHidden first (in addition to leftSidebarOpen and hasNoSessions) and
use a label like "Show sidebar" or "Expand sidebar" combined with
toggleSidebarShortcutLabel so the title matches the actual toggle behavior
governed by the uiStore cycle logic.

Comment thread src/renderer/components/TabBar/NewTabPopover.tsx Outdated
Comment on lines +7 to +8
export function captureException(_err: unknown, _ctx?: unknown): void {}
export function captureMessage(_msg: unknown, _level?: unknown, _ctx?: unknown): void {}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

No-op error capture drops all explicit reports in web-desktop.

On Line 7 and Line 8, captureException/captureMessage silently discard errors, so web-desktop failures won’t reach telemetry.

Proposed fix
+import { captureException as reportException, captureMessage as reportMessage } from '../utils/sentry';
+
 export function init(_options: unknown): void {}
-export function captureException(_err: unknown, _ctx?: unknown): void {}
-export function captureMessage(_msg: unknown, _level?: unknown, _ctx?: unknown): void {}
+export function captureException(err: unknown, ctx?: unknown): void {
+	reportException(err instanceof Error ? err : new Error(String(err)), {
+		extra: { source: 'web-desktop/sentry-shim', ctx },
+	});
+}
+export function captureMessage(msg: unknown, _level?: unknown, ctx?: unknown): void {
+	reportMessage(String(msg), { source: 'web-desktop/sentry-shim', ctx });
+}

As per coding guidelines: src/**/*.{ts,tsx}: Do not silently swallow errors... Use Sentry utilities (captureException, captureMessage) from src/utils/sentry.ts for explicit error reporting with context.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function captureException(_err: unknown, _ctx?: unknown): void {}
export function captureMessage(_msg: unknown, _level?: unknown, _ctx?: unknown): void {}
import { captureException as reportException, captureMessage as reportMessage } from '../utils/sentry';
export function init(_options: unknown): void {}
export function captureException(err: unknown, ctx?: unknown): void {
reportException(err instanceof Error ? err : new Error(String(err)), {
extra: { source: 'web-desktop/sentry-shim', ctx },
});
}
export function captureMessage(msg: unknown, _level?: unknown, ctx?: unknown): void {
reportMessage(String(msg), { source: 'web-desktop/sentry-shim', ctx });
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/web-desktop/sentry-shim.ts` around lines 7 - 8, The no-op implementations
of captureException and captureMessage drop all reports; update these functions
in sentry-shim.ts to delegate to the actual Sentry utilities from
src/utils/sentry.ts (import the real captureException/captureMessage there) and
forward the incoming parameters (error/message, level, ctx) so events and
context are preserved; keep the existing signatures (captureException(_err:
unknown, _ctx?: unknown): void and captureMessage(_msg: unknown, _level?:
unknown, _ctx?: unknown): void) but call the corresponding exports from
src/utils/sentry.ts and ensure any exceptions during forwarding are not
swallowed silently (let them propagate or log).

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 13, 2026

Greptile Summary

Ships the web-desktop bundle feature: the full Maestro renderer runs in a browser tab by mirroring every ipcMain handler over a WebSocket bridge, gated behind the webDesktopBundle Encore Feature flag. The PR also adds a responsive mobile UX layer (drawer sidebars, touch-aware file picker, iOS safe-area CSS) and several bug fixes (iOS crypto.randomUUID fallback, Promise.allSettled for file stat resilience, WebSocket reconnect pending-request cleanup).

  • Bridge architectureinstallWebContentsBridgeHook monkey-patches WebContents.prototype.send to fan out events as bridge.event; handleBridgeInvoke dispatches bridge.invoke messages to the real ipcMain._invokeHandlers map; install/uninstall is tied to server start/stop.
  • Responsive UXuseViewportBreakpoint writes data-bp on <html> for CSS-driven drawer behavior; tri-state sidebar (leftSidebarHidden) and mutual-exclusion effects keep layout coherent on narrow viewports.
  • Feature gate — four enforcement points (route registration, static asset mount, bridge hook install, bridge.invoke dispatch) ensure the IPC surface is sealed by default.

Confidence Score: 5/5

Safe to merge; all four bridge enforcement points are correctly gated and the server install/uninstall lifecycle is well-managed.

The feature gate is enforced at four independent layers so the IPC surface cannot be accidentally widened. The WebSocket reconnect hang, HTML blocking, broadcastSink lifecycle, and first-paint layout flash — all previously flagged — have been addressed in this revision. Remaining findings are minor correctness hazards that do not affect correct operation with the current token format or Electron version.

No files require special attention for merging; bridgeHandlers.ts and WebServer.ts are worth a second read if the Electron version is bumped or the security-token generation scheme changes.

Important Files Changed

Filename Overview
src/main/web-server/handlers/bridgeHandlers.ts New IPC bridge: monkey-patches WebContents.prototype.send to fan out events to WS clients, dispatches bridge.invoke to ipcMain._invokeHandlers. Install/uninstall cycle is correctly managed; the private _invokeHandlers API is a forward-compatibility risk.
src/main/web-server/WebServer.ts Adds web-desktop bundle route/asset serving, HTML token injection, and bridge hook install/uninstall at server start/stop. HTML is cached after first read; token is used as a raw replace() string.
src/web-desktop/electron-shim.ts WebSocket-backed ipcRenderer/contextBridge shim. Reconnect handling now correctly rejects in-flight pending requests on close, preventing indefinite hangs.
src/renderer/hooks/ui/useViewportBreakpoint.ts New hook writing data-bp to for CSS-driven responsive layout. Synchronous DOM write during render intentionally avoids first-paint flash; resize and orientationchange listeners are properly cleaned up.
src/renderer/components/FileExplorerPanel.tsx Adds touch-aware file open (single-tap on coarse pointer) and long-press context menu. longPressTimerRef lacks a useEffect cleanup for unmount scenarios.
src/renderer/App.tsx Wires viewport breakpoint hook; auto-collapses sidebars on narrow viewports; adds mutual-exclusion effect for drawer open state; adds mobile backdrop overlay. Logic is correct.
src/renderer/stores/uiStore.ts Adds leftSidebarHidden + cycleLeftSidebar for tri-state sidebar. Cycle transitions (full→collapsed→hidden→full) are correct and consistent.
src/main/web-server/handlers/messageHandlers.ts Routes bridge.invoke messages through feature-gate check before dispatching to bridgeHandlers. Gate is read on every invocation for runtime toggle support.

Sequence Diagram

sequenceDiagram
    participant Browser as Browser (Web Desktop)
    participant WS as WebSocket (/<token>/ws)
    participant MH as MessageHandler
    participant BH as bridgeHandlers
    participant IPC as ipcMain._invokeHandlers
    participant WC as WebContents.send (patched)
    participant BS as BroadcastService

    Browser->>WS: "{ type: bridge.invoke, requestId, channel, args }"
    WS->>MH: handleMessage()
    MH->>MH: getWebDesktopBundleEnabled()?
    alt Feature disabled
        MH-->>Browser: "{ type: bridge.response, ok: false }"
    else Feature enabled
        MH->>BH: handleBridgeInvoke()
        BH->>IPC: handlers.get(channel)(FAKE_EVENT, ...args)
        IPC-->>BH: result / throws
        BH-->>Browser: "{ type: bridge.response, ok: true/false }"
    end
    Note over WC,BS: Fanout path
    WC->>BS: broadcastSink(channel, args)
    BS-->>Browser: "{ type: bridge.event, channel, args }"
Loading

Reviews (2): Last reviewed commit: "fix(web-desktop): recover from WS constr..." | Re-trigger Greptile

Comment thread src/web-desktop/electron-shim.ts
Comment thread src/main/web-server/WebServer.ts
Comment thread src/main/web-server/handlers/bridgeHandlers.ts
Comment thread src/renderer/hooks/ui/useViewportBreakpoint.ts
@pedramamini
Copy link
Copy Markdown
Collaborator

Thanks for the ambitious push here @ronaldeddings — the IPC mirror behind an Encore Feature flag is exactly the right shape for this, and the responsive UX work + iOS Safari fixes are great quality-of-life wins. PR is clean against rc with no merge conflicts.

One real bug worth fixing before we merge, plus a couple of smaller items:

🔴 BridgeClient doesn't drain pending on WebSocket closesrc/web-desktop/electron-shim.ts:105-109

The close handler resets ready and reconnects, but never iterates this.pending to reject in-flight invokes. After a server restart (or any drop where the server doesn't replay), those requestIds are unknown to the new server, so every ipcRenderer.invoke() issued during the disconnect window hangs forever — and any React component awaiting that promise just stalls. Suggest something like:

this.ws.addEventListener('close', () => {
    const err = new Error('bridge disconnected');
    for (const [id, p] of this.pending) p.reject(err);
    this.pending.clear();
    this.queue.length = 0; // queued frames are also orphaned
    setTimeout(() => this.connect(url), 1000);
    this.ready = new Promise((r) => (this.resolveReady = r));
});

🟡 Smaller items (optional, can be follow-ups):

  • serveWebDesktopIndex (WebServer.ts:802) does readFileSync on every desktop page load. The HTML is static after build — cache it on first read or read it once in start().
  • useViewportBreakpoint sets data-bp only inside useEffect, so first paint on narrow viewports flashes the desktop layout before the attribute lands. Setting it synchronously during hook init (or via an inline pre-mount script) would avoid the flash.

The reconnect drain is the only blocker from my side — once that lands I'm happy to approve. Thanks again!

P0 fixes
- BridgeClient close: drain `pending` (reject in-flight invokes with
  "bridge disconnected") and clear the outbound queue before reconnect.
  Without this, any `ipcRenderer.invoke()` issued during a disconnect
  window hangs forever — the new server has no memory of those request
  IDs after restart. (catch: pedramamini, greptile-apps)
- App.tsx mutual-exclusion: track previous values via refs and react to
  the *transition* that opened a drawer instead of the static both-open
  state. Old logic was biased toward left — opening the right panel
  while left was open immediately re-closed the right. (catch: coderabbitai)
- package.json: include `build:web-desktop` in the root `build` script
  so CI/release builds produce dist/web-desktop. Without this the
  Encore Feature is dead in any non-dev build. (catch: coderabbitai)

P1 fixes
- useViewportBreakpoint: set `data-bp` on <html> synchronously during
  hook init (in addition to the useEffect). CSS rules using
  `:root[data-bp='xs']` now apply before first paint on narrow
  viewports — no more one-frame desktop-layout flash. (catch: pedramamini, greptile-apps)
- WebServer: cache the resolved web-desktop asset path and the rendered
  index.html on first request. Eliminates the existsSync + readFileSync
  + regex-replace round-trip on every desktop page load. (catch: pedramamini, greptile-apps)
- bridgeHandlers: add uninstallWebContentsBridgeHook() that restores
  the original WebContents.prototype.send and clears broadcastSink.
  Wired into WebServer.stop() so a defunct BroadcastService isn't
  called after the Encore Feature is toggled off and the server
  restarts. (catch: greptile-apps)

Minor fixes
- NewTabPopover: consolidate POPOVER_WIDTH (overflow math) and the
  inline `minWidth: 180` into a single shared `POPOVER_MIN_WIDTH = 200`
  constant. The two had drifted, making the edge-flip trigger slightly
  earlier than necessary. (catch: coderabbitai)
- web-server-factory: guard the `encoreFeatures` cast with
  `typeof === 'object' && !== null` before reading `webDesktopBundle`.
  Returns false on corrupted settings instead of throwing. (catch: coderabbitai)

Scope cleanup
- Remove PRD-CY-CONTEXT-RUNTIME.md, scripts/import-vram-sessions.ts,
  and scripts/populate-real-conversations.ts. These were personal
  utilities/PRDs that shouldn't have ridden along with this PR.

Validation
- npm run lint (tsc all configs): clean
- npm run lint:eslint: clean
- npm run test: 27,844 passed / 108 skipped / 0 failed
- npm run build: main + preload + renderer + web + web-desktop + cli all built
@ronaldeddings
Copy link
Copy Markdown
Contributor Author

Thanks for the catch @pedramamini — got all three!

Pushed e374d724c:

  • 🔴 BridgeClient pending drainsrc/web-desktop/electron-shim.ts close handler now rejects every in-flight pending with a bridge disconnected error, clears the outbound queue, recreates the ready promise, then schedules the reconnect. Pretty much exactly the shape you sketched.
  • 🟡 serveWebDesktopIndex sync I/O — added webDesktopPathCache and webDesktopHtmlCache on the WebServer instance. The path resolution and the post-replacement HTML are both memoized on first hit; subsequent desktop loads are a single in-memory send.
  • 🟡 useViewportBreakpoint flash — now sets data-bp on <html> synchronously during hook init (in addition to the useEffect for resize/orientation), so the CSS branches apply before first paint on narrow viewports.

While I was in there, also picked up a few of the AI-reviewer items that held up under verification:

  • App.tsx mutual-exclusion was biased toward the left drawer (opening the right while the left was open would immediately close the right) — now tracks prev-values via refs and reacts to the transition.
  • Root build script was missing build:web-desktop, so the bundle wasn't produced in CI/release builds.
  • Added an uninstallWebContentsBridgeHook so a stale broadcastSink doesn't linger after the Encore Feature is toggled off and the server restarts.
  • Couple of smaller items: unified the NewTabPopover width constant, null-guarded the encoreFeatures cast.

Also dropped three files from the diff that snuck in by accident (PRD-CY-CONTEXT-RUNTIME.md and two personal import scripts) — they were unrelated to the bundle.

Gates re-ran clean: tsc, ESLint, 27,844 tests, full build with web-desktop. Ready for another look whenever you have a minute.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/web-desktop/electron-shim.ts (1)

184-188: ⚡ Quick win

Consider failing loudly for unsupported IPC methods if they are ever called.

These stubs (removeAllListeners, postMessage, sendSync, sendTo, sendToHost) currently succeed silently without doing anything. While no code in the repository currently calls these methods, the coding guidelines require that errors not be silently swallowed. If these methods are ever invoked in the future, they should fail immediately or throw/capture to Sentry so gaps are obvious rather than misbehaving silently. For now, they can remain as defensive stubs, but consider adding comments explaining why they are no-ops or implementing them properly if they become needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/web-desktop/electron-shim.ts` around lines 184 - 188, The current
electron-shim stubs removeAllListeners, postMessage, sendSync, sendTo, and
sendToHost silently no-op; change them to fail loudly by throwing a clear,
descriptive Error (e.g., "Unsupported in electron shim: <methodName>") or
capturing the failure with the existing error reporting mechanism
(Sentry/logger) so any future invocation is immediately visible; update the
implementations for the functions named removeAllListeners, postMessage,
sendSync, sendTo, and sendToHost to either throw or log-and-throw, and add a
short comment explaining this defensive behavior for future maintainers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/web-desktop/electron-shim.ts`:
- Around line 76-80: The JSON.parse inside the message handler (the try/catch
around parsing ev.data) currently swallows parse errors and returns; instead, on
parse failure capture the error, call the socket close/terminate path (e.g.
invoke the WebSocket instance's close() or onClose handler) so any pending
invoke() calls receive a deterministic disconnect error, and log/report the
parse error (or rethrow to let Sentry capture it) rather than silently
returning; update the catch block in the message parsing code to close the
socket and surface the error information.

---

Nitpick comments:
In `@src/web-desktop/electron-shim.ts`:
- Around line 184-188: The current electron-shim stubs removeAllListeners,
postMessage, sendSync, sendTo, and sendToHost silently no-op; change them to
fail loudly by throwing a clear, descriptive Error (e.g., "Unsupported in
electron shim: <methodName>") or capturing the failure with the existing error
reporting mechanism (Sentry/logger) so any future invocation is immediately
visible; update the implementations for the functions named removeAllListeners,
postMessage, sendSync, sendTo, and sendToHost to either throw or log-and-throw,
and add a short comment explaining this defensive behavior for future
maintainers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5d8fc505-042b-41c8-b1f2-096e4d3f8b5b

📥 Commits

Reviewing files that changed from the base of the PR and between 3b67299 and e374d72.

📒 Files selected for processing (8)
  • package.json
  • src/main/web-server/WebServer.ts
  • src/main/web-server/handlers/bridgeHandlers.ts
  • src/main/web-server/web-server-factory.ts
  • src/renderer/App.tsx
  • src/renderer/components/TabBar/NewTabPopover.tsx
  • src/renderer/hooks/ui/useViewportBreakpoint.ts
  • src/web-desktop/electron-shim.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/renderer/hooks/ui/useViewportBreakpoint.ts
  • src/main/web-server/WebServer.ts
  • src/main/web-server/handlers/bridgeHandlers.ts
  • src/renderer/App.tsx

Comment on lines +76 to +80
try {
msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data));
} catch {
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't silently drop malformed bridge frames.

If the server ever emits bad JSON here, this just returns and any waiting invoke() can sit pending until the socket later closes. Capture the parse failure and close the socket so callers get a deterministic disconnect error instead of a silent hang.

As per coding guidelines, "Do not silently swallow errors. Let unhandled exceptions bubble up to Sentry for error tracking in production."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/web-desktop/electron-shim.ts` around lines 76 - 80, The JSON.parse inside
the message handler (the try/catch around parsing ev.data) currently swallows
parse errors and returns; instead, on parse failure capture the error, call the
socket close/terminate path (e.g. invoke the WebSocket instance's close() or
onClose handler) so any pending invoke() calls receive a deterministic
disconnect error, and log/report the parse error (or rethrow to let Sentry
capture it) rather than silently returning; update the catch block in the
message parsing code to close the socket and surface the error information.

Addresses CodeRabbit follow-up on PR RunMaestro#991.

- JSON.parse failure on a WS frame: log the error + raw frame preview
  via console.warn and drop the frame, instead of silently returning.
  Deliberately NOT closing the socket — one malformed payload shouldn't
  reject every in-flight invoke (the drain logic added in the prior
  commit would fire) or spin a reconnect loop on a flaky network.
- removeAllListeners: implement against BridgeClient.listeners (clear
  one channel's Set when given, clear the whole Map when not) instead
  of returning ipcRenderer with no effect.
- sendSync: throw a clear Error. Synchronous IPC can't be tunneled
  over a WebSocket; returning undefined would let callers miscompute
  on a silent null. Real renderer code paths only use invoke/send/on.
- postMessage / sendTo / sendToHost: kept as no-ops since they have no
  callers in this codebase and no meaningful translation to the
  bridge, but added a comment explaining the intent so the next
  reviewer doesn't re-flag them.

Validation
- npm run lint: clean
- npm run lint:eslint: clean
- npm run test: 27,844 passed / 108 skipped / 0 failed
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/web-desktop/electron-shim.ts`:
- Around line 177-179: The ipcRenderer.send shim currently swallows all
bridge.invoke errors via .catch(() => {}); update the send implementation (the
send arrow function that calls bridge.invoke) to not silently discard
rejections: at minimum call console.error (or call the existing
captureException/Sentry hook if available in this bundle) with the error in the
.catch handler so failures are visible, and avoid suppressing the rejection
(either rethrow the error or omit the catch so it can bubble to global
handlers/Sentry); reference the send function and bridge.invoke when making this
change.
- Around line 63-69: connect() currently catches WebSocket constructor errors,
logs and returns without scheduling a reconnect, leaving this.ready unresolved
and ipcRenderer.invoke() calls hung; update the connect method (where new
WebSocket(url) is called and this.ws is set) to, on constructor error, schedule
the same 1s reconnect timer used in the close path (ensuring the retry logic
used elsewhere is invoked) so that transient failures trigger a retry and
this.ready can eventually resolve; ensure the reconnect scheduling reuses the
existing retry mechanism or timer variable/name used in this class so behavior
matches the close/error paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bf4bc623-4408-4c9e-8065-e0d45b47fe7a

📥 Commits

Reviewing files that changed from the base of the PR and between e374d72 and bccaf36.

📒 Files selected for processing (1)
  • src/web-desktop/electron-shim.ts

Comment thread src/web-desktop/electron-shim.ts
Comment thread src/web-desktop/electron-shim.ts
…failures

Addresses CodeRabbit 3rd-pass review on PR RunMaestro#991.

- connect(): when `new WebSocket(url)` throws synchronously (SyntaxError
  on a malformed URL, SECURITY_ERR from a CSP-blocked port, etc.), now
  schedule the same 1s reconnect the close path uses. Without this
  this.ready never resolves and every subsequent ipcRenderer.invoke()
  hangs on `await this.ready` — same hang class as the original Pedram
  finding, different entry point.
- ipcRenderer.send: replace `.catch(() => {})` with a `console.error`
  log so bridge.invoke rejections (unknown channel, server-side handler
  throws, disconnect) are visible. Still fire-and-forget — no rethrow,
  since callers don't expect a Promise.

Validation
- npm run lint: clean
- npm run lint:eslint: clean
- npm run test: 27,844 passed / 108 skipped / 0 failed
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/web-desktop/electron-shim.ts (1)

81-91: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Malformed bridge frames still leave invokes hanging.

Dropping an unparseable frame here means a corrupted bridge.response can keep its pending entry alive until some later disconnect. Close the socket and report the parse failure so callers deterministically hit the existing bridge disconnected path instead of hanging behind a swallowed protocol error. As per coding guidelines, "Do not silently swallow errors. Let unhandled exceptions bubble up to Sentry for error tracking in production."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/web-desktop/electron-shim.ts` around lines 81 - 91, In the onmessage
parsing catch block (where ev, msg, raw and preview are used) don't silently
drop the frame: after logging the parse failure, immediately close the
underlying socket (call socket.close() / ws.close()) and rethrow the error (or
call the existing bridge disconnect handler) so the connection takes the normal
"bridge disconnected" path and pending entries are cleared and reported to error
tracking.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/web-desktop/electron-shim.ts`:
- Around line 108-113: The current loop calling each renderer listener (for ...
of set) swallows exceptions by only console.error; instead import and use the
Sentry utilities from src/utils/sentry.ts (captureException / captureMessage) to
report the error with context (include channel and any relevant args or
fakeEvent) and then re-throw the exception so unexpected renderer/preload errors
propagate to Sentry and preserve Electron event semantics; update the block that
invokes cb(fakeEvent, ...args) to captureException/captureMessage on error with
context including channel and listener identity, then re-throw the caught error
rather than continuing.

---

Duplicate comments:
In `@src/web-desktop/electron-shim.ts`:
- Around line 81-91: In the onmessage parsing catch block (where ev, msg, raw
and preview are used) don't silently drop the frame: after logging the parse
failure, immediately close the underlying socket (call socket.close() /
ws.close()) and rethrow the error (or call the existing bridge disconnect
handler) so the connection takes the normal "bridge disconnected" path and
pending entries are cleared and reported to error tracking.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d7187fdb-0b81-4c0a-a075-ad15d3599256

📥 Commits

Reviewing files that changed from the base of the PR and between bccaf36 and b3c6b2a.

📒 Files selected for processing (1)
  • src/web-desktop/electron-shim.ts

Comment on lines +108 to +113
for (const cb of set) {
try {
cb(fakeEvent, ...args);
} catch (err) {
console.error(`[bridge] listener for ${channel} threw`, err);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't swallow renderer listener exceptions.

Catching cb(...) and only logging it hides preload/renderer bugs from Sentry and changes Electron-style event semantics. Treat these as unexpected failures: report them with src/utils/sentry.ts context and re-throw instead of continuing after a partially handled event. As per coding guidelines, "For unexpected errors, re-throw them to allow Sentry to capture them. Use Sentry utilities (captureException, captureMessage) from src/utils/sentry.ts for explicit error reporting with context."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/web-desktop/electron-shim.ts` around lines 108 - 113, The current loop
calling each renderer listener (for ... of set) swallows exceptions by only
console.error; instead import and use the Sentry utilities from
src/utils/sentry.ts (captureException / captureMessage) to report the error with
context (include channel and any relevant args or fakeEvent) and then re-throw
the exception so unexpected renderer/preload errors propagate to Sentry and
preserve Electron event semantics; update the block that invokes cb(fakeEvent,
...args) to captureException/captureMessage on error with context including
channel and listener identity, then re-throw the caught error rather than
continuing.

@pedramamini
Copy link
Copy Markdown
Collaborator

@greptile-apps review
@coderabbitat review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

@pedramamini Sure! I'll kick off a fresh review of PR #991, including the fixes pushed in e374d724c. I'll look at the BridgeClient pending drain, the serveWebDesktopIndex caching, the useViewportBreakpoint flash fix, and the additional cleanup items.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chr1syy
Copy link
Copy Markdown
Contributor

chr1syy commented May 13, 2026

Manual test pass against this PR on a local 0.16.17-RC build (Linux AppImage). Bridge architecture works — /desktop/ route is correctly gated, bridge.invoke/bridge.event traffic flows on the WS, and the desktop UI renders inside Chromium without modification. Found four bugs and one UX nit worth flagging before this lands.

Bug 1 — No UI toggle in Encore Features tab

The flag is wired into EncoreFeatureFlags, defaults (settingsStore.ts), and all four backend gate enforcement points, but EncoreTab.tsx has zero references to webDesktopBundle (vs. 8–10 each for every other Encore flag). Users can only enable via:

```bash
maestro-cli settings set encoreFeatures.webDesktopBundle true
```

…plus a full app restart (the bridge hook installs once at server startup). Likely a one-line follow-up to add the toggle alongside the other Encore feature switches.

Bug 2 — Messages sent through the bridge don't reach the agent or echo back ⚠️ highest severity

Repro: open `/desktop/` in browser, send a prompt to an agent through the web-desktop UI. Expected: message arrives at the agent and appears in both the desktop app and the web-desktop view. Actual: message did not appear in either, and was gone from the web-desktop view on browser refresh. Suggests either (a) the user-input dispatch isn't routing through the bridge, (b) state isn't persisted server-side for the bridge client, or (c) the WebContents.send echo isn't broadcasting back to the originating client. This is the one that breaks the core "send a message from your phone" use case, so I'd treat it as a P0 before merge.

Bug 3 — SettingsModal threw an unrecoverable error in the web-desktop view, requiring a full window reload

Repro: open Settings inside the web-desktop browser tab. Expected: settings load and edit normally via bridge. Actual: error printed in the modal, couldn't recover, full reload required to escape. Indicates a bridge handler somewhere in the settings flow is failing — likely a renderer-only API (file system access, native dialogs, Electron `shell.openExternal`, etc.) being invoked without a graceful no-op fallback for the browser context. Worth auditing every IPC channel used by Settings for browser-safe equivalents.

Bug 4 — LIVE button renders inside the /desktop/ view; user can disconnect themselves

Repro: in the /desktop/ browser view, click the LIVE button at the top of the SessionList. Panel opens with a "Live Mode" toggle that can be flipped off, killing the webserver the browser is connected to. Confirmed in code: `isWebDesktop` (or any equivalent runtime detection) doesn't exist anywhere in `src/renderer/` — the LIVE button is rendered unconditionally. Fix: detect the web-desktop context (e.g. `window.location.pathname.includes('/desktop/')` or a build-time `WEB_DESKTOP` Vite define) and hide/disable destructive surfaces — at minimum the LIVE toggle, probably also Quit/Restart/Update controls and anything else that assumes Electron host.

UX feedback — split-state routes feel like a roadmap question

With `webDesktopBundle` ON, both `//` (the original mobile/web UI) and `//desktop/` (the new bundle) are served simultaneously. They're separate apps with separate state — a session opened on one route doesn't reflect on the other without round-tripping through the desktop app. Personal preference: the web-desktop bundle should replace the mobile webserver as the single web entry point (responsive-design'd so the same `//` URL serves desktop on big screens and mobile-shell on small ones), rather than living alongside `/web/` indefinitely. Worth deciding the long-term shape before more UX accrues in the legacy /web/ path.


Otherwise the bridge feels great in the browser — once Bug 2 + Bug 3 are fixed, this is going to be a really nice way to control Maestro from anywhere.

Addresses chr1syy's manual-test finding on PR RunMaestro#991 that messages sent
from /desktop/ neither reached the agent nor echoed back into the web
view.

Root cause
- Every main→renderer push goes through `safeSend` in
  `utils/safe-send.ts`, which gates on `mainWindow` existing AND
  webContents being alive.
- The previous bridge implementation monkey-patched
  `WebContents.prototype.send` to fan out implicitly. That worked only
  while `safeSend` actually reached `webContents.send(...)`. If the
  Electron window was closing, destroyed, or mid-launch, `safeSend`
  short-circuited and the prototype patch never fired — so web-desktop
  clients silently missed every push (agent output, session updates,
  state-sync events, the lot). Sending worked (it goes through
  `ipcMain.handle('process:write')` which the bridge does intercept),
  but the receive path was dead.

Fix
- Replace the prototype monkey-patch with an explicit
  `broadcastBridgeEvent(channel, args)` export from `bridgeHandlers.ts`.
- Call it from `safeSend` unconditionally — before the `mainWindow`
  liveness check — so every IPC push fans out to web-desktop clients
  even when the Electron renderer isn't reachable.
- `broadcastBridgeEvent` is a no-op when the Encore Feature is off or
  no clients are connected (broadcastSink === null), so cost in the
  default-off case is one null check per send.
- Remove `WebContents.prototype.send` patch entirely. Direct
  `mainWindow.webContents.send(...)` calls in `web-server-factory.ts`
  (the `remote:*` events) are mobile→desktop-driver events that target
  the existing Electron renderer specifically; web-desktop intentionally
  shouldn't receive them.

Validation
- npm run lint: clean
- npm run lint:eslint: clean
- npm run test: 27,847 passed / 108 skipped (3 new tests covering
  broadcastBridgeEvent install/no-op/uninstall paths)
- npm run build:web-desktop: clean
- Live end-to-end test via Interceptor in real Chrome:
    1. Opened http://localhost:31999/<token>/desktop/
    2. Typed "ping from web-desktop bridge test — please reply with
       PONG" in the active agent session
    3. Confirmed message arrived in main process and was written to
       the agent's PTY
    4. Agent's "thinking PONG" and final "PONG" streamed back into
       the web-desktop view in real time

Tests
- bridgeHandlers.test.ts gains three cases for the new public surface:
  - no-op before install
  - fanout shape after install (type/channel/args/timestamp)
  - no-op again after uninstall
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
src/main/web-server/handlers/bridgeHandlers.ts (2)

98-102: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Report bridge fanout failures to Sentry.

The catch block logs the error but doesn't report it to Sentry. Broadcast failures are unexpected system issues and should be captured for production diagnostics. As per coding guidelines, use captureException from src/utils/sentry.ts to report unexpected errors with context.

🔍 Proposed fix
 import { ipcMain } from 'electron';
 import { logger } from '../../utils/logger';
+import { captureException } from '../../utils/sentry';
 import type { WebClient } from '../types';
 import type { BroadcastService } from '../services';

 // ...

 export function broadcastBridgeEvent(channel: string, args: unknown[]): void {
 	if (!broadcastSink) return;
 	try {
 		broadcastSink(channel, args);
 	} catch (err) {
+		captureException(err instanceof Error ? err : new Error(String(err)), {
+			extra: { operation: 'bridge:eventFanout', channel },
+		});
 		logger.warn(`bridge fanout failed: ${(err as Error).message}`, LOG_CONTEXT);
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/web-server/handlers/bridgeHandlers.ts` around lines 98 - 102, The
catch block around broadcastSink currently only logs with logger.warn; update it
to also report the exception to Sentry by calling captureException(err, { extra:
{ channel, args, logContext: LOG_CONTEXT } }) (import captureException from
src/utils/sentry.ts) inside the same catch for unexpected bridge fanout
failures, keeping the existing logger.warn call for local logs and ensuring you
pass the error object and contextual metadata (channel/args/LOG_CONTEXT) to
captureException for diagnostics.

148-155: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Report handler exceptions to Sentry.

The catch block converts handler errors to client responses but doesn't report them to Sentry. Handler exceptions are unexpected failures that should be tracked for production diagnostics. As per coding guidelines, use captureException from src/utils/sentry.ts to report unexpected errors with context.

🔍 Proposed fix
 import { ipcMain } from 'electron';
 import { logger } from '../../utils/logger';
+import { captureException } from '../../utils/sentry';
 import type { WebClient } from '../types';
 import type { BroadcastService } from '../services';

 // ...

 	try {
 		const result = await handler(FAKE_EVENT, ...args);
 		send(client, {
 			type: 'bridge.response',
 			requestId,
 			ok: true,
 			result,
 		});
 	} catch (err) {
+		captureException(err instanceof Error ? err : new Error(String(err)), {
+			extra: { operation: 'bridge:invoke', channel, requestId },
+		});
 		const error = err instanceof Error ? err.message : String(err);
 		send(client, {
 			type: 'bridge.response',
 			requestId,
 			ok: false,
 			error,
 		});
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/web-server/handlers/bridgeHandlers.ts` around lines 148 - 155, The
catch block in bridgeHandlers.ts that currently converts handler errors into a
client response needs to also report the original error to Sentry: import and
call captureException (from src/utils/sentry.ts) inside the catch before sending
the bridge.response, passing the raw err (or the Error instance) and include
contextual metadata such as requestId and the incoming message type so Sentry
can correlate it; keep the existing send(client, { type: 'bridge.response',
requestId, ok: false, error }) after calling captureException.
🧹 Nitpick comments (1)
src/__tests__/main/web-server/handlers/bridgeHandlers.test.ts (1)

164-164: 💤 Low value

Consider removing unused helper or adding a basic usage test.

The void lastSend; statement is a workaround to suppress unused variable warnings, but it's unusual. If the helper is intended for future tests, consider either adding a minimal test that uses it now, or removing it and re-adding when needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__tests__/main/web-server/handlers/bridgeHandlers.test.ts` at line 164,
The declaration "void lastSend;" is an unused workaround to silence TS warnings;
either remove the unused helper entirely from the test file or add a minimal
assertion that uses the lastSend variable (e.g., initialize lastSend where it’s
set in the tests and include a simple expect(lastSend).toBeDefined() or similar)
so it is referenced; update the bridgeHandlers.test.ts tests around the existing
lastSend variable usage (search for lastSend) to implement one of these two
options.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/main/web-server/handlers/bridgeHandlers.ts`:
- Around line 98-102: The catch block around broadcastSink currently only logs
with logger.warn; update it to also report the exception to Sentry by calling
captureException(err, { extra: { channel, args, logContext: LOG_CONTEXT } })
(import captureException from src/utils/sentry.ts) inside the same catch for
unexpected bridge fanout failures, keeping the existing logger.warn call for
local logs and ensuring you pass the error object and contextual metadata
(channel/args/LOG_CONTEXT) to captureException for diagnostics.
- Around line 148-155: The catch block in bridgeHandlers.ts that currently
converts handler errors into a client response needs to also report the original
error to Sentry: import and call captureException (from src/utils/sentry.ts)
inside the catch before sending the bridge.response, passing the raw err (or the
Error instance) and include contextual metadata such as requestId and the
incoming message type so Sentry can correlate it; keep the existing send(client,
{ type: 'bridge.response', requestId, ok: false, error }) after calling
captureException.

---

Nitpick comments:
In `@src/__tests__/main/web-server/handlers/bridgeHandlers.test.ts`:
- Line 164: The declaration "void lastSend;" is an unused workaround to silence
TS warnings; either remove the unused helper entirely from the test file or add
a minimal assertion that uses the lastSend variable (e.g., initialize lastSend
where it’s set in the tests and include a simple expect(lastSend).toBeDefined()
or similar) so it is referenced; update the bridgeHandlers.test.ts tests around
the existing lastSend variable usage (search for lastSend) to implement one of
these two options.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f13c78c0-f2ab-41d4-b1a9-9bd6c9cb00b5

📥 Commits

Reviewing files that changed from the base of the PR and between b3c6b2a and 53ce861.

📒 Files selected for processing (3)
  • src/__tests__/main/web-server/handlers/bridgeHandlers.test.ts
  • src/main/utils/safe-send.ts
  • src/main/web-server/handlers/bridgeHandlers.ts

…, 4)

Addresses the remaining items from chr1syy's manual-test review on
PR RunMaestro#991, plus a regression from the round-1 P1 fix.

Bug 1 — UI toggle in EncoreTab
- Add a Web-Desktop Bundle toggle to EncoreTab.tsx alongside the other
  Encore Features (Symphony, Cue, Director's Notes). Reuses the same
  card pattern, Globe icon, Beta badge, and feature-card border. Body
  copy explains what /<token>/desktop/ does and reminds the user to
  restart Maestro after toggling (the bridge hook installs once at
  server startup).
- Register the new toggle in searchableSettings.ts so the Settings
  search bar finds it by "web", "desktop", "bridge", "bundle", etc.

Bug 3 — SettingsModal in web-desktop
- Reproduced via Interceptor end-to-end (General/Display/SSH/Encore/
  Maestro Prompts tabs all open cleanly, prompt editor loads its
  textbox, no thrown exceptions visible in the renderer console).
  Crash chr1syy reported on the round-1 code was almost certainly an
  indirect symptom of Bug 2 — Settings depends on push events through
  safeSend for its state-sync, and those silently dropped when the
  bridge fanout was gated on mainWindow liveness. Bug 2's switch to
  the explicit broadcastBridgeEvent fanout fixed it as a side effect.
  No additional code change needed; this commit records the verdict.

Bug 4 — Electron-host-only UI guards
- New helper: src/renderer/utils/runtimeContext.ts exposes
  isWebDesktop() / isElectronDesktop(). Reads the server-injected
  __MAESTRO_CONFIG__ (load-bearing) with a /desktop/ path fallback.
  Memoized after first call.
- SessionList.tsx hides the global LIVE / OFFLINE toggle when
  isWebDesktop() is true. Toggling Live Mode off from inside the
  bridge would kill the webserver the user's own browser is connected
  to, which is exactly the disconnect chr1syy hit.
- HamburgerMenuContent.tsx hides the Check for Updates entry in web-
  desktop — it assumes the Electron binary is the runtime host.
- App.tsx hides the draggable title bar (WebkitAppRegion: drag) in
  web-desktop. There's no Electron host to drag the window of, the
  bar's just visual clutter, and a stale instance had re-appeared
  since the round-1 cleanup.
- App.tsx also drops the 40px (pt-10) top reservation that was there
  to clear the now-hidden title bar — without this, the renderer left
  a blank strip at the top of /desktop/.

Bonus — revert HTML cache from round-1 P1 fix
- WebServer.ts: the round-1 PR1-P1 "cache the served index.html on
  first read" change served stale Vite asset hashes after every
  rebuild and left /desktop/ stuck on "Loading…" with 404s in the
  network panel. Path resolution stays cached (cheap, doesn't change
  across rebuilds), but the HTML itself is now re-read per request.
  The readFileSync of a sub-2KB index is negligible compared to the
  bug.

Validation
- npm run lint: clean
- npm run lint:eslint: clean
- npm run test: 27,847 passed / 108 skipped / 0 failed
- npm run build:web-desktop: clean
- Live verification via Interceptor in real Chrome at
  http://localhost:31999/<token>/desktop/:
    • Web-Desktop Bundle toggle visible in Encore Features tab
    • LIVE/OFFLINE chip absent from header
    • Check for Updates absent from hamburger menu
    • Drag-region title bar absent from DOM (0 elements with
      app-region: drag)
    • Computed paddingTop on .maestro-app-shell == 0px
    • Settings modal opens, tabs navigate, prompts editor loads
    • Bug 2 ping/pong round-trip still intact
@ronaldeddings
Copy link
Copy Markdown
Contributor Author

Thanks @chr1syy — the manual test pass made all the difference here. The level of detail (repro steps, severity ranking, references to specific code surfaces, screenshots in your head) turned what would've been four debugging sessions into four targeted fixes. Especially appreciate the headline call on Bug 2 — that one was hiding behind a passing CI suite and I would not have found it from code review alone.

Pushed 172c616dc (with 53ce8611a for Bug 2 ahead of it). Quick rundown of what each bug needed:

Bug 2 — messages from /desktop/ didn't reach the agent (P0)

The bridge fanout was monkey-patching WebContents.prototype.send to mirror main→renderer pushes to web clients. That worked only when safeSend actually reached webContents.send(...). Every push first runs through safeSend in utils/safe-send.ts, which gates on mainWindow existing AND webContents being alive — so if the Electron window was closing, destroyed, or mid-launch, safeSend short-circuited and the patched prototype never fired. Send worked (it routes through ipcMain.handle('process:write') which the bridge does intercept), but the receive path was dead.

Fix: replaced the monkey-patch with an explicit broadcastBridgeEvent(channel, args) export from bridgeHandlers.ts, called from safeSend unconditionally — before the mainWindow liveness check. Now every IPC push reaches web-desktop regardless of Electron renderer state. Verified end-to-end via Interceptor: sent "ping from web-desktop bridge test — please reply with PONG" from the browser, agent received it, agent's PONG streamed back into both the desktop window and the web-desktop view.

Bug 1 — no UI toggle in EncoreTab

Straightforward gap. Added the toggle to EncoreTab.tsx alongside Symphony / Cue / Director's Notes — Globe icon, Beta badge, explanatory body copy, and a yellow warning panel reminding users to restart Maestro after toggling (the bridge installs once at server startup). Also registered the new card in searchableSettings.ts so the Settings search bar picks it up under keywords like "web", "desktop", "bridge", "bundle", "tailscale".

Bug 3 — SettingsModal crash in web-desktop

Could not reproduce on the new code. Walked through every Settings tab (General / Display / Shortcuts / Themes / Notifications / AI Commands / Maestro Prompts / SSH Hosts / Environment / Encore Features), opened the prompt editor, navigated back and forth — all clean, no thrown exceptions in the renderer console. Strong hypothesis: Bug 3 was an indirect symptom of Bug 2. Settings depends on push-event state-sync through safeSend, and those pushes were silently dropping in the round-1 code. Once the bridge fanout became unconditional in broadcastBridgeEvent, Settings started receiving its initial state correctly. No additional code change needed; recorded the verdict in the commit body so the next reviewer doesn't re-flag it.

Bug 4 — destructive surfaces visible inside /desktop/

Added src/renderer/utils/runtimeContext.ts with an isWebDesktop() helper that reads the server-injected __MAESTRO_CONFIG__ (with a /desktop/ path fallback) and memoizes. Then gated:

  • LIVE / OFFLINE toggle in SessionList.tsx — the one you flagged. Toggling it off would kill the webserver the browser is connected to, which is the exact disconnect you hit.
  • Check for Updates in HamburgerMenuContent.tsx — assumes the Electron binary is the runtime host.
  • Draggable title bar in App.tsx (WebkitAppRegion: drag) — no Electron host to drag, just visual clutter. A stale instance had snuck back in since the round-1 cleanup.
  • 40px top padding that was reserved to clear the title bar — without removing it, the bar's absence left a blank strip across the top of the page.

The helper is reusable for any future surface that needs the same treatment (Quit, Restart, "Open in Finder", etc.) — just if (isWebDesktop()) return null; at the render path or !isWebDesktop() && <Component/> in JSX.

On the UX nit (split /web/ vs /desktop/ routes)

Agree this is the right shape long-term — one URL that picks layout off the viewport instead of two parallel apps. Tracking it as a follow-up rather than expanding this PR's surface. I'd rather get the bridge fundamentals stable first and revisit the route consolidation as a focused piece of work.

Validation

  • npm run lint (tsc all configs): clean
  • npm run lint:eslint: clean
  • npm run test: 27,847 passed / 108 skipped / 0 failed (3 new tests around broadcastBridgeEvent install / fanout / uninstall)
  • npm run build: main + preload + renderer + web + web-desktop + cli all built
  • Live Interceptor verification in real Chrome at http://localhost:31999/<token>/desktop/: send / receive round-trip, Settings navigation, LIVE button absent, Check for Updates absent, drag bar absent, paddingTop == 0px

Thanks again — happy to dig into anything that still feels off when you do another pass.

@chr1syy
Copy link
Copy Markdown
Contributor

chr1syy commented May 18, 2026

Two more follow-up findings from the latest manual test pass against PR #991 head (172c616d) layered on upstream/rc (b96e9a07e). Both are in the new vite.config.web-desktop.mts. Each blocks a clean build/boot of the web-desktop bundle.

Bug 5 — vite.config.web-desktop.mts missing cssMinify: 'esbuild'

Production build fails on the renderer's bundled CSS:

✗ Build failed in 8.36s
error during build:
Build failed with 1 error:

[plugin vite:css-post]
SyntaxError: [lightningcss minify] Unexpected token Semicolon
  at style.css:2786:2
  -: \s|;

The repro is just npm run build:web-desktop after merging this PR — fails at the CSS minify step on what looks like a leftover xterm.js selector. Vite 8 flipped the default CSS minifier to lightningcss, which is stricter than esbuild and chokes on this. The fix is already documented in vite.config.mts and vite.config.web.mts — both pin cssMinify: 'esbuild' with a TODO referencing this exact xterm issue. The new web-desktop config just needs the same pin:

build: {
  // …
  cssMinify: 'esbuild',
  // …
},

Bug 6 — Web-Desktop bootstrap crashes with TypeError: te is not a function

After fixing Bug 5 and shipping the build, opening /desktop/ in a browser surfaces an error overlay:

Web-Desktop bootstrap failed
TypeError: te is not a function
    at http://192.168.x.y:PORT/<token>/desktop/assets/src-CtwyxH4l.js:1:56

Tracing the minified bundle: src-CtwyxH4l.js does import {r as te} from "./main-BRyRTqAn.js" to grab Rolldown's __commonJS helper, then immediately calls te(((t,n) => { (function(e,r) {…}).call(t, …); …})) to wrap dayjs's UMD module. But main and src form a load cycle (main pulls in src, src reads bindings from main), so the wrapper evaluates before main finishes exporting — the r/te binding is still TDZ. Hence "te is not a function" at the very first call.

dayjs is pulled in transitively by mermaid (npm ls dayjsmermaid → dayjs@1.11.19). The desktop bundle's Rollup config doesn't hit this because its chunking is different; the web-desktop config inherits Vite's defaults and lands in the cycle.

Fix: add a manualChunks callback to coalesce the CJS-wrapped mermaid transitive deps into a vendor chunk. Rolldown then auto-emits a tiny standalone rolldown-runtime-*.js chunk for the helpers, breaking the cycle. optimizeDeps.include doesn't help here — that only applies to vite dev, not the production build.

build: {
  // …
  rollupOptions: {
    input: { main: path.join(__dirname, 'src/web-desktop/index.html') },
    output: {
      manualChunks(id: string): string | undefined {
        if (id.includes('node_modules/dayjs/')) return 'vendor';
        if (id.includes('node_modules/khroma/')) return 'vendor';
        return undefined;
      },
    },
  },
  // …
},

Verified the cycle is gone post-patch: the new src-B3Hj0pCQ.js imports import {o as Wn} from "./rolldown-runtime-DcJc2D6O.js"; import {d as Qn} from "./vendor-DTKXQiAc.js" — no edge back to main, and /desktop/ now boots cleanly to the desktop UI.


Both patches are one-liners in the same file (vite.config.web-desktop.mts). Happy to open a tiny follow-up PR with the two fixes if useful.

@chr1syy
Copy link
Copy Markdown
Contributor

chr1syy commented May 18, 2026

One more finding from continued testing on the bug-fix build. Bug 2's safeSend fix is correctly merged and the broadcast path is intact — verified src/main/utils/safe-send.ts:34 unconditionally calls broadcastBridgeEvent before the renderer push, and broadcastService.broadcastToAll has no per-client filtering. But there's a downstream rendering issue that produces a Bug-2-shaped symptom.

Bug 7 — Cross-view messages: user input doesn't fan out and agent responses collapse into one blob

Repro:

  1. Open the desktop app and /desktop/ in the browser.
  2. Select the same agent in both views' Left Bar.
  3. Send a prompt from the desktop view.
  4. Look at the web view: your user message is missing. The agent's reply is there, but every chunk has collapsed into a single message slot instead of splitting into discrete turns.
  5. Swap directions (send from web): same symmetric symptom — desktop view is missing the user message, agent response is one giant blob.

What works:

  • The bridge.event fanout itself is firing — otherwise the web view would see nothing, not "the wrong thing." Confirmed by toggling Live Mode off+on (which forces the bridge connection to drop, reconnect, and re-fetch session state). After the reconnect, the web view renders the conversation history correctly.
  • No errors in either DevTools console.

Most likely root cause:

The renderer splits the streamed process:data chunks into discrete agent messages by listening for a turn-boundary signal that fires when the user submits input. That signal currently appears to be renderer-local — only the renderer where the user actually typed knows a new turn started. The other renderer (the cross-view client) receives the streamed process:data chunks via safeSend → broadcastBridgeEvent → bridge.event, but with no turn-start marker, it appends every chunk to the last-open message slot.

This also explains why the user-typed message never appears in the cross-view: it isn't a process:data event at all, just an optimistic local insert in the originating renderer's state. The cross-view never gets any event for it.

Fix direction (two equally workable options):

  • Add a turn-boundary IPC event that main fans out to all renderers when input is received (e.g., process:input-submitted carrying { sessionId, content, role: 'user' }). Both views could then create the user-message slot and the next-turn agent-message slot from the same signal.
  • Or encode turn boundaries into the process:data stream itself — e.g., the first chunk of an agent reply carries a turnId/messageStart flag the renderer can detect.

The first option is closer to how the desktop renderer already works (everything fans out via main), and avoids re-shaping the data plane.

Diagnostic that misdirected us first

I initially suspected WebServer.ts:1264 skipping installWebContentsBridgeHook when webDesktopBundle is toggled on after Live Mode is already running. That code path is real and worth hardening (re-install/uninstall the hook when the Encore flag changes mid-session), but it isn't what was biting us here — the hook was installed. Leaving the observation in case it's useful for hardening separately.


Open offer from the earlier comment still stands: happy to bundle Bug 5 (cssMinify), Bug 6 (manualChunks), and Bug 7 (turn-boundary fanout) into a single follow-up PR if useful.

@chr1syy
Copy link
Copy Markdown
Contributor

chr1syy commented May 19, 2026

One more finding from continued testing on the bug-fix build.

Bug 8 — Copy-to-Clipboard silently fails on AI and Human messages in /desktop/ view

Repro: open /desktop/ in a browser, click the copy button on any AI message or any user message. Expected: text on the clipboard + the usual center-flash ack. Actual: nothing — no clipboard write, no error toast, no flash. Works correctly in the Electron desktop app.

Root cause in src/renderer/utils/clipboard.ts:16-25:

export async function safeClipboardWrite(text: string): Promise<boolean> {
    try {
        await navigator.clipboard.writeText(text);
        return true;
    } catch {
        // NotAllowedError when document not focused, or other clipboard failures.
        return false;
    }
}

safeClipboardWrite() calls navigator.clipboard.writeText() inside a swallow-all catch {} that returns false with no surfaced failure. In the browser context that call can fail from any of: non-secure context (when /desktop/ is accessed via LAN IP like http://192.168.x.y:PORT/... rather than localhost), document-focus loss across the WS round-trip, or missing clipboard permission — and every one of those is hidden from the user.

Two compounding issues:

  1. No IPC fallback for text. Image copy in the same file (safeClipboardWriteImage, line 47) checks window.maestro?.shell?.copyImageToClipboard first and only falls back to navigator.clipboard. Text copy has no escape hatch.
  2. No handler to mirror. src/main/ipc/handlers/system.ts only registers clipboard:writeImage. There is no clipboard:writeText handler, so the PR feat(web): web-desktop bundle Encore Feature + responsive mobile UX #991 bridge in src/main/web-server/handlers/bridgeHandlers.ts has nothing to mirror even if safeClipboardWrite wanted to try the IPC path.

Call sites that hit this: src/renderer/components/GroupChatMessages.tsx (the chat-message copy buttons) and src/renderer/components/MarkdownRenderer.tsx (the code-block copy button) — both go through copyToClipboard()safeClipboardWrite(), so the same silent-failure affects every copy surface in the web-desktop view.

Suggested fixes (in order of preference):

  1. Add a clipboard:writeText IPC handler in src/main/ipc/handlers/system.ts + a window.maestro.shell.copyTextToClipboard preload bridge. Have safeClipboardWrite() try the IPC path first, mirroring the image-copy pattern. The PR feat(web): web-desktop bundle Encore Feature + responsive mobile UX #991 bridge then handles text copy cleanly in both Electron and /desktop/.
  2. At minimum, surface failure: fire a red notifyToast (or even a console.warn) on the false return so users aren't left wondering whether the click registered.

Open offer from the earlier comments still stands: happy to bundle Bug 5 (cssMinify), Bug 6 (manualChunks), Bug 7 (turn-boundary fanout), and Bug 8 (clipboard IPC fallback) into a single follow-up PR if useful.

@chr1syy
Copy link
Copy Markdown
Contributor

chr1syy commented May 19, 2026

Quick follow-up on Bug 8 — the blast radius is wider than my comment implied.

Right-click → Copy Link on a hyperlink in agent chat fails with the same symptom (silent, no clipboard write, no toast). Traced to src/renderer/components/LinkContextMenu.tsx:47-50:

const handleCopy = useCallback(() => {
    safeClipboardWrite(menu.url);
    onDismiss();
}, [menu.url, onDismiss]);

Same safeClipboardWrite() underneath, so this is one bug, not two — but it prompted me to grep the renderer for every caller. safeClipboardWrite | copyToClipboard | navigator.clipboard hits 41 files under src/renderer/. Sampling the non-chat surfaces:

  • Right-click context menus: LinkContextMenu.tsx, FileContextMenu.tsx, NodeContextMenu.tsx (DocumentGraph)
  • Code-block copy in MarkdownRenderer.tsx
  • Terminal copy: TerminalOutput.tsx, XTerminal.tsx
  • JSON viewers: JsonlViewer.tsx, CollapsibleJsonViewer.tsx
  • All four tab-bar copies: AITab.tsx, FileTab.tsx, TerminalTabItem.tsx, BrowserTabItem.tsx
  • Modals: QuickActionsModal.tsx, HistoryDetailModal.tsx, GistPublishModal.tsx, DebugPackageModal.tsx
  • Misc: ImageAnnotator.tsx, AutoRunLightbox.tsx, AIOverviewTab.tsx (Director's Notes), ExecutionQueueBrowser.tsx, StandingOvationOverlay.tsx, LiveOverlayPanel.tsx, etc.

Effectively every copy action in the renderer is silently broken in /desktop/ view, not just chat-message copy. The good news: fixing Bug 8's root cause (add clipboard:writeText IPC handler + window.maestro.shell.copyTextToClipboard preload bridge + IPC-first try in safeClipboardWrite) clears all ~40 surfaces at once. Worth bumping the priority a notch on that basis — wanted to flag before you scope the fix.

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.

3 participants