dsh-bettertaskgraphs 0.0.0-stage → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +55 -2
- package/client.js +539 -0
- package/client.source.mjs +322 -0
- package/cordis.patch.yml +5 -0
- package/core.mjs +432 -0
- package/host.mjs +472 -0
- package/index.mjs +70 -0
- package/journal.mjs +131 -0
- package/package.json +78 -4
- package/projection.mjs +205 -0
- package/remote.mjs +51 -0
- package/scripts/build-client.mjs +26 -0
- package/store.mjs +152 -0
- package/tools.mjs +94 -0
- package/view-model.mjs +157 -0
- package/work-policy.mjs +74 -0
package/README.md
CHANGED
|
@@ -1,3 +1,56 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-bettertaskgraphs
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DeepSeek Harness 的事务型任务图 Bundle,包含模型侧的任务图工具和会话输入区的任务图面板。支持嵌套容器、条件分支、单一活动叶节点,以及基于 DSH 会话证据的提交与对账。
|
|
4
|
+
|
|
5
|
+
面板的事务关系区列出进入条件及对应任务状态。点击「全屏」后,沿连线从左往右阅读。可沿同一条前置链推出的重复条件会合并显示。独立的跨列前置留在目标卡片的进入条件中,并在卡片上标记数量。点击节点可切换所选事务,按 Esc 返回会话。
|
|
6
|
+
|
|
7
|
+
## 兼容范围
|
|
8
|
+
|
|
9
|
+
- DSH `0.2.1-rc.5`,使用 Web profile。
|
|
10
|
+
- Node.js `^22.19.0 || >=24.0.0`,开发用 npm。此包与 DSH 共享会话、工具和远程调用接口,升级 DSH 时需要重新验证这些接口。
|
|
11
|
+
|
|
12
|
+
## 构建和打包
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm ci
|
|
16
|
+
npm run build
|
|
17
|
+
npm test
|
|
18
|
+
npm run pack:bundle
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`build` 从前端源码生成可由 DSH Web 加载的 `client.js`。`check` 校验生成物与源码一致,`pack:bundle` 会重新构建前端并生成 `dist/dsh-bettertaskgraphs-0.2.2.tgz`。包内包含服务端模块、Web 构建产物、对应源码和构建脚本,以及 Bundle 配置补丁。
|
|
22
|
+
|
|
23
|
+
## 安装到独立 Web profile
|
|
24
|
+
|
|
25
|
+
在本仓库目录下运行。以下命令使用名为 `bettertaskgraphs` 的新 profile。DSH 的 `--from-default-profile` 会重置同名 profile,已有该名称时请改用新名字。
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
dsh --profile bettertaskgraphs --from-default-profile web --dump-config
|
|
29
|
+
dsh plugin --profile bettertaskgraphs add "$PWD/dist/dsh-bettertaskgraphs-0.2.2.tgz"
|
|
30
|
+
dsh --profile bettertaskgraphs --dump-config
|
|
31
|
+
dsh --profile bettertaskgraphs
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
通过 `--dump-config` 可以确认已合成 `dsh-bettertaskgraphs` 配置层。若同一台机器已运行 Web,请为新 profile 选择一个空闲端口。用 `dsh plugin --profile bettertaskgraphs remove dsh-bettertaskgraphs` 移除该配置层。
|
|
35
|
+
|
|
36
|
+
## 工作区与已有任务图
|
|
37
|
+
|
|
38
|
+
Bundle 默认将会话创建时的工作目录作为项目根,账本位于根目录下的 `.dsh/transaction-graphs-v4/`。后续工作目录必须位于该根内,账本路径拒绝符号链接。也可以在 profile 的 `cordis.patch.yml` 中固定项目根。
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
- id: dsh-bettertaskgraphs
|
|
42
|
+
config:
|
|
43
|
+
projectRoot: /absolute/path/to/project
|
|
44
|
+
storageRelative: .dsh/transaction-graphs-v4
|
|
45
|
+
enableSwitch: false
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
替换原有的 `dsh-project-transaction-graph` 时,请在目标 profile 移除旧 Bundle 后再安装此包。两者使用同一组工具和会话投影键。新包沿用 V4 账本与会话事件格式。要接回已有任务图,须使用原会话、相同的项目根和账本目录,并沿用原先的 `enableSwitch` 设置。如果会话从项目子目录启动,请显式指定原项目根。
|
|
49
|
+
|
|
50
|
+
[DSH 官方 Bundle 打包文档](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.2.1-rc.5/docs/user/develop/basic/publish.zh.md)
|
|
51
|
+
|
|
52
|
+
## 许可证
|
|
53
|
+
|
|
54
|
+
Copyright (C) 2026 oldcitynight and contributors.
|
|
55
|
+
|
|
56
|
+
本插件按 GPL-3.0-only 授权。完整条款见 [LICENSE](./LICENSE)。
|
package/client.js
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Corresponding source: https://github.com/oldcitynight/dsh-bettertaskgraphs/tree/v0.2.2
|
|
3
|
+
// Generated by scripts/build-client.mjs.
|
|
4
|
+
window.__ModuleLoader__.load({id:"dsh-bettertaskgraphs",factory(require){
|
|
5
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
6
|
+
// Shared by the Host registration and the lazy browser module.
|
|
7
|
+
function createRemoteContribution(z) {
|
|
8
|
+
const packageName = 'dsh-bettertaskgraphs'
|
|
9
|
+
const id = z.string().min(1).max(128)
|
|
10
|
+
const input = z.object({ sessionId: id }).strict()
|
|
11
|
+
const node = z.object({
|
|
12
|
+
id, label: z.string().min(1).max(512), kind: z.enum(['container', 'leaf']),
|
|
13
|
+
parent: id.nullable(),
|
|
14
|
+
status: z.enum(['COMPLETE', 'CONTAINER_INCOMPLETE', 'ACTIVE', 'INACTIVE', 'INCOMPLETE', 'PENDING', 'SKIPPED']),
|
|
15
|
+
blocked: z.literal(true).optional(),
|
|
16
|
+
disposition: z.enum(['COMPLETED', 'SKIPPED_NOT_RUN', 'UNSATISFIED', 'FAILED']).optional(),
|
|
17
|
+
branchState: z.enum(['SELECTED', 'WAITING', 'NOT_SELECTED']).optional(),
|
|
18
|
+
selectedBranch: z.object({
|
|
19
|
+
outcome: z.enum(['complete', 'incomplete']), case: z.string().min(1).max(64).optional(), targetId: id.optional(),
|
|
20
|
+
}).strict().optional(),
|
|
21
|
+
}).strict()
|
|
22
|
+
const edge = z.discriminatedUnion('kind', [
|
|
23
|
+
z.object({ from: id, to: id, kind: z.literal('prerequisite') }).strict(),
|
|
24
|
+
z.object({ from: id, to: id, kind: z.literal('route'), outcome: z.enum(['complete', 'incomplete']) }).strict(),
|
|
25
|
+
z.object({ from: id, to: id, kind: z.literal('case'), case: z.string().min(1).max(64) }).strict(),
|
|
26
|
+
])
|
|
27
|
+
const choice = z.object({ id, choice: z.number().int().min(0).max(127) }).strict()
|
|
28
|
+
const graph = z.object({
|
|
29
|
+
revision: z.number().int().min(1).max(1024), title: z.string().min(1).max(512),
|
|
30
|
+
nodes: z.array(node).min(2).max(128), edges: z.array(edge).max(512),
|
|
31
|
+
active: z.object({ nodeId: id, recoveryRequired: z.boolean() }).strict().nullable(),
|
|
32
|
+
pending: z.object({ operation: z.string().min(1).max(64), qualification: z.string().min(1).max(128) }).strict().nullable(),
|
|
33
|
+
legalNext: z.array(choice).max(128), skipChoices: z.array(choice).max(128),
|
|
34
|
+
}).strict()
|
|
35
|
+
const output = z.discriminatedUnion('status', [
|
|
36
|
+
z.object({ sessionId: id, status: z.literal('ready'), graph }).strict(),
|
|
37
|
+
z.object({ sessionId: id, status: z.literal('empty'), graph: z.null() }).strict(),
|
|
38
|
+
z.object({ sessionId: id, status: z.literal('unavailable'), graph: z.null() }).strict(),
|
|
39
|
+
])
|
|
40
|
+
const codec = (key, schema) => ({ mode: 'strict', typeSymbol: `${packageName}#${key}`, create: () => schema })
|
|
41
|
+
return {
|
|
42
|
+
package: packageName,
|
|
43
|
+
descriptors: [{
|
|
44
|
+
id: `${packageName}#transactionGraphView/status`,
|
|
45
|
+
service: 'transactionGraph', namespace: 'transactionGraphView', method: 'status', invocation: { kind: 'direct' },
|
|
46
|
+
parameters: [{
|
|
47
|
+
name: 'request', wire: 'request', source: 'json',
|
|
48
|
+
codec: codec('TransactionGraphStatusRequest', input),
|
|
49
|
+
strictCodec: codec('TransactionGraphStatusStrictRequest', input),
|
|
50
|
+
}],
|
|
51
|
+
cancellation: { parameter: 'signal' },
|
|
52
|
+
result: codec('TransactionGraphStatusResult', output),
|
|
53
|
+
}],
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
58
|
+
// Only reduce a prerequisite that is already implied by a chain of leaf
|
|
59
|
+
// prerequisites. Entering a conditional task can change branch participation.
|
|
60
|
+
function chainEdges(graph) {
|
|
61
|
+
const leaves = new Set(graph.nodes.filter(node => node.kind === 'leaf').map(node => node.id))
|
|
62
|
+
const prerequisites = new Map()
|
|
63
|
+
const conditional = new Set()
|
|
64
|
+
for (const edge of graph.edges) {
|
|
65
|
+
if (edge.kind !== 'prerequisite') {
|
|
66
|
+
conditional.add(edge.to)
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
const sources = prerequisites.get(edge.to) ?? []
|
|
70
|
+
sources.push(edge.from)
|
|
71
|
+
prerequisites.set(edge.to, sources)
|
|
72
|
+
}
|
|
73
|
+
return graph.edges.filter(edge => {
|
|
74
|
+
if (edge.kind !== 'prerequisite' || !leaves.has(edge.from)) return true
|
|
75
|
+
const sources = prerequisites.get(edge.to)
|
|
76
|
+
if (sources.length < 2) return true
|
|
77
|
+
const pending = sources.filter(id => id !== edge.from)
|
|
78
|
+
const seen = new Set()
|
|
79
|
+
while (pending.length) {
|
|
80
|
+
const id = pending.pop()
|
|
81
|
+
if (id === edge.from) return false
|
|
82
|
+
if (!leaves.has(id) || conditional.has(id) || seen.has(id)) continue
|
|
83
|
+
seen.add(id)
|
|
84
|
+
pending.push(...(prerequisites.get(id) ?? []))
|
|
85
|
+
}
|
|
86
|
+
return true
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Host supplies legal choices. The browser only organizes them for display.
|
|
91
|
+
function graphPresentation(graph, selectedId) {
|
|
92
|
+
const nodes = new Map(graph.nodes.map(node => [node.id, node]))
|
|
93
|
+
const children = new Map()
|
|
94
|
+
for (const node of graph.nodes) {
|
|
95
|
+
const siblings = children.get(node.parent) ?? []
|
|
96
|
+
siblings.push(node)
|
|
97
|
+
children.set(node.parent, siblings)
|
|
98
|
+
}
|
|
99
|
+
const active = graph.active ? nodes.get(graph.active.nodeId) ?? null : null
|
|
100
|
+
const legal = new Set(graph.legalNext.map(choice => choice.id))
|
|
101
|
+
const selected = nodes.get(selectedId) ?? active ?? nodes.get(graph.legalNext[0]?.id) ?? children.get(null)?.[0] ?? null
|
|
102
|
+
const conditionIds = new Set(chainEdges(graph).filter(edge => edge.to === selected?.id).map(edge => edge.from))
|
|
103
|
+
const conditions = [...conditionIds].map(id => nodes.get(id)).filter(Boolean)
|
|
104
|
+
const allLeaves = graph.nodes.filter(node => node.kind === 'leaf')
|
|
105
|
+
const leaves = allLeaves.filter(node => node.branchState !== 'NOT_SELECTED')
|
|
106
|
+
const count = status => leaves.filter(node => node.status === status).length
|
|
107
|
+
return {
|
|
108
|
+
nodes, children, active, legal, selected, conditions,
|
|
109
|
+
progress: {
|
|
110
|
+
total: leaves.length, complete: count('COMPLETE'), incomplete: count('INCOMPLETE'),
|
|
111
|
+
skipped: count('SKIPPED'), inactive: count('INACTIVE'), excluded: allLeaves.length - leaves.length, ready: legal.size,
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function branchEdgeState(edge, source, target) {
|
|
117
|
+
if (source.branchState === 'NOT_SELECTED' || target.branchState === 'NOT_SELECTED') return 'branchExcluded'
|
|
118
|
+
if (edge.kind === 'prerequisite') {
|
|
119
|
+
if (source.status === 'COMPLETE') return 'prerequisiteMet'
|
|
120
|
+
if (source.status === 'INCOMPLETE' || source.status === 'SKIPPED' || source.blocked) return 'blocked'
|
|
121
|
+
return 'edgeWaiting'
|
|
122
|
+
}
|
|
123
|
+
const selected = source.selectedBranch
|
|
124
|
+
if (selected?.targetId === edge.to && (edge.kind === 'route' ? selected.outcome === edge.outcome : selected.case === edge.case))
|
|
125
|
+
return 'branchChosen'
|
|
126
|
+
return selected || source.status === 'SKIPPED' ? 'branchExcluded' : 'edgeWaiting'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Rank by explicit dependencies. Containers stay in the tree unless they
|
|
130
|
+
// participate in a dependency. A leaf still shows which container it belongs to.
|
|
131
|
+
function branchLayout(graph) {
|
|
132
|
+
const nodesById = new Map(graph.nodes.map(node => [node.id, node]))
|
|
133
|
+
const edges = chainEdges(graph).filter(edge => nodesById.has(edge.from) && nodesById.has(edge.to))
|
|
134
|
+
const connected = new Set(edges.flatMap(edge => [edge.from, edge.to]))
|
|
135
|
+
const nodes = graph.nodes.filter(node => node.kind === 'leaf' || connected.has(node.id))
|
|
136
|
+
const outgoing = new Map(nodes.map(node => [node.id, []]))
|
|
137
|
+
const incoming = new Map(nodes.map(node => [node.id, []]))
|
|
138
|
+
for (const edge of edges) {
|
|
139
|
+
outgoing.get(edge.from).push(edge)
|
|
140
|
+
incoming.get(edge.to).push(edge)
|
|
141
|
+
}
|
|
142
|
+
const incomingCount = new Map(nodes.map(node => [node.id, incoming.get(node.id).length]))
|
|
143
|
+
const rank = new Map(nodes.map(node => [node.id, 0]))
|
|
144
|
+
const queue = nodes.filter(node => incomingCount.get(node.id) === 0).map(node => node.id)
|
|
145
|
+
for (const id of queue) {
|
|
146
|
+
for (const { to: target } of outgoing.get(id)) {
|
|
147
|
+
rank.set(target, Math.max(rank.get(target), rank.get(id) + 1))
|
|
148
|
+
const remaining = incomingCount.get(target) - 1
|
|
149
|
+
incomingCount.set(target, remaining)
|
|
150
|
+
if (remaining === 0) queue.push(target)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const columns = Array.from({ length: 1 + Math.max(0, ...rank.values()) }, () => [])
|
|
154
|
+
for (const node of nodes) columns[rank.get(node.id)].push(node)
|
|
155
|
+
|
|
156
|
+
const padding = 26, cardWidth = 194, cardHeight = 84, columnGap = 104, rowGap = 26
|
|
157
|
+
const rows = Math.max(1, ...columns.map(column => column.length))
|
|
158
|
+
const bodyHeight = rows * (cardHeight + rowGap) - rowGap
|
|
159
|
+
const cards = columns.flatMap((column, index) => column.map((node, row) => ({
|
|
160
|
+
node,
|
|
161
|
+
group: nodesById.get(node.parent)?.label,
|
|
162
|
+
column: index,
|
|
163
|
+
additionalPrerequisites: 0,
|
|
164
|
+
x: padding + index * (cardWidth + columnGap),
|
|
165
|
+
y: padding + (rows - column.length) * (cardHeight + rowGap) / 2 + row * (cardHeight + rowGap),
|
|
166
|
+
})))
|
|
167
|
+
const cardById = new Map(cards.map(card => [card.node.id, card]))
|
|
168
|
+
const drawnEdges = new Set(edges.filter(edge => {
|
|
169
|
+
const source = cardById.get(edge.from), target = cardById.get(edge.to)
|
|
170
|
+
if (edge.kind !== 'prerequisite' || target.column === source.column + 1) return true
|
|
171
|
+
// The entry list still shows independent prerequisites that skip a column.
|
|
172
|
+
target.additionalPrerequisites += 1
|
|
173
|
+
return false
|
|
174
|
+
}))
|
|
175
|
+
for (const [id, group] of outgoing)
|
|
176
|
+
outgoing.set(id, group.filter(edge => drawnEdges.has(edge)).sort((a, b) => cardById.get(a.to).y - cardById.get(b.to).y))
|
|
177
|
+
for (const [id, group] of incoming)
|
|
178
|
+
incoming.set(id, group.filter(edge => drawnEdges.has(edge)).sort((a, b) => cardById.get(a.from).y - cardById.get(b.from).y))
|
|
179
|
+
const portOffset = (group, edge) => (group.indexOf(edge) - (group.length - 1) / 2) * Math.min(13, 52 / group.length)
|
|
180
|
+
const bendOffset = (group, edge) => 12 + 28 * (group.indexOf(edge) + 1) / (group.length + 1)
|
|
181
|
+
const pairKey = edge => JSON.stringify([edge.from, edge.to])
|
|
182
|
+
const parallel = new Map()
|
|
183
|
+
for (const edge of drawnEdges) {
|
|
184
|
+
const pair = pairKey(edge)
|
|
185
|
+
parallel.set(pair, (parallel.get(pair) ?? 0) + 1)
|
|
186
|
+
}
|
|
187
|
+
let laneCount = 0
|
|
188
|
+
const wires = [...drawnEdges].map(edge => {
|
|
189
|
+
const from = cardById.get(edge.from), to = cardById.get(edge.to)
|
|
190
|
+
const exits = outgoing.get(edge.from), entries = incoming.get(edge.to)
|
|
191
|
+
const startX = from.x + cardWidth, endX = to.x - 2
|
|
192
|
+
const startY = from.y + cardHeight / 2 + portOffset(exits, edge)
|
|
193
|
+
const endY = to.y + cardHeight / 2 + portOffset(entries, edge)
|
|
194
|
+
const state = branchEdgeState(edge, from.node, to.node)
|
|
195
|
+
const wire = { edge, from: from.node, to: to.node, state,
|
|
196
|
+
key: JSON.stringify([edge.from, edge.to, edge.kind, edge.outcome ?? edge.case]),
|
|
197
|
+
showLabel: edge.kind !== 'prerequisite' && (parallel.get(pairKey(edge)) === 1 || state === 'branchChosen') }
|
|
198
|
+
if (to.column - from.column > 1) {
|
|
199
|
+
// Route rank-skipping edges below the cards so they do not cross a task.
|
|
200
|
+
const laneY = padding + bodyHeight + 22 + laneCount++ * 16
|
|
201
|
+
const exitX = startX + bendOffset(exits, edge), entryX = endX - bendOffset(entries, edge)
|
|
202
|
+
return { ...wire, path: `M ${startX} ${startY} H ${exitX} V ${laneY} H ${entryX} V ${endY} H ${endX}`,
|
|
203
|
+
labelX: (startX + endX) / 2, labelY: laneY - 5 }
|
|
204
|
+
}
|
|
205
|
+
return { ...wire, path: `M ${startX} ${startY} C ${startX + 40} ${startY}, ${endX - 40} ${endY}, ${endX} ${endY}`,
|
|
206
|
+
labelX: (startX + endX) / 2, labelY: (startY + endY) / 2 - 7 }
|
|
207
|
+
})
|
|
208
|
+
return {
|
|
209
|
+
cards, wires, cardWidth, cardHeight,
|
|
210
|
+
width: columns.length * (cardWidth + columnGap) - columnGap + 2 * padding,
|
|
211
|
+
height: bodyHeight + 2 * padding + laneCount * 16 + (laneCount ? 12 : 0),
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
216
|
+
// Loaded in the existing DSH browser seat, without a separate React root.
|
|
217
|
+
function createClientPlugin(React, z, createRemoteContribution, graphPresentation, branchLayout) {
|
|
218
|
+
const { createElement: h, useEffect, useId, useMemo, useRef, useState } = React
|
|
219
|
+
const locale = {
|
|
220
|
+
zh: {
|
|
221
|
+
title: '事务图', progress: '任务成功 {done}/{total}', active: '当前事务', ready: '可开始', noReady: '暂无可开始事务',
|
|
222
|
+
retry: '刷新', unavailable: '暂时无法读取事务图', stale: '显示上次读取的状态', recovering: '当前事务等待恢复对账',
|
|
223
|
+
pending: '状态更新待对账', waiting: '暂时没有可执行步骤,等待当前状态核对',
|
|
224
|
+
tree: '事务层级', details: '事务关系', incoming: '进入条件:', conditionSeparator: ':',
|
|
225
|
+
noEdges: '无进入条件',
|
|
226
|
+
complete: '完成', inProgress: '执行中', pendingNode: '待执行', inactive: '分支尚未激活', incomplete: '未完成',
|
|
227
|
+
branchWaiting: '等待分支选择', branchExcluded: '未选分支', branchChosen: '已选路径', graphComplete: '选中路径已完成',
|
|
228
|
+
skipped: '跳过,未执行', failed: '失败', blocked: '受阻', unsatisfied: '未满足', grouped: '组内尚有未完成',
|
|
229
|
+
fullscreen: '全屏', exitFullscreen: '退出全屏', branchGraph: '事务分支图',
|
|
230
|
+
prerequisiteMet: '前置已满足', edgeWaiting: '等待条件',
|
|
231
|
+
additionalPrerequisites: '另有 {count} 项前置',
|
|
232
|
+
requires: '前置完成后', onComplete: '完成时', onIncomplete: '未完成时', onCase: '分支 {value}',
|
|
233
|
+
totalIncomplete: '{count} 项未完成', totalSkipped: '{count} 项跳过', totalInactive: '{count} 项分支未激活',
|
|
234
|
+
totalExcluded: '{count} 项属于未选分支',
|
|
235
|
+
revision: '版本 {revision}',
|
|
236
|
+
},
|
|
237
|
+
en: {
|
|
238
|
+
title: 'Transaction graph', progress: '{done}/{total} tasks successful', active: 'Current transaction', ready: 'Ready to start',
|
|
239
|
+
noReady: 'No transaction ready to start', retry: 'Refresh', unavailable: 'Transaction graph is unavailable', stale: 'Showing last known state',
|
|
240
|
+
recovering: 'Current transaction awaits reconciliation', pending: 'Status update awaits reconciliation',
|
|
241
|
+
waiting: 'Execution is on hold until the current state is reconciled',
|
|
242
|
+
tree: 'Transaction hierarchy', details: 'Transaction relationships', incoming: 'Entry conditions:', conditionSeparator: ': ',
|
|
243
|
+
noEdges: 'No entry conditions',
|
|
244
|
+
complete: 'Complete', inProgress: 'In progress', pendingNode: 'Pending', inactive: 'Branch not activated',
|
|
245
|
+
incomplete: 'Incomplete', skipped: 'Skipped', failed: 'Failed', blocked: 'Blocked', unsatisfied: 'Unsatisfied', grouped: 'Group incomplete',
|
|
246
|
+
fullscreen: 'Full screen', exitFullscreen: 'Exit full screen', branchGraph: 'Transaction branch graph',
|
|
247
|
+
prerequisiteMet: 'Prerequisite met', edgeWaiting: 'Awaiting condition',
|
|
248
|
+
additionalPrerequisites: '+{count} prerequisites',
|
|
249
|
+
branchWaiting: 'Awaiting branch selection', branchExcluded: 'Branch not selected',
|
|
250
|
+
branchChosen: 'Selected path', graphComplete: 'Selected paths complete',
|
|
251
|
+
requires: 'After prerequisite', onComplete: 'On completion', onIncomplete: 'On incomplete', onCase: 'Case {value}',
|
|
252
|
+
totalIncomplete: '{count} incomplete', totalSkipped: '{count} skipped', totalInactive: '{count} inactive branches',
|
|
253
|
+
totalExcluded: '{count} on unselected paths',
|
|
254
|
+
revision: 'Revision {revision}',
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
const statusLabels = {
|
|
258
|
+
ACTIVE: 'inProgress', COMPLETE: 'complete', CONTAINER_INCOMPLETE: 'grouped', INACTIVE: 'inactive',
|
|
259
|
+
INCOMPLETE: 'incomplete', PENDING: 'pendingNode', SKIPPED: 'skipped',
|
|
260
|
+
}
|
|
261
|
+
const dispositions = { FAILED: 'failed', UNSATISFIED: 'unsatisfied', SKIPPED_NOT_RUN: 'skipped' }
|
|
262
|
+
const styles = `
|
|
263
|
+
.tg-dock{--tg-border:var(--dsw-alias-border-l1,#8885);--tg-panel:var(--dsw-specific-menu,Canvas);--tg-muted:var(--dsw-alias-label-secondary,inherit);container-type:inline-size;box-sizing:border-box;width:calc(100% - 24px);max-width:var(--dsh-composer-card-max-width,860px);margin:0 auto;border:1px solid var(--tg-border);border-radius:var(--dsw-radius-lg,12px);background:var(--tg-panel);color:var(--dsw-alias-label-primary,CanvasText);font:inherit}
|
|
264
|
+
.tg-dock button{font:inherit;color:inherit;cursor:pointer}
|
|
265
|
+
.tg-dock button:focus-visible,.tg-canvas-scroll:focus-visible{outline:2px solid #4f91ed;outline-offset:-2px}
|
|
266
|
+
.tg-top{display:flex;align-items:center;gap:3px;padding-inline-end:8px}
|
|
267
|
+
.tg-header{display:flex;align-items:center;gap:10px;flex:1 1 auto;padding:7px 10px;border:0;border-radius:8px;background:transparent;text-align:left;font-size:13px!important;min-width:0}
|
|
268
|
+
.tg-heading{font-weight:600;flex:none}.tg-summary{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--tg-muted)}.tg-count{flex:none;font-size:12px;color:var(--tg-muted)}
|
|
269
|
+
.tg-chevron{display:inline-block;flex:none;transition:transform 180ms ease}.tg-header[aria-expanded=true] .tg-chevron{transform:rotate(180deg)}
|
|
270
|
+
.tg-fold{display:grid;grid-template-rows:0fr;opacity:0;transition:grid-template-rows 180ms ease,opacity 150ms ease}
|
|
271
|
+
.tg-fold[data-expanded=true]{grid-template-rows:1fr;opacity:1}.tg-fold-clip{min-height:0;overflow:hidden}
|
|
272
|
+
.tg-body{padding:0 12px 10px;max-height:min(46vh,440px);overflow:auto;overscroll-behavior:contain}.tg-overview{display:flex;align-items:center;gap:8px;justify-content:space-between;margin:4px 0 7px;overflow-wrap:anywhere}
|
|
273
|
+
.tg-overview h3,.tg-area h4,.tg-condition-heading{font-size:13px;margin:0;font-weight:600}
|
|
274
|
+
.tg-actions{display:flex;align-items:center;gap:7px;flex:none;font-size:11px}
|
|
275
|
+
.tg-dock .tg-control{border:1px solid var(--tg-border);padding:3px 7px;border-radius:6px;background:transparent;font-size:12px;flex:none}
|
|
276
|
+
.tg-control:disabled{cursor:default;opacity:.6}
|
|
277
|
+
.tg-notice{padding:6px 8px;background:#d78c0c20;border-radius:6px;margin:4px 0;font-size:12px;overflow-wrap:anywhere}
|
|
278
|
+
.tg-progress,.tg-ready{display:flex;flex-wrap:wrap;gap:5px 12px;margin:5px 0;font-size:12px;align-items:baseline}.tg-progress{color:var(--tg-muted)}
|
|
279
|
+
.tg-ready button,.tg-link-button{border:0;background:transparent;padding:0;text-align:left;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:2px;overflow-wrap:anywhere;max-width:100%}
|
|
280
|
+
.tg-grid{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:8px;margin-top:8px}
|
|
281
|
+
.tg-area{min-width:0;padding:8px;border:1px solid var(--tg-border);border-radius:8px;overflow-wrap:anywhere}
|
|
282
|
+
.tg-area h4{margin-bottom:6px}
|
|
283
|
+
.tg-tree,.tg-tree ol{list-style:none;margin:0;padding:0}.tg-tree ol{margin-inline-start:8px;padding-inline-start:8px;border-inline-start:1px solid var(--tg-border)}
|
|
284
|
+
.tg-node{display:flex;align-items:baseline;gap:5px;flex-wrap:wrap;width:100%;text-align:left;border:0;border-radius:5px;background:transparent;padding:4px 3px;font-size:12px!important;overflow-wrap:anywhere;transition:background-color 140ms ease}
|
|
285
|
+
.tg-node:hover,.tg-node[aria-pressed=true]{background:#4f91ed21}.tg-node[aria-current=step]{outline:1px solid #4f91ed;outline-offset:-1px}.tg-node-name{min-width:0;max-width:100%;overflow-wrap:anywhere}.tg-node[data-kind=container] .tg-node-name{font-weight:650}
|
|
286
|
+
.tg-badge{padding:0 4px;border-radius:4px;background:#8882;font-size:10px;line-height:16px}
|
|
287
|
+
.tg-badge[data-state=complete]{background:#3399682c}.tg-badge[data-state=inProgress]{background:#4f91ed35}
|
|
288
|
+
.tg-badge:is([data-state=blocked],[data-state=incomplete],[data-state=unsatisfied]){background:#d78c0c2c}
|
|
289
|
+
.tg-badge[data-state=failed]{background:#d452522f}
|
|
290
|
+
.tg-current,.tg-conditions{font-size:12px;line-height:1.4;overflow-wrap:anywhere}.tg-current{margin-bottom:7px}.tg-current .tg-badge{margin-inline-start:6px}
|
|
291
|
+
.tg-conditions{list-style:decimal;margin:4px 0 0;padding-inline-start:1.7em}.tg-conditions li+li{margin-top:4px}.tg-no-conditions{margin:4px 0 0;color:var(--tg-muted);font-size:12px}
|
|
292
|
+
.tg-error-only{padding:6px 9px;font-size:12px}
|
|
293
|
+
|
|
294
|
+
.tg-viewer{
|
|
295
|
+
--tg-panel:var(--dsw-alias-bg-base,Canvas);
|
|
296
|
+
--tg-card-fill:color-mix(in srgb,var(--tg-panel),currentColor 7%);
|
|
297
|
+
--tg-card-border:color-mix(in srgb,var(--tg-panel),currentColor 32%);
|
|
298
|
+
--tg-complete:light-dark(#287552,#6acda0);
|
|
299
|
+
--tg-active:light-dark(#296ac4,#80b2ff);
|
|
300
|
+
--tg-blocked:light-dark(#a36119,#edab5b);
|
|
301
|
+
--tg-failed:light-dark(#aa4242,#fa8585);
|
|
302
|
+
box-sizing:border-box;position:fixed;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;padding:14px 18px;border:0;overflow:hidden;background:linear-gradient(var(--tg-panel),var(--tg-panel)),Canvas;color:inherit;display:grid;grid-template-rows:auto auto minmax(0,1fr) auto;gap:10px;opacity:0;transform:translateY(8px) scale(.995);transition:opacity 180ms ease,transform 180ms ease,display 180ms allow-discrete,overlay 180ms allow-discrete
|
|
303
|
+
}
|
|
304
|
+
.tg-viewer:not([open]){display:none}.tg-viewer[open]{opacity:1;transform:none}
|
|
305
|
+
@starting-style{.tg-viewer[open]{opacity:0;transform:translateY(8px) scale(.995)}}
|
|
306
|
+
.tg-viewer-header{display:flex;align-items:center;justify-content:space-between;gap:10px 20px;flex-wrap:wrap}
|
|
307
|
+
.tg-viewer-header h2{margin:2px 0;font-size:20px;overflow-wrap:anywhere}.tg-viewer-eyebrow{color:var(--tg-muted);font-size:12px}
|
|
308
|
+
.tg-viewer-header .tg-actions{flex:0 1 auto;flex-wrap:wrap}.tg-viewer .tg-area{max-height:min(28vh,210px);overflow:auto}
|
|
309
|
+
.tg-legend{display:flex;flex-wrap:wrap;gap:5px 16px;margin:0;padding:0;list-style:none;color:var(--tg-muted);font-size:12px}
|
|
310
|
+
.tg-legend li{display:flex;align-items:center;gap:5px}
|
|
311
|
+
.tg-wire,.tg-legend-mark{--tg-line:light-dark(#676d76,#b0b8c6);--tg-line-opacity:.78}
|
|
312
|
+
:is(.tg-wire,.tg-legend-mark)[data-state=branchChosen]{--tg-line:var(--tg-active);--tg-line-opacity:1}
|
|
313
|
+
:is(.tg-wire,.tg-legend-mark)[data-state=prerequisiteMet]{--tg-line:var(--tg-complete);--tg-line-opacity:1}
|
|
314
|
+
:is(.tg-wire,.tg-legend-mark)[data-state=blocked]{--tg-line:var(--tg-blocked);--tg-line-opacity:1}
|
|
315
|
+
:is(.tg-wire,.tg-legend-mark)[data-state=branchExcluded]{--tg-line-opacity:.62}
|
|
316
|
+
.tg-legend-mark{width:16px;border-top:2px solid var(--tg-line);opacity:var(--tg-line-opacity)}
|
|
317
|
+
.tg-legend-mark[data-state=branchExcluded]{border-top-style:dashed}
|
|
318
|
+
.tg-canvas-scroll{display:flex;min-height:0;overflow:auto;overscroll-behavior:contain;border:1px solid var(--tg-border);border-radius:10px;background-color:color-mix(in srgb,var(--tg-panel),currentColor 2%);background-image:radial-gradient(var(--tg-border) .65px,transparent .65px);background-size:15px 15px}
|
|
319
|
+
.tg-canvas{flex:none;position:relative;margin:auto}.tg-wires{position:absolute;inset:0;pointer-events:none}
|
|
320
|
+
.tg-wire{fill:none;stroke:var(--tg-line);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;opacity:var(--tg-line-opacity);pointer-events:stroke}
|
|
321
|
+
.tg-wire[data-state=branchExcluded]{stroke-dasharray:5 5}.tg-wire[data-state=branchChosen]{stroke-width:2.6}
|
|
322
|
+
.tg-wire-label{fill:currentColor;stroke:var(--tg-panel);stroke-width:4;stroke-linejoin:round;paint-order:stroke;font-size:11px}
|
|
323
|
+
.tg-dock .tg-card{--tg-card-accent:var(--tg-card-border);box-sizing:border-box;position:absolute;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:3px;padding:5px 9px;overflow:hidden;text-align:left;border:1px solid var(--tg-card-border);border-inline-start:4px solid var(--tg-card-accent);border-radius:9px;background:var(--tg-card-fill);box-shadow:0 2px 5px #0002;transition:background-color 140ms ease,border-color 140ms ease}
|
|
324
|
+
.tg-dock .tg-card[data-state=complete]{--tg-card-accent:var(--tg-complete)}
|
|
325
|
+
.tg-dock .tg-card[data-state=failed]{--tg-card-accent:var(--tg-failed)}
|
|
326
|
+
.tg-dock .tg-card[data-state=blocked]{--tg-card-accent:var(--tg-blocked)}
|
|
327
|
+
.tg-dock .tg-card[data-state=inProgress]{--tg-card-accent:var(--tg-active)}
|
|
328
|
+
.tg-dock .tg-card[data-state=branchExcluded]{border-style:dashed;box-shadow:none}
|
|
329
|
+
.tg-dock .tg-card:hover{background:color-mix(in srgb,var(--tg-card-fill),var(--tg-active) 8%)}
|
|
330
|
+
.tg-dock .tg-card[aria-pressed=true]{background:color-mix(in srgb,var(--tg-card-fill),var(--tg-active) 15%);border-color:var(--tg-active);border-inline-start-color:var(--tg-card-accent);box-shadow:0 0 0 1px var(--tg-active)}
|
|
331
|
+
.tg-card-title{font-size:14px;line-height:1.2;font-weight:650;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}
|
|
332
|
+
.tg-card-group{font-size:11px;line-height:1.2;color:var(--tg-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
|
333
|
+
.tg-card-states{display:flex;gap:5px;max-width:100%;white-space:nowrap}.tg-card-states .tg-badge{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
334
|
+
.tg-card-states .tg-badge[data-state]{font-size:11px;line-height:18px;background:color-mix(in srgb,var(--tg-card-fill),var(--tg-card-accent) 20%)}
|
|
335
|
+
@container (max-width:580px){.tg-grid{grid-template-columns:minmax(0,1fr)}.tg-tree ol{margin-inline-start:3px;padding-inline-start:4px}}
|
|
336
|
+
@container (max-width:250px){.tg-top,.tg-header,.tg-overview,.tg-actions{flex-wrap:wrap}.tg-top{padding:4px;gap:3px}.tg-header{padding:3px;gap:4px}.tg-summary{flex-basis:100%;order:1}.tg-count{flex:1 1 auto;min-width:0;overflow-wrap:anywhere}.tg-body{padding-inline:4px}.tg-area{padding:4px}.tg-tree ol{margin-inline-start:1px;padding-inline-start:2px}.tg-actions{flex:0 1 auto;min-width:0}}
|
|
337
|
+
@media(max-width:580px){.tg-viewer{padding:8px;gap:7px}.tg-viewer-header h2{font-size:16px}}
|
|
338
|
+
@media(prefers-reduced-motion:reduce){.tg-viewer,.tg-fold,.tg-chevron,.tg-node,.tg-dock .tg-card{transition:none}}
|
|
339
|
+
`
|
|
340
|
+
|
|
341
|
+
const contribution = createRemoteContribution(z)
|
|
342
|
+
const statusResult = contribution.descriptors[0].result.create()
|
|
343
|
+
function nodeState(node) {
|
|
344
|
+
if (node.branchState === 'NOT_SELECTED') return 'branchExcluded'
|
|
345
|
+
if (node.branchState === 'WAITING' && !node.disposition) return 'branchWaiting'
|
|
346
|
+
return dispositions[node.disposition] ?? (node.blocked ? 'blocked' : statusLabels[node.status])
|
|
347
|
+
}
|
|
348
|
+
function badge(node, t) {
|
|
349
|
+
const state = nodeState(node)
|
|
350
|
+
return h('span', { className: 'tg-badge', 'data-state': state }, t(state))
|
|
351
|
+
}
|
|
352
|
+
function edgeText(edge, t) {
|
|
353
|
+
if (edge.kind === 'prerequisite') return t('requires')
|
|
354
|
+
if (edge.kind === 'case') return t('onCase', { value: edge.case })
|
|
355
|
+
return t(edge.outcome === 'complete' ? 'onComplete' : 'onIncomplete')
|
|
356
|
+
}
|
|
357
|
+
function Conditions({ selected, conditions, t, onSelect }) {
|
|
358
|
+
if (!selected) return null
|
|
359
|
+
return h('section', { className: 'tg-area', 'aria-label': t('details') },
|
|
360
|
+
h('h4', null, t('details')),
|
|
361
|
+
h('div', { className: 'tg-current' }, h('strong', null, selected.label), badge(selected, t)),
|
|
362
|
+
h('h5', { className: 'tg-condition-heading' }, t('incoming')),
|
|
363
|
+
conditions.length ? h('ol', { className: 'tg-conditions' }, conditions.map(node =>
|
|
364
|
+
h('li', { key: node.id },
|
|
365
|
+
h('button', { type: 'button', className: 'tg-link-button', onClick: () => onSelect(node.id) }, node.label),
|
|
366
|
+
t('conditionSeparator'), t(nodeState(node))))) :
|
|
367
|
+
h('p', { className: 'tg-no-conditions' }, t('noEdges')))
|
|
368
|
+
}
|
|
369
|
+
function BranchCanvas({ graph, selected, active, t, onSelect }) {
|
|
370
|
+
const layout = useMemo(() => branchLayout(graph), [graph])
|
|
371
|
+
const arrowId = useId()
|
|
372
|
+
const wires = layout.wires.map(wire => h('g', { key: wire.key },
|
|
373
|
+
h('path', { className: 'tg-wire', 'data-state': wire.state, d: wire.path, markerEnd: `url(#${arrowId})` },
|
|
374
|
+
h('title', null, `${wire.from.label} → ${wire.to.label} · ${edgeText(wire.edge, t)} · ${t(wire.state)}`)),
|
|
375
|
+
wire.showLabel && h('text', { className: 'tg-wire-label', x: wire.labelX, y: wire.labelY, textAnchor: 'middle' },
|
|
376
|
+
edgeText(wire.edge, t))))
|
|
377
|
+
const cards = layout.cards.map(({ node, group, x, y, additionalPrerequisites }) => h('button', {
|
|
378
|
+
key: node.id, type: 'button', className: 'tg-card', 'data-state': nodeState(node),
|
|
379
|
+
'aria-pressed': selected?.id === node.id, 'aria-current': active?.id === node.id ? 'step' : undefined,
|
|
380
|
+
style: { left: x, top: y, width: layout.cardWidth, height: layout.cardHeight },
|
|
381
|
+
onClick: () => onSelect(node.id), title: node.label,
|
|
382
|
+
}, group && h('span', { className: 'tg-card-group' }, group),
|
|
383
|
+
h('strong', { className: 'tg-card-title' }, node.label),
|
|
384
|
+
h('span', { className: 'tg-card-states' }, badge(node, t),
|
|
385
|
+
additionalPrerequisites > 0 && h('span', { className: 'tg-badge' },
|
|
386
|
+
t('additionalPrerequisites', { count: additionalPrerequisites })))))
|
|
387
|
+
return h('div', { className: 'tg-canvas-scroll', tabIndex: 0, role: 'region', 'aria-label': t('branchGraph') },
|
|
388
|
+
h('div', { className: 'tg-canvas', style: { width: layout.width, height: layout.height } },
|
|
389
|
+
h('svg', { className: 'tg-wires', 'aria-hidden': true, width: layout.width, height: layout.height },
|
|
390
|
+
h('defs', null, h('marker', { id: arrowId, viewBox: '0 0 8 8', refX: 7, refY: 4, markerWidth: 5, markerHeight: 5, orient: 'auto' },
|
|
391
|
+
h('path', { d: 'M 0 0 L 8 4 L 0 8 Z', fill: 'context-stroke' }))),
|
|
392
|
+
wires),
|
|
393
|
+
cards))
|
|
394
|
+
}
|
|
395
|
+
function GraphPanel({ graph, t, busy, failed, refresh }) {
|
|
396
|
+
const [expanded, setExpanded] = useState(false)
|
|
397
|
+
const [selectedId, setSelected] = useState(null)
|
|
398
|
+
const [viewerVisited, setViewerVisited] = useState(false)
|
|
399
|
+
const viewer = useRef(null)
|
|
400
|
+
const bodyId = useId()
|
|
401
|
+
const viewerId = useId()
|
|
402
|
+
const viewerTitleId = useId()
|
|
403
|
+
const model = graphPresentation(graph, selectedId)
|
|
404
|
+
const { active, selected, progress } = model
|
|
405
|
+
let current = t('noReady')
|
|
406
|
+
if (active) current = `${t(graph.active.recoveryRequired ? 'recovering' : 'active')} · ${active.label}`
|
|
407
|
+
else if (graph.pending) current = t('pending')
|
|
408
|
+
else if (model.children.get(null)?.[0]?.status === 'COMPLETE') current = t('graphComplete')
|
|
409
|
+
else if (progress.ready) current = `${t('ready')} · ${progress.ready}`
|
|
410
|
+
const title = failed ? `${t('stale')} · ${current}` : current
|
|
411
|
+
const progressText = t('progress', { done: progress.complete, total: progress.total })
|
|
412
|
+
const conditionProps = { selected, conditions: model.conditions, t, onSelect: setSelected }
|
|
413
|
+
const refreshButton = () => h('button', { className: 'tg-control', type: 'button', disabled: busy, onClick: refresh }, t('retry'))
|
|
414
|
+
const notices = () => h(React.Fragment, null,
|
|
415
|
+
failed && h('p', { className: 'tg-notice', role: 'status' }, t('unavailable')),
|
|
416
|
+
graph.pending && h('p', { className: 'tg-notice', role: 'status' }, t('waiting')))
|
|
417
|
+
function openViewer() {
|
|
418
|
+
const dialog = viewer.current
|
|
419
|
+
if (dialog.open) return
|
|
420
|
+
setViewerVisited(true)
|
|
421
|
+
dialog.showModal()
|
|
422
|
+
dialog.querySelector('[data-tg-close]').focus()
|
|
423
|
+
}
|
|
424
|
+
function branch(parent, depth = 0) {
|
|
425
|
+
const rows = model.children.get(parent)
|
|
426
|
+
if (!rows || depth >= 16) return null
|
|
427
|
+
return h('ol', { className: parent === null ? 'tg-tree' : undefined }, rows.map(node =>
|
|
428
|
+
h('li', { key: node.id },
|
|
429
|
+
h('button', { className: 'tg-node', type: 'button', 'data-kind': node.kind,
|
|
430
|
+
'aria-pressed': selected?.id === node.id,
|
|
431
|
+
'aria-current': active?.id === node.id ? 'step' : undefined,
|
|
432
|
+
onClick: () => setSelected(node.id) },
|
|
433
|
+
h('span', { className: 'tg-node-name' }, node.label), badge(node, t),
|
|
434
|
+
model.legal.has(node.id) && h('span', { className: 'tg-badge' }, t('ready'))),
|
|
435
|
+
branch(node.id, depth + 1))))
|
|
436
|
+
}
|
|
437
|
+
return h('section', { className: 'tg-dock', 'data-testid': 'transaction-graph-dock', 'aria-label': t('title') },
|
|
438
|
+
h('style', null, styles),
|
|
439
|
+
h('div', { className: 'tg-top' },
|
|
440
|
+
h('button', { type: 'button', className: 'tg-header', 'aria-expanded': expanded, 'aria-controls': bodyId,
|
|
441
|
+
onClick: () => setExpanded(value => !value) },
|
|
442
|
+
h('span', { className: 'tg-heading' }, t('title')),
|
|
443
|
+
h('span', { className: 'tg-summary', title, role: 'status' }, title),
|
|
444
|
+
h('span', { className: 'tg-count' }, progressText),
|
|
445
|
+
h('span', { className: 'tg-chevron', 'aria-hidden': true }, '⌄')),
|
|
446
|
+
h('button', { type: 'button', className: 'tg-control', 'aria-haspopup': 'dialog', 'aria-controls': viewerId,
|
|
447
|
+
onClick: openViewer }, t('fullscreen'))),
|
|
448
|
+
h('div', { id: bodyId, className: 'tg-fold', 'data-expanded': expanded, 'aria-hidden': !expanded,
|
|
449
|
+
ref: element => { if (element) element.inert = !expanded } },
|
|
450
|
+
h('div', { className: 'tg-fold-clip' }, h('div', { className: 'tg-body' },
|
|
451
|
+
h('div', { className: 'tg-overview' }, h('h3', null, graph.title),
|
|
452
|
+
h('div', { className: 'tg-actions' }, h('span', null, t('revision', { revision: graph.revision })), refreshButton())),
|
|
453
|
+
notices(),
|
|
454
|
+
(progress.incomplete > 0 || progress.skipped > 0 || progress.inactive > 0 || progress.excluded > 0) &&
|
|
455
|
+
h('p', { className: 'tg-progress' },
|
|
456
|
+
progress.incomplete > 0 && h('span', null, t('totalIncomplete', { count: progress.incomplete })),
|
|
457
|
+
progress.skipped > 0 && h('span', null, t('totalSkipped', { count: progress.skipped })),
|
|
458
|
+
progress.inactive > 0 && h('span', null, t('totalInactive', { count: progress.inactive })),
|
|
459
|
+
progress.excluded > 0 && h('span', null, t('totalExcluded', { count: progress.excluded }))),
|
|
460
|
+
graph.legalNext.length > 0 && h('div', { className: 'tg-ready' }, h('strong', null, t('ready')),
|
|
461
|
+
graph.legalNext.map(choice => h('button', { type: 'button', className: 'tg-link-button', key: choice.id,
|
|
462
|
+
onClick: () => setSelected(choice.id) }, model.nodes.get(choice.id)?.label))),
|
|
463
|
+
h('div', { className: 'tg-grid' },
|
|
464
|
+
h('section', { className: 'tg-area', 'aria-label': t('tree') }, h('h4', null, t('tree')), branch(null)),
|
|
465
|
+
h(Conditions, conditionProps))))),
|
|
466
|
+
h('dialog', { id: viewerId, ref: viewer, className: 'tg-viewer', 'aria-labelledby': viewerTitleId },
|
|
467
|
+
h('div', { className: 'tg-viewer-header' },
|
|
468
|
+
h('div', null, h('div', { className: 'tg-viewer-eyebrow' }, t('branchGraph')), h('h2', { id: viewerTitleId }, graph.title)),
|
|
469
|
+
h('div', { className: 'tg-actions' },
|
|
470
|
+
h('span', null, progressText), h('span', null, t('revision', { revision: graph.revision })), refreshButton(),
|
|
471
|
+
h('button', { className: 'tg-control', type: 'button', 'data-tg-close': true,
|
|
472
|
+
onClick: () => viewer.current.close() }, t('exitFullscreen')))),
|
|
473
|
+
h('div', null, notices(),
|
|
474
|
+
h('ul', { className: 'tg-legend' }, ['branchChosen', 'prerequisiteMet', 'blocked', 'edgeWaiting', 'branchExcluded'].map(state =>
|
|
475
|
+
h('li', { key: state }, h('span', { className: 'tg-legend-mark', 'data-state': state, 'aria-hidden': true }), t(state))))),
|
|
476
|
+
viewerVisited && h(BranchCanvas, { graph, selected, active, t, onSelect: setSelected }),
|
|
477
|
+
h(Conditions, conditionProps)))
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function makeDock(api) {
|
|
481
|
+
return function TransactionGraphDock({ sessionId, useSession, useAccess, t }) {
|
|
482
|
+
const canRead = useAccess('transactionGraphView/status')
|
|
483
|
+
const opened = useSession(session => session.openState === 'open' && !session.removed)
|
|
484
|
+
const running = useSession(session => session.running)
|
|
485
|
+
const [result, setResult] = useState(null)
|
|
486
|
+
const [refreshCount, requestRefresh] = useState(0)
|
|
487
|
+
const refresh = () => requestRefresh(value => value + 1)
|
|
488
|
+
useEffect(() => {
|
|
489
|
+
if (!canRead || !opened) return
|
|
490
|
+
const controller = new AbortController()
|
|
491
|
+
let timer
|
|
492
|
+
async function refreshStatus() {
|
|
493
|
+
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(8000)])
|
|
494
|
+
setResult(value => ({
|
|
495
|
+
sessionId, graph: value?.sessionId === sessionId ? value.graph : null,
|
|
496
|
+
busy: true, failed: value?.sessionId === sessionId && value.failed,
|
|
497
|
+
}))
|
|
498
|
+
try {
|
|
499
|
+
const response = await api.status({ sessionId }, signal)
|
|
500
|
+
if (controller.signal.aborted) return
|
|
501
|
+
const parsed = response?.ok ? statusResult.safeParse(response.value) : null
|
|
502
|
+
if (!parsed?.success || parsed.data.sessionId !== sessionId || parsed.data.status === 'unavailable') throw new Error('Unavailable')
|
|
503
|
+
setResult({ sessionId, graph: parsed.data.graph, busy: false, failed: false })
|
|
504
|
+
} catch {
|
|
505
|
+
if (controller.signal.aborted) return
|
|
506
|
+
setResult(value => ({ sessionId, graph: value?.sessionId === sessionId ? value.graph : null, busy: false, failed: true }))
|
|
507
|
+
} finally {
|
|
508
|
+
if (running && !controller.signal.aborted) timer = setTimeout(refreshStatus, 2500)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
void refreshStatus()
|
|
512
|
+
return () => { controller.abort(); clearTimeout(timer) }
|
|
513
|
+
}, [canRead, opened, running, sessionId, refreshCount])
|
|
514
|
+
if (!canRead || !opened || result?.sessionId !== sessionId) return null
|
|
515
|
+
if (!result.graph) return result.failed ? h('section', { className: 'tg-dock tg-error-only', 'aria-label': t('title') },
|
|
516
|
+
h('style', null, styles), h('span', { role: 'status' }, t('unavailable')), ' ',
|
|
517
|
+
h('button', { type: 'button', onClick: refresh }, t('retry'))) : null
|
|
518
|
+
return h(GraphPanel, { key: sessionId, graph: result.graph, t, busy: result.busy, failed: result.failed, refresh })
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
inject: ['slots', 'locale', 'remote'],
|
|
524
|
+
async apply(ctx) {
|
|
525
|
+
ctx.effect(() => ctx.locale.register('projectTransactionGraph', locale))
|
|
526
|
+
const unmount = await ctx.remote.$mount(contribution)
|
|
527
|
+
ctx.effect(() => unmount)
|
|
528
|
+
ctx.inject(['remote.transactionGraphView'], mounted => {
|
|
529
|
+
const Dock = makeDock(mounted.remote.transactionGraphView)
|
|
530
|
+
mounted.slots.inject('conversation.input.dock', () => mounted.slots.register({
|
|
531
|
+
name: 'conversation.input.dock', id: 'project-transaction-graph', locale: 'projectTransactionGraph', order: 10,
|
|
532
|
+
}, Dock))
|
|
533
|
+
})
|
|
534
|
+
},
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return createClientPlugin(require('react'),require('zod').z,createRemoteContribution,graphPresentation,branchLayout);
|
|
539
|
+
}});
|