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] 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()