Skip to content

feat: add web previews and virtual displays to workspace tabs - #426

Merged
theogravity merged 139 commits into
mainfrom
feat/pane-preview
Oct 25, 2026
Merged

theogravity merged 139 commits into
mainfrom
feat/pane-preview

Conversation

@theogravity

@theogravity theogravity commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Subshells can open web previews, watch browsers, connect to saved web services, and use virtual displays in independent workspace tabs. Views open beside their terminal and can be moved, split, maximized, or closed independently. The picker uses compact rows and plain port numbers.

Node settings independently control web previews and virtual displays. Enabling web previews makes them available to every subshell on the node. Virtual displays remain a launch option, with provider requirements checked in node settings, the launch dialog, and MCP. The server and node CLIs receive major version bumps; desktop apps receive their normal dependent bumps. No new publishable npm package is introduced.

Virtual displays now use a shared provider contract for prerequisites, allocation, local launch settings, recovery, and cleanup. Nodes can enable multiple providers and choose a default; both settings are persisted and sent to enrolled nodes through signed configuration. New subshells can choose any enabled provider, and presets and MCP carry the same choice. Each subshell pins its actual provider and display size across restarts. Disabling a provider prevents new allocation and restarts before stopping the current subshell. Xvfb + VNC remains the only bundled provider. The VNC viewer is a separate adapter, and provider launch credentials stay on the machine. This supplies the extension points for other backends; it does not add macOS support yet.

Changing the launch node clears an incompatible harness and its preset, resets the directory to that node’s own recent path or home, and turns off a display that the new node cannot provide. Compatible choices and prompts stay. The form explains corrections, waits for confirmed requirements, and ignores late results from a prior node.

The public guide covers setup, controls, limitations, and node permissions. Internal documentation explains provider integration, protocol behavior, and security boundaries. The website includes a recorded workflow and the guide includes screenshots.

Security and dependencies

Preview content is isolated from dashboard browser authority. Forwarding strips dashboard credentials, keeps target cookies server-side, rechecks access around asynchronous opens, and revokes views when permissions or subshell lifecycle change. Transport queues, image dimensions, and pending operations are bounded. Display recovery and cleanup verify ownership before signalling processes. The provider security pass fixed concurrent VNC port selection, permission changes during asynchronous readiness, and overlapping allocation/cleanup. VNC allocation and recovery now verify socket ownership; per-subshell operations are ordered and permissions are rechecked before allocation.

Preview URLs remain bearer capabilities, and the server can read preview traffic. Disabling a node feature prevents new connections while existing connections continue. Loopback VNC has no separate password, so virtual displays are unsuitable for hosts shared with untrusted local OS users. Browser isolation restricts some storage and login flows. These limits are documented in the guide and security model.

Zod dependencies are updated to 4.6.7, including the Expo CLI consumer. A tracked node-forge patch rejects malformed nested DigestAlgorithm fields; regression tests cover invalid signatures and valid Expo consumers. The audit records the remaining upstream version advisory and local mitigation. The server’s direct ws dependency is retained because its preview transport uses the package’s frame codecs.

Validation

  • Workspace build, full TypeScript suite, type checks, lint, license boundaries, design tokens, prose checks, and documentation export checks.
  • Multiple-provider runtime selection, signed policy, per-launch choices, pinned restarts, preset edit/export/import, permission, requirement, recovery, cleanup, and launch-rollback regressions. Added regressions for concurrent port allocation, foreign listeners, policy changes during readiness, and competing providers with pending cleanup.
  • Chromium end-to-end checks for enrolled-node forwarding, browser watching, browser isolation, and saved services.
  • Real concurrent Xvfb/VNC allocations with distinct ports, RFB greetings, provider launch environment, socket-verified recovery, and cleanup.
  • Node provider selector, enable switch, persisted policy, readiness, login, and form corrections on node changes verified through the hot-reload demo.
  • CI build failure traced to the removed direct ws dependency; frozen installation and workspace build verified after restoring it. The remaining SSH test race now waits for command delivery instead of assuming it completes in 25 ms.

The source/security review found no remaining major or minor issues within the reviewed scope. Native macOS providers and every browser/framework combination remain outside this implementation.

@theogravity theogravity changed the title docs: pane preview design spec and implementation plan feat: pane preview - watch browser panes, open forwarded ports, noVNC screen Oct 23, 2026
@theogravity
theogravity force-pushed the feat/pane-preview branch 3 times, most recently from 97fe585 to 6efc19d Compare October 24, 2026 01:28
@theogravity theogravity changed the title feat: pane preview - watch browser panes, open forwarded ports, noVNC screen feat: preview subshell apps and screens in movable workspace tabs Oct 24, 2026
theogravity and others added 24 commits October 24, 2026 20:49
14 TDD tasks in dependency order: protocol 23 grammar, node switch, port
probe, forward lanes, CDP bridge, virtual displays, plane broker, REST
authz matrix, /pv proxy, live watch fan-out through the §11.14 control,
dashboard surfaces, noVNC screen viewer, e2e and docs.

Recorded decisions against the spec: @novnc/novnc npm dep replaces git
vendoring (same pinned/MPL ruling, cleaner); forward lanes are per
browser connection, not per session (dev servers mix keep-alive HTTP
with a permanent HMR socket); close-reason set gains target-gone and a
preview-closed node event because the relay precedent has no target-death
signal. Route and command names follow the spec exactly (preview_open
kinds forward/screencast, /preview/open).

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewer findings folded in: pending-ack off-chain dial for forward
lanes (ssh-relay-exec A-branch precedent), the real launch command and
pane-runtime tmux-runner paths, launch.screen switch gate before spawn,
fps clamp [1,5] with optional wire field, snake_case NodeEvent members
plus a preview_closed event and target-gone/not-cdp reasons, node
1 MiB slow-link constant, Xvfb -auth pinned env, census tests are
hand-pinned, node-results validators, five spec §7 dashboard behaviors,
version-floor wording, and the static noVNC import (no dynamic import
rule has one named exception). Spec fixes: the nonexistent
polling-routes request-log exclusion, the dead docs/node-gates.md
pointer. Branch rebuilt on current main: the docs commits had landed on
the dropdown branch after #422 merged.

Co-Authored-By: Claude <noreply@anthropic.com>
The WS tunnel split is the substantive one: one blind byte pump cannot
serve both its consumers (raw RFB needs no handshake, HMR targets need
theirs), so sessions carry an http/raw mode the broker derives from the
pane's registered screen port - no wire or REST change. Plus the
close-reason runtime list and pinned census, a closeLaneForConnection
broker member, deps seams on the forward lane, bridge, and broker, the
queue constant, raw/ws test cases that cannot silently pass, the docs
meta.json registration, and the child-process e2e reality.

Co-Authored-By: Claude <noreply@anthropic.com>
Watch is now wired end to end: REST owns bridge lifecycle, the live
watch/unwatch frames attach the VIEWING SOCKET, and the SPA sends both
halves (the feed hook's existing send() becomes the seam). Lane
subscriptions key on laneRef so an iframe and an HMR socket under one
session stop trading bytes. preview_close reaches the CDP bridge and
link-drop reaches bridgeCloseAll. Plane->node lane bytes get a real
sender cloning sendRelayFrameOverNodeSocket - they never rode
sendCommand. The bridge drops the invented traffic-idle rule (a static
page repaints nothing) and keeps spec §5's zero-subscriber semantics.
Plus stopWatch by pane id, closeForPane replacing the uncalled
closeSession, deps seams the tests name, per-socket §11.14 precision,
the e2e/docs/commit-discipline minors, and the NIT list.

Co-Authored-By: Claude <noreply@anthropic.com>
Eight minors and six nits, every one re-verified on disk first:
the 401 node-key row, the paneEnvFor env seam (tmux-runner opts.env
is the tmux client env, not the pane's), launch leaving the dataless
list and set_preview_enabled's unread ack, the revoke cut inside
setShares beside the shares-changed announce, no ref in preview
audit rows (the relay says out loud its ref is not a credential;
ours is), a per-subscription watch counter not a Set, mode
discrimination from the live ports record so a plane restart cannot
downgrade a display to http, REQUEST_LOG_IGNORE entries for the info
poll and the /pv path - which also means my earlier spec erratum was
wrong: the exclusion mechanism exists, restored in spec and plan.
Task 3 now spells its storage clone out as the 0062 column-pair
migration, and the /pv upgrade probe is described as the
reachability/gate probe it actually is.

Co-Authored-By: Claude <noreply@anthropic.com>
One major: the node event switch in node-ws-handler.ts is where
every node event is dispatched, has no default branch, and no task
touched it - all three preview event types would have landed dead
with every unit test green. Preview arms, named, plus a dispatch
test in the file's own handleNodeMessage style.

Five minors: Task 1's commit stops staging whole app directories in
a tree carrying other workstreams' WIP; the lane-bypass test now
counts the fake node's bytes (an in-proc target answers plane
loopback, so a bypassed dial used to pass its own guard);
sendToSockets' production wiring has an owner (Task 11), the
per-viewer byte-debt seam is designed (bufferedAmount as the
optional LiveWsSocket member the readyState habit already allows,
checked at broker fan-out, PREVIEW_STREAM_DEBT_MAX pinned at 4 MiB),
and Task 3's node test states the gate it can actually express.

Nits: the routing-ref-not-a-credential quote is at :583, not :608;
screen-launch-hint joins the file structure; /pv names server.ts and
the before-the-SPA-plugin ordering; PREVIEW_COMMAND_TYPES is
package-exported like its SSH twin.

Co-Authored-By: Claude <noreply@anthropic.com>
Five minors, all quote-verified: SSH_COMMAND_TYPES lives in
ssh-frames.ts, not node-frames.ts, so the census instruction now
copies the shape and puts PREVIEW_COMMAND_TYPES with its own grammar;
the 0039 curated-list precedent is in apps/server/api/AGENTS.md:824,
not the testing rules; the e2e spec lands where Playwright's testDir
will find it (e2e/tests/47-preview.spec.ts); the fps comment no
longer promises a default the arm must not apply; and the permission
surface got planned - the switch route gets its own nodes:preview
catalog member (reusing nodes:ssh would let every ssh-scoped key flip
preview) and the pane routes carry the read/execute annotations their
siblings draw, with the scoped-key refusal pinned as its own matrix
row.

Nits: the push assertion moves to the service test whose scripted
wire can actually see it (the route test's node fixture is offline on
purpose); the disable/role routes' missing relay cuts are named as a
different axis; bufferedAmount is described as the Task 11 addition
it is, not something LiveWsSocket already has. One nit was rejected
on the disk: "POLLED by a page somebody leaves open" is verbatim at
context.plugin.ts:19.

Co-Authored-By: Claude <noreply@anthropic.com>
- scoped-key refusal row: #424's named-permission 403 branch, block re-anchored :201-213
- /pv upgrade gated with browserOriginAllowed (registry origins pass; plane-origin equality rejected published-network dashboards and contradicted spec §6's 'cross-site' wording)
- Task 11 updates ws.plugin.ts's stale watch-previews forward-reference
- Task 1 staging note: sibling WIP merged; refresh the dirty-tree discipline text
- preview_open range fixture gains the required port (lifetime-bound cases
  were vacuous: the missing field refused the body before bounds ran)
- watch bridge is pane-keyed, matching the spec's portless DELETE, pane-keyed
  socket map and ref-less stream frame; a second port on a live bridge is a
  named 409, and the SPA sends its live frame only after a REST join
- decision citations fixed: spec §6 (not §5) owns the raw-tunnel quote; the
  POLLED-by-a-page reason belongs to context.plugin.ts:19, not the test
- MAJOR: Task 7 now wires preview-ports-exec.ts to the display registry
  (Task 4 hardwired screen:null per its own note and nothing un-hardwired
  it: decision 6's discriminator and Task 14's real leg read a supply line
  no task built); + ports-screen and bindProbe-seam tests
- PREVIEW_STREAM_DEBT_MAX actually lands in the constants fence (was named,
  fenced nowhere)
- byte-cap seam keys named where the cap tests need them (Task 5
  laneBufferMaxBytes, Task 10 preAckMaxBytes)
- live sender decided: feed context value gains bound send(frame): boolean
  (helper at :23 needs a socket the provider closure never shared), plus
  watch re-fire on reconnect
- decision 7 recorded: per-BRIDGE subscriber sum vs PREVIEW_WATCH_SUBSCRIBERS_MAX,
  and the socket fan-out capped at the same 8 with over-cap audit
- NITs: phantom previewFlags() -> getPreviewEnabled(); ::1 added to the
  CDP host check per the Global Constraint; orphan dropViewer removed;
  collision-retry seam
- Task 1 runs lint:lockfile:fix and ships the re-recorded bun.lock version
  line (bun.lock:128 pins 2.3.0; bun install never resyncs; lint.yml:84
  gates it, so the bump commit without the fix reddens CI forward)
- closeForGrantee is detach-only, matching Task 11's pinned socket-alive
  outcome; Task 8's fake asserts close is never called
- Files-block gaps: Task 7 gains commands/context.ts, Task 8 gains
  node-ws-handler.test.ts (the dispatch rows)
- Task 11 revocation pointer corrected: the matrix has no revoke case today,
  the precedent is live-ws.ts:284-288
- info on an online non-Linux node pins nodeEnabled true +
  preview-ports-unsupported reason (Task 14's mac leg reads exactly that)
preview_enabled event wired end to end (Task 1 grammar, Task 3 reconcile/
dispatch/heartbeat, spec bump entry), preview-node-off named in Task 4,
wire-level tests for the disable/role/shares cuts, PreviewStreamSocket +
PreviewStreamFrame typed once, closeAll lane test, Task 1 barrel in Files.

Co-Authored-By: Claude <noreply@anthropic.com>
- Cookie jar gets an owner: rides PreviewSession (dies with it, no second
  teardown path), pv-http reads/writes it, pv-ws http re-dial carries it
- Sender cap named and placed: sendLaneBytes closes over-cap plane-side,
  forward pump case (i) node-side; over-cap's only close use is spec
  section 6's sentence that reserved it
- Node-reported bridge close now tears the plane half down (session,
  viewer counts, detach-not-close) so startWatch re-opens instead of
  joining a phantom bridge
- fps grammar reworded to optional-under-screencast (matches the test)
- Task 14 manual live leg grounded in spec section 10's own non-gating
  sentence (reviewer's CI-job premise not in the spec)
- Files entries now have owning steps: Task 9 info-poll REQUEST_LOG_IGNORE
  (+ pinned-test row), Task 12 settings toggle + create-dialog screen
  field (tests in Step 1, green in Step 4), Task 13 Screen-button wiring
  + render test, Task 14 changeset step, Task 10 bun add ws + server.ts
  registration
- Task 2's barrel line: PreviewStreamFrame cannot reach Tasks 8/11 until
  index.ts names it (named-list block, no subpath export)
- over-cap naming unified: refusals carry preview-*-over-cap codes, the
  bare word names closes (the sender rule, both halves), and
  node.preview_watch.viewer_drop gives the two unnamed audits (attach
  cap, byte debt) an action with reason in metadata; Task 14 inventory
  extended
- Commit discipline restated in Global Constraints (every task commits,
  neighbors see only their own task); Task 1 Step 7 stages file paths,
  not the protocol directory
- Probe-lane close named (viewer-left, wire-only; audit pairs belong to
  sessions), getPorts gains its declared shape and openForward's
  catch-and-fall-back for the mac leg
- NITs: barrel Files parenthetical no longer a stale review note
Union fold of round 9's decomposition pair (0/2/2) and correctness pair
(0/1/3), both pinned to b4ad882, plus the six held round-8
re-review findings (H1-H6). The wiring clause moves onto live-ws.ts and
preview-broker.ts is named NOT-an-edit-site; Task 2's barrel export gets
its step; the screencast branch pins its pane-gone rung; display.json is
written incrementally and boot rehydrate is conditional on bindProbe;
deps gains kill; the switch-off screen-launch refusal moves to a failing
step; Task 13 feeds viewer-left the way it actually arrives.
Union fold of round 10's decomposition pair (0/4/3) and correctness pair
(0/1/0); the badge gap was found independently by both. The spec §9
mid-pane display loss gains a real mechanism: the display module watches
its spawned children, reaps on exit, and marks display.lostFor, so the
ports answer grows one optional screenLost binary-name field threaded
Tasks 1-4-7-9-12 (deliberately NOT folded into the info route's reason
word). Also owned at last: Task 7's pane-runtime extraEnv test, the fps/
age badge, the allowedHosts doc sentence, Task 13's novnc install step,
Task 3's 0062 migration test, and Task 11's ws.plugin.ts comment fix.
@theogravity theogravity changed the title feat: preview subshell apps and screens in movable workspace tabs feat: add web previews and virtual displays to workspace tabs Oct 25, 2026
@theogravity
theogravity merged commit cec0b06 into main Oct 25, 2026
21 checks passed
@theogravity
theogravity deleted the feat/pane-preview branch October 25, 2026 08:18
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant