From 08ce02da2b742de256a2a40fcdac60bcbcb8855d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:15:29 +0800 Subject: [PATCH] docs(web): finalize workspace UI product flow --- ...07-25-workspace-ui-product-flow.i18n.yaml} | 4 +- .../2026-07-25-workspace-ui-product-flow.md | 117 +++++++++++++++++ ...2026-07-25-workspace-ui-product-flow.zh.md | 117 +++++++++++++++++ ...-07-25-workspace-gui-and-session-drafts.md | 121 ------------------ ...-25-workspace-gui-and-session-drafts.zh.md | 121 ------------------ packages/client/runtime/README.md | 2 +- .../runtime/src/client/sessions/manager.ts | 10 +- .../runtime/src/client/sessions/service.ts | 11 +- .../runtime/src/client/sessions/session.ts | 5 +- ...drafts.spec.ts => session-intents.spec.ts} | 0 packages/client/ui-conversation/README.md | 2 +- .../ui-conversation/src/client/service.ts | 5 +- packages/host/apiproxy/README.md | 2 +- packages/workspace/workspace/README.md | 2 +- 14 files changed, 265 insertions(+), 254 deletions(-) rename .agents/notes/{proposed/feature/2026-07-25-workspace-gui-and-session-drafts.i18n.yaml => implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml} (61%) create mode 100644 .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md create mode 100644 .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md delete mode 100644 .agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md delete mode 100644 .agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.zh.md rename packages/client/runtime/tests/{session-drafts.spec.ts => session-intents.spec.ts} (100%) diff --git a/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml similarity index 61% rename from .agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.i18n.yaml rename to .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index 7ea5b2afb3..3295a845f3 100644 --- a/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml @@ -2,5 +2,5 @@ # 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-gui-and-session-drafts.md: 9e44e092ca584a285d5e49c109063aecbbac239d -2026-07-25-workspace-gui-and-session-drafts.zh.md: 7e13e7de281711227bf446e406e0e4a18394cb4f +2026-07-25-workspace-ui-product-flow.md: a02087235a36f2c257de407facf2dc02ed072f3b +2026-07-25-workspace-ui-product-flow.zh.md: 8ccbf5b98401bef9c3fd40e948d35ec5f0818202 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md new file mode 100644 index 0000000000..a02087235a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md new file mode 100644 index 0000000000..8ccbf5b984 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -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 path;SessionHeader 不增加 `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 可重入,最后才写 marker;marker 写入后,绕过 `workspaceId` 的新 Session 不再被自动收编。 + +### Client 对象模型 + +`Session` 与 `Workspace` 从页面 Intent 阶段开始就是前端对象。 + +- 前端 Session 创建时预分配 SessionId,并在对象内持有 Intent target 与 `pendingPrompt`;Host `session.create` 成功后仍是同一个 Session 对象。 +- 前端 Workspace 在 materialize 前没有 WorkspaceId,并在对象内持有 create input、phase 与 error;Host `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。两者不写 Host,composer 始终可输入;首次发送才依次 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` seam;owner 只提供 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 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。 +- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 +- 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 +- UI 与 Host 两层拒绝同名 Workspace;cwd-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 行为。 diff --git a/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md b/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md deleted file mode 100644 index 9e44e092ca..0000000000 --- a/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md +++ /dev/null @@ -1,121 +0,0 @@ -# Agent Note: Workspace GUI and session drafts - -Status: proposed - -English | [中文](2026-07-25-workspace-gui-and-session-drafts.zh.md) - -## Problem - -[Domain KV storage and the Workspace entity](../architecture/2026-07-24-domain-kv-storage-and-workspace.md) define the persistent Workspace entity, path conventions, and ordered session ledger, but do not define Host wiring, historical-data initialization, or GUI flows. The GUI displays Workspaces and Sessions together, and users must be able to type immediately after entering the New Session page, even when no real Session or even real Workspace exists yet. - -Using one intent to represent both a pending Workspace and a pending Session would make explicit Create Workspace actions, the automatic empty state, sidebar draft rows, and first-send failures share an ambiguous state. Creating a Host Session in advance to support the empty state would instead produce an empty Session with no user input, no persisted data before its first event, and no survival across restarts. Existing historical Sessions also expose only `SessionHeader.cwd`, so the system needs to build an initial Workspace view without reading event bodies. - -## Proposal - -### State and ownership - -Workspace and Session are two real Host objects; WorkspaceDraft and SessionDraft are two page-local Client states: - -- A `Workspace` can be empty, persists durably, and always appears in the sidebar; -- A `Session` is a real object already created by the Host; -- A `WorkspaceDraft` exists only for the automatic empty state when the system has no Workspace at all and does not appear in the sidebar; -- A `SessionDraft` represents a pending Session and holds its target Workspace or WorkspaceDraft, preallocated SessionId, composer content, and send phase. - -At most one SessionDraft exists on a page. A draft under a real Workspace appears in the sidebar as “New session”; neither a WorkspaceDraft nor its SessionDraft appears there. A new draft replaces the old one; selecting a real Session or refreshing the page discards any unmaterialized draft and uncommitted input. Real Workspaces, real Sessions, and messages already accepted by the Host are unaffected. - -The Client represents the current page with the discriminated union `ConversationStage = Session | SessionDraft` instead of simulating a draft by clearing current and storing an intent elsewhere. Workspace, Session, and ConversationStage are separate object layers; only a real Session selection can be persisted. - -### End-to-end Host and wire flow - -The Host exposes the following GUI wiring over the existing Workspace entity: - -| RPC | Behavior | -| --- | --- | -| `workspace.list` | Returns real Workspaces in a stable order and filters out session ids that fail header validation | -| `workspace.create({ name })` | Creates a directory at `workspaceRoot/name` and a Workspace when the name is available; duplicate-name requests fail | -| `workspace.create({ path })` | Adopts an existing directory without creating directories for arbitrary input paths | -| `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a real Session with an optional preallocated id, and attaches it | -| `session.create({ cwd })` | Remains available to non-GUI callers and creates an Ungrouped Session | - -`workspaceRoot` is an independent Host configuration that falls back to the Host cwd when unset; it is unrelated to the `storageRoot` that stores Workspace domain data. The Host stream pushes incremental Workspace and Session updates, while reconnection uses `workspace.list` and `session.list` as its two baselines. - -The GUI preallocates a SessionId in SessionDraft but creates no Host intent before the first send. On the first send, the Client passes that id to `session.create`; the Host uses the same id to create both the real Session and its persistence create-intent. Retrying the same id with the same cwd is idempotent; an existing id with a different cwd fails loudly. This lets a lost response or partial attach failure reconcile to the same Session instead of creating a duplicate. - -A Workspace's `sessionIds` is an ordered candidate index. A Session is a member only when its id is present in the index and its canonicalized `SessionHeader.cwd` equals 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 with a missing header or mismatched cwd does not enter the projection. A Session appearing in two Workspace indexes is corrupt state and fails loudly. - -### One-time historical initialization - -The Workspace domain uses a durable marker to distinguish “never initialized” from “initialized but empty.” When the marker is absent, WorkspaceRegistry performs a reentrant bootstrap once: - -1. Call `SessionPersistence.list()` exactly once; JSONL reads only the first header line, SQLite reads only session metadata rows, and the bootstrap must not call `load`, `inspect`, history APIs, or parse event bodies. -2. Ignore headers with no cwd, a nonexistent path, a path that is not a directory, or a failed realpath lookup; these Sessions remain Ungrouped. -3. Group by canonical cwd, sort each group by header `createdAt` in descending order before writing `sessionIds`, and order Workspace groups stably by each group's maximum `createdAt`, also descending. -4. After a crash, reentry reuses Workspaces already written for the same canonical path and merges missing ids; write the marker last, after all records are durable. - -After the marker is written, the system no longer creates Workspaces or backfills their ledgers automatically from cwd. Subsequent call paths that omit `workspaceId` remain Ungrouped; this is a compatibility path, not a second source that continuously derives Workspaces. - -### User flows - -On initial entry, the Client waits until both Workspace and Session baselines are ready; it restores a still-existing real Session selection when possible and otherwise enters the New Session flow. When the user explicitly enters New Session, the Client does not restore the old selection: it selects the most recent Workspace and creates a SessionDraft. The most recent Workspace is determined by the maximum `updatedAt` among its validated member Sessions, with an empty Workspace falling back to `createdAt`. This value only chooses the default target for New Session; it neither changes the sidebar Workspace order nor triggers a second selection after the Session list arrives. - -When no Workspace exists at all, the page creates a WorkspaceDraft named `workspace` and its SessionDraft. Neither is written to the Host, but the composer always remains editable. The top-level New Session action reenters this empty-state selection flow without calling `session.create` immediately. - -The plus button in the Sidebar Workspace section header and the Workspace creation entry in the composer reuse the same picker and modal: - -- Select an existing Workspace: create only a SessionDraft targeting that Workspace; -- Use an existing folder: call `workspace.create({ path })`, then create a SessionDraft under it after success; -- Create new: use one input as both the directory name and title; the UI disables confirmation when an existing Workspace has that title, and the Host rejects duplicate-name requests caused by bypassing the UI or concurrent creation; after success, create a SessionDraft under it. - -Explicit Create Workspace creates a real Workspace as soon as the user confirms and immediately displays it in the sidebar; the empty Workspace remains even if the user never sends a message. The inline plus button on a Workspace row creates only a SessionDraft under that group: it neither creates another Workspace nor immediately creates a Host Session. - -Sending the first message performs these steps in order: create the Workspace when necessary, create the Session with the preallocated id, hand the stage and composer buffer off to the real Session, and call `session.prompt`. The Client clears the input only after the Host accepts the prompt. A Workspace remains if failure occurs after it is created; a real Session remains selected if failure occurs after it is published; a prompt failure retains the original input and retries the same Session. - -### Sidebar and ordering - -Workspace groups use the persistent stable order returned by the Host. Bootstrap establishes the historical order once, and explicitly created Workspaces go first; Session activity never moves Workspace groups. - -Within a group, Sessions render strictly in `Workspace.sessionIds` order. Historical Sessions are initialized from the header `createdAt`, and new Sessions go first; whenever a Session's `updatedAt` advances afterward, the Host moves only that id to the front of its Workspace and persists the change. The Client does not batch-sort by `updatedAt` after Session list hydration, so the page never displays the bootstrap order and then jumps as a whole. - -SessionDraft is a presentation-layer row appended without writing to `sessionIds`. When a real Workspace has a SessionDraft, the sidebar's page-derived session count temporarily increases by one; once the real Session with the same id appears, it must not be counted twice, and refreshing removes both the draft and its temporary count. `host/session-added` and `host/workspace-changed` may arrive in either order; the Client merges them by the preallocated SessionId and removes the draft once the real row can be located, without ever briefly showing two rows with the same id. - -### Client and UI boundaries - -A dedicated WorkspacesService manages the Workspace list phase, incremental upserts, reconnect refresh, creation, and recent-Workspace derivation. SessionsService manages only the real Session list, Session scope, history, running state, and real selection. A page-local conversation coordinator manages ConversationStage, SessionDraft, materialization phase, errors, and composer-buffer handoff. - -The existing sidebar layout, row styles, EmptyHero, composer styles, Menu/Modal/Tooltip, portal and slot infrastructure, and `ui-workspace` component skeleton can remain. The Workspace/Session state boundary, empty state, creation actions, first-send state machine, historical initialization, and component props need to be rewritten. The Sidebar and conversation-empty entry points must use the same Workspace data and creation actions; only their anchor direction, open state, and selection callback may differ. - -This phase uses English UI text and does not provide Workspace rename/delete, Session delete, cross-Workspace moves, drag ordering, manual adoption from Ungrouped, multiple SessionDrafts, draft restoration after refresh, or separate display-name and directory-name inputs. - -## Alternatives considered - -**Continue deriving Workspaces dynamically from cwd.** This cannot represent empty Workspaces, stable display names, or explicit order, and it would automatically adopt non-GUI Sessions. Derivation is allowed only for the one-time historical bootstrap; ownership must subsequently be written explicitly to the index. - -**Use one WorkspaceIntent to represent both WorkspaceDraft and SessionDraft.** Their visibility, persistence, and materialization timing differ. Combining them prevents explicit Create Workspace from taking effect immediately and prevents the sidebar from distinguishing a hidden WorkspaceDraft from a draft row under a real Workspace. - -**Create a Host Session or Host persistence intent immediately for the empty state.** A Session with no input would enter the Host lifecycle, while refresh semantics would conflict with a page-local draft. Only a Client SessionDraft exists before the first send. - -**Delay explicit Create Workspace until the first send.** The sidebar would still have no real empty Workspace after user confirmation, conflating “Create Workspace” with “prepare Session.” Only the automatic no-Workspace empty state allows delayed creation. - -**Batch-reorder on the Client by updatedAt after the Session list arrives.** The page would first show the bootstrap `createdAt` order and then jump as a whole, while reconnection could not restore the same order. The Host moves only the corresponding id when an individual Session becomes active. - -**Add workspaceId to SessionHeader.** This would create two persistent ownership fields alongside the Workspace index and require dual writes. The header retains the Session's own cwd fact, the Workspace index owns explicit membership, and reads validate both directions. - -## Acceptance criteria - -- Explicit Create Workspace immediately creates and displays an empty Workspace; the automatic empty state with no Workspace writes nothing to the Host and remains editable. -- New Session, selecting an existing Workspace, both Workspace creation methods, and the inline plus button on a Workspace row each produce the single SessionDraft and follow the sidebar visibility rules. -- The first send materializes Workspace, Session, and prompt in that order; successful stages are not rolled back, input is retained until the prompt is accepted, and retries use the same SessionId. -- The Workspace list performs one reentrant bootstrap using headers only; tests prove it never reads event bodies and that an initialized empty registry does not repeat the bootstrap after restart. -- Membership reads validate both the index and header cwd; cwd-only Sessions, invalid historical cwd values, and failed attaches become Ungrouped. -- Initial rendering waits for both baselines to be ready; Session activity does not move Workspace groups, arrival of the Session list does not trigger a full reorder, and activity in one Session moves only that Session to the front and preserves the order across reconnection. -- Workspace and Session updates arriving in either order never create duplicate Session rows; every first-send failure stage can recover to the same preallocated id. -- Create new rejects duplicate Workspace names at both the UI and Host layers; a SessionDraft under a real Workspace temporarily counts toward the sidebar total, and neither materialization nor refresh leaves a duplicate count. -- Real runnable keyless snapshots cover the empty state, explicit creation, successful first send, failed first send, refresh, and Ungrouped; package-level tests cover bootstrap, bidirectional membership validation, ordering, and idempotency. - -## Risks - -- Header-only bootstrap has no historical activity time and can initialize order only from `createdAt`; it does not batch-correct from the Session list afterward, and only new activity in individual Sessions progressively changes in-group order. -- Historical Sessions with a missing cwd or a path that cannot be resolved by realpath remain Ungrouped; this phase has no manual adoption entry point. -- Refreshing the page discards WorkspaceDraft, SessionDraft, and input not yet accepted by the Host; this is the page-local contract. -- Before its first event, a Host Session still has only a live object and a persistence create-intent; restarting the Host loses that empty Session. This design does not change the existing lazy-persistence semantics by persisting page drafts. -- Explicit Create Workspace persists immediately, so leaving without sending a message still leaves an empty Workspace; this is the cost of making the operation take effect immediately. diff --git a/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.zh.md b/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.zh.md deleted file mode 100644 index 7e13e7de28..0000000000 --- a/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.zh.md +++ /dev/null @@ -1,121 +0,0 @@ -# Agent Note: Workspace GUI and session drafts - -[English](2026-07-25-workspace-gui-and-session-drafts.md) | 中文 - -Status: proposed - -## Problem - -[Domain KV storage 与 Workspace entity](../architecture/2026-07-24-domain-kv-storage-and-workspace.md)定义了 Workspace 的持久实体、路径规范和有序 session 账本,但没有定义 Host 接线、历史数据初始化或 GUI 动线。GUI 同时显示 Workspace 和 Session,并且用户进入 New Session 页面后必须立即输入,即使此时还没有真实 Session,甚至没有真实 Workspace。 - -若用一个 intent 同时表示待创建 Workspace 和待创建 Session,显式 Create Workspace、自动零态、sidebar draft 行和首发失败会共享一组含混状态。若为了解决零态而提前创建 Host Session,又会产生没有用户输入、首个事件前不落盘且重启即消失的空 Session。现有历史 Session 还只有 `SessionHeader.cwd`,需要在不读取事件正文的前提下建立一次初始 Workspace 视图。 - -## Proposal - -### 状态与所有权 - -Workspace 与 Session 是两个真实 Host 对象;WorkspaceDraft 与 SessionDraft 是两个 page-local Client 状态: - -- `Workspace` 可以为空,持久存在并始终显示在 sidebar; -- `Session` 是已经由 Host 创建的真实对象; -- `WorkspaceDraft` 只用于“系统完全没有 Workspace”的自动零态,不显示在 sidebar; -- `SessionDraft` 表示一个待创建 Session,持有目标 Workspace 或 WorkspaceDraft、预分配 SessionId、composer 内容和发送 phase。 - -页面至多存在一个 SessionDraft。真实 Workspace 下的 draft 在 sidebar 显示为 “New session”;WorkspaceDraft 及其 SessionDraft 都不显示。新 draft 替换旧 draft;选择真实 Session 或刷新页面会丢弃未物化 draft 和未提交输入。真实 Workspace、真实 Session 和已经接受的消息不受影响。 - -Client 用判别联合 `ConversationStage = Session | SessionDraft` 表达当前页面,不再用“清空 current 再另存 intent”模拟草稿。Workspace、Session 和 ConversationStage 各有独立对象层;只有真实 Session selection 可以持久化。 - -### Host 与 wire 全链路 - -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 })` | 保留给非 GUI 调用方,创建 Ungrouped Session | - -`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 和 Session 增量,重连以 `workspace.list` 与 `session.list` 两份基线为准。 - -GUI 在 SessionDraft 中预分配 SessionId,但首次发送前不创建任何 Host intent。首次发送时,Client 才把该 id 传给 `session.create`;Host 用同一 id 创建真实 Session 和 persistence create-intent。相同 id、相同 cwd 的重试幂等;id 已存在但 cwd 不同则 fail loud。这样响应丢失和 attach 部分失败都能对账到同一个 Session,而不是重复创建。 - -Workspace 的 `sessionIds` 是有序候选索引。读取成员必须同时满足 id 在索引中且 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 仍是 Ungrouped,索引命中但 header 缺失或 cwd 不匹配的 id 不进入投影。同一 Session 出现在两个 Workspace 索引中属于损坏状态并 fail loud。 - -### 一次性历史初始化 - -Workspace domain 用 durable marker 区分“从未初始化”和“已初始化但为空”。marker 未设置时,WorkspaceRegistry 执行一次可重入 bootstrap: - -1. 只调用一次 `SessionPersistence.list()`;JSONL 只读 header 首行,SQLite 只读 session 元数据行,禁止调用 `load`、`inspect`、history 或解析事件正文。 -2. 忽略无 cwd、目录不存在、非目录或 realpath 失败的 header;这些 Session 留在 Ungrouped。 -3. 按 canonical cwd 分组,组内按 header `createdAt` 降序写入 `sessionIds`,Workspace 组按各组最大 `createdAt` 降序写入稳定顺序。 -4. 崩溃重入时按 canonical path 复用已经写入的 Workspace 并合并缺失 id;全部记录 durable 后最后写 marker。 - -marker 写入后不再按 cwd 自动建 Workspace 或补账。后续绕过 `workspaceId` 的调用链保持 Ungrouped;这是一条兼容路径,不是持续派生 Workspace 的第二写源。 - -### 用户动线 - -应用首次进入时,Client 等待 Workspace 与 Session 两份基线都 ready;仍存在的真实 Session selection 可以恢复,否则进入 New Session 流程。用户显式进入 New Session 时不恢复旧 selection,而是选择最近 Workspace 并创建 SessionDraft。最近 Workspace 取其已验证成员 Session 的最大 `updatedAt`;空 Workspace 回退到 `createdAt`。该值只决定 New Session 的默认目标,不改变 sidebar 的 Workspace 顺序,也不会在 Session list 到达后触发二次选择。 - -完全没有 Workspace 时,页面创建名为 `workspace` 的 WorkspaceDraft 和其 SessionDraft。它们不写 Host,但 composer 始终可输入。顶部 New Session 重新进入该零态选择流程,不立即调用 `session.create`。 - -Sidebar Workspace 区头加号和 composer 的 Workspace 创建入口复用同一个 picker 与 modal: - -- 选择已有 Workspace:只创建指向该 Workspace 的 SessionDraft; -- Use an existing folder:调用 `workspace.create({ path })`,成功后创建其下的 SessionDraft; -- Create new:用一个输入同时作为目录名和 title;UI 对已有 Workspace title 禁止确认,Host 拒绝绕过 UI 或并发产生的重名请求;成功后创建其下的 SessionDraft。 - -显式 Create Workspace 在用户确认时立即产生真实 Workspace,并立即显示在 sidebar;即使用户不发送消息,也会留下空 Workspace。Workspace 行内加号只创建该组下的 SessionDraft,不创建另一个 Workspace,也不立即创建 Host Session。 - -发送首条消息时依次执行:必要时创建 Workspace、以预分配 id 创建 Session、把 stage 和 composer buffer 转交给真实 Session、调用 `session.prompt`。只有 Host 接受 prompt 后才清空输入。Workspace 已创建后失败则保留 Workspace;Session 已发布后失败则保留并聚焦真实 Session;prompt 失败则保留原输入并重试同一 Session。 - -### Sidebar 与排序 - -Workspace 组使用 Host 返回的持久稳定顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放到首位;Session 活跃不会移动 Workspace 组。 - -组内严格按 `Workspace.sessionIds` 渲染。历史 Session 以 header `createdAt` 初始化,新 Session 放到首位;此后某个 Session 的 `updatedAt` 前进时,Host 只把该 id 移到所属 Workspace 的首位并持久化。Client 不在 Session list hydration 后按 `updatedAt` 批量排序,因此页面不会先显示 bootstrap 顺序再整体跳动。 - -SessionDraft 是渲染层附加行,不写入 `sessionIds`。真实 Workspace 下存在 SessionDraft 时,sidebar 的页面派生 session 数量临时加一;同 id 的真实 Session 出现后不能重复计数,刷新后 draft 与临时计数一起消失。`host/session-added` 与 `host/workspace-changed` 可能以任意顺序到达;Client 按预分配 SessionId 合并,并在真实行可定位后移除 draft,不能短暂显示两个同 id 行。 - -### Client 与 UI 边界 - -独立 WorkspacesService 管理 Workspace list phase、增量 upsert、重连 refresh、create 和最近 Workspace 派生;SessionsService 只管理真实 Session list、Session scope、history、running 状态与真实 selection;page-local conversation coordinator 管理 ConversationStage、SessionDraft、物化 phase、错误和 composer buffer 转交。 - -现有 sidebar 布局、行样式、EmptyHero、composer 样式、Menu/Modal/Tooltip、portal、slot 基建和 `ui-workspace` 组件骨架可以保留。需要重写的是 Workspace/Session 状态边界、零态、创建动作、首发状态机、历史初始化和组件 props。Sidebar 与 conversation empty 两个入口必须使用同一 Workspace 数据和创建动作,只允许锚点方向、开关状态与选中回调不同。 - -本期 UI 使用英文,不提供 Workspace rename/delete、Session delete、跨 Workspace 移动、拖拽排序、Ungrouped 手动收编、多 SessionDraft、draft 刷新恢复或显示名与目录名的双输入。 - -## Alternatives considered - -**继续按 cwd 动态派生 Workspace。** 该方案无法表示空 Workspace、稳定显示名或显式顺序,也会把非 GUI Session 自动收编;只允许一次历史 bootstrap,之后归属必须显式写入索引。 - -**用一个 WorkspaceIntent 同时表示 WorkspaceDraft 与 SessionDraft。** 两者的显示、持久化和物化时点不同;合并后显式 Create Workspace 无法立即生效,sidebar 也无法区分隐藏 WorkspaceDraft 与真实 Workspace 下的 draft 行。 - -**零态立即创建 Host Session 或 Host persistence intent。** 未输入的 Session 会进入 Host 生命周期,刷新语义与 page-local 草稿冲突;首次发送前只保留 Client SessionDraft。 - -**显式 Create Workspace 延迟到首次发送。** 用户确认后 sidebar 仍没有真实空 Workspace,“Create Workspace”与“准备 Session”语义混合;只有自动无 Workspace 零态允许延迟。 - -**Client 在 Session list 到达后按 updatedAt 批量重排。** 页面会先展示 bootstrap 的 `createdAt` 顺序再整体跳动,重连也无法恢复同一顺序;Host 只在单个 Session 活跃时前移对应 id。 - -**在 SessionHeader 中增加 workspaceId。** 它会与 Workspace 索引形成两个持久归属字段并要求双写;header 保留 session 自身的 cwd 事实,Workspace 索引负责显式归属,读取时双向校验。 - -## Acceptance criteria - -- 显式 Create Workspace 立即创建并显示空 Workspace;完全无 Workspace 的自动零态不写 Host 且允许输入。 -- New Session、选择已有 Workspace、两种 Workspace 创建方式和 Workspace 行内加号都产生唯一的 SessionDraft,并遵守 sidebar 可见性规则。 -- 首发按 Workspace、Session、prompt 顺序物化;各已成功阶段不回滚,输入在 prompt 接受前不丢失,重复创建使用同一 SessionId。 -- Workspace list 只用 header 完成一次可重入 bootstrap;测试证明不读取事件正文,initialized 的空 registry 重启也不重复执行。 -- 归属读取同时校验索引与 header cwd;cwd-only Session、无效历史 cwd 和 attach 失败进入 Ungrouped。 -- 首次渲染等待两份基线 ready;Workspace 组不因 Session 活跃移动,Session list 到达不触发整体重排,单个活跃 Session 只前移自身并在重连后保持顺序。 -- Workspace 与 Session 增量以任意顺序到达都不会产生重复 Session 行;首发各失败阶段都能恢复到同一个预分配 id。 -- Create new 在 UI 与 Host 两层拒绝重名 Workspace;真实 Workspace 下的 SessionDraft 临时计入 sidebar 数量,物化与刷新都不会留下重复计数。 -- 真实 runnable keyless snapshot 覆盖零态、显式创建、首发成功、首发失败、刷新和 Ungrouped;包级测试覆盖 bootstrap、双向归属、排序与幂等。 - -## Risks - -- Header-only bootstrap 没有历史活跃时间,只能用 `createdAt` 初始化顺序;初始化后不按 Session list 批量修正,只有新的单项活跃逐步改变组内顺序。 -- 历史 cwd 缺失或无法 realpath 的 Session 会留在 Ungrouped;本期没有手动收编入口。 -- 页面刷新会丢弃 WorkspaceDraft、SessionDraft 和尚未接受的输入;这是 page-local 契约。 -- Host Session 在首个事件前仍只有 live 对象和 persistence create-intent,Host 重启会丢失该空 Session;本设计不通过持久化页面 draft 改变现有懒持久化语义。 -- 显式 Create Workspace 立即落盘,因此用户不发送就离开也会留下空 Workspace;这是该操作真实生效的代价。 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index f1e13f007a..6b697cbed9 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-runtime -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state, and page-local Session Intent state; WorkspacesService owns Workspace objects, list/actions, page-local Workspace Intent state, and default-target derivation. The runtime fans the shared Host stream into both managers. Contract: api-contracts v3 §4. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state, and page-local Session Intent state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, page-local Workspace Intent state, default-target derivation, and the cross-object New Session flow. The runtime fans the shared Host stream into both managers. Contract: api-contracts v3 §4. ## Workspace and Session lists diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 8d9dd4d958..907fc961f6 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -149,12 +149,18 @@ export class SessionManager { return session } - /** @returns the active frontend Session, if one remains selected. */ + /** + * Resolve the active frontend Session Intent. + * @returns the active frontend Session, if one remains selected. + */ getIntent(): Session | undefined { return this.intentSessionId === undefined ? undefined : this.sessions.get(this.intentSessionId) } - /** @param text - exact controlled-input value for the active frontend Session. */ + /** + * Update the retained prompt of the active frontend Session. + * @param text - exact controlled-input value for the active frontend Session. + */ updateIntent(text: string): void { this.getIntent()?.updatePendingPrompt(text) } diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index f77717d7f0..845292a481 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -209,17 +209,24 @@ export class SessionsService { * Start or retarget the sole client-local Session intent. * @param target - resolved real or frontend-only Workspace target. * @param prompt - optional prompt retained across retargeting. + * @returns the frontend Session object that owns the Intent. */ startIntent(target: SessionIntentTarget, prompt = ''): Session { return this.manager.startIntent(target, prompt) } - /** @returns the active frontend Session object, if one exists. */ + /** + * Resolve the active frontend Session Intent. + * @returns the active frontend Session object, if one exists. + */ intent(): Session | undefined { return this.manager.getIntent() } - /** @param text - exact controlled-input value for the current Session Intent. */ + /** + * Update the retained prompt of the active frontend Session. + * @param text - exact controlled-input value for the current Session Intent. + */ updateIntent(text: string): void { this.manager.updateIntent(text) } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 4173318fc2..396d0aa798 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -141,7 +141,10 @@ export class Session implements ObservableSnapshot { return result } - /** @param text - exact controlled value of this Session's retained prompt. */ + /** + * Update this Session's retained prompt while it remains editable. + * @param text - exact controlled value of this Session's retained prompt. + */ updatePendingPrompt(text: string): void { const pending = this.pendingPrompt if (pending === null || pending.phase === 'sending') return diff --git a/packages/client/runtime/tests/session-drafts.spec.ts b/packages/client/runtime/tests/session-intents.spec.ts similarity index 100% rename from packages/client/runtime/tests/session-drafts.spec.ts rename to packages/client/runtime/tests/session-intents.spec.ts diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 0b7e4ef247..0711adcccb 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,7 +2,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts and publishes the two intents. The Session object keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService. +The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts the cross-object flow; each Workspace or Session object owns its own materialization. The Session keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService. The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 6e1ca2b70e..5ea5ea96ee 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -49,7 +49,10 @@ export class ConversationService extends Service { await this.scopedSession('loadOlder').loadOlder() } - /** Update the scoped Session's retained pending prompt. */ + /** + * Update the scoped Session's retained pending prompt. + * @param text - exact controlled-input value to retain. + */ updatePendingPrompt(text: string): void { this.scopedSession('updatePendingPrompt').updatePendingPrompt(text) } diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index cc8b5a7237..58cab1f2cc 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -10,7 +10,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. -Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. Session drafts are client-only and have no wire method. +Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. Frontend Workspace and Session Intents are client-only and have no wire method. ## Carrier layer (`/client` + root) diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index a687910319..b333d90ad0 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -2,7 +2,7 @@ Workspace entity registry (`ctx.workspace`) for the DeepSeek Harness: durable workspace records, stable workspace order, and a newest-first candidate session index stored through the domain data form. Consumers see the `Workspace` interface; the entity implementation stays package-private. -The entity/storage rationale lives in the [domain Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md); header-only bootstrap and GUI ordering live in the [Workspace GUI Agent Note](../../../.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md). +The entity/storage rationale lives in the [domain Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md); header-only bootstrap and GUI ordering live in the [Workspace UI product-flow Agent Note](../../../.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md). ## Shape