feat(workflow): show durable run records in Chat
This commit is contained in:
@@ -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/README.md
|
||||
README.md: 567e10f74ae9d017abef1d876401a958eb80fcfd
|
||||
README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169
|
||||
README.md: 6b3904c1e97a5a3da4864731aa624b3afbf5d027
|
||||
README.zh.md: 9f71ffa04fb80f0fd6d62b1d4b23f0ea1474c107
|
||||
|
||||
@@ -23,6 +23,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. |
|
||||
| [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. |
|
||||
| [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. |
|
||||
| [`ui-workflow-run/`](ui-workflow-run/README.md) | Replays durable workflow runs as nested Chat disclosures with live-only child navigation. |
|
||||
| [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. |
|
||||
| [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. |
|
||||
| [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. |
|
||||
|
||||
@@ -23,6 +23,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 |
|
||||
| [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 |
|
||||
| [`ui-tool/`](ui-tool/README.md) | 编排工具调用树和按工具键控的视图。 |
|
||||
| [`ui-workflow-run/`](ui-workflow-run/README.md) | 把持久工作流运行回放为 Chat 嵌套折叠项,并只为实时子 Session 提供导航。 |
|
||||
| [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 |
|
||||
| [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent(智能体)活动的其他视图。 |
|
||||
| [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 |
|
||||
|
||||
6
packages/client/ui-workflow-run/README.i18n.yaml
Normal file
6
packages/client/ui-workflow-run/README.i18n.yaml
Normal file
@@ -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 packages/client/ui-workflow-run/README.md
|
||||
README.md: 66539e0c16ac4102f9e1fe881106e6881b36a7d5
|
||||
README.zh.md: a803857af24802e8a4645c4d5aca56c04424c85e
|
||||
35
packages/client/ui-workflow-run/README.md
Normal file
35
packages/client/ui-workflow-run/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# @deepseek-ai/dsh-client-ui-workflow-run
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The browser plugin that reconstructs durable top-level workflow runs as independent Chat nodes. It consumes the four `tool-workflow/*` Session events owned by [`dsh-tool-workflow`](../../workflow/tool-workflow/README.md), registers one `ConversationNodeDefinition`, and renders through the keyed `conversation.chat.node` slot without changing the existing workflow tool card.
|
||||
|
||||
## Durable state and replay
|
||||
|
||||
`tool-workflow/run-start` creates one Context keyed by `runId`; member starts, member endings, and the run ending update that Context in log order. A history tail containing only updates remains pending until an older page supplies the unique start, after which prepend, complete replay, and live append produce the same state. A closed Turn or Step with missing terminal events presents the affected run or members as interrupted without changing the tool result.
|
||||
|
||||
Phase groups come only from members that actually started. Exact phase strings share a group, an omitted phase is distinct from the empty string, and settlement changes status without removing or reordering members.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## Composition
|
||||
|
||||
The package registers its Definition, locale dictionary, and `workflow-run` renderer as Cordis effects. Removing the client entry retracts all three contributions. The shipped Web bundle includes the plugin after `ui-conversation` and `ui-tool`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package renders durable Session facts for humans and adds no prompt, tool schema, request content, or model-visible result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Only top-level calls through `dsh-tool-workflow` produce these records; nested Code Mode calls and direct `WorkflowService` consumers do not.
|
||||
- Navigation is intentionally live-only. Terminal members remain visible for review but never expose a cold-session opener from this node.
|
||||
- The node shows run, phase, member identity, and status only; scripts, outputs, errors, logs, usage, static topology, and controls remain outside this surface.
|
||||
35
packages/client/ui-workflow-run/README.zh.md
Normal file
35
packages/client/ui-workflow-run/README.zh.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# @deepseek-ai/dsh-client-ui-workflow-run
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这个浏览器插件把持久化的顶层工作流运行重建为独立 Chat 节点。它消费由 [`dsh-tool-workflow`](../../workflow/tool-workflow/README.md) 拥有的四类 `tool-workflow/*` Session 事件,注册一个 `ConversationNodeDefinition`,并通过 keyed `conversation.chat.node` slot 渲染,不改变现有工作流工具卡。
|
||||
|
||||
## 持久状态与回放
|
||||
|
||||
`tool-workflow/run-start` 以 `runId` 创建唯一 Context;成员开始、成员结束和运行结束事件按日志顺序更新该 Context。只有 update 的历史尾页会保持 pending,直到更早页面补入唯一 start;此后 prepend、完整回放和实时 append 得到相同状态。若所属 Turn 或 Step 已关闭但终点事件缺失,界面把相应运行或成员显示为已中断,而不改写工具结果。
|
||||
|
||||
阶段组只来自真正开始过的成员。完全相同的阶段字符串归入同一组,字段缺省与空字符串保持不同身份;成员结算只改变状态,不删除或重排成员。
|
||||
|
||||
## 展示与导航
|
||||
|
||||
运行和每个阶段分别拥有本地 disclosure 状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。运行中记录首次挂载时展开,从历史加载的终态记录首次挂载时折叠。只要 keyed 节点仍挂载,本地选择就在数据更新时保持;只有完整 remount 才重新初始化。
|
||||
|
||||
只有所有实时事实同时成立时,成员才可打开子 Session:成员仍在运行、子 id 位于普通 Session 列表、列表行为 `origin: 'subagent'`、`parentId` 等于当前 Session,且列表行仍标记运行。带下划线的成员文字是唯一可见导航提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,右侧状态仍只显示“运行中”。组件只调用注入的普通 `sessions.open(id)`;远程、仅地址化、父级不符或终态的行都不可交互。
|
||||
|
||||
## 装配
|
||||
|
||||
本包把 Definition、locale 字典和 `workflow-run` renderer 都注册为 Cordis effect;移除客户端 entry 会撤销三者。shipped Web bundle 在 `ui-conversation` 与 `ui-tool` 之后装配该插件。
|
||||
|
||||
## Model Experience
|
||||
|
||||
无,因为本包只为人类展示持久 Session 事实,不增加 prompt、工具 schema、请求内容或模型可见结果。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
无。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- 只有经 `dsh-tool-workflow` 发起的顶层调用会生成这些记录;嵌套 Code Mode 调用和直接 `WorkflowService` 消费方不会生成。
|
||||
- 导航刻意只面向实时运行。终态成员继续保留供复盘,但本节点永不为其提供冷 Session 入口。
|
||||
- 节点只显示运行、阶段、成员身份与状态;脚本、输出、错误、日志、用量、静态拓扑和控制操作都不属于本界面。
|
||||
73
packages/client/ui-workflow-run/package.json
Normal file
73
packages/client/ui-workflow-run/package.json
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-workflow-run",
|
||||
"description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-workflow": "^0.0.1",
|
||||
"@deepseek-ai/dsh-workflow": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.runHeader {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runHeader:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.runLeading {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.runTitle {
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
max-width: 42%;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 510;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.runSummary {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statusTail {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
font-weight: 510;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.phaseHeader {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.phaseHeader:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.phaseLeading {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.phaseTitle {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.phaseCount {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.phaseStatus {
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
width: 132px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.phaseList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 4px 0 0 16px;
|
||||
}
|
||||
|
||||
.phase {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.members {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: 0 0 0 16px;
|
||||
}
|
||||
|
||||
.memberRow,
|
||||
.memberButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.memberButton {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memberButton .memberLabel {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-position: from-font;
|
||||
}
|
||||
|
||||
.dotSlot {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memberButton:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.memberButton:focus-visible .memberLabelWrap {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.memberLabelWrap {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
padding: 0 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.memberLabel {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.memberStatus {
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
width: 64px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.phaseList,
|
||||
.members {
|
||||
padding-left: 12px;
|
||||
}
|
||||
}
|
||||
235
packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx
Normal file
235
packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useMemo, useState, type KeyboardEvent } from 'react'
|
||||
import {
|
||||
IconChevronDownOutline14, IconChevronRightOutline14, StateDot, type StateDotState,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkflowRunKey } from './locales.ts'
|
||||
import type {
|
||||
WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus,
|
||||
} from './workflow-definition.ts'
|
||||
import css from './WorkflowRunPanel.module.css'
|
||||
|
||||
/** Navigation action injected from the plugin's own SessionsService access. */
|
||||
export interface WorkflowRunInjected {
|
||||
readonly openSession: (id: SessionId) => void
|
||||
}
|
||||
|
||||
/** Complete keyed Chat renderer props. */
|
||||
export type WorkflowRunPanelProps =
|
||||
PropsRuntime<'conversation.chat.node', 'workflow-run'>
|
||||
& PropsLocale<'workflowRun'>
|
||||
& WorkflowRunInjected
|
||||
|
||||
const STATUS_KEYS = {
|
||||
running: 'status.running',
|
||||
completed: 'status.completed',
|
||||
failed: 'status.failed',
|
||||
cancelled: 'status.cancelled',
|
||||
interrupted: 'status.interrupted',
|
||||
} as const satisfies Record<WorkflowRunStatus, WorkflowRunKey>
|
||||
|
||||
function dotState(status: WorkflowRunStatus): StateDotState {
|
||||
switch (status) {
|
||||
case 'running': return 'ongoing'
|
||||
case 'completed': return 'done'
|
||||
case 'failed': return 'error'
|
||||
case 'cancelled':
|
||||
case 'interrupted': return 'warning'
|
||||
/* v8 ignore next -- WorkflowRunStatus is closed and every variant is handled above. */
|
||||
default: return status satisfies never
|
||||
}
|
||||
}
|
||||
|
||||
function readablePhase(phase: string | null, t: WorkflowRunPanelProps['t']): string {
|
||||
if (phase === null) return t('phase.unassigned')
|
||||
return phase === '' ? t('phase.empty') : phase
|
||||
}
|
||||
|
||||
function readableMember(label: string, t: WorkflowRunPanelProps['t']): string {
|
||||
return label === '' ? t('member.empty') : label
|
||||
}
|
||||
|
||||
function statusCount(
|
||||
status: WorkflowRunStatus,
|
||||
count: number,
|
||||
t: WorkflowRunPanelProps['t'],
|
||||
): string {
|
||||
return t(`statusCount.${status}`, { count })
|
||||
}
|
||||
|
||||
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)
|
||||
const count = (status: WorkflowRunStatus): number => counts.get(status) ?? 0
|
||||
const active = (['running', 'failed', 'cancelled', 'interrupted'] as const)
|
||||
.filter(status => count(status) > 0)
|
||||
if (active.length === 0) return statusCount('completed', count('completed'), t)
|
||||
const visible = active.includes('interrupted') && count('completed') > 0
|
||||
? ['completed' as const, ...active]
|
||||
: active
|
||||
return visible.map(status => statusCount(status, count(status), t)).join(' · ')
|
||||
}
|
||||
|
||||
function handleDisclosureKey(event: KeyboardEvent<HTMLDivElement>, onToggle: () => void): void {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
|
||||
function RunHeader({ count, name, onToggle, open, status, t }: {
|
||||
readonly count: number
|
||||
readonly name: string
|
||||
readonly onToggle: () => void
|
||||
readonly open: boolean
|
||||
readonly status: WorkflowRunStatus
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={css.runHeader}
|
||||
data-run-header
|
||||
data-status={status}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={open}
|
||||
onClick={onToggle}
|
||||
onKeyDown={(event) => { handleDisclosureKey(event, onToggle) }}
|
||||
>
|
||||
<span className={css.runLeading}>
|
||||
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
</span>
|
||||
<span className={css.runTitle}>{t('run.title', { name })}</span>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={css.runSummary}>{t('run.members', { count })}</span>
|
||||
<span className={css.statusTail} data-run-status-tail data-status={status}>
|
||||
<StateDot state={dotState(status)} />
|
||||
<span>{t(STATUS_KEYS[status])}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemberRow({ member, navigable, openSession, t }: {
|
||||
readonly member: WorkflowRunMemberData
|
||||
readonly navigable: boolean
|
||||
readonly openSession: WorkflowRunInjected['openSession']
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
const name = readableMember(member.label, t)
|
||||
const content = (
|
||||
<>
|
||||
<span className={css.dotSlot}><StateDot state={dotState(member.status)} /></span>
|
||||
<span className={css.memberLabelWrap} data-member-label-wrap><span className={css.memberLabel} data-member-label>{name}</span></span>
|
||||
<span className={css.memberStatus} data-member-status-text>{t(STATUS_KEYS[member.status])}</span>
|
||||
</>
|
||||
)
|
||||
if (!navigable) {
|
||||
return <div className={css.memberRow} data-member-status={member.status}>{content}</div>
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.memberButton}
|
||||
data-member-status={member.status}
|
||||
aria-label={t('member.open', { name })}
|
||||
onClick={() => { openSession(member.childId) }}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PhaseSection({ phase, navigable, openSession, t }: {
|
||||
readonly phase: WorkflowRunPhaseData
|
||||
readonly navigable: ReadonlySet<SessionId>
|
||||
readonly openSession: WorkflowRunInjected['openSession']
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const toggle = (): void => { setOpen(value => !value) }
|
||||
return (
|
||||
<div className={css.phase} data-phase-key={phase.key} data-phase-status={phase.status}>
|
||||
<div
|
||||
className={css.phaseHeader}
|
||||
data-phase-header
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => { handleDisclosureKey(event, toggle) }}
|
||||
>
|
||||
<span className={css.phaseLeading}>
|
||||
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
</span>
|
||||
<span className={css.phaseTitle}>{readablePhase(phase.phase, t)}</span>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={css.phaseCount} data-phase-count>{t('run.members', { count: phase.members.length })}</span>
|
||||
<span className={css.phaseStatus} data-phase-status-text>{phaseStatusSummary(phase.members, t)}</span>
|
||||
</div>
|
||||
{open && (
|
||||
<div className={css.members}>
|
||||
{phase.members.map(member => (
|
||||
<MemberRow
|
||||
key={member.seq}
|
||||
member={member}
|
||||
navigable={navigable.has(member.childId)}
|
||||
openSession={openSession}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Render one durable workflow run with independent run and phase disclosure. */
|
||||
export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) {
|
||||
const [open, setOpen] = useState(() => node.data.status === 'running')
|
||||
const sessions = useSessions(value => value)
|
||||
const navigable = useMemo(() => {
|
||||
const ordinary = new Set(sessions.ids)
|
||||
const result = new Set<SessionId>()
|
||||
for (const phase of node.data.phases) {
|
||||
for (const member of phase.members) {
|
||||
const summary = sessions.byId[member.childId]
|
||||
if (member.status === 'running'
|
||||
&& ordinary.has(member.childId)
|
||||
&& summary?.origin === 'subagent'
|
||||
&& summary.parentId === sessionId
|
||||
&& summary.running) {
|
||||
result.add(member.childId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [node.data.phases, sessionId, sessions])
|
||||
return (
|
||||
<section className={css.root} data-workflow-run data-run-status={node.data.status}>
|
||||
<RunHeader
|
||||
count={node.data.memberCount}
|
||||
name={node.data.name}
|
||||
open={open}
|
||||
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>
|
||||
: node.data.phases.map(phase => (
|
||||
<PhaseSection
|
||||
key={phase.key}
|
||||
phase={phase}
|
||||
navigable={navigable}
|
||||
openSession={openSession}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
38
packages/client/ui-workflow-run/src/client/index.ts
Normal file
38
packages/client/ui-workflow-run/src/client/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/** Browser plugin for durable workflow-run Conversation Nodes. */
|
||||
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { WorkflowRunPanel, type WorkflowRunInjected } from './WorkflowRunPanel.tsx'
|
||||
import { en, NS, type WorkflowRunKey, zh } from './locales.ts'
|
||||
import { workflowRunDefinition } from './workflow-definition.ts'
|
||||
|
||||
export type { WorkflowRunInjected, WorkflowRunPanelProps } from './WorkflowRunPanel.tsx'
|
||||
export type {
|
||||
WorkflowRunChatData, WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus,
|
||||
} from './workflow-definition.ts'
|
||||
export type { WorkflowRunKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Durable workflow-run node copy. */
|
||||
workflowRun: WorkflowRunKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services for Definition, keyed renderer, navigation, and copy. */
|
||||
export const inject = ['conversationEvents', 'slots', 'sessions', 'locale']
|
||||
|
||||
/** Register the workflow Definition, dictionary, and keyed Chat renderer. */
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.conversationEvents.register(workflowRunDefinition)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workflow-run: dictionaries')
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'workflow-run',
|
||||
locale: NS,
|
||||
inject: (): WorkflowRunInjected => ({
|
||||
openSession: (id: SessionId) => { ctx.sessions.open(id) },
|
||||
}),
|
||||
}, WorkflowRunPanel))
|
||||
}
|
||||
49
packages/client/ui-workflow-run/src/client/locales.ts
Normal file
49
packages/client/ui-workflow-run/src/client/locales.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/** `workflowRun` namespace dictionaries. */
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'workflowRun'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'run.title': '{name}',
|
||||
'run.members': '{count} 个成员',
|
||||
'run.empty': '没有启动成员',
|
||||
'phase.unassigned': '未分阶段',
|
||||
'phase.empty': '空阶段名',
|
||||
'statusCount.running': '运行中 {count}',
|
||||
'statusCount.completed': '已完成 {count}',
|
||||
'statusCount.failed': '失败 {count}',
|
||||
'statusCount.cancelled': '已取消 {count}',
|
||||
'statusCount.interrupted': '已中断 {count}',
|
||||
'member.empty': '空成员名',
|
||||
'member.open': '打开 {name}',
|
||||
'status.running': '运行中',
|
||||
'status.completed': '已完成',
|
||||
'status.failed': '失败',
|
||||
'status.cancelled': '已取消',
|
||||
'status.interrupted': '已中断',
|
||||
}
|
||||
|
||||
/** English dictionary (same key set). */
|
||||
export const en: Record<WorkflowRunKey, string> = {
|
||||
'run.title': '{name}',
|
||||
'run.members': '{count} members',
|
||||
'run.empty': 'No members started',
|
||||
'phase.unassigned': 'Unphased',
|
||||
'phase.empty': 'Empty phase name',
|
||||
'statusCount.running': 'Running {count}',
|
||||
'statusCount.completed': 'Completed {count}',
|
||||
'statusCount.failed': 'Failed {count}',
|
||||
'statusCount.cancelled': 'Cancelled {count}',
|
||||
'statusCount.interrupted': 'Interrupted {count}',
|
||||
'member.empty': 'Empty member name',
|
||||
'member.open': 'Open {name}',
|
||||
'status.running': 'Running',
|
||||
'status.completed': 'Completed',
|
||||
'status.failed': 'Failed',
|
||||
'status.cancelled': 'Cancelled',
|
||||
'status.interrupted': 'Interrupted',
|
||||
}
|
||||
|
||||
/** Union of this namespace's dictionary keys. */
|
||||
export type WorkflowRunKey = keyof typeof zh
|
||||
@@ -0,0 +1,200 @@
|
||||
import type {
|
||||
ChatConversationViewNode, ConversationLocation, ConversationNodeContext,
|
||||
ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
ToolWorkflowAgentEndData, ToolWorkflowAgentStartData,
|
||||
} from '@deepseek-ai/dsh-tool-workflow/types'
|
||||
import type { WorkflowAgentOutcome, WorkflowStopReason } from '@deepseek-ai/dsh-workflow/types'
|
||||
|
||||
/** Status shown for a workflow, phase, or member. */
|
||||
export type WorkflowRunStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'interrupted'
|
||||
|
||||
/** Final renderer data for one member. */
|
||||
export interface WorkflowRunMemberData {
|
||||
readonly seq: number
|
||||
readonly label: string
|
||||
readonly childId: SessionId
|
||||
readonly status: WorkflowRunStatus
|
||||
}
|
||||
|
||||
/** Final renderer data for one exact phase identity. */
|
||||
export interface WorkflowRunPhaseData {
|
||||
readonly key: string
|
||||
/** `null` is the absent field; the empty string remains a distinct identity. */
|
||||
readonly phase: string | null
|
||||
readonly status: WorkflowRunStatus
|
||||
readonly members: readonly WorkflowRunMemberData[]
|
||||
}
|
||||
|
||||
/** Final keyed Chat payload for one workflow run. */
|
||||
export interface WorkflowRunChatData {
|
||||
readonly name: string
|
||||
readonly status: WorkflowRunStatus
|
||||
readonly memberCount: number
|
||||
readonly phases: readonly WorkflowRunPhaseData[]
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Durable top-level workflow run and all members that actually started. */
|
||||
'workflow-run': WorkflowRunChatData
|
||||
}
|
||||
}
|
||||
|
||||
interface WorkflowMemberState extends ToolWorkflowAgentStartData {
|
||||
readonly outcome?: WorkflowAgentOutcome
|
||||
}
|
||||
|
||||
interface WorkflowState {
|
||||
readonly name: string
|
||||
readonly stopReason?: WorkflowStopReason
|
||||
readonly members: readonly WorkflowMemberState[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a collision-free phase key preserving absent versus empty identity.
|
||||
* @param phase - exact phase string, or null for an omitted field.
|
||||
* @returns the stable renderer key for that phase identity.
|
||||
*/
|
||||
export function workflowPhaseKey(phase: string | null): string {
|
||||
return phase === null ? 'missing' : `value:${phase.length}:${phase}`
|
||||
}
|
||||
|
||||
function statusFromStopReason(stopReason: WorkflowStopReason): WorkflowRunStatus {
|
||||
switch (stopReason) {
|
||||
case 'completed': return 'completed'
|
||||
case 'cancelled': return 'cancelled'
|
||||
case 'error': return 'failed'
|
||||
/* v8 ignore next -- WorkflowStopReason is closed and every variant is handled above. */
|
||||
default: return stopReason satisfies never
|
||||
}
|
||||
}
|
||||
|
||||
function statusFromOutcome(outcome: WorkflowAgentOutcome): WorkflowRunStatus {
|
||||
switch (outcome) {
|
||||
case 'completed': return 'completed'
|
||||
case 'cancelled': return 'cancelled'
|
||||
case 'failed': return 'failed'
|
||||
/* v8 ignore next -- WorkflowAgentOutcome is closed and every variant is handled above. */
|
||||
default: return outcome satisfies never
|
||||
}
|
||||
}
|
||||
|
||||
function locationClosed(location: ConversationLocation | undefined): boolean {
|
||||
if (location === undefined) return false
|
||||
if (location.kind === 'step') {
|
||||
return location.step.status === 'closed' || location.turn.status === 'closed'
|
||||
}
|
||||
return location.kind === 'turn' && location.turn.status === 'closed'
|
||||
}
|
||||
|
||||
function aggregateStatus(members: readonly WorkflowRunMemberData[]): WorkflowRunStatus {
|
||||
if (members.some(member => member.status === 'running')) return 'running'
|
||||
if (members.some(member => member.status === 'failed')) return 'failed'
|
||||
if (members.some(member => member.status === 'cancelled')) return 'cancelled'
|
||||
if (members.some(member => member.status === 'interrupted')) return 'interrupted'
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
function projectWorkflow(
|
||||
context: ConversationNodeContext<WorkflowState>,
|
||||
): WorkflowRunChatData | undefined {
|
||||
const state = context.state
|
||||
if (state === undefined) return undefined
|
||||
const interrupted = state.stopReason === undefined
|
||||
&& locationClosed(context.start?.location ?? context.matches[0]?.location)
|
||||
const phases = new Map<string, { phase: string | null; members: WorkflowRunMemberData[] }>()
|
||||
for (const member of state.members) {
|
||||
const phase = member.phase === undefined ? null : member.phase
|
||||
const key = workflowPhaseKey(phase)
|
||||
let group = phases.get(key)
|
||||
if (group === undefined) {
|
||||
group = { phase, members: [] }
|
||||
phases.set(key, group)
|
||||
}
|
||||
group.members.push({
|
||||
seq: member.seq,
|
||||
label: member.label,
|
||||
childId: member.childId,
|
||||
status: member.outcome === undefined
|
||||
? interrupted ? 'interrupted' : 'running'
|
||||
: statusFromOutcome(member.outcome),
|
||||
})
|
||||
}
|
||||
const projectedPhases = [...phases].map(([key, phase]) => ({
|
||||
key,
|
||||
phase: phase.phase,
|
||||
status: aggregateStatus(phase.members),
|
||||
members: phase.members,
|
||||
}))
|
||||
return {
|
||||
name: state.name,
|
||||
status: state.stopReason === undefined
|
||||
? interrupted ? 'interrupted' : 'running'
|
||||
: statusFromStopReason(state.stopReason),
|
||||
memberCount: state.members.length,
|
||||
phases: projectedPhases,
|
||||
}
|
||||
}
|
||||
|
||||
function updateAgentStart(state: WorkflowState, data: ToolWorkflowAgentStartData): WorkflowState {
|
||||
return { ...state, members: [...state.members, data] }
|
||||
}
|
||||
|
||||
function updateAgentEnd(state: WorkflowState, data: ToolWorkflowAgentEndData): WorkflowState {
|
||||
return {
|
||||
...state,
|
||||
members: state.members.map(member => member.seq === data.seq
|
||||
? { ...member, outcome: data.outcome }
|
||||
: member),
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable workflow event family folded into one keyed Chat node. */
|
||||
export const workflowRunDefinition: ConversationNodeDefinition<WorkflowState> = {
|
||||
kind: 'workflow-run',
|
||||
match: (event) => {
|
||||
if (event.type === 'tool-workflow/run-start') return { id: String(event.data.runId), role: 'start' }
|
||||
if (event.type === 'tool-workflow/agent-start'
|
||||
|| event.type === 'tool-workflow/agent-end'
|
||||
|| event.type === 'tool-workflow/run-end') {
|
||||
return { id: String(event.data.runId), role: 'update' }
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'tool-workflow/run-start') {
|
||||
throw new Error('workflow-run start requires tool-workflow/run-start')
|
||||
}
|
||||
return { name: match.event.data.name, members: [] }
|
||||
},
|
||||
update: (context, match) => {
|
||||
if (match.event.type === 'tool-workflow/agent-start') {
|
||||
return updateAgentStart(context.state, match.event.data)
|
||||
}
|
||||
if (match.event.type === 'tool-workflow/agent-end') {
|
||||
return updateAgentEnd(context.state, match.event.data)
|
||||
}
|
||||
if (match.event.type === 'tool-workflow/run-end') {
|
||||
return { ...context.state, stopReason: match.event.data.stopReason }
|
||||
}
|
||||
return context.state
|
||||
},
|
||||
buildViewNode: (context, target): ChatConversationViewNode | null => {
|
||||
if (target !== 'chat') return null
|
||||
const data = projectWorkflow(context)
|
||||
if (data === undefined || context.start === undefined) return null
|
||||
return {
|
||||
key: context.key,
|
||||
kind: 'workflow-run',
|
||||
id: context.id,
|
||||
target: 'chat',
|
||||
anchorSeq: context.start.event.seq,
|
||||
location: context.start.location,
|
||||
visibility: 'visible',
|
||||
data,
|
||||
}
|
||||
},
|
||||
}
|
||||
6
packages/client/ui-workflow-run/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-workflow-run/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
4
packages/client/ui-workflow-run/src/index.ts
Normal file
4
packages/client/ui-workflow-run/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Durable workflow-run UI plugin, node half. */
|
||||
|
||||
/** Host plugin body; the feature is entirely browser-side. */
|
||||
export function apply(): void {}
|
||||
24
packages/client/ui-workflow-run/src/invariant.ts
Normal file
24
packages/client/ui-workflow-run/src/invariant.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/** Package-owned invariant companion for the workflow-run UI plugin. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-workflow-run'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-workflow-run-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the browser plugin contributes one effect-owned
|
||||
* Conversation Definition, keyed renderer, and dictionary; tests prove their
|
||||
* disposal and the Host tool package owns the durable event invariant.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/** Register this package's invariant companion. */
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
526
packages/client/ui-workflow-run/tests/workflow-run.spec.tsx
Normal file
526
packages/client/ui-workflow-run/tests/workflow-run.spec.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context, Service } from 'cordis'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ConversationEventRegistry, ConversationNodeAssembler, SlotsService,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ChatConversationViewNode, ConversationEventInput, ConversationMatch, ConversationNodeDefinition,
|
||||
ConversationViewDefinition, ConversationViewNode, SessionId, SessionListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import {
|
||||
WorkflowRunPanel, type WorkflowRunInjected, type WorkflowRunPanelProps,
|
||||
} from '../src/client/WorkflowRunPanel.tsx'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import {
|
||||
workflowRunDefinition, type WorkflowRunChatData,
|
||||
} from '../src/client/workflow-definition.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
import { apply as applyInvariant } from '../src/invariant.ts'
|
||||
import type {} from '../src/client/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const PARENT_ID = 'parent' as SessionId
|
||||
const CHILD_ID = 'child-1' as SessionId
|
||||
|
||||
interface ChatSnapshot {
|
||||
readonly nodes: ReadonlyMap<string, ChatConversationViewNode>
|
||||
}
|
||||
|
||||
class TestEventDefinitions {
|
||||
entries(): readonly ConversationNodeDefinition[] { return [workflowRunDefinition] }
|
||||
fallbackEntry(): undefined { return undefined }
|
||||
}
|
||||
|
||||
class TestViewDefinitions {
|
||||
entries(): readonly ConversationViewDefinition[] { return [chatViewDefinition] }
|
||||
}
|
||||
|
||||
const chatViewDefinition: ConversationViewDefinition<ChatConversationViewNode, ChatSnapshot> = {
|
||||
target: 'chat',
|
||||
create: () => {
|
||||
let nodes = new Map<string, ChatConversationViewNode>()
|
||||
const snapshot = (): ChatSnapshot => ({ nodes })
|
||||
return {
|
||||
empty: snapshot(),
|
||||
replace: ({ nodes: values }) => {
|
||||
nodes = new Map(values.map(node => [node.key, node]))
|
||||
return snapshot()
|
||||
},
|
||||
apply: ({ upserts }) => {
|
||||
nodes = new Map(nodes)
|
||||
for (const node of upserts) nodes.set(node.key, node)
|
||||
return snapshot()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function at(seq: number, type: string, data: unknown): ConversationEventInput {
|
||||
return { event: { seq, time: seq * 100, type, data } as ConversationEventInput['event'], view: undefined }
|
||||
}
|
||||
|
||||
function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch {
|
||||
return { ...input, role, location: { kind: 'unresolved' } }
|
||||
}
|
||||
|
||||
function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler {
|
||||
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
|
||||
value.replaceWindow(entries, hasMore)
|
||||
value.flush()
|
||||
return value
|
||||
}
|
||||
|
||||
function workflowData(value: ConversationNodeAssembler): WorkflowRunChatData | undefined {
|
||||
const snapshot = value.snapshot('chat') as ChatSnapshot
|
||||
return [...snapshot.nodes.values()][0]?.data as WorkflowRunChatData | undefined
|
||||
}
|
||||
|
||||
function completeEvents(): ConversationEventInput[] {
|
||||
return [
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'tool-workflow/run-start', { runId: 'run-1', name: 'audit' }),
|
||||
at(4, 'tool-workflow/agent-start', {
|
||||
runId: 'run-1', seq: 1, label: 'first', phase: '', childId: 'child-1',
|
||||
}),
|
||||
at(5, 'tool-workflow/agent-start', {
|
||||
runId: 'run-1', seq: 2, label: 'second', childId: 'child-2',
|
||||
}),
|
||||
at(6, 'tool-workflow/agent-end', { runId: 'run-1', seq: 1, outcome: 'completed' }),
|
||||
at(7, 'tool-workflow/agent-end', { runId: 'run-1', seq: 2, outcome: 'failed' }),
|
||||
at(8, 'tool-workflow/run-end', { runId: 'run-1', stopReason: 'error' }),
|
||||
at(9, 'step/end', { turn: 1, step: 1 }),
|
||||
at(10, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
]
|
||||
}
|
||||
|
||||
describe('workflow-run Conversation Definition', () => {
|
||||
it('groups exact phase identities in first-member order and preserves terminal members', () => {
|
||||
const value = assembler(completeEvents())
|
||||
const data = workflowData(value)
|
||||
expect(data).toEqual({
|
||||
name: 'audit',
|
||||
status: 'failed',
|
||||
memberCount: 2,
|
||||
phases: [
|
||||
{
|
||||
key: 'value:0:', phase: '', status: 'completed',
|
||||
members: [{ seq: 1, label: 'first', childId: 'child-1', status: 'completed' }],
|
||||
},
|
||||
{
|
||||
key: 'missing', phase: null, status: 'failed',
|
||||
members: [{ seq: 2, label: 'second', childId: 'child-2', status: 'failed' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
const node = [...(value.snapshot('chat') as ChatSnapshot).nodes.values()][0]!
|
||||
expect(node.anchorSeq).toBe(3)
|
||||
expect(node.kind).toBe('workflow-run')
|
||||
})
|
||||
|
||||
it('keeps an update-only tail pending until prepend supplies the unique start', () => {
|
||||
const tail = completeEvents().slice(3)
|
||||
const value = assembler(tail, true)
|
||||
expect(workflowData(value)).toBeUndefined()
|
||||
value.prepend(completeEvents().slice(0, 3), false)
|
||||
value.flush()
|
||||
expect(workflowData(value)).toEqual(workflowData(assembler(completeEvents())))
|
||||
})
|
||||
|
||||
it('produces the same final data through live append as complete replay', () => {
|
||||
const events = completeEvents()
|
||||
const value = assembler(events.slice(0, 3))
|
||||
for (const event of events.slice(3)) value.append(event)
|
||||
value.flush()
|
||||
expect(workflowData(value)).toEqual(workflowData(assembler(events)))
|
||||
})
|
||||
|
||||
it('shows missing terminal facts as interrupted only after the owning Location closes', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'tool-workflow/run-start', { runId: 'run-1', name: 'audit' }),
|
||||
at(4, 'tool-workflow/agent-start', {
|
||||
runId: 'run-1', seq: 1, label: 'worker', childId: 'child-1',
|
||||
}),
|
||||
])
|
||||
expect(workflowData(value)?.status).toBe('running')
|
||||
value.append(at(5, 'step/end', { turn: 1, step: 1 }))
|
||||
value.flush()
|
||||
expect(workflowData(value)).toMatchObject({
|
||||
status: 'interrupted',
|
||||
phases: [{ members: [{ status: 'interrupted' }] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('retains a zero-member run as its own completed node', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'tool-workflow/run-start', { runId: 'empty', name: 'empty' }),
|
||||
at(4, 'tool-workflow/run-end', { runId: 'empty', stopReason: 'completed' }),
|
||||
])
|
||||
expect(workflowData(value)).toEqual({
|
||||
name: 'empty', status: 'completed', memberCount: 0, phases: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('folds same-phase cancellation and a turn-level interruption', () => {
|
||||
const cancelled = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'tool-workflow/run-start', { runId: 'cancelled', name: 'cancelled' }),
|
||||
at(3, 'tool-workflow/agent-start', {
|
||||
runId: 'cancelled', seq: 1, label: 'one', phase: 'Research', childId: 'child-1',
|
||||
}),
|
||||
at(4, 'tool-workflow/agent-start', {
|
||||
runId: 'cancelled', seq: 2, label: 'two', phase: 'Research', childId: 'child-2',
|
||||
}),
|
||||
at(5, 'tool-workflow/agent-end', { runId: 'cancelled', seq: 1, outcome: 'cancelled' }),
|
||||
at(6, 'tool-workflow/agent-end', { runId: 'cancelled', seq: 2, outcome: 'completed' }),
|
||||
at(7, 'tool-workflow/run-end', { runId: 'cancelled', stopReason: 'cancelled' }),
|
||||
])
|
||||
expect(workflowData(cancelled)).toMatchObject({
|
||||
status: 'cancelled',
|
||||
phases: [{ phase: 'Research', status: 'cancelled', members: [{ status: 'cancelled' }, { status: 'completed' }] }],
|
||||
})
|
||||
|
||||
const interruptedTurn = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'tool-workflow/run-start', { runId: 'turn', name: 'turn' }),
|
||||
at(3, 'tool-workflow/agent-start', {
|
||||
runId: 'turn', seq: 1, label: 'open', childId: 'child-1',
|
||||
}),
|
||||
at(4, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
])
|
||||
expect(workflowData(interruptedTurn)?.status).toBe('interrupted')
|
||||
})
|
||||
|
||||
it('handles session/unresolved placement and defensive Definition calls', () => {
|
||||
const sessionLevel = assembler([
|
||||
at(1, 'tool-workflow/run-start', { runId: 'session', name: 'session' }),
|
||||
at(2, 'tool-workflow/agent-start', {
|
||||
runId: 'session', seq: 1, label: 'open', childId: 'child-1',
|
||||
}),
|
||||
])
|
||||
expect(workflowData(sessionLevel)?.status).toBe('running')
|
||||
|
||||
const invalidStart = matched(at(1, 'tool-workflow/agent-start', {
|
||||
runId: 'direct', seq: 1, label: 'member', childId: 'child-1',
|
||||
}), 'start')
|
||||
const emptyContext: Parameters<typeof workflowRunDefinition.start>[0] = {
|
||||
key: 'workflow-run:direct', kind: 'workflow-run', id: 'direct',
|
||||
matches: [invalidStart], start: invalidStart, state: undefined, current: new Map(),
|
||||
}
|
||||
const reader: Parameters<typeof workflowRunDefinition.start>[2] = { previous: () => undefined }
|
||||
expect(() => workflowRunDefinition.start(emptyContext, invalidStart, reader))
|
||||
.toThrow('workflow-run start requires tool-workflow/run-start')
|
||||
|
||||
const start = matched(at(2, 'tool-workflow/run-start', { runId: 'direct', name: 'direct' }), 'start')
|
||||
const startedContext = { ...emptyContext, matches: [start], start }
|
||||
const state = workflowRunDefinition.start(startedContext, start, reader)
|
||||
const updateContext: Parameters<typeof workflowRunDefinition.update>[0] = { ...startedContext, state }
|
||||
const unrelated = matched(at(3, 'turn/start', { turn: 1 }), 'update')
|
||||
expect(workflowRunDefinition.update(updateContext, unrelated)).toBe(state)
|
||||
expect(workflowRunDefinition.buildViewNode(updateContext, 'trajectory')).toBeNull()
|
||||
expect(workflowRunDefinition.buildViewNode({
|
||||
...updateContext, matches: [], start: undefined,
|
||||
}, 'chat')).toBeNull()
|
||||
const directNode = workflowRunDefinition.buildViewNode(updateContext, 'chat') as ChatConversationViewNode | null
|
||||
if (directNode === null) throw new Error('expected direct workflow Chat node')
|
||||
expect(directNode.kind).toBe('workflow-run')
|
||||
expect((directNode.data as WorkflowRunChatData).status).toBe('running')
|
||||
})
|
||||
})
|
||||
|
||||
function node(data: WorkflowRunChatData): WorkflowRunPanelProps['node'] {
|
||||
return {
|
||||
key: '12:workflow-runrun-1',
|
||||
kind: 'workflow-run',
|
||||
id: 'run-1',
|
||||
target: 'chat',
|
||||
anchorSeq: 3,
|
||||
location: { kind: 'unresolved' },
|
||||
visibility: 'visible',
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
const phase = (overrides: Partial<WorkflowRunChatData['phases'][number]> = {}): WorkflowRunChatData['phases'][number] => ({
|
||||
key: 'missing',
|
||||
phase: null,
|
||||
status: 'running',
|
||||
members: [{ seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: 'running' }],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const listState = (overrides: Partial<SessionListState> = {}): SessionListState => ({
|
||||
ids: [PARENT_ID, CHILD_ID],
|
||||
byId: {
|
||||
[PARENT_ID]: {
|
||||
id: PARENT_ID, displayTitle: 'parent', running: true, blank: false, updatedAt: 0,
|
||||
},
|
||||
[CHILD_ID]: {
|
||||
id: CHILD_ID, displayTitle: 'child', parentId: PARENT_ID, origin: 'subagent',
|
||||
running: true, blank: false, updatedAt: 0,
|
||||
},
|
||||
},
|
||||
current: PARENT_ID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
function panelProps(data: WorkflowRunChatData, sessions = listState(), openSession = vi.fn()): WorkflowRunPanelProps {
|
||||
return {
|
||||
node: node(data),
|
||||
sessionId: PARENT_ID,
|
||||
useSessions: selector => selector(sessions),
|
||||
useSession: (() => undefined) as WorkflowRunPanelProps['useSession'],
|
||||
useProjection: () => undefined,
|
||||
useInput: () => { throw new Error('unused') },
|
||||
inputActions: { setDraft: () => {}, submit: () => {} } as unknown as WorkflowRunPanelProps['inputActions'],
|
||||
useWorkspaces: (() => undefined) as WorkflowRunPanelProps['useWorkspaces'],
|
||||
useTurnData: () => undefined,
|
||||
selectedCallId: undefined,
|
||||
cwd: undefined,
|
||||
openFile: () => {},
|
||||
inspectCall: () => {},
|
||||
forkAt: () => {},
|
||||
loadImage: () => Promise.reject(new Error('unused')),
|
||||
fileMentions: () => undefined,
|
||||
openSession,
|
||||
t: makeTranslate(zh),
|
||||
}
|
||||
}
|
||||
|
||||
describe('WorkflowRunPanel', () => {
|
||||
it('defaults running runs open, terminal history closed, and keeps the current choice across data updates', () => {
|
||||
const running: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', memberCount: 1, phases: [phase()],
|
||||
}
|
||||
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
|
||||
expect(screen.getByText('未分阶段')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
|
||||
expect(screen.queryByText('未分阶段')).toBeNull()
|
||||
|
||||
const terminal: WorkflowRunChatData = { ...running, status: 'completed' }
|
||||
view.rerender(<WorkflowRunPanel {...panelProps(terminal)} />)
|
||||
expect(screen.queryByText('未分阶段')).toBeNull()
|
||||
|
||||
cleanup()
|
||||
render(<WorkflowRunPanel {...panelProps(terminal)} />)
|
||||
expect(screen.queryByText('未分阶段')).toBeNull()
|
||||
})
|
||||
|
||||
it('supports root keyboard disclosure and renders a zero-member running state', () => {
|
||||
render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'keyboard', status: 'running', memberCount: 1,
|
||||
phases: [phase({ key: 'research', phase: 'Research' })],
|
||||
})} />)
|
||||
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')
|
||||
|
||||
cleanup()
|
||||
render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'empty', status: 'running', memberCount: 0, phases: [],
|
||||
})} />)
|
||||
expect(screen.getByText('没有启动成员')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps phase disclosure independent and preserves empty versus absent names', () => {
|
||||
render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'audit', status: 'running', memberCount: 2,
|
||||
phases: [
|
||||
phase({ key: 'value:0:', phase: '', members: [{
|
||||
seq: 1, label: '', childId: 'child-1' as SessionId, status: 'running',
|
||||
}] }),
|
||||
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.getByText('second')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
|
||||
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', memberCount: 1,
|
||||
phases: [phase({
|
||||
status: 'completed',
|
||||
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()
|
||||
|
||||
const mixed: WorkflowRunChatData = {
|
||||
name: 'repo-audit', status: 'failed', memberCount: 2,
|
||||
phases: [phase({
|
||||
status: 'failed',
|
||||
members: [
|
||||
{ seq: 1, label: 'failed', childId: 'child-1' as SessionId, status: 'failed' },
|
||||
{ seq: 2, label: 'cancelled', childId: 'child-2' as SessionId, status: 'cancelled' },
|
||||
],
|
||||
})],
|
||||
}
|
||||
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'])
|
||||
expect(mixedView.container.querySelectorAll('[data-state="error"]')).toHaveLength(2)
|
||||
expect(mixedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
|
||||
mixedView.unmount()
|
||||
|
||||
const interrupted: WorkflowRunChatData = {
|
||||
name: 'repo-audit', status: 'interrupted', memberCount: 2,
|
||||
phases: [
|
||||
phase({
|
||||
status: 'interrupted',
|
||||
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', status: 'interrupted',
|
||||
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/ }))
|
||||
expect(screen.getByText('已完成 1 · 已中断 1')).toBeTruthy()
|
||||
expect(interruptedView.container.querySelector('[data-run-status="interrupted"]')).toBeTruthy()
|
||||
expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens only a running ordinary-list subagent proven to have this parent', () => {
|
||||
const data: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', memberCount: 1, phases: [phase()],
|
||||
}
|
||||
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')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['not in ordinary list', listState({ ids: [PARENT_ID] }), 'running'],
|
||||
['remote row', listState({ byId: {
|
||||
...listState().byId,
|
||||
[CHILD_ID]: { ...listState().byId[CHILD_ID]!, origin: undefined },
|
||||
} }), 'running'],
|
||||
['wrong parent', listState({ byId: {
|
||||
...listState().byId,
|
||||
[CHILD_ID]: { ...listState().byId[CHILD_ID]!, parentId: 'other' as SessionId },
|
||||
} }), 'running'],
|
||||
['list terminal', listState({ byId: {
|
||||
...listState().byId,
|
||||
[CHILD_ID]: { ...listState().byId[CHILD_ID]!, running: false },
|
||||
} }), 'running'],
|
||||
['member terminal', listState(), 'completed'],
|
||||
] as const)('does not navigate when %s', (_name, sessions, memberStatus) => {
|
||||
const data: WorkflowRunChatData = {
|
||||
name: 'audit', status: 'running', memberCount: 1,
|
||||
phases: [phase({
|
||||
status: memberStatus === 'running' ? 'running' : 'completed',
|
||||
members: [{
|
||||
seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: memberStatus,
|
||||
}],
|
||||
})],
|
||||
}
|
||||
render(<WorkflowRunPanel {...panelProps(data, sessions)} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
|
||||
expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull()
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
class TestSessions extends Service {
|
||||
readonly opened: SessionId[] = []
|
||||
constructor(ctx: Context) { super(ctx, 'sessions') }
|
||||
open(id: SessionId): void { this.opened.push(id) }
|
||||
}
|
||||
|
||||
describe('plugin lifecycle', () => {
|
||||
it('registers and removes the Definition and keyed renderer with its fiber', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
await ctx.plugin(ConversationEventRegistry).await()
|
||||
await ctx.plugin(TestSessions).await()
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: { 'conversation.chat.node': { kind: 'keyed', scope: 'session' } },
|
||||
} as never, () => null)
|
||||
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run'])
|
||||
expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1)
|
||||
const entry = ctx.slots.entries('conversation.chat.node')[0]!
|
||||
const face = entry.inject?.() as unknown as WorkflowRunInjected
|
||||
face.openSession(CHILD_ID)
|
||||
expect((ctx.sessions as unknown as TestSessions).opened).toEqual([CHILD_ID])
|
||||
await fiber.dispose()
|
||||
expect(ctx.conversationEvents.entries()).toEqual([])
|
||||
expect(ctx.slots.entries('conversation.chat.node')).toEqual([])
|
||||
|
||||
const replacement = ctx.plugin({ inject: [...inject], apply })
|
||||
await replacement.await()
|
||||
expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run'])
|
||||
expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1)
|
||||
await replacement.dispose()
|
||||
})
|
||||
|
||||
it('keeps the node half inert and registers invariant ownership', async () => {
|
||||
applyNode()
|
||||
const registered: string[] = []
|
||||
const ctx = new Context()
|
||||
ctx.provide('invariants')
|
||||
ctx.set('invariants', {
|
||||
register: (pkg: string) => { registered.push(pkg); return () => {} },
|
||||
} as never)
|
||||
await applyInvariant(ctx)
|
||||
expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-workflow-run'])
|
||||
})
|
||||
})
|
||||
|
||||
void ({} as ConversationViewNode)
|
||||
42
packages/client/ui-workflow-run/tsconfig.json
Normal file
42
packages/client/ui-workflow-run/tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../workflow/workflow"
|
||||
},
|
||||
{
|
||||
"path": "../../workflow/tool-workflow"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-workflow-run/tsdown.config.ts
Normal file
3
packages/client/ui-workflow-run/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-workflow-run', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user