Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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.
30 changes: 30 additions & 0 deletions .storybook/shims/xnet-plugins-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
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 { 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<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