opencode-subagent-magazine 1.6.0-beta.3 → 1.6.0

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