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 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 3b58ed65b7110f073f6f00c97a830a6bd1c1bc7e Mon Sep 17 00:00:00 2001
From: Turtle
Date: Mon, 3 Aug 2026 17:15:04 +0800
Subject: [PATCH 09/10] feat(tui): wrap and page question dialogs
---
...24-tui-question-dialog-multiline.i18n.yaml | 6 +
...026-07-24-tui-question-dialog-multiline.md | 41 ++
...-07-24-tui-question-dialog-multiline.zh.md | 41 ++
docs/cordis-catalog/services.md | 2 +-
packages/ui/tui/README.i18n.yaml | 4 +-
packages/ui/tui/README.md | 6 +-
packages/ui/tui/README.zh.md | 6 +-
packages/ui/tui/src/chat/questions.ts | 10 +-
packages/ui/tui/src/components/dialogs.ts | 395 ++++++++++++--
.../ui/tui/src/extension/overlay-manager.ts | 24 +-
packages/ui/tui/src/index.ts | 62 ++-
.../question-dialog-detail-paged.expected.txt | 39 ++
.../question-dialog-paged.expected.txt | 39 ++
...question-dialog-single-option.expected.txt | 30 +-
.../question-dialog-validation.expected.txt | 72 +--
.../snapshots/question-dialog.expected.txt | 66 +--
.../snapshots/untrusted-controls.expected.txt | 39 +-
packages/ui/tui/tests/tui.snapshot.ts | 16 +-
packages/ui/tui/tests/tui.spec.ts | 511 ++++++++++++++++++
19 files changed, 1231 insertions(+), 178 deletions(-)
create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md
create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md
create mode 100644 packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt
create mode 100644 packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt
diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml
new file mode 100644
index 0000000000..8cc4d64238
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md
+2026-07-24-tui-question-dialog-multiline.md: fc6e9bceeee4abc46a69a23124d09fcd4f3c7224
+2026-07-24-tui-question-dialog-multiline.zh.md: a56821921bad1016009687bde63eae5f4d893cdf
diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md
new file mode 100644
index 0000000000..fc6e9bceee
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md
@@ -0,0 +1,41 @@
+# Agent Note: TUI QuestionDialog renders options across multiple lines
+
+Status: implemented
+
+English | [中文](2026-07-24-tui-question-dialog-multiline.zh.md)
+
+## Problem
+
+`ctx.userInteraction.ask()` must keep question text, supporting `detail`, option labels, descriptions, validation, and controls readable inside configured width and height bounds. The question panel also belongs directly above the editor: placing it at the terminal edge separates the pending decision from both the transcript that prompted it and the input that follows it.
+
+## Decision
+
+The TUI renders a pending question as an inline modal between the transcript/status area and the editor while retaining the shared FIFO with model and plugin overlays:
+
+- `InlineModalComponent` applies `questionDialogWidth` and `questionDialogMaxHeight` inside the normal component flow. The effective question height is additionally clamped to the current viewport after reserving the editor, so the editor remains below the question during resize.
+- `renderOptionBlock` wraps each label beneath its cursor/number prefix and renders the muted description on separately wrapped, equally indented lines. The progress header, question, custom-answer hint, validation text, and final rows are width-bounded as well; the final ellipsis clamp is only a safety boundary for prefixes or other indivisible content. The explicit `↑ N lines hidden` fallback is reserved for a viewport below the configured minimum, where the whole semantic layout cannot fit.
+- When question text or `detail` exceeds the header allocation, the header becomes a paged line viewport with its own `… lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down traverse both line viewports: forward navigation exhausts the header/detail pages before entering oversized selected-option pages, and backward navigation reverses that order. This keeps plan-review detail reachable rather than leaving it behind the height clamp.
+- The option-line budget subtracts padding, header, position, and footer rows before `windowBlocks` runs. The window obeys both `maxQuestionOptions` and the remaining row budget, keeps the selected option visible, and renders omitted options as `↑ N more` / `↓ N more` markers. If fixed chrome would leave fewer than four option rows, the compact header becomes the line pager so selected content, paging status, and both option markers still fit.
+- When one selected block exceeds its allocation, it becomes a line viewport with a `lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down expose every wrapped line without allowing the block to hide the option markers, validation, or controls.
+
+Package tests pin count and height bounds, header and selected-block paging order, narrow-width wrapping, selection behavior, and placement relative to retained editor input. Semantic TUI snapshots pin the assembled terminal layout, header/detail and selected-option page transitions, and validation state.
+
+## Alternatives considered
+
+**Ellipsis-only horizontal truncation.** Keeping one option per row would signal lost text without making the description readable and would not address vertical bounds. The implementation wraps readable content and retains an ellipsis only as a final safety boundary.
+
+**Wrap the combined label and description.** A composite row couples their widths, so either side can starve the other. Separate lines keep both widths predictable.
+
+**Keep the question as a bottom-edge overlay.** A terminal-edge anchor can place the panel after the editor or cover lower chrome, depending on transcript and viewport height. The inline modal preserves ordering while the modal manager retains focus and FIFO ownership.
+
+**Push the bounds into pi-tui.** Generic overlay slicing cannot identify option boundaries, selected content, controls, or the inline editor relationship. The owning dialog therefore applies semantic count, row, and paging rules.
+
+**Use only the option-count cap.** `maxQuestionOptions` remains a public count bound, but it cannot contain wrapped blocks by itself. The dialog enforces the count and row bounds together.
+
+## Consequences
+
+- Descriptions consume additional rows, so fewer options can be visible than `maxQuestionOptions`; markers state the omitted option counts.
+- Long question text and plan-review detail remain reachable inside a height-bounded panel, at the cost of sharing Page Up and Page Down with selected-option paging.
+- An oversized selected block reserves one status row and requires Page Up or Page Down to read beyond the current line page.
+- The inline question can displace older transcript rows from a short viewport. Below the configured minimum height, the final fallback can collapse upper rows behind an explicit hidden-line marker so the input controls and editor remain available.
+- The model-facing schema, selected labels, abort/cancel behavior, and ACP elicitation path are unchanged.
diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md
new file mode 100644
index 0000000000..a56821921b
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md
@@ -0,0 +1,41 @@
+# Agent Note: TUI QuestionDialog 以多行方式渲染选项
+
+Status: implemented
+
+[English](2026-07-24-tui-question-dialog-multiline.md) | 中文
+
+## 问题
+
+`ctx.userInteraction.ask()` 必须确保问题正文、`detail` 补充内容、选项标签、描述、校验信息和控件在已配置的宽度与高度边界内均可读。问题面板也直接位于编辑器上方:若将其置于终端边缘,待处理决策就会同时脱离触发该决策的 transcript(文本记录)和后续输入。
+
+## 决策
+
+TUI 将待处理问题渲染为位于 transcript/状态区域与编辑器之间的内联模态框,同时仍与模型浮层和插件浮层共享 FIFO:
+
+- `InlineModalComponent` 在正常组件流内应用 `questionDialogWidth` 和 `questionDialogMaxHeight`。系统在为编辑器预留空间后,还会根据当前视口限制问题的实际高度,因此调整窗口大小时,编辑器仍位于问题下方。
+- `renderOptionBlock` 将每个标签换行到光标/编号前缀下方,并在另行换行且缩进相同的行上渲染弱化的描述。进度标题、问题、自定义答案提示、校验文本和末尾行也受宽度边界约束;最终的省略号截断仅作为前缀或其他不可拆分内容的安全边界。明确的 `↑ N lines hidden` 回退仅用于低于已配置最小值、无法容纳完整语义布局的视口。
+- 当问题正文或 `detail` 超出头部分配的空间时,头部会成为带有独立 `… lines A-B/N • PgUp/PgDn` 状态行的分页行视口。Page Up 和 Page Down 会遍历这两个行视口:向前导航先翻完问题正文/`detail` 页面,再进入超大选中选项页面;向后导航则采用相反顺序。这样可确保计划评审的 `detail` 内容始终可达,而不会被高度边界挡住。
+- 在 `windowBlocks` 运行前,选项行预算会扣除内边距、标题行、位置行和页脚行。窗口同时遵守 `maxQuestionOptions` 和剩余行预算,保持选中项可见,并将省略的选项渲染为 `↑ N more`/`↓ N more` 标记。若固定界面元素会使选项行少于四行,紧凑头部会转为行分页器,从而容纳选中内容、分页状态和上下两个选项标记。
+- 当一个选中块超出分配空间时,它会成为带有 `lines A-B/N • PgUp/PgDn` 状态行的行视口。Page Up 和 Page Down 可展示每一行已换行内容,同时防止该块遮住选项标记、校验信息或控件。
+
+包(package)测试固定数量和高度边界、头部与选中块的分页顺序、窄宽度换行、选择行为,以及问题相对于保留的编辑器输入的位置。语义 TUI 快照固定组装后的终端布局、头部/详情与选中选项的分页转换,以及校验状态。
+
+## 备选方案
+
+**仅用省略号进行横向截断。** 保持每个选项占一行,只能提示文本有所丢失,无法使描述变得可读,也无法处理纵向边界。该实现会对可读内容换行,仅将省略号保留为最终安全边界。
+
+**将标签与描述合并后换行。** 组合行会将两者的宽度耦合在一起,任一方都可能挤占另一方的空间。分行渲染可使二者的宽度保持可预测。
+
+**将问题保留为终端底边浮层。** 根据 transcript 和视口高度,锚定在终端边缘的面板可能出现在编辑器之后,也可能遮盖下方界面元素。内联模态框可保留顺序,同时由模态管理器继续负责焦点和 FIFO 所有权。
+
+**将边界处理下推至 pi-tui。** 通用浮层切片无法识别选项边界、选中内容、控件或内联编辑器关系。因此,负责该语义的对话框会应用数量、行数和分页规则。
+
+**仅使用选项数量上限。** `maxQuestionOptions` 仍是公开的数量边界,但仅靠它无法容纳已换行的块。对话框会同时执行数量边界和行数边界。
+
+## 后果
+
+- 描述会占用额外行,因此可见选项数可能少于 `maxQuestionOptions`;标记会说明省略的选项数量。
+- 较长的问题正文和计划评审 `detail` 在受高度约束的面板内仍然可达,代价是 Page Up 和 Page Down 需要与选中选项分页共用。
+- 超出空间的选中块会预留一行状态信息;若要阅读当前页面之外的行,必须使用 Page Up 或 Page Down。
+- 在较矮的视口内,内联问题可能将较早的 transcript 行挤出可见区域。低于已配置最小高度时,最终回退可能将上部行折叠到明确的隐藏行标记之后,从而让输入控件和编辑器仍然可用。
+- 面向模型的 schema、选中的标签、中止/取消行为,以及 ACP(Agent Client Protocol)的 elicitation 路径均保持不变。
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 8e76c1c8de..b518f37876 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -2451,7 +2451,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
```
-Source: [`packages/ui/tui/src/index.ts:244`](../../packages/ui/tui/src/index.ts)
+Source: [`packages/ui/tui/src/index.ts:245`](../../packages/ui/tui/src/index.ts)
## `ctx.typert` — `TypertRegistry`
diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml
index 246e459e57..a2487c1b20 100644
--- a/packages/ui/tui/README.i18n.yaml
+++ b/packages/ui/tui/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/ui/tui/README.md
-README.md: 60e42a64305931a6bf93a470f851f662be33e2bb
-README.zh.md: fc12b75a1e320103a1d4cc70cc00adfeb639a3d9
+README.md: e441cf949d15ea76f93efc89f970aa2096e83153
+README.zh.md: cc98ea4405ff57b56059e283ecf1b9c8d4d95b91
diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md
index 60e42a6430..e441cf949d 100644
--- a/packages/ui/tui/README.md
+++ b/packages/ui/tui/README.md
@@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects `
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme (including terminal-safe DeepSeek `brand` treatment), display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
-The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ` — `. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing.
+The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions inline between the transcript/status area and the editor. The question panel shows progress, numbered options, wrapped labels, and separately indented descriptions; it obeys both `maxQuestionOptions` and `questionDialogMaxHeight`, marks hidden options with `↑ N more` / `↓ N more`, and uses Page Up / Page Down to page long question/detail content before an individually oversized selected block while keeping the editor visible. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ` — `. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing.
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.
@@ -51,11 +51,11 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY
| `showReasoning` | `true` | Render reasoning blocks |
| `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview |
| `maxDiffEditLength` | `1000` | Maximum added and removed lines explored for an exact diff before whole-side fallback |
-| `maxQuestionOptions` | `8` | Visible options in a question panel |
+| `maxQuestionOptions` | `8` | Maximum option blocks visible at once; the row bound may reduce this further |
| `maxModelOptions` | `8` | Visible models in the model selector |
| `maxResumeOptions` | `8` | Visible sessions in the resume selector |
| `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal |
-| `questionDialogMaxHeight` | `20` | Question-panel maximum rows |
+| `questionDialogMaxHeight` | `20` | Maximum question-panel rows, further bounded to retain the editor |
| `modelDialogWidth` | `76` | Model-selector width in columns |
| `modelDialogMaxHeight` | `20` | Model-selector maximum rows |
| `detailsDialogWidth` | `72` | Transcript-details selector width in columns |
diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md
index fc12b75a1e..cc98ea4405 100644
--- a/packages/ui/tui/README.zh.md
+++ b/packages/ui/tui/README.zh.md
@@ -12,7 +12,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题(包括终端安全的 DeepSeek `brand` 样式)、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
-TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ` — `。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。
+TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在 transcript/状态区域与编辑器之间内联展示 `ctx.userInteraction` 问题。问题面板会显示进度、编号选项、换行标签和另行缩进的描述;它同时遵守 `maxQuestionOptions` 和 `questionDialogMaxHeight`,用 `↑ N more`/`↓ N more` 标记隐藏选项,并在保持编辑器可见的同时,通过 Page Up 和 Page Down 先分页浏览过长的问题/详情内容,再分页浏览单个超大的选中块。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ` — `。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。
如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。
@@ -51,11 +51,11 @@ Footer 将会话报告的用量汇总为 `↑ ↓