Files
tutti/docs/architecture/workbench-contributions.md
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

17 KiB

Workbench Contributions

This document defines the current compatible extension model for @tutti-os/workbench-surface.

The goal is to let hosts move from one large, centralized workbench host assembly layer toward feature-owned workbench contributions without breaking existing consumers that already integrate WorkbenchHost directly.

Problem Summary

WorkbenchHost currently exposes a flat host API:

  • nodes
  • dockEntries
  • externalStateSource
  • onLaunchRequest
  • onNodeCloseRequest
  • snapshotRepository
  • other host shell inputs such as workspaceId, missionControl, and layout constraints

That API is still valid and should remain the primary public integration model for current consumers. It works well for small hosts and for hosts that want one composition root.

The pressure appears when a host grows multiple reusable workbench capabilities:

  • browser node integration
  • terminal node integration
  • issue-manager integration
  • files node integration
  • future agent, preview, search, or provider-owned nodes

Without another composition layer, hosts tend to accumulate one increasingly large assembly service that owns:

  • node definitions
  • dock entry wiring
  • external state composition
  • launch dispatch
  • close-policy dispatch
  • package-specific host adapters

That assembly style becomes harder to maintain, but replacing the current WorkbenchHost props with a new required model would break downstream consumers.

Decision

WorkbenchHost accepts an optional contributions input while preserving the explicit host props and their behavior.

contributions are an additive composition mechanism, not a replacement host API.

Compatibility rule:

  • existing WorkbenchHost integrations must continue to work without code changes
  • existing explicit props remain supported and keep their current semantics
  • contributions are optional and may be adopted incrementally

New workbench modules should default to contribution-based integration. Direct top-level nodes, dockEntries, externalStateSource, onLaunchRequest, and onNodeCloseRequest wiring should be reserved for compatibility, host-wide policy, or deliberately simple shells.

Design Goals

The contribution model should:

  1. preserve backward compatibility for current WorkbenchHost consumers
  2. let feature-owning packages participate in workbench integration without forcing all assembly back into one app-level host service
  3. keep host-specific business policy, preload access, and daemon adapters in the consuming host
  4. make merge behavior deterministic and easy to explain to downstream users
  5. keep workbench-surface free of product-specific workflow knowledge

Non-Goals

This change does not try to:

  • remove or deprecate the flat WorkbenchHost prop model
  • move preload APIs, Electron globals, tuttid clients, or host-specific transport wiring into shared packages
  • make workbench contributions responsible for top-level shell concerns such as snapshotRepository, workspaceId, missionControl, or layout constraints
  • force simple or compatibility consumers to use contributions

Ownership Boundaries

packages/workbench/surface

workbench-surface should own:

  • the contribution type contract
  • normalization and merge helpers
  • deterministic composition rules between explicit host props and contributions
  • deterministic composition for host-wide close preparation contributed by workbench modules
  • default runtime behavior after configuration is resolved

It should not own:

  • desktop-specific preload or Electron details
  • tuttid client construction
  • product copy overrides
  • app-specific diagnostics sinks
  • host-specific business close or reuse policy

Capability-owning packages

Packages such as:

  • @tutti-os/browser-node
  • @tutti-os/workspace-terminal
  • @tutti-os/workspace-issue-manager

may expose feature-specific workbench contribution factories when the shared capability already owns reusable node or dock behavior.

Those packages may own:

  • workbench node definitions for the capability
  • dock entries for the capability
  • capability-local external state shaping
  • capability-local launch helpers
  • capability-local close-policy hooks
  • custom-header presentation declared through WorkbenchHostNodeWindowCapabilities.header

They must not own:

  • host globals such as window.tutti
  • preload surface assumptions
  • app-specific daemon client creation
  • product-specific enablement or routing policy

Window header presentation

A node definition declares structural custom-header state through window.header: heightPx, layout: "overlay", overflow: "visible", or border: "none". WorkbenchHost projects that state onto the .workbench-window subject as data attributes and, for height, a CSS custom property.

Do not discover these states with .workbench-window:has(...) or by inspecting rendered header descendants. A descendant mutation inside an editor, browser, or other live node can otherwise make the full window subtree a style invalidation candidate. Component-local relational selectors remain acceptable only when both the subject and mutation scope are small and bounded.

External State Subscription

External state is node-scoped. WorkbenchHostExternalStateSource exposes subscribeNodeState(input, listener) and, when a node renderer consumes shared workspace state, subscribeWorkspaceState(input, listener). The host binds those subscriptions at node-renderer leaves; it does not maintain a host-wide external-state revision.

Source snapshots returned by getNodeState(...) and getWorkspaceState(...) must preserve reference identity until their observable slice changes. A contribution must not aggregate unrelated capability, room, or shell state into one workspace snapshot. If a capability has no live state, it omits the corresponding subscription method.

Consuming hosts such as apps/desktop

The consuming host should continue to own:

  • host adapter construction
  • daemon clients and preload wiring
  • product policy such as feature enablement or access rules
  • top-level shell inputs such as snapshotRepository, workspaceId, missionControl, and layout constraints
  • any product-local contributions that do not yet have a real shared boundary

Public Shape

The current WorkbenchHostProps shape includes one optional composition field:

export interface WorkbenchContribution {
  id: string;
  nodes?: readonly WorkbenchHostNodeDefinition[];
  dockEntries?: readonly WorkbenchHostDockEntry[];
  externalStateSource?: WorkbenchHostExternalStateSource;
  onLaunchRequest?: (
    request: WorkbenchHostLaunchRequest
  ) =>
    | Promise<WorkbenchHostLaunchResult | null | void>
    | WorkbenchHostLaunchResult
    | null
    | void;
  onNodeCloseRequest?: (
    request: WorkbenchHostNodeCloseRequest
  ) =>
    | Promise<WorkbenchHostNodeCloseDecision | void>
    | WorkbenchHostNodeCloseDecision
    | void;
  prepareHostClose?: (
    context: WorkbenchHostClosePreparationContext
  ) => Promise<boolean> | boolean;
}

export interface WorkbenchHostProps {
  // existing fields remain supported
  contributions?: readonly WorkbenchContribution[];
}

The model stays intentionally small. It mirrors the parts of the existing host API that are already repeated across feature integrations.

Configuration Resolution

WorkbenchHost normalizes all configuration through one internal resolution step before runtime wiring.

Illustrative shape:

interface ResolvedWorkbenchHostConfig {
  nodes: readonly WorkbenchHostNodeDefinition[];
  dockEntries: readonly WorkbenchHostDockEntry[];
  externalStateSource?: WorkbenchHostExternalStateSource;
  onLaunchRequest?: WorkbenchHostProps["onLaunchRequest"];
  onNodeCloseRequest?: WorkbenchHostProps["onNodeCloseRequest"];
}

function resolveWorkbenchHostConfig(
  props: WorkbenchHostProps
): ResolvedWorkbenchHostConfig;

This resolver is the compatibility boundary.

Rules:

  • old-style explicit host props remain first-class public API
  • contributions are folded into the same resolved shape
  • runtime code below the resolver should not need to care whether a capability came from explicit props or a contribution

Merge Rules

Merge rules must be deterministic and documented because external consumers may depend on current behavior.

Nodes

  • contribution-provided nodes are collected first in contribution order
  • explicit nodes prop is appended last
  • if multiple entries share the same typeId, the later one wins
  • therefore an explicit top-level nodes entry overrides contribution-provided nodes with the same typeId

Rationale:

  • additive contributions stay simple
  • existing direct consumers keep override authority

Dock Entries

  • contribution-provided dock entries are collected first in contribution order
  • explicit dockEntries prop is appended last
  • if multiple entries share the same id, the later one wins
  • therefore an explicit top-level dock entry overrides a contribution-provided entry with the same id

External State Source

  • if the explicit top-level externalStateSource prop is present, it keeps its current meaning and contribution-provided external-state sources are ignored
  • otherwise contribution-provided sources are combined in contribution order
  • when combining contribution sources, the first non-null, non-undefined state result wins for each lookup

This preserves current direct-consumer behavior while still allowing a purely contribution-based host to avoid hand-writing a combiner.

If a host wants custom composition on top of contribution sources, it should build and pass one explicit top-level externalStateSource intentionally rather than rely on hidden fallback behavior.

Launch Request

  • if an explicit top-level onLaunchRequest is present, it runs first
  • if it returns a non-null result, resolution stops
  • if it returns null or void, contribution launch handlers run in contribution order until one returns a non-null result

This keeps current host launch behavior authoritative while allowing contributions to act as fallbacks.

Node Close Request

  • if an explicit top-level onNodeCloseRequest is present, it runs first
  • if it returns a close decision, resolution stops
  • if it returns void, contribution close handlers run in contribution order until one returns a close decision

Top-Level Single-Owner Inputs

The following remain top-level host inputs and are not part of the first contribution contract:

  • snapshotRepository
  • workspaceId
  • missionControl
  • layoutConstraints
  • top-level shell renderers such as renderTopChrome and renderBottomChrome
  • wallpaper and shortcut policy

These are host-shell concerns, not feature contributions.

Compatibility Model

The compatibility model is the main reason to introduce contributions as an additive API.

Existing Consumers

Existing consumers may continue using:

<WorkbenchHost
  nodes={...}
  dockEntries={...}
  externalStateSource={...}
  onLaunchRequest={...}
  onNodeCloseRequest={...}
  snapshotRepository={...}
  workspaceId={...}
/>

No migration is required in the first landing.

Mixed Consumers

Hosts that want gradual adoption may mix both styles:

<WorkbenchHost
  contributions={[
    browserContribution,
    terminalContribution,
    issueManagerContribution
  ]}
  nodes={[filesNode]}
  onLaunchRequest={hostLaunchOverride}
  snapshotRepository={repository}
  workspaceId={workspaceId}
/>

This mixed mode is intentional and should be supported, not treated as a temporary edge case.

Contribution-first Consumers

New hosts may adopt a mostly contribution-based model while still providing the required top-level shell inputs:

<WorkbenchHost
  contributions={contributions}
  snapshotRepository={repository}
  workspaceId={workspaceId}
/>

Downstream Package Guidance

When a shared capability package adds contribution support, prefer a narrow factory such as:

createTerminalWorkbenchContribution(...)
createIssueManagerWorkbenchContribution(...)
createBrowserWorkbenchContribution(...)

Those factories should accept host-provided adapters and policy inputs rather than discovering globals or constructing product clients internally.

If a package cannot expose a contribution without learning host-specific behavior, that is a sign the boundary is not yet ready to move into the package.

Desktop Adoption Boundaries

apps/desktop should adopt workbench contributions through feature-local services, not through React components.

The desktop workspace workbench currently uses these modules:

  • @tutti-os/workbench-host owns the public capability descriptor and registry contracts. The registry resolves by order then id and rejects duplicate factory, contribution, node type, and dock entry ownership before host input is published. services/internal/workbenchProductProfile.ts keeps Tutti's product ID and scope kind private because the shared kernel does not yet consume or enforce those fields. Desktop production code and lifecycle tests import the shared package directly; there is no private registry, coordinator, or session compatibility path.
  • services/internal/tuttiWorkbenchProductProfile.ts owns the Tutti capability selection. It binds each desktop contribution factory to a newly projected, capability-specific context so factories do not receive the full product context or discover services through a locator.
  • services/internal/workspaceWorkbenchContributionFactory.ts defines the desktop-local adapter input and generic binding helper. Concrete factories declare only the subset of host adapters, i18n runtimes, renderer callbacks, and workspace context their capability uses, then return the existing WorkbenchContribution contract.
  • @tutti-os/workbench-host owns the lifecycle-diagnostics contract used by coordinator/session disposal. services/internal/workbenchHostPorts.ts retains the private snapshot repository port because snapshot transport is still product-owned and is not consumed by the shared kernel. Desktop implementations remain under services/internal/adapters; Tutti transport and diagnostic payload mapping do not move into the neutral kernel.
  • services/internal/contributions/* owns concrete desktop-local adapters for browser, terminal, issue-manager, files, and other workspace capabilities that have not yet moved into a shared package factory.
  • workspaceWorkbenchHostInputResolver.ts owns product host-input assembly, including contribution factories, dynamic dock entries, snapshot repository wiring, close policy, and node-preview capture.
  • workspaceWorkbenchHostService.ts remains the desktop facade for session leases, wallpaper/onboarding persistence, external bridges, and desktop IPC adapters. It delegates host-input assembly to the resolver instead of becoming a second feature business core.
  • services/workspaceWorkbenchShellRuntimeController.ts owns shell-level runtime state that is independent of DOM rendering: mission control, close guard dialog state, host close requests, wallpaper selection, and the resolved host input.
  • UI hooks such as useWorkspaceWorkbenchShellRuntime.tsx and useWorkspaceChromeState.ts are React adapters. They may subscribe to controllers and pass browser events or DI services into them, but they should not own feature orchestration.

This split keeps the module interfaces deep:

  • contribution factories hide feature-specific workbench wiring behind a small createContribution(...) call
  • the registry hides ordering and availability policy
  • the host service hides desktop adapter construction
  • shell controllers hide state transitions and side effects that do not need React
  • React files stay shallow and mostly render snapshots, wire DOM events, and connect browser lifecycle to controllers

When adding a new desktop workbench capability, prefer this path:

  1. add a contribution factory under the owning feature or workspace-workbench/services/internal/contributions
  2. declare its narrow context and bind it in tuttiWorkbenchProductProfile.ts
  3. let the neutral capability registry resolve and validate the profile
  4. keep daemon clients, preload calls, and product enablement in desktop-owned adapters
  5. expose only render callbacks or browser event subscriptions through the UI adapter when React is actually required

Do not add new workbench capability wiring directly to WorkspaceWorkbench.tsx, WorkspaceChrome.tsx, or settings panels unless the behavior is purely presentational. If the code decides what to launch, close, persist, refresh, or subscribe to, it belongs in a feature-local service or controller first.

Relationship To Existing Workbench Documents

This contribution model complements the current workbench architecture:

Contributions are only a host configuration mechanism. They do not change the underlying node lifecycle or snapshot rules.

Current Contract Summary

The workbench contribution model is a backward-compatible optional composition layer. It improves module ownership without forcing simple or downstream hosts to rewrite explicit WorkbenchHost integrations.