From 7401587ac26e5b15774c24c263db90206e6c400b Mon Sep 17 00:00:00 2001
From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
Date: Thu, 30 Jul 2026 01:12:48 -0700
Subject: [PATCH 1/9] fix(ui-workspace): show approval-waiting sessions
---
packages/client/ui-workspace/README.i18n.yaml | 4 +--
packages/client/ui-workspace/README.md | 2 ++
packages/client/ui-workspace/README.zh.md | 2 ++
.../ui-workspace/src/client/rows/Rows.tsx | 17 ++++++---
.../client/ui-workspace/src/client/tree.ts | 3 ++
.../client/ui-workspace/tests/rows.spec.tsx | 36 +++++++++++++++----
.../client/ui-workspace/tests/tree.spec.ts | 8 +++++
7 files changed, 59 insertions(+), 13 deletions(-)
diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml
index 536911a16a..bada1e738d 100644
--- a/packages/client/ui-workspace/README.i18n.yaml
+++ b/packages/client/ui-workspace/README.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 packages/client/ui-workspace/README.md
-README.md: a1b58f4abe0925be3b426d10344777e46caa9ba0
-README.zh.md: a472507bc45549c8feb55a75d294cbd7b3138cc5
+README.md: 1497f816a295e2cd156af9b779bce0b42759e1c7
+README.zh.md: be496412db9790b0625b40f0bbb06c1d406af015
diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md
index a1b58f4abe..1497f816a2 100644
--- a/packages/client/ui-workspace/README.md
+++ b/packages/client/ui-workspace/README.md
@@ -6,6 +6,8 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration.
+Session rows project the runtime's live `waitingApproval` fact: an amber warning dot takes precedence over the blue running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Running and idle presentation is unchanged when no approval is pending.
+
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
## Model Experience
diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md
index a472507bc4..be496412db 100644
--- a/packages/client/ui-workspace/README.zh.md
+++ b/packages/client/ui-workspace/README.zh.md
@@ -6,6 +6,8 @@
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
+Session 行会投影 runtime 的实时 `waitingApproval` 状态:琥珀色警告点优先于蓝色运行指示器,hover 卡片在请求解决前显示 **Waiting for approval**。没有待审批请求时,运行与空闲展示保持不变。
+
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
## 模型体验
diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx
index d75fabdd8b..4f823d531f 100644
--- a/packages/client/ui-workspace/src/client/rows/Rows.tsx
+++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx
@@ -121,15 +121,23 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
* @param props.onToggle - unfold/fold a subtree by id.
* @returns the node's row followed by its children.
*/
-/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */
+/** Session status presentation; approval waiting outranks the underlying running state. */
+function sessionStatus(node: SessionNode): { state: 'warning' | 'ongoing' | 'done'; label: string } {
+ if (node.waitingApproval) return { state: 'warning', label: 'Waiting for approval' }
+ if (node.running) return { state: 'ongoing', label: 'Running' }
+ return { state: 'done', label: 'Idle' }
+}
+
+/** Hover-card body: full title, relative time, and approval/running/idle status. */
function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) {
+ const status = sessionStatus(node)
return (
{node.title}
{`${formatRelativeTime(node.updatedAt, now)} ago`}
-
- {node.running ? 'Running' : 'Idle'}
+
+ {status.label}
)
@@ -175,6 +183,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
}) {
const row = node
const selected = node.id === currentId
+ const status = sessionStatus(node)
const [menuOpen, setMenuOpen] = useState(false)
// Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
// the title): both slots are always reserved so titles align whether or not
@@ -226,7 +235,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
)
: null}
- {row.running && }
+ {(row.waitingApproval || row.running) && }
{row.title}
{formatRelativeTime(row.updatedAt, now)}
diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts
index c0adfadd6f..af2c6cd051 100644
--- a/packages/client/ui-workspace/src/client/tree.ts
+++ b/packages/client/ui-workspace/src/client/tree.ts
@@ -20,6 +20,8 @@ export interface SessionNode {
/** The session HAS children in the data (the twist renders even while folded). */
hasChildren: boolean
expanded: boolean
+ /** A pending approval takes display precedence over the running state. */
+ waitingApproval: boolean
running: boolean
updatedAt: number
}
@@ -183,6 +185,7 @@ function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChi
children,
hasChildren,
expanded,
+ waitingApproval: s.waitingApproval,
running: s.running,
updatedAt: s.updatedAt,
}
diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx
index bfaa8a36dd..0b6837c0bc 100644
--- a/packages/client/ui-workspace/tests/rows.spec.tsx
+++ b/packages/client/ui-workspace/tests/rows.spec.tsx
@@ -59,11 +59,11 @@ describe('workspace browser rows', () => {
it('renders and operates selected, running, recursive Session nodes', () => {
const child: SessionNode = {
id: sid('child'), title: 'Child', children: [], hasChildren: false,
- expanded: false, running: false, updatedAt: 0,
+ expanded: false, waitingApproval: false, running: false, updatedAt: 0,
}
const parent: SessionNode = {
id: sid('parent'), title: 'Parent', children: [child], hasChildren: true,
- expanded: true, running: true, updatedAt: 0,
+ expanded: true, waitingApproval: false, running: true, updatedAt: 0,
}
const onOpen = vi.fn()
const onToggle = vi.fn()
@@ -142,7 +142,7 @@ describe('workspace browser rows', () => {
const onRename = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'One', children: [], hasChildren: false,
- expanded: false, running: false, updatedAt: 0,
+ expanded: false, waitingApproval: false, running: false, updatedAt: 0,
}
render()
@@ -169,7 +169,7 @@ describe('workspace browser rows', () => {
it('flat variant renders no twist even for a parent and ignores toggling', () => {
const node: SessionNode = {
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
- expanded: false, running: false, updatedAt: 0,
+ expanded: false, waitingApproval: false, running: false, updatedAt: 0,
}
render()
@@ -181,7 +181,7 @@ describe('workspace browser rows', () => {
try {
const node: SessionNode = {
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
- expanded: false, running: true, updatedAt: 0,
+ expanded: false, waitingApproval: false, running: true, updatedAt: 0,
}
render()
@@ -203,12 +203,34 @@ describe('workspace browser rows', () => {
}
})
+ it('shows approval waiting as warning ahead of the running state', () => {
+ vi.useFakeTimers()
+ try {
+ const node: SessionNode = {
+ id: sid('approval'), title: 'Needs approval', children: [], hasChildren: false,
+ expanded: false, waitingApproval: true, running: true, updatedAt: 0,
+ }
+ render()
+ const row = screen.getByRole('treeitem')
+ expect(row.querySelector('[data-state="warning"]')).toBeTruthy()
+ expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
+
+ fireEvent.pointerEnter(row.parentElement as HTMLElement)
+ act(() => { vi.advanceTimersByTime(500) })
+ expect(screen.getByText('Waiting for approval')).toBeTruthy()
+ expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('idle hover card shows the Idle status line', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
- expanded: false, running: false, updatedAt: 0,
+ expanded: false, waitingApproval: false, running: false, updatedAt: 0,
}
render()
@@ -224,7 +246,7 @@ describe('workspace browser rows', () => {
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
const node: SessionNode = {
id: sid('s1'), title: 'Drag me', children: [], hasChildren: false,
- expanded: false, running: false, updatedAt: 0,
+ expanded: false, waitingApproval: false, running: false, updatedAt: 0,
}
const inactive = dragProps()
const { rerender } = render(
diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts
index eb34f633d8..2af6c1a6ab 100644
--- a/packages/client/ui-workspace/tests/tree.spec.ts
+++ b/packages/client/ui-workspace/tests/tree.spec.ts
@@ -33,6 +33,14 @@ describe('deriveGroups', () => {
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
})
+ it('projects approval-waiting state into grouped and flat rows', () => {
+ const awaiting = { ...summary('awaiting', 10), waitingApproval: true, running: true }
+ const sessions = list(awaiting)
+ const grouped = deriveGroups(sessions, [workspace('project', ['awaiting'])], view(['project']))
+ expect(grouped[0]!.sessions[0]).toMatchObject({ waitingApproval: true, running: true })
+ expect(deriveFlat(sessions, { query: '' })[0]).toMatchObject({ waitingApproval: true, running: true })
+ })
+
it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY]))
From 61803f1a462467d49ec06b1f1b107ba00e40bf03 Mon Sep 17 00:00:00 2001
From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
Date: Thu, 30 Jul 2026 02:07:36 -0700
Subject: [PATCH 2/9] fix(ui-workspace): expose session status accessibly
---
packages/client/ui-sidebar/README.i18n.yaml | 4 +--
packages/client/ui-sidebar/README.md | 2 +-
packages/client/ui-sidebar/README.zh.md | 2 +-
packages/client/ui-workspace/README.i18n.yaml | 4 +--
packages/client/ui-workspace/README.md | 3 +-
packages/client/ui-workspace/README.zh.md | 3 +-
.../src/client/rows/Rows.module.css | 9 +++++
.../ui-workspace/src/client/rows/Rows.tsx | 36 ++++++++++++-------
.../client/ui-workspace/tests/rows.spec.tsx | 15 +++++---
9 files changed, 52 insertions(+), 26 deletions(-)
diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml
index 6c5f1735e3..00b33602d0 100644
--- a/packages/client/ui-sidebar/README.i18n.yaml
+++ b/packages/client/ui-sidebar/README.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 packages/client/ui-sidebar/README.md
-README.md: 93a1f15a5802f94a0ebe930dda1dbd4fbc7343c9
-README.zh.md: 8c8545a5d7d8cb4d58772abf867d7ee82c31bf1d
+README.md: d2c0c3332f2202986f1daf3a45c84cc1e65eee6d
+README.zh.md: 03cb86842d8a28f3a18250a9d77dd0a0a217d7b9
diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md
index 93a1f15a58..d2c0c3332f 100644
--- a/packages/client/ui-sidebar/README.md
+++ b/packages/client/ui-sidebar/README.md
@@ -22,6 +22,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
-- **State dots have two live data states (running/none)** — the done/error/amber sources arrive with P-II approvals and notifications; the four-color primitive is already wired.
+- **State dots have approval-waiting/running/none live states** — approval waiting is amber and outranks running; done/error notification sources remain deferred.
- **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred.
- **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host.
diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md
index 8c8545a5d7..03cb86842d 100644
--- a/packages/client/ui-sidebar/README.zh.md
+++ b/packages/client/ui-sidebar/README.zh.md
@@ -22,6 +22,6 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work
## 已知限制与暂缓事项
-- **状态点只有两种实时数据状态(running/none)**:done/error/amber 的数据源将随 P-II 审批与通知功能一并提供;四色原语已接入。
+- **状态点具有待审批/running/none 三种实时状态**:待审批使用琥珀色并优先于 running;done/error 的通知数据源仍暂缓实现。
- **分组选单只提供按 Workspace 分组**:Update/Status 分组策略只有图稿而没有规范,暂缓实现。
- **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。
diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml
index bada1e738d..27cb783db7 100644
--- a/packages/client/ui-workspace/README.i18n.yaml
+++ b/packages/client/ui-workspace/README.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 packages/client/ui-workspace/README.md
-README.md: 1497f816a295e2cd156af9b779bce0b42759e1c7
-README.zh.md: be496412db9790b0625b40f0bbb06c1d406af015
+README.md: 4ca836e4f1beeb164716e5fc4741253719d2700c
+README.zh.md: 2a5448a12d58184b027c99b5301510370ba63a83
diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md
index 1497f816a2..4ca836e4f1 100644
--- a/packages/client/ui-workspace/README.md
+++ b/packages/client/ui-workspace/README.md
@@ -6,7 +6,7 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration.
-Session rows project the runtime's live `waitingApproval` fact: an amber warning dot takes precedence over the blue running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Running and idle presentation is unchanged when no approval is pending.
+Session rows distinguish the runtime's live `waitingApproval` fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, an accompanying visually hidden label exposes the state to assistive technology, and the hover card reports **Waiting for approval** until the request is resolved. Running and idle presentation is unchanged when no approval is pending.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
@@ -21,4 +21,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No Session deletion or fork control** — the Session menu's Fork and Delete rows remain visual-only (Rename is wired); Workspace registration deletion does not delete Sessions.
+- **Approval waiting is not aggregated into hidden ancestors** — a waiting child Session under a folded parent, or any waiting row inside a collapsed group, becomes visible only after that container is expanded.
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.
diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md
index be496412db..2a5448a12d 100644
--- a/packages/client/ui-workspace/README.zh.md
+++ b/packages/client/ui-workspace/README.zh.md
@@ -6,7 +6,7 @@
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
-Session 行会投影 runtime 的实时 `waitingApproval` 状态:琥珀色警告点优先于蓝色运行指示器,hover 卡片在请求解决前显示 **Waiting for approval**。没有待审批请求时,运行与空闲展示保持不变。
+Session 行会把 runtime 的实时 `waitingApproval` 状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,随附的视觉隐藏标签会向辅助技术公开这一状态,hover 卡片则在请求解决前显示 **Waiting for approval**。没有待审批请求时,运行与空闲展示保持不变。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
@@ -21,4 +21,5 @@ Session 行会投影 runtime 的实时 `waitingApproval` 状态:琥珀色警
## 已知限制与暂缓事项
- **没有 Session 删除与 fork 控件**:Session 菜单的 Fork 与 Delete 行仍仅提供视觉效果(Rename 已接线);删除 Workspace 注册记录不会删除 Session。
+- **待审批状态不会聚合到隐藏的祖先节点**:折叠父节点下正在等待的子 Session,或折叠分组内的任何等待行,只有在对应容器展开后才可见。
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。
diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css
index 7b19284b66..6d5e90beeb 100644
--- a/packages/client/ui-workspace/src/client/rows/Rows.module.css
+++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css
@@ -68,6 +68,15 @@
color: var(--dsw-alias-label-tertiary);
}
+.visuallyHidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+}
+
.folderActive {
color: var(--dsw-alias-state-business-primary);
diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx
index 4f823d531f..92796c409e 100644
--- a/packages/client/ui-workspace/src/client/rows/Rows.tsx
+++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx
@@ -109,18 +109,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
)
}
-/**
- * One session subtree: the node's own 34px row (indent by depth, expand
- * twist when it has children, running dot, relative time) plus its visible
- * children, recursively — the component tree mirrors the derived tree.
- * @param props.node - derived session node.
- * @param props.depth - 0 = directly under the group header.
- * @param props.currentId - selected session id (row highlight).
- * @param props.now - epoch ms for relative-time formatting.
- * @param props.onOpen - open a session by id.
- * @param props.onToggle - unfold/fold a subtree by id.
- * @returns the node's row followed by its children.
- */
/** Session status presentation; approval waiting outranks the underlying running state. */
function sessionStatus(node: SessionNode): { state: 'warning' | 'ongoing' | 'done'; label: string } {
if (node.waitingApproval) return { state: 'warning', label: 'Waiting for approval' }
@@ -167,6 +155,21 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
+/**
+ * One session subtree: the node's own 34px row (indent by depth, expand
+ * twist when it has children, status dot, relative time) plus its visible
+ * children, recursively — the component tree mirrors the derived tree.
+ * @param props.node - derived session node.
+ * @param props.depth - 0 = directly under the group header.
+ * @param props.currentId - selected session id (row highlight).
+ * @param props.now - epoch ms for relative-time formatting.
+ * @param props.onOpen - open a session by id.
+ * @param props.onRename - rename a session by id and current title.
+ * @param props.onToggle - unfold/fold a subtree by id.
+ * @param props.drag - optional root-row drag wiring.
+ * @param props.flat - omit tree indentation controls for a flat list.
+ * @returns the node's row followed by its children.
+ */
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onToggle, drag, flat = false }: {
node: SessionNode
depth: number
@@ -235,7 +238,14 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
)
: null}
- {(row.waitingApproval || row.running) && }
+
+ {status.state !== 'done' && (
+ <>
+
+ {status.label}
+ >
+ )}
+
{row.title}
{formatRelativeTime(row.updatedAt, now)}
diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx
index 0b6837c0bc..f9caa54c0b 100644
--- a/packages/client/ui-workspace/tests/rows.spec.tsx
+++ b/packages/client/ui-workspace/tests/rows.spec.tsx
@@ -191,7 +191,7 @@ describe('workspace browser rows', () => {
// Card body: full title + relative time + running status.
expect(screen.getAllByText('Hovered')).toHaveLength(2)
expect(screen.getByText('1min ago')).toBeTruthy()
- expect(screen.getByText('Running')).toBeTruthy()
+ expect(screen.getAllByText('Running')).toHaveLength(2)
fireEvent.pointerLeave(wrapper)
// Menu open (disabled=true) suppresses the card for the same hover.
fireEvent.click(screen.getByRole('button', { name: 'Session actions for Hovered' }))
@@ -210,15 +210,20 @@ describe('workspace browser rows', () => {
id: sid('approval'), title: 'Needs approval', children: [], hasChildren: false,
expanded: false, waitingApproval: true, running: true, updatedAt: 0,
}
- render()
const row = screen.getByRole('treeitem')
expect(row.querySelector('[data-state="warning"]')).toBeTruthy()
expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
-
- fireEvent.pointerEnter(row.parentElement as HTMLElement)
- act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('Waiting for approval')).toBeTruthy()
+
+ view.rerender()
+ expect(screen.getByRole('treeitem').querySelector('[data-state="warning"]')).toBeTruthy()
+
+ fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
+ act(() => { vi.advanceTimersByTime(500) })
+ expect(screen.getAllByText('Waiting for approval')).toHaveLength(2)
expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
} finally {
vi.useRealTimers()
From 8014abffa011d4b8b4d983f27b0ce2a1776d0fa7 Mon Sep 17 00:00:00 2001
From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
Date: Thu, 30 Jul 2026 02:13:02 -0700
Subject: [PATCH 3/9] test(web): cover waiting approval in built graph
---
apps/web/tests/built-boot.snapshot.ts | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts
index 69d5d5cfae..018a9f2180 100644
--- a/apps/web/tests/built-boot.snapshot.ts
+++ b/apps/web/tests/built-boot.snapshot.ts
@@ -102,6 +102,14 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
await within(tree).findByText('4 sessions')
+ // The resident approval fixture proves the assembled workspace plugin
+ // distinguishes a blocked running session from an ordinarily busy one.
+ const waitingTitle = await within(tree).findByText('Fixture 历史会话')
+ const waitingRow = waitingTitle.closest('[role="treeitem"]')
+ expect(waitingRow?.querySelector('[data-state="warning"]')).not.toBeNull()
+ expect(waitingRow?.querySelector('[data-state="ongoing"]')).toBeNull()
+ expect(within(waitingRow as HTMLElement).getByText('Waiting for approval')).not.toBeNull()
+
// Opening a session reaches chat content through the fixture transport.
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
await waitFor(() => {
From 472ba33cd941ace8d0ab15aa6f89932206926c3c Mon Sep 17 00:00:00 2001
From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
Date: Thu, 30 Jul 2026 02:17:14 -0700
Subject: [PATCH 4/9] refactor(ui-workspace): reuse status dot vocabulary
---
packages/client/ui-workspace/src/client/rows/Rows.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx
index f9bd7f3eaf..fbde2b9522 100644
--- a/packages/client/ui-workspace/src/client/rows/Rows.tsx
+++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx
@@ -12,6 +12,7 @@ import {
IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
+import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
import type { GroupNode, SessionNode } from '../tree.ts'
import { formatRelativeTime } from '../tree.ts'
import css from './Rows.module.css'
@@ -135,7 +136,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
}
/** Session status presentation; approval waiting outranks the underlying running state. */
-function sessionStatus(node: SessionNode): { state: 'warning' | 'ongoing' | 'done'; label: string } {
+function sessionStatus(node: SessionNode): { state: StateDotState; label: string } {
if (node.waitingApproval) return { state: 'warning', label: 'Waiting for approval' }
if (node.running) return { state: 'ongoing', label: 'Running' }
return { state: 'done', label: 'Idle' }
From 285cd60744e0fbebcec20e5f50605c3ea3dc7f8b Mon Sep 17 00:00:00 2001
From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
Date: Thu, 30 Jul 2026 02:40:31 -0700
Subject: [PATCH 5/9] docs(ui-workspace): align approval status contracts
---
apps/web/tests/built-boot.snapshot.ts | 20 ++++++++++---------
packages/client/ui-workspace/README.i18n.yaml | 4 ++--
packages/client/ui-workspace/README.md | 2 +-
packages/client/ui-workspace/README.zh.md | 2 +-
.../client/ui-workspace/src/client/tree.ts | 2 +-
5 files changed, 16 insertions(+), 14 deletions(-)
diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts
index 018a9f2180..d436d41866 100644
--- a/apps/web/tests/built-boot.snapshot.ts
+++ b/apps/web/tests/built-boot.snapshot.ts
@@ -6,10 +6,10 @@
// layers, per-plugin CSS injection, and a rendered journey reaching chat
// content from the keyless FixtureApiClient transport.
//
-// Behavior assertions do NOT belong here: component and wiring behavior is
-// pinned by the per-package suites (SlotTestRuntime benches over src), which
-// this smoke's plugin set cannot influence — bundling, module-table
-// resolution, and boot layering are the only failure modes left to it.
+// Component behavior remains owned by per-package suites (SlotTestRuntime
+// benches over src). This smoke additionally pins the resident approval
+// fixture's cross-plugin projection because only the built connection/runtime/
+// workspace graph can prove that transport-to-row path end to end.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
@@ -105,13 +105,15 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
// The resident approval fixture proves the assembled workspace plugin
// distinguishes a blocked running session from an ordinarily busy one.
const waitingTitle = await within(tree).findByText('Fixture 历史会话')
- const waitingRow = waitingTitle.closest('[role="treeitem"]')
- expect(waitingRow?.querySelector('[data-state="warning"]')).not.toBeNull()
- expect(waitingRow?.querySelector('[data-state="ongoing"]')).toBeNull()
- expect(within(waitingRow as HTMLElement).getByText('Waiting for approval')).not.toBeNull()
+ const waitingRow = waitingTitle.closest('[role="treeitem"]')
+ expect(waitingRow).not.toBeNull()
+ if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row')
+ expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull()
+ expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull()
+ expect(within(waitingRow).getByText('Waiting for approval')).not.toBeNull()
// Opening a session reaches chat content through the fixture transport.
- fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
+ fireEvent.click(waitingTitle)
await waitFor(() => {
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
}, { timeout: 10_000 })
diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml
index 27cb783db7..25a2713cfb 100644
--- a/packages/client/ui-workspace/README.i18n.yaml
+++ b/packages/client/ui-workspace/README.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 packages/client/ui-workspace/README.md
-README.md: 4ca836e4f1beeb164716e5fc4741253719d2700c
-README.zh.md: 2a5448a12d58184b027c99b5301510370ba63a83
+README.md: 7109de680f98ede4d8374444cf50b439317ce128
+README.zh.md: 874d9e3d190e0488d95362ce1eea260341d6a23e
diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md
index 4ca836e4f1..7109de680f 100644
--- a/packages/client/ui-workspace/README.md
+++ b/packages/client/ui-workspace/README.md
@@ -6,7 +6,7 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration.
-Session rows distinguish the runtime's live `waitingApproval` fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, an accompanying visually hidden label exposes the state to assistive technology, and the hover card reports **Waiting for approval** until the request is resolved. Running and idle presentation is unchanged when no approval is pending.
+Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits are tracked separately and do not set `waitingApproval`.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md
index 2a5448a12d..874d9e3d19 100644
--- a/packages/client/ui-workspace/README.zh.md
+++ b/packages/client/ui-workspace/README.zh.md
@@ -6,7 +6,7 @@
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
-Session 行会把 runtime 的实时 `waitingApproval` 状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,随附的视觉隐藏标签会向辅助技术公开这一状态,hover 卡片则在请求解决前显示 **Waiting for approval**。没有待审批请求时,运行与空闲展示保持不变。
+Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,hover 卡片则在请求解决前显示 **Waiting for approval**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(`Waiting for approval` 或 `Running`);空闲行会保留空的状态槽位。问题等待由另一套状态跟踪,不会设置 `waitingApproval`。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts
index 210148c72f..763818334f 100644
--- a/packages/client/ui-workspace/src/client/tree.ts
+++ b/packages/client/ui-workspace/src/client/tree.ts
@@ -20,7 +20,7 @@ export interface SessionNode {
/** The session HAS children in the data (the twist renders even while folded). */
hasChildren: boolean
expanded: boolean
- /** A pending approval takes display precedence over the running state. */
+ /** The runtime Session list reports a pending approval request for this Session. */
waitingApproval: boolean
running: boolean
updatedAt: number
From 51711a37720144172125d6713330a886ddf65b6f Mon Sep 17 00:00:00 2001
From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
Date: Thu, 30 Jul 2026 02:41:32 -0700
Subject: [PATCH 6/9] docs(ui-sidebar): defer session status ownership
---
packages/client/ui-sidebar/README.i18n.yaml | 4 ++--
packages/client/ui-sidebar/README.md | 2 +-
packages/client/ui-sidebar/README.zh.md | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml
index 00b33602d0..c1f5d5df03 100644
--- a/packages/client/ui-sidebar/README.i18n.yaml
+++ b/packages/client/ui-sidebar/README.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 packages/client/ui-sidebar/README.md
-README.md: d2c0c3332f2202986f1daf3a45c84cc1e65eee6d
-README.zh.md: 03cb86842d8a28f3a18250a9d77dd0a0a217d7b9
+README.md: 19c2d1033de4475816249aa8429f4a589eeb6481
+README.zh.md: b8c154586570cf1b9fd4bf776bc09b36ab5ee7d2
diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md
index d2c0c3332f..19c2d1033d 100644
--- a/packages/client/ui-sidebar/README.md
+++ b/packages/client/ui-sidebar/README.md
@@ -22,6 +22,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
-- **State dots have approval-waiting/running/none live states** — approval waiting is amber and outranks running; done/error notification sources remain deferred.
+- **Session state-dot rendering is owned by [ui-workspace](../ui-workspace/README.md)** — done/error notification sources remain deferred.
- **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred.
- **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host.
diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md
index 03cb86842d..b8c1545865 100644
--- a/packages/client/ui-sidebar/README.zh.md
+++ b/packages/client/ui-sidebar/README.zh.md
@@ -22,6 +22,6 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work
## 已知限制与暂缓事项
-- **状态点具有待审批/running/none 三种实时状态**:待审批使用琥珀色并优先于 running;done/error 的通知数据源仍暂缓实现。
+- **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**:done/error 的通知数据源仍暂缓实现。
- **分组选单只提供按 Workspace 分组**:Update/Status 分组策略只有图稿而没有规范,暂缓实现。
- **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。
From eb101230154e40dea237209e047759acf9d47cb0 Mon Sep 17 00:00:00 2001
From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
Date: Thu, 30 Jul 2026 03:57:57 -0700
Subject: [PATCH 7/9] test(web): simplify approval snapshot assertions
---
apps/web/tests/built-boot.snapshot.ts | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts
index d436d41866..7ca9b5fbbb 100644
--- a/apps/web/tests/built-boot.snapshot.ts
+++ b/apps/web/tests/built-boot.snapshot.ts
@@ -106,11 +106,10 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
// distinguishes a blocked running session from an ordinarily busy one.
const waitingTitle = await within(tree).findByText('Fixture 历史会话')
const waitingRow = waitingTitle.closest('[role="treeitem"]')
- expect(waitingRow).not.toBeNull()
if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row')
expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull()
expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull()
- expect(within(waitingRow).getByText('Waiting for approval')).not.toBeNull()
+ within(waitingRow).getByText('Waiting for approval')
// Opening a session reaches chat content through the fixture transport.
fireEvent.click(waitingTitle)
From a3d897359f8f5b90a3695f34d7630534fb998d65 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 3 Aug 2026 17:11:36 +0800
Subject: [PATCH 8/9] test(web): refresh Markdown image golden for the
fork-eligibility gate
---
apps/web/tests/snapshots/markdown-images/ui.expected.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md
index 76e01397c2..58b72e0d65 100644
--- a/apps/web/tests/snapshots/markdown-images/ui.expected.md
+++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md
@@ -7,8 +7,9 @@
- text: Show the Markdown image policy. {{clock}}
- button "Copy":
- img
-- button "Branch into a new conversation":
+- button "Branch into a new conversation" [disabled]:
- img
+- text: Available only on the last message of a completed turn
- heading "Markdown images" [level=2]
- paragraph:
- img "Remote test image"
From 5884a44e6ba2b53ef2081e3554b14dabdaaf54f6 Mon Sep 17 00:00:00 2001
From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
Date: Mon, 3 Aug 2026 02:43:27 -0700
Subject: [PATCH 9/9] docs(ui): clarify approval wait ownership
---
packages/client/ui-conversation/README.i18n.yaml | 4 ++--
packages/client/ui-conversation/README.md | 2 +-
packages/client/ui-conversation/README.zh.md | 2 +-
packages/client/ui-workspace/README.i18n.yaml | 4 ++--
packages/client/ui-workspace/README.md | 2 +-
packages/client/ui-workspace/README.zh.md | 2 +-
6 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 09e17a4371..da6ea1a597 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md
-README.md: e1dbe7d4d5992b6b5b029fddfc9d9857ccae7443
-README.zh.md: fd82ad65f8e903a6f7106e8b8ff8eccbf1435957
+README.md: 78572ba0ab3ce9475dba31dee8844017564e2a18
+README.zh.md: 7708e980e24f4ea4365fbbacd641a5be6c61b138
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index e1dbe7d4d5..78572ba0ab 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -10,7 +10,7 @@ The resident conversation shell survives no-session and session transitions. Wit
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.
-Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
+Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager tracks this approval wait through the `waitingApproval` list bit even for uninstantiated sessions; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index fd82ad65f8..7708e980e2 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);bash 示例是第三方姿态的范例。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
-审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
+审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 通过 `waitingApproval` 列表位跟踪这种审批等待,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml
index 597b036340..374efd0f58 100644
--- a/packages/client/ui-workspace/README.i18n.yaml
+++ b/packages/client/ui-workspace/README.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 packages/client/ui-workspace/README.md
-README.md: 09370a56d891044d14212992b63323448f0d4420
-README.zh.md: 7c6e9fee286fee172fe8476ad6ab294d45a8ece1
+README.md: 17105f9d70ab5fa0c0472c4b3fb39b759107f469
+README.zh.md: b40b9469271e539501a8f6fc0b70a84f8961f7ab
diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md
index 09370a56d8..17105f9d70 100644
--- a/packages/client/ui-workspace/README.md
+++ b/packages/client/ui-workspace/README.md
@@ -12,7 +12,7 @@ Workspace and Session hover cards copy the value their row clips: activating a W
The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list.
-Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits are tracked separately and do not set `waitingApproval`.
+Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits do not set a list-level status bit such as `waitingApproval`.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md
index 7c6e9fee28..b40b946927 100644
--- a/packages/client/ui-workspace/README.zh.md
+++ b/packages/client/ui-workspace/README.zh.md
@@ -12,7 +12,7 @@ Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Wor
Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。
-Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,hover 卡片则在请求解决前显示**等待审批**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(等待审批或进行中,随词典本地化);空闲行会保留空的状态槽位。问题等待由另一套状态跟踪,不会设置 `waitingApproval`。
+Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,hover 卡片则在请求解决前显示**等待审批**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(等待审批或进行中,随词典本地化);空闲行会保留空的状态槽位。问题等待不会设置如 `waitingApproval` 这样的列表级状态位。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。