diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 06890bc19..f91ac2bde 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -67,7 +67,7 @@ module.exports = { // shared by web and desktop. The desktop renderer has no router, so the // shell reaches navigation only through the PlatformPort — a direct // router import here is the fork restarting inside shared code. - files: ['apps/web/src/workbench/**/*.{ts,tsx}'], + files: ['apps/web/src/workbench/**/*.{ts,tsx}', 'packages/workbench/src/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': [ 'error', diff --git a/apps/web/package.json b/apps/web/package.json index a84c84cf6..53f04e388 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -47,6 +47,7 @@ "@xnetjs/ui": "workspace:*", "@xnetjs/vectors": "workspace:*", "@xnetjs/views": "workspace:*", + "@xnetjs/workbench": "workspace:*", "fflate": "0.8.3", "lucide-react": "^0.453.0", "maplibre-gl": "^5.0.0", diff --git a/apps/web/src/lib/doc-creation.tsx b/apps/web/src/lib/doc-creation.tsx index a3aada2da..af25b5af9 100644 --- a/apps/web/src/lib/doc-creation.tsx +++ b/apps/web/src/lib/doc-creation.tsx @@ -2,11 +2,11 @@ * Shared document-creation affordances: the per-type route/icon/label table * and the "New …" dropdown items used by both the sidebar and the home page. */ +export { newDocId, type CreatableDocType } from '@xnetjs/workbench' +import type { CreatableDocType } from '@xnetjs/workbench' import type { ComponentType } from 'react' import { Code2, Database, FileText, Layout, LayoutDashboard, MapPin } from 'lucide-react' -export type CreatableDocType = 'page' | 'database' | 'canvas' | 'dashboard' | 'map' | 'lab' - export interface DocTypeRoute { to: string paramKey: string @@ -28,10 +28,6 @@ export const DOC_TYPE_ROUTES: Record = { lab: { to: '/lab/$labId', paramKey: 'labId', label: 'Lab', icon: Code2 } } -export function newDocId(): string { - return Math.random().toString(36).substring(2, 15) -} - /** The shared "New …" dropdown entries. */ export function CreateDocMenuItems({ types, diff --git a/apps/web/src/workbench/commands.ts b/apps/web/src/workbench/commands.ts index af7330515..3b629a5fd 100644 --- a/apps/web/src/workbench/commands.ts +++ b/apps/web/src/workbench/commands.ts @@ -1,190 +1,5 @@ /** - * Core workbench keyboard map (exploration 0166). - * - * Every shell action is a CommandRegistry command so the palette can - * list it with its chord. Cmd+K (palette) lives in GlobalSearch; - * Cmd+T / Cmd+W / Ctrl+Tab / Cmd+1/2 are registered by the editor - * area where tab context lives. + * Shim (0406): canonical module lives in @xnetjs/workbench — the 0280 + * layout-tree pattern. New code imports the package directly. */ -import { getCommandRegistry } from '@xnetjs/plugins' -import { useEffect } from 'react' -import { useWorkbench } from './state' - -export function useWorkbenchCommands(): void { - useEffect(() => { - const registry = getCommandRegistry() - const wb = () => useWorkbench.getState() - - const disposables = [ - registry.register({ - id: 'workbench.toggleLeftPanel', - title: 'Toggle left panel', - key: 'Mod-B', - allowInInput: true, - run: () => wb().togglePanel('left') - }), - registry.register({ - id: 'workbench.toggleRightPanel', - title: 'Toggle right panel', - key: 'Mod-\\', - allowInInput: true, - run: () => wb().togglePanel('right') - }), - registry.register({ - id: 'workbench.toggleBottomPanel', - title: 'Toggle bottom panel', - key: 'Mod-J', - allowInInput: true, - run: () => wb().togglePanel('bottom') - }), - registry.register({ - id: 'workbench.focus', - title: 'View: Focus mode (hide chrome)', - key: 'Mod-.', - allowInInput: true, - run: () => wb().toggleFocus() - }), - registry.register({ - id: 'workbench.switchLayout', - title: 'View: Switch layout (Calm ↔ Workbench)', - run: () => wb().toggleLayout() - }), - // Tabless mode (0353): both directions are one ⌘K away, so the - // preference is reversible without hunting through settings. - registry.register({ - id: 'workbench.disableTabs', - title: 'View: Turn off tabs (single surface)', - when: () => useWorkbench.getState().tabsEnabled, - run: () => wb().setTabsEnabled(false) - }), - registry.register({ - id: 'workbench.enableTabs', - title: 'View: Turn on tabs', - when: () => !useWorkbench.getState().tabsEnabled, - run: () => wb().setTabsEnabled(true) - }), - // Quiet-surface posture (0273): both directions get explicit palette - // entries so either posture is one ⌘K away from the other. - registry.register({ - id: 'workbench.quietChrome', - title: 'View: Quiet chrome (surface first)', - when: () => useWorkbench.getState().chrome !== 'quiet', - run: () => wb().setChrome('quiet') - }), - registry.register({ - id: 'workbench.pinnedChrome', - title: 'View: Pinned chrome', - when: () => useWorkbench.getState().chrome !== 'pinned', - run: () => wb().setChrome('pinned') - }), - registry.register({ - id: 'workbench.showExplorer', - title: 'Show explorer', - run: () => wb().showPanelView('left', 'explorer') - }), - registry.register({ - id: 'workbench.showTasksPanel', - title: 'Show tasks panel', - run: () => wb().showPanelView('left', 'tasks') - }), - registry.register({ - id: 'workbench.showDataPanel', - title: 'Show data panel', - run: () => wb().showPanelView('left', 'data') - }), - registry.register({ - id: 'workbench.setStartupTab', - title: 'Use current tab at startup', - when: () => { - const state = useWorkbench.getState() - const group = state.groups.find((g) => g.id === state.activeGroupId) - return Boolean(group?.activeTabId) - }, - run: () => { - const state = wb() - const group = state.groups.find((g) => g.id === state.activeGroupId) - const tab = group?.tabs.find((t) => t.id === group.activeTabId) - if (tab) state.setStartupTab({ nodeType: tab.nodeType, nodeId: tab.nodeId }) - } - }), - registry.register({ - id: 'workbench.clearStartupTab', - title: 'Clear startup tab', - when: () => Boolean(useWorkbench.getState().startupTab), - run: () => wb().setStartupTab(null) - }) - ] - - return () => { - for (const disposable of disposables) disposable.dispose() - } - }, []) -} - -/** - * The pinned frame's Esc ladder (0280 phase 4, extending 0273): each Esc - * closes ONE open dock — bottom, then right, then left — walking the - * disclosure ladder down to the bare surface. Runs only when nothing - * closer to the keystroke claimed it (palette, dialogs, editors all - * preventDefault first) and never steals Esc from text inputs. - */ -export function useShellEscape(): void { - useEffect(() => { - const handler = (event: KeyboardEvent) => { - if (event.key !== 'Escape' || event.defaultPrevented) return - const target = event.target instanceof HTMLElement ? event.target : null - if ( - target && - (target.closest('input, textarea, [contenteditable="true"]') || target.isContentEditable) - ) { - return - } - const state = useWorkbench.getState() - // Focus mode (0284): a single Esc restores the chrome. - if (state.focus) { - event.preventDefault() - state.setFocus(false) - return - } - // Tabless split (0353) is the newest surface, so it's the first rung - // down: Esc closes the second pane before it starts closing docks. - if (state.splitTarget) { - event.preventDefault() - state.setSplitTarget(null) - return - } - if (state.chrome === 'quiet' || state.mode === 'zen') return - const side = (['bottom', 'right', 'left'] as const).find((s) => state[s].open) - if (!side) return - event.preventDefault() - state.setPanelOpen(side, false) - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, []) -} - -/** Exit zen with Esc Esc (two presses within 500ms), preserving layout. */ -export function useZenEscape(): void { - const mode = useWorkbench((state) => state.mode) - - useEffect(() => { - if (mode !== 'zen') return - - let lastEscape = 0 - const handler = (event: KeyboardEvent) => { - if (event.key !== 'Escape') return - const now = Date.now() - if (now - lastEscape < 500) { - event.preventDefault() - useWorkbench.getState().toggleZen() - lastEscape = 0 - } else { - lastEscape = now - } - } - - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, [mode]) -} +export * from '@xnetjs/workbench' diff --git a/apps/web/src/workbench/focus.ts b/apps/web/src/workbench/focus.ts index 000903301..3b629a5fd 100644 --- a/apps/web/src/workbench/focus.ts +++ b/apps/web/src/workbench/focus.ts @@ -1,101 +1,5 @@ /** - * Shell-level focus ring (exploration 0166). - * - * Regions carry data-wb-region attributes; F6 / Shift+F6 cycle focus - * through the visible ones (the VS Code model), and Escape inside a - * panel returns focus to the editor — the per-region "trap" exit. - * Every shell action stays reachable by keyboard alone. + * Shim (0406): canonical module lives in @xnetjs/workbench — the 0280 + * layout-tree pattern. New code imports the package directly. */ -import { getCommandRegistry } from '@xnetjs/plugins' -import { useEffect } from 'react' -import { useWorkbench } from './state' - -export type WorkbenchRegion = 'left' | 'editor' | 'right' | 'bottom' - -const RING_ORDER: WorkbenchRegion[] = ['left', 'editor', 'right', 'bottom'] - -const FOCUSABLE = - 'input, textarea, select, button, a[href], [tabindex]:not([tabindex="-1"]), [contenteditable="true"]' - -export function focusRegion(region: WorkbenchRegion): void { - const root = document.querySelector(`[data-wb-region="${region}"]`) - if (!root) return - const target = root.querySelector(FOCUSABLE) ?? root - target.focus() -} - -function visibleRing(): WorkbenchRegion[] { - const state = useWorkbench.getState() - return RING_ORDER.filter((region) => { - if (region === 'editor') return true - return state[region].open - }) -} - -function currentRegion(): WorkbenchRegion | null { - const active = document.activeElement - if (!(active instanceof HTMLElement)) return null - const root = active.closest('[data-wb-region]') - return (root?.dataset.wbRegion as WorkbenchRegion | undefined) ?? null -} - -function cycleRegion(delta: 1 | -1): void { - const ring = visibleRing() - if (ring.length === 0) return - const current = currentRegion() - const index = current ? ring.indexOf(current) : -1 - const next = ring[(index + delta + ring.length) % ring.length] - focusRegion(next) -} - -function returnFocusToEditor(): void { - const region = currentRegion() - if (region !== null && region !== 'editor') focusRegion('editor') -} - -const EDITABLE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']) - -function isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) return false - return EDITABLE_TAGS.has(target.tagName) || target.isContentEditable -} - -export function useFocusRing(): void { - useEffect(() => { - const registry = getCommandRegistry() - const disposables = [ - registry.register({ - id: 'workbench.focusNextRegion', - title: 'Focus next region', - key: 'F6', - allowInInput: true, - run: () => cycleRegion(1) - }), - registry.register({ - id: 'workbench.focusPreviousRegion', - title: 'Focus previous region', - key: 'Shift-F6', - allowInInput: true, - run: () => cycleRegion(-1) - }), - registry.register({ - id: 'workbench.focusEditor', - title: 'Focus editor', - run: () => focusRegion('editor') - }) - ] - - // Escape inside a panel returns to the editor; inputs keep their - // own Escape semantics (blur, close menus) untouched. - const escapeHandler = (event: KeyboardEvent) => { - if (event.key !== 'Escape' || isEditableTarget(event.target)) return - returnFocusToEditor() - } - - window.addEventListener('keydown', escapeHandler) - return () => { - for (const disposable of disposables) disposable.dispose() - window.removeEventListener('keydown', escapeHandler) - } - }, []) -} +export * from '@xnetjs/workbench' diff --git a/apps/web/src/workbench/layout-tree.ts b/apps/web/src/workbench/layout-tree.ts index 0ed9da37c..3b629a5fd 100644 --- a/apps/web/src/workbench/layout-tree.ts +++ b/apps/web/src/workbench/layout-tree.ts @@ -1,33 +1,5 @@ /** - * 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. + * Shim (0406): canonical module lives in @xnetjs/workbench — the 0280 + * layout-tree pattern. New code imports the package directly. */ -export { - createDefaultTree, - createPresetTree, - DEFAULT_WORKSPACE_ID, - insertSlot, - moveSlot, - parseWorkspacePayload, - placementOf, - PRESET_IDS, - PRESET_WORKSPACE_ID_PREFIX, - isPresetWorkspaceId, - presetForWorkspaceId, - presetWorkspaceId, - REGION_IDS, - regionOf, - serializeWorkspacePayload, - setSlotTier, - slotsIn -} from '@xnetjs/plugins' -export type { - ChromePosture, - LayoutTree, - PresetId, - RegionId, - SlotPlacement, - SlotTier, - WorkspacePayload -} from '@xnetjs/plugins' +export * from '@xnetjs/workbench' diff --git a/apps/web/src/workbench/navigation.ts b/apps/web/src/workbench/navigation.ts index 43b1d14bb..3b629a5fd 100644 --- a/apps/web/src/workbench/navigation.ts +++ b/apps/web/src/workbench/navigation.ts @@ -1,72 +1,5 @@ /** - * Tab navigation helpers, expressed against the {@link PlatformPort} - * (exploration 0406) — the host decides whether "open this node" means a URL - * (web) or a shell-state transition (desktop). - * - * VS Code-style preview tabs (0284): opening a node from a single click is - * a *preview* by default — it renders italic and is replaced by the next - * single-click open. Editing it, or double-clicking its tab/source row, - * promotes it to a permanent tab. Centralizing the preview intent here means - * every single-click source (explorer, home list, chat links, person/space/ - * tag views, desk) gets the behavior without each remembering to opt in. - * Pass `{ preview: false }` for activation of an already-open tab, and note - * that deep links / back-forward / creation never route through here, so - * they stay permanent. + * Shim (0406): canonical module lives in @xnetjs/workbench — the 0280 + * layout-tree pattern. New code imports the package directly. */ -import type { NavigateOptions, NavTarget } from './platform' -import type { TabNodeType } from './state' -import type { CreatableDocType } from '../lib/doc-creation' -import { newDocId } from '../lib/doc-creation' - -/** The port's navigate — what every helper here threads through. */ -export type PlatformNavigate = (target: NavTarget, options?: NavigateOptions) => void - -export function navigateToNode( - navigate: PlatformNavigate, - nodeType: TabNodeType, - nodeId: string, - opts: { preview?: boolean } = {} -): void { - // Preview intent is applied by the host (it owns the tab store's latch); - // the shell only states whether this open is a preview. - navigate({ - kind: 'node', - nodeType, - nodeId, - ...(opts.preview === false ? { preview: false } : {}) - }) -} - -/** - * Open a node through an arbitrary registered view (0346). - * `frameSpec` = `~`; one route covers every - * registry/plugin view. Tabless (0353) this is a plain route like any - * other — the `frame` TabNodeType survives only so the tab path (still - * reachable behind the preference) keeps working. - */ -export function navigateToFrame( - navigate: PlatformNavigate, - viewType: string, - nodeId: string, - opts: { preview?: boolean } = {} -): void { - navigateToNode(navigate, 'frame', `${viewType}~${nodeId}`, opts) -} - -/** Parse a frame tab's nodeId back into view type + target node. */ -export function parseFrameSpec(frameSpec: string): { viewType: string; nodeId: string } | null { - const idx = frameSpec.indexOf('~') - if (idx <= 0 || idx === frameSpec.length - 1) return null - return { viewType: frameSpec.slice(0, idx), nodeId: frameSpec.slice(idx + 1) } -} - -/** - * Generate an id for a new document and open its surface — the port-shaped - * sibling of `lib/doc-creation`'s `navigateToNewDoc`. Every creatable doc - * type is a {@link TabNodeType}, which the signature relies on; the old call - * sites cast the router's navigate through `NavigateLike`, a hole the - * compiler could not see into. - */ -export function navigateToNewDoc(navigate: PlatformNavigate, type: CreatableDocType): void { - navigate({ kind: 'node', nodeType: type, nodeId: newDocId(), preview: false }) -} +export * from '@xnetjs/workbench' diff --git a/apps/web/src/workbench/platform.ts b/apps/web/src/workbench/platform.ts index bce58aa24..3b629a5fd 100644 --- a/apps/web/src/workbench/platform.ts +++ b/apps/web/src/workbench/platform.ts @@ -1,134 +1,5 @@ /** - * 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. + * Shim (0406): canonical module lives in @xnetjs/workbench — the 0280 + * layout-tree pattern. New code imports the package directly. */ - -import type { TabNodeType } from './state' -import type { ComponentType, ReactNode } from 'react' -import React, { createContext, createElement, 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 } - -/** Query-string-shaped state (`/tasks?task=…`, `/ai?q=…`). */ -export type SearchParams = Record - -export interface NavigateOptions { - /** Replace the current history entry instead of pushing (web); no-op elsewhere. */ - replace?: boolean - /** Search params for the destination. Hosts without URLs keep them in memory. */ - search?: SearchParams -} - -/** - * 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 - /** Search params for the destination (`/tasks?task=…`). */ - search?: SearchParams - children: ReactNode - className?: string - title?: string - onClick?: () => void - /** Rows are drag sources (the tasks panel drags onto the board). */ - draggable?: boolean - onDragStart?: (event: React.DragEvent) => void - 'data-testid'?: string -} - -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 - /** The current location's search params (`?section=…`). */ - useSearch(): Readonly> - /** 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 -} - -/** The current pathname, host-agnostic. */ -export function usePathname(): string { - return usePlatform().usePathname() -} - -/** The current search params, host-agnostic. */ -export function useSearch(): Readonly> { - return usePlatform().useSearch() -} - -/** - * The host's link component, resolved from context. `createElement` rather - * than JSX keeps this module a plain `.ts` file. - */ -export function PlatformLink(props: PlatformLinkProps): React.ReactElement { - return createElement(usePlatform().Link, props) -} +export * from '@xnetjs/workbench' diff --git a/apps/web/src/workbench/route-title.ts b/apps/web/src/workbench/route-title.ts index 7d9db6fc8..3b629a5fd 100644 --- a/apps/web/src/workbench/route-title.ts +++ b/apps/web/src/workbench/route-title.ts @@ -1,52 +1,5 @@ /** - * Route-derived titles (exploration 0353). - * - * With tabs gone the header can't read `selectActiveTab` for "what am I - * looking at" — nothing owns that state any more. Views publish their - * title against the route instead, and the header reads it back. While - * tabs are still on the same call also writes the tab title, so a view - * has exactly ONE title call either way. + * Shim (0406): canonical module lives in @xnetjs/workbench — the 0280 + * layout-tree pattern. New code imports the package directly. */ -import { useEffect } from 'react' -import { usePathname } from './platform' -import { useWorkbench } from './state' -import { tabFromPathname } from './tabs' - -/** - * Publish this view's title for the current route. Pass the node id the - * view renders so the title also reaches the tab store (tabs on) and - * the recents entry (both modes). - * - * `sourceId` is the id of the record the title came from. `useNode` keeps - * the previous node's data while the next one loads, so during a - * navigation `title` briefly belongs to the node we just left while - * `nodeId` is already the new one — publishing then puts the old title on - * the new route and mints a mis-titled recents entry. Passing the loaded - * record's own id lets us hold the publish until the data catches up. - */ -export function usePublishTitle( - nodeId: string, - title: string | null | undefined, - sourceId?: string | null -): void { - const pathname = usePathname() - - useEffect(() => { - if (!title) return - if (sourceId != null && sourceId !== nodeId) return - const state = useWorkbench.getState() - state.setRouteTitle(pathname, title) - // Tabs (while they exist) and recents both key off the node id. - state.setTabTitle(nodeId, title) - const descriptor = tabFromPathname(pathname) - if (descriptor) { - state.touchRecent({ nodeId: descriptor.nodeId, nodeType: descriptor.nodeType, title }) - } - }, [pathname, nodeId, title, sourceId]) -} - -/** The current route's published title, if a view has published one. */ -export function useRouteTitle(): string | null { - const pathname = usePathname() - return useWorkbench((state) => state.routeTitles[pathname] ?? null) -} +export * from '@xnetjs/workbench' diff --git a/apps/web/src/workbench/sidebar/sections.ts b/apps/web/src/workbench/sidebar/sections.ts index a30b7414d..3b629a5fd 100644 --- a/apps/web/src/workbench/sidebar/sections.ts +++ b/apps/web/src/workbench/sidebar/sections.ts @@ -1,230 +1,5 @@ /** - * User sections (exploration 0353) — the successor to `SURFACES` + - * `navPinned` + the "More" roll-out. - * - * The old model made every feature area mint a nav: twelve surfaces, - * each either a bespoke panel or a route, with their own switcher. That - * is the Microsoft Teams anti-pattern in miniature (a rail of apps, each - * with its own internal navigation). - * - * Here there is exactly one nav — the tree — and sections are just - * *entries the user curates* pointing at three things: - * - * - `lens` — a projection of the one tree (Docs, Chats, People, …) - * - `route` — a destination that isn't a node list (Inbox, Meetings…) - * - `node` — a pinned node (any row the user promoted) - * - * Per-user and reorderable, following Slack's custom-sections and - * Linear's personalized-sidebar precedent: each person chooses their - * own unification boundary rather than the app choosing for them. + * Shim (0406): canonical module lives in @xnetjs/workbench — the 0280 + * layout-tree pattern. New code imports the package directly. */ -import { - BarChart3, - Bot, - CheckSquare2, - Compass, - Contact, - Database, - Files, - FlaskConical, - Import, - Inbox, - MessageSquare, - Mic, - Sparkles, - Wallet, - type LucideIcon -} from 'lucide-react' - -export type SidebarSectionKind = 'lens' | 'route' | 'node' - -export interface SidebarSection { - /** Stable id — persisted in the user's section order. */ - id: string - kind: SidebarSectionKind - label: string - /** Lens id, route path, or node id, per `kind`. */ - target: string - /** Live count source for the trailing badge. */ - badge?: 'requests' - /** Emphasised (ink pill) rather than muted-mono count. */ - emphasis?: boolean -} - -const ICONS: Record = { - 'lens:all': Files, - 'lens:docs': Files, - 'lens:chats': MessageSquare, - 'lens:people': Contact, - 'lens:views': Database, - 'route:/requests': Inbox, - 'route:/tasks': CheckSquare2, - 'route:/meetings': Mic, - 'route:/discover': Compass, - 'route:/finance': Wallet, - 'route:/analytics': BarChart3, - 'route:/ai': Bot, - 'route:/companion': Sparkles, - 'route:/experiments': FlaskConical, - 'route:/social-import': Import -} - -export function sectionIcon(section: SidebarSection): LucideIcon { - return ICONS[`${section.kind}:${section.target}`] ?? Files -} - -/** - * The default sections a new identity gets. Everything that used to be - * a bespoke panel is now a lens; everything that was a route surface is - * a route entry — one grammar, no panel/route fork. - */ -export const DEFAULT_SECTIONS: SidebarSection[] = [ - { id: 'all', kind: 'lens', label: 'All', target: 'all' }, - { id: 'docs', kind: 'lens', label: 'Docs', target: 'docs' }, - { id: 'chats', kind: 'lens', label: 'Chats', target: 'chats' }, - { - id: 'inbox', - kind: 'route', - label: 'Inbox', - target: '/requests', - badge: 'requests', - emphasis: true - }, - { id: 'tasks', kind: 'route', label: 'Tasks', target: '/tasks' }, - { id: 'people', kind: 'lens', label: 'People', target: 'people' }, - { id: 'views', kind: 'lens', label: 'Views', target: 'views' }, - // The BYO-model chat surface (0174/0192). It was a `panel` surface, a kind - // this list doesn't have, so 0353 dropped it and left it unreachable — - // restored as a route (0388). - { id: 'ai', kind: 'route', label: 'AI', target: '/ai' }, - { id: 'meetings', kind: 'route', label: 'Meetings', target: '/meetings' }, - { id: 'discover', kind: 'route', label: 'Discover', target: '/discover' }, - { id: 'finance', kind: 'route', label: 'Finance', target: '/finance' }, - { id: 'companion', kind: 'route', label: 'Companion', target: '/companion' }, - { id: 'experiments', kind: 'route', label: 'Experiments', target: '/experiments' }, - { id: 'social-import', kind: 'route', label: 'Import', target: '/social-import' }, - { id: 'analytics', kind: 'route', label: 'Analytics', target: '/analytics' } -] - -/** - * Whether a section is available in this build (exploration 0388). - * - * A nav row that always dead-ends is worse than a missing one: it teaches - * people the nav lies. Analytics renders "this surface is off by default" - * unless the telemetry dashboard is compiled in, so it is absent rather than - * dead when the flag is unset — the UI restatement of the CI-lane rule that a - * gate nobody can pass is worse than no gate. - */ -export function isSectionEnabled(section: SidebarSection): boolean { - if (section.id !== 'analytics') return true - const env = (import.meta as { env?: Record }).env - return env?.VITE_TELEMETRY_DASHBOARD === '1' || env?.VITE_TELEMETRY_DASHBOARD === 'true' -} - -/** Section ids shown as primary rows for a fresh identity. */ -export const DEFAULT_PINNED_SECTION_IDS = ['all', 'docs', 'chats', 'inbox'] - -/** - * Resolve a persisted order against the known defaults: unknown ids are - * dropped (a section removed by a later build must not crash the shell, - * the 0280 migration lesson) and new defaults append. - */ -export function resolveSections(order: string[]): SidebarSection[] { - const available = DEFAULT_SECTIONS.filter(isSectionEnabled) - const byId = new Map(available.map((section) => [section.id, section])) - const ordered = order - .map((id) => byId.get(id)) - .filter((section): section is SidebarSection => Boolean(section)) - const seen = new Set(ordered.map((section) => section.id)) - return [...ordered, ...available.filter((section) => !seen.has(section.id))] -} - -/** - * Where a section sends the main area (exploration 0388). - * - * The one rule this nav is held to: **every primary row changes the main - * area**. Lens sections resolve through the registered lens's `route`, so the - * destination lives with the lens rather than in a switch here. - */ -export function sectionDestination( - section: SidebarSection, - lensRoute: (lensId: string) => string | undefined -): string | undefined { - if (section.kind === 'lens') return lensRoute(section.target) - if (section.kind === 'route') return section.target - return undefined -} - -/** - * Whether a section is the one the user is currently looking at. - * - * Derived from the **route** first, so the sidebar and the main area can never - * disagree — the pre-0388 predicate compared only `activeLensId`, which left - * "Views" highlighted while the main area showed Meetings, and highlighted - * nothing at all when the active lens wasn't pinned. - * - * Lenses sharing a route (the three home lenses on `/`) additionally require - * the lens to match; a lens with its own route (`people` → `/crm`) is active - * on that route regardless, so a reload lands with the right row lit. - */ -export function isSectionActive({ - section, - pathname, - activeLensId, - lensRoute -}: { - section: SidebarSection - pathname: string - activeLensId: string - lensRoute: (lensId: string) => string | undefined -}): boolean { - const destination = sectionDestination(section, lensRoute) - if (!destination) return false - - const onRoute = - destination === '/' - ? pathname === '/' - : pathname === destination || pathname.startsWith(`${destination}/`) - if (!onRoute) return false - - if (section.kind !== 'lens') { - // A route section loses the highlight to a lens that owns this exact - // route (People owns /crm), so only one row is ever lit. - return !lensOwningRoute(destination, lensRoute) - } - - const owner = lensOwningRoute(destination, lensRoute) - return owner ? owner === section.target : section.target === activeLensId -} - -/** - * The lens that exclusively owns a route, if exactly one does. Shared routes - * (`/`) have no owner — there the active lens decides. - */ -function lensOwningRoute( - route: string, - lensRoute: (lensId: string) => string | undefined -): string | undefined { - const owners = LENS_SECTION_IDS.filter((id) => lensRoute(id) === route) - return owners.length === 1 ? owners[0] : undefined -} - -/** Lens ids that ship as sections — the candidates for route ownership. */ -const LENS_SECTION_IDS = DEFAULT_SECTIONS.filter((section) => section.kind === 'lens').map( - (section) => section.target -) - -/** - * The lens a route restores on load, when the route belongs to exactly one - * lens. `/crm` → `people`, `/data` → `views`; `/` keeps whatever lens the user - * last chose. - */ -export function lensForRoute( - pathname: string, - lensRoute: (lensId: string) => string | undefined -): string | undefined { - return LENS_SECTION_IDS.find((id) => { - const route = lensRoute(id) - return route && route !== '/' && (pathname === route || pathname.startsWith(`${route}/`)) - }) -} +export * from '@xnetjs/workbench' diff --git a/apps/web/src/workbench/state.ts b/apps/web/src/workbench/state.ts index 17afb7117..3b629a5fd 100644 --- a/apps/web/src/workbench/state.ts +++ b/apps/web/src/workbench/state.ts @@ -1,1097 +1,5 @@ /** - * Workbench shell state (exploration 0166). - * - * One persisted zustand store holds the layout state machine - * (default / working / zen), the three collapsible panels, the editor - * groups with their tabs, explorer pins, and recents. Panel *sizes* are - * persisted separately by react-resizable-panels' useDefaultLayout. - * - * The router stays authoritative for navigation: components navigate, - * and the route effect in EditorArea reconciles the tab store against - * the URL. Store actions never call the router. + * Shim (0406): canonical module lives in @xnetjs/workbench — the 0280 + * layout-tree pattern. New code imports the package directly. */ -import type { ExplorerSort } from './views/explorer-sort' -import { create } from 'zustand' -import { persist } from 'zustand/middleware' -import { - createDefaultTree, - createPresetTree, - insertSlot as insertSlotInTree, - isPresetWorkspaceId, - moveSlot as moveSlotInTree, - setSlotTier as setSlotTierInTree, - slotsIn, - type LayoutTree, - type PresetId, - type RegionId, - type SlotTier, - type WorkspacePayload -} from './layout-tree' -import { DEFAULT_PINNED_SECTION_IDS, DEFAULT_SECTIONS } from './sidebar/sections' - -export type WorkbenchMode = 'default' | 'zen' -export type PanelSide = 'left' | 'right' | 'bottom' - -/** - * Shell composition (exploration 0250). `workbench` is the original VS-Code - * multi-pane grid (0166); `calm` is the Claude-desktop "mode · list · surface · - * contextual canvas" shell. Both reuse the same views, routes and panel state — - * only the arrangement differs — so the choice is a single persisted flag. - */ -export type ShellLayout = 'workbench' | 'calm' - -/** - * The three primary modes of the calm shell — xNet's analog of Claude - * desktop's Chat / Cowork / Code. Companion = talk to your agent; Workspace = - * your pages/databases/canvases/tasks; Network = people, channels, discover. - */ -export type CalmMode = 'companion' | 'workspace' | 'network' - -/** - * Chrome posture of the calm shell (exploration 0273). `pinned` is the 0250 - * composition — ModeSwitch and List always on screen. `quiet` inverts it: the - * surface owns the whole viewport at rest, and the same chrome is summoned - * from corner glyphs, edge hot-zones/swipes, chords, or ⌘K. An orthogonal - * axis (like density vs color), not a third shell. - */ -export type ChromePosture = 'pinned' | 'quiet' - -/** - * The quiet shell's disclosure ladder (0273): 0 = bare surface (glyphs - * dimmed), 1 = affordances lit by pointer/touch intent, 2 = one overlay open. - * Level 3 (pinned chrome / workbench) is represented by `chrome`/`layout`, - * not here. Ephemeral — deliberately excluded from persistence. - */ -export type DiscloseLevel = 0 | 1 | 2 - -/** Runtime list backing {@link TabNodeType} (migration filters against it). */ -export const TAB_NODE_TYPES = [ - 'page', - 'post', - 'database', - 'canvas', - 'dashboard', - 'map', - 'savedview', - 'tasks', - 'meetings', - 'data', - 'experiments', - 'crm', - 'finance', - 'channel', - 'tag', - 'person', - 'lab', - 'space', - 'settings', - // Frame tabs (0346): nodeId is `~` — ONE tab type - // covers every registry/plugin view, so new views need no app edits. - 'frame' -] as const - -export type TabNodeType = (typeof TAB_NODE_TYPES)[number] - -export interface WorkbenchTab { - /** `${nodeType}:${nodeId}` — stable across sessions */ - id: string - nodeId: string - nodeType: TabNodeType - /** Last known title (refreshed when the view loads) */ - title: string - pinned: boolean - /** Single-click preview tab; editing or double-click promotes */ - preview: boolean -} - -export interface EditorGroup { - id: string - tabs: WorkbenchTab[] - activeTabId: string | null -} - -export interface PanelState { - open: boolean - activeViewId: string -} - -interface ZenSnapshot { - left: boolean - right: boolean - bottom: boolean -} - -export interface RecentEntry { - nodeId: string - nodeType: TabNodeType - title: string - at: number -} - -export interface ShelfEntry { - nodeId: string - nodeType: string - title?: string - schemaId?: string -} - -/** - * A queued "Pin to Desk" (exploration 0273). Pins are queued here (persisted) - * and drained onto the Desk canvas through the normal ingestion path the next - * time it is on screen — no surface ever has to load the Desk's Y.Doc. - */ -export interface DeskPinEntry { - nodeId: string - schemaId: string - title: string -} - -const MAX_RECENTS = 30 - -export function tabIdFor(nodeType: TabNodeType, nodeId: string): string { - return `${nodeType}:${nodeId}` -} - -function createTab(input: { - nodeId: string - nodeType: TabNodeType - title?: string - preview?: boolean -}): WorkbenchTab { - return { - id: tabIdFor(input.nodeType, input.nodeId), - nodeId: input.nodeId, - nodeType: input.nodeType, - title: input.title ?? '', - pinned: false, - preview: input.preview ?? false - } -} - -interface WorkbenchState { - /** - * Active shell composition (0250). Defaults to `calm` for new identities; - * the original `workbench` grid stays one toggle away. Reuses the same - * `left`/`right` panels (as the calm List/Canvas) and `mode` (as focus). - */ - layout: ShellLayout - /** - * The layout tree (exploration 0280): regions → slots → views, the - * single data model behind every shell posture. The former shells are - * presets over this tree; behind `xnet:experiment:layout-tree` the - * ShellFrame renders it directly, and the legacy axes (`layout`, - * `chrome`) are kept coherent with it during the transition. - */ - tree: LayoutTree - /** Active primary mode of the calm shell (0250). */ - calmMode: CalmMode - /** - * Chrome posture of the calm shell (0273). Persisted so an opted-in quiet - * posture survives reloads; existing users keep their stored `pinned`. - */ - chrome: ChromePosture - /** Quiet shell disclosure level (0273). Ephemeral, not persisted. */ - discloseLevel: DiscloseLevel - /** - * Arrange mode (0282): the shell renders as an editable schematic of - * its own layout tree. Ephemeral — never persisted (same rule as - * `discloseLevel`); reload always lands on the live shell. - */ - arranging: boolean - /** - * Contextual-canvas target (0250). When set, the calm shell's right Canvas - * hosts the full content view for this node (the Claude "artifact opens on - * the right" move — e.g. the agent drafts a page). When null the Canvas falls - * back to the inspector (properties/comments/backlinks) for the active view. - */ - canvasTarget: { nodeType: TabNodeType; nodeId: string; title?: string } | null - mode: WorkbenchMode - zenSnapshot: ZenSnapshot | null - left: PanelState - right: PanelState - bottom: PanelState - groups: EditorGroup[] - activeGroupId: string - /** - * Tabless mode (0353). When false the editor renders the router outlet - * directly — no strip, no groups, no preview/promote — and the working - * set lives in the sidebar's Pinned + Recents sections instead. The - * router stays authoritative either way, so navigation is unaffected. - */ - tabsEnabled: boolean - /** - * Route → title, published by the views themselves (0353). Replaces - * `setTabTitle` as the header's title source when tabless; the tab - * store mirrors it while tabs are on. - */ - routeTitles: Record - /** - * The last two visited routes, newest first (0353). Backs the - * "recent two" Ctrl-Tab toggle — the tabless replacement for - * cycling tabs. - */ - routeHistory: string[] - /** - * The node shown in the tabless split pane, or null (0353). A layout - * concern, not a window: closing it orphans nothing, and durable - * side-by-side belongs on a page as frames (0346). - */ - splitTarget: { nodeId: string; nodeType: TabNodeType } | null - /** - * The unified sidebar's active lens (0353) — the projection of the one - * tree currently on screen ('all' | 'docs' | 'chats' | …). - */ - activeLensId: string - /** - * Muted sidebar rows (0353). ONE flag suppressing badge *and* unread - * bump: shipping those independently is a recurring bug class. - */ - mutedRowIds: string[] - /** - * The user's section order (0353) — the successor to `navPinned` + - * the "More" roll-out. Per-user, reorderable; unknown ids resolve - * away rather than crashing a shell shared across builds. - */ - sectionOrder: string[] - /** Section ids shown as primary rows; the rest live behind "More". */ - pinnedSectionIds: string[] - pinnedNodeIds: string[] - recents: RecentEntry[] - /** Expanded folders in the Explorer tree (exploration 0169) */ - expandedFolderIds: string[] - /** Muse-style shelf: nodes held in transit between contexts */ - shelf: ShelfEntry[] - /** Queued Desk pins, drained by the Desk canvas when visible (0273). */ - deskPins: DeskPinEntry[] - /** Tab opened when the workspace starts at '/' (configurable) */ - startupTab: { nodeType: TabNodeType; nodeId: string } | null - /** - * Active Space scope (exploration 0181). When set, the Explorer and new-doc - * filing are scoped to this Space. `null` = All (the global, pre-Spaces view). - */ - currentSpaceId: string | null - /** - * Multi-select view filter (exploration 0190). Empty = follow - * `currentSpaceId`. When non-empty the Explorer list shows the union of these - * Spaces, while the create target stays the single `currentSpaceId` primary. - */ - spaceFilter: string[] - /** Sort order for the flat Explorer list (exploration 0190). */ - explorerSort: ExplorerSort - /** - * Newest changelog entry id the user has acknowledged (in-app What's New, - * exploration 0195). `null` = never seen; seeded to the latest on first run - * so existing users don't get a wall of history. - */ - lastSeenChangelogId: string | null - /** - * Coachmark tip ids the user has dismissed (first-run onboarding, - * exploration 0206). Empty = nothing seen yet. Versioned ids - * (`crm:overview@1`) let a copy rewrite re-surface a tip once. - */ - seenTips: string[] - /** - * Sidebar collapsed to the icon rail (exploration 0284). Persisted so the - * user's chosen width survives reloads (the Notion/Linear pattern). - */ - sidebarCollapsed: boolean - /** - * Focus mode (0284): hide the sidebar, docks and status bar so the surface - * owns the viewport. One boolean replaces the former zen `mode`, the quiet - * `chrome` posture, and the `discloseLevel` ladder. Ephemeral — never - * persisted, so a reload always returns to full chrome. - */ - focus: boolean - - // ─── Floating Islands shell (0286) ───────────────────────────── - /** - * The primary surface the contextual (bottom) sidebar island shows in the - * Floating shell — a view id (`explorer`, `tasks`, …) or a route surface - * id. Persisted so the user returns to the surface they left on. - */ - activeSurface: string - /** - * Surfaces pinned to the top sidebar island's primary rows. The rest live - * in the "More" surfaces roll-out. Persisted (the user's curation). - */ - navPinned: string[] - /** Sidebar island width in px (tweakable knob, 230–320; default 264). */ - sidebarWidth: number - /** - * The top (nav/header) sidebar island is collapsed to its compact rail - * (0287). Only the header island's height changes — the Explorer island - * below (flex-1) grows to fill. Persisted (the user's chosen density). - */ - sidebarCompact: boolean - /** Floating Assistant chat island visible (dismissable). */ - floatAi: boolean - /** Floating video-call island visible (dismissable). */ - floatCall: boolean - - /** Set the active primary surface (drives the bottom sidebar island). */ - setActiveSurface: (surface: string) => void - /** Pin/unpin a surface to the top island's primary rows. */ - toggleNavPinned: (surface: string) => void - /** Set the sidebar island width (clamped 230–320). */ - setSidebarWidth: (width: number) => void - /** Collapse/expand the top (nav) sidebar island to its compact rail. */ - toggleSidebarCompact: () => void - /** Show/hide the floating Assistant island. */ - setFloatAi: (open: boolean) => void - /** Show/hide the floating video-call island. */ - setFloatCall: (open: boolean) => void - - // ─── Spaces ──────────────────────────────────────────────────── - setCurrentSpace: (spaceId: string | null) => void - setSpaceFilter: (ids: string[]) => void - setExplorerSort: (sort: ExplorerSort) => void - /** Set the primary scope and the multi-filter together, atomically. */ - applyScopeSelection: (scope: string | null, filter: string[]) => void - - // ─── Layout tree (0280) ──────────────────────────────────────── - /** Replace the tree with a built-in preset (and align the legacy axes). */ - applyPreset: (preset: PresetId) => void - /** Load a workspace payload (a `xnet:workspace` node) into the tree. */ - loadWorkspace: (payload: WorkspacePayload) => void - /** Move a view to another region (keeps its tier; ordered last). */ - moveSlot: (viewId: string, region: RegionId) => void - /** Insert a view at an index within a region (reorder or cross-move). */ - insertSlot: (viewId: string, region: RegionId, index: number) => void - /** Change a placed view's disclosure tier. */ - setSlotTier: (viewId: string, tier: SlotTier) => void - - // ─── Sidebar + focus (0284) ──────────────────────────────────── - /** Collapse/expand the sidebar to the icon rail. */ - toggleSidebar: () => void - setSidebarCollapsed: (collapsed: boolean) => void - /** Enter/exit focus mode (chrome hidden, surface owns the viewport). */ - toggleFocus: () => void - setFocus: (focus: boolean) => void - - // ─── Shell layout (0250) ─────────────────────────────────────── - setLayout: (layout: ShellLayout) => void - /** Flip between the calm shell and the workbench grid. */ - toggleLayout: () => void - /** Switch the calm shell's active primary mode. */ - setCalmMode: (mode: CalmMode) => void - /** Set the calm shell's chrome posture (0273). */ - setChrome: (chrome: ChromePosture) => void - /** Flip between pinned and quiet chrome (0273). */ - toggleChrome: () => void - /** Update the quiet shell's disclosure level (0273). */ - setDiscloseLevel: (level: DiscloseLevel) => void - /** Enter/exit arrange mode (0282). */ - setArranging: (arranging: boolean) => void - /** Open the contextual Canvas hosting a node's full content view. */ - openCanvas: (target: { nodeType: TabNodeType; nodeId: string; title?: string }) => void - /** Close the Canvas and clear its content target (back to the inspector). */ - closeCanvas: () => void - - // ─── Panels ──────────────────────────────────────────────────── - setPanelOpen: (side: PanelSide, open: boolean) => void - togglePanel: (side: PanelSide) => void - /** Open the panel showing the given view; collapse if already showing it */ - showPanelView: (side: PanelSide, viewId: string) => void - - // ─── Zen ─────────────────────────────────────────────────────── - toggleZen: () => void - - // ─── Tabs ────────────────────────────────────────────────────── - /** - * Open (or activate) a tab in a group. Preview tabs replace the - * group's existing preview tab; re-opening an existing tab without - * `preview` promotes it. - */ - openTab: (input: { - nodeId: string - nodeType: TabNodeType - title?: string - preview?: boolean - groupId?: string - background?: boolean - }) => void - activateTab: (tabId: string, groupId?: string) => void - /** Returns the tab to navigate to next (active tab of active group) */ - closeTab: (tabId: string, groupId?: string) => void - promoteTab: (tabId: string) => void - setTabPinned: (tabId: string, pinned: boolean) => void - setTabTitle: (nodeId: string, title: string) => void - moveTab: (tabId: string, groupId: string, toIndex: number) => void - /** Move/open a tab into the second group, creating it if needed */ - splitWith: (input: { nodeId: string; nodeType: TabNodeType; title?: string }) => void - closeGroup: (groupId: string) => void - focusGroup: (groupId: string) => void - /** Cycle active tab within the active group (Ctrl+Tab) */ - cycleTab: (delta: 1 | -1) => void - - // ─── Tabless mode (0353) ─────────────────────────────────────── - setTabsEnabled: (enabled: boolean) => void - /** Publish the current route's title (route-derived header chrome). */ - setRouteTitle: (pathname: string, title: string) => void - /** Record a visited route for the recent-two toggle. */ - pushRouteHistory: (pathname: string) => void - /** Show (or clear) the tabless split pane's node. */ - setSplitTarget: (target: { nodeId: string; nodeType: TabNodeType } | null) => void - /** Switch the unified sidebar's lens. */ - setActiveLens: (lensId: string) => void - /** Mute/unmute a row: badge and recency bump, together. */ - toggleRowMuted: (rowId: string) => void - /** Show/hide a section among the primary rows. */ - toggleSectionPinned: (sectionId: string) => void - /** Persist a user-chosen section order. */ - setSectionOrder: (order: string[]) => void - - // ─── Explorer pins & recents ─────────────────────────────────── - togglePinnedNode: (nodeId: string) => void - touchRecent: (entry: Omit) => void - toggleFolderExpanded: (folderId: string) => void - - // ─── Shelf ───────────────────────────────────────────────────── - shelfAdd: (entry: ShelfEntry) => void - shelfRemove: (nodeId: string) => void - shelfClear: () => void - - // ─── Desk pins (0273) ────────────────────────────────────────── - /** Queue a node for pinning onto the Desk (deduped by nodeId). */ - queueDeskPin: (entry: DeskPinEntry) => void - /** Remove drained pins from the queue. */ - clearDeskPins: (nodeIds: string[]) => void - - setStartupTab: (tab: { nodeType: TabNodeType; nodeId: string } | null) => void - - // ─── What's New ──────────────────────────────────────────────── - setLastSeenChangelogId: (id: string) => void - - // ─── Onboarding coachmarks (0206) ────────────────────────────── - /** Record a tip as dismissed so it never auto-shows again. */ - markTipSeen: (id: string) => void - /** Clear all dismissed tips so onboarding replays (Settings → Replay). */ - resetTips: () => void -} - -function freshGroups(): EditorGroup[] { - return [{ id: 'group-1', tabs: [], activeTabId: null }] -} - -/** - * The store patch for adopting a tree (0280): the legacy `layout`/`chrome` - * axes stay coherent, docks open where the tree pins a view, and each - * dock's active view snaps to its first placement so the panel hosts show - * what the tree says at rest. - */ -function stateForTree(tree: LayoutTree): Partial { - const patch: Partial = { - tree, - layout: tree.surface.tabsEnabled ? 'workbench' : 'calm', - chrome: tree.chrome, - discloseLevel: 0 - } - const docks: Array<[PanelSide, RegionId]> = [ - ['left', 'dock.left'], - ['right', 'dock.right'], - ['bottom', 'dock.bottom'] - ] - for (const [side, region] of docks) { - const placements = slotsIn(tree, region) - const pinned = slotsIn(tree, region, 'pinned') - const active = pinned[0] ?? placements[0] - patch[side] = { - open: pinned.length > 0, - activeViewId: active?.viewId ?? '' - } - } - return patch -} - -export const useWorkbench = create()( - persist( - (set, get) => ({ - // One coherent shell (0284): every identity lands in the single default - // tree — a sectioned sidebar that surfaces every tool, the full left - // dock, tabs on. The former quiet/calm/bench trichotomy is gone; "focus" - // (hide chrome) is a toggle, not a preset. - layout: 'workbench', - tree: createDefaultTree(), - calmMode: 'companion', - chrome: 'pinned', - discloseLevel: 0, - arranging: false, - canvasTarget: null, - mode: 'default', - zenSnapshot: null, - sidebarCollapsed: false, - focus: false, - activeSurface: 'explorer', - navPinned: ['explorer', 'requests', 'tasks'], - sidebarWidth: 264, - sidebarCompact: false, - // The Assistant starts minimized to its reopener pill on desktop — the - // full island is one click away but doesn't claim editor space at rest. - // The video-call island likewise stays off until a real calling backend - // is wired (0286) — no fabricated live-call state is shown at rest. - floatAi: false, - floatCall: false, - left: { open: true, activeViewId: 'explorer' }, - right: { open: false, activeViewId: 'context' }, - bottom: { open: false, activeViewId: 'tray' }, - groups: freshGroups(), - activeGroupId: 'group-1', - // 0353 Phase 5: tabless is the default — one surface, with the - // working set in the sidebar. The tab path stays reachable - // ("View: Turn on tabs") for one release so the dogfood tripwire - // in the exploration has something to fall back to. - tabsEnabled: false, - routeTitles: {}, - routeHistory: [], - splitTarget: null, - activeLensId: 'all', - mutedRowIds: [], - sectionOrder: DEFAULT_SECTIONS.map((section) => section.id), - pinnedSectionIds: [...DEFAULT_PINNED_SECTION_IDS], - pinnedNodeIds: [], - recents: [], - expandedFolderIds: [], - shelf: [], - deskPins: [], - startupTab: null, - currentSpaceId: null, - spaceFilter: [], - explorerSort: 'recent', - lastSeenChangelogId: null, - seenTips: [], - - // Setting a single scope always exits multi-select (keeps the create - // target unambiguous — exploration 0190). - setCurrentSpace: (spaceId) => set({ currentSpaceId: spaceId, spaceFilter: [] }), - setSpaceFilter: (ids) => set({ spaceFilter: ids }), - setExplorerSort: (sort) => set({ explorerSort: sort }), - applyScopeSelection: (scope, filter) => set({ currentSpaceId: scope, spaceFilter: filter }), - - applyPreset: (preset) => set(stateForTree(createPresetTree(preset))), - - loadWorkspace: (payload) => set(stateForTree(payload.tree)), - - moveSlot: (viewId, region) => - set((state) => { - const tree = moveSlotInTree(state.tree, viewId, region) - return tree === state.tree ? {} : { tree } - }), - - insertSlot: (viewId, region, index) => - set((state) => { - const tree = insertSlotInTree(state.tree, viewId, region, index) - return tree === state.tree ? {} : { tree } - }), - - setSlotTier: (viewId, tier) => - set((state) => { - const tree = setSlotTierInTree(state.tree, viewId, tier) - if (tree === state.tree) return {} - // Un-pinning the active view of a dock closes that dock at rest. - const patch: Partial = { tree } - for (const side of ['left', 'right', 'bottom'] as const) { - const panel = state[side] - if (panel.activeViewId === viewId && tier !== 'pinned' && panel.open) { - patch[side] = { ...panel, open: false } - } - } - return patch - }), - - // ─── Sidebar + focus (0284) ────────────────────────────────── - toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })), - setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }), - toggleFocus: () => set((state) => ({ focus: !state.focus })), - setFocus: (focus) => set({ focus }), - - // ─── Floating Islands shell (0286) ─────────────────────────── - setActiveSurface: (activeSurface) => set({ activeSurface }), - toggleNavPinned: (surface) => - set((state) => ({ - navPinned: state.navPinned.includes(surface) - ? state.navPinned.filter((id) => id !== surface) - : [...state.navPinned, surface] - })), - setSidebarWidth: (width) => set({ sidebarWidth: Math.max(230, Math.min(320, width)) }), - toggleSidebarCompact: () => set((state) => ({ sidebarCompact: !state.sidebarCompact })), - setFloatAi: (floatAi) => set({ floatAi }), - setFloatCall: (floatCall) => set({ floatCall }), - - // Switching layout is choosing a preset (0280): the legacy axes stay - // coherent with the tree so both renderers agree during the rollout. - setLayout: (layout) => - set((state) => - stateForTree( - createPresetTree( - layout === 'workbench' ? 'bench' : state.chrome === 'quiet' ? 'quiet' : 'calm' - ) - ) - ), - - toggleLayout: () => - set((state) => - stateForTree( - createPresetTree( - state.layout === 'calm' ? 'bench' : state.chrome === 'quiet' ? 'quiet' : 'calm' - ) - ) - ), - - setCalmMode: (calmMode) => set({ calmMode }), - - // Chrome is an orthogonal axis (0273): flipping it never resets a - // customized tree, it only changes the posture of the same placements. - setChrome: (chrome) => - set((state) => ({ chrome, discloseLevel: 0, tree: { ...state.tree, chrome } })), - - toggleChrome: () => - set((state) => { - const chrome = state.chrome === 'quiet' ? 'pinned' : 'quiet' - return { chrome, discloseLevel: 0, tree: { ...state.tree, chrome } } - }), - - setDiscloseLevel: (discloseLevel) => - set((state) => (state.discloseLevel === discloseLevel ? {} : { discloseLevel })), - - setArranging: (arranging) => set({ arranging }), - - openCanvas: (target) => - set((state) => ({ canvasTarget: target, right: { ...state.right, open: true } })), - - closeCanvas: () => - set((state) => ({ canvasTarget: null, right: { ...state.right, open: false } })), - - setPanelOpen: (side, open) => set((state) => ({ [side]: { ...state[side], open } })), - - togglePanel: (side) => - set((state) => ({ [side]: { ...state[side], open: !state[side].open } })), - - showPanelView: (side, viewId) => - set((state) => { - const panel = state[side] - if (panel.open && panel.activeViewId === viewId) { - return { [side]: { ...panel, open: false } } - } - return { [side]: { open: true, activeViewId: viewId } } - }), - - toggleZen: () => - set((state) => { - if (state.mode === 'zen') { - const snapshot = state.zenSnapshot - return { - mode: 'default' as const, - zenSnapshot: null, - ...(snapshot - ? { - left: { ...state.left, open: snapshot.left }, - right: { ...state.right, open: snapshot.right }, - bottom: { ...state.bottom, open: snapshot.bottom } - } - : {}) - } - } - return { - mode: 'zen' as const, - zenSnapshot: { - left: state.left.open, - right: state.right.open, - bottom: state.bottom.open - }, - left: { ...state.left, open: false }, - right: { ...state.right, open: false }, - bottom: { ...state.bottom, open: false } - } - }), - - openTab: ({ nodeId, nodeType, title, preview = false, groupId, background = false }) => - set((state) => { - const targetGroupId = groupId ?? state.activeGroupId - const tabId = tabIdFor(nodeType, nodeId) - - const groups = state.groups.map((group) => { - if (group.id !== targetGroupId) return group - - const existing = group.tabs.find((tab) => tab.id === tabId) - if (existing) { - const tabs = preview - ? group.tabs - : group.tabs.map((tab) => (tab.id === tabId ? { ...tab, preview: false } : tab)) - return { ...group, tabs, activeTabId: background ? group.activeTabId : tabId } - } - - const next = createTab({ nodeId, nodeType, title, preview }) - let tabs: WorkbenchTab[] - if (preview) { - const previewIndex = group.tabs.findIndex((tab) => tab.preview && !tab.pinned) - if (previewIndex >= 0) { - tabs = group.tabs.map((tab, index) => (index === previewIndex ? next : tab)) - } else { - tabs = [...group.tabs, next] - } - } else { - tabs = [...group.tabs, next] - } - return { ...group, tabs, activeTabId: background ? group.activeTabId : next.id } - }) - - return { - groups, - activeGroupId: background ? state.activeGroupId : targetGroupId - } - }), - - activateTab: (tabId, groupId) => - set((state) => { - const targetGroupId = - groupId ?? state.groups.find((g) => g.tabs.some((t) => t.id === tabId))?.id - if (!targetGroupId) return {} - return { - activeGroupId: targetGroupId, - groups: state.groups.map((group) => - group.id === targetGroupId && group.tabs.some((tab) => tab.id === tabId) - ? { ...group, activeTabId: tabId } - : group - ) - } - }), - - closeTab: (tabId, groupId) => - set((state) => { - const targetGroupId = - groupId ?? state.groups.find((g) => g.tabs.some((t) => t.id === tabId))?.id - if (!targetGroupId) return {} - - let groups = state.groups.map((group) => { - if (group.id !== targetGroupId) return group - const index = group.tabs.findIndex((tab) => tab.id === tabId) - if (index < 0) return group - const tabs = group.tabs.filter((tab) => tab.id !== tabId) - let activeTabId = group.activeTabId - if (group.activeTabId === tabId) { - const neighbor = tabs[Math.min(index, tabs.length - 1)] - activeTabId = neighbor ? neighbor.id : null - } - return { ...group, tabs, activeTabId } - }) - - // Drop an emptied second group; always keep at least one group. - let activeGroupId = state.activeGroupId - if (groups.length > 1) { - const empty = groups.filter((group) => group.tabs.length === 0) - if (empty.length > 0) { - groups = groups.filter((group) => group.tabs.length > 0) - if (groups.length === 0) groups = freshGroups() - if (!groups.some((group) => group.id === activeGroupId)) { - activeGroupId = groups[0].id - } - } - } - - return { groups, activeGroupId } - }), - - promoteTab: (tabId) => - set((state) => ({ - groups: state.groups.map((group) => ({ - ...group, - tabs: group.tabs.map((tab) => (tab.id === tabId ? { ...tab, preview: false } : tab)) - })) - })), - - setTabPinned: (tabId, pinned) => - set((state) => ({ - groups: state.groups.map((group) => ({ - ...group, - tabs: group.tabs.map((tab) => - tab.id === tabId ? { ...tab, pinned, preview: false } : tab - ) - })) - })), - - setTabTitle: (nodeId, title) => - set((state) => { - if (!title) return {} - let changed = false - const groups = state.groups.map((group) => { - const tabs = group.tabs.map((tab) => { - if (tab.nodeId !== nodeId || tab.title === title) return tab - changed = true - return { ...tab, title } - }) - return changed ? { ...group, tabs } : group - }) - const recents = state.recents.map((recent) => - recent.nodeId === nodeId && recent.title !== title ? { ...recent, title } : recent - ) - return changed ? { groups, recents } : { recents } - }), - - moveTab: (tabId, groupId, toIndex) => - set((state) => { - const fromGroup = state.groups.find((g) => g.tabs.some((t) => t.id === tabId)) - if (!fromGroup) return {} - const tab = fromGroup.tabs.find((t) => t.id === tabId) - if (!tab) return {} - - let groups = state.groups.map((group) => - group.id === fromGroup.id - ? { - ...group, - tabs: group.tabs.filter((t) => t.id !== tabId), - activeTabId: - group.activeTabId === tabId && group.id !== groupId - ? (group.tabs.filter((t) => t.id !== tabId)[0]?.id ?? null) - : group.activeTabId - } - : group - ) - - groups = groups.map((group) => { - if (group.id !== groupId) return group - const tabs = [...group.tabs] - const clamped = Math.max(0, Math.min(toIndex, tabs.length)) - tabs.splice(clamped, 0, tab) - return { ...group, tabs, activeTabId: tabId } - }) - - // Drop an emptied source group after a cross-group move. - if (groups.length > 1) { - groups = groups.filter((group) => group.tabs.length > 0 || group.id === groupId) - } - - return { - groups, - activeGroupId: groups.some((g) => g.id === groupId) ? groupId : state.activeGroupId - } - }), - - splitWith: ({ nodeId, nodeType, title }) => { - const state = get() - let second = state.groups[1] - if (!second) { - second = { id: 'group-2', tabs: [], activeTabId: null } - set({ groups: [...state.groups, second] }) - } - get().openTab({ nodeId, nodeType, title, groupId: second.id }) - }, - - closeGroup: (groupId) => - set((state) => { - if (state.groups.length <= 1) return {} - const groups = state.groups.filter((group) => group.id !== groupId) - return { - groups, - activeGroupId: state.activeGroupId === groupId ? groups[0].id : state.activeGroupId - } - }), - - focusGroup: (groupId) => - set((state) => - state.groups.some((group) => group.id === groupId) ? { activeGroupId: groupId } : {} - ), - - cycleTab: (delta) => - set((state) => { - const group = state.groups.find((g) => g.id === state.activeGroupId) - if (!group || group.tabs.length < 2 || !group.activeTabId) return {} - const index = group.tabs.findIndex((tab) => tab.id === group.activeTabId) - const next = group.tabs[(index + delta + group.tabs.length) % group.tabs.length] - return { - groups: state.groups.map((g) => - g.id === group.id ? { ...g, activeTabId: next.id } : g - ) - } - }), - - setTabsEnabled: (enabled) => set({ tabsEnabled: enabled }), - - setRouteTitle: (pathname, title) => - set((state) => - state.routeTitles[pathname] === title - ? {} - : { routeTitles: { ...state.routeTitles, [pathname]: title } } - ), - - // Only two entries are kept: the toggle is "the other one", not a - // history stack (the router owns real back/forward). - pushRouteHistory: (pathname) => - set((state) => - state.routeHistory[0] === pathname - ? {} - : { - routeHistory: [pathname, ...state.routeHistory.filter((p) => p !== pathname)].slice( - 0, - 2 - ) - } - ), - - setSplitTarget: (target) => set({ splitTarget: target }), - - setActiveLens: (lensId) => set({ activeLensId: lensId }), - - toggleRowMuted: (rowId) => - set((state) => ({ - mutedRowIds: state.mutedRowIds.includes(rowId) - ? state.mutedRowIds.filter((id) => id !== rowId) - : [...state.mutedRowIds, rowId] - })), - - toggleSectionPinned: (sectionId) => - set((state) => ({ - pinnedSectionIds: state.pinnedSectionIds.includes(sectionId) - ? state.pinnedSectionIds.filter((id) => id !== sectionId) - : [...state.pinnedSectionIds, sectionId] - })), - - setSectionOrder: (order) => set({ sectionOrder: order }), - - togglePinnedNode: (nodeId) => - set((state) => ({ - pinnedNodeIds: state.pinnedNodeIds.includes(nodeId) - ? state.pinnedNodeIds.filter((id) => id !== nodeId) - : [...state.pinnedNodeIds, nodeId] - })), - - touchRecent: (entry) => - set((state) => { - const rest = state.recents.filter((recent) => recent.nodeId !== entry.nodeId) - return { recents: [{ ...entry, at: Date.now() }, ...rest].slice(0, MAX_RECENTS) } - }), - - toggleFolderExpanded: (folderId) => - set((state) => ({ - expandedFolderIds: state.expandedFolderIds.includes(folderId) - ? state.expandedFolderIds.filter((id) => id !== folderId) - : [...state.expandedFolderIds, folderId] - })), - - shelfAdd: (entry) => - set((state) => ({ - shelf: [entry, ...state.shelf.filter((held) => held.nodeId !== entry.nodeId)] - })), - - shelfRemove: (nodeId) => - set((state) => ({ shelf: state.shelf.filter((held) => held.nodeId !== nodeId) })), - - shelfClear: () => set({ shelf: [] }), - - queueDeskPin: (entry) => - set((state) => ({ - deskPins: [...state.deskPins.filter((pin) => pin.nodeId !== entry.nodeId), entry] - })), - - clearDeskPins: (nodeIds) => - set((state) => ({ - deskPins: state.deskPins.filter((pin) => !nodeIds.includes(pin.nodeId)) - })), - - setStartupTab: (tab) => set({ startupTab: tab }), - - setLastSeenChangelogId: (id) => set({ lastSeenChangelogId: id }), - - markTipSeen: (id) => - set((state) => (state.seenTips.includes(id) ? {} : { seenTips: [...state.seenTips, id] })), - - resetTips: () => set({ seenTips: [] }) - }), - { - name: 'xnet:workbench:v1', - // v2 (0280): the layout tree joins the persisted state. Pre-tree - // profiles derive their tree from the legacy `layout`/`chrome` axes so - // panels, pins, shelf and startup node all survive the migration. - version: 5, - migrate: (persisted, version) => { - const state = persisted as Partial - // v5 (0353): tabless becomes the default, and the unified-nav - // state gets its defaults. Persisted `groups` are deliberately - // KEPT: turning tabs back on ("View: Turn on tabs") must restore - // the session it left, which is what makes the tabless rollout - // reversible rather than a one-way door. - if (version < 5) { - state.tabsEnabled = state.tabsEnabled === true - state.sectionOrder = state.sectionOrder ?? DEFAULT_SECTIONS.map((s) => s.id) - state.pinnedSectionIds = state.pinnedSectionIds ?? [...DEFAULT_PINNED_SECTION_IDS] - state.activeLensId = state.activeLensId ?? 'all' - state.mutedRowIds = state.mutedRowIds ?? [] - } - if (version < 2 && !state.tree) { - state.tree = createPresetTree( - state.layout === 'workbench' ? 'bench' : state.chrome === 'quiet' ? 'quiet' : 'calm' - ) - } - // v4 (0284): collapse the quiet/calm/bench trichotomy to one shell. - // Any profile still on a built-in preset tree (or with none) lands in - // the single default tree; a user's own saved/arranged workspace - // (a non-preset workspaceId) is preserved. The legacy axes are - // realigned so the (transitional) renderer fork stays coherent, and a - // quiet/zen posture maps onto the ephemeral `focus` toggle at rest - // rather than a persisted chrome mode. - if (version < 4) { - if (!state.tree || isPresetWorkspaceId(state.tree.workspaceId)) { - state.tree = createDefaultTree() - } - state.layout = state.tree.surface.tabsEnabled ? 'workbench' : 'calm' - state.chrome = 'pinned' - state.sidebarCollapsed = state.sidebarCollapsed ?? false - state.focus = false - } - // v3 (0280): drop tabs whose nodeType this build doesn't know — a - // profile shared with another branch/version must never crash the - // shell (the meetings-tab incident during 0280 validation). - if (version < 3 && Array.isArray(state.groups)) { - state.groups = state.groups.map((group) => { - const tabs = group.tabs.filter((tab) => - (TAB_NODE_TYPES as readonly string[]).includes(tab.nodeType) - ) - return { - ...group, - tabs, - activeTabId: tabs.some((tab) => tab.id === group.activeTabId) - ? group.activeTabId - : (tabs[0]?.id ?? null) - } - }) - } - return state as WorkbenchState - }, - // Disclosure level, arrange mode and focus are live interaction state - // (0273/0282/0284) — persisting them would resurrect a lit/overlaid, - // mid-edit, or chrome-hidden shell on reload. - partialize: (state) => - Object.fromEntries( - Object.entries(state).filter( - ([key]) => - key !== 'discloseLevel' && - key !== 'arranging' && - key !== 'focus' && - // Floating dock visibility is live session state (0286) — a - // reload always restores the default dock, never a stuck-closed one. - key !== 'floatAi' && - key !== 'floatCall' && - // Route titles/history are derived from what's mounted right - // now (0353); persisting them would resurrect stale chrome. - key !== 'routeTitles' && - key !== 'routeHistory' && - // The split is a transient layout, never a restored window. - key !== 'splitTarget' - ) - ) as WorkbenchState - } - ) -) - -/** The active tab of the active editor group, if any. */ -export function selectActiveTab(state: Pick) { - const group = state.groups.find((g) => g.id === state.activeGroupId) - return group?.tabs.find((tab) => tab.id === group.activeTabId) ?? null -} - -/** - * The route the recent-two toggle (Ctrl-Tab) should jump to: the - * previously visited route, or null when there's only one (0353). - */ -export function selectPreviousRoute(state: Pick): string | null { - return state.routeHistory[1] ?? null -} +export * from '@xnetjs/workbench' diff --git a/apps/web/src/workbench/surfaces.ts b/apps/web/src/workbench/surfaces.ts index 3b8fdcada..3b629a5fd 100644 --- a/apps/web/src/workbench/surfaces.ts +++ b/apps/web/src/workbench/surfaces.ts @@ -1,137 +1,5 @@ /** - * Surfaces — the primary destinations the Floating shell's sidebar curates - * (exploration 0286). - * - * A surface is either a **panel** (its registered slot view renders inside the - * contextual bottom sidebar island — Explorer, Tasks, Chats, Today, Data, AI) - * or a **route** (selecting it opens that route in the editor; the bottom - * island shows a small launcher). The top island's primary rows are the - * `navPinned` subset; the rest live in the "More" surfaces roll-out, where the - * user pins/unpins to curate what stays visible. + * Shim (0406): canonical module lives in @xnetjs/workbench — the 0280 + * layout-tree pattern. New code imports the package directly. */ -import { - BarChart3, - CheckSquare2, - Compass, - Contact, - Database, - Files, - Inbox, - MessageSquare, - Mic, - Sparkles, - Sunrise, - Wallet, - type LucideIcon -} from 'lucide-react' -import { useCallback } from 'react' -import { useNavigateTo, type NavTarget } from './platform' -import { useWorkbench } from './state' -import { setPreviewIntent, tabIdForRoute } from './tabs' - -export interface SurfaceDef { - /** Stable id — persisted in `activeSurface` / `navPinned`. */ - id: string - label: string - icon: LucideIcon - /** `panel` renders a slot view in the bottom island; `route` navigates. */ - kind: 'panel' | 'route' - /** Slot view id (panel surfaces). */ - viewId?: string - /** - * Route path. Required for route surfaces; a panel surface may also carry - * one, in which case activating it drives the bottom island *and* opens the - * route in the editor (Tasks → the task board). - */ - to?: string - /** Live count source for the trailing badge. */ - badge?: 'requests' - /** Emphasised (ink pill) rather than muted-mono count. */ - emphasis?: boolean -} - -/** Every surface, in roll-out order (pinned ones are the `navPinned` subset). */ -export const SURFACES: SurfaceDef[] = [ - { id: 'explorer', label: 'Explorer', icon: Files, kind: 'panel', viewId: 'explorer' }, - { - id: 'requests', - label: 'Inbox', - icon: Inbox, - kind: 'route', - to: '/requests', - badge: 'requests', - emphasis: true - }, - { id: 'tasks', label: 'Tasks', icon: CheckSquare2, kind: 'panel', viewId: 'tasks', to: '/tasks' }, - { id: 'chats', label: 'Chats', icon: MessageSquare, kind: 'panel', viewId: 'chats' }, - { id: 'today', label: 'Today', icon: Sunrise, kind: 'panel', viewId: 'today' }, - { id: 'data', label: 'Data', icon: Database, kind: 'panel', viewId: 'data' }, - { id: 'ai', label: 'AI', icon: Sparkles, kind: 'panel', viewId: 'ai-chat' }, - { id: 'crm', label: 'People', icon: Contact, kind: 'route', to: '/crm' }, - { id: 'discover', label: 'Discover', icon: Compass, kind: 'route', to: '/discover' }, - { id: 'meetings', label: 'Meetings', icon: Mic, kind: 'route', to: '/meetings' }, - { id: 'finance', label: 'Finance', icon: Wallet, kind: 'route', to: '/finance' }, - { id: 'analytics', label: 'Analytics', icon: BarChart3, kind: 'route', to: '/analytics' } -] - -const BY_ID = new Map(SURFACES.map((surface) => [surface.id, surface])) - -export function surfaceById(id: string): SurfaceDef | undefined { - return BY_ID.get(id) -} - -/** Resolve `navPinned` ids to defs, dropping any unknown (stale) ids. */ -export function pinnedSurfaces(navPinned: string[]): SurfaceDef[] { - return navPinned.map((id) => BY_ID.get(id)).filter((s): s is SurfaceDef => Boolean(s)) -} - -/** The default panel surface the contextual island falls back to. */ -export const DEFAULT_SURFACE = 'explorer' - -/** - * The tab a surface's route opens/promotes, or null for routeless panels and - * non-tab routes (Discover, Analytics). Lets a surface row promote its tab on - * double-click without knowing the node model. - */ -export function surfaceTabId(surface: SurfaceDef): string | null { - if (!surface.to) return null - return tabIdForRoute(surface.to) -} - -/** - * Activating a surface: a **panel** drives the contextual bottom island - * (`activeSurface`); a surface with a **route** opens it in the editor as a - * VS Code-style preview tab (0288) — a single click renders it italic and the - * next single-click open replaces it; double-clicking the row (or editing) - * promotes it. A panel surface that also carries a route (Tasks) does both. - * Pure so the decision is testable; the hook below wires the side-effecting - * deps. - */ -export function activateSurface( - surface: SurfaceDef, - deps: { navigate: (target: NavTarget) => void; setActiveSurface: (id: string) => void } -): void { - if (surface.kind === 'panel') deps.setActiveSurface(surface.id) - if (surface.to) { - // Only tab routes honour the preview latch; arming it for a non-tab route - // (Discover, Analytics, Inbox) would leave it set for the next navigation. - if (tabIdForRoute(surface.to)) setPreviewIntent() - deps.navigate({ kind: 'path', path: surface.to }) - } -} - -/** - * Shared by the sidebar primary rows and the surfaces roll-out so both roads - * agree. Returns the single-click handler; double-click promote lives in the - * render sites (they call {@link surfaceTabId} + the store's `promoteTab`). - */ -export function useSurfaceActivation(): (surface: SurfaceDef) => void { - const navigate = useNavigateTo() - const setActiveSurface = useWorkbench((state) => state.setActiveSurface) - return useCallback( - (surface: SurfaceDef) => { - activateSurface(surface, { navigate, setActiveSurface }) - }, - [navigate, setActiveSurface] - ) -} +export * from '@xnetjs/workbench' diff --git a/apps/web/src/workbench/tabs.ts b/apps/web/src/workbench/tabs.ts index d6768e684..3b629a5fd 100644 --- a/apps/web/src/workbench/tabs.ts +++ b/apps/web/src/workbench/tabs.ts @@ -1,213 +1,5 @@ /** - * Tab ↔ route ↔ view mapping (exploration 0166). - * - * Everything that opens in the editor area is a tab backed by a node - * (or a singleton surface like Tasks). The router stays authoritative: - * navigating to a route activates (or opens) its tab, so deep links, - * back/forward, and old bookmarks keep working. + * Shim (0406): canonical module lives in @xnetjs/workbench — the 0280 + * layout-tree pattern. New code imports the package directly. */ -import { - CheckSquare2, - Code2, - Contact, - Database, - FileText, - FlaskConical, - Frame, - Hash, - Layout, - LayoutDashboard, - MapPin, - MessageSquare, - Mic, - Network, - Settings, - Table2, - User, - Users, - Wallet, - type LucideIcon -} from 'lucide-react' -import { tabIdFor, useWorkbench, type TabNodeType } from './state' - -export interface TabViewEntry { - label: string - icon: LucideIcon - toRoute: (nodeId: string) => string - /** Singleton surfaces (tasks, data) have a fixed node id */ - singleton?: boolean -} - -export const TAB_VIEWS: Record = { - page: { label: 'Page', icon: FileText, toRoute: (id) => `/doc/${id}` }, - post: { label: 'Topic', icon: MessageSquare, toRoute: (id) => `/post/${id}` }, - database: { label: 'Database', icon: Database, toRoute: (id) => `/db/${id}` }, - canvas: { label: 'Canvas', icon: Layout, toRoute: (id) => `/canvas/${id}` }, - dashboard: { - label: 'Dashboard', - icon: LayoutDashboard, - toRoute: (id) => `/dashboard/${id}` - }, - map: { label: 'Map', icon: MapPin, toRoute: (id) => `/map/${id}` }, - savedview: { label: 'Saved view', icon: Table2, toRoute: (id) => `/view/${id}` }, - tasks: { label: 'Tasks', icon: CheckSquare2, toRoute: () => '/tasks', singleton: true }, - meetings: { label: 'Meetings', icon: Mic, toRoute: () => '/meetings', singleton: true }, - data: { label: 'Data', icon: Network, toRoute: () => '/data', singleton: true }, - experiments: { - label: 'Experiments', - icon: FlaskConical, - toRoute: () => '/experiments', - singleton: true - }, - crm: { label: 'CRM', icon: Contact, toRoute: () => '/crm', singleton: true }, - finance: { label: 'Finance', icon: Wallet, toRoute: () => '/finance', singleton: true }, - channel: { label: 'Channel', icon: MessageSquare, toRoute: (id) => `/channel/${id}` }, - tag: { label: 'Tag', icon: Hash, toRoute: (id) => `/tag/${id}` }, - person: { label: 'Person', icon: User, toRoute: (id) => `/person/${encodeURIComponent(id)}` }, - lab: { label: 'Lab', icon: Code2, toRoute: (id) => `/lab/${id}` }, - space: { label: 'Space', icon: Users, toRoute: (id) => `/space/${encodeURIComponent(id)}` }, - settings: { label: 'Settings', icon: Settings, toRoute: () => '/settings', singleton: true }, - // 0346: a node opened through an arbitrary registered view. - frame: { label: 'View', icon: Frame, toRoute: (id) => `/frame/${encodeURIComponent(id)}` } -} - -const ROUTE_PREFIXES: Array<{ prefix: string; nodeType: TabNodeType }> = [ - { prefix: '/doc/', nodeType: 'page' }, - { prefix: '/db/', nodeType: 'database' }, - { prefix: '/canvas/', nodeType: 'canvas' }, - { prefix: '/dashboard/', nodeType: 'dashboard' }, - { prefix: '/map/', nodeType: 'map' }, - { prefix: '/view/', nodeType: 'savedview' }, - { prefix: '/channel/', nodeType: 'channel' }, - { prefix: '/post/', nodeType: 'post' }, - { prefix: '/tag/', nodeType: 'tag' }, - { prefix: '/person/', nodeType: 'person' }, - { prefix: '/lab/', nodeType: 'lab' }, - { prefix: '/space/', nodeType: 'space' }, - { prefix: '/frame/', nodeType: 'frame' } -] - -export interface RouteTabDescriptor { - nodeType: TabNodeType - nodeId: string -} - -/** Map a pathname onto a tab descriptor; null for non-tab routes. */ -export function tabFromPathname(pathname: string): RouteTabDescriptor | null { - if (pathname === '/tasks') return { nodeType: 'tasks', nodeId: 'tasks' } - if (pathname === '/meetings') return { nodeType: 'meetings', nodeId: 'meetings' } - if (pathname === '/data') return { nodeType: 'data', nodeId: 'data' } - if (pathname === '/experiments') return { nodeType: 'experiments', nodeId: 'experiments' } - if (pathname === '/crm') return { nodeType: 'crm', nodeId: 'crm' } - if (pathname === '/finance') return { nodeType: 'finance', nodeId: 'finance' } - // Settings is a singleton tab; its `?section=` search param is ignored here so - // switching sections stays on the one tab (0288). - if (pathname === '/settings') return { nodeType: 'settings', nodeId: 'settings' } - - for (const { prefix, nodeType } of ROUTE_PREFIXES) { - if (pathname.startsWith(prefix)) { - const nodeId = decodeURIComponent(pathname.slice(prefix.length)) - if (nodeId) return { nodeType, nodeId } - } - } - - return null -} - -export function routeForTab(nodeType: TabNodeType, nodeId: string): string { - // Defensive: an unknown persisted nodeType routes home instead of crashing. - return TAB_VIEWS[nodeType]?.toRoute(nodeId) ?? '/' -} - -/** - * The tab id a pathname maps to, or null for non-tab routes — lets a - * click source that only knows a route (surface rows, menu links) resolve - * the tab to promote on double-click. - */ -export function tabIdForRoute(pathname: string): string | null { - const descriptor = tabFromPathname(pathname) - return descriptor ? tabIdFor(descriptor.nodeType, descriptor.nodeId) : null -} - -/** - * Preview intent — set by single-click sources (explorer, palette) - * just before they navigate, consumed by the route→tab sync. Deep - * links, back/forward and command navigation open permanent tabs. - */ -let previewIntent = false - -export function setPreviewIntent(): void { - previewIntent = true -} - -export function consumePreviewIntent(): boolean { - const value = previewIntent - previewIntent = false - return value -} - -/** - * Record a visited route in the working set (0353): recents + the - * recent-two history. Runs in BOTH modes — it is the tabless - * replacement for the recents feed that used to ride tab opening, and - * stays correct when tabs are on. - */ -export function trackRouteVisit(pathname: string): void { - const state = useWorkbench.getState() - state.pushRouteHistory(pathname) - - const descriptor = tabFromPathname(pathname) - if (!descriptor) return - state.touchRecent({ - nodeId: descriptor.nodeId, - nodeType: descriptor.nodeType, - title: state.routeTitles[pathname] ?? '' - }) -} - -/** - * Open-or-activate the tab matching a pathname (router → store). - * Returns silently for non-tab routes. No-op when tabless (0353) — - * `trackRouteVisit` carries the working set instead. - */ -export function syncRouteToTabs(pathname: string): void { - const state = useWorkbench.getState() - if (!state.tabsEnabled) { - consumePreviewIntent() - return - } - - const descriptor = tabFromPathname(pathname) - if (!descriptor) { - // Non-tab route: drop any pending preview intent so a source that armed it - // before navigating somewhere untabbed can't leak it onto the next open. - consumePreviewIntent() - return - } - - const tabId = tabIdFor(descriptor.nodeType, descriptor.nodeId) - const owner = state.groups.find((group) => group.tabs.some((tab) => tab.id === tabId)) - - if (owner) { - consumePreviewIntent() - state.activateTab(tabId, owner.id) - } else { - state.openTab({ - nodeId: descriptor.nodeId, - nodeType: descriptor.nodeType, - preview: consumePreviewIntent() - }) - } - - // Recents are also written by `trackRouteVisit` (0353) — `touchRecent` - // dedupes by node id, so the tab title simply refines the entry. - const tab = useWorkbench - .getState() - .groups.flatMap((group) => group.tabs) - .find((entry) => entry.id === tabId) - state.touchRecent({ - nodeId: descriptor.nodeId, - nodeType: descriptor.nodeType, - title: tab?.title ?? '' - }) -} +export * from '@xnetjs/workbench' diff --git a/apps/web/src/workbench/test-platform.ts b/apps/web/src/workbench/test-platform.ts new file mode 100644 index 000000000..3b629a5fd --- /dev/null +++ b/apps/web/src/workbench/test-platform.ts @@ -0,0 +1,5 @@ +/** + * Shim (0406): canonical module lives in @xnetjs/workbench — the 0280 + * layout-tree pattern. New code imports the package directly. + */ +export * from '@xnetjs/workbench' diff --git a/apps/web/src/workbench/views/explorer-sort.ts b/apps/web/src/workbench/views/explorer-sort.ts index 7dfce7f6f..3b629a5fd 100644 --- a/apps/web/src/workbench/views/explorer-sort.ts +++ b/apps/web/src/workbench/views/explorer-sort.ts @@ -1,43 +1,5 @@ /** - * Explorer list sorting (exploration 0190) — pure and unit-tested. - * - * Controls only the order of the flat list (Unfiled / Results). The folder - * tree keeps its own fractional `sortKey` order; this never touches it. Title - * sorting uses `localeCompare` (display order) — distinct from the code-unit - * `sortKey` collation invariant, which applies only to fractional sort keys. + * Shim (0406): canonical module lives in @xnetjs/workbench — the 0280 + * layout-tree pattern. New code imports the package directly. */ -export type ExplorerSort = 'recent' | 'created' | 'name' | 'type' - -export const EXPLORER_SORTS: Array<{ id: ExplorerSort; label: string }> = [ - { id: 'recent', label: 'Recent' }, - { id: 'created', label: 'Created' }, - { id: 'name', label: 'A–Z' }, - { id: 'type', label: 'Type' } -] - -interface SortableItem { - title: string - type: string - updatedAt: number - createdAt?: number -} - -const byRecency = (a: SortableItem, b: SortableItem) => b.updatedAt - a.updatedAt -const byCreation = (a: SortableItem, b: SortableItem) => (b.createdAt ?? 0) - (a.createdAt ?? 0) -const titleOf = (item: SortableItem) => (item.title || 'Untitled').toLowerCase() - -/** Return a new array ordered by the chosen sort (recency-tiebroken). */ -export function sortExplorerItems(items: T[], sort: ExplorerSort): T[] { - const copy = items.slice() - switch (sort) { - case 'created': - return copy.sort((a, b) => byCreation(a, b) || byRecency(a, b)) - case 'name': - return copy.sort((a, b) => titleOf(a).localeCompare(titleOf(b)) || byRecency(a, b)) - case 'type': - return copy.sort((a, b) => a.type.localeCompare(b.type) || byRecency(a, b)) - case 'recent': - default: - return copy.sort(byRecency) - } -} +export * from '@xnetjs/workbench' 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 d393c2fbd..a667afc07 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 @@ -538,7 +538,7 @@ export class ShellErrorBoundary extends React.Component { ### Phase 2 — extract the package -- [ ] Create `packages/workbench` (private, React peer dep) +- [x] 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 diff --git a/packages/workbench/package.json b/packages/workbench/package.json new file mode 100644 index 000000000..2bd52a329 --- /dev/null +++ b/packages/workbench/package.json @@ -0,0 +1,38 @@ +{ + "name": "@xnetjs/workbench", + "version": "0.0.1", + "description": "xNet workbench core — the host-agnostic shell state, tabs, navigation intent, and PlatformPort shared by the web and desktop surfaces (exploration 0406)", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "scripts": { + "build": "tsup src/index.ts --format esm --dts", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@xnetjs/plugins": "workspace:*", + "lucide-react": "^0.453.0", + "zustand": "^5.0.14" + }, + "peerDependencies": { + "react": "^18.0.0" + }, + "devDependencies": { + "@testing-library/react": "^16.0.0", + "@types/react": "^18.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tsup": "^8.0.0", + "typescript": "^5.4.0", + "vitest": "^4.0.0" + }, + "private": true +} diff --git a/packages/workbench/src/commands.ts b/packages/workbench/src/commands.ts new file mode 100644 index 000000000..af7330515 --- /dev/null +++ b/packages/workbench/src/commands.ts @@ -0,0 +1,190 @@ +/** + * Core workbench keyboard map (exploration 0166). + * + * Every shell action is a CommandRegistry command so the palette can + * list it with its chord. Cmd+K (palette) lives in GlobalSearch; + * Cmd+T / Cmd+W / Ctrl+Tab / Cmd+1/2 are registered by the editor + * area where tab context lives. + */ +import { getCommandRegistry } from '@xnetjs/plugins' +import { useEffect } from 'react' +import { useWorkbench } from './state' + +export function useWorkbenchCommands(): void { + useEffect(() => { + const registry = getCommandRegistry() + const wb = () => useWorkbench.getState() + + const disposables = [ + registry.register({ + id: 'workbench.toggleLeftPanel', + title: 'Toggle left panel', + key: 'Mod-B', + allowInInput: true, + run: () => wb().togglePanel('left') + }), + registry.register({ + id: 'workbench.toggleRightPanel', + title: 'Toggle right panel', + key: 'Mod-\\', + allowInInput: true, + run: () => wb().togglePanel('right') + }), + registry.register({ + id: 'workbench.toggleBottomPanel', + title: 'Toggle bottom panel', + key: 'Mod-J', + allowInInput: true, + run: () => wb().togglePanel('bottom') + }), + registry.register({ + id: 'workbench.focus', + title: 'View: Focus mode (hide chrome)', + key: 'Mod-.', + allowInInput: true, + run: () => wb().toggleFocus() + }), + registry.register({ + id: 'workbench.switchLayout', + title: 'View: Switch layout (Calm ↔ Workbench)', + run: () => wb().toggleLayout() + }), + // Tabless mode (0353): both directions are one ⌘K away, so the + // preference is reversible without hunting through settings. + registry.register({ + id: 'workbench.disableTabs', + title: 'View: Turn off tabs (single surface)', + when: () => useWorkbench.getState().tabsEnabled, + run: () => wb().setTabsEnabled(false) + }), + registry.register({ + id: 'workbench.enableTabs', + title: 'View: Turn on tabs', + when: () => !useWorkbench.getState().tabsEnabled, + run: () => wb().setTabsEnabled(true) + }), + // Quiet-surface posture (0273): both directions get explicit palette + // entries so either posture is one ⌘K away from the other. + registry.register({ + id: 'workbench.quietChrome', + title: 'View: Quiet chrome (surface first)', + when: () => useWorkbench.getState().chrome !== 'quiet', + run: () => wb().setChrome('quiet') + }), + registry.register({ + id: 'workbench.pinnedChrome', + title: 'View: Pinned chrome', + when: () => useWorkbench.getState().chrome !== 'pinned', + run: () => wb().setChrome('pinned') + }), + registry.register({ + id: 'workbench.showExplorer', + title: 'Show explorer', + run: () => wb().showPanelView('left', 'explorer') + }), + registry.register({ + id: 'workbench.showTasksPanel', + title: 'Show tasks panel', + run: () => wb().showPanelView('left', 'tasks') + }), + registry.register({ + id: 'workbench.showDataPanel', + title: 'Show data panel', + run: () => wb().showPanelView('left', 'data') + }), + registry.register({ + id: 'workbench.setStartupTab', + title: 'Use current tab at startup', + when: () => { + const state = useWorkbench.getState() + const group = state.groups.find((g) => g.id === state.activeGroupId) + return Boolean(group?.activeTabId) + }, + run: () => { + const state = wb() + const group = state.groups.find((g) => g.id === state.activeGroupId) + const tab = group?.tabs.find((t) => t.id === group.activeTabId) + if (tab) state.setStartupTab({ nodeType: tab.nodeType, nodeId: tab.nodeId }) + } + }), + registry.register({ + id: 'workbench.clearStartupTab', + title: 'Clear startup tab', + when: () => Boolean(useWorkbench.getState().startupTab), + run: () => wb().setStartupTab(null) + }) + ] + + return () => { + for (const disposable of disposables) disposable.dispose() + } + }, []) +} + +/** + * The pinned frame's Esc ladder (0280 phase 4, extending 0273): each Esc + * closes ONE open dock — bottom, then right, then left — walking the + * disclosure ladder down to the bare surface. Runs only when nothing + * closer to the keystroke claimed it (palette, dialogs, editors all + * preventDefault first) and never steals Esc from text inputs. + */ +export function useShellEscape(): void { + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return + const target = event.target instanceof HTMLElement ? event.target : null + if ( + target && + (target.closest('input, textarea, [contenteditable="true"]') || target.isContentEditable) + ) { + return + } + const state = useWorkbench.getState() + // Focus mode (0284): a single Esc restores the chrome. + if (state.focus) { + event.preventDefault() + state.setFocus(false) + return + } + // Tabless split (0353) is the newest surface, so it's the first rung + // down: Esc closes the second pane before it starts closing docks. + if (state.splitTarget) { + event.preventDefault() + state.setSplitTarget(null) + return + } + if (state.chrome === 'quiet' || state.mode === 'zen') return + const side = (['bottom', 'right', 'left'] as const).find((s) => state[s].open) + if (!side) return + event.preventDefault() + state.setPanelOpen(side, false) + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, []) +} + +/** Exit zen with Esc Esc (two presses within 500ms), preserving layout. */ +export function useZenEscape(): void { + const mode = useWorkbench((state) => state.mode) + + useEffect(() => { + if (mode !== 'zen') return + + let lastEscape = 0 + const handler = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + const now = Date.now() + if (now - lastEscape < 500) { + event.preventDefault() + useWorkbench.getState().toggleZen() + lastEscape = 0 + } else { + lastEscape = now + } + } + + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [mode]) +} diff --git a/packages/workbench/src/doc-id.ts b/packages/workbench/src/doc-id.ts new file mode 100644 index 000000000..cf5138ce6 --- /dev/null +++ b/packages/workbench/src/doc-id.ts @@ -0,0 +1,14 @@ +/** + * New-document identity (moved from apps/web/lib/doc-creation, 0406). + * + * The creatable set and id scheme are shell concerns — "New page" exists on + * every surface — while the routes and menu chrome those docs open through + * stay host-side. + */ + +/** Doc types the shell's New affordances can create. Every one is a TabNodeType. */ +export type CreatableDocType = 'page' | 'database' | 'canvas' | 'dashboard' | 'map' | 'lab' + +export function newDocId(): string { + return Math.random().toString(36).substring(2, 15) +} diff --git a/packages/workbench/src/focus.ts b/packages/workbench/src/focus.ts new file mode 100644 index 000000000..000903301 --- /dev/null +++ b/packages/workbench/src/focus.ts @@ -0,0 +1,101 @@ +/** + * Shell-level focus ring (exploration 0166). + * + * Regions carry data-wb-region attributes; F6 / Shift+F6 cycle focus + * through the visible ones (the VS Code model), and Escape inside a + * panel returns focus to the editor — the per-region "trap" exit. + * Every shell action stays reachable by keyboard alone. + */ +import { getCommandRegistry } from '@xnetjs/plugins' +import { useEffect } from 'react' +import { useWorkbench } from './state' + +export type WorkbenchRegion = 'left' | 'editor' | 'right' | 'bottom' + +const RING_ORDER: WorkbenchRegion[] = ['left', 'editor', 'right', 'bottom'] + +const FOCUSABLE = + 'input, textarea, select, button, a[href], [tabindex]:not([tabindex="-1"]), [contenteditable="true"]' + +export function focusRegion(region: WorkbenchRegion): void { + const root = document.querySelector(`[data-wb-region="${region}"]`) + if (!root) return + const target = root.querySelector(FOCUSABLE) ?? root + target.focus() +} + +function visibleRing(): WorkbenchRegion[] { + const state = useWorkbench.getState() + return RING_ORDER.filter((region) => { + if (region === 'editor') return true + return state[region].open + }) +} + +function currentRegion(): WorkbenchRegion | null { + const active = document.activeElement + if (!(active instanceof HTMLElement)) return null + const root = active.closest('[data-wb-region]') + return (root?.dataset.wbRegion as WorkbenchRegion | undefined) ?? null +} + +function cycleRegion(delta: 1 | -1): void { + const ring = visibleRing() + if (ring.length === 0) return + const current = currentRegion() + const index = current ? ring.indexOf(current) : -1 + const next = ring[(index + delta + ring.length) % ring.length] + focusRegion(next) +} + +function returnFocusToEditor(): void { + const region = currentRegion() + if (region !== null && region !== 'editor') focusRegion('editor') +} + +const EDITABLE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']) + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + return EDITABLE_TAGS.has(target.tagName) || target.isContentEditable +} + +export function useFocusRing(): void { + useEffect(() => { + const registry = getCommandRegistry() + const disposables = [ + registry.register({ + id: 'workbench.focusNextRegion', + title: 'Focus next region', + key: 'F6', + allowInInput: true, + run: () => cycleRegion(1) + }), + registry.register({ + id: 'workbench.focusPreviousRegion', + title: 'Focus previous region', + key: 'Shift-F6', + allowInInput: true, + run: () => cycleRegion(-1) + }), + registry.register({ + id: 'workbench.focusEditor', + title: 'Focus editor', + run: () => focusRegion('editor') + }) + ] + + // Escape inside a panel returns to the editor; inputs keep their + // own Escape semantics (blur, close menus) untouched. + const escapeHandler = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || isEditableTarget(event.target)) return + returnFocusToEditor() + } + + window.addEventListener('keydown', escapeHandler) + return () => { + for (const disposable of disposables) disposable.dispose() + window.removeEventListener('keydown', escapeHandler) + } + }, []) +} diff --git a/packages/workbench/src/index.ts b/packages/workbench/src/index.ts new file mode 100644 index 000000000..7b2b06de5 --- /dev/null +++ b/packages/workbench/src/index.ts @@ -0,0 +1,28 @@ +/** + * @xnetjs/workbench — the host-agnostic workbench core (exploration 0406). + * + * What lives here is everything the shell knows without asking its host: the + * workbench store, tab/preview grammar, navigation intent, surface registry, + * command wiring, and the PlatformPort the host implements. What does NOT live + * here: routes, URLs, React chrome, and anything that imports a host API — + * web's TanStack Router and desktop's ShellState both sit on the far side of + * the port. + * + * apps/web/src/workbench/* keeps shim files at the old paths (the 0280 + * layout-tree pattern) so consumers migrate opportunistically; both roads + * resolve to this one module instance. + */ + +export * from './doc-id' +export * from './layout-tree' +export * from './platform' +export * from './state' +export * from './tabs' +export * from './navigation' +export * from './surfaces' +export * from './commands' +export * from './focus' +export * from './route-title' +export * from './sidebar/sections' +export * from './views/explorer-sort' +export * from './test-platform' diff --git a/packages/workbench/src/layout-tree.ts b/packages/workbench/src/layout-tree.ts new file mode 100644 index 000000000..0ed9da37c --- /dev/null +++ b/packages/workbench/src/layout-tree.ts @@ -0,0 +1,33 @@ +/** + * 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 { + createDefaultTree, + createPresetTree, + DEFAULT_WORKSPACE_ID, + insertSlot, + moveSlot, + parseWorkspacePayload, + placementOf, + PRESET_IDS, + PRESET_WORKSPACE_ID_PREFIX, + isPresetWorkspaceId, + presetForWorkspaceId, + presetWorkspaceId, + REGION_IDS, + regionOf, + serializeWorkspacePayload, + setSlotTier, + slotsIn +} from '@xnetjs/plugins' +export type { + ChromePosture, + LayoutTree, + PresetId, + RegionId, + SlotPlacement, + SlotTier, + WorkspacePayload +} from '@xnetjs/plugins' diff --git a/packages/workbench/src/navigation.ts b/packages/workbench/src/navigation.ts new file mode 100644 index 000000000..fca28a1d9 --- /dev/null +++ b/packages/workbench/src/navigation.ts @@ -0,0 +1,72 @@ +/** + * Tab navigation helpers, expressed against the {@link PlatformPort} + * (exploration 0406) — the host decides whether "open this node" means a URL + * (web) or a shell-state transition (desktop). + * + * VS Code-style preview tabs (0284): opening a node from a single click is + * a *preview* by default — it renders italic and is replaced by the next + * single-click open. Editing it, or double-clicking its tab/source row, + * promotes it to a permanent tab. Centralizing the preview intent here means + * every single-click source (explorer, home list, chat links, person/space/ + * tag views, desk) gets the behavior without each remembering to opt in. + * Pass `{ preview: false }` for activation of an already-open tab, and note + * that deep links / back-forward / creation never route through here, so + * they stay permanent. + */ +import type { CreatableDocType } from './doc-id' +import type { NavigateOptions, NavTarget } from './platform' +import type { TabNodeType } from './state' +import { newDocId } from './doc-id' + +/** The port's navigate — what every helper here threads through. */ +export type PlatformNavigate = (target: NavTarget, options?: NavigateOptions) => void + +export function navigateToNode( + navigate: PlatformNavigate, + nodeType: TabNodeType, + nodeId: string, + opts: { preview?: boolean } = {} +): void { + // Preview intent is applied by the host (it owns the tab store's latch); + // the shell only states whether this open is a preview. + navigate({ + kind: 'node', + nodeType, + nodeId, + ...(opts.preview === false ? { preview: false } : {}) + }) +} + +/** + * Open a node through an arbitrary registered view (0346). + * `frameSpec` = `~`; one route covers every + * registry/plugin view. Tabless (0353) this is a plain route like any + * other — the `frame` TabNodeType survives only so the tab path (still + * reachable behind the preference) keeps working. + */ +export function navigateToFrame( + navigate: PlatformNavigate, + viewType: string, + nodeId: string, + opts: { preview?: boolean } = {} +): void { + navigateToNode(navigate, 'frame', `${viewType}~${nodeId}`, opts) +} + +/** Parse a frame tab's nodeId back into view type + target node. */ +export function parseFrameSpec(frameSpec: string): { viewType: string; nodeId: string } | null { + const idx = frameSpec.indexOf('~') + if (idx <= 0 || idx === frameSpec.length - 1) return null + return { viewType: frameSpec.slice(0, idx), nodeId: frameSpec.slice(idx + 1) } +} + +/** + * Generate an id for a new document and open its surface — the port-shaped + * sibling of `lib/doc-creation`'s `navigateToNewDoc`. Every creatable doc + * type is a {@link TabNodeType}, which the signature relies on; the old call + * sites cast the router's navigate through `NavigateLike`, a hole the + * compiler could not see into. + */ +export function navigateToNewDoc(navigate: PlatformNavigate, type: CreatableDocType): void { + navigate({ kind: 'node', nodeType: type, nodeId: newDocId(), preview: false }) +} diff --git a/packages/workbench/src/platform.ts b/packages/workbench/src/platform.ts new file mode 100644 index 000000000..bce58aa24 --- /dev/null +++ b/packages/workbench/src/platform.ts @@ -0,0 +1,134 @@ +/** + * 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 React, { createContext, createElement, 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 } + +/** Query-string-shaped state (`/tasks?task=…`, `/ai?q=…`). */ +export type SearchParams = Record + +export interface NavigateOptions { + /** Replace the current history entry instead of pushing (web); no-op elsewhere. */ + replace?: boolean + /** Search params for the destination. Hosts without URLs keep them in memory. */ + search?: SearchParams +} + +/** + * 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 + /** Search params for the destination (`/tasks?task=…`). */ + search?: SearchParams + children: ReactNode + className?: string + title?: string + onClick?: () => void + /** Rows are drag sources (the tasks panel drags onto the board). */ + draggable?: boolean + onDragStart?: (event: React.DragEvent) => void + 'data-testid'?: string +} + +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 + /** The current location's search params (`?section=…`). */ + useSearch(): Readonly> + /** 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 +} + +/** The current pathname, host-agnostic. */ +export function usePathname(): string { + return usePlatform().usePathname() +} + +/** The current search params, host-agnostic. */ +export function useSearch(): Readonly> { + return usePlatform().useSearch() +} + +/** + * The host's link component, resolved from context. `createElement` rather + * than JSX keeps this module a plain `.ts` file. + */ +export function PlatformLink(props: PlatformLinkProps): React.ReactElement { + return createElement(usePlatform().Link, props) +} diff --git a/apps/web/src/workbench/route-title.test.tsx b/packages/workbench/src/route-title.test.tsx similarity index 100% rename from apps/web/src/workbench/route-title.test.tsx rename to packages/workbench/src/route-title.test.tsx diff --git a/packages/workbench/src/route-title.ts b/packages/workbench/src/route-title.ts new file mode 100644 index 000000000..7d9db6fc8 --- /dev/null +++ b/packages/workbench/src/route-title.ts @@ -0,0 +1,52 @@ +/** + * Route-derived titles (exploration 0353). + * + * With tabs gone the header can't read `selectActiveTab` for "what am I + * looking at" — nothing owns that state any more. Views publish their + * title against the route instead, and the header reads it back. While + * tabs are still on the same call also writes the tab title, so a view + * has exactly ONE title call either way. + */ +import { useEffect } from 'react' +import { usePathname } from './platform' +import { useWorkbench } from './state' +import { tabFromPathname } from './tabs' + +/** + * Publish this view's title for the current route. Pass the node id the + * view renders so the title also reaches the tab store (tabs on) and + * the recents entry (both modes). + * + * `sourceId` is the id of the record the title came from. `useNode` keeps + * the previous node's data while the next one loads, so during a + * navigation `title` briefly belongs to the node we just left while + * `nodeId` is already the new one — publishing then puts the old title on + * the new route and mints a mis-titled recents entry. Passing the loaded + * record's own id lets us hold the publish until the data catches up. + */ +export function usePublishTitle( + nodeId: string, + title: string | null | undefined, + sourceId?: string | null +): void { + const pathname = usePathname() + + useEffect(() => { + if (!title) return + if (sourceId != null && sourceId !== nodeId) return + const state = useWorkbench.getState() + state.setRouteTitle(pathname, title) + // Tabs (while they exist) and recents both key off the node id. + state.setTabTitle(nodeId, title) + const descriptor = tabFromPathname(pathname) + if (descriptor) { + state.touchRecent({ nodeId: descriptor.nodeId, nodeType: descriptor.nodeType, title }) + } + }, [pathname, nodeId, title, sourceId]) +} + +/** The current route's published title, if a view has published one. */ +export function useRouteTitle(): string | null { + const pathname = usePathname() + return useWorkbench((state) => state.routeTitles[pathname] ?? null) +} diff --git a/packages/workbench/src/sidebar/sections.ts b/packages/workbench/src/sidebar/sections.ts new file mode 100644 index 000000000..a30b7414d --- /dev/null +++ b/packages/workbench/src/sidebar/sections.ts @@ -0,0 +1,230 @@ +/** + * User sections (exploration 0353) — the successor to `SURFACES` + + * `navPinned` + the "More" roll-out. + * + * The old model made every feature area mint a nav: twelve surfaces, + * each either a bespoke panel or a route, with their own switcher. That + * is the Microsoft Teams anti-pattern in miniature (a rail of apps, each + * with its own internal navigation). + * + * Here there is exactly one nav — the tree — and sections are just + * *entries the user curates* pointing at three things: + * + * - `lens` — a projection of the one tree (Docs, Chats, People, …) + * - `route` — a destination that isn't a node list (Inbox, Meetings…) + * - `node` — a pinned node (any row the user promoted) + * + * Per-user and reorderable, following Slack's custom-sections and + * Linear's personalized-sidebar precedent: each person chooses their + * own unification boundary rather than the app choosing for them. + */ +import { + BarChart3, + Bot, + CheckSquare2, + Compass, + Contact, + Database, + Files, + FlaskConical, + Import, + Inbox, + MessageSquare, + Mic, + Sparkles, + Wallet, + type LucideIcon +} from 'lucide-react' + +export type SidebarSectionKind = 'lens' | 'route' | 'node' + +export interface SidebarSection { + /** Stable id — persisted in the user's section order. */ + id: string + kind: SidebarSectionKind + label: string + /** Lens id, route path, or node id, per `kind`. */ + target: string + /** Live count source for the trailing badge. */ + badge?: 'requests' + /** Emphasised (ink pill) rather than muted-mono count. */ + emphasis?: boolean +} + +const ICONS: Record = { + 'lens:all': Files, + 'lens:docs': Files, + 'lens:chats': MessageSquare, + 'lens:people': Contact, + 'lens:views': Database, + 'route:/requests': Inbox, + 'route:/tasks': CheckSquare2, + 'route:/meetings': Mic, + 'route:/discover': Compass, + 'route:/finance': Wallet, + 'route:/analytics': BarChart3, + 'route:/ai': Bot, + 'route:/companion': Sparkles, + 'route:/experiments': FlaskConical, + 'route:/social-import': Import +} + +export function sectionIcon(section: SidebarSection): LucideIcon { + return ICONS[`${section.kind}:${section.target}`] ?? Files +} + +/** + * The default sections a new identity gets. Everything that used to be + * a bespoke panel is now a lens; everything that was a route surface is + * a route entry — one grammar, no panel/route fork. + */ +export const DEFAULT_SECTIONS: SidebarSection[] = [ + { id: 'all', kind: 'lens', label: 'All', target: 'all' }, + { id: 'docs', kind: 'lens', label: 'Docs', target: 'docs' }, + { id: 'chats', kind: 'lens', label: 'Chats', target: 'chats' }, + { + id: 'inbox', + kind: 'route', + label: 'Inbox', + target: '/requests', + badge: 'requests', + emphasis: true + }, + { id: 'tasks', kind: 'route', label: 'Tasks', target: '/tasks' }, + { id: 'people', kind: 'lens', label: 'People', target: 'people' }, + { id: 'views', kind: 'lens', label: 'Views', target: 'views' }, + // The BYO-model chat surface (0174/0192). It was a `panel` surface, a kind + // this list doesn't have, so 0353 dropped it and left it unreachable — + // restored as a route (0388). + { id: 'ai', kind: 'route', label: 'AI', target: '/ai' }, + { id: 'meetings', kind: 'route', label: 'Meetings', target: '/meetings' }, + { id: 'discover', kind: 'route', label: 'Discover', target: '/discover' }, + { id: 'finance', kind: 'route', label: 'Finance', target: '/finance' }, + { id: 'companion', kind: 'route', label: 'Companion', target: '/companion' }, + { id: 'experiments', kind: 'route', label: 'Experiments', target: '/experiments' }, + { id: 'social-import', kind: 'route', label: 'Import', target: '/social-import' }, + { id: 'analytics', kind: 'route', label: 'Analytics', target: '/analytics' } +] + +/** + * Whether a section is available in this build (exploration 0388). + * + * A nav row that always dead-ends is worse than a missing one: it teaches + * people the nav lies. Analytics renders "this surface is off by default" + * unless the telemetry dashboard is compiled in, so it is absent rather than + * dead when the flag is unset — the UI restatement of the CI-lane rule that a + * gate nobody can pass is worse than no gate. + */ +export function isSectionEnabled(section: SidebarSection): boolean { + if (section.id !== 'analytics') return true + const env = (import.meta as { env?: Record }).env + return env?.VITE_TELEMETRY_DASHBOARD === '1' || env?.VITE_TELEMETRY_DASHBOARD === 'true' +} + +/** Section ids shown as primary rows for a fresh identity. */ +export const DEFAULT_PINNED_SECTION_IDS = ['all', 'docs', 'chats', 'inbox'] + +/** + * Resolve a persisted order against the known defaults: unknown ids are + * dropped (a section removed by a later build must not crash the shell, + * the 0280 migration lesson) and new defaults append. + */ +export function resolveSections(order: string[]): SidebarSection[] { + const available = DEFAULT_SECTIONS.filter(isSectionEnabled) + const byId = new Map(available.map((section) => [section.id, section])) + const ordered = order + .map((id) => byId.get(id)) + .filter((section): section is SidebarSection => Boolean(section)) + const seen = new Set(ordered.map((section) => section.id)) + return [...ordered, ...available.filter((section) => !seen.has(section.id))] +} + +/** + * Where a section sends the main area (exploration 0388). + * + * The one rule this nav is held to: **every primary row changes the main + * area**. Lens sections resolve through the registered lens's `route`, so the + * destination lives with the lens rather than in a switch here. + */ +export function sectionDestination( + section: SidebarSection, + lensRoute: (lensId: string) => string | undefined +): string | undefined { + if (section.kind === 'lens') return lensRoute(section.target) + if (section.kind === 'route') return section.target + return undefined +} + +/** + * Whether a section is the one the user is currently looking at. + * + * Derived from the **route** first, so the sidebar and the main area can never + * disagree — the pre-0388 predicate compared only `activeLensId`, which left + * "Views" highlighted while the main area showed Meetings, and highlighted + * nothing at all when the active lens wasn't pinned. + * + * Lenses sharing a route (the three home lenses on `/`) additionally require + * the lens to match; a lens with its own route (`people` → `/crm`) is active + * on that route regardless, so a reload lands with the right row lit. + */ +export function isSectionActive({ + section, + pathname, + activeLensId, + lensRoute +}: { + section: SidebarSection + pathname: string + activeLensId: string + lensRoute: (lensId: string) => string | undefined +}): boolean { + const destination = sectionDestination(section, lensRoute) + if (!destination) return false + + const onRoute = + destination === '/' + ? pathname === '/' + : pathname === destination || pathname.startsWith(`${destination}/`) + if (!onRoute) return false + + if (section.kind !== 'lens') { + // A route section loses the highlight to a lens that owns this exact + // route (People owns /crm), so only one row is ever lit. + return !lensOwningRoute(destination, lensRoute) + } + + const owner = lensOwningRoute(destination, lensRoute) + return owner ? owner === section.target : section.target === activeLensId +} + +/** + * The lens that exclusively owns a route, if exactly one does. Shared routes + * (`/`) have no owner — there the active lens decides. + */ +function lensOwningRoute( + route: string, + lensRoute: (lensId: string) => string | undefined +): string | undefined { + const owners = LENS_SECTION_IDS.filter((id) => lensRoute(id) === route) + return owners.length === 1 ? owners[0] : undefined +} + +/** Lens ids that ship as sections — the candidates for route ownership. */ +const LENS_SECTION_IDS = DEFAULT_SECTIONS.filter((section) => section.kind === 'lens').map( + (section) => section.target +) + +/** + * The lens a route restores on load, when the route belongs to exactly one + * lens. `/crm` → `people`, `/data` → `views`; `/` keeps whatever lens the user + * last chose. + */ +export function lensForRoute( + pathname: string, + lensRoute: (lensId: string) => string | undefined +): string | undefined { + return LENS_SECTION_IDS.find((id) => { + const route = lensRoute(id) + return route && route !== '/' && (pathname === route || pathname.startsWith(`${route}/`)) + }) +} diff --git a/apps/web/src/workbench/state.test.ts b/packages/workbench/src/state.test.ts similarity index 100% rename from apps/web/src/workbench/state.test.ts rename to packages/workbench/src/state.test.ts diff --git a/packages/workbench/src/state.ts b/packages/workbench/src/state.ts new file mode 100644 index 000000000..394a41ec1 --- /dev/null +++ b/packages/workbench/src/state.ts @@ -0,0 +1,1100 @@ +/** + * Workbench shell state (exploration 0166). + * + * One persisted zustand store holds the layout state machine + * (default / working / zen), the three collapsible panels, the editor + * groups with their tabs, explorer pins, and recents. Panel *sizes* are + * persisted separately by react-resizable-panels' useDefaultLayout. + * + * The router stays authoritative for navigation: components navigate, + * and the route effect in EditorArea reconciles the tab store against + * the URL. Store actions never call the router. + */ +import type { ExplorerSort } from './views/explorer-sort' +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import { + createDefaultTree, + createPresetTree, + insertSlot as insertSlotInTree, + isPresetWorkspaceId, + moveSlot as moveSlotInTree, + setSlotTier as setSlotTierInTree, + slotsIn, + type LayoutTree, + type PresetId, + type RegionId, + type SlotTier, + type WorkspacePayload, + ChromePosture +} from './layout-tree' +import { DEFAULT_PINNED_SECTION_IDS, DEFAULT_SECTIONS } from './sidebar/sections' + +export type WorkbenchMode = 'default' | 'zen' +export type PanelSide = 'left' | 'right' | 'bottom' + +/** + * Shell composition (exploration 0250). `workbench` is the original VS-Code + * multi-pane grid (0166); `calm` is the Claude-desktop "mode · list · surface · + * contextual canvas" shell. Both reuse the same views, routes and panel state — + * only the arrangement differs — so the choice is a single persisted flag. + */ +export type ShellLayout = 'workbench' | 'calm' + +/** + * The three primary modes of the calm shell — xNet's analog of Claude + * desktop's Chat / Cowork / Code. Companion = talk to your agent; Workspace = + * your pages/databases/canvases/tasks; Network = people, channels, discover. + */ +export type CalmMode = 'companion' | 'workspace' | 'network' + +/** + * Chrome posture of the calm shell (exploration 0273). `pinned` is the 0250 + * composition — ModeSwitch and List always on screen. `quiet` inverts it: the + * surface owns the whole viewport at rest, and the same chrome is summoned + * from corner glyphs, edge hot-zones/swipes, chords, or ⌘K. An orthogonal + * axis (like density vs color), not a third shell. + */ +// ChromePosture is canonical in @xnetjs/plugins (via ./layout-tree) — the +// local twin this file carried was the 0277 copied-type drift in miniature. +export type { ChromePosture } from './layout-tree' + +/** + * The quiet shell's disclosure ladder (0273): 0 = bare surface (glyphs + * dimmed), 1 = affordances lit by pointer/touch intent, 2 = one overlay open. + * Level 3 (pinned chrome / workbench) is represented by `chrome`/`layout`, + * not here. Ephemeral — deliberately excluded from persistence. + */ +export type DiscloseLevel = 0 | 1 | 2 + +/** Runtime list backing {@link TabNodeType} (migration filters against it). */ +export const TAB_NODE_TYPES = [ + 'page', + 'post', + 'database', + 'canvas', + 'dashboard', + 'map', + 'savedview', + 'tasks', + 'meetings', + 'data', + 'experiments', + 'crm', + 'finance', + 'channel', + 'tag', + 'person', + 'lab', + 'space', + 'settings', + // Frame tabs (0346): nodeId is `~` — ONE tab type + // covers every registry/plugin view, so new views need no app edits. + 'frame' +] as const + +export type TabNodeType = (typeof TAB_NODE_TYPES)[number] + +export interface WorkbenchTab { + /** `${nodeType}:${nodeId}` — stable across sessions */ + id: string + nodeId: string + nodeType: TabNodeType + /** Last known title (refreshed when the view loads) */ + title: string + pinned: boolean + /** Single-click preview tab; editing or double-click promotes */ + preview: boolean +} + +export interface EditorGroup { + id: string + tabs: WorkbenchTab[] + activeTabId: string | null +} + +export interface PanelState { + open: boolean + activeViewId: string +} + +interface ZenSnapshot { + left: boolean + right: boolean + bottom: boolean +} + +export interface RecentEntry { + nodeId: string + nodeType: TabNodeType + title: string + at: number +} + +export interface ShelfEntry { + nodeId: string + nodeType: string + title?: string + schemaId?: string +} + +/** + * A queued "Pin to Desk" (exploration 0273). Pins are queued here (persisted) + * and drained onto the Desk canvas through the normal ingestion path the next + * time it is on screen — no surface ever has to load the Desk's Y.Doc. + */ +export interface DeskPinEntry { + nodeId: string + schemaId: string + title: string +} + +const MAX_RECENTS = 30 + +export function tabIdFor(nodeType: TabNodeType, nodeId: string): string { + return `${nodeType}:${nodeId}` +} + +function createTab(input: { + nodeId: string + nodeType: TabNodeType + title?: string + preview?: boolean +}): WorkbenchTab { + return { + id: tabIdFor(input.nodeType, input.nodeId), + nodeId: input.nodeId, + nodeType: input.nodeType, + title: input.title ?? '', + pinned: false, + preview: input.preview ?? false + } +} + +interface WorkbenchState { + /** + * Active shell composition (0250). Defaults to `calm` for new identities; + * the original `workbench` grid stays one toggle away. Reuses the same + * `left`/`right` panels (as the calm List/Canvas) and `mode` (as focus). + */ + layout: ShellLayout + /** + * The layout tree (exploration 0280): regions → slots → views, the + * single data model behind every shell posture. The former shells are + * presets over this tree; behind `xnet:experiment:layout-tree` the + * ShellFrame renders it directly, and the legacy axes (`layout`, + * `chrome`) are kept coherent with it during the transition. + */ + tree: LayoutTree + /** Active primary mode of the calm shell (0250). */ + calmMode: CalmMode + /** + * Chrome posture of the calm shell (0273). Persisted so an opted-in quiet + * posture survives reloads; existing users keep their stored `pinned`. + */ + chrome: ChromePosture + /** Quiet shell disclosure level (0273). Ephemeral, not persisted. */ + discloseLevel: DiscloseLevel + /** + * Arrange mode (0282): the shell renders as an editable schematic of + * its own layout tree. Ephemeral — never persisted (same rule as + * `discloseLevel`); reload always lands on the live shell. + */ + arranging: boolean + /** + * Contextual-canvas target (0250). When set, the calm shell's right Canvas + * hosts the full content view for this node (the Claude "artifact opens on + * the right" move — e.g. the agent drafts a page). When null the Canvas falls + * back to the inspector (properties/comments/backlinks) for the active view. + */ + canvasTarget: { nodeType: TabNodeType; nodeId: string; title?: string } | null + mode: WorkbenchMode + zenSnapshot: ZenSnapshot | null + left: PanelState + right: PanelState + bottom: PanelState + groups: EditorGroup[] + activeGroupId: string + /** + * Tabless mode (0353). When false the editor renders the router outlet + * directly — no strip, no groups, no preview/promote — and the working + * set lives in the sidebar's Pinned + Recents sections instead. The + * router stays authoritative either way, so navigation is unaffected. + */ + tabsEnabled: boolean + /** + * Route → title, published by the views themselves (0353). Replaces + * `setTabTitle` as the header's title source when tabless; the tab + * store mirrors it while tabs are on. + */ + routeTitles: Record + /** + * The last two visited routes, newest first (0353). Backs the + * "recent two" Ctrl-Tab toggle — the tabless replacement for + * cycling tabs. + */ + routeHistory: string[] + /** + * The node shown in the tabless split pane, or null (0353). A layout + * concern, not a window: closing it orphans nothing, and durable + * side-by-side belongs on a page as frames (0346). + */ + splitTarget: { nodeId: string; nodeType: TabNodeType } | null + /** + * The unified sidebar's active lens (0353) — the projection of the one + * tree currently on screen ('all' | 'docs' | 'chats' | …). + */ + activeLensId: string + /** + * Muted sidebar rows (0353). ONE flag suppressing badge *and* unread + * bump: shipping those independently is a recurring bug class. + */ + mutedRowIds: string[] + /** + * The user's section order (0353) — the successor to `navPinned` + + * the "More" roll-out. Per-user, reorderable; unknown ids resolve + * away rather than crashing a shell shared across builds. + */ + sectionOrder: string[] + /** Section ids shown as primary rows; the rest live behind "More". */ + pinnedSectionIds: string[] + pinnedNodeIds: string[] + recents: RecentEntry[] + /** Expanded folders in the Explorer tree (exploration 0169) */ + expandedFolderIds: string[] + /** Muse-style shelf: nodes held in transit between contexts */ + shelf: ShelfEntry[] + /** Queued Desk pins, drained by the Desk canvas when visible (0273). */ + deskPins: DeskPinEntry[] + /** Tab opened when the workspace starts at '/' (configurable) */ + startupTab: { nodeType: TabNodeType; nodeId: string } | null + /** + * Active Space scope (exploration 0181). When set, the Explorer and new-doc + * filing are scoped to this Space. `null` = All (the global, pre-Spaces view). + */ + currentSpaceId: string | null + /** + * Multi-select view filter (exploration 0190). Empty = follow + * `currentSpaceId`. When non-empty the Explorer list shows the union of these + * Spaces, while the create target stays the single `currentSpaceId` primary. + */ + spaceFilter: string[] + /** Sort order for the flat Explorer list (exploration 0190). */ + explorerSort: ExplorerSort + /** + * Newest changelog entry id the user has acknowledged (in-app What's New, + * exploration 0195). `null` = never seen; seeded to the latest on first run + * so existing users don't get a wall of history. + */ + lastSeenChangelogId: string | null + /** + * Coachmark tip ids the user has dismissed (first-run onboarding, + * exploration 0206). Empty = nothing seen yet. Versioned ids + * (`crm:overview@1`) let a copy rewrite re-surface a tip once. + */ + seenTips: string[] + /** + * Sidebar collapsed to the icon rail (exploration 0284). Persisted so the + * user's chosen width survives reloads (the Notion/Linear pattern). + */ + sidebarCollapsed: boolean + /** + * Focus mode (0284): hide the sidebar, docks and status bar so the surface + * owns the viewport. One boolean replaces the former zen `mode`, the quiet + * `chrome` posture, and the `discloseLevel` ladder. Ephemeral — never + * persisted, so a reload always returns to full chrome. + */ + focus: boolean + + // ─── Floating Islands shell (0286) ───────────────────────────── + /** + * The primary surface the contextual (bottom) sidebar island shows in the + * Floating shell — a view id (`explorer`, `tasks`, …) or a route surface + * id. Persisted so the user returns to the surface they left on. + */ + activeSurface: string + /** + * Surfaces pinned to the top sidebar island's primary rows. The rest live + * in the "More" surfaces roll-out. Persisted (the user's curation). + */ + navPinned: string[] + /** Sidebar island width in px (tweakable knob, 230–320; default 264). */ + sidebarWidth: number + /** + * The top (nav/header) sidebar island is collapsed to its compact rail + * (0287). Only the header island's height changes — the Explorer island + * below (flex-1) grows to fill. Persisted (the user's chosen density). + */ + sidebarCompact: boolean + /** Floating Assistant chat island visible (dismissable). */ + floatAi: boolean + /** Floating video-call island visible (dismissable). */ + floatCall: boolean + + /** Set the active primary surface (drives the bottom sidebar island). */ + setActiveSurface: (surface: string) => void + /** Pin/unpin a surface to the top island's primary rows. */ + toggleNavPinned: (surface: string) => void + /** Set the sidebar island width (clamped 230–320). */ + setSidebarWidth: (width: number) => void + /** Collapse/expand the top (nav) sidebar island to its compact rail. */ + toggleSidebarCompact: () => void + /** Show/hide the floating Assistant island. */ + setFloatAi: (open: boolean) => void + /** Show/hide the floating video-call island. */ + setFloatCall: (open: boolean) => void + + // ─── Spaces ──────────────────────────────────────────────────── + setCurrentSpace: (spaceId: string | null) => void + setSpaceFilter: (ids: string[]) => void + setExplorerSort: (sort: ExplorerSort) => void + /** Set the primary scope and the multi-filter together, atomically. */ + applyScopeSelection: (scope: string | null, filter: string[]) => void + + // ─── Layout tree (0280) ──────────────────────────────────────── + /** Replace the tree with a built-in preset (and align the legacy axes). */ + applyPreset: (preset: PresetId) => void + /** Load a workspace payload (a `xnet:workspace` node) into the tree. */ + loadWorkspace: (payload: WorkspacePayload) => void + /** Move a view to another region (keeps its tier; ordered last). */ + moveSlot: (viewId: string, region: RegionId) => void + /** Insert a view at an index within a region (reorder or cross-move). */ + insertSlot: (viewId: string, region: RegionId, index: number) => void + /** Change a placed view's disclosure tier. */ + setSlotTier: (viewId: string, tier: SlotTier) => void + + // ─── Sidebar + focus (0284) ──────────────────────────────────── + /** Collapse/expand the sidebar to the icon rail. */ + toggleSidebar: () => void + setSidebarCollapsed: (collapsed: boolean) => void + /** Enter/exit focus mode (chrome hidden, surface owns the viewport). */ + toggleFocus: () => void + setFocus: (focus: boolean) => void + + // ─── Shell layout (0250) ─────────────────────────────────────── + setLayout: (layout: ShellLayout) => void + /** Flip between the calm shell and the workbench grid. */ + toggleLayout: () => void + /** Switch the calm shell's active primary mode. */ + setCalmMode: (mode: CalmMode) => void + /** Set the calm shell's chrome posture (0273). */ + setChrome: (chrome: ChromePosture) => void + /** Flip between pinned and quiet chrome (0273). */ + toggleChrome: () => void + /** Update the quiet shell's disclosure level (0273). */ + setDiscloseLevel: (level: DiscloseLevel) => void + /** Enter/exit arrange mode (0282). */ + setArranging: (arranging: boolean) => void + /** Open the contextual Canvas hosting a node's full content view. */ + openCanvas: (target: { nodeType: TabNodeType; nodeId: string; title?: string }) => void + /** Close the Canvas and clear its content target (back to the inspector). */ + closeCanvas: () => void + + // ─── Panels ──────────────────────────────────────────────────── + setPanelOpen: (side: PanelSide, open: boolean) => void + togglePanel: (side: PanelSide) => void + /** Open the panel showing the given view; collapse if already showing it */ + showPanelView: (side: PanelSide, viewId: string) => void + + // ─── Zen ─────────────────────────────────────────────────────── + toggleZen: () => void + + // ─── Tabs ────────────────────────────────────────────────────── + /** + * Open (or activate) a tab in a group. Preview tabs replace the + * group's existing preview tab; re-opening an existing tab without + * `preview` promotes it. + */ + openTab: (input: { + nodeId: string + nodeType: TabNodeType + title?: string + preview?: boolean + groupId?: string + background?: boolean + }) => void + activateTab: (tabId: string, groupId?: string) => void + /** Returns the tab to navigate to next (active tab of active group) */ + closeTab: (tabId: string, groupId?: string) => void + promoteTab: (tabId: string) => void + setTabPinned: (tabId: string, pinned: boolean) => void + setTabTitle: (nodeId: string, title: string) => void + moveTab: (tabId: string, groupId: string, toIndex: number) => void + /** Move/open a tab into the second group, creating it if needed */ + splitWith: (input: { nodeId: string; nodeType: TabNodeType; title?: string }) => void + closeGroup: (groupId: string) => void + focusGroup: (groupId: string) => void + /** Cycle active tab within the active group (Ctrl+Tab) */ + cycleTab: (delta: 1 | -1) => void + + // ─── Tabless mode (0353) ─────────────────────────────────────── + setTabsEnabled: (enabled: boolean) => void + /** Publish the current route's title (route-derived header chrome). */ + setRouteTitle: (pathname: string, title: string) => void + /** Record a visited route for the recent-two toggle. */ + pushRouteHistory: (pathname: string) => void + /** Show (or clear) the tabless split pane's node. */ + setSplitTarget: (target: { nodeId: string; nodeType: TabNodeType } | null) => void + /** Switch the unified sidebar's lens. */ + setActiveLens: (lensId: string) => void + /** Mute/unmute a row: badge and recency bump, together. */ + toggleRowMuted: (rowId: string) => void + /** Show/hide a section among the primary rows. */ + toggleSectionPinned: (sectionId: string) => void + /** Persist a user-chosen section order. */ + setSectionOrder: (order: string[]) => void + + // ─── Explorer pins & recents ─────────────────────────────────── + togglePinnedNode: (nodeId: string) => void + touchRecent: (entry: Omit) => void + toggleFolderExpanded: (folderId: string) => void + + // ─── Shelf ───────────────────────────────────────────────────── + shelfAdd: (entry: ShelfEntry) => void + shelfRemove: (nodeId: string) => void + shelfClear: () => void + + // ─── Desk pins (0273) ────────────────────────────────────────── + /** Queue a node for pinning onto the Desk (deduped by nodeId). */ + queueDeskPin: (entry: DeskPinEntry) => void + /** Remove drained pins from the queue. */ + clearDeskPins: (nodeIds: string[]) => void + + setStartupTab: (tab: { nodeType: TabNodeType; nodeId: string } | null) => void + + // ─── What's New ──────────────────────────────────────────────── + setLastSeenChangelogId: (id: string) => void + + // ─── Onboarding coachmarks (0206) ────────────────────────────── + /** Record a tip as dismissed so it never auto-shows again. */ + markTipSeen: (id: string) => void + /** Clear all dismissed tips so onboarding replays (Settings → Replay). */ + resetTips: () => void +} + +function freshGroups(): EditorGroup[] { + return [{ id: 'group-1', tabs: [], activeTabId: null }] +} + +/** + * The store patch for adopting a tree (0280): the legacy `layout`/`chrome` + * axes stay coherent, docks open where the tree pins a view, and each + * dock's active view snaps to its first placement so the panel hosts show + * what the tree says at rest. + */ +function stateForTree(tree: LayoutTree): Partial { + const patch: Partial = { + tree, + layout: tree.surface.tabsEnabled ? 'workbench' : 'calm', + chrome: tree.chrome, + discloseLevel: 0 + } + const docks: Array<[PanelSide, RegionId]> = [ + ['left', 'dock.left'], + ['right', 'dock.right'], + ['bottom', 'dock.bottom'] + ] + for (const [side, region] of docks) { + const placements = slotsIn(tree, region) + const pinned = slotsIn(tree, region, 'pinned') + const active = pinned[0] ?? placements[0] + patch[side] = { + open: pinned.length > 0, + activeViewId: active?.viewId ?? '' + } + } + return patch +} + +export const useWorkbench = create()( + persist( + (set, get) => ({ + // One coherent shell (0284): every identity lands in the single default + // tree — a sectioned sidebar that surfaces every tool, the full left + // dock, tabs on. The former quiet/calm/bench trichotomy is gone; "focus" + // (hide chrome) is a toggle, not a preset. + layout: 'workbench', + tree: createDefaultTree(), + calmMode: 'companion', + chrome: 'pinned', + discloseLevel: 0, + arranging: false, + canvasTarget: null, + mode: 'default', + zenSnapshot: null, + sidebarCollapsed: false, + focus: false, + activeSurface: 'explorer', + navPinned: ['explorer', 'requests', 'tasks'], + sidebarWidth: 264, + sidebarCompact: false, + // The Assistant starts minimized to its reopener pill on desktop — the + // full island is one click away but doesn't claim editor space at rest. + // The video-call island likewise stays off until a real calling backend + // is wired (0286) — no fabricated live-call state is shown at rest. + floatAi: false, + floatCall: false, + left: { open: true, activeViewId: 'explorer' }, + right: { open: false, activeViewId: 'context' }, + bottom: { open: false, activeViewId: 'tray' }, + groups: freshGroups(), + activeGroupId: 'group-1', + // 0353 Phase 5: tabless is the default — one surface, with the + // working set in the sidebar. The tab path stays reachable + // ("View: Turn on tabs") for one release so the dogfood tripwire + // in the exploration has something to fall back to. + tabsEnabled: false, + routeTitles: {}, + routeHistory: [], + splitTarget: null, + activeLensId: 'all', + mutedRowIds: [], + sectionOrder: DEFAULT_SECTIONS.map((section) => section.id), + pinnedSectionIds: [...DEFAULT_PINNED_SECTION_IDS], + pinnedNodeIds: [], + recents: [], + expandedFolderIds: [], + shelf: [], + deskPins: [], + startupTab: null, + currentSpaceId: null, + spaceFilter: [], + explorerSort: 'recent', + lastSeenChangelogId: null, + seenTips: [], + + // Setting a single scope always exits multi-select (keeps the create + // target unambiguous — exploration 0190). + setCurrentSpace: (spaceId) => set({ currentSpaceId: spaceId, spaceFilter: [] }), + setSpaceFilter: (ids) => set({ spaceFilter: ids }), + setExplorerSort: (sort) => set({ explorerSort: sort }), + applyScopeSelection: (scope, filter) => set({ currentSpaceId: scope, spaceFilter: filter }), + + applyPreset: (preset) => set(stateForTree(createPresetTree(preset))), + + loadWorkspace: (payload) => set(stateForTree(payload.tree)), + + moveSlot: (viewId, region) => + set((state) => { + const tree = moveSlotInTree(state.tree, viewId, region) + return tree === state.tree ? {} : { tree } + }), + + insertSlot: (viewId, region, index) => + set((state) => { + const tree = insertSlotInTree(state.tree, viewId, region, index) + return tree === state.tree ? {} : { tree } + }), + + setSlotTier: (viewId, tier) => + set((state) => { + const tree = setSlotTierInTree(state.tree, viewId, tier) + if (tree === state.tree) return {} + // Un-pinning the active view of a dock closes that dock at rest. + const patch: Partial = { tree } + for (const side of ['left', 'right', 'bottom'] as const) { + const panel = state[side] + if (panel.activeViewId === viewId && tier !== 'pinned' && panel.open) { + patch[side] = { ...panel, open: false } + } + } + return patch + }), + + // ─── Sidebar + focus (0284) ────────────────────────────────── + toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })), + setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }), + toggleFocus: () => set((state) => ({ focus: !state.focus })), + setFocus: (focus) => set({ focus }), + + // ─── Floating Islands shell (0286) ─────────────────────────── + setActiveSurface: (activeSurface) => set({ activeSurface }), + toggleNavPinned: (surface) => + set((state) => ({ + navPinned: state.navPinned.includes(surface) + ? state.navPinned.filter((id) => id !== surface) + : [...state.navPinned, surface] + })), + setSidebarWidth: (width) => set({ sidebarWidth: Math.max(230, Math.min(320, width)) }), + toggleSidebarCompact: () => set((state) => ({ sidebarCompact: !state.sidebarCompact })), + setFloatAi: (floatAi) => set({ floatAi }), + setFloatCall: (floatCall) => set({ floatCall }), + + // Switching layout is choosing a preset (0280): the legacy axes stay + // coherent with the tree so both renderers agree during the rollout. + setLayout: (layout) => + set((state) => + stateForTree( + createPresetTree( + layout === 'workbench' ? 'bench' : state.chrome === 'quiet' ? 'quiet' : 'calm' + ) + ) + ), + + toggleLayout: () => + set((state) => + stateForTree( + createPresetTree( + state.layout === 'calm' ? 'bench' : state.chrome === 'quiet' ? 'quiet' : 'calm' + ) + ) + ), + + setCalmMode: (calmMode) => set({ calmMode }), + + // Chrome is an orthogonal axis (0273): flipping it never resets a + // customized tree, it only changes the posture of the same placements. + setChrome: (chrome) => + set((state) => ({ chrome, discloseLevel: 0, tree: { ...state.tree, chrome } })), + + toggleChrome: () => + set((state) => { + const chrome = state.chrome === 'quiet' ? 'pinned' : 'quiet' + return { chrome, discloseLevel: 0, tree: { ...state.tree, chrome } } + }), + + setDiscloseLevel: (discloseLevel) => + set((state) => (state.discloseLevel === discloseLevel ? {} : { discloseLevel })), + + setArranging: (arranging) => set({ arranging }), + + openCanvas: (target) => + set((state) => ({ canvasTarget: target, right: { ...state.right, open: true } })), + + closeCanvas: () => + set((state) => ({ canvasTarget: null, right: { ...state.right, open: false } })), + + setPanelOpen: (side, open) => set((state) => ({ [side]: { ...state[side], open } })), + + togglePanel: (side) => + set((state) => ({ [side]: { ...state[side], open: !state[side].open } })), + + showPanelView: (side, viewId) => + set((state) => { + const panel = state[side] + if (panel.open && panel.activeViewId === viewId) { + return { [side]: { ...panel, open: false } } + } + return { [side]: { open: true, activeViewId: viewId } } + }), + + toggleZen: () => + set((state) => { + if (state.mode === 'zen') { + const snapshot = state.zenSnapshot + return { + mode: 'default' as const, + zenSnapshot: null, + ...(snapshot + ? { + left: { ...state.left, open: snapshot.left }, + right: { ...state.right, open: snapshot.right }, + bottom: { ...state.bottom, open: snapshot.bottom } + } + : {}) + } + } + return { + mode: 'zen' as const, + zenSnapshot: { + left: state.left.open, + right: state.right.open, + bottom: state.bottom.open + }, + left: { ...state.left, open: false }, + right: { ...state.right, open: false }, + bottom: { ...state.bottom, open: false } + } + }), + + openTab: ({ nodeId, nodeType, title, preview = false, groupId, background = false }) => + set((state) => { + const targetGroupId = groupId ?? state.activeGroupId + const tabId = tabIdFor(nodeType, nodeId) + + const groups = state.groups.map((group) => { + if (group.id !== targetGroupId) return group + + const existing = group.tabs.find((tab) => tab.id === tabId) + if (existing) { + const tabs = preview + ? group.tabs + : group.tabs.map((tab) => (tab.id === tabId ? { ...tab, preview: false } : tab)) + return { ...group, tabs, activeTabId: background ? group.activeTabId : tabId } + } + + const next = createTab({ nodeId, nodeType, title, preview }) + let tabs: WorkbenchTab[] + if (preview) { + const previewIndex = group.tabs.findIndex((tab) => tab.preview && !tab.pinned) + if (previewIndex >= 0) { + tabs = group.tabs.map((tab, index) => (index === previewIndex ? next : tab)) + } else { + tabs = [...group.tabs, next] + } + } else { + tabs = [...group.tabs, next] + } + return { ...group, tabs, activeTabId: background ? group.activeTabId : next.id } + }) + + return { + groups, + activeGroupId: background ? state.activeGroupId : targetGroupId + } + }), + + activateTab: (tabId, groupId) => + set((state) => { + const targetGroupId = + groupId ?? state.groups.find((g) => g.tabs.some((t) => t.id === tabId))?.id + if (!targetGroupId) return {} + return { + activeGroupId: targetGroupId, + groups: state.groups.map((group) => + group.id === targetGroupId && group.tabs.some((tab) => tab.id === tabId) + ? { ...group, activeTabId: tabId } + : group + ) + } + }), + + closeTab: (tabId, groupId) => + set((state) => { + const targetGroupId = + groupId ?? state.groups.find((g) => g.tabs.some((t) => t.id === tabId))?.id + if (!targetGroupId) return {} + + let groups = state.groups.map((group) => { + if (group.id !== targetGroupId) return group + const index = group.tabs.findIndex((tab) => tab.id === tabId) + if (index < 0) return group + const tabs = group.tabs.filter((tab) => tab.id !== tabId) + let activeTabId = group.activeTabId + if (group.activeTabId === tabId) { + const neighbor = tabs[Math.min(index, tabs.length - 1)] + activeTabId = neighbor ? neighbor.id : null + } + return { ...group, tabs, activeTabId } + }) + + // Drop an emptied second group; always keep at least one group. + let activeGroupId = state.activeGroupId + if (groups.length > 1) { + const empty = groups.filter((group) => group.tabs.length === 0) + if (empty.length > 0) { + groups = groups.filter((group) => group.tabs.length > 0) + if (groups.length === 0) groups = freshGroups() + if (!groups.some((group) => group.id === activeGroupId)) { + activeGroupId = groups[0].id + } + } + } + + return { groups, activeGroupId } + }), + + promoteTab: (tabId) => + set((state) => ({ + groups: state.groups.map((group) => ({ + ...group, + tabs: group.tabs.map((tab) => (tab.id === tabId ? { ...tab, preview: false } : tab)) + })) + })), + + setTabPinned: (tabId, pinned) => + set((state) => ({ + groups: state.groups.map((group) => ({ + ...group, + tabs: group.tabs.map((tab) => + tab.id === tabId ? { ...tab, pinned, preview: false } : tab + ) + })) + })), + + setTabTitle: (nodeId, title) => + set((state) => { + if (!title) return {} + let changed = false + const groups = state.groups.map((group) => { + const tabs = group.tabs.map((tab) => { + if (tab.nodeId !== nodeId || tab.title === title) return tab + changed = true + return { ...tab, title } + }) + return changed ? { ...group, tabs } : group + }) + const recents = state.recents.map((recent) => + recent.nodeId === nodeId && recent.title !== title ? { ...recent, title } : recent + ) + return changed ? { groups, recents } : { recents } + }), + + moveTab: (tabId, groupId, toIndex) => + set((state) => { + const fromGroup = state.groups.find((g) => g.tabs.some((t) => t.id === tabId)) + if (!fromGroup) return {} + const tab = fromGroup.tabs.find((t) => t.id === tabId) + if (!tab) return {} + + let groups = state.groups.map((group) => + group.id === fromGroup.id + ? { + ...group, + tabs: group.tabs.filter((t) => t.id !== tabId), + activeTabId: + group.activeTabId === tabId && group.id !== groupId + ? (group.tabs.filter((t) => t.id !== tabId)[0]?.id ?? null) + : group.activeTabId + } + : group + ) + + groups = groups.map((group) => { + if (group.id !== groupId) return group + const tabs = [...group.tabs] + const clamped = Math.max(0, Math.min(toIndex, tabs.length)) + tabs.splice(clamped, 0, tab) + return { ...group, tabs, activeTabId: tabId } + }) + + // Drop an emptied source group after a cross-group move. + if (groups.length > 1) { + groups = groups.filter((group) => group.tabs.length > 0 || group.id === groupId) + } + + return { + groups, + activeGroupId: groups.some((g) => g.id === groupId) ? groupId : state.activeGroupId + } + }), + + splitWith: ({ nodeId, nodeType, title }) => { + const state = get() + let second = state.groups[1] + if (!second) { + second = { id: 'group-2', tabs: [], activeTabId: null } + set({ groups: [...state.groups, second] }) + } + get().openTab({ nodeId, nodeType, title, groupId: second.id }) + }, + + closeGroup: (groupId) => + set((state) => { + if (state.groups.length <= 1) return {} + const groups = state.groups.filter((group) => group.id !== groupId) + return { + groups, + activeGroupId: state.activeGroupId === groupId ? groups[0].id : state.activeGroupId + } + }), + + focusGroup: (groupId) => + set((state) => + state.groups.some((group) => group.id === groupId) ? { activeGroupId: groupId } : {} + ), + + cycleTab: (delta) => + set((state) => { + const group = state.groups.find((g) => g.id === state.activeGroupId) + if (!group || group.tabs.length < 2 || !group.activeTabId) return {} + const index = group.tabs.findIndex((tab) => tab.id === group.activeTabId) + const next = group.tabs[(index + delta + group.tabs.length) % group.tabs.length] + return { + groups: state.groups.map((g) => + g.id === group.id ? { ...g, activeTabId: next.id } : g + ) + } + }), + + setTabsEnabled: (enabled) => set({ tabsEnabled: enabled }), + + setRouteTitle: (pathname, title) => + set((state) => + state.routeTitles[pathname] === title + ? {} + : { routeTitles: { ...state.routeTitles, [pathname]: title } } + ), + + // Only two entries are kept: the toggle is "the other one", not a + // history stack (the router owns real back/forward). + pushRouteHistory: (pathname) => + set((state) => + state.routeHistory[0] === pathname + ? {} + : { + routeHistory: [pathname, ...state.routeHistory.filter((p) => p !== pathname)].slice( + 0, + 2 + ) + } + ), + + setSplitTarget: (target) => set({ splitTarget: target }), + + setActiveLens: (lensId) => set({ activeLensId: lensId }), + + toggleRowMuted: (rowId) => + set((state) => ({ + mutedRowIds: state.mutedRowIds.includes(rowId) + ? state.mutedRowIds.filter((id) => id !== rowId) + : [...state.mutedRowIds, rowId] + })), + + toggleSectionPinned: (sectionId) => + set((state) => ({ + pinnedSectionIds: state.pinnedSectionIds.includes(sectionId) + ? state.pinnedSectionIds.filter((id) => id !== sectionId) + : [...state.pinnedSectionIds, sectionId] + })), + + setSectionOrder: (order) => set({ sectionOrder: order }), + + togglePinnedNode: (nodeId) => + set((state) => ({ + pinnedNodeIds: state.pinnedNodeIds.includes(nodeId) + ? state.pinnedNodeIds.filter((id) => id !== nodeId) + : [...state.pinnedNodeIds, nodeId] + })), + + touchRecent: (entry) => + set((state) => { + const rest = state.recents.filter((recent) => recent.nodeId !== entry.nodeId) + return { recents: [{ ...entry, at: Date.now() }, ...rest].slice(0, MAX_RECENTS) } + }), + + toggleFolderExpanded: (folderId) => + set((state) => ({ + expandedFolderIds: state.expandedFolderIds.includes(folderId) + ? state.expandedFolderIds.filter((id) => id !== folderId) + : [...state.expandedFolderIds, folderId] + })), + + shelfAdd: (entry) => + set((state) => ({ + shelf: [entry, ...state.shelf.filter((held) => held.nodeId !== entry.nodeId)] + })), + + shelfRemove: (nodeId) => + set((state) => ({ shelf: state.shelf.filter((held) => held.nodeId !== nodeId) })), + + shelfClear: () => set({ shelf: [] }), + + queueDeskPin: (entry) => + set((state) => ({ + deskPins: [...state.deskPins.filter((pin) => pin.nodeId !== entry.nodeId), entry] + })), + + clearDeskPins: (nodeIds) => + set((state) => ({ + deskPins: state.deskPins.filter((pin) => !nodeIds.includes(pin.nodeId)) + })), + + setStartupTab: (tab) => set({ startupTab: tab }), + + setLastSeenChangelogId: (id) => set({ lastSeenChangelogId: id }), + + markTipSeen: (id) => + set((state) => (state.seenTips.includes(id) ? {} : { seenTips: [...state.seenTips, id] })), + + resetTips: () => set({ seenTips: [] }) + }), + { + name: 'xnet:workbench:v1', + // v2 (0280): the layout tree joins the persisted state. Pre-tree + // profiles derive their tree from the legacy `layout`/`chrome` axes so + // panels, pins, shelf and startup node all survive the migration. + version: 5, + migrate: (persisted, version) => { + const state = persisted as Partial + // v5 (0353): tabless becomes the default, and the unified-nav + // state gets its defaults. Persisted `groups` are deliberately + // KEPT: turning tabs back on ("View: Turn on tabs") must restore + // the session it left, which is what makes the tabless rollout + // reversible rather than a one-way door. + if (version < 5) { + state.tabsEnabled = state.tabsEnabled === true + state.sectionOrder = state.sectionOrder ?? DEFAULT_SECTIONS.map((s) => s.id) + state.pinnedSectionIds = state.pinnedSectionIds ?? [...DEFAULT_PINNED_SECTION_IDS] + state.activeLensId = state.activeLensId ?? 'all' + state.mutedRowIds = state.mutedRowIds ?? [] + } + if (version < 2 && !state.tree) { + state.tree = createPresetTree( + state.layout === 'workbench' ? 'bench' : state.chrome === 'quiet' ? 'quiet' : 'calm' + ) + } + // v4 (0284): collapse the quiet/calm/bench trichotomy to one shell. + // Any profile still on a built-in preset tree (or with none) lands in + // the single default tree; a user's own saved/arranged workspace + // (a non-preset workspaceId) is preserved. The legacy axes are + // realigned so the (transitional) renderer fork stays coherent, and a + // quiet/zen posture maps onto the ephemeral `focus` toggle at rest + // rather than a persisted chrome mode. + if (version < 4) { + if (!state.tree || isPresetWorkspaceId(state.tree.workspaceId)) { + state.tree = createDefaultTree() + } + state.layout = state.tree.surface.tabsEnabled ? 'workbench' : 'calm' + state.chrome = 'pinned' + state.sidebarCollapsed = state.sidebarCollapsed ?? false + state.focus = false + } + // v3 (0280): drop tabs whose nodeType this build doesn't know — a + // profile shared with another branch/version must never crash the + // shell (the meetings-tab incident during 0280 validation). + if (version < 3 && Array.isArray(state.groups)) { + state.groups = state.groups.map((group) => { + const tabs = group.tabs.filter((tab) => + (TAB_NODE_TYPES as readonly string[]).includes(tab.nodeType) + ) + return { + ...group, + tabs, + activeTabId: tabs.some((tab) => tab.id === group.activeTabId) + ? group.activeTabId + : (tabs[0]?.id ?? null) + } + }) + } + return state as WorkbenchState + }, + // Disclosure level, arrange mode and focus are live interaction state + // (0273/0282/0284) — persisting them would resurrect a lit/overlaid, + // mid-edit, or chrome-hidden shell on reload. + partialize: (state) => + Object.fromEntries( + Object.entries(state).filter( + ([key]) => + key !== 'discloseLevel' && + key !== 'arranging' && + key !== 'focus' && + // Floating dock visibility is live session state (0286) — a + // reload always restores the default dock, never a stuck-closed one. + key !== 'floatAi' && + key !== 'floatCall' && + // Route titles/history are derived from what's mounted right + // now (0353); persisting them would resurrect stale chrome. + key !== 'routeTitles' && + key !== 'routeHistory' && + // The split is a transient layout, never a restored window. + key !== 'splitTarget' + ) + ) as WorkbenchState + } + ) +) + +/** The active tab of the active editor group, if any. */ +export function selectActiveTab(state: Pick) { + const group = state.groups.find((g) => g.id === state.activeGroupId) + return group?.tabs.find((tab) => tab.id === group.activeTabId) ?? null +} + +/** + * The route the recent-two toggle (Ctrl-Tab) should jump to: the + * previously visited route, or null when there's only one (0353). + */ +export function selectPreviousRoute(state: Pick): string | null { + return state.routeHistory[1] ?? null +} diff --git a/apps/web/src/workbench/surfaces.test.ts b/packages/workbench/src/surfaces.test.ts similarity index 100% rename from apps/web/src/workbench/surfaces.test.ts rename to packages/workbench/src/surfaces.test.ts diff --git a/packages/workbench/src/surfaces.ts b/packages/workbench/src/surfaces.ts new file mode 100644 index 000000000..3b8fdcada --- /dev/null +++ b/packages/workbench/src/surfaces.ts @@ -0,0 +1,137 @@ +/** + * Surfaces — the primary destinations the Floating shell's sidebar curates + * (exploration 0286). + * + * A surface is either a **panel** (its registered slot view renders inside the + * contextual bottom sidebar island — Explorer, Tasks, Chats, Today, Data, AI) + * or a **route** (selecting it opens that route in the editor; the bottom + * island shows a small launcher). The top island's primary rows are the + * `navPinned` subset; the rest live in the "More" surfaces roll-out, where the + * user pins/unpins to curate what stays visible. + */ +import { + BarChart3, + CheckSquare2, + Compass, + Contact, + Database, + Files, + Inbox, + MessageSquare, + Mic, + Sparkles, + Sunrise, + Wallet, + type LucideIcon +} from 'lucide-react' +import { useCallback } from 'react' +import { useNavigateTo, type NavTarget } from './platform' +import { useWorkbench } from './state' +import { setPreviewIntent, tabIdForRoute } from './tabs' + +export interface SurfaceDef { + /** Stable id — persisted in `activeSurface` / `navPinned`. */ + id: string + label: string + icon: LucideIcon + /** `panel` renders a slot view in the bottom island; `route` navigates. */ + kind: 'panel' | 'route' + /** Slot view id (panel surfaces). */ + viewId?: string + /** + * Route path. Required for route surfaces; a panel surface may also carry + * one, in which case activating it drives the bottom island *and* opens the + * route in the editor (Tasks → the task board). + */ + to?: string + /** Live count source for the trailing badge. */ + badge?: 'requests' + /** Emphasised (ink pill) rather than muted-mono count. */ + emphasis?: boolean +} + +/** Every surface, in roll-out order (pinned ones are the `navPinned` subset). */ +export const SURFACES: SurfaceDef[] = [ + { id: 'explorer', label: 'Explorer', icon: Files, kind: 'panel', viewId: 'explorer' }, + { + id: 'requests', + label: 'Inbox', + icon: Inbox, + kind: 'route', + to: '/requests', + badge: 'requests', + emphasis: true + }, + { id: 'tasks', label: 'Tasks', icon: CheckSquare2, kind: 'panel', viewId: 'tasks', to: '/tasks' }, + { id: 'chats', label: 'Chats', icon: MessageSquare, kind: 'panel', viewId: 'chats' }, + { id: 'today', label: 'Today', icon: Sunrise, kind: 'panel', viewId: 'today' }, + { id: 'data', label: 'Data', icon: Database, kind: 'panel', viewId: 'data' }, + { id: 'ai', label: 'AI', icon: Sparkles, kind: 'panel', viewId: 'ai-chat' }, + { id: 'crm', label: 'People', icon: Contact, kind: 'route', to: '/crm' }, + { id: 'discover', label: 'Discover', icon: Compass, kind: 'route', to: '/discover' }, + { id: 'meetings', label: 'Meetings', icon: Mic, kind: 'route', to: '/meetings' }, + { id: 'finance', label: 'Finance', icon: Wallet, kind: 'route', to: '/finance' }, + { id: 'analytics', label: 'Analytics', icon: BarChart3, kind: 'route', to: '/analytics' } +] + +const BY_ID = new Map(SURFACES.map((surface) => [surface.id, surface])) + +export function surfaceById(id: string): SurfaceDef | undefined { + return BY_ID.get(id) +} + +/** Resolve `navPinned` ids to defs, dropping any unknown (stale) ids. */ +export function pinnedSurfaces(navPinned: string[]): SurfaceDef[] { + return navPinned.map((id) => BY_ID.get(id)).filter((s): s is SurfaceDef => Boolean(s)) +} + +/** The default panel surface the contextual island falls back to. */ +export const DEFAULT_SURFACE = 'explorer' + +/** + * The tab a surface's route opens/promotes, or null for routeless panels and + * non-tab routes (Discover, Analytics). Lets a surface row promote its tab on + * double-click without knowing the node model. + */ +export function surfaceTabId(surface: SurfaceDef): string | null { + if (!surface.to) return null + return tabIdForRoute(surface.to) +} + +/** + * Activating a surface: a **panel** drives the contextual bottom island + * (`activeSurface`); a surface with a **route** opens it in the editor as a + * VS Code-style preview tab (0288) — a single click renders it italic and the + * next single-click open replaces it; double-clicking the row (or editing) + * promotes it. A panel surface that also carries a route (Tasks) does both. + * Pure so the decision is testable; the hook below wires the side-effecting + * deps. + */ +export function activateSurface( + surface: SurfaceDef, + deps: { navigate: (target: NavTarget) => void; setActiveSurface: (id: string) => void } +): void { + if (surface.kind === 'panel') deps.setActiveSurface(surface.id) + if (surface.to) { + // Only tab routes honour the preview latch; arming it for a non-tab route + // (Discover, Analytics, Inbox) would leave it set for the next navigation. + if (tabIdForRoute(surface.to)) setPreviewIntent() + deps.navigate({ kind: 'path', path: surface.to }) + } +} + +/** + * Shared by the sidebar primary rows and the surfaces roll-out so both roads + * agree. Returns the single-click handler; double-click promote lives in the + * render sites (they call {@link surfaceTabId} + the store's `promoteTab`). + */ +export function useSurfaceActivation(): (surface: SurfaceDef) => void { + const navigate = useNavigateTo() + const setActiveSurface = useWorkbench((state) => state.setActiveSurface) + return useCallback( + (surface: SurfaceDef) => { + activateSurface(surface, { navigate, setActiveSurface }) + }, + [navigate, setActiveSurface] + ) +} diff --git a/apps/web/src/workbench/tabless.test.ts b/packages/workbench/src/tabless.test.ts similarity index 100% rename from apps/web/src/workbench/tabless.test.ts rename to packages/workbench/src/tabless.test.ts diff --git a/packages/workbench/src/tabs.ts b/packages/workbench/src/tabs.ts new file mode 100644 index 000000000..d6768e684 --- /dev/null +++ b/packages/workbench/src/tabs.ts @@ -0,0 +1,213 @@ +/** + * Tab ↔ route ↔ view mapping (exploration 0166). + * + * Everything that opens in the editor area is a tab backed by a node + * (or a singleton surface like Tasks). The router stays authoritative: + * navigating to a route activates (or opens) its tab, so deep links, + * back/forward, and old bookmarks keep working. + */ +import { + CheckSquare2, + Code2, + Contact, + Database, + FileText, + FlaskConical, + Frame, + Hash, + Layout, + LayoutDashboard, + MapPin, + MessageSquare, + Mic, + Network, + Settings, + Table2, + User, + Users, + Wallet, + type LucideIcon +} from 'lucide-react' +import { tabIdFor, useWorkbench, type TabNodeType } from './state' + +export interface TabViewEntry { + label: string + icon: LucideIcon + toRoute: (nodeId: string) => string + /** Singleton surfaces (tasks, data) have a fixed node id */ + singleton?: boolean +} + +export const TAB_VIEWS: Record = { + page: { label: 'Page', icon: FileText, toRoute: (id) => `/doc/${id}` }, + post: { label: 'Topic', icon: MessageSquare, toRoute: (id) => `/post/${id}` }, + database: { label: 'Database', icon: Database, toRoute: (id) => `/db/${id}` }, + canvas: { label: 'Canvas', icon: Layout, toRoute: (id) => `/canvas/${id}` }, + dashboard: { + label: 'Dashboard', + icon: LayoutDashboard, + toRoute: (id) => `/dashboard/${id}` + }, + map: { label: 'Map', icon: MapPin, toRoute: (id) => `/map/${id}` }, + savedview: { label: 'Saved view', icon: Table2, toRoute: (id) => `/view/${id}` }, + tasks: { label: 'Tasks', icon: CheckSquare2, toRoute: () => '/tasks', singleton: true }, + meetings: { label: 'Meetings', icon: Mic, toRoute: () => '/meetings', singleton: true }, + data: { label: 'Data', icon: Network, toRoute: () => '/data', singleton: true }, + experiments: { + label: 'Experiments', + icon: FlaskConical, + toRoute: () => '/experiments', + singleton: true + }, + crm: { label: 'CRM', icon: Contact, toRoute: () => '/crm', singleton: true }, + finance: { label: 'Finance', icon: Wallet, toRoute: () => '/finance', singleton: true }, + channel: { label: 'Channel', icon: MessageSquare, toRoute: (id) => `/channel/${id}` }, + tag: { label: 'Tag', icon: Hash, toRoute: (id) => `/tag/${id}` }, + person: { label: 'Person', icon: User, toRoute: (id) => `/person/${encodeURIComponent(id)}` }, + lab: { label: 'Lab', icon: Code2, toRoute: (id) => `/lab/${id}` }, + space: { label: 'Space', icon: Users, toRoute: (id) => `/space/${encodeURIComponent(id)}` }, + settings: { label: 'Settings', icon: Settings, toRoute: () => '/settings', singleton: true }, + // 0346: a node opened through an arbitrary registered view. + frame: { label: 'View', icon: Frame, toRoute: (id) => `/frame/${encodeURIComponent(id)}` } +} + +const ROUTE_PREFIXES: Array<{ prefix: string; nodeType: TabNodeType }> = [ + { prefix: '/doc/', nodeType: 'page' }, + { prefix: '/db/', nodeType: 'database' }, + { prefix: '/canvas/', nodeType: 'canvas' }, + { prefix: '/dashboard/', nodeType: 'dashboard' }, + { prefix: '/map/', nodeType: 'map' }, + { prefix: '/view/', nodeType: 'savedview' }, + { prefix: '/channel/', nodeType: 'channel' }, + { prefix: '/post/', nodeType: 'post' }, + { prefix: '/tag/', nodeType: 'tag' }, + { prefix: '/person/', nodeType: 'person' }, + { prefix: '/lab/', nodeType: 'lab' }, + { prefix: '/space/', nodeType: 'space' }, + { prefix: '/frame/', nodeType: 'frame' } +] + +export interface RouteTabDescriptor { + nodeType: TabNodeType + nodeId: string +} + +/** Map a pathname onto a tab descriptor; null for non-tab routes. */ +export function tabFromPathname(pathname: string): RouteTabDescriptor | null { + if (pathname === '/tasks') return { nodeType: 'tasks', nodeId: 'tasks' } + if (pathname === '/meetings') return { nodeType: 'meetings', nodeId: 'meetings' } + if (pathname === '/data') return { nodeType: 'data', nodeId: 'data' } + if (pathname === '/experiments') return { nodeType: 'experiments', nodeId: 'experiments' } + if (pathname === '/crm') return { nodeType: 'crm', nodeId: 'crm' } + if (pathname === '/finance') return { nodeType: 'finance', nodeId: 'finance' } + // Settings is a singleton tab; its `?section=` search param is ignored here so + // switching sections stays on the one tab (0288). + if (pathname === '/settings') return { nodeType: 'settings', nodeId: 'settings' } + + for (const { prefix, nodeType } of ROUTE_PREFIXES) { + if (pathname.startsWith(prefix)) { + const nodeId = decodeURIComponent(pathname.slice(prefix.length)) + if (nodeId) return { nodeType, nodeId } + } + } + + return null +} + +export function routeForTab(nodeType: TabNodeType, nodeId: string): string { + // Defensive: an unknown persisted nodeType routes home instead of crashing. + return TAB_VIEWS[nodeType]?.toRoute(nodeId) ?? '/' +} + +/** + * The tab id a pathname maps to, or null for non-tab routes — lets a + * click source that only knows a route (surface rows, menu links) resolve + * the tab to promote on double-click. + */ +export function tabIdForRoute(pathname: string): string | null { + const descriptor = tabFromPathname(pathname) + return descriptor ? tabIdFor(descriptor.nodeType, descriptor.nodeId) : null +} + +/** + * Preview intent — set by single-click sources (explorer, palette) + * just before they navigate, consumed by the route→tab sync. Deep + * links, back/forward and command navigation open permanent tabs. + */ +let previewIntent = false + +export function setPreviewIntent(): void { + previewIntent = true +} + +export function consumePreviewIntent(): boolean { + const value = previewIntent + previewIntent = false + return value +} + +/** + * Record a visited route in the working set (0353): recents + the + * recent-two history. Runs in BOTH modes — it is the tabless + * replacement for the recents feed that used to ride tab opening, and + * stays correct when tabs are on. + */ +export function trackRouteVisit(pathname: string): void { + const state = useWorkbench.getState() + state.pushRouteHistory(pathname) + + const descriptor = tabFromPathname(pathname) + if (!descriptor) return + state.touchRecent({ + nodeId: descriptor.nodeId, + nodeType: descriptor.nodeType, + title: state.routeTitles[pathname] ?? '' + }) +} + +/** + * Open-or-activate the tab matching a pathname (router → store). + * Returns silently for non-tab routes. No-op when tabless (0353) — + * `trackRouteVisit` carries the working set instead. + */ +export function syncRouteToTabs(pathname: string): void { + const state = useWorkbench.getState() + if (!state.tabsEnabled) { + consumePreviewIntent() + return + } + + const descriptor = tabFromPathname(pathname) + if (!descriptor) { + // Non-tab route: drop any pending preview intent so a source that armed it + // before navigating somewhere untabbed can't leak it onto the next open. + consumePreviewIntent() + return + } + + const tabId = tabIdFor(descriptor.nodeType, descriptor.nodeId) + const owner = state.groups.find((group) => group.tabs.some((tab) => tab.id === tabId)) + + if (owner) { + consumePreviewIntent() + state.activateTab(tabId, owner.id) + } else { + state.openTab({ + nodeId: descriptor.nodeId, + nodeType: descriptor.nodeType, + preview: consumePreviewIntent() + }) + } + + // Recents are also written by `trackRouteVisit` (0353) — `touchRecent` + // dedupes by node id, so the tab title simply refines the entry. + const tab = useWorkbench + .getState() + .groups.flatMap((group) => group.tabs) + .find((entry) => entry.id === tabId) + state.touchRecent({ + nodeId: descriptor.nodeId, + nodeType: descriptor.nodeType, + title: tab?.title ?? '' + }) +} diff --git a/apps/web/src/workbench/test-platform.tsx b/packages/workbench/src/test-platform.tsx similarity index 100% rename from apps/web/src/workbench/test-platform.tsx rename to packages/workbench/src/test-platform.tsx diff --git a/apps/web/src/workbench/views/explorer-sort.test.ts b/packages/workbench/src/views/explorer-sort.test.ts similarity index 100% rename from apps/web/src/workbench/views/explorer-sort.test.ts rename to packages/workbench/src/views/explorer-sort.test.ts diff --git a/packages/workbench/src/views/explorer-sort.ts b/packages/workbench/src/views/explorer-sort.ts new file mode 100644 index 000000000..7dfce7f6f --- /dev/null +++ b/packages/workbench/src/views/explorer-sort.ts @@ -0,0 +1,43 @@ +/** + * Explorer list sorting (exploration 0190) — pure and unit-tested. + * + * Controls only the order of the flat list (Unfiled / Results). The folder + * tree keeps its own fractional `sortKey` order; this never touches it. Title + * sorting uses `localeCompare` (display order) — distinct from the code-unit + * `sortKey` collation invariant, which applies only to fractional sort keys. + */ +export type ExplorerSort = 'recent' | 'created' | 'name' | 'type' + +export const EXPLORER_SORTS: Array<{ id: ExplorerSort; label: string }> = [ + { id: 'recent', label: 'Recent' }, + { id: 'created', label: 'Created' }, + { id: 'name', label: 'A–Z' }, + { id: 'type', label: 'Type' } +] + +interface SortableItem { + title: string + type: string + updatedAt: number + createdAt?: number +} + +const byRecency = (a: SortableItem, b: SortableItem) => b.updatedAt - a.updatedAt +const byCreation = (a: SortableItem, b: SortableItem) => (b.createdAt ?? 0) - (a.createdAt ?? 0) +const titleOf = (item: SortableItem) => (item.title || 'Untitled').toLowerCase() + +/** Return a new array ordered by the chosen sort (recency-tiebroken). */ +export function sortExplorerItems(items: T[], sort: ExplorerSort): T[] { + const copy = items.slice() + switch (sort) { + case 'created': + return copy.sort((a, b) => byCreation(a, b) || byRecency(a, b)) + case 'name': + return copy.sort((a, b) => titleOf(a).localeCompare(titleOf(b)) || byRecency(a, b)) + case 'type': + return copy.sort((a, b) => a.type.localeCompare(b.type) || byRecency(a, b)) + case 'recent': + default: + return copy.sort(byRecency) + } +} diff --git a/packages/workbench/tsconfig.json b/packages/workbench/tsconfig.json new file mode 100644 index 000000000..ab1fbff58 --- /dev/null +++ b/packages/workbench/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41bdf6773..bd448491c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -587,6 +587,9 @@ importers: '@xnetjs/views': specifier: workspace:* version: link:../../packages/views + '@xnetjs/workbench': + specifier: workspace:* + version: link:../../packages/workbench fflate: specifier: 0.8.3 version: 0.8.3 @@ -2370,6 +2373,40 @@ importers: specifier: ^4.0.0 version: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.27.0)(msw@2.12.7(@types/node@20.19.30)(typescript@5.9.3))(terser@5.48.0)(tsx@4.21.0)(yaml@2.9.0) + packages/workbench: + dependencies: + '@xnetjs/plugins': + specifier: workspace:* + version: link:../plugins + lucide-react: + specifier: ^0.453.0 + version: 0.453.0(react@18.3.1) + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + devDependencies: + '@testing-library/react': + specifier: ^16.0.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ^18.3.0 + version: 18.3.27 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + tsup: + specifier: ^8.0.0 + version: 8.5.1(@microsoft/api-extractor@7.58.11(@types/node@20.19.30))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.4.0 + version: 5.9.3 + vitest: + specifier: ^4.0.0 + version: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@20.19.30)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.27.0)(msw@2.12.7(@types/node@20.19.30)(typescript@5.9.3))(terser@5.48.0)(tsx@4.21.0)(yaml@2.9.0) + site: dependencies: '@astrojs/starlight': diff --git a/vitest.config.ts b/vitest.config.ts index 5064e773e..06bd50fcc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -87,7 +87,8 @@ const workspaceAliases = { '@xnetjs/ui': new URL('./packages/ui/src/index.ts', import.meta.url).pathname, '@xnetjs/unreal': new URL('./packages/unreal/src/index.ts', import.meta.url).pathname, '@xnetjs/vectors': new URL('./packages/vectors/src/index.ts', import.meta.url).pathname, - '@xnetjs/views': new URL('./packages/views/src/index.ts', import.meta.url).pathname + '@xnetjs/views': new URL('./packages/views/src/index.ts', import.meta.url).pathname, + '@xnetjs/workbench': new URL('./packages/workbench/src/index.ts', import.meta.url).pathname } export default defineConfig({ @@ -157,7 +158,7 @@ export default defineConfig({ pool: 'threads', isolate: true, include: [ - 'packages/{canvas,react,views,devtools,ui,dashboard,charts,maps}/src/**/*.test.{ts,tsx}', + 'packages/{canvas,react,views,devtools,ui,dashboard,charts,maps,workbench}/src/**/*.test.{ts,tsx}', 'packages/{canvas,react,views,devtools,ui,dashboard,charts,maps}/test/**/*.test.{ts,tsx}', // App-level logic tests (workbench shell, 0166) 'apps/web/src/**/*.test.{ts,tsx}'