Skip to content

fix(electron): stop the system menu blanking the window, and define the shell/host seam (0406) - #641

Merged
crs48 merged 4 commits into
mainfrom
claude/0406-unified-shell
Jul 28, 2026
Merged

fix(electron): stop the system menu blanking the window, and define the shell/host seam (0406)#641
crs48 merged 4 commits into
mainfrom
claude/0406-unified-shell

Conversation

@crs48

@crs48 crs48 commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Implements exploration 0406 — partially (8/33). See the doc in this PR for the full plan.

Why the desktop UI looked "minimal"

Desktop is not behind web; it is a different shell. Web's entire workbench — islands, explorer, panels, palette, AI chat, 6,698 LOC — lives in apps/web/src/workbench/, where Electron structurally cannot import it. Every shell improvement since 0280 landed there and was unavailable to desktop by construction.

The load-bearing discovery is that the state model is already shared: apps/web/src/workbench/layout-tree.ts is a shim re-exporting @xnetjs/plugins, and workspace-parity.test.ts already guards that desktop consumes the same module. Both surfaces agree on what a workspace is; they disagree only on how to draw it.

0280 named this exact fork ("Electron divergence is the tax on shell-as-code") and its open question 6 scheduled the ShellFrame port for phase 5, which never ran.

What this PR ships

The crash (user-visible)

Opening the desktop system menu — the shell's only navigation affordance — threw during render and unmounted the whole React tree to a black window. MenuLabel wrapped Base UI's Menu.GroupLabel, which throws unless it finds a <Menu.Group> ancestor; the simple Menu has no groups.

A browser tab that white-screens still has a reload button. A packaged desktop window has neither, so this was a dead app recoverable only by quitting.

  • MenuLabel is now a plain element (real grouping lives on DropdownMenuGroup + DropdownMenuLabel)
  • ShellErrorBoundary degrades any future shell render failure to a recoverable panel
  • Drops the orphaned Sidebar.tsx and the @tanstack/react-router dep Electron declares but never imports

The seam (no behaviour change)

PlatformPort / NavTarget / PlatformCapabilities plus the web implementation. The shell states intent — "open this node", "go to this surface" — and the host decides what that means: web pushes URLs, desktop will transition its ShellState reducer.

The node→route table moves to the host (routes are a property of the web host, not of the shell) and is now Record<TabNodeType, …>, so a missing entry is a compile error; previously it was a runtime console.warn and the click silently did nothing.

Verification

Menu fix verified in the running desktop app over CDP, not just in tests:

menuItemCount: 16
labels: ["Workspace", "Theme", "Recent"]   // role=presentation, not menuitem
shellAlive: true
console errors: 0

The MenuLabel regression test was checked against the old implementation first — it throws there, so it is not passing vacuously.

Suites: 149 electron · 188 ui · 990 web-dom · typecheck clean.

What is deliberately NOT here

8/33 items. The remaining work is the actual unification — extracting packages/workbench, mounting it in Electron, and giving the desktop agent bridge a UI.

The next item is migrating 25 workbench modules (37 raw navigate call sites) off TanStack Router onto the port. I stopped rather than half-migrate: this repo verifies UI by driving the real app rather than with UI tests, and a partly-migrated port through web's highest-churn area is worse than none. The port therefore lands with no consumers yet — a safe intermediate state, unlike a half-migrated one.

🤖 Generated with Claude Code

xNet Test added 4 commits July 28, 2026 08:15
The desktop app is not behind the web app; it is a different shell. Web's
entire workbench — islands, explorer, panels, palette, AI chat — lives in
apps/web/src/workbench/, where Electron structurally cannot import it.

The load-bearing finding is that the state model is already shared:
apps/web/src/workbench/layout-tree.ts is a shim re-exporting @xnetjs/plugins,
and a parity test already guards that desktop consumes the same module. Both
surfaces agree on what a workspace is; they disagree only on how to draw it.

0280 named this exact fork ("Electron divergence is the tax on shell-as-code")
and its open question 6 scheduled the ShellFrame port for phase 5, which never
ran. Recommends extracting the renderer into packages/workbench behind a
narrow PlatformPort so routing and native capability are the only things that
differ per surface.

Signed-off-by: xNet Test <test@xnet.dev>
MenuLabel rendered Base UI's Menu.GroupLabel, which throws unless it finds a
<Menu.Group> ancestor. The simple Menu has no groups, so opening the desktop
system menu — the shell's only navigation affordance — threw during render.
With no error boundary above it, the whole React tree unmounted to a black
window with no way back but quitting.

Make MenuLabel a plain element (grouping semantics live on DropdownMenuGroup +
DropdownMenuLabel), and add ShellErrorBoundary so any future shell render
failure degrades to a recoverable panel instead of a dead window.

The regression test asserts at the primitive, since every simple-Menu consumer
shared the fault; verified it throws against the old implementation rather than
passing vacuously.

Also drops two pieces of dead weight this exposed: the orphaned Sidebar.tsx
(no importers) and the @tanstack/react-router dependency the Electron renderer
declares but never imports.

Signed-off-by: xNet Test <test@xnet.dev>
The workbench is on its way to packages/workbench, shared by web and desktop.
Web navigates by URL through TanStack Router; the Electron renderer has no
router at all and navigates by transitioning a ShellState reducer. Rather than
force one on the other, the shell states intent — "open this node", "go to
this surface" — and the host decides what that means.

Adds PlatformPort/NavTarget/PlatformCapabilities and the web implementation.
The node -> route table moves to the host, since routes are a property of the
web host rather than of the shell, and it is now Record<TabNodeType, ...> so a
missing entry is a compile error; previously it was a runtime console.warn and
the click just did nothing.

No consumers yet, and web behaviour is unchanged: migrating the 25 workbench
modules off the router is the next item, and a half-migrated port would be
worse than none.

Also adds the ShellErrorBoundary test, so "a thrown child shows the crash
panel, not a blank window" is a verified claim rather than an asserted one.

Signed-off-by: xNet Test <test@xnet.dev>
…the desktop web ui fork (8/33)

Signed-off-by: xNet Test <test@xnet.dev>
@crs48
crs48 temporarily deployed to pr-641 July 28, 2026 15:38 — with GitHub Actions Inactive
github-actions Bot added a commit that referenced this pull request Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🖼️ UI changes in this PR

No visual differences detected in the changed UI.

CI run

github-actions Bot added a commit that referenced this pull request Jul 28, 2026
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Preview removed for PR #641.

github-actions Bot added a commit that referenced this pull request Jul 28, 2026
@crs48
crs48 merged commit 636fcac into main Jul 28, 2026
22 checks passed
@crs48
crs48 deleted the claude/0406-unified-shell branch July 28, 2026 15:53
github-actions Bot added a commit that referenced this pull request Jul 28, 2026
crs48 added a commit that referenced this pull request Jul 28, 2026
…uter (0406) (#642)

Continues exploration **0406** (11/33 after this). Follows #641, which
defined the seam; this PR makes the shell actually use it.

## What this does

Migrates all 29 workbench modules and 20 app-land consumers off direct
`@tanstack/react-router` imports onto the `PlatformPort`. The shell now
states intent — "open this node", "go home", "this path" — and the web
host resolves it to a URL. **Web behaviour is unchanged**; the point is
that the same components can now mount on desktop over a
`ShellState`-backed port.

Key moves:

- `navigation.ts` collapses to intent emission; the node→route switch
lives in the host's `routeForTarget`, where a missing entry is a
**compile error** instead of a silent dead click.
- Preview-tab arming (0284) moves into the host's navigate, extracted as
`armPreviewIntent` so its semantics stay pinned by tests through the
move.
- `navigateToNewDoc` gains a port-shaped sibling, retiring the `as
unknown as NavigateLike` casts the compiler could not see into.
- The port grows search read/write (`/settings?section=`, `/ai?q=`) and
a richer `Link` (drag sources, testids) — requirements the call-site
survey found, not guesses.
- An ESLint `no-restricted-imports` override turns any future router
import in the workbench into a red build; **verified against a probe
file**, not assumed.
- `test-platform.tsx` replaces router module mocks: components stopped
importing the router, so tests stop mocking it and provide a port.

## Verification

Beyond 992 green web tests: drove the live app through every migrated
path — doc creation via the New menu, settings `?section` round trip
(write **and** read), working-set opens, profile-menu navigation,
history back/forward. Zero console errors.

One anomaly found (palette Enter on "Go to tasks" under synthetic input
doesn't navigate) was **stash-tested against the pre-migration baseline
and reproduces identically** — pre-existing, not a regression.

## Changelog

`skip-changelog`: internal refactor, no user-visible change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
crs48 added a commit that referenced this pull request Jul 28, 2026
…ch (0406) (#644)

Continues exploration **0406** (12/33 after this). Follows #641 (the
seam) and #642 (the migration onto it); this PR makes the core
physically shareable.

## Why the core first, not the whole directory

The workbench's chrome references **52 distinct app-local modules**
(views, comms, hooks, search). A whole-directory move would churn web's
highest-traffic area for days and conflate relocation with API design.
The host-agnostic core, by contrast, has exactly four dependencies —
`react`, `zustand`, `lucide-react`, `@xnetjs/plugins` — so it moves
cleanly and delivers the thing desktop actually needs first: **the state
layer**.

`@xnetjs/workbench` now contains: the `PlatformPort`, the workbench
store, tab/preview grammar, navigation intent, surfaces, command wiring,
focus, route titles, sidebar sections, explorer sort, new-doc identity,
and the test port. Private and source-consumed (`main → src/index.ts`,
the `canvas-core` pattern) — no build ordering, no vite aliases.

Old paths keep one-line shims (the 0280 `layout-tree.ts` pattern, this
repo's own precedent). Both roads resolve to one module instance;
consumers migrate opportunistically.

## What the move itself surfaced

- `state.ts` declared its own `ChromePosture`, a twin of the canonical
type in `@xnetjs/plugins` — 0277's copied-type drift in miniature,
invisible until a barrel saw both declarations. Fixed at the source
rather than papered over in the barrel.
- `newDocId`/`CreatableDocType` graduate into the package;
`lib/doc-creation` keeps routes + menu chrome and re-exports identity.

## Why this matters for the desktop

The parity test's founding condition — *"the moment desktop adopts them
it consumes the SAME module as web"* — now covers the entire state
layer, not just `layout-tree`. Phase 3 (mounting the shell in Electron)
can begin with desktop importing `useWorkbench` and the port directly.

## Verification

- Live app: boot, working-set open, and New → Page (URL change + editor
mount) all flow through the package modules, zero console errors.
- 992 web + 89 package + 149 electron tests green (parity test
included); repo-wide typecheck clean; lint 0 errors.
- The router ban now also covers `packages/workbench/src`.

`skip-changelog`: internal restructuring, no user-visible change.
Private package → no changeset.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
crs48 added a commit that referenced this pull request Jul 28, 2026
…649)

Continues exploration **0406** (14/33 after this). The desktop's half of
the seam #641 defined.

`useDesktopPlatformPort` resolves `NavTarget` intents to `ShellState`
transitions through the shell's own handlers (home restores the
viewport; documents go through type lookup and the canvas glide), and
synthesizes pathnames in **web's route grammar** so shared core modules
(`tabFromPathname`, route titles) behave identically on both hosts.
Unhandled targets return `false` and warn loudly — the silent dead click
is the failure mode this exploration exists to kill.

`PlatformProvider` now wraps the desktop shell tree: the moment shared
chrome mounts here, its navigation already works.

Verification: 6 new unit tests pin the pathname grammar and dispatch
table (155 electron total, all green); typecheck clean; verified live
over CDP that the provider mounts and the shell + Assistant behave
unchanged.

`skip-changelog`: internal plumbing, no user-visible change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
crs48 added a commit that referenced this pull request Jul 29, 2026
Every box in exploration 0406 is checked — 23/23 implementation, 10/10
validation — so the filename flips to `[x]`.

The arc, for the record: #641/#642/#644 (port + core extraction), #648
(AI panel on desktop), #650/#651 (chrome behind injection points, first
Electron mount), #652 (desktop surfaces in the shell), #653 (flag
removed, bespoke shell deleted), #655 (cold-open back under the 0.52s
baseline), #656 (approval-gated writes — the last blocked item). The
desktop and web apps now render one shell from one module, verified live
at every step.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.

1 participant