docs(web): finalize workspace UI product flow

This commit is contained in:
imccyu
2026-07-25 16:15:29 +08:00
parent 9eb9c70a8a
commit 08ce02da2b
14 changed files with 265 additions and 254 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-25-workspace-ui-product-flow.md: a02087235a36f2c257de407facf2dc02ed072f3b
2026-07-25-workspace-ui-product-flow.zh.md: 8ccbf5b98401bef9c3fd40e948d35ec5f0818202

View File

@@ -0,0 +1,117 @@
# Agent Note: Workspace UI Complete Product Flow
Status: implemented
English | [中文](2026-07-25-workspace-ui-product-flow.zh.md)
## Problem
[Domain KV Storage and the Workspace Entity](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) defines the persistent Workspace entity, path conventions, and ordered Session ledger, but not the Host wiring, historical-data initialization, or GUI flow. The GUI presents both Workspaces and Sessions; users must be able to type immediately after entering New Session, even when no Host Session or Host Workspace exists yet.
Pending Workspaces, pending Sessions, retained input, and Host entity publication need clear owners and must preserve the same page identity when RPC completions and Host frames arrive in either order. Eagerly creating a Host Session for the zero state would bring a page with no input into the Host lifecycle. Historical Sessions also expose only the lightweight `SessionHeader.cwd` for grouping; initialization cannot read event bodies.
## Decision
### Host and persistent data
The Host provides the following GUI wiring on the Workspace entity:
| RPC | Behavior |
| --- | --- |
| `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation |
| `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict |
| `workspace.create({ path })` | Adopts an existing directory and does not create an arbitrary path |
| `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it |
| `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session |
`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting.
A Workspace's `sessionIds` is an ordered candidate index. A membership projection requires both that an id appear in the index and that the corresponding canonicalized `SessionHeader.cwd` equal the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id is filtered out if its header is missing, its cwd is invalid, or its cwd does not match. Two Workspace indexes claiming the same Session is corrupt state and fails loudly.
The Workspace domain uses a durable marker to distinguish “never initialized” from “initialized but empty.” When the marker is absent, the Registry calls only `SessionPersistence.list()` to read header metadata; it calls neither `load` nor `inspect`, reads no history, and parses no event bodies. Valid cwd values are grouped by canonical path, and both Sessions within each group and the Workspace groups themselves are initialized in descending header `createdAt` order. Bootstrap is reentrant and writes the marker last; after the marker is written, new Sessions created without `workspaceId` are no longer adopted automatically.
### Client object model
`Session` and `Workspace` are frontend objects from the page Intent stage onward.
- A frontend Session preallocates a SessionId when created and owns its Intent target and `pendingPrompt`; it remains the same Session object after Host `session.create` succeeds.
- Before materialization, a frontend Workspace has no WorkspaceId and owns its create input, phase, and error; after Host `workspace.create` succeeds, the same Workspace object adopts the returned view.
- `SessionManager` and `WorkspaceManager` own object indexes and merge Host baselines and deltas; the objects are the sole source of state for both Intents and Host views.
- `SessionsService` provides Session objects, real selection, scope, and list projections; `WorkspacesService` depends on `SessionsService` and owns the default Workspace, cross-object New Session flow, and Workspace materialization.
A page has at most one frontend Session Intent and one accompanying Workspace Intent that exists only in the zero-Workspace state. Intents exist only on the current page and disappear on refresh; real Session selection can be restored persistently. Selecting a real Session or starting another Session Intent revokes the old Intent's eligibility for automatic sending, but does not roll back a Session already published by the Host or any accepted message.
The Session owns the first input and drives one internal pipeline: when necessary, it attaches to a Workspace with its preallocated id, then sends `pendingPrompt`. Both attach and send failures return to the same Session. Workspace creation phase and error belong only to the Workspace object; the Session does not simulate the Workspace lifecycle.
### User flow
On initial entry, the application waits until both the Workspace and Session baselines are ready. It restores a real Session selection that remains valid; otherwise, it enters New Session and selects the most recent Workspace exactly once. The most recent Workspace is determined by the maximum `updatedAt` of its member Sessions, falling back to `createdAt` for an empty Workspace. This derived value chooses only the default target: it does not alter the Host Workspace order or trigger another selection after later hydration.
When no Workspace exists, the page creates a frontend Workspace object named `workspace` and a frontend Session that targets it. Neither writes to the Host, and the composer always accepts input; the first send materializes the Workspace, attaches the Session, and sends the message in that order.
Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's Use an existing folder and Create a new workspace actions immediately create a real Workspace when the user confirms, then retarget the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message.
Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Rename, Delete, moving across Workspaces, drag-and-drop ordering, manual adoption from Ungrouped, and separate display-name and directory-name inputs are outside this iteration's scope.
### First send and recovery
A frontend Session's `pendingPrompt` retains its original text until the Host accepts the message. The first send advances through Workspace materialization, Session attachment, and prompt sending in order:
1. If Workspace creation fails, the Workspace Intent retains its input and error, and the Session continues to target that object.
2. If Session creation fails before publication, the Session Intent returns to an editable state and retries with the same preallocated SessionId.
3. `workspace-attach-failed` proves that the Session has been published; the same Session object enters the real list and retains the prompt, and subsequent retries attach it.
4. If the prompt fails, the Session retains it and retries only send without recreating the Workspace or Session.
5. If the page switches to another Intent while a Session is being created, the old Session does not send automatically even if it is subsequently published; it retains its original prompt and visible error.
Lost RPC responses, Host frames arriving before completions, and completions arriving before Host frames all converge through the preallocated SessionId and object identity. The Manager performs ordered upserts of Host views and prioritizes preserving the original object identity during local materialization, rather than creating a temporary second row with the same id.
### Sidebar and ordering
Workspace groups strictly follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and Session activity does not move Workspace groups.
Within each group, order strictly follows `Workspace.sessionIds`. A newly attached Session is placed first; when a Session later becomes active, the Host moves only that id to the front and persists the change. The Client does not reorder the entire group by time after the Session list arrives, so it never displays one Workspace order and then jumps to another during hydration.
A frontend Session Intent appears as a “New session” row and temporarily counts toward the group's Session total only when it targets a real Workspace. When it targets a Workspace Intent, neither the Workspace nor the Session appears in the sidebar. After the Intent is published, the real row with the same preallocated id takes its place; after refresh, both the Intent row and temporary count disappear. Search mode neither retains nor filters Intent rows.
Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order.
### React and slot boundaries
React components only consume `useSessions`, `useWorkspaces`, and session-scoped hooks; they do not own entity lifecycles. The Zustand store retains only layout, the current view, composer text for ordinary real Sessions, and other purely presentational state. Session and Workspace Intents, materialization phases, errors, and retained prompts reside in the React-free runtime object layer.
The Sidebar and conversation empty hero receive standardized actions through slots: `startSession`, `updateSessionPrompt`, `sendSession`, `open`, and `toggleSidebar`. The Workspace picker reuses the same component and the `createWorkspace` seam; its owner supplies only popover state, an anchor, and a selection callback. The presentation layer does not send `host/workspace-changed` directly; Host events originate only from Host mutations and the stream adapter.
## Alternatives considered
**Store separate page records for pending Workspaces and Sessions.** This approach must replace identities after materialization and hand off input, errors, focus, and sidebar rows; Intent state owned by the objects preserves identity continuity.
**Let the presentation layer or root Zustand store orchestrate object lifecycles.** This approach duplicates Manager and Service responsibilities and brings domain state back into React. Runtime services provide standardized actions, while slots inject only the narrow interfaces required by presentation.
**Immediately create a Host Session or Host persistence intent in the zero state.** A page with no input would enter the Host lifecycle and change refresh semantics; before the first send, the frontend Session retains only a page-local Intent.
**Delay an explicit Create Workspace until the first send.** After confirmation, the sidebar would still show no real empty Workspace, conflating “create a Workspace” with “prepare a Session”; only the zero-Workspace Intent generated automatically by the system delays materialization.
**Continuously derive Workspaces dynamically from cwd.** This cannot represent empty Workspaces, stable display names, or explicit ordering, and would automatically adopt non-Workspace callers; cwd is used only for one historical bootstrap and bidirectional membership validation.
**Have the Client batch-reorder by time after the Session list arrives.** The initial screen would first show the Host order and then jump as a whole, and reconnecting could change positions again; the Host's persistent ledger owns ordering, while the Client merges only individual updates.
**Add workspaceId to SessionHeader.** This would create two persistent ownership fields alongside the Workspace index and require double writes; the header retains the Session's own cwd fact, while the Workspace index owns explicit membership.
## Verification
- The zero state with no Workspace writes nothing to the Host and accepts input; explicit Create Workspace immediately creates and displays an empty Workspace.
- Frontend Sessions and Workspaces preserve object identity across materialization; input, errors, focus, and sidebar projections always originate from the object layer.
- The first send advances through Workspace, Session, and prompt in order; successful stages are not rolled back, input is not lost before the prompt is accepted, and creation retries use the same SessionId.
- Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd.
- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front.
- A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts.
- Both the UI and Host reject duplicate Workspace names; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped.
- Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order.
## Consequences
- SessionHeader does not record last-active time, so historical bootstrap can initialize order only by `createdAt`; real Session activity events move individual entries afterward.
- Historical Sessions with a missing cwd, an invalid directory, or a failed realpath remain Ungrouped; this iteration has no manual-adoption entry point.
- Refreshing the page discards unmaterialized Workspace and Session Intents and input not yet accepted by the Host; this is the page-local contract.
- Explicit Create Workspace writes to disk immediately, so leaving without sending still leaves an empty Workspace.
- Before its first event, a Host Session retains the existing lazy-persistence semantics; frontend Intents do not change empty-Session behavior after a Host restart.

View File

@@ -0,0 +1,117 @@
# Agent Note: Workspace UI 完整产品动线
[English](2026-07-25-workspace-ui-product-flow.md) | 中文
Status: implemented
## Problem
[Domain KV storage 与 Workspace entity](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)定义了 Workspace 的持久实体、路径规范和有序 Session 账本,但没有定义 Host 接线、历史数据初始化或 GUI 动线。GUI 同时呈现 Workspace 和 Session用户进入 New Session 后必须立即输入,即使此时还没有 Host Session甚至没有 Host Workspace。
待创建 Workspace、待创建 Session、输入保留与 Host 实体发布必须具有明确所有者,并在 RPC completion 与 Host frame 以任意顺序到达时保持同一页面身份。若零态提前创建 Host Session则无输入的页面状态会进入 Host 生命周期。历史 Session 又只有轻量 `SessionHeader.cwd` 可用于归组,初始化不能读取事件正文。
## Decision
### Host 与持久数据
Host 在 Workspace entity 上提供以下 GUI 接线:
| RPC | 行为 |
| --- | --- |
| `workspace.list` | 返回持久有序的 Workspace并过滤未通过 header 校验的 Session id |
| `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace显示名冲突时失败 |
| `workspace.create({ path })` | 收编已经存在的目录,不为任意路径创建目录 |
| `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd以可选预分配 id 幂等创建 Session 并 attach |
| `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session |
`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量Client 重连后分别刷新 `workspace.list``session.list` 基线。
Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace pathSessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并 fail loud。
Workspace domain 以 durable marker 区分“从未初始化”和“已初始化但为空”。marker 未设置时Registry 只调用 `SessionPersistence.list()` 读取 header 元数据,不调用 `load``inspect`、history 或解析事件正文;有效 cwd 按 canonical path 分组,组内 Session 与 Workspace 组均按 header `createdAt` 降序初始化。Bootstrap 可重入,最后才写 markermarker 写入后,绕过 `workspaceId` 的新 Session 不再被自动收编。
### Client 对象模型
`Session``Workspace` 从页面 Intent 阶段开始就是前端对象。
- 前端 Session 创建时预分配 SessionId并在对象内持有 Intent target 与 `pendingPrompt`Host `session.create` 成功后仍是同一个 Session 对象。
- 前端 Workspace 在 materialize 前没有 WorkspaceId并在对象内持有 create input、phase 与 errorHost `workspace.create` 成功后同一个 Workspace 对象 adopt 返回的 view。
- `SessionManager``WorkspaceManager` 负责对象索引、Host 基线和增量合并;对象是 Intent 与 Host view 的唯一状态源。
- `SessionsService` 提供 Session 对象、真实 selection、scope 与列表投影;`WorkspacesService` 依赖 `SessionsService`,负责默认 Workspace、跨对象 New Session 动线和 Workspace materialize。
页面至多有一个前端 Session Intent 和一个仅在零 Workspace 状态下配套的 Workspace Intent。Intent 只存在于当前页面,刷新后消失;真实 Session selection 可以持久恢复。选择真实 Session 或启动另一个 Session Intent 会放弃旧 Intent 的自动发送资格,但已经由 Host 发布的 Session 和已经接受的消息不会回滚。
Session 自己持有首条输入并驱动一条内部流水线:必要时以预分配 id attach 到 Workspace然后发送 `pendingPrompt`。attach 与 send 的失败都落回同一 Session。Workspace 创建 phase/error 只属于 Workspace 对象Session 不模拟 Workspace 生命周期。
### 用户动线
应用首次进入时等待 Workspace 与 Session 两份基线 ready。仍有效的真实 Session selection 被恢复;否则进入 New Session并固定选择一次最近 Workspace。最近 Workspace 取其成员 Session 的最大 `updatedAt`,空 Workspace 回退到 `createdAt`;该派生只决定默认目标,不改变 Host Workspace 顺序,也不会在后续 hydration 时二次改选。
完全没有 Workspace 时,页面创建默认名为 `workspace` 的前端 Workspace 对象和指向它的前端 Session。两者不写 Hostcomposer 始终可输入;首次发送才依次 materialize Workspace、attach Session、发送消息。
顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的 Use an existing folder 与 Create a new workspace 会在用户确认时立即创建真实 Workspace再把前端 Session 定位到该 Workspace即使用户不发送消息显式创建的空 Workspace 也保留。
Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认Host 继续拒绝绕过 UI 或并发产生的同名请求。Rename、Delete、跨 Workspace 移动、拖拽排序、Ungrouped 手动收编和显示名/目录名双输入不在本期范围。
### 首次发送与恢复
前端 Session 的 `pendingPrompt` 在 Host 接受消息前始终保留原文。首次发送按 Workspace materialize、Session attach、prompt send 顺序推进:
1. Workspace 创建失败时Workspace Intent 保留输入与错误Session 仍指向该对象。
2. Session 创建在发布前失败时Session Intent 回到可编辑状态,以同一预分配 SessionId 重试。
3. `workspace-attach-failed` 证明 Session 已发布;同一 Session 对象进入真实列表并保留 prompt后续重试 attach。
4. prompt 失败时Session 保留 prompt 并只重试 send不重复创建 Workspace 或 Session。
5. Session 创建期间若页面切换到另一个 Intent旧 Session 即使随后发布也不自动发送;它保留原 prompt 和可见错误。
RPC lost response、Host frame 先于 completion 和 completion 先于 Host frame 都通过预分配 SessionId 与对象身份收敛。Manager 对 Host view 做有序 upsert本地 materialize 时优先保留原对象身份,不生成同 id 的临时第二行。
### Sidebar 与排序
Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位Session 活跃不会移动 Workspace 组。
组内严格使用 `Workspace.sessionIds`。新 attach 的 Session 放在首位,后续某个 Session 活跃时 Host 只前移该 id 并持久化。Client 不在 Session list 到达后按时间整体重排,因此不会先显示一套 Workspace 顺序再因 hydration 瞬间跳动。
前端 Session Intent 只有在目标是真实 Workspace 时才作为 “New session” 行显示,并临时计入该组 Session 数量;目标是 Workspace Intent 时Workspace 与 Session 都不进入 sidebar。Intent 发布后由同一预分配 id 对应的真实行接替,刷新后 Intent 行和临时计数一起消失。搜索模式不保存或筛选 Intent 行。
无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added``workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。
### React 与 slot 边界
React 组件只消费 `useSessions``useWorkspaces` 与 session-scoped hooks不拥有实体生命周期。Zustand store 只保留布局、当前 view、普通真实 Session 的 composer 文本和其他纯呈现状态Session/Workspace Intent、materialize phase、错误和 retained prompt 位于 React-free runtime 对象层。
Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSession``updateSessionPrompt``sendSession``open``toggleSidebar`。Workspace picker 复用同一组件与 `createWorkspace` seamowner 只提供 popover 开关、锚点和选中回调。呈现层不直接发送 `host/workspace-changed`Host event 只由 Host mutation 与 stream adapter 产生。
## Alternatives considered
**为待创建 Workspace 与 Session 保存独立页面记录。** 该方案在 materialize 后需要替换身份并转交输入、错误、焦点和 sidebar 行;对象自身的 Intent 状态可以保持身份连续。
**由呈现层或 root Zustand store 编排对象生命周期。** 该方案会重复 Manager/Service 的职责,并把领域状态带回 React。标准化动作由 runtime service 提供slot 只注入呈现所需的窄接口。
**零态立即创建 Host Session 或 Host persistence intent。** 未输入页面会进入 Host 生命周期,并改变刷新语义;前端 Session 在首次发送前只保留 page-local Intent。
**显式 Create Workspace 延迟到首次发送。** 用户确认后 sidebar 仍看不到真实空 Workspace“创建 Workspace”与“准备 Session”语义混合只有系统自动产生的零 Workspace Intent 延迟 materialize。
**持续按 cwd 动态派生 Workspace。** 该方案无法表达空 Workspace、稳定显示名和显式顺序也会自动收编非 Workspace 调用方cwd 只用于一次历史 bootstrap 与成员双向校验。
**Client 在 Session list 到达后按时间批量重排。** 首屏会先展示 Host 顺序再整体跳动,重连也可能改变位置;排序由 Host 持久账本拥有Client 只合并单项更新。
**在 SessionHeader 增加 workspaceId。** 它会与 Workspace 索引形成两个持久归属字段并要求双写header 保留 Session 自身 cwd 事实Workspace 索引负责显式归属。
## Verification
- 完全无 Workspace 的零态不写 Host 且允许输入;显式 Create Workspace 立即创建并显示空 Workspace。
- 前端 Session 与 Workspace 在 materialize 前后保持对象身份,输入、错误、焦点和 sidebar 投影始终来自对象层。
- 首发按 Workspace、Session、prompt 顺序推进,各成功阶段不回滚,输入在 prompt 接受前不丢失,创建重试使用同一 SessionId。
- Workspace list 只读取 header 完成一次可重入 bootstrapinitialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。
- 初始默认目标只在两份基线 ready 后确定一次Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。
- 真实 Workspace 下的前端 Session 临时计入 sidebar 数量Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。
- UI 与 Host 两层拒绝同名 Workspacecwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。
- keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。
## Consequences
- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化;此后由真实 Session 活跃事件逐项前移。
- 历史 cwd 缺失、目录无效或 realpath 失败的 Session 留在 Ungrouped本期没有手动收编入口。
- 页面刷新会丢弃未 materialize 的 Workspace/Session Intent 和尚未被 Host 接受的输入,这是 page-local 契约。
- 显式 Create Workspace 立即落盘,用户不发送就离开也会留下空 Workspace。
- Host Session 在首个事件前仍遵循现有懒持久化语义;前端 Intent 不改变 Host 重启后的空 Session 行为。