dsh-plugin-agent-tree 0.0.0-stage → 0.1.0
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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.en.md +118 -0
- package/README.md +136 -2
- package/client.js +1261 -0
- package/cordis.patch.yml +8 -0
- package/docs/assets/screenshot.png +0 -0
- package/docs/compat.md +43 -0
- package/docs/install.en.md +87 -0
- package/docs/install.md +95 -0
- package/docs/manual.en.md +197 -0
- package/docs/manual.md +199 -0
- package/index.js +541 -0
- package/package.json +68 -4
package/client.js
ADDED
|
@@ -0,0 +1,1261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dsh-plugin-agent-tree` — 客户端半部(v0.1.0)。
|
|
3
|
+
*
|
|
4
|
+
* 内容:一张常驻角落的迷你 agent 树。
|
|
5
|
+
* - 顶层是「本机 agent 列表」:`dsh`(本实例)与本机 `claude` / `codex` 进程同级;
|
|
6
|
+
* - `dsh` 下面 workspace → 对话 → subagent(可再下钻),git-graph 风格连接线;
|
|
7
|
+
* - 等你拍板的会话(待答/待批/待审)琥珀色置顶;定时任务的会话带 ⏱ 倒计时;
|
|
8
|
+
* - 每行右侧是该会话正在用的模型;
|
|
9
|
+
* - codex 那行下面挂它自己的 subagent 线程(读 rollout 头,见宿主半部),
|
|
10
|
+
* 再下面是它拉起的 MCP / computer use 等工具进程。
|
|
11
|
+
*
|
|
12
|
+
* 只读:所有数据来自客户端已有的 session store / projection,加上宿主半部的
|
|
13
|
+
* 只读小接口 `GET /local/dsh-agent-tree/processes`。
|
|
14
|
+
*
|
|
15
|
+
* 视觉约定:
|
|
16
|
+
* - 顶层对话:绿色圆点
|
|
17
|
+
* - 第 2 层 subagent:蓝色圆点
|
|
18
|
+
* - 第 3 层及更深:紫色圆点(已结束的用灰点)
|
|
19
|
+
*
|
|
20
|
+
* 数据来源(官方 0.2.1-rc.2 客户端契约):
|
|
21
|
+
* - `useSessions(s => s)` → { ids, byId }
|
|
22
|
+
* - `useSessionStatus(map => map)`,没有 global props 时自己订阅
|
|
23
|
+
* `ctx.remote.$on('api-session/status', (sessionId, running) => …)`
|
|
24
|
+
* - `ctx.workspaces.list.getSnapshot()` → items[].{ workspaceId, path, title, sessionIds }
|
|
25
|
+
* - `byId[id].projectionValues.{subagentTiming, modelSelection}` → 耗时 / 模型
|
|
26
|
+
* - `ctx.remote.schedule.catalog()` + `schedule/changed` → 定时任务
|
|
27
|
+
* - `GET /local/dsh-agent-tree/processes`(本插件宿主半部)→ 外部 agent 进程
|
|
28
|
+
*/
|
|
29
|
+
window.__ModuleLoader__.load({
|
|
30
|
+
id: 'dsh-plugin-agent-tree',
|
|
31
|
+
factory(require) {
|
|
32
|
+
const React = require('react');
|
|
33
|
+
const h = React.createElement;
|
|
34
|
+
|
|
35
|
+
const OVERLAY_ID = 'dsh-agent-tree.mini';
|
|
36
|
+
const POS_KEY = 'dsh-agent-tree:pos';
|
|
37
|
+
const WIDTH = 288;
|
|
38
|
+
const MAX_ROWS = 48;
|
|
39
|
+
const POLL_MS = 1000;
|
|
40
|
+
const STATUS_EVENT = 'api-session/status';
|
|
41
|
+
const SCHEDULE_EVENT = 'schedule/changed';
|
|
42
|
+
const SCHEDULE_POLL_MS = 15000;
|
|
43
|
+
const PROCESS_POLL_MS = 5000;
|
|
44
|
+
const PROCESS_ROUTE = '/local/dsh-agent-tree/processes';
|
|
45
|
+
|
|
46
|
+
const DOT = {
|
|
47
|
+
conversation: 'var(--dsw-alias-state-success-primary, #138a63)',
|
|
48
|
+
level2: 'var(--dsw-alias-brand-primary, #4d6bfe)',
|
|
49
|
+
deep: '#8b5cf6',
|
|
50
|
+
idle: 'var(--dsw-alias-border-l3, #c9ccd1)',
|
|
51
|
+
};
|
|
52
|
+
const KIND_DOT = { claude: '#d97757', codex: '#10a37f' };
|
|
53
|
+
|
|
54
|
+
/** 需要你拍板的状态:琥珀色,和「在跑」的绿/蓝明确区分。 */
|
|
55
|
+
const WAIT = {
|
|
56
|
+
question: { color: '#d97706', badge: '待答', title: '等你回答 agent 的问题' },
|
|
57
|
+
approval: { color: '#d97706', badge: '待批', title: '等你批准一次操作' },
|
|
58
|
+
'plan-review': { color: '#d97706', badge: '待审', title: '等你审阅 agent 的计划' },
|
|
59
|
+
};
|
|
60
|
+
const WAIT_ORDER = { question: 0, approval: 1, 'plan-review': 2 };
|
|
61
|
+
|
|
62
|
+
const GLYPH_FONT = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
|
|
63
|
+
|
|
64
|
+
// ------------------------------------------------------------------ 纯函数
|
|
65
|
+
|
|
66
|
+
function hasSchedule(node) {
|
|
67
|
+
return node && node.schedule !== undefined && node.schedule !== null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 子孙进程按标签归组:`computer use ×4`、`MCP ×3`,按数量再按时长排序。 */
|
|
71
|
+
function groupProcessChildren(children) {
|
|
72
|
+
const groups = new Map();
|
|
73
|
+
for (const child of Array.isArray(children) ? children : []) {
|
|
74
|
+
const label = (child && child.label) || 'child';
|
|
75
|
+
let entry = groups.get(label);
|
|
76
|
+
if (!entry) {
|
|
77
|
+
entry = { label, count: 0, elapsedSeconds: 0, cpu: 0 };
|
|
78
|
+
groups.set(label, entry);
|
|
79
|
+
}
|
|
80
|
+
entry.count += 1;
|
|
81
|
+
entry.elapsedSeconds = Math.max(entry.elapsedSeconds, Number(child.elapsedSeconds) || 0);
|
|
82
|
+
entry.cpu += Number(child.cpu) || 0;
|
|
83
|
+
}
|
|
84
|
+
return Array.from(groups.values()).sort(
|
|
85
|
+
(a, b) => b.count - a.count || b.elapsedSeconds - a.elapsedSeconds,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sortNodes(nodes) {
|
|
90
|
+
nodes.sort(
|
|
91
|
+
(a, b) =>
|
|
92
|
+
nodeRank(b) - nodeRank(a) ||
|
|
93
|
+
(WAIT_ORDER[b.waiting] ?? 9) - (WAIT_ORDER[a.waiting] ?? 9) ||
|
|
94
|
+
(b.row.updatedAt || 0) - (a.row.updatedAt || 0),
|
|
95
|
+
);
|
|
96
|
+
for (const node of nodes) sortNodes(node.children);
|
|
97
|
+
return nodes;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isRunning(row, statuses) {
|
|
101
|
+
const live = statuses && typeof statuses.get === 'function' ? statuses.get(row.id) : undefined;
|
|
102
|
+
if (live && typeof live.running === 'boolean') return live.running;
|
|
103
|
+
return row.running === true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 这个会话有没有在等你做判断:`question` / `approval` / `plan-review`。 */
|
|
107
|
+
function waitingKind(statuses, sessionId) {
|
|
108
|
+
const live = statuses && typeof statuses.get === 'function' ? statuses.get(sessionId) : undefined;
|
|
109
|
+
const kind = live && live.pendingInteraction && live.pendingInteraction.kind;
|
|
110
|
+
return WAIT[kind] ? kind : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 节点的排序权重:等你拍板 > 在跑 > 有定时任务 > 其它。 */
|
|
114
|
+
function nodeRank(node) {
|
|
115
|
+
if (node && node.waiting) return 3;
|
|
116
|
+
if (node && node.active) return 2;
|
|
117
|
+
if (hasSchedule(node)) return 1;
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function baseName(path) {
|
|
122
|
+
if (typeof path !== 'string' || path.length === 0) return '';
|
|
123
|
+
const parts = path.split('/').filter(Boolean);
|
|
124
|
+
return parts.length === 0 ? path : parts[parts.length - 1];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function workspaceLabel(ws) {
|
|
128
|
+
if (!ws) return '未分组';
|
|
129
|
+
const title = typeof ws.title === 'string' ? ws.title.trim() : '';
|
|
130
|
+
return title.length > 0 ? title : baseName(ws.path) || String(ws.workspaceId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function shortTitle(row) {
|
|
134
|
+
const title = (row.displayTitle || '').trim();
|
|
135
|
+
return title.length > 0 ? title : row.id;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function durationMs(projectionValues, running, now) {
|
|
139
|
+
const timing = projectionValues && projectionValues.subagentTiming;
|
|
140
|
+
if (!timing) return 0;
|
|
141
|
+
const settled = timing.settledMs || 0;
|
|
142
|
+
if (!timing.active) return settled;
|
|
143
|
+
const through = running ? now : timing.active.through || now;
|
|
144
|
+
return settled + Math.max(0, through - (timing.active.since || through));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function formatDuration(ms) {
|
|
148
|
+
if (!ms || ms < 1000) return '';
|
|
149
|
+
const total = Math.floor(ms / 1000);
|
|
150
|
+
if (total < 60) return `${total}s`;
|
|
151
|
+
const minutes = Math.floor(total / 60);
|
|
152
|
+
if (minutes < 60) return `${minutes}m${total % 60}s`;
|
|
153
|
+
return `${Math.floor(minutes / 60)}h${minutes % 60}m`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `subagentTiming` 的兄弟投影:模型选择 → 卡片里那个小标签。 */
|
|
157
|
+
function modelLabel(projectionValues) {
|
|
158
|
+
const selection = projectionValues && projectionValues.modelSelection;
|
|
159
|
+
if (!selection) return '';
|
|
160
|
+
const chosen = selection.next || selection.lastUsed;
|
|
161
|
+
if (!chosen || typeof chosen.model !== 'string') return '';
|
|
162
|
+
let label = chosen.model;
|
|
163
|
+
if (label.includes('/')) label = label.slice(label.lastIndexOf('/') + 1);
|
|
164
|
+
label = label.replace(/^deepseek[-_]/, '').replace(/^anthropic[-_]/, '');
|
|
165
|
+
if (label.length > 16) label = `${label.slice(0, 15)}…`;
|
|
166
|
+
return label;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 定时任务:宿主返回的 catalog 条目 → `sessionId -> { count, nextAt }`。 */
|
|
170
|
+
function toScheduleMap(entries) {
|
|
171
|
+
const map = new Map();
|
|
172
|
+
if (!Array.isArray(entries)) return map;
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
if (!entry || entry.status !== 'active' || entry.sessionId === undefined) continue;
|
|
175
|
+
const current = map.get(entry.sessionId) || { count: 0, nextAt: Number.POSITIVE_INFINITY };
|
|
176
|
+
const at = Date.parse(entry.scheduledAt);
|
|
177
|
+
map.set(entry.sessionId, {
|
|
178
|
+
count: current.count + 1,
|
|
179
|
+
nextAt: Number.isFinite(at) ? Math.min(current.nextAt, at) : current.nextAt,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return map;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 距离下次触发还有多久,比如 `8m` / `2h` / `3d`。 */
|
|
186
|
+
function countdownLabel(nextAt, now) {
|
|
187
|
+
if (!Number.isFinite(nextAt)) return '';
|
|
188
|
+
const diff = nextAt - now;
|
|
189
|
+
if (diff <= 0) return 'now';
|
|
190
|
+
const minutes = Math.round(diff / 60000);
|
|
191
|
+
if (minutes < 1) return '<1m';
|
|
192
|
+
if (minutes < 60) return `${minutes}m`;
|
|
193
|
+
const hours = Math.round(minutes / 60);
|
|
194
|
+
if (hours < 24) return `${hours}h`;
|
|
195
|
+
return `${Math.round(hours / 24)}d`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 外部进程已经跑了多久:秒 → `12m` / `1h3m`。 */
|
|
199
|
+
function formatElapsed(seconds) {
|
|
200
|
+
if (!seconds || seconds < 0) return '';
|
|
201
|
+
if (seconds < 60) return `${Math.round(seconds)}s`;
|
|
202
|
+
const minutes = Math.floor(seconds / 60);
|
|
203
|
+
if (minutes < 60) return `${minutes}m`;
|
|
204
|
+
const hours = Math.floor(minutes / 60);
|
|
205
|
+
return `${hours}h${minutes % 60}m`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 建真树:workspace → 对话 → subagent(可递归)。
|
|
210
|
+
* 只保留通往「活跃 / 有定时任务」节点的分支,所以卡永远小。
|
|
211
|
+
*
|
|
212
|
+
* node = { kind: 'conversation' | 'subagent', row, active, children, schedule? }
|
|
213
|
+
* group = { id, label, items: node[] }
|
|
214
|
+
*/
|
|
215
|
+
function selectActiveTree(sessionState, workspaceState, statuses, schedules) {
|
|
216
|
+
const byId = (sessionState && sessionState.byId) || {};
|
|
217
|
+
const ids =
|
|
218
|
+
Array.isArray(sessionState && sessionState.ids) && sessionState.ids.length > 0
|
|
219
|
+
? sessionState.ids
|
|
220
|
+
: Object.keys(byId);
|
|
221
|
+
const rows = ids.map((id) => byId[id]).filter((row) => row !== undefined && !row.blank);
|
|
222
|
+
|
|
223
|
+
const stats = { total: rows.length, subagents: 0, runningSnapshot: 0, runningLive: 0 };
|
|
224
|
+
for (const row of rows) {
|
|
225
|
+
if (row.origin === 'subagent') stats.subagents += 1;
|
|
226
|
+
if (row.running === true) stats.runningSnapshot += 1;
|
|
227
|
+
if (isRunning(row, statuses)) stats.runningLive += 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const workspaces = (workspaceState && workspaceState.items) || [];
|
|
231
|
+
const workspaceOf = new Map();
|
|
232
|
+
const workspaceById = new Map();
|
|
233
|
+
for (const ws of workspaces) {
|
|
234
|
+
if (!ws || ws.workspaceId === undefined) continue;
|
|
235
|
+
workspaceById.set(ws.workspaceId, ws);
|
|
236
|
+
for (const sessionId of ws.sessionIds || []) {
|
|
237
|
+
if (!workspaceOf.has(sessionId)) workspaceOf.set(sessionId, ws.workspaceId);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 祖先链:根 → 自己(父不在 byId 就停在能到的最上层)
|
|
242
|
+
const chainOf = (row) => {
|
|
243
|
+
const chain = [];
|
|
244
|
+
const seen = new Set();
|
|
245
|
+
let current = row;
|
|
246
|
+
while (current && !seen.has(current.id)) {
|
|
247
|
+
seen.add(current.id);
|
|
248
|
+
chain.unshift(current);
|
|
249
|
+
const parentId = current.parentId;
|
|
250
|
+
const parent = parentId && byId[parentId];
|
|
251
|
+
current = parent && parent !== current ? parent : undefined;
|
|
252
|
+
}
|
|
253
|
+
return chain;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const groups = new Map();
|
|
257
|
+
const ensureGroup = (workspaceId) => {
|
|
258
|
+
let group = groups.get(workspaceId);
|
|
259
|
+
if (!group) {
|
|
260
|
+
group = {
|
|
261
|
+
id: String(workspaceId),
|
|
262
|
+
label: workspaceLabel(workspaceById.get(workspaceId)),
|
|
263
|
+
items: [],
|
|
264
|
+
};
|
|
265
|
+
groups.set(workspaceId, group);
|
|
266
|
+
}
|
|
267
|
+
return group;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const conversationNodes = new Map(); // `${groupId}|${rowId}` -> node
|
|
271
|
+
const subagentNodes = new Map(); // rowId -> node
|
|
272
|
+
|
|
273
|
+
const ensureConversation = (group, row) => {
|
|
274
|
+
const key = `${group.id}|${row.id}`;
|
|
275
|
+
let node = conversationNodes.get(key);
|
|
276
|
+
if (!node) {
|
|
277
|
+
node = { kind: 'conversation', row, active: isRunning(row, statuses), children: [] };
|
|
278
|
+
conversationNodes.set(key, node);
|
|
279
|
+
group.items.push(node);
|
|
280
|
+
}
|
|
281
|
+
return node;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const ensureSubagent = (parent, group, row) => {
|
|
285
|
+
let node = subagentNodes.get(row.id);
|
|
286
|
+
if (!node) {
|
|
287
|
+
node = { kind: 'subagent', row, active: isRunning(row, statuses), children: [] };
|
|
288
|
+
subagentNodes.set(row.id, node);
|
|
289
|
+
if (parent) parent.children.push(node);
|
|
290
|
+
else group.items.push(node);
|
|
291
|
+
}
|
|
292
|
+
return node;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/** 把一条会话挂进树:顶层是对话就建对话节点,顶层是 subagent 就直挂 workspace。 */
|
|
296
|
+
const attach = (row) => {
|
|
297
|
+
const chain = chainOf(row);
|
|
298
|
+
const top = chain[0];
|
|
299
|
+
const workspaceId = workspaceOf.has(top.id)
|
|
300
|
+
? workspaceOf.get(top.id)
|
|
301
|
+
: workspaceOf.has(row.id)
|
|
302
|
+
? workspaceOf.get(row.id)
|
|
303
|
+
: '__ungrouped__';
|
|
304
|
+
const group = ensureGroup(workspaceId);
|
|
305
|
+
if (top.origin === 'subagent') {
|
|
306
|
+
let parent = null;
|
|
307
|
+
for (const link of chain) parent = ensureSubagent(parent, group, link);
|
|
308
|
+
return subagentNodes.get(row.id) || parent;
|
|
309
|
+
}
|
|
310
|
+
const conversation = ensureConversation(group, top);
|
|
311
|
+
let parent = conversation;
|
|
312
|
+
for (const link of chain.slice(1)) parent = ensureSubagent(parent, group, link);
|
|
313
|
+
return subagentNodes.get(row.id) || conversation;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const activeRows = rows.filter((row) => isRunning(row, statuses));
|
|
317
|
+
for (const row of activeRows) attach(row);
|
|
318
|
+
|
|
319
|
+
// 需要你判断的会话:可能已经不在跑(等输入),但必须出现在最上面
|
|
320
|
+
const waitingNodes = new Set();
|
|
321
|
+
for (const row of rows) {
|
|
322
|
+
const kind = waitingKind(statuses, row.id);
|
|
323
|
+
if (kind === undefined) continue;
|
|
324
|
+
const node = attach(row);
|
|
325
|
+
if (!node) continue;
|
|
326
|
+
node.waiting = kind;
|
|
327
|
+
waitingNodes.add(node);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 定时任务(间歇式工作的会话):不在跑也要列出来,标上倒计时
|
|
331
|
+
const scheduleEntries = schedules instanceof Map ? schedules : new Map();
|
|
332
|
+
const scheduledNodes = new Set();
|
|
333
|
+
for (const [sessionId, info] of scheduleEntries) {
|
|
334
|
+
const row = byId[sessionId];
|
|
335
|
+
if (!row || row.blank) continue;
|
|
336
|
+
const node = attach(row);
|
|
337
|
+
if (!node) continue;
|
|
338
|
+
node.schedule = info;
|
|
339
|
+
scheduledNodes.add(node);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const group of groups.values()) sortNodes(group.items);
|
|
343
|
+
|
|
344
|
+
const ordered = [];
|
|
345
|
+
for (const ws of workspaces) {
|
|
346
|
+
const group = groups.get(ws.workspaceId);
|
|
347
|
+
if (group) ordered.push(group);
|
|
348
|
+
}
|
|
349
|
+
const ungrouped = groups.get('__ungrouped__');
|
|
350
|
+
if (ungrouped) ordered.push(ungrouped);
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
groups: ordered,
|
|
354
|
+
running: activeRows.length,
|
|
355
|
+
waiting: waitingNodes.size,
|
|
356
|
+
scheduled: scheduledNodes.size,
|
|
357
|
+
conversations: conversationNodes.size,
|
|
358
|
+
stats,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function readSavedPos() {
|
|
363
|
+
try {
|
|
364
|
+
const raw = window.localStorage.getItem(POS_KEY);
|
|
365
|
+
if (raw === null) return null;
|
|
366
|
+
const parsed = JSON.parse(raw);
|
|
367
|
+
if (typeof parsed.x === 'number' && typeof parsed.y === 'number') return parsed;
|
|
368
|
+
} catch (err) {
|
|
369
|
+
/* localStorage 不可用或内容坏了:用默认位置 */
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function savePos(pos) {
|
|
375
|
+
try {
|
|
376
|
+
window.localStorage.setItem(POS_KEY, JSON.stringify(pos));
|
|
377
|
+
} catch (err) {
|
|
378
|
+
/* 忽略 */
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function ensureStyles() {
|
|
383
|
+
if (typeof document === 'undefined') return;
|
|
384
|
+
const tagId = OVERLAY_ID + '/styles';
|
|
385
|
+
if (document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']')) return;
|
|
386
|
+
const tag = document.createElement('style');
|
|
387
|
+
tag.dataset.plugin = 'dsh-plugin-agent-tree';
|
|
388
|
+
tag.dataset.pluginCss = tagId;
|
|
389
|
+
tag.textContent = [
|
|
390
|
+
'@keyframes dshAgentTreePulse{0%,100%{opacity:.3}50%{opacity:1}}',
|
|
391
|
+
'.dsh-agent-tree-dot-run{animation:dshAgentTreePulse 1.2s ease-in-out infinite}',
|
|
392
|
+
'.dsh-agent-tree-mini-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
393
|
+
].join('');
|
|
394
|
+
document.head.appendChild(tag);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let clientCtx = null;
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------- 组件
|
|
400
|
+
|
|
401
|
+
function useNow() {
|
|
402
|
+
const [now, setNow] = React.useState(() => Date.now());
|
|
403
|
+
React.useEffect(() => {
|
|
404
|
+
const timer = setInterval(() => setNow(Date.now()), POLL_MS);
|
|
405
|
+
return () => clearInterval(timer);
|
|
406
|
+
}, []);
|
|
407
|
+
return now;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function Dot({ color, active }) {
|
|
411
|
+
return h('span', {
|
|
412
|
+
className: active ? 'dsh-agent-tree-dot-run' : undefined,
|
|
413
|
+
style: {
|
|
414
|
+
width: 6,
|
|
415
|
+
height: 6,
|
|
416
|
+
borderRadius: 999,
|
|
417
|
+
flex: 'none',
|
|
418
|
+
display: 'inline-block',
|
|
419
|
+
background: active ? color : DOT.idle,
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function clampSpan(text, style) {
|
|
425
|
+
return h(
|
|
426
|
+
'span',
|
|
427
|
+
{
|
|
428
|
+
style: {
|
|
429
|
+
minWidth: 0,
|
|
430
|
+
flex: 1,
|
|
431
|
+
whiteSpace: 'nowrap',
|
|
432
|
+
overflow: 'hidden',
|
|
433
|
+
textOverflow: 'ellipsis',
|
|
434
|
+
...style,
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
text,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function glyph(text) {
|
|
442
|
+
return h(
|
|
443
|
+
'span',
|
|
444
|
+
{
|
|
445
|
+
style: {
|
|
446
|
+
flex: 'none',
|
|
447
|
+
fontFamily: GLYPH_FONT,
|
|
448
|
+
fontSize: 9.5,
|
|
449
|
+
lineHeight: '12px',
|
|
450
|
+
color: 'var(--dsw-alias-border-l3, #b9bcc2)',
|
|
451
|
+
whiteSpace: 'pre',
|
|
452
|
+
letterSpacing: 0,
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
text,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** 每行左侧的模型小标签。 */
|
|
460
|
+
function ModelChip({ text, inherited }) {
|
|
461
|
+
if (!text) return null;
|
|
462
|
+
return h(
|
|
463
|
+
'span',
|
|
464
|
+
{
|
|
465
|
+
title: inherited ? `模型:${text}(继承自上级)` : `模型:${text}`,
|
|
466
|
+
style: {
|
|
467
|
+
flex: 'none',
|
|
468
|
+
maxWidth: 68,
|
|
469
|
+
overflow: 'hidden',
|
|
470
|
+
textOverflow: 'ellipsis',
|
|
471
|
+
whiteSpace: 'nowrap',
|
|
472
|
+
fontFamily: GLYPH_FONT,
|
|
473
|
+
fontSize: 8,
|
|
474
|
+
lineHeight: '12px',
|
|
475
|
+
padding: '0 3px',
|
|
476
|
+
borderRadius: 3,
|
|
477
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
478
|
+
background: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,.12))',
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
text,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** 「等你拍板」的小徽标:琥珀色 `待答` / `待批` / `待审`。 */
|
|
486
|
+
function WaitBadge({ kind }) {
|
|
487
|
+
const info = WAIT[kind];
|
|
488
|
+
if (!info) return null;
|
|
489
|
+
return h(
|
|
490
|
+
'span',
|
|
491
|
+
{
|
|
492
|
+
title: info.title,
|
|
493
|
+
style: {
|
|
494
|
+
flex: 'none',
|
|
495
|
+
fontSize: 8.5,
|
|
496
|
+
fontWeight: 600,
|
|
497
|
+
lineHeight: 1,
|
|
498
|
+
padding: '1px 3px',
|
|
499
|
+
borderRadius: 3,
|
|
500
|
+
color: info.color,
|
|
501
|
+
border: `1px solid ${info.color}55`,
|
|
502
|
+
background: `${info.color}14`,
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
info.badge,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function MiniInner({ tree, onOpen, mode, processes }) {
|
|
510
|
+
const [manualCollapsed, setManualCollapsed] = React.useState(null);
|
|
511
|
+
const collapsed =
|
|
512
|
+
manualCollapsed === null ? tree.running === 0 && tree.scheduled === 0 && tree.waiting === 0 : manualCollapsed;
|
|
513
|
+
const [collapsedNodes, setCollapsedNodes] = React.useState({});
|
|
514
|
+
const now = useNow();
|
|
515
|
+
const [pos, setPos] = React.useState(() => {
|
|
516
|
+
const saved = readSavedPos();
|
|
517
|
+
if (saved !== null) return saved;
|
|
518
|
+
const width = typeof window !== 'undefined' ? window.innerWidth : 1280;
|
|
519
|
+
const height = typeof window !== 'undefined' ? window.innerHeight : 800;
|
|
520
|
+
return { x: Math.max(8, width - WIDTH - 20), y: Math.max(8, height - 300) };
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const dragRef = React.useRef(null);
|
|
524
|
+
const onPointerDown = (event) => {
|
|
525
|
+
if (event.button !== undefined && event.button !== 0) return;
|
|
526
|
+
dragRef.current = { x: event.clientX, y: event.clientY, left: pos.x, top: pos.y };
|
|
527
|
+
const onMove = (moveEvent) => {
|
|
528
|
+
const base = dragRef.current;
|
|
529
|
+
if (base === null) return;
|
|
530
|
+
const maxX = Math.max(0, window.innerWidth - WIDTH - 4);
|
|
531
|
+
const maxY = Math.max(0, window.innerHeight - 36);
|
|
532
|
+
setPos({
|
|
533
|
+
x: Math.min(maxX, Math.max(0, base.left + moveEvent.clientX - base.x)),
|
|
534
|
+
y: Math.min(maxY, Math.max(0, base.top + moveEvent.clientY - base.y)),
|
|
535
|
+
});
|
|
536
|
+
};
|
|
537
|
+
const onUp = () => {
|
|
538
|
+
dragRef.current = null;
|
|
539
|
+
window.removeEventListener('pointermove', onMove);
|
|
540
|
+
window.removeEventListener('pointerup', onUp);
|
|
541
|
+
setPos((current) => {
|
|
542
|
+
savePos(current);
|
|
543
|
+
return current;
|
|
544
|
+
});
|
|
545
|
+
};
|
|
546
|
+
window.addEventListener('pointermove', onMove);
|
|
547
|
+
window.addEventListener('pointerup', onUp);
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const toggle = (key) => setCollapsedNodes((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
551
|
+
|
|
552
|
+
/** 递归画树:git log --graph 风格的 ├─ └─ │ 连接线。 */
|
|
553
|
+
const renderNodes = (nodes, prefix, depth, out, inheritedModel) => {
|
|
554
|
+
nodes.forEach((node, index) => {
|
|
555
|
+
const isLast = index === nodes.length - 1;
|
|
556
|
+
const connector = `${prefix}${isLast ? '└─' : '├─'}`;
|
|
557
|
+
const childPrefix = `${prefix}${isLast ? ' ' : '│ '}`;
|
|
558
|
+
const key = `${node.kind}:${node.row.id}`;
|
|
559
|
+
const hasChildren = node.children.length > 0;
|
|
560
|
+
const expanded = !collapsedNodes[key];
|
|
561
|
+
const own = modelLabel(node.row.projectionValues);
|
|
562
|
+
const model = own || inheritedModel || '';
|
|
563
|
+
const scheduled = node.schedule;
|
|
564
|
+
const clock = scheduled
|
|
565
|
+
? h(
|
|
566
|
+
'span',
|
|
567
|
+
{
|
|
568
|
+
title: `${scheduled.count} 个定时任务${
|
|
569
|
+
Number.isFinite(scheduled.nextAt)
|
|
570
|
+
? `,下次 ${new Date(scheduled.nextAt).toLocaleString()}`
|
|
571
|
+
: ''
|
|
572
|
+
}`,
|
|
573
|
+
style: { flex: 'none', fontSize: 8.5, color: 'var(--dsw-alias-label-tertiary)' },
|
|
574
|
+
},
|
|
575
|
+
`⏱${countdownLabel(scheduled.nextAt, now)}`,
|
|
576
|
+
)
|
|
577
|
+
: null;
|
|
578
|
+
|
|
579
|
+
if (node.kind === 'conversation') {
|
|
580
|
+
out.push(
|
|
581
|
+
h(
|
|
582
|
+
'div',
|
|
583
|
+
{
|
|
584
|
+
key,
|
|
585
|
+
className: 'dsh-agent-tree-mini-row',
|
|
586
|
+
onClick: () => onOpen(node.row.id),
|
|
587
|
+
title: `${shortTitle(node.row)}\n${node.row.id}`,
|
|
588
|
+
style: {
|
|
589
|
+
display: 'flex',
|
|
590
|
+
alignItems: 'center',
|
|
591
|
+
gap: 4,
|
|
592
|
+
padding: '3px 8px 3px 12px',
|
|
593
|
+
cursor: 'pointer',
|
|
594
|
+
minWidth: 0,
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
glyph(connector),
|
|
598
|
+
h(
|
|
599
|
+
'span',
|
|
600
|
+
{
|
|
601
|
+
onClick: (event) => {
|
|
602
|
+
if (!hasChildren) return;
|
|
603
|
+
event.stopPropagation();
|
|
604
|
+
toggle(key);
|
|
605
|
+
},
|
|
606
|
+
style: {
|
|
607
|
+
width: 8,
|
|
608
|
+
flex: 'none',
|
|
609
|
+
fontSize: 8.5,
|
|
610
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
611
|
+
textAlign: 'center',
|
|
612
|
+
cursor: hasChildren ? 'pointer' : 'default',
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
hasChildren ? (expanded ? '▾' : '▸') : '',
|
|
616
|
+
),
|
|
617
|
+
node.waiting
|
|
618
|
+
? h(Dot, { color: WAIT[node.waiting].color, active: true })
|
|
619
|
+
: node.active
|
|
620
|
+
? h(Dot, { color: DOT.conversation, active: true })
|
|
621
|
+
: null,
|
|
622
|
+
clampSpan(shortTitle(node.row), {
|
|
623
|
+
fontSize: 10.5,
|
|
624
|
+
fontWeight: node.waiting || node.active ? 600 : 500,
|
|
625
|
+
color: node.waiting ? WAIT[node.waiting].color : 'var(--dsw-alias-label-primary)',
|
|
626
|
+
}),
|
|
627
|
+
node.waiting ? h(WaitBadge, { kind: node.waiting }) : clock,
|
|
628
|
+
h(ModelChip, { text: model, inherited: own === '' && model !== '' }),
|
|
629
|
+
),
|
|
630
|
+
);
|
|
631
|
+
if (expanded) renderNodes(node.children, childPrefix, depth + 1, out, model);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const color = depth <= 2 ? DOT.level2 : DOT.deep;
|
|
636
|
+
out.push(
|
|
637
|
+
h(
|
|
638
|
+
'div',
|
|
639
|
+
{
|
|
640
|
+
key,
|
|
641
|
+
className: 'dsh-agent-tree-mini-row',
|
|
642
|
+
onClick: () => onOpen(node.row.id),
|
|
643
|
+
title: `${shortTitle(node.row)}\n${node.row.id}`,
|
|
644
|
+
style: {
|
|
645
|
+
display: 'flex',
|
|
646
|
+
alignItems: 'center',
|
|
647
|
+
gap: 4,
|
|
648
|
+
padding: '2px 8px 2px 12px',
|
|
649
|
+
cursor: 'pointer',
|
|
650
|
+
minWidth: 0,
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
glyph(connector),
|
|
654
|
+
h(Dot, { color: node.waiting ? WAIT[node.waiting].color : color, active: node.active || Boolean(node.waiting) }),
|
|
655
|
+
clampSpan(shortTitle(node.row), {
|
|
656
|
+
fontSize: 10.5,
|
|
657
|
+
color: node.waiting
|
|
658
|
+
? WAIT[node.waiting].color
|
|
659
|
+
: node.active
|
|
660
|
+
? 'var(--dsw-alias-label-primary)'
|
|
661
|
+
: 'var(--dsw-alias-label-tertiary)',
|
|
662
|
+
}),
|
|
663
|
+
node.waiting ? h(WaitBadge, { kind: node.waiting }) : clock,
|
|
664
|
+
h(ModelChip, { text: model, inherited: own === '' && model !== '' }),
|
|
665
|
+
h(
|
|
666
|
+
'span',
|
|
667
|
+
{ style: { flex: 'none', fontSize: 9.5, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
668
|
+
formatDuration(durationMs(node.row.projectionValues, node.active, now)),
|
|
669
|
+
),
|
|
670
|
+
),
|
|
671
|
+
);
|
|
672
|
+
if (expanded && hasChildren) renderNodes(node.children, childPrefix, depth + 1, out, model);
|
|
673
|
+
});
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const rows = [];
|
|
677
|
+
const processList = Array.isArray(processes) ? processes : [];
|
|
678
|
+
|
|
679
|
+
// 顶层:本机上的 agent——dsh 自己 + 每个外部 claude/codex,彼此同级。
|
|
680
|
+
const rootKey = 'root';
|
|
681
|
+
const rootOpen = !collapsedNodes[rootKey];
|
|
682
|
+
const caret = (open) =>
|
|
683
|
+
h(
|
|
684
|
+
'span',
|
|
685
|
+
{
|
|
686
|
+
style: {
|
|
687
|
+
width: 8,
|
|
688
|
+
flex: 'none',
|
|
689
|
+
fontSize: 8.5,
|
|
690
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
691
|
+
textAlign: 'center',
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
open ? '▾' : '▸',
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
rows.push(
|
|
698
|
+
h(
|
|
699
|
+
'div',
|
|
700
|
+
{
|
|
701
|
+
key: rootKey,
|
|
702
|
+
className: 'dsh-agent-tree-mini-row',
|
|
703
|
+
onClick: () => toggle(rootKey),
|
|
704
|
+
title: '这个 DSH 实例',
|
|
705
|
+
style: {
|
|
706
|
+
display: 'flex',
|
|
707
|
+
alignItems: 'center',
|
|
708
|
+
gap: 4,
|
|
709
|
+
padding: '4px 8px 2px 10px',
|
|
710
|
+
cursor: 'pointer',
|
|
711
|
+
minWidth: 0,
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
caret(rootOpen),
|
|
715
|
+
h(Dot, {
|
|
716
|
+
color: tree.waiting > 0 ? WAIT.question.color : DOT.conversation,
|
|
717
|
+
active: tree.running > 0 || tree.waiting > 0,
|
|
718
|
+
}),
|
|
719
|
+
clampSpan('dsh', { flex: 'none', fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' }),
|
|
720
|
+
h('span', { style: { flex: 1 } }),
|
|
721
|
+
h(
|
|
722
|
+
'span',
|
|
723
|
+
{ style: { flex: 'none', fontSize: 9.5, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
724
|
+
`${tree.groups.length} 个 workspace`,
|
|
725
|
+
),
|
|
726
|
+
),
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
if (rootOpen) tree.groups.forEach((group, groupIndex) => {
|
|
730
|
+
const isLastGroup = groupIndex === tree.groups.length - 1;
|
|
731
|
+
const groupConnector = isLastGroup ? '└─' : '├─';
|
|
732
|
+
const groupPrefix = isLastGroup ? ' ' : '│ ';
|
|
733
|
+
const groupKey = `g:${group.id}`;
|
|
734
|
+
const groupOpen = !collapsedNodes[groupKey];
|
|
735
|
+
rows.push(
|
|
736
|
+
h(
|
|
737
|
+
'div',
|
|
738
|
+
{
|
|
739
|
+
key: groupKey,
|
|
740
|
+
className: 'dsh-agent-tree-mini-row',
|
|
741
|
+
onClick: () => toggle(groupKey),
|
|
742
|
+
title: group.label,
|
|
743
|
+
style: {
|
|
744
|
+
display: 'flex',
|
|
745
|
+
alignItems: 'center',
|
|
746
|
+
gap: 4,
|
|
747
|
+
padding: '4px 8px 2px 12px',
|
|
748
|
+
cursor: 'pointer',
|
|
749
|
+
minWidth: 0,
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
glyph(groupConnector),
|
|
753
|
+
h(
|
|
754
|
+
'span',
|
|
755
|
+
{
|
|
756
|
+
onClick: (event) => {
|
|
757
|
+
event.stopPropagation();
|
|
758
|
+
toggle(groupKey);
|
|
759
|
+
},
|
|
760
|
+
style: {
|
|
761
|
+
width: 8,
|
|
762
|
+
flex: 'none',
|
|
763
|
+
fontSize: 8.5,
|
|
764
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
765
|
+
textAlign: 'center',
|
|
766
|
+
},
|
|
767
|
+
},
|
|
768
|
+
groupOpen ? '▾' : '▸',
|
|
769
|
+
),
|
|
770
|
+
clampSpan(group.label, { fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' }),
|
|
771
|
+
h(
|
|
772
|
+
'span',
|
|
773
|
+
{ style: { flex: 'none', fontSize: 9.5, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
774
|
+
String(group.items.length),
|
|
775
|
+
),
|
|
776
|
+
),
|
|
777
|
+
);
|
|
778
|
+
if (groupOpen) renderNodes(group.items, groupPrefix, 1, rows, '');
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// 外部 claude / codex:和 dsh 同级的顶层节点(不缩进),子进程挂在它们下面。
|
|
782
|
+
processList.forEach((proc) => {
|
|
783
|
+
const procKey = `proc:${proc.pid}`;
|
|
784
|
+
const children = Array.isArray(proc.children) ? proc.children : [];
|
|
785
|
+
const procOpen = !collapsedNodes[procKey];
|
|
786
|
+
const childGroups = groupProcessChildren(children);
|
|
787
|
+
rows.push(
|
|
788
|
+
h(
|
|
789
|
+
'div',
|
|
790
|
+
{
|
|
791
|
+
key: procKey,
|
|
792
|
+
className: 'dsh-agent-tree-mini-row',
|
|
793
|
+
onClick: () => {
|
|
794
|
+
if (children.length === 0) return;
|
|
795
|
+
toggle(procKey);
|
|
796
|
+
},
|
|
797
|
+
title: `${proc.kind} · pid ${proc.pid}${
|
|
798
|
+
proc.sessionTitle ? `\n${proc.sessionTitle}` : ''
|
|
799
|
+
}${proc.cwd ? `\n${proc.cwd}` : ''}${children.length > 0 ? `\n${children.length} 个子进程` : ''}`,
|
|
800
|
+
style: {
|
|
801
|
+
display: 'flex',
|
|
802
|
+
alignItems: 'center',
|
|
803
|
+
gap: 4,
|
|
804
|
+
padding: '4px 8px 2px 10px',
|
|
805
|
+
cursor: children.length > 0 ? 'pointer' : 'default',
|
|
806
|
+
minWidth: 0,
|
|
807
|
+
},
|
|
808
|
+
},
|
|
809
|
+
children.length === 0 ? h('span', { style: { width: 8, flex: 'none' } }) : caret(procOpen),
|
|
810
|
+
h(Dot, { color: KIND_DOT[proc.kind] || DOT.deep, active: true }),
|
|
811
|
+
clampSpan(proc.kind, {
|
|
812
|
+
flex: 'none',
|
|
813
|
+
fontSize: 11,
|
|
814
|
+
fontWeight: 600,
|
|
815
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
816
|
+
}),
|
|
817
|
+
clampSpan(proc.label || `pid ${proc.pid}`, {
|
|
818
|
+
fontSize: 10.5,
|
|
819
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
820
|
+
}),
|
|
821
|
+
proc.sessionTitle && proc.cwd && proc.cwd !== '/' && proc.cwd !== ''
|
|
822
|
+
? clampSpan(baseName(proc.cwd), {
|
|
823
|
+
flex: 'none',
|
|
824
|
+
fontSize: 9,
|
|
825
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
826
|
+
})
|
|
827
|
+
: null,
|
|
828
|
+
h(
|
|
829
|
+
'span',
|
|
830
|
+
{ style: { flex: 'none', fontSize: 9.5, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
831
|
+
`${formatElapsed(proc.elapsedSeconds)}${proc.cpu >= 1 ? ` ${Math.round(proc.cpu)}%` : ''}`,
|
|
832
|
+
),
|
|
833
|
+
),
|
|
834
|
+
);
|
|
835
|
+
if (!procOpen) return;
|
|
836
|
+
|
|
837
|
+
// 子对话(codex 的 subagent threads,来自它自己的 rollout 记录)排在子进程前面
|
|
838
|
+
const threads = Array.isArray(proc.threads) ? proc.threads : [];
|
|
839
|
+
const entries = [
|
|
840
|
+
...threads.map((thread) => ({ kind: 'thread', thread })),
|
|
841
|
+
...childGroups.map((child) => ({ kind: 'child', child })),
|
|
842
|
+
];
|
|
843
|
+
entries.forEach((entry, index) => {
|
|
844
|
+
const isLastEntry = index === entries.length - 1;
|
|
845
|
+
const connector = isLastEntry ? '└─' : '├─';
|
|
846
|
+
|
|
847
|
+
if (entry.kind === 'thread') {
|
|
848
|
+
const thread = entry.thread;
|
|
849
|
+
const indent = ' '.repeat(Math.max(0, (Number(thread.depth) || 1) - 1));
|
|
850
|
+
const pathLeaf = thread.agentPath ? String(thread.agentPath).split('/').filter(Boolean).pop() || '' : '';
|
|
851
|
+
const idle = Number(thread.idleSeconds) || 0;
|
|
852
|
+
rows.push(
|
|
853
|
+
h(
|
|
854
|
+
'div',
|
|
855
|
+
{
|
|
856
|
+
key: `${procKey}:thread:${index}`,
|
|
857
|
+
title: `${thread.nickname}${thread.agentPath ? `\n${thread.agentPath}` : ''}\n${
|
|
858
|
+
thread.running ? '正在工作' : `最后活动 ${formatElapsed(idle)}前`
|
|
859
|
+
}`,
|
|
860
|
+
style: {
|
|
861
|
+
display: 'flex',
|
|
862
|
+
alignItems: 'center',
|
|
863
|
+
gap: 4,
|
|
864
|
+
padding: '2px 8px 2px 12px',
|
|
865
|
+
minWidth: 0,
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
glyph(`${indent}${connector}`),
|
|
869
|
+
h(Dot, { color: DOT.conversation, active: thread.running === true }),
|
|
870
|
+
clampSpan(thread.nickname || pathLeaf || '子对话', {
|
|
871
|
+
flex: 'none',
|
|
872
|
+
fontSize: 10.5,
|
|
873
|
+
fontWeight: thread.running === true ? 600 : 500,
|
|
874
|
+
color: thread.running === true ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
|
|
875
|
+
}),
|
|
876
|
+
pathLeaf && thread.nickname
|
|
877
|
+
? clampSpan(pathLeaf, { fontSize: 9, color: 'var(--dsw-alias-label-tertiary)' })
|
|
878
|
+
: h('span', { style: { flex: 1 } }),
|
|
879
|
+
h(
|
|
880
|
+
'span',
|
|
881
|
+
{ style: { flex: 'none', fontSize: 9.5, color: 'var(--dsw-alias-label-tertiary)', opacity: 0.85 } },
|
|
882
|
+
thread.running === true ? 'running' : `${formatElapsed(idle)}前`,
|
|
883
|
+
),
|
|
884
|
+
),
|
|
885
|
+
);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const child = entry.child;
|
|
890
|
+
rows.push(
|
|
891
|
+
h(
|
|
892
|
+
'div',
|
|
893
|
+
{
|
|
894
|
+
key: `${procKey}:${child.label}`,
|
|
895
|
+
title: child.count > 1 ? `${child.count} 个 ${child.label} 子进程` : child.label,
|
|
896
|
+
style: {
|
|
897
|
+
display: 'flex',
|
|
898
|
+
alignItems: 'center',
|
|
899
|
+
gap: 4,
|
|
900
|
+
padding: '2px 8px 2px 12px',
|
|
901
|
+
minWidth: 0,
|
|
902
|
+
},
|
|
903
|
+
},
|
|
904
|
+
glyph(connector),
|
|
905
|
+
clampSpan(child.count > 1 ? `${child.label} ×${child.count}` : child.label, {
|
|
906
|
+
fontSize: 10.5,
|
|
907
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
908
|
+
}),
|
|
909
|
+
h(
|
|
910
|
+
'span',
|
|
911
|
+
{ style: { flex: 'none', fontSize: 9.5, color: 'var(--dsw-alias-label-tertiary)', opacity: 0.8 } },
|
|
912
|
+
formatElapsed(child.elapsedSeconds),
|
|
913
|
+
),
|
|
914
|
+
),
|
|
915
|
+
);
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
const visible = rows.slice(0, MAX_ROWS);
|
|
920
|
+
const stats = tree.stats || { total: 0, subagents: 0, runningLive: 0 };
|
|
921
|
+
const statsLine = `会话 ${stats.total} · 子代理 ${stats.subagents} · 运行中 ${stats.runningLive}(${mode})`;
|
|
922
|
+
const busyParts = [];
|
|
923
|
+
if (tree.running > 0) busyParts.push(`${tree.running} 个在跑`);
|
|
924
|
+
if (tree.scheduled > 0) busyParts.push(`${tree.scheduled} 个定时`);
|
|
925
|
+
const headline =
|
|
926
|
+
tree.waiting > 0
|
|
927
|
+
? `${tree.waiting} 个等你处理${busyParts.length > 0 ? ` · ${busyParts.join(' · ')}` : ''}`
|
|
928
|
+
: busyParts.length > 0
|
|
929
|
+
? `${tree.running > 0 ? `${tree.running} 个 agent 在跑` : '空闲'}${
|
|
930
|
+
tree.scheduled > 0 ? ` · ${tree.scheduled} 个定时` : ''
|
|
931
|
+
}`
|
|
932
|
+
: 'Agent 树';
|
|
933
|
+
|
|
934
|
+
return h(
|
|
935
|
+
'div',
|
|
936
|
+
{
|
|
937
|
+
style: {
|
|
938
|
+
position: 'fixed',
|
|
939
|
+
left: pos.x,
|
|
940
|
+
top: pos.y,
|
|
941
|
+
width: WIDTH,
|
|
942
|
+
zIndex: 60,
|
|
943
|
+
borderRadius: 10,
|
|
944
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.28))',
|
|
945
|
+
background: 'var(--dsw-alias-bg-layer-1, #fff)',
|
|
946
|
+
boxShadow: '0 8px 28px rgba(0,0,0,.16)',
|
|
947
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
948
|
+
overflow: 'hidden',
|
|
949
|
+
userSelect: 'none',
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
h(
|
|
953
|
+
'div',
|
|
954
|
+
{
|
|
955
|
+
onPointerDown,
|
|
956
|
+
title: `按住拖动位置\n${statsLine}`,
|
|
957
|
+
style: {
|
|
958
|
+
display: 'flex',
|
|
959
|
+
alignItems: 'center',
|
|
960
|
+
gap: 6,
|
|
961
|
+
padding: '5px 8px',
|
|
962
|
+
cursor: 'grab',
|
|
963
|
+
borderBottom: collapsed ? 'none' : '1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.16))',
|
|
964
|
+
},
|
|
965
|
+
},
|
|
966
|
+
h(Dot, {
|
|
967
|
+
color: tree.waiting > 0 ? WAIT.question.color : DOT.conversation,
|
|
968
|
+
active: tree.running > 0 || tree.waiting > 0,
|
|
969
|
+
}),
|
|
970
|
+
h(
|
|
971
|
+
'span',
|
|
972
|
+
{
|
|
973
|
+
style: {
|
|
974
|
+
flex: 1,
|
|
975
|
+
fontSize: 10.5,
|
|
976
|
+
letterSpacing: 0.2,
|
|
977
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
978
|
+
whiteSpace: 'nowrap',
|
|
979
|
+
overflow: 'hidden',
|
|
980
|
+
textOverflow: 'ellipsis',
|
|
981
|
+
},
|
|
982
|
+
},
|
|
983
|
+
headline,
|
|
984
|
+
),
|
|
985
|
+
h(
|
|
986
|
+
'button',
|
|
987
|
+
{
|
|
988
|
+
type: 'button',
|
|
989
|
+
onClick: (event) => {
|
|
990
|
+
event.stopPropagation();
|
|
991
|
+
setManualCollapsed((value) => {
|
|
992
|
+
const current = value === null ? tree.running === 0 && tree.scheduled === 0 : value;
|
|
993
|
+
return !current;
|
|
994
|
+
});
|
|
995
|
+
},
|
|
996
|
+
title: collapsed ? '展开' : '收起',
|
|
997
|
+
style: {
|
|
998
|
+
flex: 'none',
|
|
999
|
+
border: 0,
|
|
1000
|
+
background: 'transparent',
|
|
1001
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
1002
|
+
cursor: 'pointer',
|
|
1003
|
+
fontSize: 10,
|
|
1004
|
+
lineHeight: '14px',
|
|
1005
|
+
padding: 0,
|
|
1006
|
+
},
|
|
1007
|
+
},
|
|
1008
|
+
collapsed ? '▸' : '▾',
|
|
1009
|
+
),
|
|
1010
|
+
),
|
|
1011
|
+
collapsed
|
|
1012
|
+
? null
|
|
1013
|
+
: h(
|
|
1014
|
+
'div',
|
|
1015
|
+
{ style: { maxHeight: '46vh', overflowY: 'auto', padding: '0 0 5px' } },
|
|
1016
|
+
visible.length === 0
|
|
1017
|
+
? h(
|
|
1018
|
+
'div',
|
|
1019
|
+
{ style: { padding: '6px 10px 2px', fontSize: 10, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
1020
|
+
'当前没有在跑什么。',
|
|
1021
|
+
)
|
|
1022
|
+
: visible,
|
|
1023
|
+
rows.length > MAX_ROWS
|
|
1024
|
+
? h(
|
|
1025
|
+
'div',
|
|
1026
|
+
{ style: { padding: '2px 8px 0', fontSize: 9.5, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
1027
|
+
`还有 ${rows.length - MAX_ROWS} 行…`,
|
|
1028
|
+
)
|
|
1029
|
+
: null,
|
|
1030
|
+
h(
|
|
1031
|
+
'div',
|
|
1032
|
+
{
|
|
1033
|
+
style: {
|
|
1034
|
+
padding: '4px 10px 0',
|
|
1035
|
+
fontSize: 9,
|
|
1036
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
1037
|
+
opacity: 0.75,
|
|
1038
|
+
whiteSpace: 'nowrap',
|
|
1039
|
+
overflow: 'hidden',
|
|
1040
|
+
textOverflow: 'ellipsis',
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
statsLine,
|
|
1044
|
+
),
|
|
1045
|
+
),
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function useWorkspaceSnapshot() {
|
|
1050
|
+
const [snapshot, setSnapshot] = React.useState(() => {
|
|
1051
|
+
try {
|
|
1052
|
+
return clientCtx && clientCtx.workspaces ? clientCtx.workspaces.list.getSnapshot() : null;
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
React.useEffect(() => {
|
|
1058
|
+
const read = () => {
|
|
1059
|
+
try {
|
|
1060
|
+
if (!clientCtx || !clientCtx.workspaces) return;
|
|
1061
|
+
setSnapshot(clientCtx.workspaces.list.getSnapshot());
|
|
1062
|
+
} catch (err) {
|
|
1063
|
+
/* 忽略瞬时错误 */
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
read();
|
|
1067
|
+
const timer = setInterval(read, POLL_MS);
|
|
1068
|
+
return () => clearInterval(timer);
|
|
1069
|
+
}, []);
|
|
1070
|
+
return snapshot;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* 拿 `remote.schedule` 命名空间。
|
|
1075
|
+
* 它是别的客户端插件挂上来的服务(服务名就是 `remote.schedule`),
|
|
1076
|
+
* 只读 `ctx.remote.schedule` 在命名空间还没挂上时会拿到 undefined,
|
|
1077
|
+
* 所以再用 `ctx.get('remote.schedule')` 试一次。
|
|
1078
|
+
*/
|
|
1079
|
+
function scheduleRemote(ctx) {
|
|
1080
|
+
if (!ctx) return undefined;
|
|
1081
|
+
try {
|
|
1082
|
+
const direct = ctx.remote && ctx.remote.schedule;
|
|
1083
|
+
if (direct && typeof direct.catalog === 'function') return direct;
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
/* 服务还没就绪:继续走 ctx.get */
|
|
1086
|
+
}
|
|
1087
|
+
try {
|
|
1088
|
+
const service = typeof ctx.get === 'function' ? ctx.get('remote.schedule') : undefined;
|
|
1089
|
+
if (service && typeof service.catalog === 'function') return service;
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
/* 没有 schedule 能力就当没有定时任务 */
|
|
1092
|
+
}
|
|
1093
|
+
return undefined;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** 定时任务目录:轮询 + 跟随 `schedule/changed`。 */
|
|
1097
|
+
function useScheduleCatalog() {
|
|
1098
|
+
const [schedules, setSchedules] = React.useState(() => new Map());
|
|
1099
|
+
React.useEffect(() => {
|
|
1100
|
+
let alive = true;
|
|
1101
|
+
const read = async () => {
|
|
1102
|
+
const schedule = scheduleRemote(clientCtx);
|
|
1103
|
+
if (!schedule) return;
|
|
1104
|
+
try {
|
|
1105
|
+
const result = await schedule.catalog();
|
|
1106
|
+
if (!alive || !result || result.ok !== true) return;
|
|
1107
|
+
setSchedules(toScheduleMap(result.value));
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
/* 读取失败保留上一份 */
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
read();
|
|
1113
|
+
const timer = setInterval(read, SCHEDULE_POLL_MS);
|
|
1114
|
+
let dispose;
|
|
1115
|
+
try {
|
|
1116
|
+
if (clientCtx && clientCtx.remote && typeof clientCtx.remote.$on === 'function') {
|
|
1117
|
+
dispose = clientCtx.remote.$on(SCHEDULE_EVENT, () => read());
|
|
1118
|
+
}
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
/* 订阅失败只影响及时性 */
|
|
1121
|
+
}
|
|
1122
|
+
return () => {
|
|
1123
|
+
alive = false;
|
|
1124
|
+
clearInterval(timer);
|
|
1125
|
+
if (typeof dispose === 'function') dispose();
|
|
1126
|
+
};
|
|
1127
|
+
}, []);
|
|
1128
|
+
return schedules;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** 本机 claude / codex 进程:走插件自己的宿主接口,拿不到就当没有。 */
|
|
1132
|
+
function useExternalProcesses() {
|
|
1133
|
+
const [processes, setProcesses] = React.useState([]);
|
|
1134
|
+
React.useEffect(() => {
|
|
1135
|
+
let alive = true;
|
|
1136
|
+
const read = async () => {
|
|
1137
|
+
try {
|
|
1138
|
+
const response = await fetch(PROCESS_ROUTE, { headers: { accept: 'application/json' } });
|
|
1139
|
+
if (!response.ok) {
|
|
1140
|
+
if (alive) setProcesses([]);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
const data = await response.json();
|
|
1144
|
+
if (alive && data && Array.isArray(data.processes)) setProcesses(data.processes);
|
|
1145
|
+
} catch (err) {
|
|
1146
|
+
if (alive) setProcesses([]);
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
read();
|
|
1150
|
+
const timer = setInterval(read, PROCESS_POLL_MS);
|
|
1151
|
+
return () => {
|
|
1152
|
+
alive = false;
|
|
1153
|
+
clearInterval(timer);
|
|
1154
|
+
};
|
|
1155
|
+
}, []);
|
|
1156
|
+
return processes;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function MiniLive(props) {
|
|
1160
|
+
const sessions = props.useSessions((snapshot) => snapshot);
|
|
1161
|
+
const statuses = props.useSessionStatus((value) => value);
|
|
1162
|
+
const workspaces = useWorkspaceSnapshot();
|
|
1163
|
+
const schedules = useScheduleCatalog();
|
|
1164
|
+
const processes = useExternalProcesses();
|
|
1165
|
+
const tree = selectActiveTree(sessions, workspaces, statuses, schedules);
|
|
1166
|
+
return h(MiniInner, { tree, onOpen: props.open, mode: 'live', processes });
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** 没有 global props 时:轮询快照 + 自己订阅状态流。 */
|
|
1170
|
+
function MiniPolled({ open }) {
|
|
1171
|
+
const [sessions, setSessions] = React.useState(null);
|
|
1172
|
+
const [statuses, setStatuses] = React.useState(() => new Map());
|
|
1173
|
+
const workspaces = useWorkspaceSnapshot();
|
|
1174
|
+
const schedules = useScheduleCatalog();
|
|
1175
|
+
const processes = useExternalProcesses();
|
|
1176
|
+
|
|
1177
|
+
React.useEffect(() => {
|
|
1178
|
+
const read = () => {
|
|
1179
|
+
const ctx = clientCtx;
|
|
1180
|
+
if (!ctx || !ctx.sessions || !ctx.sessions.list) return;
|
|
1181
|
+
setSessions(ctx.sessions.list.getSnapshot());
|
|
1182
|
+
};
|
|
1183
|
+
read();
|
|
1184
|
+
const timer = setInterval(read, POLL_MS);
|
|
1185
|
+
return () => clearInterval(timer);
|
|
1186
|
+
}, []);
|
|
1187
|
+
|
|
1188
|
+
React.useEffect(() => {
|
|
1189
|
+
const ctx = clientCtx;
|
|
1190
|
+
if (!ctx || !ctx.remote || typeof ctx.remote.$on !== 'function') return undefined;
|
|
1191
|
+
try {
|
|
1192
|
+
return ctx.remote.$on(STATUS_EVENT, (sessionId, running) => {
|
|
1193
|
+
setStatuses((prev) => {
|
|
1194
|
+
const next = new Map(prev);
|
|
1195
|
+
next.set(sessionId, { running: running === true });
|
|
1196
|
+
return next;
|
|
1197
|
+
});
|
|
1198
|
+
});
|
|
1199
|
+
} catch (err) {
|
|
1200
|
+
console.warn('[dsh-agent-tree] status subscription failed:', err);
|
|
1201
|
+
return undefined;
|
|
1202
|
+
}
|
|
1203
|
+
}, []);
|
|
1204
|
+
|
|
1205
|
+
if (sessions === null) return null;
|
|
1206
|
+
const tree = selectActiveTree(sessions, workspaces, statuses, schedules);
|
|
1207
|
+
return h(MiniInner, { tree, onOpen: open, mode: 'poll', processes });
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function Mini(props) {
|
|
1211
|
+
return props && props.useSessions && props.useSessionStatus
|
|
1212
|
+
? h(MiniLive, props)
|
|
1213
|
+
: h(MiniPolled, { open: props && props.open });
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
return {
|
|
1217
|
+
inject: ['sessions', 'slots', 'workspaces', 'remote', 'uiWorkspace'],
|
|
1218
|
+
apply(ctx) {
|
|
1219
|
+
clientCtx = ctx;
|
|
1220
|
+
ensureStyles();
|
|
1221
|
+
|
|
1222
|
+
const open = (id) => {
|
|
1223
|
+
try {
|
|
1224
|
+
const address = ctx.sessions.subagentAddress ? ctx.sessions.subagentAddress(id) : undefined;
|
|
1225
|
+
ctx.uiWorkspace.openSession(address === undefined ? id : address);
|
|
1226
|
+
} catch (err) {
|
|
1227
|
+
console.warn('[dsh-agent-tree] openSession failed:', err);
|
|
1228
|
+
}
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
ctx.effect(
|
|
1232
|
+
() =>
|
|
1233
|
+
ctx.slots.inject('shell.overlay', () =>
|
|
1234
|
+
ctx.slots.register({ name: 'shell.overlay', id: OVERLAY_ID, inject: () => ({ open }) }, Mini),
|
|
1235
|
+
),
|
|
1236
|
+
'dsh-agent-tree: mini overlay',
|
|
1237
|
+
);
|
|
1238
|
+
},
|
|
1239
|
+
// 仅供本地单测使用。
|
|
1240
|
+
__test: {
|
|
1241
|
+
selectActiveTree,
|
|
1242
|
+
MiniInner,
|
|
1243
|
+
durationMs,
|
|
1244
|
+
formatDuration,
|
|
1245
|
+
shortTitle,
|
|
1246
|
+
baseName,
|
|
1247
|
+
workspaceLabel,
|
|
1248
|
+
isRunning,
|
|
1249
|
+
waitingKind,
|
|
1250
|
+
nodeRank,
|
|
1251
|
+
scheduleRemote,
|
|
1252
|
+
sortNodes,
|
|
1253
|
+
groupProcessChildren,
|
|
1254
|
+
modelLabel,
|
|
1255
|
+
toScheduleMap,
|
|
1256
|
+
countdownLabel,
|
|
1257
|
+
formatElapsed,
|
|
1258
|
+
},
|
|
1259
|
+
};
|
|
1260
|
+
},
|
|
1261
|
+
});
|