From 0bbf110a15cf03e678d67e1791f1019ed78d407f Mon Sep 17 00:00:00 2001 From: xNet Test Date: Tue, 28 Jul 2026 13:58:18 -0700 Subject: [PATCH] feat(electron): the desktop host implements the PlatformPort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds useDesktopPlatformPort (0406 phase 3): NavTarget intents resolve to ShellState transitions through the shell's own handlers — home restores the viewport, documents go through type lookup and the canvas glide — and the pathname is synthesized in web's route grammar so shared core modules (tabFromPathname, route titles) behave identically on both hosts. Unhandled targets return false and the port warns loudly — the silent dead click is the failure mode 0406 exists to kill. Six unit tests pin the pathname grammar and dispatch table; verified live that the provider mounts and the shell + Assistant behave unchanged. This is the desktop's half of the seam #641 defined: the moment shared chrome mounts here, its navigation already works. Signed-off-by: xNet Test --- apps/electron/src/renderer/App.tsx | 212 ++++++++++-------- .../renderer/shell/desktop-platform.test.ts | 86 +++++++ .../src/renderer/shell/desktop-platform.ts | 191 ++++++++++++++++ ...SURFACES_ENDING_THE_DESKTOP_WEB_UI_FORK.md | 2 +- 4 files changed, 394 insertions(+), 97 deletions(-) create mode 100644 apps/electron/src/renderer/shell/desktop-platform.test.ts create mode 100644 apps/electron/src/renderer/shell/desktop-platform.ts diff --git a/apps/electron/src/renderer/App.tsx b/apps/electron/src/renderer/App.tsx index fc306d506..53bc79445 100644 --- a/apps/electron/src/renderer/App.tsx +++ b/apps/electron/src/renderer/App.tsx @@ -10,6 +10,7 @@ import type { ConnectHubRequest } from './components/ConnectHubDialog' import { useCommandPalette, CommandPalette } from '@xnetjs/ui' +import { PlatformProvider } from '@xnetjs/workbench' import { AiChatPanel } from '@xnetjs/workbench/ai' import React, { useCallback, useEffect, useState } from 'react' import { ActionDock } from './components/ActionDock' @@ -26,6 +27,7 @@ import { SocialImportView } from './components/SocialImportView' import { StorybookView } from './components/StorybookView' import { SystemMenu } from './components/SystemMenu' import { setPersistedHubUrl } from './lib/hub-url' +import { useDesktopPlatformPort } from './shell/desktop-platform' import { STORIES_ENABLED, useDocumentShell } from './shell/use-document-shell' import { useShellPaletteCommands } from './shell/use-shell-palette-commands' @@ -92,6 +94,18 @@ export function App(): React.ReactElement { await window.__xnetIpcSyncManager?.configureShareSession({ signalingUrl: request.hub }) }, []) + // The desktop PlatformPort (0406 phase 3): shared workbench modules navigate + // by intent; this host resolves intents to ShellState transitions. + const platform = useDesktopPlatformPort({ + shellState, + returnHome: handleReturnHome, + openDocument: handleOpenDocument, + openAssistant: handleOpenAssistant, + openSettings: handleOpenSettings, + openMeetings: handleOpenMeetings, + openDataWorkspace: handleOpenDataWorkspace + }) + const paletteCommands = useShellPaletteCommands({ canvasViewRef, canvasCommandState, @@ -274,112 +288,118 @@ export function App(): React.ReactElement { } return ( -
-
-
-
- { - setPrefilledShareValue('') - setShowAddSharedDialog(true) - }} - onToggleDebugPanel={() => { - window.dispatchEvent(new CustomEvent('xnet-devtools-toggle')) - }} - /> -
-
+ +
+
+
+
+ { + setPrefilledShareValue('') + setShowAddSharedDialog(true) + }} + onToggleDebugPanel={() => { + window.dispatchEvent(new CustomEvent('xnet-devtools-toggle')) + }} + /> +
+
-
-
- +
+ void handleCreateLinkedDocument('page')} + onCreateDatabase={() => void handleCreateLinkedDocument('database')} + onCreateNote={handleCreateCanvasNote} + onCommandStateChange={handleCommandStateChange} + onPendingInsertConsumed={handlePendingInsertConsumed} + onOpenDocument={(docId, docType) => focusDocument(docId, docType, true)} + onOpenDatabaseSplit={openDatabaseSplit} + /> +
+ + {renderOverlay()} + + void handleCreateLinkedDocument('page')} onCreateDatabase={() => void handleCreateLinkedDocument('database')} onCreateNote={handleCreateCanvasNote} - onCommandStateChange={handleCommandStateChange} - onPendingInsertConsumed={handlePendingInsertConsumed} - onOpenDocument={(docId, docType) => focusDocument(docId, docType, true)} - onOpenDatabaseSplit={openDatabaseSplit} + onCreateShape={() => { + canvasViewRef.current?.createShape('rectangle') + }} + onCreateFrame={() => { + canvasViewRef.current?.createFrame() + }} + onCreateReference={() => { + canvasViewRef.current?.createExternalReference() + }} + onCreateMedia={() => { + canvasViewRef.current?.createMediaFile() + }} + onOpenSearch={showPalette} + onReturnHome={handleReturnHome} + onZoomOut={() => { + canvasViewRef.current?.zoomOut() + }} + onZoomIn={() => { + canvasViewRef.current?.zoomIn() + }} + onFitToContent={() => { + canvasViewRef.current?.fitCanvasContent() + }} + onResetView={() => { + canvasViewRef.current?.resetCanvasView() + }} /> -
- - {renderOverlay()} +
- void handleCreateLinkedDocument('page')} - onCreateDatabase={() => void handleCreateLinkedDocument('database')} - onCreateNote={handleCreateCanvasNote} - onCreateShape={() => { - canvasViewRef.current?.createShape('rectangle') - }} - onCreateFrame={() => { - canvasViewRef.current?.createFrame() - }} - onCreateReference={() => { - canvasViewRef.current?.createExternalReference() - }} - onCreateMedia={() => { - canvasViewRef.current?.createMediaFile() - }} - onOpenSearch={showPalette} - onReturnHome={handleReturnHome} - onZoomOut={() => { - canvasViewRef.current?.zoomOut() - }} - onZoomIn={() => { - canvasViewRef.current?.zoomIn() - }} - onFitToContent={() => { - canvasViewRef.current?.fitCanvasContent() - }} - onResetView={() => { - canvasViewRef.current?.resetCanvasView() + { + setShowAddSharedDialog(false) + setPrefilledShareValue('') }} + onAdd={handleAddShared} + initialValue={prefilledShareValue} /> - - { - setShowAddSharedDialog(false) - setPrefilledShareValue('') - }} - onAdd={handleAddShared} - initialValue={prefilledShareValue} - /> - - setConnectRequest(null)} - onConfirm={handleCloudConnect} - /> + setConnectRequest(null)} + onConfirm={handleCloudConnect} + /> - + - -
+ +
+ ) } diff --git a/apps/electron/src/renderer/shell/desktop-platform.test.ts b/apps/electron/src/renderer/shell/desktop-platform.test.ts new file mode 100644 index 000000000..bbd59b68e --- /dev/null +++ b/apps/electron/src/renderer/shell/desktop-platform.test.ts @@ -0,0 +1,86 @@ +/** + * The desktop PlatformPort's two pure halves (exploration 0406): + * shell state → synthesized pathname, and nav intent → shell action. + * + * The pathname grammar mirrors web's routes on purpose — shared core modules + * (`tabFromPathname`, route titles) must behave identically on both hosts. + */ + +import type { NavTarget } from '@xnetjs/workbench' +import { describe, expect, it, vi } from 'vitest' +import { navigateShell, pathnameForShellState, type DesktopNavDeps } from './desktop-platform' + +function makeDeps(): DesktopNavDeps & { calls: string[] } { + const calls: string[] = [] + return { + calls, + shellState: { kind: 'canvas-home' }, + returnHome: () => void calls.push('shell:return-home'), + openDocument: (id) => void calls.push(`doc:${id}`), + openAssistant: () => void calls.push('assistant'), + openSettings: () => void calls.push('settings'), + openMeetings: () => void calls.push('meetings'), + openDataWorkspace: () => void calls.push('data') + } +} + +describe('pathnameForShellState', () => { + it('mirrors the web route grammar for every shell kind', () => { + expect(pathnameForShellState({ kind: 'canvas-home' })).toBe('/') + expect(pathnameForShellState({ kind: 'page-focus', docId: 'p1', returnViewport: null })).toBe( + '/doc/p1' + ) + expect( + pathnameForShellState({ kind: 'database-focus', docId: 'd1', returnViewport: null }) + ).toBe('/db/d1') + expect(pathnameForShellState({ kind: 'database-split', docId: 'd2' })).toBe('/db/d2') + expect(pathnameForShellState({ kind: 'settings' })).toBe('/settings') + expect(pathnameForShellState({ kind: 'data-workspace' })).toBe('/data') + expect(pathnameForShellState({ kind: 'assistant' })).toBe('/ai') + }) + + it('encodes doc ids the way web routes do (seeded ids contain slashes)', () => { + expect( + pathnameForShellState({ kind: 'page-focus', docId: 'default/spec', returnViewport: null }) + ).toBe('/doc/default%2Fspec') + }) +}) + +describe('navigateShell', () => { + it('routes node intents through the shell handlers', () => { + const deps = makeDeps() + navigateShell({ kind: 'node', nodeType: 'page', nodeId: 'p1' }, deps) + navigateShell({ kind: 'node', nodeType: 'settings', nodeId: '' }, deps) + navigateShell({ kind: 'home' }, deps) + expect(deps.calls).toEqual(['doc:p1', 'settings', 'shell:return-home']) + }) + + it('maps the path escape hatch onto desktop surfaces', () => { + const deps = makeDeps() + navigateShell({ kind: 'path', path: '/ai' }, deps) + navigateShell({ kind: 'path', path: '/data' }, deps) + expect(deps.calls).toEqual(['assistant', 'data']) + }) + + it('returns false for targets this host has no surface for — never a silent no-op', () => { + const deps = makeDeps() + const unhandled: NavTarget[] = [ + { kind: 'node', nodeType: 'crm', nodeId: '' }, + { kind: 'path', path: '/requests' }, + { kind: 'surface', surfaceId: 'discover' } + ] + for (const target of unhandled) { + expect(navigateShell(target, deps), JSON.stringify(target)).toBe(false) + } + expect(deps.calls).toEqual([]) + }) + + it('handles every TabNodeType without throwing (exhaustive dispatch)', () => { + const deps = makeDeps() + const spy = vi.fn() + // Types desktop cannot open return false; none may throw. + for (const nodeType of ['post', 'dashboard', 'map', 'savedview', 'tag', 'channel'] as const) { + expect(() => spy(navigateShell({ kind: 'node', nodeType, nodeId: 'x' }, deps))).not.toThrow() + } + }) +}) diff --git a/apps/electron/src/renderer/shell/desktop-platform.ts b/apps/electron/src/renderer/shell/desktop-platform.ts new file mode 100644 index 000000000..711ee5bc7 --- /dev/null +++ b/apps/electron/src/renderer/shell/desktop-platform.ts @@ -0,0 +1,191 @@ +/** + * The desktop host's {@link PlatformPort} (exploration 0406, phase 3). + * + * Web resolves a {@link NavTarget} to a URL through TanStack Router; this host + * resolves it to a {@link ShellState} transition. The existing shell reducer + * survives as the navigation *implementation* — exactly the deal 0280 deferred + * and the exploration's sequence diagram draws. + * + * The port's pathname is synthesized from the shell state so shared components + * highlight active rows with one code path. The synthesized paths reuse web's + * route grammar (`/doc/:id`, `/settings`, …) — not because desktop has URLs, + * but so `tabFromPathname`/`routeTitles` in the shared core behave identically + * on both hosts. + */ + +import type { ShellState } from './shell-state' +import type { + NavTarget, + PlatformCapabilities, + PlatformLinkProps, + PlatformPort +} from '@xnetjs/workbench' +import { createElement, useMemo } from 'react' + +export interface DesktopNavDeps { + shellState: ShellState + /** The shell's own home transition (viewport/timer-aware). */ + returnHome: () => void + /** Open a document by id through the shell's own resolution (type lookup + canvas glide). */ + openDocument: (docId: string) => void + openAssistant: () => void + openSettings: () => void + openMeetings: () => void + openDataWorkspace: () => void +} + +const DESKTOP_CAPABILITIES: PlatformCapabilities = { + nativeMenus: true, + meetingsCapture: true, + // Live since #638; the preload always exposes the control surface. + agentBridge: true, + filesystem: true, + urlAddressable: false +} + +/** Synthesize web's route grammar from the shell state (read side of the port). */ +export function pathnameForShellState(state: ShellState): string { + switch (state.kind) { + case 'canvas-home': + return '/' + case 'page-focus': + return `/doc/${encodeURIComponent(state.docId)}` + case 'database-focus': + case 'database-split': + return `/db/${encodeURIComponent(state.docId)}` + case 'settings': + return '/settings' + case 'data-workspace': + return '/data' + case 'social-import': + return '/social-import' + case 'meetings': + return '/meetings' + case 'stories': + return '/stories' + case 'assistant': + return '/ai' + } +} + +/** + * Resolve a nav intent to a shell action via the injected handlers (write side). + * Returns false when this host has no surface for the target — the caller + * decides whether that is a log or an error, but it must not be silent. + */ +export function navigateShell(target: NavTarget, deps: DesktopNavDeps): boolean { + switch (target.kind) { + case 'home': + deps.returnHome() + return true + case 'node': + switch (target.nodeType) { + case 'page': + case 'database': + case 'canvas': + deps.openDocument(target.nodeId) + return true + case 'settings': + deps.openSettings() + return true + case 'meetings': + deps.openMeetings() + return true + case 'data': + deps.openDataWorkspace() + return true + default: + return false + } + case 'surface': + if (target.surfaceId === 'ai') { + deps.openAssistant() + return true + } + return false + case 'path': + switch (target.path) { + case '/': + deps.returnHome() + return true + case '/settings': + deps.openSettings() + return true + case '/meetings': + deps.openMeetings() + return true + case '/data': + deps.openDataWorkspace() + return true + case '/ai': + deps.openAssistant() + return true + default: + return false + } + } +} + +/** + * Links have no meaning without URLs: render a button-shaped anchor that + * navigates through the port on click. Drag/testid passthrough matches the + * web link so shared rows behave identically. + */ +function makeDesktopLink(navigate: (target: NavTarget) => void) { + return function DesktopLink({ + target, + children, + className, + title, + onClick, + draggable, + onDragStart, + 'data-testid': testId + }: PlatformLinkProps) { + return createElement( + 'a', + { + className, + title, + draggable, + onDragStart, + 'data-testid': testId, + onClick: (event: MouseEvent) => { + event.preventDefault() + onClick?.() + navigate(target) + } + }, + children + ) + } +} + +/** Build the desktop port. Memoised against the live shell state + handlers. */ +export function useDesktopPlatformPort(deps: DesktopNavDeps): PlatformPort { + const { shellState } = deps + return useMemo(() => { + const navigate = (target: NavTarget): void => { + if (!navigateShell(target, deps)) { + // Loud, not silent: a dead click is the failure mode 0406 exists to kill. + console.warn('[desktop-platform] no surface for target', target) + } + } + return { + navigate, + usePathname: () => pathnameForShellState(shellState), + useSearch: () => ({}), + Link: makeDesktopLink(navigate), + capabilities: DESKTOP_CAPABILITIES + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- deps is a fresh object per render; memoise on its fields + }, [ + shellState, + deps.returnHome, + deps.openDocument, + deps.openAssistant, + deps.openSettings, + deps.openMeetings, + deps.openDataWorkspace + ]) +} 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 index 4efd87bdd..8aa228641 100644 --- 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 @@ -545,7 +545,7 @@ export class ShellErrorBoundary extends React.Component { ### Phase 3 — desktop mounts the shell -- [ ] Add `DesktopPlatformPort` backed by the existing `ShellState` reducer +- [x] 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