diff --git a/apps/electron/package.json b/apps/electron/package.json index 37ef0a735..ba6a07473 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -26,7 +26,6 @@ "test:watch": "vitest" }, "dependencies": { - "@tanstack/react-router": "^1.45.0", "@xnetjs/canvas": "workspace:*", "@xnetjs/charts": "workspace:*", "@xnetjs/core": "workspace:*", diff --git a/apps/electron/src/renderer/components/ShellErrorBoundary.test.tsx b/apps/electron/src/renderer/components/ShellErrorBoundary.test.tsx new file mode 100644 index 000000000..98eeb89d7 --- /dev/null +++ b/apps/electron/src/renderer/components/ShellErrorBoundary.test.tsx @@ -0,0 +1,65 @@ +/** + * @vitest-environment jsdom + */ + +/** + * A shell render failure must degrade to a recoverable panel (exploration 0406). + * + * A browser tab that white-screens still has a reload button; a packaged + * desktop window has none, so an unmounted tree is a dead app. This is the + * guard for the failure the `MenuLabel` crash actually produced. + */ + +import { render, screen } from '@testing-library/react' +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ShellErrorBoundary } from './ShellErrorBoundary' + +function Boom(): React.ReactElement { + throw new Error('MenuGroupRootContext is missing') +} + +describe('ShellErrorBoundary', () => { + beforeEach(() => { + // React logs the caught error; silence it so the run stays readable. + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders children when nothing throws', () => { + render( + +

shell

+
+ ) + expect(screen.getByText('shell')).toBeTruthy() + }) + + it('shows the crash panel instead of a blank window', () => { + render( + + + + ) + + expect(screen.getByText('Something broke in the shell')).toBeTruthy() + // The message is surfaced, not swallowed — a silent shell crash reads as + // "the app is fine". + expect(screen.getByText(/MenuGroupRootContext is missing/)).toBeTruthy() + expect(screen.getByRole('button', { name: 'Reload' })).toBeTruthy() + }) + + it('reports the failure rather than swallowing it', () => { + render( + + + + ) + + const logged = vi.mocked(console.error).mock.calls.flat() + expect(logged.some((arg) => String(arg).includes('[shell] render failure'))).toBe(true) + }) +}) diff --git a/apps/electron/src/renderer/components/ShellErrorBoundary.tsx b/apps/electron/src/renderer/components/ShellErrorBoundary.tsx new file mode 100644 index 000000000..3883b4191 --- /dev/null +++ b/apps/electron/src/renderer/components/ShellErrorBoundary.tsx @@ -0,0 +1,78 @@ +/** + * Catches render failures anywhere in the desktop shell (exploration 0406). + * + * A browser tab that white-screens still has a URL bar and a reload button. A + * packaged desktop window has neither: an unmounted React tree is a dead app + * the user can only fix by quitting. That is what happened when `MenuLabel` + * rendered Base UI's `GroupLabel` outside a group — opening the system menu, + * the shell's only navigation affordance, blanked the whole window. + * + * So the boundary degrades to a recoverable panel and reports loudly rather + * than swallowing: a shell crash that logs nothing reads as "the app is fine". + */ + +import React from 'react' + +interface Props { + children: React.ReactNode +} + +interface State { + error: Error | null +} + +export class ShellErrorBoundary extends React.Component { + state: State = { error: null } + + static getDerivedStateFromError(error: Error): State { + return { error } + } + + componentDidCatch(error: Error, info: React.ErrorInfo): void { + console.error('[shell] render failure', error, info.componentStack) + } + + private handleReload = (): void => { + window.location.reload() + } + + private handleDismiss = (): void => { + this.setState({ error: null }) + } + + render(): React.ReactNode { + const { error } = this.state + if (!error) return this.props.children + + return ( +
+
+

Something broke in the shell

+

+ The rest of the app kept running. Reloading restores the window; your data is + unaffected. +

+
+            {error.message}
+          
+
+ + +
+
+
+ ) + } +} diff --git a/apps/electron/src/renderer/components/Sidebar.tsx b/apps/electron/src/renderer/components/Sidebar.tsx deleted file mode 100644 index 5a188aad3..000000000 --- a/apps/electron/src/renderer/components/Sidebar.tsx +++ /dev/null @@ -1,339 +0,0 @@ -/** - * Sidebar Component - * - * Shows document list with icons for different types. - * Also renders plugin-contributed sidebar items. - */ - -import type { Document } from '../lib/types' -import type { SidebarContribution } from '@xnetjs/plugins' -import { CANVAS_INTERNAL_NODE_MIME, serializeCanvasInternalNodeDragData } from '@xnetjs/canvas' -import { DatabaseSchema, PageSchema } from '@xnetjs/data' -import * as icons from 'lucide-react' -import { - FileText, - Database, - Layout, - Plus, - Trash2, - ChevronDown, - ChevronRight, - Link, - Settings -} from 'lucide-react' -import React, { useState, useMemo, type ComponentType } from 'react' - -interface SidebarProps { - documents: Document[] - selectedId: string | null - onSelect: (id: string) => void - onDelete: (id: string) => void - onCreate: (type: Document['type']) => void - onAddShared: () => void - /** Plugin-contributed sidebar items */ - pluginItems?: SidebarContribution[] - /** Handler for settings navigation */ - onSettings?: () => void -} - -const typeIcons = { - page: FileText, - database: Database, - canvas: Layout -} as const - -const typeLabels: Record = { - page: 'Page', - database: 'Database', - canvas: 'Canvas' -} - -const schemaByType = { - page: PageSchema._schemaId, - database: DatabaseSchema._schemaId -} as const - -/** - * Render an icon from a string name or component - */ -function renderIcon(icon: string | ComponentType, size = 14, className = ''): React.ReactNode { - if (typeof icon !== 'string') { - const IconComp = icon - return - } - - // Convert kebab-case to PascalCase for Lucide lookup - const iconName = icon - .split('-') - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join('') - - // Look up in lucide-react (cast to any to avoid complex Lucide types) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const IconComponent = (icons as any)[iconName] as - | React.ComponentType<{ size?: number; className?: string }> - | undefined - if (IconComponent) { - return - } - - // Fallback to Circle - return -} - -export function Sidebar({ - documents, - selectedId, - onSelect, - onDelete, - onCreate, - onAddShared, - pluginItems = [], - onSettings -}: SidebarProps) { - const [showCreateMenu, setShowCreateMenu] = useState(false) - const [expandedSections, setExpandedSections] = useState>({ - page: true, - database: true, - canvas: true - }) - - // Sort plugin items by priority and group by position - const sortedPluginItems = useMemo(() => { - const sorted = [...pluginItems].sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100)) - return { - top: sorted.filter((i) => i.position === 'top' || !i.position), - bottom: sorted.filter((i) => i.position === 'bottom'), - sections: sorted.filter((i) => i.position === 'section') - } - }, [pluginItems]) - - // Group section items by section name - const sectionGroups = useMemo(() => { - const groups = new Map() - for (const item of sortedPluginItems.sections) { - const section = item.section ?? 'Other' - if (!groups.has(section)) groups.set(section, []) - groups.get(section)!.push(item) - } - return groups - }, [sortedPluginItems.sections]) - - // Group documents by type - const groupedDocs = documents.reduce( - (acc, doc) => { - const type = doc.type || 'page' - if (!acc[type]) acc[type] = [] - acc[type].push(doc) - return acc - }, - {} as Record - ) - - const toggleSection = (type: Document['type']) => { - setExpandedSections((prev) => ({ ...prev, [type]: !prev[type] })) - } - - const handleCreate = (type: Document['type']) => { - onCreate(type) - setShowCreateMenu(false) - } - - return ( - - ) -} - -/** - * Render a plugin sidebar item - */ -function PluginSidebarItem({ item }: { item: SidebarContribution }) { - const badge = item.badge?.() - - const handleClick = () => { - if (typeof item.action === 'function') { - item.action() - } else { - // Route navigation - would need router integration - console.log('[Sidebar] Navigate to:', item.action) - } - } - - return ( - - ) -} diff --git a/apps/electron/src/renderer/main.tsx b/apps/electron/src/renderer/main.tsx index 806f26f2e..351dadcbf 100644 --- a/apps/electron/src/renderer/main.tsx +++ b/apps/electron/src/renderer/main.tsx @@ -25,6 +25,7 @@ import { createRoot, type Root } from 'react-dom/client' import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate } from 'y-protocols/awareness' import * as Y from 'yjs' import { App } from './App' +import { ShellErrorBoundary } from './components/ShellErrorBoundary' import { configuredHubUrl } from './lib/hub-url' import { createIPCBlobStore } from './lib/ipc-blob-store' import { IPCNodeStorageAdapter } from './lib/ipc-node-storage' @@ -938,7 +939,11 @@ async function init() { > - + {/* Inside the providers so the fallback keeps app chrome and + theme; a shell crash must not take the window with it. */} + + + diff --git a/apps/web/src/platform/web-platform.test.ts b/apps/web/src/platform/web-platform.test.ts new file mode 100644 index 000000000..946f33dc5 --- /dev/null +++ b/apps/web/src/platform/web-platform.test.ts @@ -0,0 +1,47 @@ +/** + * The web host's NavTarget → route mapping (exploration 0406). + * + * This is the seam the desktop shell will implement differently, so the + * mapping is worth pinning: a node type with no route used to navigate + * nowhere, silently — the failure mode 0353 called out for the history chords. + */ + +import { describe, expect, it } from 'vitest' +import { TAB_NODE_TYPES } from '../workbench/state' +import { routeForTarget } from './web-platform' + +describe('routeForTarget', () => { + it('resolves every TabNodeType — none may navigate nowhere', () => { + for (const nodeType of TAB_NODE_TYPES) { + const route = routeForTarget({ kind: 'node', nodeType, nodeId: 'abc' }) + expect(route, `no route for node type ${nodeType}`).not.toBeNull() + expect(route?.to.startsWith('/')).toBe(true) + } + }) + + it('substitutes the id into parameterised routes', () => { + expect(routeForTarget({ kind: 'node', nodeType: 'page', nodeId: 'doc-1' })).toEqual({ + to: '/doc/$docId', + params: { docId: 'doc-1' } + }) + }) + + it('omits params for singleton surfaces', () => { + expect(routeForTarget({ kind: 'node', nodeType: 'tasks', nodeId: 'ignored' })).toEqual({ + to: '/tasks' + }) + }) + + it('maps home and raw paths', () => { + expect(routeForTarget({ kind: 'home' })).toEqual({ to: '/' }) + expect(routeForTarget({ kind: 'path', path: '/requests' })).toEqual({ to: '/requests' }) + }) + + it('resolves a surface by its stable id', () => { + expect(routeForTarget({ kind: 'surface', surfaceId: 'tasks' })?.to).toBe('/tasks') + }) + + it('returns null for an unknown surface rather than guessing a path', () => { + expect(routeForTarget({ kind: 'surface', surfaceId: 'nope' })).toBeNull() + }) +}) diff --git a/apps/web/src/platform/web-platform.tsx b/apps/web/src/platform/web-platform.tsx new file mode 100644 index 000000000..d3537a087 --- /dev/null +++ b/apps/web/src/platform/web-platform.tsx @@ -0,0 +1,139 @@ +/** + * The web host's {@link PlatformPort} (exploration 0406). + * + * This is the only place in the web app that turns a shell `NavTarget` into a + * URL. The mapping lived in `workbench/navigation.ts`, but routes are a + * property of *this host* — the desktop shell has none — so it belongs on the + * host side of the port, and moves here rather than into `packages/workbench`. + */ + +import type { TabNodeType } from '../workbench/state' +import { Link as RouterLink, useLocation, useNavigate } from '@tanstack/react-router' +import { useMemo } from 'react' +import { + type NavTarget, + type NavigateOptions, + type PlatformCapabilities, + type PlatformLinkProps, + type PlatformPort +} from '../workbench/platform' +import { SURFACES } from '../workbench/surfaces' +import { setPreviewIntent } from '../workbench/tabs' + +/** + * Node type → route path + param name. `null` marks the singleton surfaces + * that take no id. Exhaustive over {@link TabNodeType}: a missing entry used + * to navigate nowhere, silently. + */ +const NODE_ROUTES: Record = { + page: { to: '/doc/$docId', param: 'docId' }, + post: { to: '/post/$postId', param: 'postId' }, + database: { to: '/db/$dbId', param: 'dbId' }, + canvas: { to: '/canvas/$canvasId', param: 'canvasId' }, + dashboard: { to: '/dashboard/$dashboardId', param: 'dashboardId' }, + map: { to: '/map/$mapId', param: 'mapId' }, + savedview: { to: '/view/$viewId', param: 'viewId' }, + tag: { to: '/tag/$tagId', param: 'tagId' }, + channel: { to: '/channel/$channelId', param: 'channelId' }, + person: { to: '/person/$did', param: 'did' }, + lab: { to: '/lab/$labId', param: 'labId' }, + space: { to: '/space/$spaceId', param: 'spaceId' }, + frame: { to: '/frame/$frameSpec', param: 'frameSpec' }, + tasks: { to: '/tasks', param: null }, + meetings: { to: '/meetings', param: null }, + data: { to: '/data', param: null }, + experiments: { to: '/experiments', param: null }, + crm: { to: '/crm', param: null }, + finance: { to: '/finance', param: null }, + settings: { to: '/settings', param: null } +} + +/** Resolve a nav target to a router path + params. */ +export function routeForTarget( + target: NavTarget +): { to: string; params?: Record } | null { + switch (target.kind) { + case 'home': + return { to: '/' } + case 'path': + return { to: target.path } + case 'surface': { + const surface = SURFACES.find((s) => s.id === target.surfaceId) + return surface?.to ? { to: surface.to } : null + } + case 'node': { + const route = NODE_ROUTES[target.nodeType] + if (!route) return null + return route.param + ? { to: route.to, params: { [route.param]: target.nodeId } } + : { to: route.to } + } + } +} + +function hrefForTarget(target: NavTarget): string { + const route = routeForTarget(target) + if (!route) return '#' + if (!route.params) return route.to + return Object.entries(route.params).reduce( + (path, [key, value]) => path.replace(`$${key}`, encodeURIComponent(value)), + route.to + ) +} + +const WEB_CAPABILITIES: PlatformCapabilities = { + nativeMenus: false, + meetingsCapture: false, + agentBridge: false, + filesystem: false, + urlAddressable: true +} + +/** Hoisted so it is a hook in its own right, not one closed over inside a memo. */ +function usePathname(): string { + return useLocation({ select: (location) => location.pathname }) +} + +function WebLink({ target, children, className, title, onClick }: PlatformLinkProps) { + return ( + + {children} + + ) +} + +/** Build the web port. Memoised so consumers don't re-render on every tick. */ +export function useWebPlatformPort(): PlatformPort { + const navigate = useNavigate() + + return useMemo(() => { + const go = (target: NavTarget, options?: NavigateOptions): void => { + // Preview-tab intent (0284) is set before navigating, since the route + // effect in EditorArea reconciles the store afterwards. + if (target.kind === 'node' && target.preview !== false) setPreviewIntent() + + const route = routeForTarget(target) + if (!route) { + console.warn('[platform] no route for target', target) + return + } + void navigate({ + to: route.to as never, + ...(route.params ? { params: route.params as never } : {}), + ...(options?.replace ? { replace: true } : {}) + }) + } + + return { + navigate: go, + usePathname, + Link: WebLink, + capabilities: WEB_CAPABILITIES + } + }, [navigate]) +} diff --git a/apps/web/src/workbench/platform.ts b/apps/web/src/workbench/platform.ts new file mode 100644 index 000000000..d76e09c8b --- /dev/null +++ b/apps/web/src/workbench/platform.ts @@ -0,0 +1,103 @@ +/** + * PlatformPort — everything the shell needs from its host (exploration 0406). + * + * 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 pick one and force it on the other, the shell states its + * *intent* — "open this node", "go to this surface" — and the host decides + * what that means. + * + * Keep this interface narrow. Every method added is a place the two surfaces + * can drift, so the bar is "the shell genuinely cannot be written without it". + * Platform checks must never appear in component bodies — that is how a fork + * restarts inside shared code. + */ + +import type { TabNodeType } from './state' +import type { ComponentType, ReactNode } from 'react' +import { createContext, useContext } from 'react' + +/** Where the shell wants to send the user, independent of how the host gets there. */ +export type NavTarget = + /** A workspace node. `preview` carries the 0284 preview-tab intent. */ + | { kind: 'node'; nodeType: TabNodeType; nodeId: string; preview?: boolean } + /** A primary destination by its stable `SurfaceDef.id` (e.g. `tasks`). */ + | { kind: 'surface'; surfaceId: string } + /** The shell's home. */ + | { kind: 'home' } + /** + * Escape hatch for destinations that are still only expressible as a path + * (settings sections, request queues). Hosts without URLs map these onto + * their own surfaces; every use here is a candidate for promotion to a + * `surface`, so prefer the cases above. + */ + | { kind: 'path'; path: string } + +export interface NavigateOptions { + /** Replace the current history entry instead of pushing (web); no-op elsewhere. */ + replace?: boolean +} + +/** + * Host capabilities. Absent means the affordance is not rendered — the shell + * asks what the host *can do*, never what platform it is running on. + */ +export interface PlatformCapabilities { + /** Native application menus (desktop only). */ + nativeMenus: boolean + /** System-audio meeting capture (desktop only). */ + meetingsCapture: boolean + /** The in-process MCP agent bridge (desktop only, PR #638). */ + agentBridge: boolean + /** Real filesystem access beyond the browser sandbox. */ + filesystem: boolean + /** The host addresses destinations with URLs (web); false for the desktop shell. */ + urlAddressable: boolean +} + +/** Props the shell passes to the host's link component. */ +export interface PlatformLinkProps { + target: NavTarget + children: ReactNode + className?: string + title?: string + onClick?: () => void +} + +export interface PlatformPort { + navigate(target: NavTarget, options?: NavigateOptions): void + /** + * The current location as a path. Web returns the real pathname; hosts + * without URLs synthesise a stable equivalent so the shell can highlight + * active nav rows with one code path. + */ + usePathname(): string + /** Anchor-or-equivalent for a nav target. Web renders a real ``. */ + Link: ComponentType + capabilities: Readonly +} + +const PlatformContext = createContext(null) + +export const PlatformProvider = PlatformContext.Provider + +/** + * Read the host port. + * + * Throws rather than falling back to a no-op: a shell that silently stops + * navigating looks like a dead click, which is far harder to diagnose than a + * missing provider at boot. + */ +export function usePlatform(): PlatformPort { + const port = useContext(PlatformContext) + if (!port) { + throw new Error('usePlatform: no PlatformProvider above this component.') + } + return port +} + +/** Convenience for the common case — the shell navigates far more than it reads. */ +export function useNavigateTo(): (target: NavTarget, options?: NavigateOptions) => void { + return usePlatform().navigate +} diff --git a/docs/explorations/0406_[-]_ONE_SHELL_TWO_SURFACES_ENDING_THE_DESKTOP_WEB_UI_FORK.md b/docs/explorations/0406_[-]_ONE_SHELL_TWO_SURFACES_ENDING_THE_DESKTOP_WEB_UI_FORK.md new file mode 100644 index 000000000..f02c0d162 --- /dev/null +++ b/docs/explorations/0406_[-]_ONE_SHELL_TWO_SURFACES_ENDING_THE_DESKTOP_WEB_UI_FORK.md @@ -0,0 +1,606 @@ +--- +title: One Shell, Two Surfaces — Ending the Desktop/Web UI Fork +status: draft +last_updated: 2026-07-28 +tags: [electron, web, ui, architecture, workbench] +--- + +# One Shell, Two Surfaces — Ending the Desktop/Web UI Fork + +> [!TIP] +> **TL;DR** — The desktop app is not "behind" the web app; it is a **different +> shell**. Web's entire workbench (6,698 LOC — islands, explorer, panels, +> command palette, AI chat) lives in `apps/web/src/workbench/`, where Electron +> structurally cannot import it. The state model underneath is _already_ +> shared via `@xnetjs/plugins`. Extract the renderer into **`packages/workbench`** +> and mount it from both apps behind a small **platform port**. This is not a +> new idea — exploration 0280 named it, scheduled it for phase 5, and it never +> happened. + +## Problem Statement + +The desktop app presents a canvas and a `⋯` button. The web app presents a +full workbench. A user moving between them does not experience "two clients of +one product" — they experience two products. + +The instinct is that desktop _missed some updates_. It did not. **Desktop was +never on the same code path.** Every shell improvement shipped since 0280 — +floating islands (0286), the tabless left nav (0353), the consolidated New +button (0387), the AI chat panel — landed in `apps/web/src/workbench/` and was +structurally unavailable to Electron. + +> [!IMPORTANT] +> The divergence is **architectural, not cosmetic**. Copying components across +> would reproduce the fork one release later. The question is not "how do we +> update desktop" but "why can desktop not import the shell at all". + +## Executive Summary + +| Layer | Shared today? | Where it lives | +| --------------------------------------------------------- | --------------- | ---------------------------------------------- | +| Design primitives | ✅ Shared | `packages/ui` | +| Data views (table, kanban, calendar) | ✅ Shared | `packages/views` | +| Layout state model (`LayoutTree`, presets, payload codec) | ✅ Shared | `packages/plugins/src/workspace/` | +| Canvas engine | ✅ Shared | `packages/canvas` | +| **Shell renderer** (islands, explorer, panels, palette) | ❌ **Forked** | `apps/web/src/workbench/` | +| **AI chat panel** | ❌ **Web-only** | `apps/web/src/workbench/views/AiChatPanel.tsx` | + +The foundation layer the user is asking for **mostly exists**. One layer — the +shell renderer — sits in an app instead of a package, and that single placement +decision produces the entire visible divergence. + +--- + +## Current State In The Repository + +### The two shells + +```mermaid +graph TB + subgraph shared["Shared packages ✅"] + UI["@xnetjs/ui
primitives"] + VIEWS["@xnetjs/views
data views"] + PLUGINS["@xnetjs/plugins
LayoutTree · presets · codec"] + CANVAS["@xnetjs/canvas"] + end + + subgraph web["apps/web ❌ shell trapped here"] + WB["workbench/ — 6,698 LOC"] + SF["ShellFrame · FloatingFrame"] + SI["SidebarIslands · Explorer"] + AI["AiChatPanel"] + WB --- SF --- SI --- AI + end + + subgraph desk["apps/electron 🔁 reimplemented"] + APP["App.tsx + shell/"] + SS["shell-state.ts
ShellKind union"] + AD["ActionDock · SystemMenu"] + APP --- SS --- AD + end + + shared --> web + shared --> desk + web -. "cannot import" .-x desk + + style web fill:#4a2020,color:#fff + style desk fill:#4a3a20,color:#fff + style shared fill:#204a2a,color:#fff +``` + +### What each side actually has + +| Capability | Web | Desktop | Notes | +| ------------------------------------- | --- | ---------- | --------------------------------------------------------------- | +| Floating islands frame (0286) | ✅ | ❌ | `ShellFrame.tsx` | +| Left nav / Explorer tree (0353) | ✅ | ❌ | `views/Explorer.tsx` | +| Command palette | ✅ | 🚧 Partial | desktop has `use-shell-palette-commands.ts` (549 LOC, own impl) | +| Consolidated New button (0387) | ✅ | ❌ | `QuickCreateHost.tsx` | +| Tasks / Today panels | ✅ | ❌ | `views/TasksPanel.tsx`, `TodayPanel.tsx` | +| **AI chat panel** | ✅ | ❌ | see below — this one stings | +| Canvas home | 🚧 | ✅ | desktop's genuine differentiator | +| Meetings, social import, native menus | ❌ | ✅ | legitimately desktop-only | + +> [!WARNING] +> **The agent bridge has no face on desktop.** PR #638 shipped the in-process +> MCP server so Claude Code can read and write the workspace — `window.xnetAgentBridge` +> reports `workspaceTools: true` in the running desktop app. But **nothing in +> `apps/electron/src/renderer/` references it.** The only UI that drives that +> bridge is `apps/web/src/workbench/views/AiChatPanel.tsx`. We shipped the +> engine to the surface that has no steering wheel. + +### Three symptoms observed this week + +
+The evidence trail (all verified in the running app) + +1. **`SystemMenu` crashed the entire app.** `MenuLabel` wrapped Base UI's + `GroupLabel`, which throws without a `` ancestor. With no error + boundary the React tree unmounted to a black screen the instant the menu + opened. Desktop's _only_ navigation affordance was a crash. Web never hit + this because web does not use `MenuLabel`. +2. **`apps/electron/src/renderer/components/Sidebar.tsx` is orphaned** — not + referenced by `App.tsx` or anything in `shell/`. A previous attempt at nav, + stranded. +3. **Electron declares `@tanstack/react-router@^1.45.0` and never imports it** + — while web is on `^1.57.0` and uses it in 18 workbench modules. + +
+ +### The state model is already shared + +This is the load-bearing discovery. `apps/web/src/workbench/layout-tree.ts` is +**not an implementation** — it is a shim: + +```ts +/** + * LayoutTree (0280) — canonical module lives in @xnetjs/plugins + * (`workspace/layout-tree`), shared with the seed and the desktop shell. + * This shim keeps the workbench's local import paths stable. + */ +export { createPresetTree, parseWorkspacePayload, PRESET_IDS /* … */ } from '@xnetjs/plugins' +``` + +And `apps/electron/src/renderer/shell/workspace-parity.test.ts` actively +guards that desktop consumes the same module and never forks its own: + +```ts +it('no desktop source forks its own layout-tree or preset definitions', () => { + // …fails if any desktop file declares `interface LayoutTree` or `createPresetTree` +}) +``` + +> [!NOTE] +> **The hard part is done.** Both surfaces already agree on what a workspace +> _is_. They disagree only on how to draw it. That is a far smaller problem +> than it looks from the outside. + +### This was predicted, and scheduled + +Exploration **0280** listed among "the rigidities the redesign must dissolve": + +> **Electron is a fourth shell** (`apps/electron/src/renderer/App.tsx`, +> `shell/shell-state.ts` — document-centric `ShellKind`, no calm grammar, no +> palette) — **every shell improvement currently forks.** + +and, in its Key Findings: + +> **Electron divergence is the tax on shell-as-code.** + +Its open question #6 chose the deferral explicitly: + +> **Electron sequencing.** Porting `ShellFrame` to Electron is real work (its +> command wiring is ref-based, no palette). Do we gate phase 3 on it, or accept +> temporary divergence with a parity test? **Recommendation: accept divergence +> through phase 3, port in phase 5** alongside the agent work. + +Phase 5 never ran. The parity test is the IOU, and this exploration is the +payment. + +--- + +## External Research + +**Slack — [Interop's Labyrinth: Sharing Code Between Web & Electron Apps](https://slack.engineering/interops-labyrinth-sharing-code-between-web-electron-apps/)** +is the closest prior art: Slack ran a shared web/Electron client and found the +sustainable seam is a **shared renderer with a thin platform interop layer**, +not per-platform UI trees. Their hard-won rule: platform differences belong +behind a _narrow, explicit interface_ — the moment platform checks leak into +component bodies, the fork restarts inside the shared code. + +**VS Code** is the strongest existence proof at scale: one renderer runs in +Electron and in the browser (vscode.dev), with platform capability behind +service interfaces resolved per-host. Desktop-only capability is a _service +implementation_, not a separate component tree. + +**Obsidian** ships desktop (Electron) and mobile (Capacitor) from largely one +UI codebase — again, wrapper differs, UI does not. + +> [!NOTE] +> Nobody credible maintains two hand-written shells for the same product. The +> universal pattern is **one renderer + a capability port**. Our `packages/ui` +> and `packages/plugins` split already follows it; the workbench simply never +> made the trip. + +--- + +## Key Findings + +1. **Desktop is not behind — it is separate.** No amount of feature-porting + closes a gap created by module placement. +2. **~80% of the foundation is already shared.** Primitives, data views, canvas, + and the layout state model are packages. Only the shell renderer is stranded. +3. **The fork has a measurable tax**: an app-killing crash in the only nav + affordance, orphaned dead code, an unused router dependency, and a shipped + agent bridge with no UI to drive it. +4. **`@tanstack/react-router` is the one real coupling** — 18 workbench modules + import it; the Electron renderer has no router at all. This is the main + porting cost and the crux of the design. +5. **A parity test already exists** and encodes the deferral's terms. Extending + it is how we prevent re-divergence. +6. **Desktop has genuine differentiators worth preserving** — canvas home, + meetings capture, social import, native menus. The goal is _one shell with + platform capabilities_, not "make desktop into web". + +--- + +## Options And Tradeoffs + +### Option A — Port features into the desktop shell one by one + +Copy Explorer, islands, panels into `apps/electron/src/renderer/components/`. + +| | | +| --- | ---------------------------------------------------------------------------------------------- | +| ✅ | No refactor; incremental; ships something visible immediately | +| ❌ | **Reproduces the fork.** Two copies drift from the first divergent fix | +| ❌ | Doubles the cost of every future shell change — the exact tax 0280 named | +| 🛑 | Directly contradicts the user's stated goal ("don't need to do anything to keep them in sync") | + +### Option B — Extract the workbench into `packages/workbench` ⭐ + +Move `apps/web/src/workbench/` into a package. Both apps mount ``. +Platform differences resolve through an injected port. + +| | | +| --- | ------------------------------------------------------------------ | +| ✅ | **One shell. Sync is structural, not a process** — exactly the ask | +| ✅ | Layout model already shared, so the risky half is done | +| ✅ | Desktop inherits every past _and future_ shell improvement free | +| ✅ | Follows the repo's own 0276/0277 core-extraction playbook | +| ⚠️ | Requires a navigation port to decouple `@tanstack/react-router` | +| ⚠️ | Large, reviewable-but-wide diff through web's highest-churn area | + +### Option C — Desktop renders the web app in a `BrowserView` + +Point Electron at the built web bundle. + +| | | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ✅ | Instant parity, near-zero UI work | +| ❌ | **Kills the reason desktop exists**: preload exposes 10 `contextBridge` namespaces the browser cannot have — `better-sqlite3`, native menus, meetings audio capture, the agent bridge | +| 🛑 | The renderer calls those at ~76 sites; a browser-shaped renderer cannot | + +### Option D — Design-system-only convergence + +Push more into `packages/ui`, leave both shells hand-written. + +| | | +| --- | ------------------------------------------------------------------------------------- | +| ✅ | Low risk; genuinely useful regardless | +| ❌ | Primitives are _already_ shared — this is the status quo that produced the divergence | +| ❌ | Layout, navigation, and panels are where the gap lives, and they stay forked | + +### Comparison + +| Option | Ends the fork | Preserves native capability | Effort | Verdict | +| ----------------------- | ------------- | --------------------------- | ------------------ | -------------------------- | +| A — port features | ❌ | ✅ | Medium (recurring) | 🛑 Rejected | +| **B — extract package** | ✅ | ✅ | High (one-time) | ⭐ **Recommended** | +| C — BrowserView | ✅ | ❌ | Low | 🛑 Rejected | +| D — design system only | ❌ | ✅ | Low | 🚧 Necessary, insufficient | + +--- + +## Recommendation + +> [!IMPORTANT] +> **Extract `apps/web/src/workbench/` into `packages/workbench`, and have both +> apps mount the same `` behind a `PlatformPort`.** Desktop keeps +> canvas home and its native capabilities as _port implementations and +> registered surfaces_, not as a second shell. + +### Target architecture + +```mermaid +graph TB + subgraph pkg["packages/workbench 🆕"] + W["Workbench · ShellFrame · Islands
Explorer · Panels · Palette"] + PORT["PlatformPort (interface)"] + W --> PORT + end + + subgraph webapp["apps/web"] + WP["WebPlatformPort
TanStack Router · no native"] + end + + subgraph deskapp["apps/electron"] + DP["DesktopPlatformPort
in-memory nav · native menus
agent bridge · meetings"] + end + + PORT -.implemented by.-> WP + PORT -.implemented by.-> DP + UI["@xnetjs/ui"] --> W + PL["@xnetjs/plugins
LayoutTree"] --> W + + style pkg fill:#204a2a,color:#fff +``` + +### The seam that matters + +The only structural blocker is routing. Rather than force Electron onto +TanStack Router (or strip it from web), invert it: + +```mermaid +sequenceDiagram + participant C as Workbench component + participant P as PlatformPort + participant W as Web impl + participant D as Desktop impl + + C->>P: navigate({ kind: 'node', id }) + alt running in web + P->>W: router.navigate({ to: '/node/$id' }) + W-->>C: URL updates, history entry + else running in desktop + P->>D: shellDispatch({ type: 'focus-document', id }) + D-->>C: ShellState transition, no URL + end +``` + +Desktop keeps its `ShellState` reducer as a _navigation implementation_. Web +keeps URLs. Neither leaks into component bodies — Slack's rule. + +### Phasing + +| Phase | Scope | Ships | +| ----- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| 0 | Add error boundary; fix `MenuLabel` | Desktop stops dying on menu open | +| 1 | Define `PlatformPort`; replace direct router imports in workbench | No user-visible change; web still green | +| 2 | Move `workbench/` → `packages/workbench`; web imports it | No user-visible change; proves the extraction | +| 3 | Mount `` in Electron with `DesktopPlatformPort` | **Desktop gains islands, explorer, panels, palette** | +| 4 | Register desktop-only surfaces (canvas home, meetings, social import) as workbench views | Desktop differentiators return, inside one shell | +| 5 | Mount `AiChatPanel` on desktop | **The agent bridge finally gets a UI** | +| 6 | Extend parity test to fail on any desktop-local shell component | Re-divergence becomes a red build | + +> [!TIP] +> Phase 0 is worth shipping **today**, independently. A missing error boundary +> that lets one bad label blank the whole app is a defect regardless of what we +> decide about the shell. + +--- + +## Example Code + +
+The PlatformPort interface and both implementations + +```ts +// packages/workbench/src/platform.ts + +/** + * Everything the shell needs from its host. Kept deliberately narrow: each + * added method is a place the two surfaces can drift, so the bar for adding + * one is "the shell genuinely cannot be written without it". + */ +export interface PlatformPort { + /** Where the shell sends the user. Web pushes URLs; desktop transitions state. */ + navigate(target: NavTarget): void + /** Current target, for highlighting nav and restoring on boot. */ + useCurrentTarget(): NavTarget | null + /** Capabilities the host can back. Absent = the affordance is not rendered. */ + capabilities: Readonly +} + +export type NavTarget = + | { kind: 'node'; id: string } + | { kind: 'surface'; id: string } + | { kind: 'home' } + +export interface PlatformCapabilities { + nativeMenus: boolean + meetingsCapture: boolean + /** The in-process MCP bridge (#638) — desktop only. */ + agentBridge: boolean + filesystem: boolean +} +``` + +```tsx +// apps/web/src/workbench-host.tsx +import { useNavigate, useMatches } from '@tanstack/react-router' +import { Workbench, type PlatformPort } from '@xnetjs/workbench' + +export function WebWorkbench() { + const navigate = useNavigate() + const matches = useMatches() + + const port: PlatformPort = { + navigate: (target) => + target.kind === 'node' + ? navigate({ to: '/node/$id', params: { id: target.id } }) + : navigate({ to: '/' }), + useCurrentTarget: () => targetFromMatches(matches), + capabilities: { + nativeMenus: false, + meetingsCapture: false, + agentBridge: false, + filesystem: false + } + } + + return +} +``` + +```tsx +// apps/electron/src/renderer/workbench-host.tsx +import { Workbench, type PlatformPort } from '@xnetjs/workbench' +import { useDocumentShell } from './shell/use-document-shell' + +export function DesktopWorkbench() { + // The existing ShellState reducer survives — as a navigation implementation, + // not as a second shell. + const { shellState, focusDocument, handleReturnHome } = useDocumentShell() + + const port: PlatformPort = { + navigate: (target) => (target.kind === 'node' ? focusDocument(target.id) : handleReturnHome()), + useCurrentTarget: () => targetFromShellState(shellState), + capabilities: { + nativeMenus: true, + meetingsCapture: true, + // Live since #638: window.xnetAgentBridge reports workspaceTools: true. + agentBridge: Boolean(window.xnetAgentBridge), + filesystem: true + } + } + + return +} +``` + +
+ +
+Phase 0 — the error boundary desktop is missing + +```tsx +// apps/electron/src/renderer/components/ShellErrorBoundary.tsx + +/** + * A render failure anywhere in the shell must degrade to a recoverable panel, + * never a blank window. Desktop has no browser chrome to reload from, so an + * unmounted tree is a dead app — as the MenuLabel/GroupLabel crash proved. + */ +export class ShellErrorBoundary extends React.Component { + state: State = { error: null } + + static getDerivedStateFromError(error: Error): State { + return { error } + } + + componentDidCatch(error: Error, info: React.ErrorInfo): void { + // Loud, not silent: a swallowed shell crash reads as "app is fine". + console.error('[shell] render failure', error, info.componentStack) + } + + render(): React.ReactNode { + if (!this.state.error) return this.props.children + return location.reload()} /> + } +} +``` + +
+ +--- + +## Risks And Open Questions + +> [!CAUTION] +> **`apps/web/src/workbench/` is among the repo's highest-churn areas.** A wide +> move will collide with in-flight branches. Sequence the phase 2 move as its +> own PR, merged fast, rather than bundled with behaviour changes. + +| Risk | Severity | Mitigation | +| ------------------------------------------------------- | -------- | --------------------------------------------------------------------------------- | +| Router extraction leaks platform checks into components | High | Lint rule: no `@tanstack/*` import inside `packages/workbench` | +| Move conflicts with in-flight web work | High | Phase 2 is a pure `git mv` + import rewrite, no behaviour change, merged same day | +| Desktop bundle grows with web-only deps | Medium | `zustand` + `react-resizable-panels` are small; measure in phase 3 | +| Canvas home does not fit the layout tree | Medium | Register as a surface (phase 4); keep `ShellState` until it does | +| Preload globals accessed from shared code | High | Only via `PlatformPort.capabilities`; never `window.xnet*` inside the package | +| Desktop regressions invisible to CI | Medium | Extend `workspace-parity.test.ts` + `electron-e2e` | + +**Open questions:** + +1. **Does desktop keep canvas home as the default surface?** It is desktop's + real differentiator and users may expect it. Recommendation: keep it as the + default _preset_, not as a different shell. +2. **Package name** — `@xnetjs/workbench` vs folding into `@xnetjs/ui`. + Recommendation: separate package; `ui` is primitives, and mixing shell + composition into it muddies a clean boundary. +3. **Does `packages/workbench` become publishable?** If yes it needs changesets + and a public API surface; if private, faster iteration. Recommendation: + private initially (`"private": true`), revisit once the API settles. +4. **Mobile/Expo** — does `MobileShell` become a third port implementation, or + stay separate? Out of scope here, but the port design should not preclude it. + +--- + +## Implementation Checklist + +**Status:** `░░░░░░░░░░ 0/22 items` + +### Phase 0 — stop the bleeding (independently shippable) + +- [x] Fix `MenuLabel` to stop rendering `BaseMenu.GroupLabel` outside a group +- [x] Add `ShellErrorBoundary` around the desktop shell tree +- [x] Add a regression test that opening `SystemMenu` does not throw +- [x] Delete the orphaned `apps/electron/src/renderer/components/Sidebar.tsx` +- [x] Remove the unused `@tanstack/react-router` dep from `apps/electron` (or adopt it in phase 1) + +### Phase 1 — define the seam + +- [x] Add `PlatformPort` / `NavTarget` / `PlatformCapabilities` types +- [ ] Replace direct `@tanstack/react-router` imports in `workbench/` with port calls +- [x] Add `WebPlatformPort` in `apps/web`; web behaviour unchanged +- [ ] Add an ESLint rule banning `@tanstack/*` imports from workbench sources + +### Phase 2 — extract the package + +- [ ] Create `packages/workbench` (private, React peer dep) +- [ ] `git mv apps/web/src/workbench/*` into it; rewrite imports +- [ ] Relocate app-local deps the shell pulls in (`useSpaces`, `doc-creation`, `SelfAvatar`, `CoachmarkLayer`) +- [ ] Web imports `@xnetjs/workbench`; full web e2e green with no visual diff + +### Phase 3 — desktop mounts the shell + +- [ ] Add `DesktopPlatformPort` backed by the existing `ShellState` reducer +- [ ] Render `` in `apps/electron` behind an `XNET_UNIFIED_SHELL` flag +- [ ] Verify islands, explorer, panels, and palette render over real SQLite data +- [ ] Measure desktop bundle delta and cold-open time against baseline + +### Phase 4 — desktop capabilities as surfaces + +- [ ] Register canvas home, meetings, and social import as workbench views +- [ ] Gate native-only affordances on `capabilities`, not `process.platform` +- [ ] Remove the flag; delete the superseded bespoke desktop shell components + +### Phase 5 — the agent gets a face + +- [ ] Mount `AiChatPanel` on desktop, wired to `window.xnetAgentBridge` (#638) +- [ ] Confirm a chat turn creates a node in the desktop store from the panel + +### Phase 6 — make re-divergence a red build + +- [ ] Extend `workspace-parity.test.ts` to fail on desktop-local shell components + +## Validation Checklist + +- [ ] Desktop and web render the same island frame, explorer, and panels from one module +- [x] `SystemMenu` opens with zero console errors; a thrown child shows the crash panel, not a blank window +- [ ] A shell change made once appears on both surfaces with no second edit — **the user's actual acceptance criterion** +- [ ] Desktop-only capabilities (native menus, meetings, agent bridge) still work +- [ ] Web-only paths are unaffected: no URL/deep-link/back-button regressions +- [ ] `pnpm --filter @xnetjs/workbench test` and both app suites green +- [ ] `electron-e2e` passes against the unified shell +- [ ] Desktop cold-open time within 10% of the pre-unification baseline +- [ ] `grep -rn "@tanstack" packages/workbench/src` returns nothing +- [ ] A chat turn from the desktop AI panel writes a node into the desktop store + +--- + +## References + +**In-repo** + +- `apps/web/src/workbench/` — the shell to extract (6,698 LOC) +- `apps/web/src/workbench/layout-tree.ts` — the shim proving the model is shared +- `apps/electron/src/renderer/shell/workspace-parity.test.ts` — the deferral's terms +- `apps/electron/src/renderer/App.tsx`, `shell/shell-state.ts` — the fourth shell +- `packages/plugins/src/workspace/layout-tree.ts` — canonical layout model +- `apps/electron/AGENTS.md` — preload namespaces, prototyping ladder + +**Explorations** + +- `0280_[x]_MALLEABLE_WORKBENCH_COMPOSABLE_WORKSPACE.md` — named this fork; open question 6 deferred the port to phase 5 +- `0284_[x]_COHERENT_SINGLE_SHELL_REDESIGN.md` — focus mode, one shell +- `0286_[x]_WORKBENCH_FLOATING_ISLANDS_REDESIGN.md` — the frame desktop lacks +- `0353_[x]_TABLESS_REMOVING_THE_TAB_STRIP_AND_UNIFYING_THE_LEFT_NAV.md` — the nav desktop lacks +- `0387_[x]_CONSOLIDATED_NEW_BUTTON.md` — shell-level create hub +- `0250_[_]_THE_EVERYPERSON_SHELL_A_CLAUDE_DESKTOP_UI_FOR_XNET.md` — calm-by-default + +**External** + +- Slack Engineering — [Interop's Labyrinth: Sharing Code Between Web & Electron Apps](https://slack.engineering/interops-labyrinth-sharing-code-between-web-electron-apps/) +- Ink & Switch — [Malleable Software](https://www.inkandswitch.com/essay/malleable-software/) (0280's frame) diff --git a/packages/ui/src/primitives/Menu.test.tsx b/packages/ui/src/primitives/Menu.test.tsx new file mode 100644 index 000000000..73384b39a --- /dev/null +++ b/packages/ui/src/primitives/Menu.test.tsx @@ -0,0 +1,56 @@ +/** + * Regression: `MenuLabel` must not require a `` ancestor. + * + * It used to render Base UI's `Menu.GroupLabel`, which throws + * "MenuGroupRootContext is missing" unless a `Menu.Group` is above it. The + * simple `Menu` has no groups, so every consumer crashed the instant the menu + * opened — and with no error boundary above it in the desktop shell, the whole + * React tree unmounted to a blank window (exploration 0406). + * + * The desktop system menu is the app's only navigation affordance, so this is + * asserted at the primitive: any simple `Menu` containing a `MenuLabel` must + * open without throwing. + */ + +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { Menu, MenuItem, MenuLabel, MenuSeparator } from './Menu' + +function SystemMenuShape() { + return ( + Open}> + Workspace + {}}>Settings + + Theme + {}}>Dark + + ) +} + +describe('MenuLabel inside the simple Menu', () => { + it('opens without throwing', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Open' })) + + // Reaching an item at all means the popup mounted rather than throwing + // during render. + expect(await screen.findByText('Settings')).toBeTruthy() + expect(screen.getByText('Workspace')).toBeTruthy() + expect(screen.getByText('Theme')).toBeTruthy() + }) + + it('renders labels outside the menu item roles', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Open' })) + await screen.findByText('Settings') + + // A label is decoration, not a target: it must not be announced or + // arrow-key reachable as a menu item. + const itemNames = screen.getAllByRole('menuitem').map((el) => el.textContent) + expect(itemNames).not.toContain('Workspace') + expect(itemNames).not.toContain('Theme') + }) +}) diff --git a/packages/ui/src/primitives/Menu.tsx b/packages/ui/src/primitives/Menu.tsx index 90c26df4a..a945e4592 100644 --- a/packages/ui/src/primitives/Menu.tsx +++ b/packages/ui/src/primitives/Menu.tsx @@ -114,12 +114,18 @@ export function MenuSeparator() { /** * A label for use with the simple Menu component. + * + * Deliberately a plain element rather than `BaseMenu.GroupLabel`: that part + * throws unless it finds a `` ancestor, and the simple `Menu` above + * has no notion of groups — so using it here crashed every consumer the moment + * the menu opened. Reach for `DropdownMenuGroup` + `DropdownMenuLabel` when you + * want real grouping semantics. */ export function MenuLabel({ children }: { children: React.ReactNode }) { return ( - +
{children} - +
) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 570065583..41bdf6773 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,9 +240,6 @@ importers: apps/electron: dependencies: - '@tanstack/react-router': - specifier: ^1.45.0 - version: 1.153.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@xnetjs/canvas': specifier: workspace:* version: link:../../packages/canvas diff --git a/site/src/data/changelog/2026-07-28-the-desktop-system-menu-no-longer-blanks.json b/site/src/data/changelog/2026-07-28-the-desktop-system-menu-no-longer-blanks.json new file mode 100644 index 000000000..e785109f3 --- /dev/null +++ b/site/src/data/changelog/2026-07-28-the-desktop-system-menu-no-longer-blanks.json @@ -0,0 +1,8 @@ +{ + "id": "2026-07-28-the-desktop-system-menu-no-longer-blanks", + "date": "July 28, 2026", + "title": "The desktop system menu no longer blanks the window", + "summary": "Opening the menu in the desktop app crashed it to a black screen with no way back. It works again, and a shell error now shows a recoverable panel instead of taking the whole window down.", + "highlights": [], + "tags": ["app"] +}