Files
tutti/docs/architecture/workbench-dock-model.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

16 KiB

Workbench Dock Model

This document defines the target dock model for @tutti-os/workbench-surface.

The goal is to make dock behavior expressive enough for product-grade desktop navigation without forcing hosts to encode business state into node typeId, icon render trees, CSS hacks, or placeholder nodes.

Problem Summary

Workbench dock behavior must support product navigation concerns such as:

  • one node type with multiple provider-specific dock entries
  • entries that are hidden by default but appear when matching nodes exist
  • grouped dock sections with stable ordering and separators
  • host-controlled disabled, loading, unavailable, and install/sync guidance
  • host-controlled badge and attention state
  • stable semantic anchor keys that are not equal to node typeId

These are dock concerns, not node-type concerns, so the dock model should be entry-centric rather than type-centric.

Design Goals

The target model should:

  1. separate node type identity from dock entry identity
  2. keep business workflow state owned by the consuming host
  3. support default dock rendering with low host integration cost
  4. allow narrow presentation overrides without redefining the dock model
  5. preserve snapshot purity by keeping transient launch payloads out of persisted layout state
  6. keep dock grouping, popup behavior, and anchor lookup stable across empty, active, and restored workbench sessions

Non-Goals

This change does not try to:

  • make workbench-surface understand product-specific workflows such as agent installation, provider sync, or transfer lifecycle semantics
  • turn dock hover content into a general-purpose business popover framework
  • store host-owned workflow state in workbench snapshots

Ownership Boundaries

workbench-surface should own:

  • dock layout, grouping, separator, popup, and anchor mechanics
  • default dock entry rendering and keyboard/pointer interaction
  • normalized badge, state, attention, and hover-action presentation
  • event dispatch for dock entry interactions

The consuming host should own:

  • dock entry configuration data
  • business-state derivation for entry visibility, disabled/loading state, and badges
  • install, sync, retry, or other domain actions
  • launch payload semantics
  • custom presentation overrides beyond the default dock primitives

This keeps the shared package thin and stable while still reducing repeated UI work for hosts.

Core Model

The dock should be modeled as a host-level collection of dock entries rather than as a property of node definitions.

Node Definitions

WorkbenchHostNodeDefinition continues to describe shell-level behavior for a node type:

  • typeId
  • default frame and title
  • renderers
  • instance strategy
  • window capabilities

Node definitions should not be the primary source of dock slot identity, ordering, visibility, badge state, or provider-specific grouping.

Dock Entries

Each dock entry describes one navigation target in the host chrome.

Illustrative shape:

export interface WorkbenchHostDockEntry {
  id: string;
  typeId: string;

  label: string;
  icon: ReactNode;

  sectionId?: string;
  order?: number;
  anchorKey?: string;

  visibility?: "always" | "when-open" | "never";
  launchBehavior?: "enabled" | "disabled";
  launchPayload?: unknown;

  state?: {
    kind: "enabled" | "disabled" | "loading" | "unavailable";
    reason?: string;
  };

  badge?:
    | { kind: "count"; value: number }
    | { kind: "status"; status: "running" | "completed" | "failed" | "warning" }
    | { kind: "custom"; content: ReactNode };

  attentionToken?: string | number | null;

  capturePopupItemPreview?: (
    input: WorkbenchHostDockPopupItemInput
  ) => Promise<string | null> | string | null;

  resolvePopupItem?: (
    input: WorkbenchHostDockPopupItemInput
  ) => WorkbenchHostDockPopupItemDescriptor;

  hoverActions?: readonly {
    disabled?: boolean;
    id: string;
    label: string;
    pendingLabel?: string;
  }[];

  matchNode?: (node: WorkbenchNode<WorkbenchHostNodeData>) => boolean;
}

Key points:

  • id is the dock identity key
  • typeId says what kind of node this entry launches
  • anchorKey defaults to id
  • launchPayload is transient launch input, not persisted layout state
  • popup title, subtitle, and preview remain host-controlled through narrow entry hooks
  • matchNode is a fallback, not the preferred grouping mechanism

Dock Entry Affinity

Launched or projected nodes may need to declare which dock entry they belong to.

Illustrative shape:

export interface WorkbenchHostNodeData {
  typeId: string;
  instanceId: string;
  instanceKey?: string | null;
  dockEntryId?: string | null;
}

dockEntryId is the preferred mechanism for popup grouping, anchor lookup, and stable dock reappearance after snapshot restore.

When dockEntryId is missing, a dock entry may still match nodes through matchNode, but that path should stay secondary.

Launch Payload

Launch should support host-provided payloads without overloading typeId.

Illustrative shape:

export interface WorkbenchHostLaunchInput {
  reason: "dock" | "command" | "shortcut" | "host";
  typeId: string;
  payload?: unknown;
  dockEntryId?: string;
}

export interface WorkbenchHostLaunchRequest extends WorkbenchHostLaunchInput {
  workspaceId: string;
}

export interface WorkbenchHostLaunchResult {
  typeId: string;
  instanceId: string;
  instanceKey?: string | null;
  title?: string;
  defaultFrame?: WorkbenchFrame;
  activation?: {
    type: string;
    payload?: unknown;
  } | null;
  dockEntryId?: string;
}

Rules:

  • launch payloads are transient
  • launch payloads are not written to snapshots
  • dockEntryId is stable shell affinity metadata and may be persisted

Visibility And Launch Semantics

Dock visibility and launchability should be modeled separately.

Visibility

  • always: render the dock entry even when no matching node exists
  • when-open: render the dock entry only when one or more matching nodes exist
  • never: never render the dock entry

when-open is the intended solution for entries that are not part of the default navigation set but should appear when a matching node is already open or restored from snapshot.

Installed workspace applications default to when-open: installation makes an application launchable from App Center but does not pin it to the Dock. A host may persist an explicit Keep in Dock choice and override visibility to always; closing the last matching node hides an application without that retained choice.

Open-State Indicator

An entry with a matching open or minimized node renders the shared Dock state indicator; a closed entry does not. This is a Workbench interaction invariant, not an application-specific presentation choice. Dock placement may reposition the indicator but must not suppress it: the bottom Dock places it below the icon, while the left Dock places it to the left and vertically centers it.

The indicator state follows canonical dockEntryId affinity. Hosts should migrate stale durable affinity values rather than loosen exact matching or add application-specific CSS exceptions.

Launch Behavior

  • enabled: dock may create a new node when click behavior resolves to launch
  • disabled: dock never launches a new node, but may still render and surface hover content

This avoids overloading a single boolean to describe both visibility and interactivity.

Grouping, Ordering, And Separators

Dock ordering should not depend on nodeDefinitions array order.

The preferred structure is:

  • sectionId groups adjacent entries into visual sections
  • order sorts entries inside a section
  • the host renderer inserts separators between section changes

This model is more stable than separatorBefore and separatorAfter flags and better matches product-owned dock organization.

Popup And Click Behavior

Dock interaction should follow stable, entry-level rules.

Dock magnification snapshots viewport and slot geometry once when a pointer session starts. Later animation frames derive centered or overflow-aligned slot positions from that snapshot and the sizes already written by the spring. They must not interleave per-frame getBoundingClientRect() reads with slot size writes. Resetting the interaction or changing slot membership invalidates the snapshot.

Native popup previews pass the clamped target rectangle to Electron capture; they must not capture the whole webContents and crop afterward. A native region capture is valid only for the foreground node represented by those composited pixels. When native capture misses, a popup first reads the Node's shared latest Dock preview from memory. After a memory miss, it reads the unrevisioned persistent latest identity and the exact-revision identity in parallel. The shared latest entry wins; an exact-revision entry is a compatibility fallback for previously captured generations and is promoted to the shared identity when used. When every cache source misses, a non-minimized background node may fall back to a DOM-cloned snapshot. Successful native and DOM captures update the one shared latest entry, so popup and minimize capture reuse the same bounded Dock PNG without duplicating persistent writes or sharing the full-resolution Genie animation texture. DOM fallbacks run serially, are deduplicated by preview identity and revision, and yield the renderer task queue between clones so a large popup cannot turn the serialized work into one microtask-bound long task. They persist only successful images so repeated popup openings do not repeat the expensive clone. Minimized nodes must not request a fresh DOM snapshot because their bodies may be unhydrated or their imperative resources may be inactive. A failed capture is terminal only for the mounted popup; it must not be retained across popup lifetimes because the node may be foreground the next time. Popup capture effects are keyed by preview identity and revision, not transient item arrays or callback references. Cleanup may fence stale UI writes, but it must retain the pending marker for a native capture that has already started until that capture settles, because Electron capture cannot be canceled. Skeletons represent in-flight capture only; a terminally unavailable preview uses a non-loading static placeholder.

Dock popup responsibilities stay split by lifecycle: WorkbenchHostDockPopup owns portal/layout and dismissal, WorkbenchHostDockPopupPresentation owns cards and context-menu rendering, and useWorkbenchHostDockPopupPreviewCapture owns capture, pending-operation fencing, and preview caches.

WorkbenchHost normalizes its preview capture callbacks in both directions. Hosts that can capture captureNodePreviewImages do not also need to provide captureNodePreviewImage; the surface derives the Dock image from dockPreviewImageUrl. The legacy single-image callback remains supported and is preferred when both callbacks are present, so normalization never starts a second native capture.

Matching

For each dock entry, the host resolves matching nodes using:

  1. node.data.dockEntryId === entry.id
  2. fallback entry.matchNode(node) when dockEntryId is unavailable

Grouping by typeId alone should no longer be the default.

Click Resolution

Suggested default behavior:

  1. if the entry state is disabled, loading, or unavailable, do not launch
  2. if there is exactly one matching node, restore and focus that node
  3. if there are multiple matching nodes, open the dock popup
  4. if there are no matching nodes and launch is enabled, dispatch a launch request using typeId, dockEntryId, and launchPayload

This keeps dock behavior focused on navigation first and creation second.

State, Badge, And Attention

Dock entry state should be host-controlled and surface-rendered.

State

The shared package should recognize normalized presentation states:

  • enabled
  • disabled
  • loading
  • unavailable

The shared package may provide default visuals through explicit asset subpaths and may provide tooltip treatment for these states, but it should not own the underlying workflow state machine.

Dock placement may adapt those visuals without changing node state semantics. The bottom Dock renders the existing open/minimized status dot below the icon. The left Dock keeps one centered icon axis without reserving a horizontal gutter, and renders the same indicator to the left of the icon.

Badge

Dock entries should support:

  • count badges
  • normalized status badges
  • optional custom badge content through a narrow override

Hosts should not be forced to hide badge logic inside icon render trees.

Attention

Dock attention should be modeled as a change token rather than a persistent boolean.

attentionToken should trigger a one-shot animation when the value changes, allowing hosts to signal events such as completed transfers without forcing the shared package to understand business semantics.

Dynamic Dock State Source

dockEntries should describe stable dock identity and launch wiring. They should not become the transport for high-churn business state such as provider installation, authentication, quota, sync, or usage status.

When a dock entry needs live business state, the host should provide a dynamic state source beside the static entry list:

export type WorkbenchHostDockEntryDynamicState = Partial<
  Pick<
    WorkbenchHostDockEntry,
    | "attentionToken"
    | "badge"
    | "hoverActions"
    | "launchBehavior"
    | "state"
    | "visibility"
  >
>;

export interface WorkbenchHostDockEntryStateSource {
  getEntryState(
    entryId: string
  ): WorkbenchHostDockEntryDynamicState | null | undefined;
  subscribe(listener: () => void): () => void;
}

export interface WorkbenchHostProps {
  dockEntries: readonly WorkbenchHostDockEntry[];
  dockStateSource?: WorkbenchHostDockEntryStateSource;
}

Rules:

  • keep dockEntries referentially stable for a workspace whenever node registration and launch wiring did not actually change
  • use dockStateSource for dynamic presentation fields such as disabled state, install/login hover actions, loading labels, badges, attention tokens, and temporary visibility changes
  • the source should expose getters over the owning service/store rather than copying business state into the Workbench host input
  • source updates should re-render the dock only; they must not recreate the Workbench host session, node definitions, or active node shells
  • avoid using a host-level revision prop or React state just to force createHostInput(...) to run after every business-status update

This separation lets a host show local CLI install/login status in the dock while keeping open Agent GUI or terminal nodes stable during status refreshes.

Hover Actions And Default Components

The shared package should provide reliable default dock primitives to reduce host implementation cost:

  • dock entry button
  • badge rendering
  • state indicator treatment
  • popup item preview/title/subtitle rendering
  • hover panel shell
  • hover action list
  • multi-window popup

The shared package should not become a full custom popover framework. Instead, it should expose narrow extension hooks around a standard shell.

Recommended host callback:

export interface WorkbenchHostProps {
  dockEntries: readonly WorkbenchHostDockEntry[];
  dockStateSource?: WorkbenchHostDockEntryStateSource;
  onDockEntryAction?: (input: {
    entryId: string;
    actionId: string;
    host: WorkbenchHostHandle;
  }) => void | Promise<void>;
}

This allows flows such as "hover disabled entry, click Install, host transitions entry state to loading, then back to enabled" without moving installation logic into workbench-surface.

Snapshot Rules

Snapshot purity remains a P0 rule.

Allowed dock-related snapshot metadata:

  • dockEntryId when needed to reconnect a shell to its dock entry

Disallowed dock-related snapshot metadata:

  • launch payloads
  • hover action definitions
  • badge state derived from host business state
  • disabled/loading/unavailable workflow internals

The host should recompute dock visibility, badges, and hover actions from live state on each render.

Current Outcomes

The implemented model lets hosts express:

  • stable dock ordering and grouping
  • provider-specific entries for a shared node type
  • default-hidden entries that appear when matching nodes exist
  • stable anchor keys for genie animation and tests
  • normalized badge and attention behavior
  • host-controlled disabled, loading, and unavailable states
  • host-controlled hover guidance such as install or sync actions

This restores dock to a host-chrome navigation model while keeping business logic out of the shared workbench surface.