Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slot-contributions-0280.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/workspace-schema-0280.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions apps/electron/src/renderer/shell/workspace-parity.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
10 changes: 9 additions & 1 deletion apps/web/src/hooks/useShareLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
53 changes: 53 additions & 0 deletions apps/web/src/plugins/workbench-slash-plugin.ts
Original file line number Diff line number Diff line change
@@ -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')
}
]
}
}
91 changes: 91 additions & 0 deletions apps/web/src/plugins/workspace-agent-module.test.ts
Original file line number Diff line number Diff line change
@@ -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 { regionOf } from '../workbench/layout-tree'
import { registerBuiltinSlotViews } from '../workbench/slot-registry'
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<string, string>; 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'])
})
})
Loading
Loading