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

494 lines
16 KiB
Markdown

# 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:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
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.