opencode-subagent-magazine 1.6.1 → 1.7.1
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/README.md +12 -1
- package/dist/_version.d.ts +1 -1
- package/dist/_version.js +1 -1
- package/dist/core/format.d.ts +7 -0
- package/dist/core/format.js +45 -0
- package/dist/core/kv.d.ts +22 -1
- package/dist/core/kv.js +34 -1
- package/dist/core/types.d.ts +20 -0
- package/dist/core/types.js +7 -0
- package/dist/i18n.d.ts +24 -0
- package/dist/i18n.js +96 -0
- package/dist/index.js +90 -2
- package/dist/panel/SubAgentPanel.d.ts +6 -1
- package/dist/panel/SubAgentPanel.js +645 -266
- package/dist/panel/entry-map.d.ts +17 -0
- package/dist/panel/entry-map.js +75 -1
- package/dist/panel/panel-api.d.ts +24 -0
- package/dist/tui.js +1230 -567
- package/dist/v2/commands.js +202 -44
- package/dist/v2/db.d.ts +60 -0
- package/dist/v2/db.js +341 -0
- package/dist/v2/index.js +28 -6
- package/dist/v2/settings-menu.d.ts +9 -0
- package/dist/v2/settings-menu.js +242 -0
- package/dist/v2/v2-panel-api.js +315 -44
- package/dist/v2.js +2139 -708
- package/package.json +1 -1
- package/src/_version.ts +1 -1
- package/src/core/format.ts +44 -0
- package/src/core/kv.ts +43 -2
- package/src/core/types.ts +25 -0
- package/src/i18n.ts +96 -0
- package/src/index.tsx +113 -2
- package/src/panel/SubAgentPanel.tsx +621 -238
- package/src/panel/entry-map.ts +79 -2
- package/src/panel/panel-api.ts +25 -0
- package/src/v2/commands.ts +201 -43
- package/src/v2/db.ts +346 -0
- package/src/v2/index.tsx +54 -7
- package/src/v2/settings-menu.ts +259 -0
- package/src/v2/types.ts +3 -1
- package/src/v2/v2-panel-api.ts +246 -33
|
@@ -3,13 +3,13 @@ import { createMemo, createSignal, createEffect, onMount, onCleanup, untrack, Sh
|
|
|
3
3
|
import { PLUGIN_VERSION } from "../_version";
|
|
4
4
|
import { copyText } from "../clipboard";
|
|
5
5
|
import { createT } from "../i18n";
|
|
6
|
-
import { SUBAGENT_TOOLS } from "../core/types";
|
|
7
|
-
import { visualWidth, truncate,
|
|
6
|
+
import { SUBAGENT_TOOLS, isBackgroundInput } from "../core/types";
|
|
7
|
+
import { visualWidth, truncate, fmtDuration, fmtTokens, safeErrorMsg } from "../core/format";
|
|
8
8
|
import { rgb, desaturateTo, dimColor, FALLBACK, MAX_SAT } from "../core/color";
|
|
9
|
-
import { KV_PREFIX } from "../core/kv";
|
|
9
|
+
import { KV_PREFIX, updateSessionData } from "../core/kv";
|
|
10
10
|
import { globalEntryCache, clearTick } from "./store";
|
|
11
11
|
import { isDirectChildSession } from "./session-routing";
|
|
12
|
-
import { findSubEntryKey, upsertSubEntry } from "./entry-map";
|
|
12
|
+
import { findSubEntryKey, mergeSubEntries, upsertSubEntry } from "./entry-map";
|
|
13
13
|
/** Entry line left prefix: icon + space + status dot + space */
|
|
14
14
|
const LEFT_PAD = 4;
|
|
15
15
|
/** Detail row indent: two spaces */
|
|
@@ -17,6 +17,22 @@ const INDENT = 2;
|
|
|
17
17
|
// ===================================================================
|
|
18
18
|
// Sidebar component
|
|
19
19
|
// ===================================================================
|
|
20
|
+
/** 读取 part/state 两级的 tool metadata(TUI 数据层放在 part 级,旧形状嵌在 state 里)。 */
|
|
21
|
+
function partMetadata(part, st) {
|
|
22
|
+
const partMeta = part?.metadata;
|
|
23
|
+
if (partMeta && Object.keys(partMeta).length > 0)
|
|
24
|
+
return partMeta;
|
|
25
|
+
const stateMeta = st?.metadata;
|
|
26
|
+
return stateMeta && Object.keys(stateMeta).length > 0 ? stateMeta : undefined;
|
|
27
|
+
}
|
|
28
|
+
/** model 字段的有效值——历史 KV 里可能混入 "[object Object]" 这类序列化垃圾。 */
|
|
29
|
+
function validModel(model) {
|
|
30
|
+
if (typeof model !== "string")
|
|
31
|
+
return false;
|
|
32
|
+
const s = model.trim();
|
|
33
|
+
// 历史脏值:String(对象) 与未解析的 JSON 文本(session_v2.model 的形态)。
|
|
34
|
+
return s.length > 0 && s !== "[object Object]" && !s.startsWith("{");
|
|
35
|
+
}
|
|
20
36
|
export function SubAgentPanel(props) {
|
|
21
37
|
const t = createT(() => props.lang());
|
|
22
38
|
// ── session data (single-key, true deletion on cleanup) ──
|
|
@@ -33,11 +49,31 @@ export function SubAgentPanel(props) {
|
|
|
33
49
|
return {};
|
|
34
50
|
}
|
|
35
51
|
};
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
/**
|
|
53
|
+
* 持久化存储的原子 read-merge-write:所有写入都经过这里。
|
|
54
|
+
* 多个 TUI 共用一个 KV 文件时,直接写入内存快照
|
|
55
|
+
* 会丢掉其他实例创建的条目。
|
|
56
|
+
*/
|
|
57
|
+
const mutateSessionData = (mutator) => {
|
|
58
|
+
// 宿主存储可能异步 reject;吞掉以免失败写入变成未处理 rejection 拖垮 TUI。
|
|
59
|
+
void Promise.resolve(updateSessionData(props.api.kv, mutator)).catch(() => { });
|
|
60
|
+
};
|
|
61
|
+
/** 带合理默认值的 SessionRecord(记录可能在首次写入时创建)。 */
|
|
62
|
+
const ensureRootRecord = (data, sid) => {
|
|
63
|
+
const rec = data[sid];
|
|
64
|
+
if (rec)
|
|
65
|
+
return rec;
|
|
66
|
+
const created = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} };
|
|
67
|
+
data[sid] = created;
|
|
68
|
+
return created;
|
|
69
|
+
};
|
|
70
|
+
const ensureChildRecord = (data, parentSid, sid) => {
|
|
71
|
+
const parent = ensureRootRecord(data, parentSid);
|
|
72
|
+
if (!parent.children)
|
|
73
|
+
parent.children = {};
|
|
74
|
+
if (!parent.children[sid])
|
|
75
|
+
parent.children[sid] = { scroll: 0, expanded: "", entries: [] };
|
|
76
|
+
return parent.children[sid];
|
|
41
77
|
};
|
|
42
78
|
/** 将任意 session ID 解析为父会话 ID + 是否子会话。
|
|
43
79
|
* 通过 SDK session.get(sid).parentID 判断,无 parentID 即为主会话。 */
|
|
@@ -73,62 +109,53 @@ export function SubAgentPanel(props) {
|
|
|
73
109
|
clearTimeout(persistTimer);
|
|
74
110
|
persistTimer = setTimeout(() => {
|
|
75
111
|
try {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
saveSessionData(data);
|
|
112
|
+
mutateSessionData((data) => {
|
|
113
|
+
const { parentSid, isChild } = resolveParent(sid);
|
|
114
|
+
if (isChild) {
|
|
115
|
+
const parent = ensureRootRecord(data, parentSid);
|
|
116
|
+
const child = ensureChildRecord(data, parentSid, sid);
|
|
117
|
+
child.entries = [...mergeSubEntries(child.entries ?? [], entries.values()).values()];
|
|
118
|
+
parent.ts = Date.now();
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const rec = ensureRootRecord(data, sid);
|
|
122
|
+
rec.ts = Date.now();
|
|
123
|
+
rec.entries = [...mergeSubEntries(rec.entries ?? [], entries.values()).values()];
|
|
124
|
+
}
|
|
125
|
+
});
|
|
91
126
|
}
|
|
92
127
|
catch { }
|
|
93
128
|
}, 200);
|
|
94
129
|
};
|
|
95
130
|
const persistScroll = (sid, scroll) => {
|
|
96
131
|
try {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
data[sid] = { ...data[sid], ts: Date.now(), scroll, children: data[sid]?.children ?? {} };
|
|
110
|
-
}
|
|
111
|
-
saveSessionData(data);
|
|
132
|
+
mutateSessionData((data) => {
|
|
133
|
+
const { parentSid, isChild } = resolveParent(sid);
|
|
134
|
+
if (isChild) {
|
|
135
|
+
ensureChildRecord(data, parentSid, sid).scroll = scroll;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const rec = ensureRootRecord(data, sid);
|
|
139
|
+
rec.ts = Date.now();
|
|
140
|
+
rec.scroll = scroll;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
112
143
|
}
|
|
113
144
|
catch { }
|
|
114
145
|
};
|
|
115
146
|
const persistExpanded = (sid, expanded) => {
|
|
116
147
|
try {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
else {
|
|
129
|
-
data[sid] = { ...data[sid], ts: Date.now(), expanded, children: data[sid]?.children ?? {} };
|
|
130
|
-
}
|
|
131
|
-
saveSessionData(data);
|
|
148
|
+
mutateSessionData((data) => {
|
|
149
|
+
const { parentSid, isChild } = resolveParent(sid);
|
|
150
|
+
if (isChild) {
|
|
151
|
+
ensureChildRecord(data, parentSid, sid).expanded = expanded;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
const rec = ensureRootRecord(data, sid);
|
|
155
|
+
rec.ts = Date.now();
|
|
156
|
+
rec.expanded = expanded;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
132
159
|
}
|
|
133
160
|
catch { }
|
|
134
161
|
};
|
|
@@ -136,17 +163,13 @@ export function SubAgentPanel(props) {
|
|
|
136
163
|
if (ttlDays <= 0)
|
|
137
164
|
return; // 无期限,跳过清理
|
|
138
165
|
try {
|
|
139
|
-
const data = loadSessionData();
|
|
140
166
|
const cutoff = Date.now() - TTL_MS;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
changed = true;
|
|
167
|
+
mutateSessionData((data) => {
|
|
168
|
+
for (const sid of Object.keys(data)) {
|
|
169
|
+
if (data[sid].ts < cutoff)
|
|
170
|
+
delete data[sid];
|
|
146
171
|
}
|
|
147
|
-
}
|
|
148
|
-
if (changed)
|
|
149
|
-
saveSessionData(data);
|
|
172
|
+
});
|
|
150
173
|
}
|
|
151
174
|
catch { }
|
|
152
175
|
};
|
|
@@ -169,21 +192,20 @@ export function SubAgentPanel(props) {
|
|
|
169
192
|
if (needsImmediateFlush) {
|
|
170
193
|
clearTimeout(persistTimer);
|
|
171
194
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
saveSessionData(data);
|
|
195
|
+
mutateSessionData((data) => {
|
|
196
|
+
const { parentSid, isChild } = resolveParent(props.sessionId);
|
|
197
|
+
if (isChild) {
|
|
198
|
+
const parent = ensureRootRecord(data, parentSid);
|
|
199
|
+
const child = ensureChildRecord(data, parentSid, props.sessionId);
|
|
200
|
+
child.entries = [...mergeSubEntries(child.entries ?? [], next.values()).values()];
|
|
201
|
+
parent.ts = Date.now();
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
const rec = ensureRootRecord(data, props.sessionId);
|
|
205
|
+
rec.ts = Date.now();
|
|
206
|
+
rec.entries = [...mergeSubEntries(rec.entries ?? [], next.values()).values()];
|
|
207
|
+
}
|
|
208
|
+
});
|
|
187
209
|
}
|
|
188
210
|
catch { }
|
|
189
211
|
}
|
|
@@ -327,8 +349,9 @@ export function SubAgentPanel(props) {
|
|
|
327
349
|
// V2 事件流是全局的——若 payload 带发起会话 ID,则只归账到正在查看的会话,
|
|
328
350
|
// 避免其他会话的子代理写进当前侧边栏;V1 宿主已按会话 scope,不传 sessionID。
|
|
329
351
|
const eventSid = event.payload?.sessionID !== undefined ? String(event.payload.sessionID) : undefined;
|
|
330
|
-
if (event.scope === "global" && eventSid !== props.sessionId)
|
|
352
|
+
if (event.scope === "global" && eventSid !== props.sessionId) {
|
|
331
353
|
return;
|
|
354
|
+
}
|
|
332
355
|
// SubtaskPart
|
|
333
356
|
if (part.type === "subtask") {
|
|
334
357
|
const agent = String(part.agent ?? "?");
|
|
@@ -339,7 +362,7 @@ export function SubAgentPanel(props) {
|
|
|
339
362
|
const subSid = part.sessionID !== undefined ? String(part.sessionID) : undefined;
|
|
340
363
|
const partModel = part.model;
|
|
341
364
|
const modelId = partModel?.modelID ? String(partModel.modelID) : undefined;
|
|
342
|
-
upsertEntry({ id, title, agent, prompt, sessionId: subSid, status: "running", model: modelId });
|
|
365
|
+
upsertEntry({ id, title, agent, prompt, sessionId: subSid, status: "running", model: modelId, origin: "sub" });
|
|
343
366
|
}
|
|
344
367
|
// ToolPart
|
|
345
368
|
if (part.type === "tool") {
|
|
@@ -359,7 +382,7 @@ export function SubAgentPanel(props) {
|
|
|
359
382
|
const id = `tool:${String(part.id ?? "")}`;
|
|
360
383
|
if (!part.id)
|
|
361
384
|
return;
|
|
362
|
-
const stMeta = st
|
|
385
|
+
const stMeta = partMetadata(part, st);
|
|
363
386
|
const errorSid = stMeta?.session_id !== undefined ? String(stMeta.session_id)
|
|
364
387
|
: stMeta?.sessionId !== undefined ? String(stMeta.sessionId)
|
|
365
388
|
: undefined;
|
|
@@ -373,6 +396,8 @@ export function SubAgentPanel(props) {
|
|
|
373
396
|
prompt: existing.prompt,
|
|
374
397
|
sessionId: existing.sessionId ?? errorSid,
|
|
375
398
|
status: "error",
|
|
399
|
+
origin: "tool",
|
|
400
|
+
background: isBackgroundInput(st?.input),
|
|
376
401
|
});
|
|
377
402
|
}
|
|
378
403
|
return;
|
|
@@ -385,8 +410,8 @@ export function SubAgentPanel(props) {
|
|
|
385
410
|
// Background tasks: tool completion ≠ agent completion — keep running until session.idle
|
|
386
411
|
// Only keep running if state metadata confirms a child session was spawned;
|
|
387
412
|
// otherwise (failed spawn, invalid agent) mark as done so the entry isn't stuck forever.
|
|
388
|
-
if ((input
|
|
389
|
-
const stMetaCheck = st
|
|
413
|
+
if (isBackgroundInput(input) && status === "done") {
|
|
414
|
+
const stMetaCheck = partMetadata(part, st);
|
|
390
415
|
const hasChild = stMetaCheck?.session_id !== undefined || stMetaCheck?.sessionId !== undefined;
|
|
391
416
|
if (hasChild)
|
|
392
417
|
status = "running";
|
|
@@ -398,11 +423,11 @@ export function SubAgentPanel(props) {
|
|
|
398
423
|
const id = `tool:${String(part.id ?? crypto.randomUUID())}`;
|
|
399
424
|
// Child session ID lives in state-level metadata (ToolStateCompleted.metadata),
|
|
400
425
|
// injected by the tool executor. ToolPart.sessionID is the parent session.
|
|
401
|
-
const stMeta = st
|
|
426
|
+
const stMeta = partMetadata(part, st);
|
|
402
427
|
const subSid = stMeta?.session_id !== undefined ? String(stMeta.session_id)
|
|
403
428
|
: stMeta?.sessionId !== undefined ? String(stMeta.sessionId)
|
|
404
429
|
: undefined;
|
|
405
|
-
upsertEntry({ id, title, agent, prompt, sessionId: subSid, status });
|
|
430
|
+
upsertEntry({ id, title, agent, prompt, sessionId: subSid, status, origin: "tool", background: isBackgroundInput(input) });
|
|
406
431
|
}
|
|
407
432
|
};
|
|
408
433
|
const handleSessionEnd = (event, status) => {
|
|
@@ -598,22 +623,34 @@ export function SubAgentPanel(props) {
|
|
|
598
623
|
if (!found) {
|
|
599
624
|
const data = loadSessionData();
|
|
600
625
|
const rec = data[parentSid];
|
|
601
|
-
if (rec?.entries) {
|
|
626
|
+
if (rec?.entries?.length) {
|
|
602
627
|
const fallbackMap = new Map(rec.entries.map((e) => [e.id, e]));
|
|
603
628
|
found = tryMatchAndUpdate(fallbackMap, sid, status, nowTs);
|
|
604
629
|
if (found) {
|
|
605
|
-
// 回退命中后写入 KV
|
|
606
|
-
|
|
607
|
-
saveSessionData(data);
|
|
630
|
+
// 回退命中后写入 KV 并回填缓存。
|
|
631
|
+
// merge(而非整表替换):其他实例写入的新条目不能被旧缓存覆盖。
|
|
608
632
|
globalEntryCache.set(parentSid, fallbackMap);
|
|
633
|
+
mutateSessionData((latest) => {
|
|
634
|
+
const latestRec = latest[parentSid];
|
|
635
|
+
latest[parentSid] = {
|
|
636
|
+
...(latestRec ?? rec),
|
|
637
|
+
ts: nowTs,
|
|
638
|
+
entries: [...mergeSubEntries(latestRec?.entries ?? [], fallbackMap.values()).values()],
|
|
639
|
+
};
|
|
640
|
+
});
|
|
609
641
|
}
|
|
610
642
|
}
|
|
611
643
|
}
|
|
612
|
-
//
|
|
644
|
+
// 将模块级缓存中的最新状态 merge 进 KV(缓存可能缺少其他实例的新条目)
|
|
613
645
|
if (found && parentCache) {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
646
|
+
mutateSessionData((data) => {
|
|
647
|
+
const rec = data[parentSid];
|
|
648
|
+
data[parentSid] = {
|
|
649
|
+
...(rec ?? { entries: [], scroll: 0, expanded: "", children: {}, ts: nowTs }),
|
|
650
|
+
ts: nowTs,
|
|
651
|
+
entries: [...mergeSubEntries(rec?.entries ?? [], parentCache.values()).values()],
|
|
652
|
+
};
|
|
653
|
+
});
|
|
617
654
|
}
|
|
618
655
|
}
|
|
619
656
|
}
|
|
@@ -651,52 +688,502 @@ export function SubAgentPanel(props) {
|
|
|
651
688
|
};
|
|
652
689
|
// ── bumpRenderTick: force re-render (visual-cache pattern) ──
|
|
653
690
|
const bump = () => setRenderTick((v) => v + 1);
|
|
691
|
+
// ── history scan(mount/switch、重试与消息驱动 rescan 共用)──
|
|
692
|
+
/** 将子会话已 idle 的 running 条目标记为已落定。
|
|
693
|
+
* 纯 map 变换——是否持久化由调用方决定。 */
|
|
694
|
+
const settleIdleEntries = (prev) => {
|
|
695
|
+
let changed = false;
|
|
696
|
+
const next = new Map(prev);
|
|
697
|
+
for (const [id, entry] of next) {
|
|
698
|
+
if (entry.status !== "running" && entry.status !== "cancel_requested")
|
|
699
|
+
continue;
|
|
700
|
+
// 无链接条目:先用宿主/本地库按 call id 回填子会话 id;
|
|
701
|
+
// 回填不了就维持原状(不猜测、不设时间阈值)。
|
|
702
|
+
let sid = entry.sessionId;
|
|
703
|
+
if (!sid) {
|
|
704
|
+
try {
|
|
705
|
+
const callId = String(entry.id).replace(/^tool:/, "");
|
|
706
|
+
sid = props.api.session.resolveChild?.({
|
|
707
|
+
parentId: props.sessionId, callId, agent: entry.agent, startedAt: entry.startedAt,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
catch { }
|
|
711
|
+
if (!sid)
|
|
712
|
+
continue;
|
|
713
|
+
next.set(id, { ...entry, sessionId: sid });
|
|
714
|
+
changed = true;
|
|
715
|
+
}
|
|
716
|
+
try {
|
|
717
|
+
const st = props.api.session.status(sid);
|
|
718
|
+
if (!st || st.type !== "idle")
|
|
719
|
+
continue;
|
|
720
|
+
const tokens = props.api.usage.readSessionTokens(sid);
|
|
721
|
+
const cost = props.api.usage.readSessionCost(sid);
|
|
722
|
+
const finalStatus = entry.status === "cancel_requested" && entry.abortAccepted
|
|
723
|
+
? "cancelled"
|
|
724
|
+
: "done";
|
|
725
|
+
next.set(id, {
|
|
726
|
+
...entry, sessionId: sid, status: finalStatus, endedAt: Date.now(),
|
|
727
|
+
tokens: tokens ?? entry.tokens,
|
|
728
|
+
cost: cost ?? entry.cost,
|
|
729
|
+
});
|
|
730
|
+
changed = true;
|
|
731
|
+
}
|
|
732
|
+
catch { }
|
|
733
|
+
}
|
|
734
|
+
return changed ? next : prev;
|
|
735
|
+
};
|
|
736
|
+
/** 周期性兜底遗漏的 session.idle 事件(僵尸 running 条目)。
|
|
737
|
+
* 由 500ms 定时器驱动,即使没有事件到达也能让条目落定。 */
|
|
738
|
+
const reconcileIdleEntries = () => {
|
|
739
|
+
try {
|
|
740
|
+
const before = entryMap();
|
|
741
|
+
const after = settleIdleEntries(before);
|
|
742
|
+
if (after !== before)
|
|
743
|
+
setEntryMap(after);
|
|
744
|
+
}
|
|
745
|
+
catch { }
|
|
746
|
+
};
|
|
747
|
+
/** 扫描会话历史并 merge 进 entry map。
|
|
748
|
+
* 消息数据尚不可用时返回 false,供调用方重试。 */
|
|
749
|
+
const runSessionScan = (sid, replace) => {
|
|
750
|
+
let msgs;
|
|
751
|
+
try {
|
|
752
|
+
msgs = props.api.session.messages(sid);
|
|
753
|
+
}
|
|
754
|
+
catch { }
|
|
755
|
+
if (!msgs || msgs.length === 0)
|
|
756
|
+
return false;
|
|
757
|
+
// scan 结果先写入内存(setEntryMapRaw);scan 末尾统一走 persistEntries 的 merge-safe 落盘。
|
|
758
|
+
setEntryMapRaw((prev) => {
|
|
759
|
+
// 优先从模块级缓存加载,KV 仅作缓存未命中时的回退
|
|
760
|
+
const next = replace
|
|
761
|
+
? new Map(globalEntryCache.get(sid) ?? loadEntries(sid))
|
|
762
|
+
: new Map(prev);
|
|
763
|
+
// 从 KV 加载当前会话的清除名单,扫描时跳过被手动清除的历史条目
|
|
764
|
+
const { parentSid: scanPSid, isChild: scanChild } = resolveParent(sid);
|
|
765
|
+
const scanRec = loadSessionData()[scanPSid];
|
|
766
|
+
const clearedIds = new Set(scanChild ? scanRec?.children?.[sid]?.clearedIds : scanRec?.clearedIds);
|
|
767
|
+
try {
|
|
768
|
+
for (const msg of msgs) {
|
|
769
|
+
const parts = props.api.session.part(msg.id) ?? [];
|
|
770
|
+
for (const partRaw of parts) {
|
|
771
|
+
const part = partRaw;
|
|
772
|
+
// Subtask entries are purely event-driven — never created by scan.
|
|
773
|
+
// (SubtaskPart exists from spawn, not completion, so we cannot infer status.)
|
|
774
|
+
if (part.type === "tool") {
|
|
775
|
+
const tool = String(part.tool ?? "");
|
|
776
|
+
if (!SUBAGENT_TOOLS.has(tool))
|
|
777
|
+
continue;
|
|
778
|
+
const id = `tool:${String(part.id ?? "")}`;
|
|
779
|
+
if (!part.id)
|
|
780
|
+
continue;
|
|
781
|
+
const st = part.state;
|
|
782
|
+
const rawStatus = String(st?.status ?? "");
|
|
783
|
+
const scanInput = st?.input;
|
|
784
|
+
// TUI 数据层把子代理 metadata 放在 part 级;旧缓存
|
|
785
|
+
// 形状可能嵌在 state 里。两处都读。
|
|
786
|
+
const scanMeta = partMetadata(part, st);
|
|
787
|
+
let scanSubSid = scanMeta?.session_id !== undefined ? String(scanMeta.session_id)
|
|
788
|
+
: scanMeta?.sessionId !== undefined ? String(scanMeta.sessionId)
|
|
789
|
+
: scanMeta?.sessionID !== undefined ? String(scanMeta.sessionID)
|
|
790
|
+
: undefined;
|
|
791
|
+
// 宿主缓存/历史缺少子会话链接时,用本地库按工具 call id 回填。
|
|
792
|
+
if (!scanSubSid) {
|
|
793
|
+
try {
|
|
794
|
+
scanSubSid = props.api.session.resolveChild?.({ parentId: sid, callId: String(part.id) });
|
|
795
|
+
}
|
|
796
|
+
catch { }
|
|
797
|
+
}
|
|
798
|
+
const existingKey = findSubEntryKey(next, id, scanSubSid);
|
|
799
|
+
const exists = existingKey ? next.get(existingKey) : undefined;
|
|
800
|
+
// 已手动清除的条目:scan 发现但不在内存 → 跳过重建
|
|
801
|
+
if (!exists && clearedIds.has(id))
|
|
802
|
+
continue;
|
|
803
|
+
// Only create entries for tool calls that entered execution.
|
|
804
|
+
// "pending" / empty: skip new entries; allow heuristics for existing ones below.
|
|
805
|
+
if ((rawStatus === "pending" || rawStatus === "") && !exists)
|
|
806
|
+
continue;
|
|
807
|
+
// "error": only update existing, never create a new entry
|
|
808
|
+
if (rawStatus === "error") {
|
|
809
|
+
if (exists && exists.status === "running") {
|
|
810
|
+
next.set(existingKey ?? id, { ...exists, status: "error", endedAt: Date.now() });
|
|
811
|
+
}
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
// 已落定的条目:仅在补全缺失数据时回访
|
|
815
|
+
// (session/tokens/cost/model)。其终态必须保持。
|
|
816
|
+
const settled = exists !== undefined &&
|
|
817
|
+
exists.status !== "running" &&
|
|
818
|
+
exists.status !== "cancel_requested";
|
|
819
|
+
const needsRepair = settled &&
|
|
820
|
+
(exists.sessionId === undefined ||
|
|
821
|
+
exists.tokens === undefined ||
|
|
822
|
+
exists.cost === undefined ||
|
|
823
|
+
!validModel(exists.model));
|
|
824
|
+
if (settled && !needsRepair)
|
|
825
|
+
continue;
|
|
826
|
+
let status = "running";
|
|
827
|
+
if (rawStatus === "completed")
|
|
828
|
+
status = "done";
|
|
829
|
+
// Background tasks: tool completion ≠ agent completion — keep running until session.idle
|
|
830
|
+
// 仅在 metadata 确认已派生子会话时保持 running。
|
|
831
|
+
if (isBackgroundInput(scanInput) && status === "done") {
|
|
832
|
+
if (scanSubSid !== undefined)
|
|
833
|
+
status = "running";
|
|
834
|
+
}
|
|
835
|
+
// 子会话存活判定:工具调用已完成但子会话仍 busy,
|
|
836
|
+
// 说明子代理本身仍在运行(覆盖缓存 part 中
|
|
837
|
+
// 缺少 background 标志的 input)。
|
|
838
|
+
if (status === "done" && scanSubSid) {
|
|
839
|
+
try {
|
|
840
|
+
const childStatus = props.api.session.status(scanSubSid);
|
|
841
|
+
if (childStatus && childStatus.type !== "idle")
|
|
842
|
+
status = "running";
|
|
843
|
+
}
|
|
844
|
+
catch { }
|
|
845
|
+
}
|
|
846
|
+
if (settled) {
|
|
847
|
+
// 修复流程:保留已记录的终态。
|
|
848
|
+
status = exists.status;
|
|
849
|
+
}
|
|
850
|
+
else if (exists && status === "running") {
|
|
851
|
+
// Running entry with no explicit status improvement from part:
|
|
852
|
+
// try message-level heuristics first, then time-based fallback.
|
|
853
|
+
if (!rawStatus) {
|
|
854
|
+
const msgTokens = msg?.tokens;
|
|
855
|
+
if (msgTokens && (Number(msgTokens.input) > 0 || Number(msgTokens.output) > 0)) {
|
|
856
|
+
status = "done"; // LLM returned tokens → agent completed
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
// 无终态证据时不猜测:等本地库/事件回填子会话状态后由 reconcile 落定。
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
// If already tracked as running but tool state says completed/error → update
|
|
868
|
+
// If not tracked → add fresh
|
|
869
|
+
const agent = String(part.subagent_type ?? scanInput?.agent ?? scanInput?.subagent_type ?? tool);
|
|
870
|
+
const prompt = String(scanInput?.prompt ?? part.description ?? "");
|
|
871
|
+
const desc = scanInput?.description !== undefined ? String(scanInput.description) : "";
|
|
872
|
+
const title = desc || truncate(prompt.replace(/\n/g, " ").trim(), 40);
|
|
873
|
+
let tokens;
|
|
874
|
+
if (scanSubSid)
|
|
875
|
+
tokens = props.api.usage.readSessionTokens(scanSubSid);
|
|
876
|
+
let cost;
|
|
877
|
+
if (scanSubSid)
|
|
878
|
+
cost = props.api.usage.readSessionCost(scanSubSid);
|
|
879
|
+
let model;
|
|
880
|
+
if (scanSubSid)
|
|
881
|
+
model = props.api.usage.readSessionModel(scanSubSid);
|
|
882
|
+
const entryKey = existingKey ?? id;
|
|
883
|
+
next.set(entryKey, {
|
|
884
|
+
...(exists ?? { id }),
|
|
885
|
+
title: title || exists?.title || "",
|
|
886
|
+
agent: exists?.agent ?? agent,
|
|
887
|
+
prompt: prompt || exists?.prompt || "",
|
|
888
|
+
// Preserve existing values (from handleSessionEnd / KV) — scan must not overwrite
|
|
889
|
+
tokens: exists?.tokens ?? tokens,
|
|
890
|
+
cost: exists?.cost ?? cost,
|
|
891
|
+
model: validModel(exists?.model) ? exists.model : model,
|
|
892
|
+
sessionId: exists?.sessionId ?? scanSubSid,
|
|
893
|
+
status,
|
|
894
|
+
startedAt: exists?.startedAt || Date.now(),
|
|
895
|
+
endedAt: exists?.endedAt ?? (status === "done" ? Date.now() : undefined),
|
|
896
|
+
origin: "tool",
|
|
897
|
+
background: isBackgroundInput(scanInput) || exists?.background === true,
|
|
898
|
+
});
|
|
899
|
+
if (entryKey !== id)
|
|
900
|
+
next.delete(id);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
catch { }
|
|
906
|
+
return next;
|
|
907
|
+
});
|
|
908
|
+
// Reconcile: check running entries against live child session status.
|
|
909
|
+
// Covers session.idle events missed while user was inside a child session.
|
|
910
|
+
setEntryMapRaw((prev) => settleIdleEntries(prev));
|
|
911
|
+
// 持久化 scan 修复结果(merge-safe:缺失字段始终以 KV 现有值为准),
|
|
912
|
+
// 让恢复的条目在重新挂载后仍在,并能同步到其他实例。
|
|
913
|
+
const snapshot = entryMap();
|
|
914
|
+
if (snapshot.size > 0) {
|
|
915
|
+
globalEntryCache.set(sid, new Map(snapshot));
|
|
916
|
+
persistEntries(sid, snapshot);
|
|
917
|
+
}
|
|
918
|
+
return true;
|
|
919
|
+
};
|
|
920
|
+
let rescanTimer;
|
|
921
|
+
/** 当最新消息与 entry map 不一致时为 true:子代理 tool part 尚未建条目,
|
|
922
|
+
* 或非后台 tool 已完成而条目仍停在 running。
|
|
923
|
+
* 事件驱动 rescan 与周期性 discovery poll 共用的轻量检查:
|
|
924
|
+
* 即使宿主的所有事件订阅都静默失效,
|
|
925
|
+
* 也能恢复条目。 */
|
|
926
|
+
const missingNewestToolPart = () => {
|
|
927
|
+
try {
|
|
928
|
+
const msgs = props.api.session.messages(props.sessionId);
|
|
929
|
+
if (!msgs || msgs.length === 0)
|
|
930
|
+
return false;
|
|
931
|
+
const map = entryMap();
|
|
932
|
+
const from = Math.max(0, msgs.length - 6);
|
|
933
|
+
for (let i = msgs.length - 1; i >= from; i--) {
|
|
934
|
+
const parts = props.api.session.part(msgs[i]?.id) ?? [];
|
|
935
|
+
for (const part of parts) {
|
|
936
|
+
if (!part || part.type !== "tool")
|
|
937
|
+
continue;
|
|
938
|
+
if (!SUBAGENT_TOOLS.has(String(part.tool ?? "")))
|
|
939
|
+
continue;
|
|
940
|
+
const partId = String(part.id ?? "");
|
|
941
|
+
if (!partId)
|
|
942
|
+
continue;
|
|
943
|
+
const st = part.state;
|
|
944
|
+
const rawStatus = String(st?.status ?? "");
|
|
945
|
+
// scan 从不为 pending/error part 创建条目;
|
|
946
|
+
// 这里忽略它们可避免对一直保持该状态的 part 反复 rescan。
|
|
947
|
+
if (rawStatus !== "running" && rawStatus !== "completed")
|
|
948
|
+
continue;
|
|
949
|
+
const id = `tool:${partId}`;
|
|
950
|
+
const meta = partMetadata(part, st);
|
|
951
|
+
const subSid = meta?.session_id !== undefined ? String(meta.session_id)
|
|
952
|
+
: meta?.sessionId !== undefined ? String(meta.sessionId)
|
|
953
|
+
: undefined;
|
|
954
|
+
const key = findSubEntryKey(map, id, subSid);
|
|
955
|
+
const entry = key ? map.get(key) : undefined;
|
|
956
|
+
if (!entry)
|
|
957
|
+
return true;
|
|
958
|
+
// 非后台 tool 已完成、条目却仍停在 running:补一次 scan 收尾
|
|
959
|
+
// (success 事件可能因缺少 metadata 被适配层丢弃)。
|
|
960
|
+
const input = st?.input;
|
|
961
|
+
const isBackground = isBackgroundInput(input);
|
|
962
|
+
const stillRunning = entry.status === "running" || entry.status === "cancel_requested";
|
|
963
|
+
if (rawStatus === "completed" && !isBackground && stillRunning)
|
|
964
|
+
return true;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
catch { }
|
|
969
|
+
return false;
|
|
970
|
+
};
|
|
971
|
+
/** 新消息到达时防抖重扫。覆盖 scan 早于宿主消息缓存
|
|
972
|
+
* 加载完成的竞态。 */
|
|
973
|
+
const scheduleRescan = () => {
|
|
974
|
+
if (disposed)
|
|
975
|
+
return;
|
|
976
|
+
clearTimeout(rescanTimer);
|
|
977
|
+
rescanTimer = setTimeout(() => {
|
|
978
|
+
if (disposed)
|
|
979
|
+
return;
|
|
980
|
+
untrack(() => {
|
|
981
|
+
if (!missingNewestToolPart())
|
|
982
|
+
return;
|
|
983
|
+
runSessionScan(props.sessionId, false);
|
|
984
|
+
bump();
|
|
985
|
+
});
|
|
986
|
+
}, 400);
|
|
987
|
+
};
|
|
988
|
+
// usage enrichment 轮询的 round-robin 游标。
|
|
989
|
+
let enrichCursor = 0;
|
|
654
990
|
onMount(() => {
|
|
655
991
|
// Fast clock for smooth time display, separate from token polling
|
|
656
992
|
const clock = setInterval(() => { setNow(Date.now()); bump(); }, 100);
|
|
657
993
|
// Token poll — runs every 500ms for running entries
|
|
994
|
+
let tick = 0;
|
|
658
995
|
const tokenTimer = setInterval(() => {
|
|
996
|
+
tick++;
|
|
659
997
|
untrack(() => {
|
|
998
|
+
let enriched;
|
|
999
|
+
let attempted = 0;
|
|
660
1000
|
setEntryMapRaw((prev) => {
|
|
661
1001
|
let changed = false;
|
|
662
1002
|
const next = new Map(prev);
|
|
1003
|
+
// 每 ~2s 从本地库同步衍生会话(spawn):没有对应工具条目的
|
|
1004
|
+
// 子会话补成 sub: 条目(标题/agent/用量/终态都来自数据库)。
|
|
1005
|
+
if (tick % 4 === 0) {
|
|
1006
|
+
try {
|
|
1007
|
+
const children = props.api.session.listChildren?.(props.sessionId);
|
|
1008
|
+
if (children) {
|
|
1009
|
+
const linked = new Set();
|
|
1010
|
+
for (const e of next.values())
|
|
1011
|
+
if (e.sessionId)
|
|
1012
|
+
linked.add(e.sessionId);
|
|
1013
|
+
const { parentSid, isChild } = resolveParent(props.sessionId);
|
|
1014
|
+
const rec = loadSessionData()[parentSid];
|
|
1015
|
+
const cleared = new Set(isChild ? rec?.children?.[props.sessionId]?.clearedIds : rec?.clearedIds);
|
|
1016
|
+
for (const child of children) {
|
|
1017
|
+
if (!child.id || linked.has(child.id))
|
|
1018
|
+
continue;
|
|
1019
|
+
const id = `sub:${child.id}`;
|
|
1020
|
+
if (cleared.has(id))
|
|
1021
|
+
continue;
|
|
1022
|
+
const finished = child.timeIdle != null && child.active !== true;
|
|
1023
|
+
const status = finished
|
|
1024
|
+
? (child.idleOutcome === "interrupted" ? "cancelled" : "done")
|
|
1025
|
+
: "running";
|
|
1026
|
+
next.set(id, {
|
|
1027
|
+
id,
|
|
1028
|
+
title: child.title ?? child.agent ?? "",
|
|
1029
|
+
agent: child.agent ?? "?",
|
|
1030
|
+
prompt: "",
|
|
1031
|
+
status,
|
|
1032
|
+
sessionId: child.id,
|
|
1033
|
+
startedAt: child.timeCreated ?? Date.now(),
|
|
1034
|
+
endedAt: finished ? child.timeIdle : undefined,
|
|
1035
|
+
tokens: child.tokens,
|
|
1036
|
+
cost: child.cost,
|
|
1037
|
+
model: child.model,
|
|
1038
|
+
origin: "sub",
|
|
1039
|
+
});
|
|
1040
|
+
changed = true;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
catch { }
|
|
1045
|
+
// 恢复运行:终态条目若子会话被 resume(宿主或本地库回报 busy),
|
|
1046
|
+
// 回到 running,等下一次真实 idle 再落定。
|
|
1047
|
+
for (const [id, entry] of next) {
|
|
1048
|
+
if (entry.status !== "done" && entry.status !== "error")
|
|
1049
|
+
continue;
|
|
1050
|
+
const sid = entry.sessionId;
|
|
1051
|
+
if (!sid)
|
|
1052
|
+
continue;
|
|
1053
|
+
try {
|
|
1054
|
+
if (props.api.session.status(sid)?.type === "busy") {
|
|
1055
|
+
next.set(id, { ...entry, status: "running", endedAt: undefined });
|
|
1056
|
+
changed = true;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
catch { }
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
// 候选:仍缺 usage 数据的条目。以 round-robin 遍历,
|
|
1063
|
+
// 避免一串读不到数据的会话(如 free-model 子会话)
|
|
1064
|
+
// 饿死列表其余部分;每个 tick 只读取少数会话。
|
|
1065
|
+
const candidates = [];
|
|
663
1066
|
for (const [id, entry] of next) {
|
|
664
|
-
|
|
665
|
-
|
|
1067
|
+
const running = entry.status === "running" || entry.status === "cancel_requested";
|
|
1068
|
+
const missing = entry.tokens === undefined || entry.cost === undefined || !validModel(entry.model);
|
|
1069
|
+
// 无链接条目(含历史 done)也参与:先用宿主/本地库回填 sid,再补 usage。
|
|
1070
|
+
if (entry.sessionId && !running && !missing)
|
|
1071
|
+
continue;
|
|
1072
|
+
candidates.push(id);
|
|
1073
|
+
}
|
|
1074
|
+
if (candidates.length > 0) {
|
|
1075
|
+
const start = enrichCursor % candidates.length;
|
|
1076
|
+
let budget = 4;
|
|
1077
|
+
for (let k = 0; k < candidates.length && budget > 0; k++) {
|
|
1078
|
+
const id = candidates[(start + k) % candidates.length];
|
|
1079
|
+
let entry = next.get(id);
|
|
1080
|
+
if (!entry)
|
|
1081
|
+
continue;
|
|
1082
|
+
if (!entry.sessionId) {
|
|
1083
|
+
try {
|
|
1084
|
+
const callId = String(entry.id).replace(/^tool:/, "");
|
|
1085
|
+
const sid = props.api.session.resolveChild?.({
|
|
1086
|
+
parentId: props.sessionId, callId, agent: entry.agent, startedAt: entry.startedAt,
|
|
1087
|
+
});
|
|
1088
|
+
if (sid) {
|
|
1089
|
+
entry = { ...entry, sessionId: sid };
|
|
1090
|
+
next.set(id, entry);
|
|
1091
|
+
changed = true;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
catch { }
|
|
1095
|
+
}
|
|
1096
|
+
if (!entry.sessionId)
|
|
1097
|
+
continue;
|
|
1098
|
+
const childSid = entry.sessionId;
|
|
1099
|
+
// 只从子会话读取,绝不读父会话。较旧的子会话
|
|
1100
|
+
// 可能不在本地缓存中,此时它们仍属于
|
|
1101
|
+
// 本面板的 map,读取是安全的。
|
|
666
1102
|
let isChild = false;
|
|
667
1103
|
try {
|
|
668
|
-
const s = props.api.session.get(
|
|
669
|
-
isChild = s
|
|
1104
|
+
const s = props.api.session.get(childSid);
|
|
1105
|
+
isChild = !s || s.parentID === props.sessionId;
|
|
1106
|
+
}
|
|
1107
|
+
catch {
|
|
1108
|
+
isChild = true;
|
|
670
1109
|
}
|
|
671
|
-
catch { }
|
|
672
1110
|
if (!isChild)
|
|
673
1111
|
continue;
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
const
|
|
1112
|
+
budget--;
|
|
1113
|
+
attempted++;
|
|
1114
|
+
const running = entry.status === "running" || entry.status === "cancel_requested";
|
|
1115
|
+
let entryChanged = false;
|
|
677
1116
|
const nextEntry = { ...entry };
|
|
678
|
-
if (
|
|
679
|
-
|
|
680
|
-
|
|
1117
|
+
if (running || entry.tokens === undefined) {
|
|
1118
|
+
const total = props.api.usage.readSessionTokens(childSid);
|
|
1119
|
+
if (total !== undefined && total !== entry.tokens) {
|
|
1120
|
+
nextEntry.tokens = total;
|
|
1121
|
+
entryChanged = true;
|
|
1122
|
+
}
|
|
681
1123
|
}
|
|
682
|
-
if (
|
|
683
|
-
|
|
1124
|
+
if (running || entry.cost === undefined) {
|
|
1125
|
+
const cost = props.api.usage.readSessionCost(childSid);
|
|
1126
|
+
if (cost !== undefined && cost !== entry.cost) {
|
|
1127
|
+
nextEntry.cost = cost;
|
|
1128
|
+
entryChanged = true;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
if (running || !validModel(entry.model)) {
|
|
1132
|
+
const model = props.api.usage.readSessionModel(childSid);
|
|
1133
|
+
if (model && model !== entry.model) {
|
|
1134
|
+
nextEntry.model = model;
|
|
1135
|
+
entryChanged = true;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
if (running) {
|
|
1139
|
+
const todo = props.api.usage.readSessionTodo(childSid);
|
|
1140
|
+
if (todo !== undefined && (todo.total !== entry.todoTotal || todo.done !== entry.todoDone)) {
|
|
684
1141
|
nextEntry.todoTotal = todo.total;
|
|
685
1142
|
nextEntry.todoDone = todo.done;
|
|
686
|
-
|
|
1143
|
+
entryChanged = true;
|
|
687
1144
|
}
|
|
688
1145
|
}
|
|
689
|
-
if (
|
|
690
|
-
|
|
1146
|
+
if (entryChanged) {
|
|
1147
|
+
next.set(id, nextEntry);
|
|
691
1148
|
changed = true;
|
|
692
1149
|
}
|
|
693
|
-
if (changed)
|
|
694
|
-
next.set(id, nextEntry);
|
|
695
1150
|
}
|
|
1151
|
+
enrichCursor = (start + Math.max(1, attempted)) % candidates.length;
|
|
696
1152
|
}
|
|
697
|
-
|
|
1153
|
+
if (!changed)
|
|
1154
|
+
return prev;
|
|
1155
|
+
enriched = next;
|
|
1156
|
+
return next;
|
|
698
1157
|
});
|
|
1158
|
+
if (enriched) {
|
|
1159
|
+
// usage enrichment 是真实状态变更(cost/tokens/model)——通过与事件
|
|
1160
|
+
// 处理器相同的 merge-safe 路径持久化。
|
|
1161
|
+
globalEntryCache.set(props.sessionId, new Map(enriched));
|
|
1162
|
+
persistEntries(props.sessionId, enriched);
|
|
1163
|
+
}
|
|
699
1164
|
});
|
|
1165
|
+
reconcileIdleEntries();
|
|
1166
|
+
// discovery poll(约每 2s):恢复从未通过宿主事件到达的
|
|
1167
|
+
// 子代理 part。与事件订阅的健康状况无关。
|
|
1168
|
+
if (tick % 4 === 0) {
|
|
1169
|
+
untrack(() => {
|
|
1170
|
+
if (missingNewestToolPart()) {
|
|
1171
|
+
runSessionScan(props.sessionId, false);
|
|
1172
|
+
bump();
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
// 周期性对账(约每 30s):存在 running 条目时与历史重扫一次,
|
|
1177
|
+
// 兜底丢失的 success / idle 事件(含消息同步后才可见的 part)。
|
|
1178
|
+
if (tick % 60 === 0) {
|
|
1179
|
+
untrack(() => {
|
|
1180
|
+
const hasRunning = entryList().some((e) => e.status === "running" || e.status === "cancel_requested");
|
|
1181
|
+
if (hasRunning) {
|
|
1182
|
+
runSessionScan(props.sessionId, false);
|
|
1183
|
+
bump();
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
700
1187
|
bump();
|
|
701
1188
|
}, 500);
|
|
702
1189
|
bump();
|
|
@@ -704,7 +1191,7 @@ export function SubAgentPanel(props) {
|
|
|
704
1191
|
handlePartUpdated(e);
|
|
705
1192
|
bump();
|
|
706
1193
|
});
|
|
707
|
-
const unsubMsg = props.api.event.on("message.updated", () => bump());
|
|
1194
|
+
const unsubMsg = props.api.event.on("message.updated", () => { bump(); scheduleRescan(); });
|
|
708
1195
|
const unsubIdle = props.api.event.on("session.idle", (e) => {
|
|
709
1196
|
handleSessionEnd(e, "done");
|
|
710
1197
|
bump();
|
|
@@ -715,6 +1202,7 @@ export function SubAgentPanel(props) {
|
|
|
715
1202
|
});
|
|
716
1203
|
onCleanup(() => {
|
|
717
1204
|
disposed = true;
|
|
1205
|
+
clearTimeout(rescanTimer);
|
|
718
1206
|
clearInterval(clock);
|
|
719
1207
|
clearInterval(tokenTimer);
|
|
720
1208
|
unsubPart();
|
|
@@ -735,7 +1223,9 @@ export function SubAgentPanel(props) {
|
|
|
735
1223
|
const tick = clearTick(); // 外部触发清除时 +1,effect 重跑
|
|
736
1224
|
const forceReload = tick !== lastTick && !switched;
|
|
737
1225
|
lastTick = tick;
|
|
738
|
-
|
|
1226
|
+
let attempts = 0;
|
|
1227
|
+
let retryTimer;
|
|
1228
|
+
const run = () => {
|
|
739
1229
|
untrack(() => {
|
|
740
1230
|
if (switched) {
|
|
741
1231
|
const { parentSid, isChild } = resolveParent(sid);
|
|
@@ -746,155 +1236,29 @@ export function SubAgentPanel(props) {
|
|
|
746
1236
|
setScrollOffset(saved);
|
|
747
1237
|
// 刷新父会话的访问时间 TTL,防止活跃会话的数据过期
|
|
748
1238
|
if (!isChild && data[sid]?.entries?.length) {
|
|
749
|
-
|
|
750
|
-
|
|
1239
|
+
void Promise.resolve(updateSessionData(props.api.kv, (latest) => {
|
|
1240
|
+
if (latest[sid]?.entries?.length)
|
|
1241
|
+
latest[sid].ts = Date.now();
|
|
1242
|
+
})).catch(() => { });
|
|
751
1243
|
}
|
|
752
1244
|
}
|
|
753
|
-
|
|
754
|
-
//
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
const { parentSid: scanPSid, isChild: scanChild } = resolveParent(sid);
|
|
762
|
-
const scanRec = loadSessionData()[scanPSid];
|
|
763
|
-
const clearedIds = new Set(scanChild ? scanRec?.children?.[sid]?.clearedIds : scanRec?.clearedIds);
|
|
764
|
-
try {
|
|
765
|
-
const msgs = props.api.session.messages(sid);
|
|
766
|
-
if (msgs && msgs.length) {
|
|
767
|
-
for (const msg of msgs) {
|
|
768
|
-
const parts = props.api.session.part(msg.id) ?? [];
|
|
769
|
-
for (const partRaw of parts) {
|
|
770
|
-
const part = partRaw;
|
|
771
|
-
// Subtask entries are purely event-driven — never created by scan.
|
|
772
|
-
// (SubtaskPart exists from spawn, not completion, so we cannot infer status.)
|
|
773
|
-
if (part.type === "tool") {
|
|
774
|
-
const tool = String(part.tool ?? "");
|
|
775
|
-
if (!SUBAGENT_TOOLS.has(tool))
|
|
776
|
-
continue;
|
|
777
|
-
const id = `tool:${String(part.id ?? "")}`;
|
|
778
|
-
if (!part.id)
|
|
779
|
-
continue;
|
|
780
|
-
const st = part.state;
|
|
781
|
-
const rawStatus = String(st?.status ?? "");
|
|
782
|
-
const scanStMeta = st?.metadata;
|
|
783
|
-
const scanSubSid = scanStMeta?.session_id !== undefined ? String(scanStMeta.session_id)
|
|
784
|
-
: scanStMeta?.sessionId !== undefined ? String(scanStMeta.sessionId)
|
|
785
|
-
: undefined;
|
|
786
|
-
const existingKey = findSubEntryKey(next, id, scanSubSid);
|
|
787
|
-
const exists = existingKey ? next.get(existingKey) : undefined;
|
|
788
|
-
// 已手动清除的条目:scan 发现但不在内存 → 跳过重建
|
|
789
|
-
if (!exists && clearedIds.has(id))
|
|
790
|
-
continue;
|
|
791
|
-
// Only create entries for tool calls that entered execution.
|
|
792
|
-
// "pending" / empty: skip new entries; allow heuristics for existing ones below.
|
|
793
|
-
if ((rawStatus === "pending" || rawStatus === "") && !exists)
|
|
794
|
-
continue;
|
|
795
|
-
// "error": only update existing, never create a new entry
|
|
796
|
-
if (rawStatus === "error") {
|
|
797
|
-
if (exists && exists.status === "running") {
|
|
798
|
-
next.set(existingKey ?? id, { ...exists, status: "error", endedAt: Date.now() });
|
|
799
|
-
}
|
|
800
|
-
continue;
|
|
801
|
-
}
|
|
802
|
-
let status = "running";
|
|
803
|
-
if (rawStatus === "completed")
|
|
804
|
-
status = "done";
|
|
805
|
-
// Background tasks: tool completion ≠ agent completion — keep running until session.idle
|
|
806
|
-
// Only keep running if state metadata confirms a child session was spawned.
|
|
807
|
-
if ((st?.input?.run_in_background === true || st?.input?.background === true) && status === "done") {
|
|
808
|
-
const scanStMeta = st?.metadata;
|
|
809
|
-
const scanHasChild = scanStMeta?.session_id !== undefined || scanStMeta?.sessionId !== undefined;
|
|
810
|
-
if (scanHasChild)
|
|
811
|
-
status = "running";
|
|
812
|
-
}
|
|
813
|
-
// Already settled → skip
|
|
814
|
-
if (exists && existingKey === id && exists.status !== "running" && exists.status !== "cancel_requested")
|
|
815
|
-
continue;
|
|
816
|
-
// Running entry with no explicit status improvement from part:
|
|
817
|
-
// try message-level heuristics first, then time-based fallback.
|
|
818
|
-
if (exists && status === "running") {
|
|
819
|
-
if (!rawStatus) {
|
|
820
|
-
const msgTokens = msg?.tokens;
|
|
821
|
-
if (msgTokens && (Number(msgTokens.input) > 0 || Number(msgTokens.output) > 0)) {
|
|
822
|
-
status = "done"; // LLM returned tokens → agent completed
|
|
823
|
-
}
|
|
824
|
-
else if (Date.now() - exists.startedAt > 30 * 60 * 1000) {
|
|
825
|
-
status = "done"; // >30 min idle → assume completed
|
|
826
|
-
}
|
|
827
|
-
else {
|
|
828
|
-
continue;
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
else {
|
|
832
|
-
continue;
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
// If already tracked as running but tool state says completed/error → update
|
|
836
|
-
// If not tracked → add fresh
|
|
837
|
-
const input = st?.input;
|
|
838
|
-
const agent = String(part.subagent_type ?? input?.agent ?? input?.subagent_type ?? tool);
|
|
839
|
-
const prompt = String(input?.prompt ?? part.description ?? "");
|
|
840
|
-
const desc = input?.description !== undefined ? String(input.description) : "";
|
|
841
|
-
const title = desc || truncate(prompt.replace(/\n/g, " ").trim(), 40);
|
|
842
|
-
let tokens;
|
|
843
|
-
if (scanSubSid)
|
|
844
|
-
tokens = props.api.usage.readSessionTokens(scanSubSid);
|
|
845
|
-
const ended = status === "done"; // "error" handled above, never reaches here
|
|
846
|
-
const entryKey = existingKey ?? id;
|
|
847
|
-
next.set(entryKey, {
|
|
848
|
-
id: exists?.id ?? id, title, agent, prompt,
|
|
849
|
-
// Preserve existing values (from handleSessionEnd / KV) — scan must not overwrite
|
|
850
|
-
tokens: exists?.tokens ?? tokens,
|
|
851
|
-
sessionId: exists?.sessionId ?? scanSubSid,
|
|
852
|
-
status,
|
|
853
|
-
startedAt: exists?.startedAt || Date.now(),
|
|
854
|
-
endedAt: ended ? (exists?.endedAt || Date.now()) : undefined,
|
|
855
|
-
});
|
|
856
|
-
if (entryKey !== id)
|
|
857
|
-
next.delete(id);
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
catch { }
|
|
864
|
-
return next;
|
|
865
|
-
});
|
|
866
|
-
// Reconcile: check running entries against live child session status.
|
|
867
|
-
// Covers session.idle events missed while user was inside a child session.
|
|
868
|
-
setEntryMapRaw((prev) => {
|
|
869
|
-
let changed = false;
|
|
870
|
-
const next = new Map(prev);
|
|
871
|
-
for (const [id, entry] of next) {
|
|
872
|
-
if ((entry.status !== "running" && entry.status !== "cancel_requested") || !entry.sessionId)
|
|
873
|
-
continue;
|
|
874
|
-
try {
|
|
875
|
-
const st = props.api.session.status(entry.sessionId);
|
|
876
|
-
if (!st || st.type !== "idle")
|
|
877
|
-
continue;
|
|
878
|
-
const tokens = props.api.usage.readSessionTokens(entry.sessionId);
|
|
879
|
-
const cost = props.api.usage.readSessionCost(entry.sessionId);
|
|
880
|
-
const finalStatus = entry.status === "cancel_requested" && entry.abortAccepted
|
|
881
|
-
? "cancelled"
|
|
882
|
-
: "done";
|
|
883
|
-
next.set(id, {
|
|
884
|
-
...entry, status: finalStatus, endedAt: Date.now(),
|
|
885
|
-
tokens: tokens ?? entry.tokens,
|
|
886
|
-
cost: cost ?? entry.cost,
|
|
887
|
-
});
|
|
888
|
-
changed = true;
|
|
889
|
-
}
|
|
890
|
-
catch { }
|
|
891
|
-
}
|
|
892
|
-
return changed ? next : prev;
|
|
893
|
-
});
|
|
894
|
-
bump();
|
|
1245
|
+
const ok = runSessionScan(sid, switched || forceReload);
|
|
1246
|
+
// 首次挂载时宿主消息缓存可能尚未就绪;
|
|
1247
|
+
// 重试几次,避免面板一直空着直到
|
|
1248
|
+
// 下一次切换会话。
|
|
1249
|
+
if (!ok && attempts < 4) {
|
|
1250
|
+
attempts++;
|
|
1251
|
+
retryTimer = setTimeout(run, 400);
|
|
1252
|
+
}
|
|
895
1253
|
});
|
|
896
|
-
|
|
897
|
-
|
|
1254
|
+
bump();
|
|
1255
|
+
};
|
|
1256
|
+
const t = setTimeout(run, 150);
|
|
1257
|
+
onCleanup(() => {
|
|
1258
|
+
clearTimeout(t);
|
|
1259
|
+
if (retryTimer)
|
|
1260
|
+
clearTimeout(retryTimer);
|
|
1261
|
+
});
|
|
898
1262
|
});
|
|
899
1263
|
// ── palette ──
|
|
900
1264
|
const pal = createMemo(() => {
|
|
@@ -1166,12 +1530,24 @@ export function SubAgentPanel(props) {
|
|
|
1166
1530
|
};
|
|
1167
1531
|
const timeColor = () => isActiveRunning ? pal().warning : isError ? pal().error : pal().muted;
|
|
1168
1532
|
// Entry label: collapsed shows title only, expanded shows title only too
|
|
1169
|
-
const tokenText = () => !isExpanded() && entry.tokens !== undefined && entry.tokens > 0
|
|
1533
|
+
const tokenText = () => !isExpanded() && props.showEntryTokens() && entry.tokens !== undefined && entry.tokens > 0
|
|
1170
1534
|
? ` ${fmtTokens(entry.tokens)}`
|
|
1171
1535
|
: "";
|
|
1172
|
-
const
|
|
1173
|
-
?
|
|
1536
|
+
const costText = () => !isExpanded() && props.showEntryCost() && entry.cost !== undefined && entry.cost > 0
|
|
1537
|
+
? ` $${entry.cost.toFixed(4)}`
|
|
1538
|
+
: "";
|
|
1539
|
+
const timeText = () => !isExpanded() && props.showEntryTime() && (elapsed() >= 2000 || entry.endedAt !== undefined)
|
|
1540
|
+
? fmtDuration(elapsed(), isActiveRunning, props.timeFormat())
|
|
1174
1541
|
: "";
|
|
1542
|
+
// 来源标记:⇢ = 后台衍生(工具输入 background),↳ = 衍生会话(spawn)。
|
|
1543
|
+
const originMark = () => {
|
|
1544
|
+
if (!props.showOrigin())
|
|
1545
|
+
return "";
|
|
1546
|
+
if (entry.origin === "sub")
|
|
1547
|
+
return "\u21b3";
|
|
1548
|
+
return entry.background === true ? "\u21e2" : "";
|
|
1549
|
+
};
|
|
1550
|
+
const originW = () => (originMark() ? 2 : 0);
|
|
1175
1551
|
const suffixW = () => {
|
|
1176
1552
|
let w = 0;
|
|
1177
1553
|
const t = timeText();
|
|
@@ -1180,9 +1556,12 @@ export function SubAgentPanel(props) {
|
|
|
1180
1556
|
const tk = tokenText();
|
|
1181
1557
|
if (tk)
|
|
1182
1558
|
w += visualWidth(tk);
|
|
1559
|
+
const c = costText();
|
|
1560
|
+
if (c)
|
|
1561
|
+
w += visualWidth(c);
|
|
1183
1562
|
return w;
|
|
1184
1563
|
};
|
|
1185
|
-
const labelAvail = () => Math.max(6, panelWidth() - gutter() - LEFT_PAD - suffixW());
|
|
1564
|
+
const labelAvail = () => Math.max(6, panelWidth() - gutter() - LEFT_PAD - originW() - suffixW());
|
|
1186
1565
|
const labelText = () => {
|
|
1187
1566
|
const max = labelAvail();
|
|
1188
1567
|
const text = entry.title || entry.agent;
|
|
@@ -1190,10 +1569,10 @@ export function SubAgentPanel(props) {
|
|
|
1190
1569
|
const pad = Math.max(0, max - visualWidth(truncated));
|
|
1191
1570
|
return truncated + " ".repeat(pad);
|
|
1192
1571
|
};
|
|
1193
|
-
return (_jsxs(_Fragment, { children: [_jsxs("text", { onMouseUp: () => toggleExpand(entry.id), children: [_jsx("span", { style: { fg: pal().muted }, children: isExpanded() ? "\u25bc" : "\u25b6" }), " ", _jsx("span", { style: { fg: statusColor() }, children: statusDot() }), " ", _jsx("span", { style: { fg: pal().text }, children: labelText() }), timeText() ? (_jsxs(_Fragment, { children: [" ", _jsx("span", { style: { fg: timeColor() }, children: timeText() })] })) : null, tokenText() ? (_jsx("span", { style: { fg: pal().muted }, children: tokenText() })) : null] }), _jsxs(Show, { when: isExpanded(), children: [_jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("agent.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("agent.label"))) }), _jsx("span", { style: { fg: pal().muted }, children: entry.agent })] }), _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("status.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("status.label"))) }), _jsx("span", { style: { fg: isActiveRunning ? pal().warning : isCancelled ? pal().muted : isError ? pal().error : pal().success }, children: isActiveRunning ? t("status.running") : isCancelled ? t("status.cancelled") : isError ? t("status.error") : t("status.done") })] }), _jsx(Show, { when: elapsed() >= 2000 || entry.endedAt !== undefined, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("time.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("time.label"))) }), _jsx("span", { style: { fg: pal().muted }, children:
|
|
1572
|
+
return (_jsxs(_Fragment, { children: [_jsxs("text", { onMouseUp: () => toggleExpand(entry.id), children: [_jsx("span", { style: { fg: pal().muted }, children: isExpanded() ? "\u25bc" : "\u25b6" }), " ", _jsx("span", { style: { fg: statusColor() }, children: statusDot() }), " ", originMark() ? _jsx("span", { style: { fg: pal().muted }, children: originMark() + " " }) : null, _jsx("span", { style: { fg: pal().text }, children: labelText() }), timeText() ? (_jsxs(_Fragment, { children: [" ", _jsx("span", { style: { fg: timeColor() }, children: timeText() })] })) : null, tokenText() ? (_jsx("span", { style: { fg: pal().muted }, children: tokenText() })) : null, costText() ? (_jsx("span", { style: { fg: pal().warning }, children: costText() })) : null] }), _jsxs(Show, { when: isExpanded(), children: [_jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("agent.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("agent.label"))) }), _jsx("span", { style: { fg: pal().muted }, children: entry.agent })] }), _jsx(Show, { when: props.showOrigin(), children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("origin.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("origin.label"))) }), _jsxs("span", { style: { fg: pal().muted }, children: [entry.origin === "sub" ? t("origin.sub") : t("origin.tool"), entry.background === true ? ` (${t("origin.background")})` : ""] })] }) }), _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("status.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("status.label"))) }), _jsx("span", { style: { fg: isActiveRunning ? pal().warning : isCancelled ? pal().muted : isError ? pal().error : pal().success }, children: isActiveRunning ? t("status.running") : isCancelled ? t("status.cancelled") : isError ? t("status.error") : t("status.done") })] }), _jsx(Show, { when: elapsed() >= 2000 || entry.endedAt !== undefined, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("time.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("time.label"))) }), _jsx("span", { style: { fg: pal().muted }, children: fmtDuration(elapsed(), isActiveRunning, props.timeFormat()) })] }) }), _jsx(Show, { when: entry.tokens !== undefined, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("tokens.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("tokens.label"))) }), _jsx("span", { style: { fg: pal().muted }, children: fmtTokens(entry.tokens) })] }) }), _jsx(Show, { when: entry.error, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().error }, children: [t("error.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("error.label"))) }), _jsx("span", { style: { fg: pal().error }, children: truncate(String(entry.error), expandedValAvail()) })] }) }), _jsx(Show, { when: entry.cost !== undefined, children: (() => {
|
|
1194
1573
|
const cost = entry.cost;
|
|
1195
1574
|
return (_jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("cost.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("cost.label"))) }), _jsxs("span", { style: { fg: pal().muted }, children: ["$", cost.toFixed(4)] })] }));
|
|
1196
|
-
})() }), _jsx(Show, { when: entry.model, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("model.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("model.label"))) }), _jsx("span", { style: { fg: pal().muted }, children: truncate(entry.model, expandedValAvail()) })] }) }), _jsx(Show, { when: entry.todoTotal !== undefined, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("todo.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("todo.label"))) }), _jsxs("span", { style: { fg: pal().muted }, children: [entry.todoDone, "/", entry.todoTotal] })] }) }), _jsx(Show, { when: entry.sessionId, children: _jsxs("text", { onMouseUp: async () => {
|
|
1575
|
+
})() }), _jsx(Show, { when: validModel(entry.model) ? entry.model : undefined, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("model.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("model.label"))) }), _jsx("span", { style: { fg: pal().muted }, children: truncate(entry.model, expandedValAvail()) })] }) }), _jsx(Show, { when: entry.todoTotal !== undefined, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("todo.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("todo.label"))) }), _jsxs("span", { style: { fg: pal().muted }, children: [entry.todoDone, "/", entry.todoTotal] })] }) }), _jsx(Show, { when: entry.sessionId, children: _jsxs("text", { onMouseUp: async () => {
|
|
1197
1576
|
const sessionId = entry.sessionId;
|
|
1198
1577
|
if (!sessionId)
|
|
1199
1578
|
return;
|