opencode-subagent-magazine 1.7.0 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_version.d.ts +1 -1
- package/dist/core/kv.d.ts +1 -0
- package/dist/core/types.d.ts +8 -0
- package/dist/i18n.d.ts +5 -0
- package/dist/panel/SubAgentPanel.d.ts +1 -0
- package/dist/panel/entry-map.d.ts +1 -0
- package/dist/panel/panel-api.d.ts +16 -0
- package/dist/tui.js +385 -218
- package/dist/v2/db.d.ts +14 -1
- package/dist/v2.js +473 -223
- package/package.json +17 -4
- package/dist/_version.js +0 -2
- package/dist/clipboard.js +0 -85
- package/dist/core/color.js +0 -68
- package/dist/core/format.js +0 -113
- package/dist/core/index.js +0 -6
- package/dist/core/kv.js +0 -63
- package/dist/core/state-machine.js +0 -27
- package/dist/core/types.js +0 -2
- package/dist/core/usage.js +0 -1
- package/dist/i18n.js +0 -333
- package/dist/index.js +0 -511
- package/dist/panel/SubAgentPanel.js +0 -1572
- package/dist/panel/entry-map.js +0 -99
- package/dist/panel/panel-api.js +0 -1
- package/dist/panel/session-routing.js +0 -4
- package/dist/panel/store.js +0 -5
- package/dist/server.js +0 -14
- package/dist/v2/commands.js +0 -420
- package/dist/v2/db.js +0 -267
- package/dist/v2/index.js +0 -83
- package/dist/v2/settings-menu.js +0 -225
- package/dist/v2/theme.js +0 -12
- package/dist/v2/types.js +0 -6
- package/dist/v2/v2-panel-api.js +0 -612
- package/src/_version.ts +0 -2
- package/src/clipboard.ts +0 -134
- package/src/core/color.ts +0 -70
- package/src/core/format.ts +0 -105
- package/src/core/index.ts +0 -6
- package/src/core/kv.ts +0 -77
- package/src/core/state-machine.ts +0 -46
- package/src/core/types.ts +0 -72
- package/src/core/usage.ts +0 -16
- package/src/i18n.ts +0 -345
- package/src/index.tsx +0 -564
- package/src/panel/SubAgentPanel.tsx +0 -1802
- package/src/panel/entry-map.ts +0 -115
- package/src/panel/panel-api.ts +0 -83
- package/src/panel/session-routing.ts +0 -10
- package/src/panel/store.ts +0 -8
- package/src/server.ts +0 -19
- package/src/v2/commands.ts +0 -408
- package/src/v2/db.ts +0 -274
- package/src/v2/index.tsx +0 -144
- package/src/v2/settings-menu.ts +0 -241
- package/src/v2/theme.ts +0 -14
- package/src/v2/types.ts +0 -167
- package/src/v2/v2-panel-api.ts +0 -515
|
@@ -1,1572 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/solid/jsx-runtime";
|
|
2
|
-
import { createMemo, createSignal, createEffect, onMount, onCleanup, untrack, Show, For, } from "solid-js";
|
|
3
|
-
import { PLUGIN_VERSION } from "../_version";
|
|
4
|
-
import { copyText } from "../clipboard";
|
|
5
|
-
import { createT } from "../i18n";
|
|
6
|
-
import { SUBAGENT_TOOLS } from "../core/types";
|
|
7
|
-
import { visualWidth, truncate, fmtDuration, fmtTokens, safeErrorMsg } from "../core/format";
|
|
8
|
-
import { rgb, desaturateTo, dimColor, FALLBACK, MAX_SAT } from "../core/color";
|
|
9
|
-
import { KV_PREFIX, updateSessionData } from "../core/kv";
|
|
10
|
-
import { globalEntryCache, clearTick } from "./store";
|
|
11
|
-
import { isDirectChildSession } from "./session-routing";
|
|
12
|
-
import { findSubEntryKey, mergeSubEntries, upsertSubEntry } from "./entry-map";
|
|
13
|
-
/** Entry line left prefix: icon + space + status dot + space */
|
|
14
|
-
const LEFT_PAD = 4;
|
|
15
|
-
/** Detail row indent: two spaces */
|
|
16
|
-
const INDENT = 2;
|
|
17
|
-
// ===================================================================
|
|
18
|
-
// Sidebar component
|
|
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
|
-
}
|
|
36
|
-
export function SubAgentPanel(props) {
|
|
37
|
-
const t = createT(() => props.lang());
|
|
38
|
-
// ── session data (single-key, true deletion on cleanup) ──
|
|
39
|
-
const SESSION_DATA_KEY = `${KV_PREFIX}.session_data`;
|
|
40
|
-
const ttlDaysRaw = parseInt(String(props.api.kv.get(`${KV_PREFIX}.ttl_days`, "3")), 10);
|
|
41
|
-
const ttlDays = Number.isNaN(ttlDaysRaw) ? 3 : ttlDaysRaw;
|
|
42
|
-
const TTL_MS = ttlDays * 24 * 60 * 60 * 1000;
|
|
43
|
-
const loadSessionData = () => {
|
|
44
|
-
try {
|
|
45
|
-
const raw = props.api.kv.get(SESSION_DATA_KEY, "{}");
|
|
46
|
-
return JSON.parse(String(raw));
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
return {};
|
|
50
|
-
}
|
|
51
|
-
};
|
|
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];
|
|
77
|
-
};
|
|
78
|
-
/** 将任意 session ID 解析为父会话 ID + 是否子会话。
|
|
79
|
-
* 通过 SDK session.get(sid).parentID 判断,无 parentID 即为主会话。 */
|
|
80
|
-
const resolveParent = (sid) => {
|
|
81
|
-
try {
|
|
82
|
-
const session = props.api.session.get(sid);
|
|
83
|
-
const parentID = session?.parentID;
|
|
84
|
-
if (parentID)
|
|
85
|
-
return { parentSid: parentID, isChild: true };
|
|
86
|
-
}
|
|
87
|
-
catch { }
|
|
88
|
-
return { parentSid: sid, isChild: false };
|
|
89
|
-
};
|
|
90
|
-
const loadEntries = (sid) => {
|
|
91
|
-
let m = new Map();
|
|
92
|
-
try {
|
|
93
|
-
const { parentSid, isChild } = resolveParent(sid);
|
|
94
|
-
const rec = loadSessionData()[parentSid];
|
|
95
|
-
if (rec) {
|
|
96
|
-
const source = isChild ? rec.children?.[sid]?.entries : rec.entries;
|
|
97
|
-
if (source) {
|
|
98
|
-
for (const e of source) {
|
|
99
|
-
m = upsertSubEntry(m, e);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
catch { }
|
|
105
|
-
return m;
|
|
106
|
-
};
|
|
107
|
-
let persistTimer;
|
|
108
|
-
const persistEntries = (sid, entries) => {
|
|
109
|
-
clearTimeout(persistTimer);
|
|
110
|
-
persistTimer = setTimeout(() => {
|
|
111
|
-
try {
|
|
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
|
-
});
|
|
126
|
-
}
|
|
127
|
-
catch { }
|
|
128
|
-
}, 200);
|
|
129
|
-
};
|
|
130
|
-
const persistScroll = (sid, scroll) => {
|
|
131
|
-
try {
|
|
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
|
-
});
|
|
143
|
-
}
|
|
144
|
-
catch { }
|
|
145
|
-
};
|
|
146
|
-
const persistExpanded = (sid, expanded) => {
|
|
147
|
-
try {
|
|
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
|
-
});
|
|
159
|
-
}
|
|
160
|
-
catch { }
|
|
161
|
-
};
|
|
162
|
-
const cleanupOldSessions = () => {
|
|
163
|
-
if (ttlDays <= 0)
|
|
164
|
-
return; // 无期限,跳过清理
|
|
165
|
-
try {
|
|
166
|
-
const cutoff = Date.now() - TTL_MS;
|
|
167
|
-
mutateSessionData((data) => {
|
|
168
|
-
for (const sid of Object.keys(data)) {
|
|
169
|
-
if (data[sid].ts < cutoff)
|
|
170
|
-
delete data[sid];
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
catch { }
|
|
175
|
-
};
|
|
176
|
-
cleanupOldSessions();
|
|
177
|
-
const [entryMap, setEntryMapRaw] = createSignal(loadEntries(props.sessionId));
|
|
178
|
-
// Wrapped setter — also persists to kv on every mutation
|
|
179
|
-
const setEntryMap = (arg) => {
|
|
180
|
-
setEntryMapRaw((prev) => {
|
|
181
|
-
const next = typeof arg === "function" ? arg(prev) : arg;
|
|
182
|
-
// entry 状态落定(done/error)时立即持久化到 KV,跳过常规 debounce,
|
|
183
|
-
// 确保跨视图的状态一致性。
|
|
184
|
-
let needsImmediateFlush = false;
|
|
185
|
-
for (const [id, entry] of next) {
|
|
186
|
-
const prevEntry = prev.get(id);
|
|
187
|
-
if (prevEntry?.status === "running" && (entry.status === "done" || entry.status === "error" || entry.status === "cancelled")) {
|
|
188
|
-
needsImmediateFlush = true;
|
|
189
|
-
break;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
if (needsImmediateFlush) {
|
|
193
|
-
clearTimeout(persistTimer);
|
|
194
|
-
try {
|
|
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
|
-
});
|
|
209
|
-
}
|
|
210
|
-
catch { }
|
|
211
|
-
}
|
|
212
|
-
else {
|
|
213
|
-
persistEntries(props.sessionId, next);
|
|
214
|
-
}
|
|
215
|
-
// 同步到模块级缓存,供其他视图读取当前 session 的最新状态
|
|
216
|
-
globalEntryCache.set(props.sessionId, new Map(next));
|
|
217
|
-
return next;
|
|
218
|
-
});
|
|
219
|
-
};
|
|
220
|
-
const [panelWidth, setPanelWidth] = createSignal(28);
|
|
221
|
-
const [open, setOpen] = createSignal((() => { try {
|
|
222
|
-
return props.api.kv.get(`${KV_PREFIX}.open`, true);
|
|
223
|
-
}
|
|
224
|
-
catch {
|
|
225
|
-
return true;
|
|
226
|
-
} })());
|
|
227
|
-
const [expanded, setExpanded] = createSignal((() => {
|
|
228
|
-
try {
|
|
229
|
-
const { parentSid, isChild } = resolveParent(props.sessionId);
|
|
230
|
-
const rec = loadSessionData()[parentSid];
|
|
231
|
-
if (rec)
|
|
232
|
-
return isChild ? rec.children?.[props.sessionId]?.expanded || undefined : rec.expanded || undefined;
|
|
233
|
-
}
|
|
234
|
-
catch { }
|
|
235
|
-
return undefined;
|
|
236
|
-
})());
|
|
237
|
-
const [hoveredOpen, setHoveredOpen] = createSignal(undefined);
|
|
238
|
-
const [hoveredDismiss, setHoveredDismiss] = createSignal(undefined);
|
|
239
|
-
const [hoveredCancel, setHoveredCancel] = createSignal(undefined);
|
|
240
|
-
const [hoveredTop, setHoveredTop] = createSignal(false);
|
|
241
|
-
const [hoveredMoreAbove, setHoveredMoreAbove] = createSignal(false);
|
|
242
|
-
const [hoveredMoreBelow, setHoveredMoreBelow] = createSignal(false);
|
|
243
|
-
const [scrollOffset, setScrollOffset] = createSignal((() => {
|
|
244
|
-
try {
|
|
245
|
-
const { parentSid, isChild } = resolveParent(props.sessionId);
|
|
246
|
-
const rec = loadSessionData()[parentSid];
|
|
247
|
-
return isChild ? rec?.children?.[props.sessionId]?.scroll ?? 0 : rec?.scroll ?? 0;
|
|
248
|
-
}
|
|
249
|
-
catch {
|
|
250
|
-
return 0;
|
|
251
|
-
}
|
|
252
|
-
})());
|
|
253
|
-
const [now, setNow] = createSignal(Date.now());
|
|
254
|
-
const [renderTick, setRenderTick] = createSignal(0);
|
|
255
|
-
let boxEl;
|
|
256
|
-
let disposed = false;
|
|
257
|
-
// ── upsert ──
|
|
258
|
-
const upsertEntry = (partial) => {
|
|
259
|
-
setEntryMap((prev) => upsertSubEntry(prev, partial));
|
|
260
|
-
};
|
|
261
|
-
// ── cancel helpers ──
|
|
262
|
-
const isDescendantOf = (childId, rootId) => {
|
|
263
|
-
const visited = new Set();
|
|
264
|
-
try {
|
|
265
|
-
let current = props.api.session.get(childId);
|
|
266
|
-
while (current?.parentID) {
|
|
267
|
-
if (visited.has(current.id))
|
|
268
|
-
return false;
|
|
269
|
-
visited.add(current.id);
|
|
270
|
-
if (current.parentID === rootId)
|
|
271
|
-
return true;
|
|
272
|
-
current = props.api.session.get(current.parentID);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
catch { }
|
|
276
|
-
return false;
|
|
277
|
-
};
|
|
278
|
-
const settleOnIdle = (entry) => {
|
|
279
|
-
if (entry.status === "cancel_requested" && entry.abortAccepted)
|
|
280
|
-
return "cancelled";
|
|
281
|
-
return "done";
|
|
282
|
-
};
|
|
283
|
-
const cancelEntry = async (entry) => {
|
|
284
|
-
const childId = entry.sessionId;
|
|
285
|
-
if (!childId) {
|
|
286
|
-
props.api.ui.toast(t("cancel.label") + ": " + t("cancel.no_session"), { title: entry.title || entry.agent });
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
try {
|
|
290
|
-
const child = props.api.session.get(childId);
|
|
291
|
-
if (!child?.parentID) {
|
|
292
|
-
props.api.ui.toast(t("cancel.label") + ": " + t("cancel.not_child"), { title: entry.title || entry.agent });
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
catch {
|
|
297
|
-
props.api.ui.toast(t("cancel.label") + ": " + t("cancel.read_error"), { title: entry.title || entry.agent });
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
if (!isDescendantOf(childId, props.sessionId)) {
|
|
301
|
-
props.api.ui.toast(t("cancel.label") + ": " + t("cancel.outside_tree"), { title: entry.title || entry.agent });
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
try {
|
|
305
|
-
const st = props.api.session.status(childId);
|
|
306
|
-
if (st?.type !== "busy") {
|
|
307
|
-
const tokens = props.api.usage.readSessionTokens(childId);
|
|
308
|
-
const cost = props.api.usage.readSessionCost(childId);
|
|
309
|
-
upsertEntry({
|
|
310
|
-
id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
|
|
311
|
-
status: "done", sessionId: entry.sessionId,
|
|
312
|
-
tokens, cost,
|
|
313
|
-
});
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
catch {
|
|
318
|
-
props.api.ui.toast(t("cancel.label") + ": " + t("cancel.status_error"), { title: entry.title || entry.agent });
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
upsertEntry({
|
|
322
|
-
id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
|
|
323
|
-
status: "cancel_requested", sessionId: entry.sessionId,
|
|
324
|
-
cancelRequestedAt: Date.now(), abortAccepted: false, cancelReason: "manual",
|
|
325
|
-
});
|
|
326
|
-
try {
|
|
327
|
-
await props.api.client.abort({ sessionID: childId });
|
|
328
|
-
upsertEntry({
|
|
329
|
-
id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
|
|
330
|
-
status: "cancel_requested", sessionId: entry.sessionId,
|
|
331
|
-
abortAccepted: true,
|
|
332
|
-
});
|
|
333
|
-
props.api.ui.toast(t("cancel.label") + ": " + t("cancel.sent"));
|
|
334
|
-
}
|
|
335
|
-
catch (err) {
|
|
336
|
-
upsertEntry({
|
|
337
|
-
id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
|
|
338
|
-
status: "error", sessionId: entry.sessionId,
|
|
339
|
-
error: String(err),
|
|
340
|
-
});
|
|
341
|
-
props.api.ui.toast(t("cancel.label") + ": " + t("cancel.failed"), { title: entry.title || entry.agent });
|
|
342
|
-
}
|
|
343
|
-
};
|
|
344
|
-
// ── event handlers ──
|
|
345
|
-
const handlePartUpdated = (event) => {
|
|
346
|
-
const part = event.payload?.part;
|
|
347
|
-
if (!part)
|
|
348
|
-
return;
|
|
349
|
-
// V2 事件流是全局的——若 payload 带发起会话 ID,则只归账到正在查看的会话,
|
|
350
|
-
// 避免其他会话的子代理写进当前侧边栏;V1 宿主已按会话 scope,不传 sessionID。
|
|
351
|
-
const eventSid = event.payload?.sessionID !== undefined ? String(event.payload.sessionID) : undefined;
|
|
352
|
-
if (event.scope === "global" && eventSid !== props.sessionId) {
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
// SubtaskPart
|
|
356
|
-
if (part.type === "subtask") {
|
|
357
|
-
const agent = String(part.agent ?? "?");
|
|
358
|
-
const prompt = String(part.prompt ?? "");
|
|
359
|
-
const desc = String(part.description ?? "");
|
|
360
|
-
const title = desc || truncate(prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(), 40);
|
|
361
|
-
const id = `sub:${String(part.id ?? crypto.randomUUID())}`;
|
|
362
|
-
const subSid = part.sessionID !== undefined ? String(part.sessionID) : undefined;
|
|
363
|
-
const partModel = part.model;
|
|
364
|
-
const modelId = partModel?.modelID ? String(partModel.modelID) : undefined;
|
|
365
|
-
upsertEntry({ id, title, agent, prompt, sessionId: subSid, status: "running", model: modelId });
|
|
366
|
-
}
|
|
367
|
-
// ToolPart
|
|
368
|
-
if (part.type === "tool") {
|
|
369
|
-
const tool = String(part.tool ?? "");
|
|
370
|
-
if (!SUBAGENT_TOOLS.has(tool))
|
|
371
|
-
return;
|
|
372
|
-
const st = part.state;
|
|
373
|
-
const rawStatus = String(st?.status ?? "");
|
|
374
|
-
// Only create entries for tool calls that actually entered execution.
|
|
375
|
-
// "pending" / empty → state unknown yet, wait for next event
|
|
376
|
-
if (rawStatus === "pending" || rawStatus === "")
|
|
377
|
-
return;
|
|
378
|
-
// "error" → tool call failed, sub-agent never spawned.
|
|
379
|
-
// Only update an existing entry (e.g. previously running → now error),
|
|
380
|
-
// never create a new one.
|
|
381
|
-
if (rawStatus === "error") {
|
|
382
|
-
const id = `tool:${String(part.id ?? "")}`;
|
|
383
|
-
if (!part.id)
|
|
384
|
-
return;
|
|
385
|
-
const stMeta = partMetadata(part, st);
|
|
386
|
-
const errorSid = stMeta?.session_id !== undefined ? String(stMeta.session_id)
|
|
387
|
-
: stMeta?.sessionId !== undefined ? String(stMeta.sessionId)
|
|
388
|
-
: undefined;
|
|
389
|
-
const key = findSubEntryKey(entryMap(), id, errorSid);
|
|
390
|
-
const existing = key ? entryMap().get(key) : undefined;
|
|
391
|
-
if (existing) {
|
|
392
|
-
upsertEntry({
|
|
393
|
-
id: existing.id,
|
|
394
|
-
title: existing.title,
|
|
395
|
-
agent: existing.agent,
|
|
396
|
-
prompt: existing.prompt,
|
|
397
|
-
sessionId: existing.sessionId ?? errorSid,
|
|
398
|
-
status: "error",
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
// rawStatus is "running" or "completed" — tool entered execution, track it.
|
|
404
|
-
const input = st?.input;
|
|
405
|
-
let status = "running";
|
|
406
|
-
if (rawStatus === "completed")
|
|
407
|
-
status = "done";
|
|
408
|
-
// Background tasks: tool completion ≠ agent completion — keep running until session.idle
|
|
409
|
-
// Only keep running if state metadata confirms a child session was spawned;
|
|
410
|
-
// otherwise (failed spawn, invalid agent) mark as done so the entry isn't stuck forever.
|
|
411
|
-
if ((input?.run_in_background === true || input?.background === true) && status === "done") {
|
|
412
|
-
const stMetaCheck = partMetadata(part, st);
|
|
413
|
-
const hasChild = stMetaCheck?.session_id !== undefined || stMetaCheck?.sessionId !== undefined;
|
|
414
|
-
if (hasChild)
|
|
415
|
-
status = "running";
|
|
416
|
-
}
|
|
417
|
-
const agent = String(part.subagent_type ?? input?.agent ?? input?.subagent_type ?? input?.category ?? tool);
|
|
418
|
-
const prompt = String(input?.prompt ?? part.description ?? "");
|
|
419
|
-
const desc = input?.description !== undefined ? String(input.description) : "";
|
|
420
|
-
const title = desc || truncate(prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(), 40);
|
|
421
|
-
const id = `tool:${String(part.id ?? crypto.randomUUID())}`;
|
|
422
|
-
// Child session ID lives in state-level metadata (ToolStateCompleted.metadata),
|
|
423
|
-
// injected by the tool executor. ToolPart.sessionID is the parent session.
|
|
424
|
-
const stMeta = partMetadata(part, st);
|
|
425
|
-
const subSid = stMeta?.session_id !== undefined ? String(stMeta.session_id)
|
|
426
|
-
: stMeta?.sessionId !== undefined ? String(stMeta.sessionId)
|
|
427
|
-
: undefined;
|
|
428
|
-
upsertEntry({ id, title, agent, prompt, sessionId: subSid, status });
|
|
429
|
-
}
|
|
430
|
-
};
|
|
431
|
-
const handleSessionEnd = (event, status) => {
|
|
432
|
-
const props_ = event.payload;
|
|
433
|
-
const sid = String(props_?.sessionID ?? "");
|
|
434
|
-
if (!sid)
|
|
435
|
-
return;
|
|
436
|
-
const isGlobalEvent = event.scope === "global";
|
|
437
|
-
let eventSession;
|
|
438
|
-
if (isGlobalEvent) {
|
|
439
|
-
try {
|
|
440
|
-
eventSession = props.api.session.get(sid);
|
|
441
|
-
}
|
|
442
|
-
catch {
|
|
443
|
-
eventSession = undefined;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
const isCurrentChild = !isGlobalEvent || isDirectChildSession(props.sessionId, sid, eventSession);
|
|
447
|
-
const sessionTokens = props.api.usage.readSessionTokens(sid);
|
|
448
|
-
const sessionCost = props.api.usage.readSessionCost(sid);
|
|
449
|
-
const sessionModel = props.api.usage.readSessionModel(sid);
|
|
450
|
-
const sessionTodo = props.api.usage.readSessionTodo(sid);
|
|
451
|
-
let sessionAgent;
|
|
452
|
-
let errorMsg;
|
|
453
|
-
try {
|
|
454
|
-
const s = props.api.session.get(sid);
|
|
455
|
-
sessionAgent = s?.agent;
|
|
456
|
-
if (status === "error") {
|
|
457
|
-
const evtErr = props_?.error;
|
|
458
|
-
errorMsg = safeErrorMsg(evtErr) || safeErrorMsg(props_?.message);
|
|
459
|
-
if (!errorMsg) {
|
|
460
|
-
const msgs = props.api.session.messages(sid);
|
|
461
|
-
if (msgs) {
|
|
462
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
463
|
-
const m = msgs[i];
|
|
464
|
-
if (m.role === "assistant" && m.error) {
|
|
465
|
-
errorMsg = safeErrorMsg(m.error);
|
|
466
|
-
break;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
catch { }
|
|
474
|
-
// 在给定的 entries Map 中查找并更新匹配的子代理 entry。
|
|
475
|
-
// 返回 true 表示找到并更新了,false 表示未找到。
|
|
476
|
-
const tryMatchAndUpdate = (entriesMap, targetSid, targetStatus, nowTs) => {
|
|
477
|
-
// 精确匹配:sessionId 对得上 + 状态为 running / cancel_requested
|
|
478
|
-
for (const [, entry] of entriesMap) {
|
|
479
|
-
if (entry.sessionId === targetSid && (entry.status === "running" || entry.status === "cancel_requested")) {
|
|
480
|
-
const finalStatus = targetStatus === "error" ? "error" : settleOnIdle(entry);
|
|
481
|
-
entry.status = finalStatus;
|
|
482
|
-
entry.endedAt = nowTs;
|
|
483
|
-
entry.tokens = entry.tokens ?? sessionTokens;
|
|
484
|
-
entry.cost = entry.cost ?? sessionCost;
|
|
485
|
-
entry.model = entry.model ?? sessionModel;
|
|
486
|
-
entry.todoTotal = entry.todoTotal ?? sessionTodo?.total;
|
|
487
|
-
entry.todoDone = entry.todoDone ?? sessionTodo?.done;
|
|
488
|
-
entry.error = errorMsg || entry.error;
|
|
489
|
-
return true;
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
// 回退:sessionId 未关联但 agent 名匹配 + 状态为 running / cancel_requested
|
|
493
|
-
if (sessionAgent) {
|
|
494
|
-
const normalize = (s) => s.toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
495
|
-
const saNorm = normalize(sessionAgent);
|
|
496
|
-
let best = null;
|
|
497
|
-
for (const [, entry] of entriesMap) {
|
|
498
|
-
if (entry.status !== "running" && entry.status !== "cancel_requested")
|
|
499
|
-
continue;
|
|
500
|
-
const eaNorm = normalize(entry.agent);
|
|
501
|
-
if (!eaNorm || !saNorm)
|
|
502
|
-
continue;
|
|
503
|
-
if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm))
|
|
504
|
-
continue;
|
|
505
|
-
const gap = nowTs - (entry.startedAt || 0);
|
|
506
|
-
if (!best || gap > best.gap)
|
|
507
|
-
best = { entry, gap };
|
|
508
|
-
}
|
|
509
|
-
if (!best) {
|
|
510
|
-
for (const [, entry] of entriesMap) {
|
|
511
|
-
if (entry.status !== "running" && entry.status !== "cancel_requested")
|
|
512
|
-
continue;
|
|
513
|
-
if (entry.sessionId)
|
|
514
|
-
continue;
|
|
515
|
-
const gap = nowTs - (entry.startedAt || 0);
|
|
516
|
-
if (!best || gap > best.gap)
|
|
517
|
-
best = { entry, gap };
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
if (best) {
|
|
521
|
-
const finalStatus = targetStatus === "error" ? "error" : settleOnIdle(best.entry);
|
|
522
|
-
best.entry.status = finalStatus;
|
|
523
|
-
best.entry.endedAt = nowTs;
|
|
524
|
-
best.entry.tokens = best.entry.tokens ?? sessionTokens;
|
|
525
|
-
best.entry.cost = best.entry.cost ?? sessionCost;
|
|
526
|
-
best.entry.model = best.entry.model ?? sessionModel;
|
|
527
|
-
best.entry.todoTotal = best.entry.todoTotal ?? sessionTodo?.total;
|
|
528
|
-
best.entry.todoDone = best.entry.todoDone ?? sessionTodo?.done;
|
|
529
|
-
best.entry.sessionId = targetSid;
|
|
530
|
-
best.entry.error = errorMsg || best.entry.error;
|
|
531
|
-
return true;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
return false;
|
|
535
|
-
};
|
|
536
|
-
setEntryMap((prev) => {
|
|
537
|
-
let changed = false;
|
|
538
|
-
const next = new Map(prev);
|
|
539
|
-
for (const [id, entry] of next) {
|
|
540
|
-
if (!isCurrentChild || entry.sessionId !== sid)
|
|
541
|
-
continue;
|
|
542
|
-
if (entry.status !== "running" && entry.status !== "done" && entry.status !== "cancel_requested")
|
|
543
|
-
continue;
|
|
544
|
-
// Skip parent session idle — subagent entries belong to child sessions only
|
|
545
|
-
if (sid === props.sessionId)
|
|
546
|
-
continue;
|
|
547
|
-
// For "done" entries (sync tasks completed before session.idle), only backfill tokens/cost
|
|
548
|
-
const alreadySettled = entry.status !== "running" && entry.status !== "cancel_requested";
|
|
549
|
-
const finalStatus = status === "error" ? "error" : settleOnIdle(entry);
|
|
550
|
-
next.set(id, {
|
|
551
|
-
...entry,
|
|
552
|
-
...(alreadySettled ? {} : { status: finalStatus, endedAt: Date.now() }),
|
|
553
|
-
tokens: entry.tokens ?? sessionTokens,
|
|
554
|
-
cost: entry.cost ?? sessionCost,
|
|
555
|
-
model: entry.model ?? sessionModel,
|
|
556
|
-
todoTotal: entry.todoTotal ?? sessionTodo?.total,
|
|
557
|
-
todoDone: entry.todoDone ?? sessionTodo?.done,
|
|
558
|
-
error: errorMsg || entry.error,
|
|
559
|
-
});
|
|
560
|
-
changed = true;
|
|
561
|
-
}
|
|
562
|
-
if (!changed && sessionAgent && isCurrentChild) {
|
|
563
|
-
const nowTs = Date.now();
|
|
564
|
-
const normalize = (s) => s.toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
565
|
-
const saNorm = normalize(sessionAgent);
|
|
566
|
-
let best = null;
|
|
567
|
-
// Phase 1: try matching by agent name(agent 名有交集)
|
|
568
|
-
for (const [id, entry] of next) {
|
|
569
|
-
if (entry.status !== "running" && entry.status !== "cancel_requested")
|
|
570
|
-
continue;
|
|
571
|
-
const eaNorm = normalize(entry.agent);
|
|
572
|
-
if (!eaNorm || !saNorm)
|
|
573
|
-
continue;
|
|
574
|
-
if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm))
|
|
575
|
-
continue;
|
|
576
|
-
const gap = nowTs - (entry.startedAt || 0);
|
|
577
|
-
if (!best || gap > best.gap)
|
|
578
|
-
best = { id, gap };
|
|
579
|
-
}
|
|
580
|
-
// Phase 2: if agent name has no overlap (e.g. category calls: agent="deep" vs sessionAgent="Sisyphus-Junior"),
|
|
581
|
-
// fall back to time proximity for entries that have no sessionId yet
|
|
582
|
-
if (!best) {
|
|
583
|
-
for (const [id, entry] of next) {
|
|
584
|
-
if (entry.status !== "running" && entry.status !== "cancel_requested")
|
|
585
|
-
continue;
|
|
586
|
-
if (entry.sessionId)
|
|
587
|
-
continue;
|
|
588
|
-
const gap = nowTs - (entry.startedAt || 0);
|
|
589
|
-
if (!best || gap > best.gap)
|
|
590
|
-
best = { id, gap };
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
if (best) {
|
|
594
|
-
const entry = next.get(best.id);
|
|
595
|
-
const finalStatus = status === "error" ? "error" : settleOnIdle(entry);
|
|
596
|
-
next.set(best.id, {
|
|
597
|
-
...entry, status: finalStatus, endedAt: nowTs,
|
|
598
|
-
tokens: sessionTokens || entry.tokens,
|
|
599
|
-
cost: sessionCost || entry.cost,
|
|
600
|
-
sessionId: sid,
|
|
601
|
-
error: errorMsg || entry.error,
|
|
602
|
-
});
|
|
603
|
-
changed = true;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
return changed ? next : prev;
|
|
607
|
-
});
|
|
608
|
-
// 当子代理所属的父 session 与当前视图不同时,通过模块级缓存定位
|
|
609
|
-
// 并更新父 session 的 entry 状态,随后写回 KV。
|
|
610
|
-
try {
|
|
611
|
-
const parentSid = (isGlobalEvent ? eventSession : props.api.session.get(sid))?.parentID;
|
|
612
|
-
if (parentSid && parentSid !== props.sessionId) {
|
|
613
|
-
// 优先从模块级缓存获取父 session 的 entries,不受当前视图切换影响
|
|
614
|
-
const parentCache = globalEntryCache.get(parentSid);
|
|
615
|
-
const nowTs = Date.now();
|
|
616
|
-
let found = false;
|
|
617
|
-
if (parentCache) {
|
|
618
|
-
found = tryMatchAndUpdate(parentCache, sid, status, nowTs);
|
|
619
|
-
}
|
|
620
|
-
// 缓存未命中时回退到 KV 读取
|
|
621
|
-
if (!found) {
|
|
622
|
-
const data = loadSessionData();
|
|
623
|
-
const rec = data[parentSid];
|
|
624
|
-
if (rec?.entries?.length) {
|
|
625
|
-
const fallbackMap = new Map(rec.entries.map((e) => [e.id, e]));
|
|
626
|
-
found = tryMatchAndUpdate(fallbackMap, sid, status, nowTs);
|
|
627
|
-
if (found) {
|
|
628
|
-
// 回退命中后写入 KV 并回填缓存。
|
|
629
|
-
// merge(而非整表替换):其他实例写入的新条目不能被旧缓存覆盖。
|
|
630
|
-
globalEntryCache.set(parentSid, fallbackMap);
|
|
631
|
-
mutateSessionData((latest) => {
|
|
632
|
-
const latestRec = latest[parentSid];
|
|
633
|
-
latest[parentSid] = {
|
|
634
|
-
...(latestRec ?? rec),
|
|
635
|
-
ts: nowTs,
|
|
636
|
-
entries: [...mergeSubEntries(latestRec?.entries ?? [], fallbackMap.values()).values()],
|
|
637
|
-
};
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
// 将模块级缓存中的最新状态 merge 进 KV(缓存可能缺少其他实例的新条目)
|
|
643
|
-
if (found && parentCache) {
|
|
644
|
-
mutateSessionData((data) => {
|
|
645
|
-
const rec = data[parentSid];
|
|
646
|
-
data[parentSid] = {
|
|
647
|
-
...(rec ?? { entries: [], scroll: 0, expanded: "", children: {}, ts: nowTs }),
|
|
648
|
-
ts: nowTs,
|
|
649
|
-
entries: [...mergeSubEntries(rec?.entries ?? [], parentCache.values()).values()],
|
|
650
|
-
};
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
catch { }
|
|
656
|
-
// Delayed backfill: re-read data after state sync catches up, to capture the final
|
|
657
|
-
// token/cost values that may not have been available when session.idle fired.
|
|
658
|
-
setTimeout(() => {
|
|
659
|
-
if (disposed)
|
|
660
|
-
return;
|
|
661
|
-
const finalTokens = props.api.usage.readSessionTokens(sid);
|
|
662
|
-
const finalCost = props.api.usage.readSessionCost(sid);
|
|
663
|
-
const finalModel = props.api.usage.readSessionModel(sid);
|
|
664
|
-
const finalTodo = props.api.usage.readSessionTodo(sid);
|
|
665
|
-
setEntryMap((prev) => {
|
|
666
|
-
let changed = false;
|
|
667
|
-
const next = new Map(prev);
|
|
668
|
-
for (const [id, entry] of next) {
|
|
669
|
-
if (entry.sessionId !== sid)
|
|
670
|
-
continue;
|
|
671
|
-
const t = finalTokens ?? entry.tokens;
|
|
672
|
-
const c = finalCost ?? entry.cost;
|
|
673
|
-
const m = finalModel ?? entry.model;
|
|
674
|
-
const tt = finalTodo?.total ?? entry.todoTotal;
|
|
675
|
-
const td = finalTodo?.done ?? entry.todoDone;
|
|
676
|
-
if (t !== entry.tokens || c !== entry.cost || m !== entry.model ||
|
|
677
|
-
tt !== entry.todoTotal || td !== entry.todoDone) {
|
|
678
|
-
next.set(id, { ...entry, tokens: t, cost: c, model: m, todoTotal: tt, todoDone: td });
|
|
679
|
-
changed = true;
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
return changed ? next : prev;
|
|
683
|
-
});
|
|
684
|
-
bump();
|
|
685
|
-
}, 150);
|
|
686
|
-
};
|
|
687
|
-
// ── bumpRenderTick: force re-render (visual-cache pattern) ──
|
|
688
|
-
const bump = () => setRenderTick((v) => v + 1);
|
|
689
|
-
// ── history scan(mount/switch、重试与消息驱动 rescan 共用)──
|
|
690
|
-
/** 将子会话已 idle 的 running 条目标记为已落定。
|
|
691
|
-
* 纯 map 变换——是否持久化由调用方决定。 */
|
|
692
|
-
const settleIdleEntries = (prev) => {
|
|
693
|
-
let changed = false;
|
|
694
|
-
const next = new Map(prev);
|
|
695
|
-
for (const [id, entry] of next) {
|
|
696
|
-
if (entry.status !== "running" && entry.status !== "cancel_requested")
|
|
697
|
-
continue;
|
|
698
|
-
// 无链接条目:先用宿主/本地库按 call id 回填子会话 id;
|
|
699
|
-
// 回填不了就维持原状(不猜测、不设时间阈值)。
|
|
700
|
-
let sid = entry.sessionId;
|
|
701
|
-
if (!sid) {
|
|
702
|
-
try {
|
|
703
|
-
const callId = String(entry.id).replace(/^tool:/, "");
|
|
704
|
-
sid = props.api.session.resolveChild?.({
|
|
705
|
-
parentId: props.sessionId, callId, agent: entry.agent, startedAt: entry.startedAt,
|
|
706
|
-
});
|
|
707
|
-
}
|
|
708
|
-
catch { }
|
|
709
|
-
if (!sid)
|
|
710
|
-
continue;
|
|
711
|
-
next.set(id, { ...entry, sessionId: sid });
|
|
712
|
-
changed = true;
|
|
713
|
-
}
|
|
714
|
-
try {
|
|
715
|
-
const st = props.api.session.status(sid);
|
|
716
|
-
if (!st || st.type !== "idle")
|
|
717
|
-
continue;
|
|
718
|
-
const tokens = props.api.usage.readSessionTokens(sid);
|
|
719
|
-
const cost = props.api.usage.readSessionCost(sid);
|
|
720
|
-
const finalStatus = entry.status === "cancel_requested" && entry.abortAccepted
|
|
721
|
-
? "cancelled"
|
|
722
|
-
: "done";
|
|
723
|
-
next.set(id, {
|
|
724
|
-
...entry, sessionId: sid, status: finalStatus, endedAt: Date.now(),
|
|
725
|
-
tokens: tokens ?? entry.tokens,
|
|
726
|
-
cost: cost ?? entry.cost,
|
|
727
|
-
});
|
|
728
|
-
changed = true;
|
|
729
|
-
}
|
|
730
|
-
catch { }
|
|
731
|
-
}
|
|
732
|
-
return changed ? next : prev;
|
|
733
|
-
};
|
|
734
|
-
/** 周期性兜底遗漏的 session.idle 事件(僵尸 running 条目)。
|
|
735
|
-
* 由 500ms 定时器驱动,即使没有事件到达也能让条目落定。 */
|
|
736
|
-
const reconcileIdleEntries = () => {
|
|
737
|
-
try {
|
|
738
|
-
const before = entryMap();
|
|
739
|
-
const after = settleIdleEntries(before);
|
|
740
|
-
if (after !== before)
|
|
741
|
-
setEntryMap(after);
|
|
742
|
-
}
|
|
743
|
-
catch { }
|
|
744
|
-
};
|
|
745
|
-
/** 扫描会话历史并 merge 进 entry map。
|
|
746
|
-
* 消息数据尚不可用时返回 false,供调用方重试。 */
|
|
747
|
-
const runSessionScan = (sid, replace) => {
|
|
748
|
-
let msgs;
|
|
749
|
-
try {
|
|
750
|
-
msgs = props.api.session.messages(sid);
|
|
751
|
-
}
|
|
752
|
-
catch { }
|
|
753
|
-
if (!msgs || msgs.length === 0)
|
|
754
|
-
return false;
|
|
755
|
-
// scan 结果先写入内存(setEntryMapRaw);scan 末尾统一走 persistEntries 的 merge-safe 落盘。
|
|
756
|
-
setEntryMapRaw((prev) => {
|
|
757
|
-
// 优先从模块级缓存加载,KV 仅作缓存未命中时的回退
|
|
758
|
-
const next = replace
|
|
759
|
-
? new Map(globalEntryCache.get(sid) ?? loadEntries(sid))
|
|
760
|
-
: new Map(prev);
|
|
761
|
-
// 从 KV 加载当前会话的清除名单,扫描时跳过被手动清除的历史条目
|
|
762
|
-
const { parentSid: scanPSid, isChild: scanChild } = resolveParent(sid);
|
|
763
|
-
const scanRec = loadSessionData()[scanPSid];
|
|
764
|
-
const clearedIds = new Set(scanChild ? scanRec?.children?.[sid]?.clearedIds : scanRec?.clearedIds);
|
|
765
|
-
try {
|
|
766
|
-
for (const msg of msgs) {
|
|
767
|
-
const parts = props.api.session.part(msg.id) ?? [];
|
|
768
|
-
for (const partRaw of parts) {
|
|
769
|
-
const part = partRaw;
|
|
770
|
-
// Subtask entries are purely event-driven — never created by scan.
|
|
771
|
-
// (SubtaskPart exists from spawn, not completion, so we cannot infer status.)
|
|
772
|
-
if (part.type === "tool") {
|
|
773
|
-
const tool = String(part.tool ?? "");
|
|
774
|
-
if (!SUBAGENT_TOOLS.has(tool))
|
|
775
|
-
continue;
|
|
776
|
-
const id = `tool:${String(part.id ?? "")}`;
|
|
777
|
-
if (!part.id)
|
|
778
|
-
continue;
|
|
779
|
-
const st = part.state;
|
|
780
|
-
const rawStatus = String(st?.status ?? "");
|
|
781
|
-
const scanInput = st?.input;
|
|
782
|
-
// TUI 数据层把子代理 metadata 放在 part 级;旧缓存
|
|
783
|
-
// 形状可能嵌在 state 里。两处都读。
|
|
784
|
-
const scanMeta = partMetadata(part, st);
|
|
785
|
-
let scanSubSid = scanMeta?.session_id !== undefined ? String(scanMeta.session_id)
|
|
786
|
-
: scanMeta?.sessionId !== undefined ? String(scanMeta.sessionId)
|
|
787
|
-
: scanMeta?.sessionID !== undefined ? String(scanMeta.sessionID)
|
|
788
|
-
: undefined;
|
|
789
|
-
// 宿主缓存/历史缺少子会话链接时,用本地库按工具 call id 回填。
|
|
790
|
-
if (!scanSubSid) {
|
|
791
|
-
try {
|
|
792
|
-
scanSubSid = props.api.session.resolveChild?.({ parentId: sid, callId: String(part.id) });
|
|
793
|
-
}
|
|
794
|
-
catch { }
|
|
795
|
-
}
|
|
796
|
-
const existingKey = findSubEntryKey(next, id, scanSubSid);
|
|
797
|
-
const exists = existingKey ? next.get(existingKey) : undefined;
|
|
798
|
-
// 已手动清除的条目:scan 发现但不在内存 → 跳过重建
|
|
799
|
-
if (!exists && clearedIds.has(id))
|
|
800
|
-
continue;
|
|
801
|
-
// Only create entries for tool calls that entered execution.
|
|
802
|
-
// "pending" / empty: skip new entries; allow heuristics for existing ones below.
|
|
803
|
-
if ((rawStatus === "pending" || rawStatus === "") && !exists)
|
|
804
|
-
continue;
|
|
805
|
-
// "error": only update existing, never create a new entry
|
|
806
|
-
if (rawStatus === "error") {
|
|
807
|
-
if (exists && exists.status === "running") {
|
|
808
|
-
next.set(existingKey ?? id, { ...exists, status: "error", endedAt: Date.now() });
|
|
809
|
-
}
|
|
810
|
-
continue;
|
|
811
|
-
}
|
|
812
|
-
// 已落定的条目:仅在补全缺失数据时回访
|
|
813
|
-
// (session/tokens/cost/model)。其终态必须保持。
|
|
814
|
-
const settled = exists !== undefined &&
|
|
815
|
-
exists.status !== "running" &&
|
|
816
|
-
exists.status !== "cancel_requested";
|
|
817
|
-
const needsRepair = settled &&
|
|
818
|
-
(exists.sessionId === undefined ||
|
|
819
|
-
exists.tokens === undefined ||
|
|
820
|
-
exists.cost === undefined ||
|
|
821
|
-
!validModel(exists.model));
|
|
822
|
-
if (settled && !needsRepair)
|
|
823
|
-
continue;
|
|
824
|
-
let status = "running";
|
|
825
|
-
if (rawStatus === "completed")
|
|
826
|
-
status = "done";
|
|
827
|
-
// Background tasks: tool completion ≠ agent completion — keep running until session.idle
|
|
828
|
-
// 仅在 metadata 确认已派生子会话时保持 running。
|
|
829
|
-
if ((scanInput?.run_in_background === true || scanInput?.background === true) && status === "done") {
|
|
830
|
-
if (scanSubSid !== undefined)
|
|
831
|
-
status = "running";
|
|
832
|
-
}
|
|
833
|
-
// 子会话存活判定:工具调用已完成但子会话仍 busy,
|
|
834
|
-
// 说明子代理本身仍在运行(覆盖缓存 part 中
|
|
835
|
-
// 缺少 background 标志的 input)。
|
|
836
|
-
if (status === "done" && scanSubSid) {
|
|
837
|
-
try {
|
|
838
|
-
const childStatus = props.api.session.status(scanSubSid);
|
|
839
|
-
if (childStatus && childStatus.type !== "idle")
|
|
840
|
-
status = "running";
|
|
841
|
-
}
|
|
842
|
-
catch { }
|
|
843
|
-
}
|
|
844
|
-
if (settled) {
|
|
845
|
-
// 修复流程:保留已记录的终态。
|
|
846
|
-
status = exists.status;
|
|
847
|
-
}
|
|
848
|
-
else if (exists && status === "running") {
|
|
849
|
-
// Running entry with no explicit status improvement from part:
|
|
850
|
-
// try message-level heuristics first, then time-based fallback.
|
|
851
|
-
if (!rawStatus) {
|
|
852
|
-
const msgTokens = msg?.tokens;
|
|
853
|
-
if (msgTokens && (Number(msgTokens.input) > 0 || Number(msgTokens.output) > 0)) {
|
|
854
|
-
status = "done"; // LLM returned tokens → agent completed
|
|
855
|
-
}
|
|
856
|
-
else {
|
|
857
|
-
// 无终态证据时不猜测:等本地库/事件回填子会话状态后由 reconcile 落定。
|
|
858
|
-
continue;
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
else {
|
|
862
|
-
continue;
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
// If already tracked as running but tool state says completed/error → update
|
|
866
|
-
// If not tracked → add fresh
|
|
867
|
-
const agent = String(part.subagent_type ?? scanInput?.agent ?? scanInput?.subagent_type ?? tool);
|
|
868
|
-
const prompt = String(scanInput?.prompt ?? part.description ?? "");
|
|
869
|
-
const desc = scanInput?.description !== undefined ? String(scanInput.description) : "";
|
|
870
|
-
const title = desc || truncate(prompt.replace(/\n/g, " ").trim(), 40);
|
|
871
|
-
let tokens;
|
|
872
|
-
if (scanSubSid)
|
|
873
|
-
tokens = props.api.usage.readSessionTokens(scanSubSid);
|
|
874
|
-
let cost;
|
|
875
|
-
if (scanSubSid)
|
|
876
|
-
cost = props.api.usage.readSessionCost(scanSubSid);
|
|
877
|
-
let model;
|
|
878
|
-
if (scanSubSid)
|
|
879
|
-
model = props.api.usage.readSessionModel(scanSubSid);
|
|
880
|
-
const entryKey = existingKey ?? id;
|
|
881
|
-
next.set(entryKey, {
|
|
882
|
-
...(exists ?? { id }),
|
|
883
|
-
title: title || exists?.title || "",
|
|
884
|
-
agent: exists?.agent ?? agent,
|
|
885
|
-
prompt: prompt || exists?.prompt || "",
|
|
886
|
-
// Preserve existing values (from handleSessionEnd / KV) — scan must not overwrite
|
|
887
|
-
tokens: exists?.tokens ?? tokens,
|
|
888
|
-
cost: exists?.cost ?? cost,
|
|
889
|
-
model: validModel(exists?.model) ? exists.model : model,
|
|
890
|
-
sessionId: exists?.sessionId ?? scanSubSid,
|
|
891
|
-
status,
|
|
892
|
-
startedAt: exists?.startedAt || Date.now(),
|
|
893
|
-
endedAt: exists?.endedAt ?? (status === "done" ? Date.now() : undefined),
|
|
894
|
-
});
|
|
895
|
-
if (entryKey !== id)
|
|
896
|
-
next.delete(id);
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
catch { }
|
|
902
|
-
return next;
|
|
903
|
-
});
|
|
904
|
-
// Reconcile: check running entries against live child session status.
|
|
905
|
-
// Covers session.idle events missed while user was inside a child session.
|
|
906
|
-
setEntryMapRaw((prev) => settleIdleEntries(prev));
|
|
907
|
-
// 持久化 scan 修复结果(merge-safe:缺失字段始终以 KV 现有值为准),
|
|
908
|
-
// 让恢复的条目在重新挂载后仍在,并能同步到其他实例。
|
|
909
|
-
const snapshot = entryMap();
|
|
910
|
-
if (snapshot.size > 0) {
|
|
911
|
-
globalEntryCache.set(sid, new Map(snapshot));
|
|
912
|
-
persistEntries(sid, snapshot);
|
|
913
|
-
}
|
|
914
|
-
return true;
|
|
915
|
-
};
|
|
916
|
-
let rescanTimer;
|
|
917
|
-
/** 当最新消息与 entry map 不一致时为 true:子代理 tool part 尚未建条目,
|
|
918
|
-
* 或非后台 tool 已完成而条目仍停在 running。
|
|
919
|
-
* 事件驱动 rescan 与周期性 discovery poll 共用的轻量检查:
|
|
920
|
-
* 即使宿主的所有事件订阅都静默失效,
|
|
921
|
-
* 也能恢复条目。 */
|
|
922
|
-
const missingNewestToolPart = () => {
|
|
923
|
-
try {
|
|
924
|
-
const msgs = props.api.session.messages(props.sessionId);
|
|
925
|
-
if (!msgs || msgs.length === 0)
|
|
926
|
-
return false;
|
|
927
|
-
const map = entryMap();
|
|
928
|
-
const from = Math.max(0, msgs.length - 6);
|
|
929
|
-
for (let i = msgs.length - 1; i >= from; i--) {
|
|
930
|
-
const parts = props.api.session.part(msgs[i]?.id) ?? [];
|
|
931
|
-
for (const part of parts) {
|
|
932
|
-
if (!part || part.type !== "tool")
|
|
933
|
-
continue;
|
|
934
|
-
if (!SUBAGENT_TOOLS.has(String(part.tool ?? "")))
|
|
935
|
-
continue;
|
|
936
|
-
const partId = String(part.id ?? "");
|
|
937
|
-
if (!partId)
|
|
938
|
-
continue;
|
|
939
|
-
const st = part.state;
|
|
940
|
-
const rawStatus = String(st?.status ?? "");
|
|
941
|
-
// scan 从不为 pending/error part 创建条目;
|
|
942
|
-
// 这里忽略它们可避免对一直保持该状态的 part 反复 rescan。
|
|
943
|
-
if (rawStatus !== "running" && rawStatus !== "completed")
|
|
944
|
-
continue;
|
|
945
|
-
const id = `tool:${partId}`;
|
|
946
|
-
const meta = partMetadata(part, st);
|
|
947
|
-
const subSid = meta?.session_id !== undefined ? String(meta.session_id)
|
|
948
|
-
: meta?.sessionId !== undefined ? String(meta.sessionId)
|
|
949
|
-
: undefined;
|
|
950
|
-
const key = findSubEntryKey(map, id, subSid);
|
|
951
|
-
const entry = key ? map.get(key) : undefined;
|
|
952
|
-
if (!entry)
|
|
953
|
-
return true;
|
|
954
|
-
// 非后台 tool 已完成、条目却仍停在 running:补一次 scan 收尾
|
|
955
|
-
// (success 事件可能因缺少 metadata 被适配层丢弃)。
|
|
956
|
-
const input = st?.input;
|
|
957
|
-
const isBackground = input?.background === true || input?.run_in_background === true;
|
|
958
|
-
const stillRunning = entry.status === "running" || entry.status === "cancel_requested";
|
|
959
|
-
if (rawStatus === "completed" && !isBackground && stillRunning)
|
|
960
|
-
return true;
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
catch { }
|
|
965
|
-
return false;
|
|
966
|
-
};
|
|
967
|
-
/** 新消息到达时防抖重扫。覆盖 scan 早于宿主消息缓存
|
|
968
|
-
* 加载完成的竞态。 */
|
|
969
|
-
const scheduleRescan = () => {
|
|
970
|
-
if (disposed)
|
|
971
|
-
return;
|
|
972
|
-
clearTimeout(rescanTimer);
|
|
973
|
-
rescanTimer = setTimeout(() => {
|
|
974
|
-
if (disposed)
|
|
975
|
-
return;
|
|
976
|
-
untrack(() => {
|
|
977
|
-
if (!missingNewestToolPart())
|
|
978
|
-
return;
|
|
979
|
-
runSessionScan(props.sessionId, false);
|
|
980
|
-
bump();
|
|
981
|
-
});
|
|
982
|
-
}, 400);
|
|
983
|
-
};
|
|
984
|
-
// usage enrichment 轮询的 round-robin 游标。
|
|
985
|
-
let enrichCursor = 0;
|
|
986
|
-
onMount(() => {
|
|
987
|
-
// Fast clock for smooth time display, separate from token polling
|
|
988
|
-
const clock = setInterval(() => { setNow(Date.now()); bump(); }, 100);
|
|
989
|
-
// Token poll — runs every 500ms for running entries
|
|
990
|
-
let tick = 0;
|
|
991
|
-
const tokenTimer = setInterval(() => {
|
|
992
|
-
tick++;
|
|
993
|
-
untrack(() => {
|
|
994
|
-
let enriched;
|
|
995
|
-
let attempted = 0;
|
|
996
|
-
setEntryMapRaw((prev) => {
|
|
997
|
-
let changed = false;
|
|
998
|
-
const next = new Map(prev);
|
|
999
|
-
// 候选:仍缺 usage 数据的条目。以 round-robin 遍历,
|
|
1000
|
-
// 避免一串读不到数据的会话(如 free-model 子会话)
|
|
1001
|
-
// 饿死列表其余部分;每个 tick 只读取少数会话。
|
|
1002
|
-
const candidates = [];
|
|
1003
|
-
for (const [id, entry] of next) {
|
|
1004
|
-
const running = entry.status === "running" || entry.status === "cancel_requested";
|
|
1005
|
-
const missing = entry.tokens === undefined || entry.cost === undefined || !validModel(entry.model);
|
|
1006
|
-
// 无链接条目(含历史 done)也参与:先用宿主/本地库回填 sid,再补 usage。
|
|
1007
|
-
if (entry.sessionId && !running && !missing)
|
|
1008
|
-
continue;
|
|
1009
|
-
candidates.push(id);
|
|
1010
|
-
}
|
|
1011
|
-
if (candidates.length > 0) {
|
|
1012
|
-
const start = enrichCursor % candidates.length;
|
|
1013
|
-
let budget = 4;
|
|
1014
|
-
for (let k = 0; k < candidates.length && budget > 0; k++) {
|
|
1015
|
-
const id = candidates[(start + k) % candidates.length];
|
|
1016
|
-
let entry = next.get(id);
|
|
1017
|
-
if (!entry)
|
|
1018
|
-
continue;
|
|
1019
|
-
if (!entry.sessionId) {
|
|
1020
|
-
try {
|
|
1021
|
-
const callId = String(entry.id).replace(/^tool:/, "");
|
|
1022
|
-
const sid = props.api.session.resolveChild?.({
|
|
1023
|
-
parentId: props.sessionId, callId, agent: entry.agent, startedAt: entry.startedAt,
|
|
1024
|
-
});
|
|
1025
|
-
if (sid) {
|
|
1026
|
-
entry = { ...entry, sessionId: sid };
|
|
1027
|
-
next.set(id, entry);
|
|
1028
|
-
changed = true;
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
catch { }
|
|
1032
|
-
}
|
|
1033
|
-
if (!entry.sessionId)
|
|
1034
|
-
continue;
|
|
1035
|
-
const childSid = entry.sessionId;
|
|
1036
|
-
// 只从子会话读取,绝不读父会话。较旧的子会话
|
|
1037
|
-
// 可能不在本地缓存中,此时它们仍属于
|
|
1038
|
-
// 本面板的 map,读取是安全的。
|
|
1039
|
-
let isChild = false;
|
|
1040
|
-
try {
|
|
1041
|
-
const s = props.api.session.get(childSid);
|
|
1042
|
-
isChild = !s || s.parentID === props.sessionId;
|
|
1043
|
-
}
|
|
1044
|
-
catch {
|
|
1045
|
-
isChild = true;
|
|
1046
|
-
}
|
|
1047
|
-
if (!isChild)
|
|
1048
|
-
continue;
|
|
1049
|
-
budget--;
|
|
1050
|
-
attempted++;
|
|
1051
|
-
const running = entry.status === "running" || entry.status === "cancel_requested";
|
|
1052
|
-
let entryChanged = false;
|
|
1053
|
-
const nextEntry = { ...entry };
|
|
1054
|
-
if (running || entry.tokens === undefined) {
|
|
1055
|
-
const total = props.api.usage.readSessionTokens(childSid);
|
|
1056
|
-
if (total !== undefined && total !== entry.tokens) {
|
|
1057
|
-
nextEntry.tokens = total;
|
|
1058
|
-
entryChanged = true;
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
if (running || entry.cost === undefined) {
|
|
1062
|
-
const cost = props.api.usage.readSessionCost(childSid);
|
|
1063
|
-
if (cost !== undefined && cost !== entry.cost) {
|
|
1064
|
-
nextEntry.cost = cost;
|
|
1065
|
-
entryChanged = true;
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
if (running || !validModel(entry.model)) {
|
|
1069
|
-
const model = props.api.usage.readSessionModel(childSid);
|
|
1070
|
-
if (model && model !== entry.model) {
|
|
1071
|
-
nextEntry.model = model;
|
|
1072
|
-
entryChanged = true;
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
if (running) {
|
|
1076
|
-
const todo = props.api.usage.readSessionTodo(childSid);
|
|
1077
|
-
if (todo !== undefined && (todo.total !== entry.todoTotal || todo.done !== entry.todoDone)) {
|
|
1078
|
-
nextEntry.todoTotal = todo.total;
|
|
1079
|
-
nextEntry.todoDone = todo.done;
|
|
1080
|
-
entryChanged = true;
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
if (entryChanged) {
|
|
1084
|
-
next.set(id, nextEntry);
|
|
1085
|
-
changed = true;
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
enrichCursor = (start + Math.max(1, attempted)) % candidates.length;
|
|
1089
|
-
}
|
|
1090
|
-
if (!changed)
|
|
1091
|
-
return prev;
|
|
1092
|
-
enriched = next;
|
|
1093
|
-
return next;
|
|
1094
|
-
});
|
|
1095
|
-
if (enriched) {
|
|
1096
|
-
// usage enrichment 是真实状态变更(cost/tokens/model)——通过与事件
|
|
1097
|
-
// 处理器相同的 merge-safe 路径持久化。
|
|
1098
|
-
globalEntryCache.set(props.sessionId, new Map(enriched));
|
|
1099
|
-
persistEntries(props.sessionId, enriched);
|
|
1100
|
-
}
|
|
1101
|
-
});
|
|
1102
|
-
reconcileIdleEntries();
|
|
1103
|
-
// discovery poll(约每 2s):恢复从未通过宿主事件到达的
|
|
1104
|
-
// 子代理 part。与事件订阅的健康状况无关。
|
|
1105
|
-
if (tick % 4 === 0) {
|
|
1106
|
-
untrack(() => {
|
|
1107
|
-
if (missingNewestToolPart()) {
|
|
1108
|
-
runSessionScan(props.sessionId, false);
|
|
1109
|
-
bump();
|
|
1110
|
-
}
|
|
1111
|
-
});
|
|
1112
|
-
}
|
|
1113
|
-
// 周期性对账(约每 30s):存在 running 条目时与历史重扫一次,
|
|
1114
|
-
// 兜底丢失的 success / idle 事件(含消息同步后才可见的 part)。
|
|
1115
|
-
if (tick % 60 === 0) {
|
|
1116
|
-
untrack(() => {
|
|
1117
|
-
const hasRunning = entryList().some((e) => e.status === "running" || e.status === "cancel_requested");
|
|
1118
|
-
if (hasRunning) {
|
|
1119
|
-
runSessionScan(props.sessionId, false);
|
|
1120
|
-
bump();
|
|
1121
|
-
}
|
|
1122
|
-
});
|
|
1123
|
-
}
|
|
1124
|
-
bump();
|
|
1125
|
-
}, 500);
|
|
1126
|
-
bump();
|
|
1127
|
-
const unsubPart = props.api.event.on("part.updated", (e) => {
|
|
1128
|
-
handlePartUpdated(e);
|
|
1129
|
-
bump();
|
|
1130
|
-
});
|
|
1131
|
-
const unsubMsg = props.api.event.on("message.updated", () => { bump(); scheduleRescan(); });
|
|
1132
|
-
const unsubIdle = props.api.event.on("session.idle", (e) => {
|
|
1133
|
-
handleSessionEnd(e, "done");
|
|
1134
|
-
bump();
|
|
1135
|
-
});
|
|
1136
|
-
const unsubError = props.api.event.on("session.error", (e) => {
|
|
1137
|
-
handleSessionEnd(e, "error");
|
|
1138
|
-
bump();
|
|
1139
|
-
});
|
|
1140
|
-
onCleanup(() => {
|
|
1141
|
-
disposed = true;
|
|
1142
|
-
clearTimeout(rescanTimer);
|
|
1143
|
-
clearInterval(clock);
|
|
1144
|
-
clearInterval(tokenTimer);
|
|
1145
|
-
unsubPart();
|
|
1146
|
-
unsubMsg();
|
|
1147
|
-
unsubIdle();
|
|
1148
|
-
unsubError();
|
|
1149
|
-
});
|
|
1150
|
-
});
|
|
1151
|
-
// ── session‑switch & initial‑load scan ──
|
|
1152
|
-
// On session change: load from kv (entries survive component unmount), then scan+merge.
|
|
1153
|
-
// On same session: only scan+merge (keep event‑driven running entries).
|
|
1154
|
-
let lastSid = props.sessionId;
|
|
1155
|
-
let lastTick = 0;
|
|
1156
|
-
createEffect(() => {
|
|
1157
|
-
const sid = props.sessionId;
|
|
1158
|
-
const switched = sid !== lastSid;
|
|
1159
|
-
lastSid = sid;
|
|
1160
|
-
const tick = clearTick(); // 外部触发清除时 +1,effect 重跑
|
|
1161
|
-
const forceReload = tick !== lastTick && !switched;
|
|
1162
|
-
lastTick = tick;
|
|
1163
|
-
let attempts = 0;
|
|
1164
|
-
let retryTimer;
|
|
1165
|
-
const run = () => {
|
|
1166
|
-
untrack(() => {
|
|
1167
|
-
if (switched) {
|
|
1168
|
-
const { parentSid, isChild } = resolveParent(sid);
|
|
1169
|
-
const data = loadSessionData();
|
|
1170
|
-
const saved = isChild
|
|
1171
|
-
? data[parentSid]?.children?.[sid]?.scroll ?? 0
|
|
1172
|
-
: data[sid]?.scroll ?? 0;
|
|
1173
|
-
setScrollOffset(saved);
|
|
1174
|
-
// 刷新父会话的访问时间 TTL,防止活跃会话的数据过期
|
|
1175
|
-
if (!isChild && data[sid]?.entries?.length) {
|
|
1176
|
-
void Promise.resolve(updateSessionData(props.api.kv, (latest) => {
|
|
1177
|
-
if (latest[sid]?.entries?.length)
|
|
1178
|
-
latest[sid].ts = Date.now();
|
|
1179
|
-
})).catch(() => { });
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
const ok = runSessionScan(sid, switched || forceReload);
|
|
1183
|
-
// 首次挂载时宿主消息缓存可能尚未就绪;
|
|
1184
|
-
// 重试几次,避免面板一直空着直到
|
|
1185
|
-
// 下一次切换会话。
|
|
1186
|
-
if (!ok && attempts < 4) {
|
|
1187
|
-
attempts++;
|
|
1188
|
-
retryTimer = setTimeout(run, 400);
|
|
1189
|
-
}
|
|
1190
|
-
});
|
|
1191
|
-
bump();
|
|
1192
|
-
};
|
|
1193
|
-
const t = setTimeout(run, 150);
|
|
1194
|
-
onCleanup(() => {
|
|
1195
|
-
clearTimeout(t);
|
|
1196
|
-
if (retryTimer)
|
|
1197
|
-
clearTimeout(retryTimer);
|
|
1198
|
-
});
|
|
1199
|
-
});
|
|
1200
|
-
// ── palette ──
|
|
1201
|
-
const pal = createMemo(() => {
|
|
1202
|
-
const th = props.theme;
|
|
1203
|
-
const sat = (k, fb) => desaturateTo(th[k], MAX_SAT, fb);
|
|
1204
|
-
return {
|
|
1205
|
-
primary: sat("primary", FALLBACK.primary),
|
|
1206
|
-
text: sat("text", FALLBACK.text),
|
|
1207
|
-
muted: sat("textMuted", FALLBACK.muted),
|
|
1208
|
-
success: sat("success", FALLBACK.success),
|
|
1209
|
-
warning: sat("warning", FALLBACK.warning),
|
|
1210
|
-
error: sat("error", FALLBACK.error),
|
|
1211
|
-
border: sat("border", FALLBACK.border),
|
|
1212
|
-
};
|
|
1213
|
-
});
|
|
1214
|
-
// ── derived signals ──
|
|
1215
|
-
// Stable list — only changes when entryMap changes
|
|
1216
|
-
const entryList = createMemo(() => {
|
|
1217
|
-
const entries = [...entryMap().values()];
|
|
1218
|
-
if (props.sortOrder() === "desc") {
|
|
1219
|
-
return entries.sort((a, b) => b.startedAt - a.startedAt);
|
|
1220
|
-
}
|
|
1221
|
-
return entries.sort((a, b) => a.startedAt - b.startedAt);
|
|
1222
|
-
});
|
|
1223
|
-
const max = props.maxEntries;
|
|
1224
|
-
const clampedOffset = createMemo(() => {
|
|
1225
|
-
const total = entryList().length;
|
|
1226
|
-
const m = max();
|
|
1227
|
-
if (total <= m)
|
|
1228
|
-
return 0;
|
|
1229
|
-
return Math.min(scrollOffset(), total - m);
|
|
1230
|
-
});
|
|
1231
|
-
const visibleList = createMemo(() => entryList().slice(clampedOffset(), clampedOffset() + max()));
|
|
1232
|
-
const hiddenAbove = createMemo(() => clampedOffset());
|
|
1233
|
-
const hiddenBelow = createMemo(() => Math.max(0, entryList().length - clampedOffset() - max()));
|
|
1234
|
-
// Drop hover state when ↑ more disappears (hiddenAbove hits zero)
|
|
1235
|
-
createEffect(() => {
|
|
1236
|
-
if (hiddenAbove() === 0)
|
|
1237
|
-
setHoveredMoreAbove(false);
|
|
1238
|
-
});
|
|
1239
|
-
// Reset scroll on sort order change: jump to newest in view
|
|
1240
|
-
let sortInitialized = false;
|
|
1241
|
-
createEffect(() => {
|
|
1242
|
-
props.sortOrder();
|
|
1243
|
-
if (!sortInitialized) {
|
|
1244
|
-
sortInitialized = true;
|
|
1245
|
-
return;
|
|
1246
|
-
}
|
|
1247
|
-
const total = untrack(() => entryList().length);
|
|
1248
|
-
const m = untrack(() => max());
|
|
1249
|
-
const target = props.sortOrder() === "desc" ? 0 : Math.max(0, total - m);
|
|
1250
|
-
setScrollOffset(target);
|
|
1251
|
-
setTimeout(() => {
|
|
1252
|
-
try {
|
|
1253
|
-
persistScroll(props.sessionId, target);
|
|
1254
|
-
}
|
|
1255
|
-
catch { }
|
|
1256
|
-
}, 0);
|
|
1257
|
-
});
|
|
1258
|
-
// When new entries arrive while viewing the newest end, keep the view at newest
|
|
1259
|
-
let prevEntryCount = 0;
|
|
1260
|
-
createEffect(() => {
|
|
1261
|
-
const total = entryList().length;
|
|
1262
|
-
if (prevEntryCount === 0) {
|
|
1263
|
-
prevEntryCount = total;
|
|
1264
|
-
return;
|
|
1265
|
-
}
|
|
1266
|
-
if (total === prevEntryCount)
|
|
1267
|
-
return;
|
|
1268
|
-
const m = max();
|
|
1269
|
-
const wasAtNewest = props.sortOrder() === "desc"
|
|
1270
|
-
? untrack(() => scrollOffset() === 0)
|
|
1271
|
-
: untrack(() => scrollOffset() >= prevEntryCount - m);
|
|
1272
|
-
prevEntryCount = total;
|
|
1273
|
-
if (wasAtNewest) {
|
|
1274
|
-
const target = props.sortOrder() === "desc" ? 0 : Math.max(0, total - m);
|
|
1275
|
-
setScrollOffset(target);
|
|
1276
|
-
setTimeout(() => {
|
|
1277
|
-
try {
|
|
1278
|
-
persistScroll(props.sessionId, target);
|
|
1279
|
-
}
|
|
1280
|
-
catch { }
|
|
1281
|
-
}, 0);
|
|
1282
|
-
}
|
|
1283
|
-
});
|
|
1284
|
-
const entries = createMemo(() => {
|
|
1285
|
-
const nowVal = now();
|
|
1286
|
-
return entryList().map((e) => ({
|
|
1287
|
-
...e,
|
|
1288
|
-
elapsed: (e.endedAt ?? nowVal) - e.startedAt,
|
|
1289
|
-
}));
|
|
1290
|
-
});
|
|
1291
|
-
const doneCount = createMemo(() => entryList().filter((e) => e.status === "done" || e.status === "cancelled").length);
|
|
1292
|
-
const runningCount = createMemo(() => entryList().filter((e) => e.status === "running" || e.status === "cancel_requested").length);
|
|
1293
|
-
const errCount = createMemo(() => entryList().filter((e) => e.status === "error").length);
|
|
1294
|
-
const anyEntry = () => entryList().length > 0;
|
|
1295
|
-
const totalTokens = createMemo(() => {
|
|
1296
|
-
let sum = 0;
|
|
1297
|
-
for (const e of entryList()) {
|
|
1298
|
-
if (e.tokens)
|
|
1299
|
-
sum += e.tokens;
|
|
1300
|
-
}
|
|
1301
|
-
return sum;
|
|
1302
|
-
});
|
|
1303
|
-
const totalCost = createMemo(() => {
|
|
1304
|
-
let sum = 0;
|
|
1305
|
-
for (const e of entryList()) {
|
|
1306
|
-
if (e.cost)
|
|
1307
|
-
sum += e.cost;
|
|
1308
|
-
}
|
|
1309
|
-
return sum;
|
|
1310
|
-
});
|
|
1311
|
-
const toggleExpand = (id) => {
|
|
1312
|
-
setExpanded((prev) => {
|
|
1313
|
-
const next = prev === id ? undefined : id;
|
|
1314
|
-
try {
|
|
1315
|
-
persistExpanded(props.sessionId, next ?? "");
|
|
1316
|
-
}
|
|
1317
|
-
catch { }
|
|
1318
|
-
return next;
|
|
1319
|
-
});
|
|
1320
|
-
};
|
|
1321
|
-
/** Horizontal space eaten by the panel border (1+1) and its padding (2+2). */
|
|
1322
|
-
const gutter = createMemo(() => (props.borderVisible() ? 6 : 0));
|
|
1323
|
-
const sep = () => "\u2500".repeat(Math.max(1, panelWidth() - gutter()));
|
|
1324
|
-
// The border toggling shifts the box dimensions, which may not reliably
|
|
1325
|
-
// re-fire onSizeChange across (re)mount cycles; re-sync panelWidth with the
|
|
1326
|
-
// live box so the content width stays correct.
|
|
1327
|
-
createEffect(() => {
|
|
1328
|
-
props.borderVisible();
|
|
1329
|
-
if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
|
|
1330
|
-
const w = Math.max(20, boxEl.width);
|
|
1331
|
-
setPanelWidth((prev) => (prev === w ? prev : w));
|
|
1332
|
-
}
|
|
1333
|
-
});
|
|
1334
|
-
// ── expanded detail right-align ──
|
|
1335
|
-
const expandedMaxLabelW = createMemo(() => {
|
|
1336
|
-
const labels = [
|
|
1337
|
-
t("agent.label"), t("status.label"), t("time.label"), t("tokens.label"),
|
|
1338
|
-
t("error.label"), t("cost.label"), t("model.label"), t("todo.label"), t("session.label"),
|
|
1339
|
-
];
|
|
1340
|
-
return Math.max(...labels.map(l => visualWidth(l + ": ")));
|
|
1341
|
-
});
|
|
1342
|
-
const expandedPad = (label) => Math.max(0, expandedMaxLabelW() - visualWidth(label + ": "));
|
|
1343
|
-
const expandedValAvail = () => Math.max(6, panelWidth() - gutter() - INDENT - expandedMaxLabelW());
|
|
1344
|
-
// ── header parts for colored spans ──
|
|
1345
|
-
const summaryParts = createMemo(() => {
|
|
1346
|
-
if (!anyEntry())
|
|
1347
|
-
return null;
|
|
1348
|
-
const dot = "\u25cf";
|
|
1349
|
-
const cost = totalCost();
|
|
1350
|
-
return {
|
|
1351
|
-
done: `${dot}${doneCount()}`,
|
|
1352
|
-
running: runningCount() > 0 ? `${dot}${runningCount()}` : null,
|
|
1353
|
-
err: errCount() > 0 ? `${dot}${errCount()}` : null,
|
|
1354
|
-
duration: totalTokens() > 0 ? fmtTokens(totalTokens()) : "",
|
|
1355
|
-
cost: cost > 0 ? `$${cost.toFixed(2)}` : "",
|
|
1356
|
-
};
|
|
1357
|
-
});
|
|
1358
|
-
const summaryCols = createMemo(() => {
|
|
1359
|
-
const p = summaryParts();
|
|
1360
|
-
if (!p)
|
|
1361
|
-
return 0;
|
|
1362
|
-
let w = visualWidth(p.done);
|
|
1363
|
-
if (p.running)
|
|
1364
|
-
w += 1 + visualWidth(p.running);
|
|
1365
|
-
if (p.err)
|
|
1366
|
-
w += 1 + visualWidth(p.err);
|
|
1367
|
-
w += p.duration ? 1 + visualWidth(p.duration) : 0;
|
|
1368
|
-
w += p.cost ? 1 + visualWidth(p.cost) : 0;
|
|
1369
|
-
return w;
|
|
1370
|
-
});
|
|
1371
|
-
const versionText = ` v${PLUGIN_VERSION}`;
|
|
1372
|
-
const versionW = visualWidth(versionText);
|
|
1373
|
-
const showVersion = createMemo(() => {
|
|
1374
|
-
if (!open())
|
|
1375
|
-
return false;
|
|
1376
|
-
const icon = "\u25bc";
|
|
1377
|
-
const need = visualWidth(icon) + 1 + visualWidth(t("panel.title")) + versionW + summaryCols();
|
|
1378
|
-
return need <= panelWidth() - gutter();
|
|
1379
|
-
});
|
|
1380
|
-
const leftCols = createMemo(() => {
|
|
1381
|
-
const icon = open() ? "\u25bc" : "\u25b6";
|
|
1382
|
-
let w = visualWidth(icon) + 1 + visualWidth(t("panel.title"));
|
|
1383
|
-
if (showVersion())
|
|
1384
|
-
w += versionW;
|
|
1385
|
-
return w;
|
|
1386
|
-
});
|
|
1387
|
-
const spacerCols = createMemo(() => {
|
|
1388
|
-
if (!anyEntry())
|
|
1389
|
-
return 0;
|
|
1390
|
-
return Math.max(0, panelWidth() - gutter() - leftCols() - summaryCols());
|
|
1391
|
-
});
|
|
1392
|
-
const valueCols = (label) => Math.max(4, panelWidth() - gutter() - INDENT - visualWidth(label + ": "));
|
|
1393
|
-
// ── render ──
|
|
1394
|
-
return (_jsxs("box", { border: props.borderVisible(), ...(props.borderVisible() ? { borderColor: pal().border } : {}), paddingTop: 0, paddingBottom: 0, paddingLeft: props.borderVisible() ? 2 : 0, paddingRight: props.borderVisible() ? 2 : 0, flexDirection: "column", gap: 0, ref: boxEl, onSizeChange: () => {
|
|
1395
|
-
const w = boxEl ? Math.max(20, boxEl.width ?? 0) : 28;
|
|
1396
|
-
setPanelWidth((prev) => (prev === w ? prev : w));
|
|
1397
|
-
}, children: [_jsxs("text", { onMouseUp: () => {
|
|
1398
|
-
setOpen((o) => {
|
|
1399
|
-
const n = !o;
|
|
1400
|
-
try {
|
|
1401
|
-
props.api.kv.set(`${KV_PREFIX}.open`, n);
|
|
1402
|
-
}
|
|
1403
|
-
catch { }
|
|
1404
|
-
return n;
|
|
1405
|
-
});
|
|
1406
|
-
bump();
|
|
1407
|
-
}, children: [_jsx("span", { style: { fg: pal().muted }, children: renderTick() >= 0 && open() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: t("panel.title") }), _jsx(Show, { when: showVersion(), children: _jsx("span", { style: { fg: dimColor(pal().muted, 0.75) }, children: versionText }) }), anyEntry() ? (_jsxs(_Fragment, { children: [_jsx("span", { style: { fg: pal().muted }, children: " ".repeat(spacerCols()) }), _jsx("span", { style: { fg: pal().success }, children: summaryParts().done }), runningCount() > 0 && (_jsxs("span", { style: { fg: pal().warning }, children: [" ", summaryParts().running] })), errCount() > 0 && (_jsxs("span", { style: { fg: pal().error }, children: [" ", summaryParts().err] })), summaryParts().duration ? (_jsxs("span", { style: { fg: pal().muted }, children: [" ", summaryParts().duration] })) : null, summaryParts().cost ? (_jsxs("span", { style: { fg: pal().warning }, children: [" ", summaryParts().cost] })) : null] })) : null] }), _jsxs(Show, { when: open(), children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsx(Show, { when: anyEntry(), fallback: _jsxs("text", { style: { fg: pal().muted }, children: [" ", "> ", t("status.none"), " "] }), children: _jsxs("box", { onMouseScroll: (e) => {
|
|
1408
|
-
if (props.scrollMode() === "click")
|
|
1409
|
-
return;
|
|
1410
|
-
const total = entryList().length;
|
|
1411
|
-
const m = max();
|
|
1412
|
-
if (total <= m)
|
|
1413
|
-
return;
|
|
1414
|
-
const dir = e.button === 0 ? 1 : -1;
|
|
1415
|
-
setScrollOffset((prev) => {
|
|
1416
|
-
const next = Math.max(0, Math.min(prev + dir, total - m));
|
|
1417
|
-
try {
|
|
1418
|
-
persistScroll(props.sessionId, next);
|
|
1419
|
-
}
|
|
1420
|
-
catch { }
|
|
1421
|
-
return next;
|
|
1422
|
-
});
|
|
1423
|
-
}, children: [_jsx(Show, { when: hiddenAbove() > 0, children: _jsx("text", { onMouseOver: () => setHoveredMoreAbove(true), onMouseOut: () => setHoveredMoreAbove(false), onMouseUp: () => {
|
|
1424
|
-
const total = entryList().length;
|
|
1425
|
-
const m = max();
|
|
1426
|
-
if (total <= m)
|
|
1427
|
-
return;
|
|
1428
|
-
const next = Math.max(0, scrollOffset() - m);
|
|
1429
|
-
if (next === 0) {
|
|
1430
|
-
setTimeout(() => {
|
|
1431
|
-
setScrollOffset(next);
|
|
1432
|
-
try {
|
|
1433
|
-
persistScroll(props.sessionId, next);
|
|
1434
|
-
}
|
|
1435
|
-
catch { }
|
|
1436
|
-
}, 0);
|
|
1437
|
-
}
|
|
1438
|
-
else {
|
|
1439
|
-
setScrollOffset(next);
|
|
1440
|
-
try {
|
|
1441
|
-
persistScroll(props.sessionId, next);
|
|
1442
|
-
}
|
|
1443
|
-
catch { }
|
|
1444
|
-
}
|
|
1445
|
-
}, children: _jsxs("span", { style: { fg: hoveredMoreAbove() ? pal().warning : pal().muted }, children: [" ", "\u2191 ", hiddenAbove(), " ", t("scroll.more")] }) }) }), _jsx(For, { each: visibleList(), children: (entry) => {
|
|
1446
|
-
const isExpanded = () => expanded() === entry.id;
|
|
1447
|
-
const isRunning = entry.status === "running";
|
|
1448
|
-
const isCancelRequested = entry.status === "cancel_requested";
|
|
1449
|
-
const isCancelled = entry.status === "cancelled";
|
|
1450
|
-
const isError = entry.status === "error";
|
|
1451
|
-
const isActiveRunning = isRunning || isCancelRequested;
|
|
1452
|
-
const elapsed = () => (entry.endedAt ?? now()) - entry.startedAt;
|
|
1453
|
-
const statusDot = () => "\u25cf";
|
|
1454
|
-
const statusColor = () => {
|
|
1455
|
-
if (isCancelled)
|
|
1456
|
-
return pal().muted;
|
|
1457
|
-
if (!isActiveRunning)
|
|
1458
|
-
return isError ? pal().error : pal().success;
|
|
1459
|
-
const t = (Math.sin(((now() % 2000) / 2000) * Math.PI * 2 - Math.PI / 2) + 1) / 2;
|
|
1460
|
-
const a = rgb(pal().muted), b = rgb(pal().warning);
|
|
1461
|
-
if (!a || !b)
|
|
1462
|
-
return pal().warning;
|
|
1463
|
-
const r = Math.round(a.r + (b.r - a.r) * t);
|
|
1464
|
-
const g = Math.round(a.g + (b.g - a.g) * t);
|
|
1465
|
-
const bl = Math.round(a.b + (b.b - a.b) * t);
|
|
1466
|
-
return "#" + [r, g, bl].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("");
|
|
1467
|
-
};
|
|
1468
|
-
const timeColor = () => isActiveRunning ? pal().warning : isError ? pal().error : pal().muted;
|
|
1469
|
-
// Entry label: collapsed shows title only, expanded shows title only too
|
|
1470
|
-
const tokenText = () => !isExpanded() && props.showEntryTokens() && entry.tokens !== undefined && entry.tokens > 0
|
|
1471
|
-
? ` ${fmtTokens(entry.tokens)}`
|
|
1472
|
-
: "";
|
|
1473
|
-
const costText = () => !isExpanded() && props.showEntryCost() && entry.cost !== undefined && entry.cost > 0
|
|
1474
|
-
? ` $${entry.cost.toFixed(4)}`
|
|
1475
|
-
: "";
|
|
1476
|
-
const timeText = () => !isExpanded() && props.showEntryTime() && (elapsed() >= 2000 || entry.endedAt !== undefined)
|
|
1477
|
-
? fmtDuration(elapsed(), isActiveRunning, props.timeFormat())
|
|
1478
|
-
: "";
|
|
1479
|
-
const suffixW = () => {
|
|
1480
|
-
let w = 0;
|
|
1481
|
-
const t = timeText();
|
|
1482
|
-
if (t)
|
|
1483
|
-
w += 1 + visualWidth(t);
|
|
1484
|
-
const tk = tokenText();
|
|
1485
|
-
if (tk)
|
|
1486
|
-
w += visualWidth(tk);
|
|
1487
|
-
const c = costText();
|
|
1488
|
-
if (c)
|
|
1489
|
-
w += visualWidth(c);
|
|
1490
|
-
return w;
|
|
1491
|
-
};
|
|
1492
|
-
const labelAvail = () => Math.max(6, panelWidth() - gutter() - LEFT_PAD - suffixW());
|
|
1493
|
-
const labelText = () => {
|
|
1494
|
-
const max = labelAvail();
|
|
1495
|
-
const text = entry.title || entry.agent;
|
|
1496
|
-
const truncated = truncate(text, max);
|
|
1497
|
-
const pad = Math.max(0, max - visualWidth(truncated));
|
|
1498
|
-
return truncated + " ".repeat(pad);
|
|
1499
|
-
};
|
|
1500
|
-
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, 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 })] }), _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: (() => {
|
|
1501
|
-
const cost = entry.cost;
|
|
1502
|
-
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)] })] }));
|
|
1503
|
-
})() }), _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 () => {
|
|
1504
|
-
const sessionId = entry.sessionId;
|
|
1505
|
-
if (!sessionId)
|
|
1506
|
-
return;
|
|
1507
|
-
const result = await copyText(sessionId);
|
|
1508
|
-
if (result.copied) {
|
|
1509
|
-
props.api.ui.toast(t("session.toast.copied"), {
|
|
1510
|
-
variant: "success",
|
|
1511
|
-
title: entry.title || entry.agent,
|
|
1512
|
-
duration: 2500,
|
|
1513
|
-
});
|
|
1514
|
-
return;
|
|
1515
|
-
}
|
|
1516
|
-
props.api.ui.toast(`${sessionId}\n\n${t("session.toast.copy_failed")}`, {
|
|
1517
|
-
variant: "warning",
|
|
1518
|
-
title: entry.title || entry.agent,
|
|
1519
|
-
duration: 8000,
|
|
1520
|
-
});
|
|
1521
|
-
}, children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("session.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("session.label"))) }), _jsx("span", { style: { fg: pal().muted }, children: truncate(entry.sessionId, expandedValAvail() - visualWidth(" ⎘")) }), _jsx("span", { style: { fg: pal().warning }, children: " \u2398" })] }) }), _jsx(Show, { when: entry.sessionId || isRunning, children: (() => {
|
|
1522
|
-
const openPrefix = () => " \u2192 ";
|
|
1523
|
-
const openFull = () => entry.sessionId ? openPrefix() + t("open.label") : "";
|
|
1524
|
-
const openW = () => entry.sessionId ? visualWidth(openFull()) : 0;
|
|
1525
|
-
const cancelLabel = () => ` ${t("cancel.label")}`;
|
|
1526
|
-
const dismissLabel = () => ` ${t("dismiss.label")}`;
|
|
1527
|
-
const rightW = (isRunning ? visualWidth(dismissLabel()) : 0) + (isRunning && entry.sessionId ? visualWidth(cancelLabel()) : 0);
|
|
1528
|
-
const spacerW = () => Math.max(1, panelWidth() - gutter() - openW() - rightW - 2);
|
|
1529
|
-
return (_jsxs("box", { flexDirection: "row", children: [_jsx(Show, { when: entry.sessionId, children: _jsxs("text", { onMouseOver: () => setHoveredOpen(entry.id), onMouseOut: () => setHoveredOpen(undefined), onMouseUp: () => {
|
|
1530
|
-
if (entry.sessionId) {
|
|
1531
|
-
props.api.route.navigateSession(entry.sessionId);
|
|
1532
|
-
}
|
|
1533
|
-
}, children: [_jsx("span", { style: { fg: hoveredOpen() === entry.id ? pal().warning : pal().primary }, children: openPrefix() }), _jsx("span", { style: { fg: hoveredOpen() === entry.id ? pal().warning : pal().primary }, children: t("open.label") })] }) }), _jsx("text", { style: { fg: pal().muted }, children: " ".repeat(spacerW()) }), _jsx(Show, { when: isRunning && entry.sessionId, children: _jsx("text", { onMouseOver: () => setHoveredCancel(entry.id), onMouseOut: () => setHoveredCancel(undefined), onMouseUp: () => cancelEntry(entry), children: _jsx("span", { style: { fg: hoveredCancel() === entry.id ? pal().warning : pal().error }, children: cancelLabel() }) }) }), _jsx(Show, { when: isRunning, children: _jsx("text", { onMouseOver: () => setHoveredDismiss(entry.id), onMouseOut: () => setHoveredDismiss(undefined), onMouseUp: () => {
|
|
1534
|
-
upsertEntry({ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt, status: "done" });
|
|
1535
|
-
}, children: _jsx("span", { style: { fg: hoveredDismiss() === entry.id ? pal().warning : pal().muted }, children: dismissLabel() }) }) })] }));
|
|
1536
|
-
})() })] })] }));
|
|
1537
|
-
} }), _jsx(Show, { when: hiddenBelow() > 0 || (props.sortOrder() === "desc" ? scrollOffset() > 0 : entryList().length > max() && clampedOffset() < entryList().length - max()), children: (() => {
|
|
1538
|
-
const showMore = hiddenBelow() > 0;
|
|
1539
|
-
const showTop = props.sortOrder() === "desc"
|
|
1540
|
-
? scrollOffset() > 0
|
|
1541
|
-
: entryList().length > max() && clampedOffset() < entryList().length - max();
|
|
1542
|
-
const left = showMore ? ` \u2193 ${hiddenBelow()} ${t("scroll.more")}` : " ";
|
|
1543
|
-
const right = props.sortOrder() === "desc"
|
|
1544
|
-
? `\u2191 ${t("scroll.top")}`
|
|
1545
|
-
: `\u2193 ${t("scroll.bottom")}`;
|
|
1546
|
-
const pad = showTop ? Math.max(1, panelWidth() - gutter() - visualWidth(left) - visualWidth(right)) : 0;
|
|
1547
|
-
return (_jsxs("box", { flexDirection: "row", children: [_jsx("text", { onMouseOver: () => showMore && setHoveredMoreBelow(true), onMouseOut: () => setHoveredMoreBelow(false), onMouseUp: () => {
|
|
1548
|
-
if (!showMore)
|
|
1549
|
-
return;
|
|
1550
|
-
const total = entryList().length;
|
|
1551
|
-
const m = max();
|
|
1552
|
-
if (total <= m)
|
|
1553
|
-
return;
|
|
1554
|
-
setScrollOffset((prev) => Math.min(total - m, prev + m));
|
|
1555
|
-
try {
|
|
1556
|
-
persistScroll(props.sessionId, scrollOffset());
|
|
1557
|
-
}
|
|
1558
|
-
catch { }
|
|
1559
|
-
setHoveredMoreBelow(false);
|
|
1560
|
-
}, children: _jsx("span", { style: { fg: showMore && hoveredMoreBelow() ? pal().warning : pal().muted }, children: left }) }), showTop ? (_jsxs(_Fragment, { children: [_jsx("text", { style: { fg: pal().muted }, children: " ".repeat(pad) }), _jsx("text", { onMouseOver: () => setHoveredTop(true), onMouseOut: () => setHoveredTop(false), onMouseUp: () => {
|
|
1561
|
-
const total = entryList().length;
|
|
1562
|
-
const m = max();
|
|
1563
|
-
if (props.sortOrder() === "desc") {
|
|
1564
|
-
setScrollOffset(0);
|
|
1565
|
-
}
|
|
1566
|
-
else {
|
|
1567
|
-
setScrollOffset(Math.max(0, total - m));
|
|
1568
|
-
}
|
|
1569
|
-
setHoveredTop(false);
|
|
1570
|
-
}, children: _jsx("span", { style: { fg: hoveredTop() ? pal().warning : pal().muted }, children: right }) })] })) : null] }));
|
|
1571
|
-
})() })] }) })] })] }));
|
|
1572
|
-
}
|