Merge branch 'master' into feat/web-loader-plugin-inventory-settings

This commit is contained in:
Ziya
2026-08-12 13:49:57 +08:00
committed by GitHub
13 changed files with 316 additions and 123 deletions
@@ -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 .agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md
2026-08-10-durable-workflow-runs-in-chat.md: 791a81e9e304a11f45557197ac1f97184132ccab
2026-08-10-durable-workflow-runs-in-chat.zh.md: e6c87f61a144cebc0282055c8ae315d9068616fd
2026-08-10-durable-workflow-runs-in-chat.md: 817fd4debd93a4768904e3934456ebdd4bdaa896
2026-08-10-durable-workflow-runs-in-chat.zh.md: 7b09708d94783de5aff9a9fd59757120c661775a
@@ -20,7 +20,7 @@ The workflow package exposes browser-safe run and observation vocabulary through
`ui-workflow-run` registers one `workflow-run` Conversation Definition and one keyed Chat renderer. Every event independently yields the same `runId`; run-start initializes State, later events update it in log order, and an update-only history tail remains pending until prepend supplies the unique start. The final node keeps the engine-owned key and anchors at run-start, placing it after the original tool call while preserving one React parent from running through terminal state.
The renderer gives each level a distinct visual responsibility. The run uses a 32-pixel module-platform background row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. Phases exist only when a member actually starts and group by the exact phase string; an omitted phase and the empty string retain distinct identities and localized names. Member settlement changes status without removing or reordering the member. A closed Turn or Step turns missing run or member endings into interrupted presentation; a durable ending remains authoritative when present.
The renderer gives each level a distinct visual responsibility. The run uses a 32-pixel module-platform background row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. Phases exist only when a member actually starts and group by the exact phase string; an omitted phase and the empty string retain distinct identities and localized names. Member settlement changes status without removing or reordering the member. A closed Turn or Step turns missing run or member endings into interrupted presentation; a durable ending remains authoritative when present. [Status-driven workflow disclosure](2026-08-11-workflow-run-status-driven-disclosure.md) owns which run and phase content remains visible as those facts change.
Navigation is derived from two current authorities rather than persisted. A member row is interactive only while its durable member state is running and the current ordinary Session list contains the same id with `origin: 'subagent'`, `parentId` equal to the displayed parent, and `running: true`. Underlined member text is the only visible affordance; keyboard focus draws a two-pixel business-primary ring around the name area, and the fixed status label remains the lifecycle word rather than an action instruction. The renderer invokes only the injected ordinary `sessions.open(id)` callback. Addressed-only, remote, wrong-parent, and terminal members remain visible but static.
@@ -42,4 +42,4 @@ Package tests cover top-level and nested eligibility, zero-member and concurrent
## Consequences
Workflow progress survives refresh and process recovery in the same log as its parent conversation, while execution ownership remains with the workflow run holder and the original tool card remains unchanged. The durable protocol adds four small events and one package-owned invariant; first-write failure intentionally sacrifices later observation rather than workflow correctness. Browser State is derived per loaded window, disclosure choices remain local, and navigation can disappear as list facts change. The design shows only actual runtime members and statuses, giving up static graph visualization, outputs, logs, controls, and terminal-member opening.
Workflow progress survives refresh and process recovery in the same log as its parent conversation, while execution ownership remains with the workflow run holder and the original tool card remains unchanged. The durable protocol adds four small events and one package-owned invariant; first-write failure intentionally sacrifices later observation rather than workflow correctness. Browser State is derived per loaded window, the status-driven disclosure lifecycle keeps review choices local, and navigation can disappear as list facts change. The design shows only actual runtime members and statuses, giving up static graph visualization, outputs, logs, controls, and terminal-member opening.
@@ -20,7 +20,7 @@ workflow 包通过 `@deepseek-ai/dsh-workflow/types` 提供浏览器安全的运
`ui-workflow-run` 注册一个 `workflow-run` Conversation Definition 和一个 keyed Chat renderer。每条事件都能独立给出同一 `runId`run-start 初始化 State,后续事件按日志顺序更新;只有 update 的历史尾页会保持 pending,直到 prepend 补入唯一 start。最终节点保留引擎拥有的 key,并以 run-start 锚定在原工具调用之后,从运行中到终态始终保留同一个 React 父级。
renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-platform 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。阶段只在成员真正开始时出现,并按精确阶段字符串分组;字段缺省与空字符串保留不同身份和本地化名称。成员结算只改变状态,不删除或重排成员。所属 Turn 或 Step 关闭时,缺少运行或成员终点会显示为已中断;存在持久终点时仍以它为权威。
renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-platform 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。阶段只在成员真正开始时出现,并按精确阶段字符串分组;字段缺省与空字符串保留不同身份和本地化名称。成员结算只改变状态,不删除或重排成员。所属 Turn 或 Step 关闭时,缺少运行或成员终点会显示为已中断;存在持久终点时仍以它为权威。[状态驱动的工作流 disclosure](2026-08-11-workflow-run-status-driven-disclosure.md)拥有这些事实变化时运行与阶段内容的可见性。
导航从两个当前权威派生,不写入持久记录。只有持久成员状态仍为运行中,且当前普通 Session 列表包含同一 id、`origin: 'subagent'``parentId` 等于当前父 Session、`running: true` 时,成员行才可交互。带下划线的成员文字是唯一可见提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,固定状态列继续只表达生命周期,而不写动作说明。renderer 只调用注入的普通 `sessions.open(id)` 回调。仅地址化、远程、父级不符或终态成员继续可见,但保持静态。
@@ -42,4 +42,4 @@ renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-pl
## 后果
工作流进度与父对话保存在同一日志中,能跨刷新与进程恢复;执行所有权仍属于工作流 run holder,原工具卡保持不变。持久协议增加四类小事件和一个包所有的 invariant;首次写入失败会刻意牺牲后续观察,而不是牺牲工作流正确性。浏览器 State 按已加载窗口派生,disclosure 选择保持本地,导航会随列表事实消失。设计只展示真实运行成员与状态,并放弃静态图、输出、日志、控制操作和终态成员打开。
工作流进度与父对话保存在同一日志中,能跨刷新与进程恢复;执行所有权仍属于工作流 run holder,原工具卡保持不变。持久协议增加四类小事件和一个包所有的 invariant;首次写入失败会刻意牺牲后续观察,而不是牺牲工作流正确性。浏览器 State 按已加载窗口派生,状态驱动的 disclosure 生命周期把复盘选择留在本地,导航会随列表事实消失。设计只展示真实运行成员与状态,并放弃静态图、输出、日志、控制操作和终态成员打开。
@@ -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-08-11-workflow-run-status-driven-disclosure.md
2026-08-11-workflow-run-status-driven-disclosure.md: 2f452d25a8922bb6c275419af55e8af155dd2781
2026-08-11-workflow-run-status-driven-disclosure.zh.md: 12cc106fea274a1681ee5615906ae6df266d567b
@@ -0,0 +1,43 @@
# Agent Note: Status-driven disclosure for workflow runs
Status: implemented
English | [中文](2026-08-11-workflow-run-status-driven-disclosure.zh.md)
## Problem
A durable workflow Chat node updates in place from its running prefix to a terminal record. A disclosure choice initialized only at mount can hide a newly running phase, leave completed work occupying the conversation, or bury a failed, cancelled, or interrupted member behind two collapsed levels. Making openness a pure function of completion avoids those failures but also prevents users from reopening clean history for review.
The renderer already receives every required lifecycle fact from the workflow Conversation Node. Visibility therefore needs a component-local lifecycle that gives current execution and attention states priority without adding another durable fact or taking ownership of workflow outcomes.
## Decision
Each phase derives one visibility requirement from its current members. A running, failed, cancelled, or interrupted member forces that phase open; a phase whose members are all completed is clean. The workflow forces itself open when its own status requires attention or any phase is forced open, so an abnormal member remains visible even when the workflow outcome is recorded as completed. A completed sibling phase remains independently collapsible.
A forced-open level renders as an expanded static row. It exposes no button role, focus target, keyboard toggle, or `aria-expanded` value because collapsing cannot change the result. This keeps the visual hierarchy and status summaries while making the interaction promise match the available action.
A clean level mounts an ordinary controlled disclosure in the closed state. Its local choice survives rerenders for the same continuous clean interval. New running or abnormal data replaces that manual interval with forced expansion; the next transition back to clean mounts a fresh closed disclosure, which produces one automatic fold per activity cycle. Closing the workflow naturally unmounts its phase controls, and a Session remount reconstructs every level from the current durable status rather than restoring an earlier choice.
For example, a running workflow exposes its active phase and member without clicks. When that phase completes, only the phase folds while the workflow remains open; when the workflow and every phase complete, the workflow also folds. The user can then reopen both levels for review. If another member starts under the same phase key, both affected levels immediately return to forced expansion and fold again only after the new activity completes.
The renderer owns only this visibility lifecycle. It does not add Session events, stores, settings, acknowledgement state, timers, focus movement, automatic scrolling, or cross-remount persistence. It does not change workflow status derivation, phase grouping, member order, navigation eligibility, copy, or the shared `DisclosureRow` API. Shared `data-expandable` styling owns pointer cursors, so forced-open static rows do not advertise an unavailable action. An interrupted durable prefix remains an attention state and therefore stays visible until the underlying facts change.
## Verification
Component tests drive the same keyed workflow and phase through running, clean completion, manual review, renewed activity, repeated clean completion, zero-member completion, and each abnormal status. They also verify abnormal-member propagation, clean-sibling independence, mouse and keyboard review, continuous-clean choice retention, and the absence of false button and ARIA semantics while expansion is mandatory.
The shipped Web replay observes the real workflow, worker, Session log, browser plugin graph, and child navigation. It requires the live workflow and active phase to be visible without disclosure controls, the normally settled workflow and phase to fold, manual review to retain the terminal member without navigation, and a reload to reconstruct the folded history from durable facts.
## Alternatives considered
**Keep one manual state initialized from the first render.** Rejected because later lifecycle updates cannot reopen newly active or abnormal content and cannot fold normally settled work.
**Derive `open` directly from whether a level is clean.** Rejected because completed history would remain permanently closed and could not be reopened for review.
**Persist expansion, acknowledgement, or read state.** Rejected because current lifecycle facts already determine mandatory visibility, while review choice belongs only to the mounted presentation. Persistence would add a second state owner and require semantics for stale choices, abnormal acknowledgement, replay, and synchronization that the user result does not need.
## Consequences
Workflow records expose current work and abnormal outcomes without preparatory clicks, then reclaim conversation space after normal completion without sacrificing review. Interaction semantics remain truthful during automatic control, and the same durable record produces the same initial state during live rendering, refresh, and history reconstruction.
The trade-off is deliberate local reset behavior. A phase choice disappears when its parent workflow closes or the component unmounts, and abnormal records cannot be manually hidden because the product has no acknowledgement state. Supporting either behavior later requires a separate ownership and persistence decision rather than extending this local lifecycle implicitly.
@@ -0,0 +1,43 @@
# Agent Note: 工作流运行的状态驱动 disclosure
Status: implemented
[English](2026-08-11-workflow-run-status-driven-disclosure.md) | 中文
## 问题
持久工作流 Chat 节点会在同一位置从运行前缀更新为终态记录。只在挂载时初始化的 disclosure 选择可能隐藏新开始运行的阶段,让已完成工作继续占据对话空间,或者把失败、已取消或已中断成员埋在两层折叠内容之后。若只把开合状态作为完成状态的纯派生结果,虽然能避免这些问题,却也会阻止用户重新打开干净历史进行复盘。
renderer 已经从工作流 Conversation Node 收到全部所需生命周期事实。因此,可见性需要一个组件本地生命周期:让当前执行与需注意状态优先,同时不增加另一项持久事实,也不取得工作流结果的所有权。
## 决策
每个阶段从当前成员派生一项可见性要求。存在运行中、失败、已取消或已中断成员时,该阶段强制展开;全部成员均已完成时,该阶段处于干净状态。工作流自身状态需要注意或任一阶段强制展开时,工作流也强制展开,因此即使工作流结果记录为已完成,异常成员仍保持可见。已完成的兄弟阶段继续可以独立折叠。
强制展开层级渲染为静态展开行。它不提供按钮 role、焦点目标、键盘切换或 `aria-expanded` 值,因为折叠操作无法改变结果。这样既保留视觉层级与状态摘要,也让交互承诺与实际可执行动作一致。
干净层级会以关闭状态挂载普通受控 disclosure。它的本地选择在同一段连续干净状态的 rerender 中保持。新的运行中或异常数据会用强制展开替代该手动区间;下一次回到干净状态时会挂载新的关闭 disclosure,从而让每个活动周期只自动折叠一次。关闭工作流会自然卸载其阶段控件;Session remount 会从当前持久状态重建每个层级,而不恢复更早的选择。
例如,运行中的工作流无需点击即可展示活跃阶段与成员。该阶段完成时,只有阶段折叠,工作流继续展开;工作流自身和全部阶段均完成时,工作流也会折叠。用户随后可以重新打开两个层级复盘。若同一阶段 key 下又开始新成员,受影响的两个层级会立即恢复强制展开,并且只在新活动完成后再次折叠。
renderer 只拥有这项可见性生命周期。它不增加 Session 事件、store、设置、确认状态、计时器、焦点迁移、自动滚动或跨 remount 持久化。它不改变工作流状态派生、阶段分组、成员顺序、导航准入、文案或共享 `DisclosureRow` API。pointer 光标由共享的 `data-expandable` 样式拥有,因此强制展开的静态行不会提示无法执行的操作。持久记录中的中断前缀仍属于需注意状态,因此在底层事实改变前始终可见。
## 验证
组件测试驱动同一个 keyed 工作流与阶段依次经过运行、干净完成、手动复盘、新活动、再次干净完成、零成员完成以及每种异常状态。测试还验证异常成员向上展开、干净兄弟阶段独立、鼠标和键盘复盘、连续干净状态中的选择保持,以及强制展开时不存在虚假按钮和 ARIA 语义。
shipped Web 回放观察真实工作流、worker、Session 日志、浏览器插件图和子级导航。它要求实时工作流与活跃阶段无需 disclosure 控件即可见,正常结算的工作流与阶段会折叠,手动复盘仍能看到不再可导航的终态成员,并且刷新会从持久事实重建折叠历史。
## 曾考虑的替代方案
**保留一项从首次渲染初始化的手动状态。** 拒绝,因为后续生命周期更新无法重新打开新活动或异常内容,也无法折叠正常结算的工作。
**只根据层级是否干净来派生 `open`。** 拒绝,因为已完成历史会永久保持关闭,无法重新打开复盘。
**持久化展开、确认或已读状态。** 拒绝,因为当前生命周期事实已经决定强制可见性,而复盘选择只属于已挂载的展示层。持久化会增加第二个状态归属方,并要求定义陈旧选择、异常确认、回放和同步语义,而用户结果不需要这些机制。
## 后果
工作流记录无需预备点击即可展示当前工作与异常结果,并在正常完成后回收对话空间,同时不牺牲复盘能力。自动控制期间的交互语义保持真实,同一份持久记录在实时渲染、刷新和历史重建时得到相同初始状态。
代价是有意保留的本地重置行为。父工作流关闭或组件卸载时,阶段选择会消失;由于产品没有确认状态,异常记录不能手动隐藏。以后若要支持任一行为,需要单独决定所有权与持久化,而不能隐式扩展这项本地生命周期。
+17 -7
View File
@@ -74,12 +74,16 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
await input.fill(prompt)
await input.press('Enter')
const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
const workflow = page.locator('[data-workflow-run][data-run-status="running"]')
await workflow.waitFor({ timeout: 30_000 })
expect(await workflow.getAttribute('aria-expanded')).toBe('true')
const phase = page.getByRole('button', { name: /^Run/ })
await phase.waitFor({ timeout: 15_000 })
await phase.click()
const disclosures = workflow.locator('[data-disclosure-row]')
await disclosures.nth(1).waitFor({ timeout: 15_000 })
expect(await disclosures.nth(0).getAttribute('role')).toBeNull()
expect(await disclosures.nth(0).getAttribute('aria-expanded')).toBeNull()
expect(await disclosures.nth(1).getAttribute('role')).toBeNull()
expect(await disclosures.nth(1).getAttribute('aria-expanded')).toBeNull()
expect(await disclosures.nth(0).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer')
expect(await disclosures.nth(1).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer')
const member = page.getByRole('button', { name: /^Open Reply with exactly the word/ })
await member.waitFor({ timeout: 15_000 })
await member.focus()
@@ -139,15 +143,20 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
const sessions = page.getByRole('tree', { name: 'Sessions' })
await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click()
await settled
await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor()
expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1)
expect(await page.locator('[data-chat-flow-kind="workflow-run"]').count()).toBe(1)
const terminalWorkflow = page.getByRole('button', { name: /^snapshot-flow/ })
await terminalWorkflow.waitFor()
if (await terminalWorkflow.getAttribute('aria-expanded') !== 'true') await terminalWorkflow.click()
expect(await terminalWorkflow.getAttribute('aria-expanded')).toBe('false')
expect(await terminalWorkflow.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
await terminalWorkflow.click()
const terminalPhase = page.getByRole('button', { name: /^Run/ })
await terminalPhase.waitFor()
if (await terminalPhase.getAttribute('aria-expanded') !== 'true') await terminalPhase.click()
expect(await terminalPhase.getAttribute('aria-expanded')).toBe('false')
expect(await terminalPhase.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
await terminalPhase.click()
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
await expect.poll(
() => page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count(),
@@ -165,6 +174,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
await workflow.click()
const phase = page.getByRole('button', { name: /^Run/ })
await phase.waitFor()
expect(await phase.getAttribute('aria-expanded')).toBe('false')
await phase.click()
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0)
@@ -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-workflow-run/README.md
README.md: 66539e0c16ac4102f9e1fe881106e6881b36a7d5
README.zh.md: a803857af24802e8a4645c4d5aca56c04424c85e
README.md: 489715c51759b1efd2da68d3bd3e0f7788ce7ecd
README.zh.md: 326a7ae4e4b8eaad43ca7ad0d22145452af6a734
+1 -1
View File
@@ -12,7 +12,7 @@ Phase groups come only from members that actually started. Exact phase strings s
## Presentation and navigation
The run and each phase have independent disclosure state. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. A running run initially expands; a terminal run loaded from history initially collapses. Local choices survive data updates while the keyed node remains mounted and reset only on a full remount.
The run and each phase derive disclosure control from their current lifecycle facts. The run stays expanded while its own status is running, failed, cancelled, or interrupted, or while any phase contains such a member; each affected phase also stays expanded. Forced-open headers are static expanded rows without button, keyboard, or `aria-expanded` promises. A phase folds once when every member completes, and the run folds once when it and every phase complete. Each clean layer then exposes an ordinary disclosure control whose local choice survives clean rerenders; new activity takes control again, and a remount derives the initial state from current data. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column.
A member opens a child Session only while every current fact agrees: the member is running, the child id is in the ordinary Session list, the row has `origin: 'subagent'`, its `parentId` is the current Session, and the list row is still running. Underlined member text is the only visible navigation affordance; keyboard focus draws a two-pixel business-primary ring around the name area, while status copy remains `Running`. The component calls only the injected ordinary `sessions.open(id)` action; remote, addressed-only, wrong-parent, or terminal rows remain non-interactive.
+1 -1
View File
@@ -12,7 +12,7 @@
## 展示与导航
运行和每个阶段分别拥有本地 disclosure 状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。运行中记录首次挂载时展开,从历史加载的终态记录首次挂载时折叠。只要 keyed 节点仍挂载,本地选择就在数据更新时保持;只有完整 remount 才重新初始化。
运行和每个阶段都从当前生命周期事实派生 disclosure 控制。运行自身处于运行中、失败、已取消或已中断,或者任一阶段包含这些状态的成员时,运行保持展开;受影响的阶段也保持展开。强制展开的标题行只是静态展开行,不承诺按钮、键盘操作或 `aria-expanded`。阶段在全部成员完成时折叠一次;运行在自身和全部阶段都完成时折叠一次。每个干净层级随后恢复普通 disclosure 控件,其本地选择在干净状态的 rerender 中保持;新活动会重新取得控制,remount 则从当前数据派生初始状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。
只有所有实时事实同时成立时,成员才可打开子 Session:成员仍在运行、子 id 位于普通 Session 列表、列表行为 `origin: 'subagent'``parentId` 等于当前 Session,且列表行仍标记运行。带下划线的成员文字是唯一可见导航提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,右侧状态仍只显示“运行中”。组件只调用注入的普通 `sessions.open(id)`;远程、仅地址化、父级不符或终态的行都不可交互。
@@ -14,7 +14,6 @@
padding: 0 8px;
border-radius: 8px;
background: var(--dsw-alias-bg-module-platform);
cursor: pointer;
}
.runHeader:focus-visible {
@@ -78,7 +77,6 @@
width: 100%;
min-width: 0;
height: 32px;
cursor: pointer;
}
.phaseHeader:focus-visible {
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useState, type ReactNode } from 'react'
import {
DisclosureRow, IconChevronRightOutline14, StateDot, type StateDotState,
DisclosureRow, IconChevronRightOutline14, StateDot,
type DisclosureRowProps, type StateDotState,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { shallowEqual, type SessionId, type SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
@@ -62,6 +63,36 @@ function memberCount(count: number, t: WorkflowRunPanelProps['t']): string {
return t(count === 1 ? 'run.members.one' : 'run.members.other', { count })
}
function phaseRequiresExpansion(phase: WorkflowRunPhaseData): boolean {
return phase.members.some(member => member.status !== 'completed')
}
type StatusDisclosureProps = Omit<DisclosureRowProps, 'open' | 'expandable' | 'onToggle'>
/* v8 ignore next -- DisclosureRow requires the callback but cannot invoke it when expandable is false. */
const forcedOpenToggle = (): void => {}
function ManualDisclosure(props: StatusDisclosureProps) {
const [open, setOpen] = useState(false)
return (
<DisclosureRow
{...props}
open={open}
expandable
onToggle={() => { setOpen(value => !value) }}
/>
)
}
function StatusDisclosure({ cleanCycleKey, requiresExpansion, ...props }: StatusDisclosureProps & {
/** Remount a clean Phase when its append-only member count changes between batched renders. */
readonly cleanCycleKey?: number | undefined
readonly requiresExpansion: boolean
}) {
if (!requiresExpansion) return <ManualDisclosure key={cleanCycleKey} {...props} />
return <DisclosureRow {...props} open expandable={false} onToggle={forcedOpenToggle} />
}
function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string {
const counts = new Map<WorkflowRunStatus, number>()
for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1)
@@ -97,21 +128,19 @@ function navigableMembers(
return result
}
function RunHeader({ count, name, onToggle, open, status, t }: {
function RunHeader({ children, count, name, requiresExpansion, status, t }: {
readonly children: ReactNode
readonly count: number
readonly name: string
readonly onToggle: () => void
readonly open: boolean
readonly requiresExpansion: boolean
readonly status: WorkflowRunStatus
readonly t: WorkflowRunPanelProps['t']
}) {
return (
<DisclosureRow
<StatusDisclosure
icon={<IconChevronRightOutline14 />}
title={t('run.title', { name })}
open={open}
expandable
onToggle={onToggle}
requiresExpansion={requiresExpansion}
expandOnRowClick
previewChevron={false}
keepContentWhenOpen
@@ -128,7 +157,9 @@ function RunHeader({ count, name, onToggle, open, status, t }: {
</span>
</>
)}
/>
>
{children}
</StatusDisclosure>
)
}
@@ -168,15 +199,12 @@ function PhaseSection({ phase, navigable, openSession, t }: {
readonly openSession: WorkflowRunInjected['openSession']
readonly t: WorkflowRunPanelProps['t']
}) {
const [open, setOpen] = useState(false)
const toggle = (): void => { setOpen(value => !value) }
return (
<DisclosureRow
<StatusDisclosure
icon={<IconChevronRightOutline14 />}
title={readablePhase(phase.phase, t)}
open={open}
expandable
onToggle={toggle}
cleanCycleKey={phase.members.length}
requiresExpansion={phaseRequiresExpansion(phase)}
expandOnRowClick
previewChevron={false}
keepContentWhenOpen
@@ -203,14 +231,15 @@ function PhaseSection({ phase, navigable, openSession, t }: {
/>
))}
</div>
</DisclosureRow>
</StatusDisclosure>
)
}
/** Render one durable workflow run with independent run and phase disclosure. */
/** Render one durable workflow run with status-driven run and phase disclosure. */
export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) {
const [open, setOpen] = useState(() => node.data.status === 'running')
const memberCount = node.data.phases.reduce((count, phase) => count + phase.members.length, 0)
const totalMembers = node.data.phases.reduce((count, phase) => count + phase.members.length, 0)
const requiresExpansion = node.data.status !== 'completed'
|| node.data.phases.some(phaseRequiresExpansion)
const navigable = useSessions(
sessions => navigableMembers(sessions, node.data.phases, sessionId),
shallowEqual,
@@ -218,14 +247,12 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
return (
<section className={css.root} data-workflow-run data-run-status={node.data.status}>
<RunHeader
count={memberCount}
count={totalMembers}
name={node.data.name}
open={open}
requiresExpansion={requiresExpansion}
status={node.data.status}
t={t}
onToggle={() => { setOpen(value => !value) }}
/>
{open && (
>
<div className={css.phaseList}>
{node.data.phases.length === 0
? <span className={css.empty}>{t('run.empty')}</span>
@@ -239,7 +266,7 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
/>
))}
</div>
)}
</RunHeader>
</section>
)
}
@@ -301,90 +301,170 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi
}
describe('WorkflowRunPanel', () => {
it('defaults running runs open, terminal history closed, and keeps the current choice across data updates', () => {
it('forces running run and phase content open without false disclosure controls', () => {
const view = render(<WorkflowRunPanel {...panelProps({
name: 'audit', status: 'running', phases: [phase({ key: 'research', phase: 'Research' })],
})} />)
expect(screen.getByText('worker')).toBeTruthy()
expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
expect(screen.queryByRole('button', { name: /Research/ })).toBeNull()
const rows = [...view.container.querySelectorAll('[data-disclosure-row]')]
expect(rows).toHaveLength(2)
for (const row of rows) {
expect(row.getAttribute('role')).toBeNull()
expect(row.getAttribute('tabindex')).toBeNull()
expect(row.getAttribute('aria-expanded')).toBeNull()
expect(row.getAttribute('data-expandable')).toBeNull()
}
})
it('folds each clean transition once and preserves review choices until activity returns', () => {
const running: WorkflowRunChatData = {
name: 'audit', status: 'running', phases: [phase()],
}
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
expect(screen.getByText('未分阶段')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
expect(screen.queryByText('未分阶段')).toBeNull()
const phaseCompleted: WorkflowRunChatData = {
...running,
phases: [phase({
members: [{
seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed',
}],
})],
}
view.rerender(<WorkflowRunPanel {...panelProps(phaseCompleted)} />)
const phaseHeader = screen.getByRole('button', { name: /未分阶段/ })
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('done')).toBeNull()
fireEvent.click(phaseHeader)
expect(screen.getByText('done')).toBeTruthy()
const terminal: WorkflowRunChatData = { ...running, status: 'completed' }
view.rerender(<WorkflowRunPanel {...panelProps(terminal)} />)
const completed: WorkflowRunChatData = { ...phaseCompleted, status: 'completed' }
view.rerender(<WorkflowRunPanel {...panelProps(completed)} />)
const runHeader = screen.getByRole('button', { name: /^audit/ })
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('未分阶段')).toBeNull()
fireEvent.keyDown(runHeader, { key: 'ArrowDown' })
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
fireEvent.keyDown(runHeader, { key: 'Enter' })
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
const completedPhase = screen.getByRole('button', { name: /未分阶段/ })
fireEvent.keyDown(completedPhase, { key: 'Enter' })
expect(screen.getByText('done')).toBeTruthy()
fireEvent.keyDown(runHeader, { key: ' ' })
expect(runHeader.getAttribute('aria-expanded')).toBe('false')
fireEvent.keyDown(runHeader, { key: ' ' })
expect(runHeader.getAttribute('aria-expanded')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
expect(screen.getByText('done')).toBeTruthy()
cleanup()
render(<WorkflowRunPanel {...panelProps(terminal)} />)
const cleanUpdate: WorkflowRunChatData = {
...completed,
phases: [phase({
members: [{
seq: 1, label: 'reviewed', childId: 'child-1' as SessionId, status: 'completed',
}],
})],
}
view.rerender(<WorkflowRunPanel {...panelProps(cleanUpdate)} />)
expect(screen.getByText('reviewed')).toBeTruthy()
view.rerender(<WorkflowRunPanel {...panelProps(running)} />)
expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
expect(screen.getByText('worker')).toBeTruthy()
view.rerender(<WorkflowRunPanel {...panelProps(completed)} />)
expect(screen.getByRole('button', { name: /^audit/ }).getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('未分阶段')).toBeNull()
})
it('supports root keyboard disclosure and renders a zero-member running state', () => {
render(<WorkflowRunPanel {...panelProps({
name: 'keyboard', status: 'running',
phases: [phase({ key: 'research', phase: 'Research' })],
it('refolds a phase when a complete activity cycle arrives as one clean update', () => {
const firstMember = {
seq: 1, label: 'first', childId: 'child-1' as SessionId, status: 'completed' as const,
}
const phaseClean: WorkflowRunChatData = {
name: 'phase-cycle', status: 'running',
phases: [phase({ members: [firstMember] })],
}
const phaseView = render(<WorkflowRunPanel {...panelProps(phaseClean)} />)
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
expect(screen.getByText('first')).toBeTruthy()
phaseView.rerender(<WorkflowRunPanel {...panelProps({
...phaseClean,
phases: [phase({ members: [firstMember, {
seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'completed',
}] })],
})} />)
const header = screen.getByRole('button', { name: /^keyboard/ })
expect(header.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(header, { key: 'ArrowDown' })
expect(header.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(header, { key: 'Enter' })
expect(header.getAttribute('aria-expanded')).toBe('false')
fireEvent.keyDown(header, { key: ' ' })
expect(header.getAttribute('aria-expanded')).toBe('true')
expect(screen.getByText('Research')).toBeTruthy()
expect(screen.getByText('运行中 1')).toBeTruthy()
const phaseHeader = screen.getByRole('button', { name: /Research/ })
fireEvent.keyDown(phaseHeader, { key: 'ArrowDown' })
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
fireEvent.keyDown(phaseHeader, { key: 'Enter' })
expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(phaseHeader, { key: ' ' })
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('first')).toBeNull()
expect(screen.queryByText('second')).toBeNull()
})
cleanup()
render(<WorkflowRunPanel {...panelProps({
name: 'empty', status: 'running', phases: [],
})} />)
it('derives the zero-member running and completed states from the current run status', () => {
const running: WorkflowRunChatData = { name: 'empty', status: 'running', phases: [] }
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
expect(screen.queryByRole('button', { name: /^empty/ })).toBeNull()
expect(screen.getByText('没有启动成员')).toBeTruthy()
view.rerender(<WorkflowRunPanel {...panelProps({ ...running, status: 'completed' })} />)
const header = screen.getByRole('button', { name: /^empty/ })
expect(header.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('没有启动成员')).toBeNull()
fireEvent.click(header)
expect(screen.getByText('没有启动成员')).toBeTruthy()
})
it('keeps phase disclosure independent and preserves empty versus absent names', () => {
it.each(['failed', 'cancelled', 'interrupted'] as const)(
'bubbles a %s member to the run and keeps a matching run outcome open',
(status) => {
const memberView = render(<WorkflowRunPanel {...panelProps({
name: 'member-outcome', status: 'completed',
phases: [phase({
members: [{ seq: 1, label: status, childId: CHILD_ID, status }],
})],
})} />)
expect(screen.queryByRole('button', { name: /^member-outcome/ })).toBeNull()
expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
expect(screen.getByText(status)).toBeTruthy()
memberView.unmount()
render(<WorkflowRunPanel {...panelProps({
name: 'run-outcome', status,
phases: [phase({
members: [{ seq: 1, label: 'done', childId: CHILD_ID, status: 'completed' }],
})],
})} />)
expect(screen.queryByRole('button', { name: /^run-outcome/ })).toBeNull()
expect(screen.getByRole('button', { name: /未分阶段/ }).getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('done')).toBeNull()
},
)
it('keeps clean sibling phases independent and preserves empty versus absent names', () => {
render(<WorkflowRunPanel {...panelProps({
name: 'audit', status: 'running',
name: 'audit', status: 'completed',
phases: [
phase({ key: 'value:0:', phase: '', members: [{
seq: 1, label: '', childId: 'child-1' as SessionId, status: 'running',
seq: 1, label: '', childId: 'child-1' as SessionId, status: 'completed',
}] }),
phase({ key: 'missing', phase: null, members: [{
seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'running',
}] }),
],
})} />)
fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
expect(screen.getByText('空成员名')).toBeTruthy()
expect(screen.queryByText('second')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
expect(screen.queryByRole('button', { name: /^audit/ })).toBeNull()
const cleanPhase = screen.getByRole('button', { name: /空阶段名/ })
expect(cleanPhase.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('button', { name: /未分阶段/ })).toBeNull()
expect(screen.queryByText('空成员名')).toBeNull()
expect(screen.getByText('second')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
fireEvent.click(cleanPhase)
expect(screen.getByText('空成员名')).toBeTruthy()
expect(screen.getByText('second')).toBeTruthy()
fireEvent.click(cleanPhase)
expect(screen.queryByText('空成员名')).toBeNull()
expect(screen.getByText('second')).toBeTruthy()
})
it('covers the Figma completed, failed/cancelled, and interrupted state boards', () => {
const completed: WorkflowRunChatData = {
name: 'repo-audit', status: 'completed',
phases: [phase({
members: [{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }],
})],
}
const completedView = render(<WorkflowRunPanel {...panelProps(completed)} />)
const completedHeader = screen.getByRole('button', { name: /^repo-audit/ })
expect(completedHeader.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(completedHeader)
expect(completedHeader.getAttribute('aria-expanded')).toBe('true')
completedView.unmount()
it('renders mixed and interrupted aggregate status while attention stays visible', () => {
const mixed: WorkflowRunChatData = {
name: 'repo-audit', status: 'failed',
phases: [phase({
@@ -395,8 +475,6 @@ describe('WorkflowRunPanel', () => {
})],
}
const mixedView = render(<WorkflowRunPanel {...panelProps(mixed)} />)
fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ }))
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy()
expect([...mixedView.container.querySelectorAll('[data-member-status]')]
.map(row => row.getAttribute('data-member-status'))).toEqual(['failed', 'cancelled'])
@@ -404,28 +482,18 @@ describe('WorkflowRunPanel', () => {
expect(mixedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
mixedView.unmount()
const interrupted: WorkflowRunChatData = {
const interruptedView = render(<WorkflowRunPanel {...panelProps({
name: 'repo-audit', status: 'interrupted',
phases: [
phase({
members: [
{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' },
{ seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' },
],
}),
phase({
key: 'interrupted-only', phase: 'Interrupted only',
members: [{
seq: 3, label: 'interrupted', childId: 'child-3' as SessionId, status: 'interrupted',
}],
}),
],
}
const interruptedView = render(<WorkflowRunPanel {...panelProps(interrupted)} />)
fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ }))
phases: [phase({
members: [
{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' },
{ seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' },
],
})],
})} />)
expect(screen.getByText('已完成 1 · 已中断 1')).toBeTruthy()
expect(interruptedView.container.querySelector('[data-run-status="interrupted"]')).toBeTruthy()
expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
})
it('opens only a running ordinary-list subagent proven to have this parent', () => {
@@ -434,7 +502,6 @@ describe('WorkflowRunPanel', () => {
}
const openSession = vi.fn()
render(<WorkflowRunPanel {...panelProps(data, listState(), openSession)} />)
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
fireEvent.click(screen.getByRole('button', { name: '打开 worker' }))
expect(openSession).toHaveBeenCalledWith('child-1')
})
@@ -464,7 +531,6 @@ describe('WorkflowRunPanel', () => {
})],
}
render(<WorkflowRunPanel {...panelProps(data, sessions)} />)
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull()
cleanup()
})