Skip to content

feat(desktop): browser tab alongside Chat and Terminal in task panels - #84799

Draft
DanielVisca wants to merge 2 commits into
masterfrom
posthog/desktop-session-browser-panel
Draft

feat(desktop): browser tab alongside Chat and Terminal in task panels#84799
DanielVisca wants to merge 2 commits into
masterfrom
posthog/desktop-session-browser-panel

Conversation

@DanielVisca

Copy link
Copy Markdown
Contributor

Problem

Desktop users iterating on their product have to leave the app to see the thing they're building — the app has Chat and Terminal per task, but checking a change means switching to an external browser, losing the task context. The Product View spike (PostHog/code#4054, pre-monorepo-migration) validated an embedded browser plus an analytics overlay in one big PR; per the Slack thread on it, the work is being split into single-purpose PRs. This is PR 1: just the browser, done properly. The analytics overlay ships separately on top of it.

A task panel is the right home (rather than a global tab): the page being iterated on belongs next to that task's Chat and Terminal, and its state should live and die with the task.

Changes

A new browser panel tab type inside products/desktop, next to Chat and Terminal — one native Chromium view per tab, owned by the Electron main process and glued under the panel's rect. The tab-bar gets a 🌐 "new browser" button beside "new terminal".

tab strip

A browser tab alongside Chat and Terminal (Storybook). In the running app the host paints the live page into the grey area — natively, above the renderer, so it cannot render in Storybook:

real view

↑ the real dev app painting posthog.com in this PR's WebContentsView, driven end-to-end through the host router in a headless sandbox (signed-out, hence no task chrome around it).

New tab → URL prompt; load failure → banner with retry:

Fresh tab Load failed
fresh failed

Layering (per products/desktop/AGENTS.md):

  • @posthog/platformIEmbeddedBrowser interface + EMBEDDED_BROWSER token; new embeddedBrowser host-capability flag (desktop true, web false — the web host hides the feature entirely).
  • @posthog/coreEmbeddedBrowserService: the single http(s)-only URL policy (normalizeBrowserUrl) every URL passes before reaching a native view; zod schemas; the browser TabData variant + addBrowserTab/updateBrowserTabUrl transforms.
  • host-routerembeddedBrowser router: one-line forwards + an event subscription (page state, load failures), mounted in both the shared and desktop assemblies.
  • apps/codeElectronEmbeddedBrowser adapter (WebContentsView per view id).
  • @posthog/uiBrowserPanel (toolbar: back/forward/reload, URL bar, open-external, DevTools) split into a pure chrome (storied + unit-tested) and a wiring container; a slot hook reporting bounds via ResizeObserver and driving visibility; a layout-diff reconciler destroying views whose tabs left the layout.

Security posture:

  • Fully sandboxed guest: sandbox: true, contextIsolation: true, no Node, no preload — nothing is injected into browsed pages in this PR.
  • Own persistent partition (persist:embedded-browser) — browsed pages never see the app's persist:main cookies; the user's product logins survive restarts.
  • All page permission requests (camera, mic, geolocation, notifications) denied.
  • http(s)-only, enforced three times: UI normalization, core service (rejects file:, javascript:, etc.), and will-navigate in the adapter.
  • OAuth-friendly UA: identity providers reject embedded webviews (disallowed_useragent), so the Electron/…/app tokens are stripped at session level and rewritten on every request header — a popup's first request is already in flight before any per-webContents override can run.
  • Popups: http(s) popups open as sandboxed same-partition child windows (popup-based SSO must share the opener's session); non-web popups denied; popups-of-popups go to the system browser.

Lifecycle & edge cases:

  • Inactive tabs: panels keep inactive tabs mounted with visibility: hidden (rects stay non-zero), so visibility is gated on the layout store's active-tab state, not the DOM.
  • Overlays: the native view paints above all DOM, so it hides while the command menu is open or a tab drag is in flight; an acquire()/release() store lets future overlays do the same.
  • Every close path destroys the view: Cmd+W, the ✕, close-others, close-to-right, closing a whole panel — a reconciler diffs browser tabs out of the layout instead of trusting per-tab onClose. Task deletion tears down all of a task's views (mirrors destroyTaskTerminals).
  • Tab/task switching keeps the view alive (like terminals keep their pty): switching back lands on the page exactly where it was — create() on an existing view re-glues and never re-navigates.
  • Restart: the tab persists its current URL (debounced) into the layout store, so the page restores; the persisted-store shape change is additive (no version bump — existing layouts untouched).
  • Zoom: bounds scale by the window zoom factor (Cmd+/-).
  • Load failures/crashes: the view survives; a banner shows the error with retry (ERR_ABORTED noise filtered). The tab label follows the page title, like the terminal tab follows its process name.
  • Cloud runs: browser stays available (it needs only the host capability, unlike the terminal which needs the local workspace).

Known limitations (follow-up candidates): a popup's first document still reports Electron in navigator.userAgent (servers already see the clean UA via the header rewrite); browser tabs in a torn-off window would still glue to the main window; toasts aren't wired into the obscured store.

How did you test this code?

  • Live end-to-end in the running dev app (Linux/Xvfb, driving this PR's real host router): open → posthog.com painted in the window (screenshot above); navigate → /pricing with canGoBack; goBack → home with canGoForward; file:///etc/passwd → rejected Not a loadable web URL; example.com → normalized to https://example.com/; popup window.open → same-partition child window, javascript: popup denied; destroy → page state null, no leaked CDP targets; server-visible UA verified clean via a header-echo service for the view and a popup's first request (this live pass caught the popup-UA bug the header rewrite now fixes). The live pass ran pre-migration in PostHog/code; after porting to products/desktop, all automated checks were re-run in this repo.
  • Unit tests (all run in this repo): URL normalization table (16 cases), core service policy, panel-layout transforms, browser-panel chrome (render/submit/escape/disabled/banner), cleanup reconciler — each catches a regression no existing test did (e.g. close-others leaking a native view, localhost:8000 misread as a custom scheme). Full @posthog/core and @posthog/ui suites pass; the whole feature is additive and capability-gated, so existing suites double as the no-regression check.
  • Storybook: 4 new stories feed the visual-review CI check.
  • pnpm build:deps, typecheck across core/ui/host-router/code/web, biome ci clean, check-host-boundaries clean (no new violations).
  • Not checked: real-app manual pass on macOS with a signed-in task view (sandbox is Linux + signed out) — screenshots of the tab inside a real task would be a good reviewer addition.

Automatic notifications

  • Publish to changelog?

Docs update

None — desktop-internal feature; screenshots live in products/desktop/docs/images/embedded-browser/.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Authored with Claude Code (PostHog Desktop cloud task). Originally built and live-verified against the pre-migration PostHog/code repo (branch posthog-code/session-browser-panel, unpushed due to an integration permissions gap), then ported here after the code moved to products/desktop — 40 of 44 files applied clean; 4 panel files were re-ported against their evolved monorepo versions (useIsCloudTask rename, import drift) and everything re-verified. Design decisions along the way: the reconciler replaced per-tab onClose cleanup after close-others was found to leak views, and the request-header UA rewrite replaced a per-webContents override after live testing showed popups' first request raced it.


Created with PostHog Desktop

@trunk-io

trunk-io Bot commented Aug 18, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@github-actions

Copy link
Copy Markdown
Contributor

Hey @DanielVisca! 👋

It looks like your git author email on this PR isn't your @posthog.com address (danielvisca96@gmail.com). Since you're on the PostHog team, it's worth pointing your local git author email at your @posthog.com address. Why it matters:

  • Consistent work identity in git history — internal tooling that attributes commits to team members keys off your @posthog.com address.
  • Keeps team contributions easy to tell apart from external community ones when scanning history.

You can fix it for this repo with:

git config user.email "you@posthog.com"

Or set it globally with git config --global user.email "you@posthog.com". No need to redo this PR — just a nudge for next time. 🙂

@github-actions github-actions Bot added the feature/desktop Feature Tag: Desktop label Aug 18, 2026
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit c525868.

@hosthog

hosthog Bot commented Aug 18, 2026

Copy link
Copy Markdown

HostHog preview — posthog-desktop-web

Latest build (c525868): https://43105f5df750428fbc507066a6a64521.hosthog.dev

Employee-gated; every push gets a fresh URL whose content never changes. All previews stop serving when the PR closes.

@trunk-io

trunk-io Bot commented Aug 20, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

@DanielVisca
DanielVisca force-pushed the posthog/desktop-session-browser-panel branch from 1f4dbee to ef76f65 Compare August 27, 2026 14:39
@github-actions

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Trunk lane — backend Python lane

This PR is assigned to the backend Python lane. It runs backend Python tests and may merge in parallel with PRs in other lanes.

A new "browser" panel tab type backed by an Electron WebContentsView the
main process owns; the renderer drives bounds/visibility/navigation over
the host router. Layered per the desktop architecture rules:

- @posthog/platform: IEmbeddedBrowser interface + EMBEDDED_BROWSER token,
  and a new embeddedBrowser host capability flag (desktop true, web false)
- @posthog/core: EmbeddedBrowserService (single http(s)-only URL policy,
  normalizeBrowserUrl), zod schemas, DI module; "browser" TabData variant
  with addBrowserTab/updateBrowserTabUrl transforms
- host-router: embeddedBrowser router (one-line forwards + event
  subscription), mounted in both the shared and desktop assemblies
- apps/code: ElectronEmbeddedBrowser adapter — sandboxed guest on its own
  persist:embedded-browser partition, all permission requests denied,
  UA cleaned at session + network-header level so OAuth popups aren't
  rejected as embedded webviews, http(s) popups allowed as same-partition
  child windows (popup SSO), zoom-aware bounds
- @posthog/ui: BrowserPanel (toolbar/URL bar/error banner + pure chrome for
  stories), slot hook gluing the native view to the panel rect, visibility
  gated on active-tab state (inactive tabs stay mounted with non-zero
  rects), layout-diff reconciler destroying views on every tab-close path,
  browser-view teardown on task deletion

Tabs persist per task through the existing panel-layout store; each tab
restores its last page across restarts, and page logins survive restarts on
the persistent partition. The whole feature is capability-gated: the web
host hides it entirely and existing layouts are untouched (additive TabData
variant, no persistence version bump).

Generated-By: PostHog Desktop
Task-Id: 1120fcea-fc07-4c54-8dbb-50b7b712f9cb
…ender

useEmbeddedBrowserSlot wrote urlRef.current and visibleRef.current straight
in the render body. React can discard or replay a render, so a value from a
render that never commits could leak into the live native view.

Both refs are only ever read from async callbacks (the rAF-scheduled bounds
reporter and the .then() after open resolves), which always run after the
commit that produced the value, so mirroring them in an effect keeps the
existing behavior while making the write happen on commit only.

Clears the two no-ref-current-in-render errors React Doctor reported.

Generated-By: PostHog Desktop
Task-Id: 1a7fb1f9-a18e-46ce-87f9-ffb6ad3400c9
@DanielVisca
DanielVisca force-pushed the posthog/desktop-session-browser-panel branch from ef76f65 to c525868 Compare September 2, 2026 16:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature/desktop Feature Tag: Desktop

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant