diff --git a/.changeset/slot-contributions-0280.md b/.changeset/slot-contributions-0280.md new file mode 100644 index 000000000..2bdcee8a9 --- /dev/null +++ b/.changeset/slot-contributions-0280.md @@ -0,0 +1,5 @@ +--- +'@xnetjs/plugins': minor +--- + +Generalize the SurfaceDock contract into shell-wide slot contributions (exploration 0280). New `SlotContribution` type (with `defaultRegion` / `allowedRegions`), `SlotRegion` union, a `slots` registry on `ContributionRegistry`, a `slots` key on `PluginContributions`, and `ExtensionContext.registerSlotView()`. `SurfaceDockContribution` and the `surfaceDock` registry remain as deprecated aliases — no breaking changes. diff --git a/.changeset/workspace-schema-0280.md b/.changeset/workspace-schema-0280.md new file mode 100644 index 000000000..a7732e5b1 --- /dev/null +++ b/.changeset/workspace-schema-0280.md @@ -0,0 +1,6 @@ +--- +'@xnetjs/data': minor +'@xnetjs/plugins': minor +--- + +Workspaces as nodes (exploration 0280): new `xnet:Workspace` schema in `@xnetjs/data` (name/preset/system/tree — the portable half of a saved shell layout), and workspace layout primitives in `@xnetjs/plugins` (`LayoutTree`, `createPresetTree`, `moveSlot`/`setSlotTier`, `parseWorkspacePayload`/`serializeWorkspacePayload`) shared by the web shell, the seed, and future desktop adoption. diff --git a/.storybook/shims/xnet-plugins-browser.ts b/.storybook/shims/xnet-plugins-browser.ts index d766f569e..0f9e4aa43 100644 --- a/.storybook/shims/xnet-plugins-browser.ts +++ b/.storybook/shims/xnet-plugins-browser.ts @@ -72,3 +72,33 @@ export type { AIGenerateRequest, AIStreamChunk } from '../../packages/plugins/src/ai/providers' + +// Workspace layout primitives + slot contributions (0280) — the workbench +// shell modules import these through the @xnetjs/plugins alias. +export { + createPresetTree, + isPresetWorkspaceId, + moveSlot, + parseWorkspacePayload, + placementOf, + PRESET_IDS, + PRESET_WORKSPACE_ID_PREFIX, + presetForWorkspaceId, + presetWorkspaceId, + REGION_IDS, + regionOf, + serializeWorkspacePayload, + setSlotTier, + slotsIn +} from '../../packages/plugins/src/workspace' +export type { + ChromePosture, + LayoutTree, + PresetId, + RegionId, + SlotPlacement, + SlotTier, + WorkspacePayload +} from '../../packages/plugins/src/workspace' +export type { SlotContribution, SlotRegion } from '../../packages/plugins/src/contributions' +export { evaluateInstallConsent, scaffoldPlugin } from '../../packages/plugins/src/ecosystem' diff --git a/apps/electron/src/renderer/shell/workspace-parity.test.ts b/apps/electron/src/renderer/shell/workspace-parity.test.ts new file mode 100644 index 000000000..e6c3653a7 --- /dev/null +++ b/apps/electron/src/renderer/shell/workspace-parity.test.ts @@ -0,0 +1,51 @@ +/** + * Workspace-primitives parity guard (exploration 0280, the 0238 pattern). + * + * The desktop shell keeps its document-centric composition for now (0280 + * risk 6: full ShellFrame adoption is deferred, not forked). This guard + * enforces the deferral's terms: + * + * 1. The canonical workspace primitives (LayoutTree, presets, payload + * parsing) resolve from @xnetjs/plugins in the desktop bundle — so the + * moment desktop adopts them it consumes the SAME module as web. + * 2. No desktop source redefines its own preset trees or layout-tree + * types — the 0277 lesson: divergence starts as a copied type. + */ +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { createPresetTree, parseWorkspacePayload, PRESET_IDS } from '@xnetjs/plugins' +import { describe, expect, it } from 'vitest' + +const RENDERER_DIR = join(__dirname, '..') + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return sourceFiles(path) + return /\.(ts|tsx)$/.test(entry.name) && !entry.name.endsWith('.test.ts') ? [path] : [] + }) +} + +describe('workspace primitives parity (0280)', () => { + it('resolves the shared preset fixtures from @xnetjs/plugins', () => { + for (const preset of PRESET_IDS) { + const tree = createPresetTree(preset) + expect(tree.workspaceId).toContain(preset) + // Round-trip through the shared payload codec — the same bytes a + // synced workspace node carries between desktop and web. + const parsed = parseWorkspacePayload({ name: preset, preset, tree }) + expect(parsed?.tree).toEqual(tree) + } + }) + + it('no desktop source forks its own layout-tree or preset definitions', () => { + const offenders: string[] = [] + for (const file of sourceFiles(RENDERER_DIR)) { + const source = readFileSync(file, 'utf8') + if (/interface\s+LayoutTree\b|function\s+createPresetTree\b/.test(source)) { + offenders.push(file) + } + } + expect(offenders).toEqual([]) + }) +}) diff --git a/apps/web/src/hooks/useShareLinks.ts b/apps/web/src/hooks/useShareLinks.ts index cb7aa0e13..a46c2c33c 100644 --- a/apps/web/src/hooks/useShareLinks.ts +++ b/apps/web/src/hooks/useShareLinks.ts @@ -10,7 +10,15 @@ import { hubApiFetch, normalizeHubHttpUrl } from '../lib/share-links' // 'space' invites bootstrap Space membership — one link shares the whole Space // (exploration 0179). -export type ShareDocType = 'page' | 'database' | 'canvas' | 'dashboard' | 'view' | 'space' +export type ShareDocType = + | 'page' + | 'database' + | 'canvas' + | 'dashboard' + | 'view' + | 'space' + // Saved shell layouts (exploration 0280) — a bench travels like a node. + | 'workspace' export type ShareRole = 'read' | 'comment' | 'write' export type ShareLink = { diff --git a/apps/web/src/plugins/index.ts b/apps/web/src/plugins/index.ts index 27b3d09b4..28d311042 100644 --- a/apps/web/src/plugins/index.ts +++ b/apps/web/src/plugins/index.ts @@ -8,8 +8,19 @@ import type { XNetExtension } from '@xnetjs/plugins' import { ChartsExtraPlugin } from './charts-extra-plugin' import { MermaidPlugin } from './mermaid-plugin' +import { WorkbenchSlashPlugin } from './workbench-slash-plugin' +import { registerWorkspaceCommands, WorkspaceAgentModule } from './workspace-agent-module' /** * List of bundled plugins to auto-install */ -export const BUNDLED_PLUGINS: XNetExtension[] = [MermaidPlugin, ChartsExtraPlugin] +export const BUNDLED_PLUGINS: XNetExtension[] = [ + MermaidPlugin, + ChartsExtraPlugin, + WorkbenchSlashPlugin, + WorkspaceAgentModule +] + +// The workspace verbs that need no React state (undo + preset switches) +// register at module load, so agent tools work headless too (0280). +registerWorkspaceCommands() diff --git a/apps/web/src/plugins/workbench-slash-plugin.ts b/apps/web/src/plugins/workbench-slash-plugin.ts new file mode 100644 index 000000000..b00b09f1e --- /dev/null +++ b/apps/web/src/plugins/workbench-slash-plugin.ts @@ -0,0 +1,53 @@ +/** + * Workbench slash verbs (exploration 0280 phase 4). + * + * The customization slope's editor road: slash commands in any page that + * run the SAME registered workbench commands the palette and chords run — + * "Pin to Desk" (0273's queueDeskPin) and "Save workspace as…". No new + * verbs, just a third road to the existing ones. + */ +import type { XNetExtension } from '@xnetjs/plugins' +import { getCommandRegistry } from '@xnetjs/plugins' + +interface SlashRange { + from: number + to: number +} + +function runAppCommand(commandId: string) { + return ({ editor, range }: { editor: unknown; range: SlashRange }) => { + // Remove the typed slash trigger, then hand off to the shared verb. + const ed = editor as { + chain: () => { focus: () => { deleteRange: (r: SlashRange) => { run: () => void } } } + } + ed.chain().focus().deleteRange(range).run() + void getCommandRegistry().runCommand(commandId) + } +} + +export const WorkbenchSlashPlugin: XNetExtension = { + id: 'fyi.xnet.workbench-verbs', + name: 'Workbench verbs', + version: '1.0.0', + description: 'Slash-command road to Pin to Desk and workspace saving (0280)', + contributes: { + slashCommands: [ + { + id: 'pin-to-desk', + name: 'Pin to Desk', + description: 'Pin this document to your Desk canvas', + aliases: ['desk', 'pin'], + icon: 'pin', + execute: runAppCommand('workbench.pinToDesk') + }, + { + id: 'save-workspace', + name: 'Save workspace as…', + description: 'Keep the current shell layout as a named workspace', + aliases: ['workspace', 'layout'], + icon: 'layers', + execute: runAppCommand('workspace.saveAs') + } + ] + } +} diff --git a/apps/web/src/plugins/workspace-agent-module.test.ts b/apps/web/src/plugins/workspace-agent-module.test.ts new file mode 100644 index 000000000..3015d0a7e --- /dev/null +++ b/apps/web/src/plugins/workspace-agent-module.test.ts @@ -0,0 +1,91 @@ +/** + * Workspace agent tools (0280): mutations go through registered commands, + * are undoable via workspace.undoLayout, and announce themselves. + */ +import { getCommandRegistry } from '@xnetjs/plugins' +import { beforeEach, describe, expect, it } from 'vitest' +import { registerBuiltinSlotViews } from '../workbench/builtin-slot-views' +import { regionOf } from '../workbench/layout-tree' +import { useWorkbench } from '../workbench/state' +import { + AGENT_LAYOUT_EVENT, + registerWorkspaceCommands, + WorkspaceAgentModule, + workspaceUndoDepth +} from './workspace-agent-module' + +const tools = Object.fromEntries( + (WorkspaceAgentModule.contributes?.agentTools ?? []).map((tool) => [tool.name, tool]) +) + +registerBuiltinSlotViews() +const disposeUndo = registerWorkspaceCommands() +void disposeUndo + +beforeEach(() => { + useWorkbench.getState().applyPreset('calm') + while (workspaceUndoDepth() > 0) { + void getCommandRegistry().runCommand('workspace.undoLayout') + } +}) + +describe('workspace agent tools', () => { + it('declares a closed network and only the Workspace schema', () => { + expect(WorkspaceAgentModule.capabilities?.network).toEqual([]) + expect(WorkspaceAgentModule.capabilities?.schemaWrite).toEqual([ + 'xnet://xnet.fyi/Workspace@1.0.0' + ]) + }) + + it('describes the current layout', async () => { + const text = (await tools.workspace_describe_layout.invoke({})) as string + expect(text).toContain('chrome: pinned') + expect(text).toContain('dock.left: navigator (pinned)') + }) + + it('applies a preset through the registered command, undoably', async () => { + const events: string[] = [] + const listener = (event: Event) => + events.push((event as CustomEvent<{ message: string }>).detail.message) + window.addEventListener(AGENT_LAYOUT_EVENT, listener) + + await tools.workspace_apply_preset.invoke({ preset: 'bench' }) + expect(useWorkbench.getState().tree.surface.tabsEnabled).toBe(true) + expect(events[0]).toContain('bench') + expect(workspaceUndoDepth()).toBe(1) + + await getCommandRegistry().runCommand('workspace.undoLayout') + expect(useWorkbench.getState().tree.surface.tabsEnabled).toBe(false) + expect(workspaceUndoDepth()).toBe(0) + window.removeEventListener(AGENT_LAYOUT_EVENT, listener) + }) + + it('moves a view via slot.move and reports the landing region', async () => { + const reply = (await tools.workspace_move_view.invoke({ + viewId: 'context', + region: 'dock.left' + })) as string + expect(reply).toContain('Moved Context to dock.left') + expect(regionOf(useWorkbench.getState().tree, 'context')).toBe('dock.left') + }) + + it('rejects unknown views with the known list', async () => { + await expect( + tools.workspace_move_view.invoke({ viewId: 'nope', region: 'dock.left' }) + ).rejects.toThrow(/unknown view: nope/) + }) +}) + +describe('workspace_scaffold_view (0280 L5)', () => { + it('scaffolds a network-closed slot-view draft with consent preview', async () => { + const result = (await tools.workspace_scaffold_view.invoke({ + id: 'com.you.focus-board', + name: 'Focus Board', + schemaRead: ['xnet://xnet.fyi/Task@1.0.0'] + })) as { files: Record; trustTier: string; consentLines: string[] } + expect(result.files['src/index.ts']).toContain("defaultRegion: 'dock.corner'") + expect(result.files['src/index.ts']).toContain('"network":[]') + expect(result.trustTier).toBe('user') + expect(result.consentLines).toEqual(['Read your Task']) + }) +}) diff --git a/apps/web/src/plugins/workspace-agent-module.ts b/apps/web/src/plugins/workspace-agent-module.ts new file mode 100644 index 000000000..be9108659 --- /dev/null +++ b/apps/web/src/plugins/workspace-agent-module.ts @@ -0,0 +1,267 @@ +/** + * Workspace agent module (exploration 0280 phase 5). + * + * The companion as a shell citizen: model-facing tools that edit the + * workspace by EMITTING THE SAME REGISTERED COMMANDS the palette and drag + * handles run — never private state. Every mutation snapshots the prior + * tree onto an undo stack (surfaced as the `workspace.undoLayout` command + * and an agent-change toast), so "Companion moved Tasks" is one Undo away. + * + * Declared as a FeatureModule so the capability surface is explicit: it + * may read/write Workspace nodes (via the commands it triggers), touches + * no other schemas, and declares `network: []` — provably offline. + */ +import type { FeatureModule, ModuleCapabilities } from '@xnetjs/plugins' +import { evaluateInstallConsent, getCommandRegistry, scaffoldPlugin } from '@xnetjs/plugins' +import { + PRESET_IDS, + REGION_IDS, + regionOf, + serializeWorkspacePayload, + type LayoutTree, + type PresetId, + type RegionId +} from '../workbench/layout-tree' +import { getSlotView, getSlotViews } from '../workbench/slot-registry' +import { useWorkbench } from '../workbench/state' + +/** Fired after an agent-driven layout change; the shell shows an Undo toast. */ +export const AGENT_LAYOUT_EVENT = 'xnet:workspace:agent-change' + +const undoStack: LayoutTree[] = [] +const MAX_UNDO = 10 + +function snapshot(): void { + undoStack.push(useWorkbench.getState().tree) + if (undoStack.length > MAX_UNDO) undoStack.shift() +} + +function announce(message: string): void { + window.dispatchEvent(new CustomEvent(AGENT_LAYOUT_EVENT, { detail: { message } })) +} + +/** Run a registered command; the tools never mutate the store directly. */ +async function emit(commandId: string): Promise { + await getCommandRegistry().runCommand(commandId) +} + +function describeTree(tree: LayoutTree): string { + const lines = REGION_IDS.filter((region) => tree.regions[region].length > 0).map((region) => { + const views = tree.regions[region] + .map((placement) => `${placement.viewId} (${placement.tier})`) + .join(', ') + return `${region}: ${views}` + }) + return [ + `workspace: ${tree.workspaceId}`, + `chrome: ${tree.chrome}; tabs: ${tree.surface.tabsEnabled}`, + ...lines + ].join('\n') +} + +export const WorkspaceAgentModule: FeatureModule = { + id: 'fyi.xnet.workspace-agent', + name: 'Workspace agent tools', + version: '1.0.0', + description: + 'Lets your agent arrange the shell through the same undoable commands you use (0280)', + capabilities: { + schemaRead: ['xnet://xnet.fyi/Workspace@1.0.0'], + schemaWrite: ['xnet://xnet.fyi/Workspace@1.0.0'], + network: [] + }, + contributes: { + agentTools: [ + { + id: 'fyi.xnet.workspace-agent.describe', + name: 'workspace_describe_layout', + description: + 'Read the current shell layout: regions, placed views and their disclosure tiers.', + risk: 'low', + invoke: () => describeTree(useWorkbench.getState().tree) + }, + { + id: 'fyi.xnet.workspace-agent.apply-preset', + name: 'workspace_apply_preset', + description: `Replace the shell layout with a built-in preset (${PRESET_IDS.join(', ')}). Undoable.`, + risk: 'medium', + inputSchema: { + type: 'object', + properties: { + preset: { type: 'string', enum: [...PRESET_IDS], description: 'Preset id' } + }, + required: ['preset'] + }, + invoke: async (args) => { + const preset = args.preset as PresetId + if (!PRESET_IDS.includes(preset)) throw new Error(`unknown preset: ${preset}`) + snapshot() + await emit(`workspace.preset:${preset}`) + announce(`Companion applied the ${preset} preset`) + return `Applied preset ${preset}. Undo: workspace.undoLayout.` + } + }, + { + id: 'fyi.xnet.workspace-agent.move-view', + name: 'workspace_move_view', + description: + 'Move a shell view to another dock region. Views and regions come from workspace_describe_layout. Undoable.', + risk: 'medium', + inputSchema: { + type: 'object', + properties: { + viewId: { type: 'string', description: 'Slot view id (e.g. tasks, navigator)' }, + region: { + type: 'string', + enum: ['dock.left', 'dock.right', 'dock.bottom', 'dock.corner'], + description: 'Destination region' + } + }, + required: ['viewId', 'region'] + }, + invoke: async (args) => { + const viewId = String(args.viewId) + const region = args.region as RegionId + const view = getSlotView(viewId) + if (!view) { + const known = getSlotViews() + .map((entry) => entry.id) + .join(', ') + throw new Error(`unknown view: ${viewId}. Known views: ${known}`) + } + snapshot() + await emit(`slot.move:${viewId}:${region}`) + const landed = regionOf(useWorkbench.getState().tree, viewId) + announce(`Companion moved ${view.label} to ${region}`) + return landed === region + ? `Moved ${view.label} to ${region}. Undo: workspace.undoLayout.` + : `Could not move ${view.label} to ${region} (not allowed there).` + } + }, + { + id: 'fyi.xnet.workspace-agent.open-view', + name: 'workspace_open_view', + description: 'Open a shell view in whichever dock currently holds it.', + risk: 'low', + inputSchema: { + type: 'object', + properties: { viewId: { type: 'string', description: 'Slot view id' } }, + required: ['viewId'] + }, + invoke: async (args) => { + const viewId = String(args.viewId) + if (!getSlotView(viewId)) throw new Error(`unknown view: ${viewId}`) + await emit(`slot.open:${viewId}`) + return `Opened ${viewId}.` + } + }, + { + id: 'fyi.xnet.workspace-agent.save', + name: 'workspace_save_layout', + description: + 'Ask the user to save the current layout as a named workspace (opens the save dialog for their confirmation — the agent never saves silently).', + risk: 'low', + invoke: async () => { + await emit('workspace.saveAs') + return 'Save dialog opened; the user confirms the name.' + } + }, + { + id: 'fyi.xnet.workspace-agent.scaffold', + name: 'workspace_scaffold_view', + description: + 'Scaffold a new dockable shell view as an installable plugin draft (0280 L5). Returns the generated files plus the consent lines the user will see. The manifest defaults to NO network access; installation always goes through the consent dialog at the ai-generated trust tier — the agent never installs silently.', + risk: 'medium', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Reverse-domain plugin id (e.g. com.you.focus-board)' + }, + name: { type: 'string', description: 'Human-readable view name' }, + schemaRead: { + type: 'array', + items: { type: 'string' }, + description: 'Schema IRIs the view may read (empty = none)' + } + }, + required: ['id', 'name'] + }, + invoke: (args) => { + const capabilities: ModuleCapabilities = { + schemaRead: (args.schemaRead as string[] | undefined) ?? [], + network: [] // provably offline by default (the essay's kitchen) + } + const { files } = scaffoldPlugin({ + id: String(args.id), + name: String(args.name), + template: 'slot-view', + capabilities + }) + const consent = evaluateInstallConsent('ai-generated', capabilities) + return { + files, + trustTier: consent.tier, + consentLines: consent.lines.map((line) => line.text), + note: 'Draft only — review the files, then install via the marketplace/devkit flow; the consent dialog will show these lines.' + } + } + }, + { + id: 'fyi.xnet.workspace-agent.undo', + name: 'workspace_undo_layout', + description: 'Undo the last agent-driven layout change.', + risk: 'low', + invoke: async () => { + await emit('workspace.undoLayout') + return 'Reverted the last layout change.' + } + } + ] + } +} + +const PRESET_COMMAND_TITLES: Record = { + quiet: 'Quiet — bare surface', + calm: 'Calm — everyperson shell', + bench: 'Bench — full workbench' +} + +/** + * The workspace verbs that need no React state: layout undo (also the + * toast's Undo button) and the preset switches. Registered once at module + * load — agent tools, palette and UI all go through these commands. + */ +export function registerWorkspaceCommands(): () => void { + const registry = getCommandRegistry() + const disposables = [ + registry.register({ + id: 'workspace.undoLayout', + title: 'Workspace: Undo layout change', + when: () => undoStack.length > 0, + run: () => { + const previous = undoStack.pop() + if (!previous) return + useWorkbench + .getState() + .loadWorkspace(serializeWorkspacePayload({ name: '', preset: null, tree: previous })) + } + }), + ...PRESET_IDS.map((preset) => + registry.register({ + id: `workspace.preset:${preset}`, + title: `Workspace: Preset: ${PRESET_COMMAND_TITLES[preset]}`, + run: () => useWorkbench.getState().applyPreset(preset) + }) + ) + ] + return () => { + for (const disposable of disposables) disposable.dispose() + } +} + +/** Test seam: the current undo depth. */ +export function workspaceUndoDepth(): number { + return undoStack.length +} diff --git a/apps/web/src/routes/share.tsx b/apps/web/src/routes/share.tsx index 2c4e8c573..a260c12c9 100644 --- a/apps/web/src/routes/share.tsx +++ b/apps/web/src/routes/share.tsx @@ -27,7 +27,7 @@ const BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '') const USE_HASH_ROUTER = import.meta.env.VITE_USE_HASH_ROUTER === 'true' const SHARE_HANDLE_RE = /^sh_[A-Za-z0-9_-]{16,}$/ -type ShareDocType = 'page' | 'database' | 'canvas' | 'dashboard' | 'view' | 'space' +type ShareDocType = 'page' | 'database' | 'canvas' | 'dashboard' | 'view' | 'space' | 'workspace' type SharePayloadV2 = { v: 2 @@ -314,7 +314,10 @@ function getWebFallbackPath(docType: ShareDocType, resource: string, shareSessio canvas: `/canvas/${encodeURIComponent(resource)}`, dashboard: `/dashboard/${encodeURIComponent(resource)}`, view: `/view/${encodeURIComponent(resource)}`, - space: `/space/${encodeURIComponent(resource)}` + space: `/space/${encodeURIComponent(resource)}`, + // Workspaces have no viewer route; land home — the granted node syncs + // and appears in the receiver's workspace switcher (0280). + workspace: `/?sharedWorkspace=${encodeURIComponent(resource)}` } return `${routePrefix}${docPath[docType]}?${query}` diff --git a/apps/web/src/workbench/EditorArea.tsx b/apps/web/src/workbench/EditorArea.tsx index d30862399..e4a0904a0 100644 --- a/apps/web/src/workbench/EditorArea.tsx +++ b/apps/web/src/workbench/EditorArea.tsx @@ -236,6 +236,35 @@ function GroupPane({ ) } +/** + * Starter chips (0280 phase 4, the 0273 empty-state pattern): three dimmed + * affordances on a tabless bench that vanish after the first real tab — + * paralysis mitigation without clutter. Each chip runs the same command + * its chord and palette entry run. + */ +function StarterChips() { + const chips = [ + { id: 'workbench.newPage', label: 'New page', hint: '⌘T' }, + { id: 'workbench.toggleLeftPanel', label: 'Open navigator', hint: '⌘B' }, + { id: 'search.open', label: 'Command palette', hint: '⌘K' } + ] + return ( +
+ {chips.map((chip) => ( + + ))} +
+ ) +} + export function EditorArea({ children }: { children: ReactNode }) { const location = useLocation() const navigate = useNavigate() @@ -262,6 +291,8 @@ export function EditorArea({ children }: { children: ReactNode }) { ? tabIdFor(routedDescriptor.nodeType, routedDescriptor.nodeId) : null + const totalTabs = groups.reduce((count, group) => count + group.tabs.length, 0) + const outlet = ( Math.max(0, depth - 1)) }} > + {totalTabs === 0 && } {groups.map((group, index) => ( diff --git a/apps/web/src/workbench/PanelViewHost.tsx b/apps/web/src/workbench/PanelViewHost.tsx index fd9cfc724..bfb656acf 100644 --- a/apps/web/src/workbench/PanelViewHost.tsx +++ b/apps/web/src/workbench/PanelViewHost.tsx @@ -1,14 +1,23 @@ /** - * PanelViewHost — renders the active view of the Left or Bottom panel - * (exploration 0166). + * PanelViewHost — renders the active view of a dock (exploration 0166, + * slot-registry backed since 0280). * - * Views are registered in a module-level registry so plugin - * contributions can add panel views without touching the shell - * (containers vs items — the VS Code model). The bottom slot renders - * its registered views as panel-local tabs (the tray). + * Views live in the shell-wide slot registry; which dock shows them is the + * layout tree's placement. The header carries the three-roads movement + * affordances: a Move menu (pointer + touch) and a drag handle, both + * dispatching the same `moveSlot` store action the palette commands run. */ import type { ComponentType } from 'react' -import { X } from 'lucide-react' +import { PopoverContent, PopoverRoot, PopoverTrigger } from '@xnetjs/ui' +import { ArrowLeftRight, X } from 'lucide-react' +import { useState } from 'react' +import { regionOf } from './layout-tree' +import { + getSlotView, + movableRegionsFor, + registerSlotView, + slotViewsInRegion +} from './slot-registry' import { useWorkbench, type PanelSide } from './state' export interface PanelViewDefinition { @@ -17,27 +26,86 @@ export interface PanelViewDefinition { component: ComponentType } -const registries: Record<'left' | 'bottom', Map> = { - left: new Map(), - bottom: new Map() -} +const SLOT_TO_REGION = { + left: 'dock.left', + right: 'dock.right', + bottom: 'dock.bottom' +} as const + +/** MIME type for dragging a slot view between docks (0280). */ +export const SLOT_DRAG_TYPE = 'application/x-xnet-slot-view' +/** + * Legacy registration shim (0166 API): panel views are slot contributions + * with the dock as their default region. + */ export function registerPanelView(slot: 'left' | 'bottom', view: PanelViewDefinition): () => void { - registries[slot].set(view.id, view) - return () => { - registries[slot].delete(view.id) - } + return registerSlotView({ + id: view.id, + label: view.title, + tier: 'secondary', + component: view.component, + defaultRegion: SLOT_TO_REGION[slot] + }) +} + +export function getPanelViews(slot: 'left' | 'bottom' | 'right'): PanelViewDefinition[] { + return slotViewsInRegion(SLOT_TO_REGION[slot]).map((view) => ({ + id: view.id, + title: view.label, + component: view.component + })) } -export function getPanelViews(slot: 'left' | 'bottom'): PanelViewDefinition[] { - return [...registries[slot].values()] +/** The Move menu — the pointer/touch twin of the `slot.move:*` commands. */ +export function MoveViewMenu({ viewId }: { viewId: string }) { + const [open, setOpen] = useState(false) + const view = getSlotView(viewId) + const moveSlot = useWorkbench((state) => state.moveSlot) + const currentRegion = useWorkbench((state) => regionOf(state.tree, viewId)) + if (!view) return null + const targets = movableRegionsFor(view).filter(({ region }) => region !== currentRegion) + if (targets.length === 0) return null + return ( + + + + + + {targets.map(({ region, label }) => ( + + ))} + + + ) } export function PanelViewHost({ slot }: { slot: 'left' | 'bottom' }) { const panel = useWorkbench((state) => state[slot as PanelSide]) const setPanelOpen = useWorkbench((state) => state.setPanelOpen) + // Subscribe to tree changes so placements re-render the host. + useWorkbench((state) => state.tree) - const view = registries[slot].get(panel.activeViewId) ?? getPanelViews(slot)[0] + const views = getPanelViews(slot) + const view = views.find((entry) => entry.id === panel.activeViewId) ?? views[0] if (!view) { return ( @@ -51,17 +119,27 @@ export function PanelViewHost({ slot }: { slot: 'left' | 'bottom' }) { return (
-
+
{ + event.dataTransfer.setData(SLOT_DRAG_TYPE, view.id) + event.dataTransfer.effectAllowed = 'move' + }} + > - +
+ + +
diff --git a/apps/web/src/workbench/ShellFrame.tsx b/apps/web/src/workbench/ShellFrame.tsx new file mode 100644 index 000000000..74e4f2411 --- /dev/null +++ b/apps/web/src/workbench/ShellFrame.tsx @@ -0,0 +1,245 @@ +/** + * ShellFrame — one renderer for every shell posture (exploration 0280). + * + * Walks the workbench store's {@link LayoutTree} and renders regions → + * slots → views. The former shells are preset trees: what used to be the + * CalmShell, the workbench grid and the quiet posture are now data-only + * fixtures over this single component. ShellFrame never branches on which + * preset is loaded — only on the tree's axes (chrome posture, slot tiers, + * `surface.tabsEnabled`); the tripwire in layout-tree.test.ts enforces it. + * + * Behind `xnet:experiment:layout-tree` (see experiments.ts); the legacy + * shells render by default until parity is proven. + */ +import type { DragEvent, ReactNode } from 'react' +import { DemoBanner, useDemoMode } from '@xnetjs/react' +import { Group, Panel, useDefaultLayout } from 'react-resizable-panels' +import { GlobalSearch } from '../components/GlobalSearch' +import { UndoToastProvider } from '../components/UndoToast' +import { WorkspaceCommands } from '../components/WorkspaceCommands' +import { CalmSurface } from './calm/CalmSurface' +import { QuietChrome } from './calm/QuietChrome' +import { SurfaceDockLauncher } from './calm/SurfaceDock' +import { useActiveCalmMode } from './calm/use-active-mode' +import { useShellEscape, useWorkbenchCommands, useZenEscape } from './commands' +import { EditorArea } from './EditorArea' +import { useFocusRing } from './focus' +import { Hairline } from './Hairline' +import { slotsIn, type LayoutTree, type RegionId } from './layout-tree' +import { PanelViewHost, SLOT_DRAG_TYPE } from './PanelViewHost' +import { getSlotView } from './slot-registry' +import { useWorkbench, type PanelSide } from './state' + +const FRAME = + 'mt-[var(--storage-banner-height,0px)] flex h-[calc(100dvh-var(--storage-banner-height,0px))] flex-col text-ink-1' + +/** + * Views that bring their own chrome render bare in a dock; everything else + * goes through the PanelViewHost (header, panel tabs, move menu). Keyed by + * view id — never by preset (the tripwire forbids that). + */ +const BARE_VIEW_IDS = new Set(['navigator', 'context', 'inspector']) + +function FrameDemoBanner() { + const { isDemo, limits } = useDemoMode() + if (!isDemo || !limits) return null + return +} + +/** Drop target props: the pointer road's landing zone for a dock (0280). */ +function dropProps(region: RegionId) { + return { + onDragOver: (event: DragEvent) => { + if (event.dataTransfer.types.includes(SLOT_DRAG_TYPE)) event.preventDefault() + }, + onDrop: (event: DragEvent) => { + const viewId = event.dataTransfer.getData(SLOT_DRAG_TYPE) + if (viewId) useWorkbench.getState().moveSlot(viewId, region) + } + } +} + +/** The dock body: the active placement's view, bare or hosted. */ +function DockBody({ tree, region, side }: { tree: LayoutTree; region: RegionId; side: PanelSide }) { + const activeViewId = useWorkbench((state) => state[side].activeViewId) + const placements = slotsIn(tree, region) + const active = placements.find((placement) => placement.viewId === activeViewId) ?? placements[0] + const view = active ? getSlotView(active.viewId) : undefined + if (view && BARE_VIEW_IDS.has(view.id)) { + const Bare = view.component + return ( +
+ +
+ ) + } + // Panel-view docks (the 0166 host: header, tabs, move menu). + const slot = side === 'right' ? undefined : side + return ( +
+ {slot ? : view ? : null} +
+ ) +} + +function EdgeStrip({ tree, region }: { tree: LayoutTree; region: 'rail' | 'status' }) { + const pinned = slotsIn(tree, region, 'pinned') + return ( + <> + {pinned.map((placement) => { + const View = getSlotView(placement.viewId)?.component + return View ? : null + })} + + ) +} + +/** Surface = the center region; tabs are a capability, not a shell. */ +function Surface({ tree, children }: { tree: LayoutTree; children: ReactNode }) { + if (tree.surface.tabsEnabled) return {children} + return {children} +} + +function ZenFrame({ tree, children }: { tree: LayoutTree; children: ReactNode }) { + return ( +
+ + +
+ {children} +
+
+ ) +} + +/** + * Pinned-chrome frame: resizable docks around the surface. Panel sizes are + * device-local and keyed by the workspace id (0280 phase 3) — a saved bench + * carries its placements everywhere, its pixel widths nowhere. + */ +function PinnedFrame({ tree, children }: { tree: LayoutTree; children: ReactNode }) { + // Keep route ↔ mode reconciliation alive in every posture. + useActiveCalmMode() + const left = useWorkbench((state) => state.left) + const right = useWorkbench((state) => state.right) + const bottom = useWorkbench((state) => state.bottom) + + const leftPlaced = slotsIn(tree, 'dock.left').length > 0 + const rightPlaced = slotsIn(tree, 'dock.right').length > 0 + const bottomPlaced = slotsIn(tree, 'dock.bottom').length > 0 + const leftOpen = leftPlaced && left.open + const rightOpen = rightPlaced && right.open + const bottomOpen = bottomPlaced && bottom.open + const cornerPlaced = slotsIn(tree, 'dock.corner').length > 0 + + const horizontal = useDefaultLayout({ + id: `xnet:frame:h:${tree.workspaceId}`, + panelIds: [...(leftOpen ? ['left'] : []), 'center', ...(rightOpen ? ['right'] : [])] + }) + const vertical = useDefaultLayout({ + id: `xnet:frame:v:${tree.workspaceId}`, + panelIds: ['editor', ...(bottomOpen ? ['bottom'] : [])] + }) + + return ( +
+ + + + +
+ + + {leftOpen && ( + <> + + + + + + )} + + + + {children} + + {bottomOpen && ( + <> + + + + + + )} + + + {rightOpen && ( + <> + + + + + + )} + + {/* The corner dock renders wherever the tree places residents — + pinned calm gains the same launcher quiet has (0280). */} + {cornerPlaced && } +
+ + +
+ ) +} + +function QuietFrame({ tree, children }: { tree: LayoutTree; children: ReactNode }) { + const mode = useActiveCalmMode() + return ( +
+ + + + + {children} + +
+ ) +} + +export function ShellFrame({ children }: { children: ReactNode }) { + useWorkbenchCommands() + useZenEscape() + useShellEscape() + useFocusRing() + + const tree = useWorkbench((state) => state.tree) + const mode = useWorkbench((state) => state.mode) + + if (mode === 'zen') { + return ( + + {children} + + ) + } + + return ( + + {tree.chrome === 'quiet' ? ( + {children} + ) : ( + {children} + )} + + ) +} diff --git a/apps/web/src/workbench/TabBar.tsx b/apps/web/src/workbench/TabBar.tsx index b4f59319c..5c3f0dd47 100644 --- a/apps/web/src/workbench/TabBar.tsx +++ b/apps/web/src/workbench/TabBar.tsx @@ -9,7 +9,7 @@ */ import { useNavigate } from '@tanstack/react-router' import { getNodeTransfer, hasNodeTransfer, setNodeTransfer, type NodeTransfer } from '@xnetjs/ui' -import { Pin, X } from 'lucide-react' +import { FileText, Pin, X } from 'lucide-react' import { useState } from 'react' import { navigateToNode } from './navigation' import { useWorkbench, type EditorGroup, type TabNodeType, type WorkbenchTab } from './state' @@ -144,7 +144,8 @@ function TabItem({ }) { const navigate = useNavigate() const [dropEdge, setDropEdge] = useState<'before' | 'after' | null>(null) - const Icon = TAB_VIEWS[tab.nodeType].icon + // Defensive: a tab persisted by a newer/other build must render, not crash. + const Icon = TAB_VIEWS[tab.nodeType]?.icon ?? FileText const activate = () => { const state = useWorkbench.getState() diff --git a/apps/web/src/workbench/Workbench.tsx b/apps/web/src/workbench/Workbench.tsx index a69b0a427..e2d072fde 100644 --- a/apps/web/src/workbench/Workbench.tsx +++ b/apps/web/src/workbench/Workbench.tsx @@ -20,15 +20,18 @@ import { CalmShell } from './calm/CalmShell' import { useWorkbenchCommands, useZenEscape } from './commands' import { ContextPanel } from './ContextPanel' import { EditorArea } from './EditorArea' +import { isLayoutTreeEnabled } from './experiments' import { useFocusRing } from './focus' import { Hairline } from './Hairline' import { MobileShell } from './MobileShell' import { PanelViewHost } from './PanelViewHost' import { Rail } from './Rail' +import { ShellFrame } from './ShellFrame' import { useWorkbench } from './state' import { StatusBar } from './StatusBar' import { useIsCompact } from './use-layout-mode' import { registerBuiltinPanelViews } from './views/register' +import { WorkspaceSwitcher } from './WorkspaceSwitcher' registerBuiltinPanelViews() @@ -115,9 +118,24 @@ function WorkbenchDemoBanner() { export function Workbench({ children }: { children: ReactNode }) { const compact = useIsCompact() const layout = useWorkbench((state) => state.layout) + const tabsEnabled = useWorkbench((state) => state.tree.surface.tabsEnabled) + // 0280: behind the layout-tree flag the ShellFrame renders every posture + // from the tree; the legacy fork below stays the default until parity. + // Mobile projections read the tree's axes too (tabsEnabled, not layout). + const treeShell = isLayoutTreeEnabled() return ( <> - {layout === 'calm' ? ( + {treeShell ? ( + compact ? ( + tabsEnabled ? ( + {children} + ) : ( + {children} + ) + ) : ( + {children} + ) + ) : layout === 'calm' ? ( // Everyperson shell (0250): the same three-mode grammar at every width — // CalmMobile reflows it to a bottom-tab phone layout (Phase 4), CalmShell // is the desktop/tablet composition. @@ -131,6 +149,8 @@ export function Workbench({ children }: { children: ReactNode }) { ) : ( {children} )} + {/* Workspace quick switcher + verbs (0280) — commands exist in every shell. */} + {/* First-run coachmarks (0206) — portals to , so position here is moot. */} {/* Opt-in "time well spent" wind-down (Charter §Calm, 0234); off by default. */} diff --git a/apps/web/src/workbench/WorkspaceSwitcher.tsx b/apps/web/src/workbench/WorkspaceSwitcher.tsx new file mode 100644 index 000000000..37ccb9888 --- /dev/null +++ b/apps/web/src/workbench/WorkspaceSwitcher.tsx @@ -0,0 +1,310 @@ +/** + * WorkspaceSwitcher — saved layouts as nodes (exploration 0280 phase 3). + * + * The quick switcher over `xnet:workspace` nodes plus the workspace verbs + * as palette commands: Save as… forks the current tree into a node, + * Switch loads one (presets are always listed, seeded or not), Reset + * returns to the tree's preset provenance, Share opens the normal node + * ShareDialog — a bench travels like any other node. + * + * Only the portable tree lives in the node; pixel sizes stay device-local + * (react-resizable-panels state keyed by workspaceId in ShellFrame). + */ +import { createNodeId, WorkspaceSchema } from '@xnetjs/data' +import { getCommandRegistry } from '@xnetjs/plugins' +import { useMutate, useQuery } from '@xnetjs/react' +import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '@xnetjs/ui' +import { Layers, Save } from 'lucide-react' +import { useEffect, useRef, useState, type JSX } from 'react' +import { contributeTips } from '../coachmarks' +import { ShareDialog } from '../components/ShareDialog' +import { AGENT_LAYOUT_EVENT } from '../plugins/workspace-agent-module' +import { + isPresetWorkspaceId, + parseWorkspacePayload, + PRESET_IDS, + presetForWorkspaceId, + serializeWorkspacePayload, + type PresetId, + type WorkspacePayload +} from './layout-tree' +import { useWorkbench } from './state' + +// One first-run tip (0206): the layout is yours to keep — say so at the +// button that proves it. Registered for the home list view; the anchor only +// exists in the pinned calm shell, so nobody else ever sees it. +contributeTips([ + { + id: 'home:workspace-save@1', + view: 'home', + anchor: '[data-coach="workspace.switch"]', + title: 'Layouts are yours to keep', + body: 'Arrange the shell, then “Workspace: Save as…” (⌘K) keeps it — switch, share or reset any time.', + side: 'right' + } +]) + +const PRESET_TITLES: Record = { + quiet: 'Quiet — bare surface', + calm: 'Calm — everyperson shell', + bench: 'Bench — full workbench' +} + +interface WorkspaceRow { + id: string + name: string + preset: string + tree: unknown +} + +/** Sanitize a workspace node into a loadable payload (never trust sync). */ +function payloadFromRow(row: WorkspaceRow): WorkspacePayload | null { + // `preset: 'none'` (the node default) falls out as null in the parser. + const parsed = parseWorkspacePayload({ name: row.name, preset: row.preset, tree: row.tree }) + if (!parsed) return null + // The node id is the tree's identity — a forked/duplicated node must not + // impersonate another workspace's device-local sizes. + return { ...parsed, tree: { ...parsed.tree, workspaceId: row.id } } +} + +/** + * Agent-change toast (0280 phase 5): when the companion edits the layout + * it announces the change; this shows "Companion moved Tasks — Undo" with + * the Undo button running the shared `workspace.undoLayout` command. + */ +function AgentChangeToast(): JSX.Element | null { + const [message, setMessage] = useState(null) + useEffect(() => { + let timer: ReturnType | undefined + const onChange = (event: Event) => { + setMessage((event as CustomEvent<{ message: string }>).detail.message) + clearTimeout(timer) + timer = setTimeout(() => setMessage(null), 8000) + } + window.addEventListener(AGENT_LAYOUT_EVENT, onChange) + return () => { + clearTimeout(timer) + window.removeEventListener(AGENT_LAYOUT_EVENT, onChange) + } + }, []) + if (!message) return null + return ( +
+ {message} + +
+ ) +} + +export function WorkspaceSwitcher(): JSX.Element | null { + const [open, setOpen] = useState(false) + const [saving, setSaving] = useState(false) + const [query, setQuery] = useState('') + const [shareFor, setShareFor] = useState(null) + + const { data } = useQuery(WorkspaceSchema) + const { create } = useMutate() + + const rows: WorkspaceRow[] = (data ?? []).map((node) => ({ + id: node.id, + name: (node.name as string) ?? 'Untitled workspace', + preset: (node.preset as string) ?? 'none', + tree: node.tree + })) + const rowsRef = useRef(rows) + rowsRef.current = rows + const createRef = useRef(create) + createRef.current = create + + // ─── Verbs ───────────────────────────────────────────────────── + const saveCurrentAs = async (name: string) => { + const state = useWorkbench.getState() + const id = createNodeId() + const preset = presetForWorkspaceId(state.tree.workspaceId) + const payload = serializeWorkspacePayload({ + name, + preset, + tree: { ...state.tree, workspaceId: id } + }) + await createRef.current( + WorkspaceSchema, + { + name, + description: '', + preset: preset ?? 'none', + system: 'user', + tree: payload.tree + }, + id + ) + state.loadWorkspace(payload) + } + + const loadRow = (row: WorkspaceRow) => { + const payload = payloadFromRow(row) + if (payload) useWorkbench.getState().loadWorkspace(payload) + } + + useEffect(() => { + const registry = getCommandRegistry() + const disposables = [ + registry.register({ + id: 'workspace.switch', + title: 'Workspace: Switch…', + run: () => { + setSaving(false) + setQuery('') + setOpen(true) + } + }), + registry.register({ + id: 'workspace.saveAs', + title: 'Workspace: Save as…', + run: () => { + setSaving(true) + setQuery('') + setOpen(true) + } + }), + registry.register({ + id: 'workspace.reset', + title: 'Workspace: Reset to preset', + run: () => { + const state = useWorkbench.getState() + const fromId = presetForWorkspaceId(state.tree.workspaceId) + if (fromId) { + state.applyPreset(fromId) + return + } + const row = rowsRef.current.find((entry) => entry.id === state.tree.workspaceId) + const preset = row && row.preset !== 'none' ? (row.preset as PresetId) : 'calm' + state.applyPreset(preset) + } + }), + registry.register({ + id: 'workspace.share', + title: 'Workspace: Share…', + when: () => !isPresetWorkspaceId(useWorkbench.getState().tree.workspaceId), + run: () => setShareFor(useWorkbench.getState().tree.workspaceId) + }) + // Preset commands (`workspace.preset:*`) register headlessly in + // plugins/workspace-agent-module.ts, shared with the agent tools. + ] + return () => { + for (const disposable of disposables) disposable.dispose() + } + }, []) + + if (shareFor) { + return ( + <> + setShareFor(null)} + /> + + + ) + } + + if (!open) return + const close = () => setOpen(false) + + const filtered = rows.filter( + (row) => !query || row.name.toLowerCase().includes(query.toLowerCase()) + ) + + return ( +
+
event.stopPropagation()}> + + { + if (event.key === 'Escape') { + event.preventDefault() + close() + } + if (saving && event.key === 'Enter' && query.trim()) { + event.preventDefault() + void saveCurrentAs(query.trim()).then(close) + } + }} + /> + + {saving ? ( + void saveCurrentAs(query.trim()).then(close)} + > + + Save current layout as “{query.trim() || '…'}” + + ) : ( + <> + No workspaces match. + {PRESET_IDS.map((preset) => ( + { + useWorkbench.getState().applyPreset(preset) + close() + }} + > + + {PRESET_TITLES[preset]} + + ))} + {filtered.map((row) => ( + { + loadRow(row) + close() + }} + > + + {row.name} + + ))} + { + setSaving(true) + setQuery('') + }} + > + + Save current layout as… + + + )} + + +
+
+ ) +} diff --git a/apps/web/src/workbench/builtin-slot-views.tsx b/apps/web/src/workbench/builtin-slot-views.tsx new file mode 100644 index 000000000..e74d9ffde --- /dev/null +++ b/apps/web/src/workbench/builtin-slot-views.tsx @@ -0,0 +1,246 @@ +/** + * First-party slot views (0280): the calm frame views, the 0166 panel + * views and the 0273 dock residents, registered once into the shell-wide + * slot registry. Kept apart from slot-registry.tsx so the registry stays + * component-free (no import cycles through Rail/StatusBar/contributions). + */ +import type { SlotContribution } from '@xnetjs/plugins' +import type { ComponentType } from 'react' +import { + Archive, + Bell, + Bot, + CalendarDays, + Compass, + Database, + FolderTree, + Info, + ListTree, + MessagesSquare, + PanelBottom, + PanelRight, + PenLine, + RefreshCw, + SquareCheck, + Terminal +} from 'lucide-react' +import { ChatsPanel } from '../comms/ChatsPanel' +import { Canvas } from './calm/Canvas' +import { ListPane } from './calm/ListPane' +import { ModeSwitch } from './calm/ModeSwitch' +import { useActiveCalmMode } from './calm/use-active-mode' +import { ContextPanel } from './ContextPanel' +import { Rail } from './Rail' +import { getSlotView, registerSlotView } from './slot-registry' +import { StatusBar } from './StatusBar' +import { AiChatPanel } from './views/AiChatPanel' +import { Explorer } from './views/Explorer' +import { DataPanelView, TasksPanelView } from './views/left' +import { ShelfTray } from './views/Shelf' +import { TodayPanel } from './views/TodayPanel' +import { NotificationsTray, QueryConsoleTray, QuickCaptureTray, SyncTray } from './views/tray' + +function NavigatorSlotView() { + const mode = useActiveCalmMode() + return +} + +/** Wrap a bare component so registry entries stay plain ComponentTypes. */ +function asComponent(Component: ComponentType): ComponentType { + return Component +} + +/** + * First-party residents, registered once (idempotent): the calm frame + * views, the 0166 panel views, and the 0273 dock residents — one registry, + * three former homes. + */ +export function registerBuiltinSlotViews(): void { + const builtin: SlotContribution[] = [ + // Frame views + { + id: 'navigator', + icon: ListTree, + label: 'Navigator', + tier: 'hero', + group: 'navigate', + priority: 0, + component: NavigatorSlotView, + defaultRegion: 'dock.left', + keywords: ['list', 'documents'] + }, + { + id: 'context', + icon: PanelRight, + label: 'Context', + tier: 'hero', + group: 'navigate', + priority: 1, + component: asComponent(Canvas), + defaultRegion: 'dock.right', + keywords: ['canvas', 'artifact', 'inspector'] + }, + { + id: 'inspector', + icon: Info, + label: 'Inspector', + tier: 'secondary', + group: 'navigate', + priority: 2, + component: asComponent(ContextPanel), + defaultRegion: 'dock.right', + keywords: ['properties', 'backlinks', 'comments'] + }, + // Edge strips (not movable into docks) + { + id: 'modes', + icon: Compass, + label: 'Mode switch', + tier: 'secondary', + group: 'navigate', + priority: 3, + component: asComponent(ModeSwitch), + defaultRegion: 'rail', + allowedRegions: ['rail'] + }, + { + id: 'rail', + icon: PanelBottom, + label: 'Rail', + tier: 'secondary', + group: 'navigate', + priority: 4, + component: asComponent(Rail), + defaultRegion: 'rail', + allowedRegions: ['rail'] + }, + { + id: 'status', + icon: PanelBottom, + label: 'Status bar', + tier: 'secondary', + group: 'navigate', + priority: 5, + component: asComponent(StatusBar), + defaultRegion: 'status', + allowedRegions: ['status'] + }, + // 0166 left-panel views + { + id: 'explorer', + icon: FolderTree, + label: 'Explorer', + tier: 'hero', + group: 'navigate', + priority: 10, + component: asComponent(Explorer), + defaultRegion: 'dock.left' + }, + { + id: 'chats', + icon: MessagesSquare, + label: 'Chats', + tier: 'secondary', + group: 'navigate', + priority: 11, + component: asComponent(ChatsPanel), + defaultRegion: 'dock.left' + }, + { + id: 'tasks', + icon: SquareCheck, + label: 'Tasks', + tier: 'hero', + group: 'navigate', + priority: 12, + component: asComponent(TasksPanelView), + defaultRegion: 'dock.left' + }, + { + id: 'today', + icon: CalendarDays, + label: 'Today', + tier: 'secondary', + group: 'navigate', + priority: 13, + component: asComponent(TodayPanel), + defaultRegion: 'dock.left' + }, + { + id: 'data', + icon: Database, + label: 'Data', + tier: 'secondary', + group: 'tools', + priority: 14, + component: asComponent(DataPanelView), + defaultRegion: 'dock.left' + }, + { + id: 'ai-chat', + icon: Bot, + label: 'AI', + tier: 'secondary', + group: 'tools', + priority: 15, + component: asComponent(AiChatPanel), + defaultRegion: 'dock.left' + }, + // 0273 dock residents + { + id: 'shelf', + icon: Archive, + label: 'Shelf', + tier: 'hero', + group: 'capture', + priority: 20, + component: asComponent(ShelfTray), + defaultRegion: 'dock.corner' + }, + { + id: 'capture', + icon: PenLine, + label: 'Capture', + tier: 'hero', + group: 'capture', + priority: 21, + component: asComponent(QuickCaptureTray), + defaultRegion: 'dock.corner' + }, + { + id: 'notifications', + icon: Bell, + label: 'Notifications', + tier: 'hero', + group: 'activity', + priority: 22, + component: asComponent(NotificationsTray), + defaultRegion: 'dock.corner' + }, + { + id: 'sync', + icon: RefreshCw, + label: 'Sync', + tier: 'secondary', + group: 'activity', + priority: 23, + keywords: ['status', 'hub'], + component: asComponent(SyncTray), + defaultRegion: 'dock.corner' + }, + { + id: 'console', + icon: Terminal, + label: 'Console', + tier: 'secondary', + group: 'tools', + priority: 24, + keywords: ['query', 'sql'], + component: asComponent(QueryConsoleTray), + defaultRegion: 'dock.corner' + } + ] + for (const view of builtin) { + if (!getSlotView(view.id)) registerSlotView(view) + } +} diff --git a/apps/web/src/workbench/calm/CalmShell.tsx b/apps/web/src/workbench/calm/CalmShell.tsx index 90ea593dc..3d4666b15 100644 --- a/apps/web/src/workbench/calm/CalmShell.tsx +++ b/apps/web/src/workbench/calm/CalmShell.tsx @@ -12,9 +12,7 @@ * and every existing view renders unchanged in the Surface. */ import type { ReactNode } from 'react' -import { useLocation } from '@tanstack/react-router' import { DemoBanner, useDemoMode } from '@xnetjs/react' -import { useEffect } from 'react' import { GlobalSearch } from '../../components/GlobalSearch' import { UndoToastProvider } from '../../components/UndoToast' import { WorkspaceCommands } from '../../components/WorkspaceCommands' @@ -24,9 +22,9 @@ import { useWorkbench } from '../state' import { CalmSurface } from './CalmSurface' import { Canvas } from './Canvas' import { ListPane } from './ListPane' -import { modeForPath } from './modes' import { ModeSwitch } from './ModeSwitch' import { QuietChrome } from './QuietChrome' +import { useActiveCalmMode } from './use-active-mode' const CALM_FRAME = 'mt-[var(--storage-banner-height,0px)] flex h-[calc(100dvh-var(--storage-banner-height,0px))] flex-col bg-surface-1 text-ink-1' @@ -42,22 +40,13 @@ export function CalmShell({ children }: { children: ReactNode }) { useZenEscape() useFocusRing() - const { pathname } = useLocation() const mode = useWorkbench((state) => state.mode) const chrome = useWorkbench((state) => state.chrome) - const storedMode = useWorkbench((state) => state.calmMode) - const setCalmMode = useWorkbench((state) => state.setCalmMode) const listOpen = useWorkbench((state) => state.left.open) const canvasOpen = useWorkbench((state) => state.right.open) - // The route is authoritative for the active mode (so deep links + back/forward - // keep the List and ModeSwitch honest); modeless surfaces (settings) fall back - // to the last real mode. Persist that fallback so it survives navigation. - const routeMode = modeForPath(pathname) - const activeMode = routeMode ?? storedMode - useEffect(() => { - if (routeMode && routeMode !== storedMode) setCalmMode(routeMode) - }, [routeMode, storedMode, setCalmMode]) + // Route ↔ mode reconciliation, shared with the ShellFrame (0280). + const activeMode = useActiveCalmMode() // Focus (zen): chrome hidden, just the surface — same affordance as the // workbench, restored on exit. diff --git a/apps/web/src/workbench/calm/ModeSwitch.tsx b/apps/web/src/workbench/calm/ModeSwitch.tsx index aabe397ac..a865caeb5 100644 --- a/apps/web/src/workbench/calm/ModeSwitch.tsx +++ b/apps/web/src/workbench/calm/ModeSwitch.tsx @@ -10,7 +10,7 @@ import { Link, useLocation, useNavigate } from '@tanstack/react-router' import { getCommandRegistry } from '@xnetjs/plugins' import { useIdentity } from '@xnetjs/react' -import { Search, Settings, type LucideIcon } from 'lucide-react' +import { Layers, Search, Settings, type LucideIcon } from 'lucide-react' import { useWorkbench } from '../state' import { CALM_MODES, modeForPath } from './modes' @@ -88,6 +88,18 @@ export function ModeSwitch() {
+ {/* Workspace switcher (0280): the pointer road to Save/Switch/Reset. */} + + {identity && (
state[side].activeViewId) + const tree = useWorkbench((state) => state.tree) + const region = side === 'left' ? 'dock.left' : 'dock.right' + const placements = slotsIn(tree, region) + const active = placements.find((placement) => placement.viewId === activeViewId) ?? placements[0] + const view = active ? getSlotView(active.viewId) : undefined + if (!view) return null + return +} + export function QuietChrome({ activeMode, children @@ -359,14 +375,14 @@ export function QuietChrome({ {/* The List → left overlay; the contextual Canvas → right overlay. Esc/scrim dismissal via the Sheet dialog. */} - + - +
) diff --git a/apps/web/src/workbench/calm/SurfaceDock.tsx b/apps/web/src/workbench/calm/SurfaceDock.tsx index 0fcdd50c1..c8631c08a 100644 --- a/apps/web/src/workbench/calm/SurfaceDock.tsx +++ b/apps/web/src/workbench/calm/SurfaceDock.tsx @@ -12,102 +12,40 @@ * so ⌘J toggles it in quiet posture exactly as it toggles the tray when the * chrome is pinned — same state, different clothes. */ -import { - getCommandRegistry, - type SurfaceDockContribution, - type SurfaceDockTier -} from '@xnetjs/plugins' +import type { SurfaceDockContribution, SurfaceDockTier } from '@xnetjs/plugins' import { Presence } from '@xnetjs/ui' -import { - Archive, - Bell, - LayoutGrid, - MoreHorizontal, - PenLine, - RefreshCw, - Terminal, - X, - type LucideIcon -} from 'lucide-react' +import { LayoutGrid, MoreHorizontal, X, type LucideIcon } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' +import { registerBuiltinSlotViews } from '../builtin-slot-views' +import { MoveViewMenu } from '../PanelViewHost' +import { registerSlotView, slotViewsInRegion } from '../slot-registry' import { useWorkbench } from '../state' -import { ShelfTray } from '../views/Shelf' -import { NotificationsTray, QueryConsoleTray, QuickCaptureTray, SyncTray } from '../views/tray' -// ─── Registry ────────────────────────────────────────────────────── - -const dockRegistry = new Map() +// ─── Registry (slot-registry backed since 0280) ──────────────────── +/** + * Register a dock panel — a slot view defaulting to the corner dock. + * Kept as the 0273 API; new code should use `registerSlotView` directly. + */ export function registerSurfaceDockPanel(item: SurfaceDockContribution): () => void { - dockRegistry.set(item.id, item) - return () => { - dockRegistry.delete(item.id) - } + return registerSlotView({ defaultRegion: 'dock.corner', ...item }) } +/** + * The corner dock's current residents: views the layout tree places in + * `dock.corner` (plus unplaced views defaulting there), by tier. + */ export function getSurfaceDockPanels(tier?: SurfaceDockTier): SurfaceDockContribution[] { - const all = [...dockRegistry.values()].sort( - (a, b) => (a.priority ?? 0) - (b.priority ?? 0) || a.label.localeCompare(b.label) - ) + const all = slotViewsInRegion('dock.corner') return tier ? all.filter((item) => item.tier === tier) : all } /** - * First-party residents: the tray views migrate here in quiet posture (their - * ids match the `bottom` panel-view ids, so `showPanelView('bottom', id)` - * works identically in both postures). + * First-party residents (0273): registration now lives in the shared slot + * registry; this remains the dock's idempotent entry point. */ export function registerBuiltinSurfaceDock(): void { - const builtin: SurfaceDockContribution[] = [ - { - id: 'shelf', - label: 'Shelf', - icon: Archive, - tier: 'hero', - group: 'capture', - priority: 0, - component: ShelfTray - }, - { - id: 'capture', - label: 'Capture', - icon: PenLine, - tier: 'hero', - group: 'capture', - priority: 1, - component: QuickCaptureTray - }, - { - id: 'notifications', - label: 'Notifications', - icon: Bell, - tier: 'hero', - group: 'activity', - priority: 2, - component: NotificationsTray - }, - { - id: 'sync', - label: 'Sync', - icon: RefreshCw, - tier: 'secondary', - group: 'activity', - priority: 3, - keywords: ['status', 'hub'], - component: SyncTray - }, - { - id: 'console', - label: 'Console', - icon: Terminal, - tier: 'secondary', - group: 'tools', - priority: 4, - keywords: ['query', 'sql'], - component: QueryConsoleTray - } - ] - for (const item of builtin) dockRegistry.set(item.id, item) + registerBuiltinSlotViews() } function iconFor(item: SurfaceDockContribution): LucideIcon { @@ -141,24 +79,6 @@ function DockItemButton({ ) } -/** Register `Dock:
- +
+ + +
@@ -308,7 +231,7 @@ export function SurfaceDockLauncher({ lit }: { lit: boolean }) { const panelOpen = bottom.open && active != null const stripVisible = expanded || panelOpen - useDockCommands(all) + // Palette road: the slot registry's `slot.open:` commands (0280). const close = useCallback(() => setPanelOpen('bottom', false), [setPanelOpen]) useDockEscape(panelOpen, close) diff --git a/apps/web/src/workbench/calm/use-active-mode.ts b/apps/web/src/workbench/calm/use-active-mode.ts new file mode 100644 index 000000000..ed3efa61a --- /dev/null +++ b/apps/web/src/workbench/calm/use-active-mode.ts @@ -0,0 +1,24 @@ +/** + * Route ↔ mode reconciliation (0250, shared since 0280). + * + * The route is authoritative for the active calm mode (deep links and + * back/forward keep the List and ModeSwitch honest); modeless surfaces + * (settings) fall back to the last real mode, persisted so it survives + * navigation. Extracted from CalmShell so the ShellFrame reconciles the + * same way without duplicating the effect. + */ +import { useLocation } from '@tanstack/react-router' +import { useEffect } from 'react' +import { useWorkbench, type CalmMode } from '../state' +import { modeForPath } from './modes' + +export function useActiveCalmMode(): CalmMode { + const { pathname } = useLocation() + const storedMode = useWorkbench((state) => state.calmMode) + const setCalmMode = useWorkbench((state) => state.setCalmMode) + const routeMode = modeForPath(pathname) + useEffect(() => { + if (routeMode && routeMode !== storedMode) setCalmMode(routeMode) + }, [routeMode, storedMode, setCalmMode]) + return routeMode ?? storedMode +} diff --git a/apps/web/src/workbench/commands.ts b/apps/web/src/workbench/commands.ts index 830380b66..24af4ccd0 100644 --- a/apps/web/src/workbench/commands.ts +++ b/apps/web/src/workbench/commands.ts @@ -107,6 +107,36 @@ export function useWorkbenchCommands(): void { }, []) } +/** + * 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() + 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) diff --git a/apps/web/src/workbench/experiments.ts b/apps/web/src/workbench/experiments.ts new file mode 100644 index 000000000..ef3ce0e2a --- /dev/null +++ b/apps/web/src/workbench/experiments.ts @@ -0,0 +1,22 @@ +/** + * Workbench experiment flags (0280). + * + * Same staged-rollout pattern as the Desk flags in `lib/desk.ts`: opt-in + * via localStorage while dogfooding; flipping the default later is + * inverting the check, never a migration. + */ + +/** + * Render the shell from the layout tree via ShellFrame (0280 phase 1) + * instead of the legacy CalmShell / workbench-grid fork. The tree state is + * always maintained; this flag only chooses the renderer. + */ +export const LAYOUT_TREE_KEY = 'xnet:experiment:layout-tree' + +export function isLayoutTreeEnabled(): boolean { + try { + return localStorage.getItem(LAYOUT_TREE_KEY) === '1' + } catch { + return false + } +} diff --git a/apps/web/src/workbench/layout-tree.ts b/apps/web/src/workbench/layout-tree.ts new file mode 100644 index 000000000..2e79c4b83 --- /dev/null +++ b/apps/web/src/workbench/layout-tree.ts @@ -0,0 +1,30 @@ +/** + * LayoutTree (0280) — canonical module lives in @xnetjs/plugins + * (`workspace/layout-tree`), shared with the seed and the desktop shell. + * This shim keeps the workbench's local import paths stable. + */ +export { + createPresetTree, + 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/apps/web/src/workbench/shell-escape.test.tsx b/apps/web/src/workbench/shell-escape.test.tsx new file mode 100644 index 000000000..2f22f8e70 --- /dev/null +++ b/apps/web/src/workbench/shell-escape.test.tsx @@ -0,0 +1,83 @@ +/** + * 0280 validation: the pinned Esc ladder walks down one dock per press + * (bottom → right → left → bare surface), and a slot move re-renders only + * tree subscribers — never the whole frame (profiler bound). + */ +import { render } from '@testing-library/react' +import React, { useRef } from 'react' +import { beforeEach, describe, expect, it } from 'vitest' +import { useShellEscape } from './commands' +import { useWorkbench } from './state' + +function EscapeProbe() { + useShellEscape() + return null +} + +function pressEscape() { + document.body.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ) +} + +beforeEach(() => { + useWorkbench.getState().applyPreset('bench') + useWorkbench.setState({ + left: { open: true, activeViewId: 'explorer' }, + right: { open: true, activeViewId: 'context' }, + bottom: { open: true, activeViewId: 'shelf' }, + chrome: 'pinned', + mode: 'default' + }) +}) + +describe('Esc ladder (pinned frame)', () => { + it('closes one dock per press: bottom, right, left, then rests', () => { + render() + pressEscape() + expect(useWorkbench.getState().bottom.open).toBe(false) + expect(useWorkbench.getState().right.open).toBe(true) + pressEscape() + expect(useWorkbench.getState().right.open).toBe(false) + expect(useWorkbench.getState().left.open).toBe(true) + pressEscape() + expect(useWorkbench.getState().left.open).toBe(false) + // Bare surface: a further Esc is a no-op, not an error. + pressEscape() + expect(useWorkbench.getState().left.open).toBe(false) + }) + + it('never steals Esc from text inputs', () => { + render( + <> + + + + ) + const field = document.querySelector('input') as HTMLInputElement + field.focus() + field.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + expect(useWorkbench.getState().bottom.open).toBe(true) + }) +}) + +/** Renders on every commit; subscribed to an UNRELATED store slice. */ +function RenderCounter({ counter }: { counter: { count: number } }) { + const recents = useWorkbench((state) => state.recents) + const renders = useRef(0) + renders.current += 1 + counter.count = renders.current + return +} + +describe('slot move re-render bound (0280 validation)', () => { + it('does not re-render components subscribed to unrelated slices', () => { + const counter = { count: 0 } + render() + const before = counter.count + useWorkbench.getState().moveSlot('console', 'dock.corner') + useWorkbench.getState().moveSlot('shelf', 'dock.right') + useWorkbench.getState().setSlotTier('capture', 'hidden') + expect(counter.count).toBe(before) + }) +}) diff --git a/apps/web/src/workbench/shell-tripwire.test.ts b/apps/web/src/workbench/shell-tripwire.test.ts new file mode 100644 index 000000000..c5adc0906 --- /dev/null +++ b/apps/web/src/workbench/shell-tripwire.test.ts @@ -0,0 +1,34 @@ +/** + * The preset tripwire (0280 risk 2): if shell components branch on which + * preset is loaded, we have rebuilt the three-shell fork inside one + * component. Presets must stay data-only — components read the tree's + * axes (chrome, tiers, tabsEnabled), never the preset identity. + */ +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const FORBIDDEN = [ + /\bpreset(Id)?\s*===/, + /\bworkspaceId\s*===\s*['"`]/, + /presetForWorkspaceId\([^)]*\)\s*===/ +] + +function componentFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return componentFiles(path) + return entry.name.endsWith('.tsx') && !entry.name.endsWith('.test.tsx') ? [path] : [] + }) +} + +describe('preset tripwire', () => { + it('no shell component branches on the loaded preset', () => { + const offenders: string[] = [] + for (const file of componentFiles(__dirname)) { + const source = readFileSync(file, 'utf8') + if (FORBIDDEN.some((pattern) => pattern.test(source))) offenders.push(file) + } + expect(offenders).toEqual([]) + }) +}) diff --git a/apps/web/src/workbench/slot-registry.tsx b/apps/web/src/workbench/slot-registry.tsx new file mode 100644 index 000000000..c0c8ac5cc --- /dev/null +++ b/apps/web/src/workbench/slot-registry.tsx @@ -0,0 +1,129 @@ +/** + * Slot registry — every shell panel is a movable SlotContribution (0280). + * + * One registry replaces the three parallel ones the shells grew (the 0166 + * PanelViewHost maps, the 0273 SurfaceDock map, and the ShellFrame's bare + * view tables). *What exists* lives here; *where it sits* lives in the + * workbench store's LayoutTree — a view's `defaultRegion` only applies + * until the user (or their agent) moves it. + * + * Registering a view also registers its movement verbs as palette + * commands (`View: Move