finch-session-board 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1134 @@
1
+ import { appendFileSync, mkdirSync, rmSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ // finchapp/shared/utils/env.js
5
+ /** 开发档为 "yes"(build.js --dev 与 dev.js 注入),正式档为 "no";字符串而非布尔,注入缺失时能一眼看出没钉上。 */
6
+ function isDev() {
7
+ return false;
8
+ }
9
+ /** 包名,取 package.json 的 name;打包遗漏时退回一个固定值,日志照常能落盘。 */
10
+ function appId() {
11
+ return "finch-session-board";
12
+ }
13
+ // finchapp/shared/utils/error.js
14
+ /**
15
+ * @param {unknown} error 任意抛出物
16
+ * @returns {string} 可读的一行错误信息
17
+ */
18
+ function reasonOf(error) {
19
+ return error instanceof Error ? error.message : String(error);
20
+ }
21
+ // finchapp/shared/backend/log.js
22
+ /** 开发日志目录名:与 desktop、vscode、web 共用同一个仓库级约定。 */
23
+ const DEV_DIR = ".yicodeLogsDev";
24
+ /** 敏感字段名,命中的值不进日志。 */
25
+ const SECRET_PATTERN = /password|passwd|token|secret|cookie|authorization|api[-_]?key|credential/i;
26
+ /** 脱敏递归的最大深度,防止深对象和循环引用把日志写炸。 */
27
+ const SANITIZE_DEPTH = 4;
28
+ /** 开发日志的完整路径。 */
29
+ function devLogPath() {
30
+ return join(homedir(), DEV_DIR, `${appId()}.dev.log.jsonl`);
31
+ }
32
+ /** 递归抹掉敏感字段的值;非对象原样返回。 */
33
+ function sanitize(value, depth = 0) {
34
+ if (depth > SANITIZE_DEPTH || value === null || typeof value !== "object") return value;
35
+ if (Array.isArray(value)) return value.map((item) => sanitize(item, depth + 1));
36
+ const result = {};
37
+ for (const [key, item] of Object.entries(value)) result[key] = SECRET_PATTERN.test(key) ? "[redacted]" : sanitize(item, depth + 1);
38
+ return result;
39
+ }
40
+ /** Error 要手动展开:JSON.stringify 会把它序列化成空对象。 */
41
+ function describe(value) {
42
+ if (value instanceof Error) return {
43
+ name: value.name,
44
+ message: value.message,
45
+ stack: value.stack
46
+ };
47
+ return value;
48
+ }
49
+ /** 补零到指定宽度,默认两位;毫秒要三位。 */
50
+ const pad = (value, size = 2) => String(value).padStart(size, "0");
51
+ /**
52
+ * 本地时间带时区偏移,形如 2026-11-04T20:37:12.345+08:00。
53
+ * 不用 toISOString 的 UTC:翻日志时还要心算 +8,也没法一眼看出是哪一刻。
54
+ * 仍然是标准 ISO 8601,new Date() 与字符串排序都照常。
55
+ */
56
+ function stampOf(date) {
57
+ const offset = -date.getTimezoneOffset();
58
+ const zone = `${offset >= 0 ? "+" : "-"}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`;
59
+ return `${`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`}T${`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`}${zone}`;
60
+ }
61
+ /**
62
+ * 建一个带作用域前缀的日志器。
63
+ * @param {object} ctx 小程序上下文,只用 logger
64
+ * @param {string} scope 模块名,例如 window / canvas / events
65
+ * @returns {{ debug: Function, info: Function, warn: Function, error: Function, file: string }}
66
+ */
67
+ function createLog(ctx, scope) {
68
+ const dev = isDev();
69
+ const file = dev ? devLogPath() : "";
70
+ if (dev) try {
71
+ mkdirSync(join(homedir(), DEV_DIR), { recursive: true });
72
+ rmSync(file, { force: true });
73
+ } catch (error) {
74
+ ctx.logger.warn(`${scope} 开发日志目录不可用`, reasonOf(error));
75
+ }
76
+ /** 开发档落盘一份,两种形态都转发给 Finch 插件日志;两件事互不依赖。 */
77
+ const write = (level, message, detail) => {
78
+ if (!dev && level === "debug") return;
79
+ if (file) {
80
+ const row = {
81
+ ts: stampOf(/* @__PURE__ */ new Date()),
82
+ level,
83
+ scope,
84
+ msg: message
85
+ };
86
+ if (detail !== void 0) row.data = sanitize(detail);
87
+ try {
88
+ appendFileSync(file, `${JSON.stringify(row)}\n`, "utf8");
89
+ } catch (error) {
90
+ ctx.logger.warn(`${scope} 日志落盘失败`, reasonOf(error));
91
+ }
92
+ }
93
+ ctx.logger[level](`[${scope}] ${message}`, ...detail === void 0 ? [] : [detail]);
94
+ };
95
+ return {
96
+ file,
97
+ debug: (message, detail) => write("debug", message, detail),
98
+ info: (message, detail) => write("info", message, detail),
99
+ warn: (message, detail) => write("warn", message, detail),
100
+ error: (message, error, detail) => write("error", message, {
101
+ ...detail,
102
+ ...error ? { error: describe(error) } : {}
103
+ })
104
+ };
105
+ }
106
+ /**
107
+ * 由记录数算出窗口尺寸。
108
+ * 高度跟着条数走:一条都没有时只剩一条标题带,不留空行。
109
+ * 全篇只有 PAD 一个间隙值——标题带上下各一份、行间一份、列表底部一份,四处看起来才一样齐。
110
+ * @param {number} count 记录条数
111
+ * @returns {{ count: number, rows: number, rowH: number, width: number, height: number, overflow: number }}
112
+ */
113
+ function layoutOf(count = 0) {
114
+ const total = Math.max(0, Math.floor(count) || 0);
115
+ const rows = Math.min(total, 8);
116
+ return {
117
+ count: total,
118
+ rows,
119
+ rowH: 22,
120
+ width: 240,
121
+ height: rows === 0 ? 32 : 42 + rows * 22,
122
+ overflow: total - rows
123
+ };
124
+ }
125
+ /**
126
+ * 图标预览模式的窗口尺寸。
127
+ * 高度由图标墙加色卡推出,和真实列表一样只有 PAD 一个间隙值。
128
+ * @returns {{ width: number, height: number }}
129
+ */
130
+ function previewOf() {
131
+ return {
132
+ width: 240,
133
+ height: 266
134
+ };
135
+ }
136
+ // finchapp/sessionBoard/src/sessions.js
137
+ /** 记录阶段。idle 只出现在会话刚出现、还没跑出第一条实质事件的时候。 */
138
+ const PHASE = {
139
+ idle: "idle",
140
+ thinking: "thinking",
141
+ answering: "answering",
142
+ tool: "tool",
143
+ waiting: "waiting",
144
+ retry: "retry",
145
+ interrupting: "interrupting",
146
+ interrupted: "interrupted",
147
+ compacting: "compacting",
148
+ done: "done",
149
+ error: "error"
150
+ };
151
+ /**
152
+ * 阶段 → 色调。颜色只回答"要不要紧",具体在做什么交给图标。
153
+ * 蓝:一切只读工作;紫:会改文件;玫红:会动系统;橙:等你动手;黄绿:系统在自己处理;绿红:两种收场。
154
+ */
155
+ const PHASE_TONE = {
156
+ idle: "gray",
157
+ thinking: "blue",
158
+ answering: "blue",
159
+ tool: "blue",
160
+ waiting: "orange",
161
+ retry: "lime",
162
+ interrupting: "lime",
163
+ interrupted: "lime",
164
+ compacting: "blue",
165
+ done: "green",
166
+ error: "red"
167
+ };
168
+ /**
169
+ * 工具色调覆盖:同样在 tool 阶段,写文件和跑命令比读取要紧,单独给色。
170
+ * 没列到的动作一律沿用阶段色(蓝)。
171
+ */
172
+ const ACTION_TONE = {
173
+ write: "purple",
174
+ shell: "rose"
175
+ };
176
+ /** 推进时钟的间隔。只用来淘汰长时间没动静的会话,完成的记录不在这里清。 */
177
+ const SWEEP_MS = 5e3;
178
+ /** 活跃会话多久没有任何事件就当作结束;等待中和已完成的会话不适用这条。 */
179
+ const IDLE_EVICT_MS = 6e4;
180
+ /** 看板最多记住多少会话,防止长跑之后 Map 无限涨。 */
181
+ const MAX_CELLS = 40;
182
+ /**
183
+ * 工具名归类:一个工具名同时给出图标和动作说法。
184
+ * 顺序即优先级——"websearch" 含 web 也含 search,先命中联网,才不会掉进查找。
185
+ * 查找单列一类:grep/glob 这类只翻不改的活,和读文件的图标要分开看。
186
+ */
187
+ const TOOL_RULES = [
188
+ {
189
+ key: "net",
190
+ icon: "globe",
191
+ action: "联网查询",
192
+ words: [
193
+ "web",
194
+ "http",
195
+ "browser",
196
+ "fetch",
197
+ "mcp",
198
+ "url"
199
+ ]
200
+ },
201
+ {
202
+ key: "shell",
203
+ icon: "terminal",
204
+ action: "执行命令",
205
+ words: [
206
+ "bash",
207
+ "shell",
208
+ "exec",
209
+ "kill",
210
+ "command"
211
+ ]
212
+ },
213
+ {
214
+ key: "write",
215
+ icon: "pencil",
216
+ action: "改写文件",
217
+ words: [
218
+ "write",
219
+ "edit",
220
+ "notebook",
221
+ "patch",
222
+ "apply"
223
+ ]
224
+ },
225
+ {
226
+ key: "search",
227
+ icon: "magnifier",
228
+ action: "查找",
229
+ words: [
230
+ "grep",
231
+ "glob",
232
+ "find",
233
+ "search",
234
+ "list"
235
+ ]
236
+ },
237
+ {
238
+ key: "read",
239
+ icon: "file",
240
+ action: "读取文件",
241
+ words: [
242
+ "read",
243
+ "cat",
244
+ "open",
245
+ "file"
246
+ ]
247
+ },
248
+ {
249
+ key: "task",
250
+ icon: "branch",
251
+ action: "调度子任务",
252
+ words: [
253
+ "task",
254
+ "agent",
255
+ "spawn"
256
+ ]
257
+ }
258
+ ];
259
+ /** 没有匹配到任何规则时用空心圆,代表"在想"而不是"在做某种具体动作"。 */
260
+ const DEFAULT_ICON = "dot";
261
+ /** 按工具名找规则;找不到返回 null。 */
262
+ function ruleOf(toolName) {
263
+ if (!toolName) return null;
264
+ const name = String(toolName).toLowerCase();
265
+ return TOOL_RULES.find((rule) => rule.words.some((word) => name.includes(word))) ?? null;
266
+ }
267
+ /**
268
+ * 由工具名推出图标名。
269
+ * @param {string | undefined} toolName 事件里的工具名
270
+ * @returns {string} globe | terminal | pencil | magnifier | file | checklist | dot
271
+ */
272
+ function iconOf(toolName) {
273
+ return ruleOf(toolName)?.icon ?? DEFAULT_ICON;
274
+ }
275
+ /**
276
+ * 由工具名推出动作的中文说法。认不出来的工具直接显示原名,不硬猜。
277
+ * @param {string | undefined} toolName 事件里的工具名
278
+ * @returns {string} 中文动作,或工具原名
279
+ */
280
+ function actionOf(toolName) {
281
+ if (!toolName) return "";
282
+ return ruleOf(toolName)?.action ?? String(toolName);
283
+ }
284
+ /** 阶段的中文状态词。 */
285
+ const PHASE_TEXT = {
286
+ idle: "空闲",
287
+ thinking: "思考中",
288
+ answering: "回答中",
289
+ tool: "执行中",
290
+ waiting: "等待确认",
291
+ retry: "重试中",
292
+ interrupting: "正在打断",
293
+ interrupted: "已打断",
294
+ compacting: "压缩上下文",
295
+ done: "已完成",
296
+ error: "出错"
297
+ };
298
+ /**
299
+ * 阶段自带的图标。工具阶段走 iconOf(工具名),其余阶段各有一个固定图标。
300
+ * 图标回答"在做什么/到哪一步",和颜色(要不要紧)是两套信号。
301
+ */
302
+ const PHASE_ICON = {
303
+ idle: "dot",
304
+ thinking: "hourglass",
305
+ answering: "bubble",
306
+ tool: DEFAULT_ICON,
307
+ waiting: "bang",
308
+ retry: "loop",
309
+ interrupting: "stop",
310
+ interrupted: "slashbox",
311
+ compacting: "collapse",
312
+ done: "check",
313
+ error: "cross"
314
+ };
315
+ /**
316
+ * 取一条记录该用哪个图标。
317
+ * 工具阶段看工具名,其余阶段看阶段本身。
318
+ * @param {{ phase: string, toolName?: string }} cell 记录
319
+ * @returns {string} 图标名
320
+ */
321
+ function iconFor(cell) {
322
+ if (cell.phase === PHASE.tool) return iconOf(cell.toolName);
323
+ return PHASE_ICON[cell.phase] ?? DEFAULT_ICON;
324
+ }
325
+ /**
326
+ * 取一条记录该用哪个色调。
327
+ * 先看工具类别(写文件和跑命令要比只读更醒目),再退回阶段色。
328
+ * @param {{ phase: string, toolName?: string }} cell 记录
329
+ * @returns {string} gray | blue | purple | rose | orange | lime | green | red
330
+ */
331
+ function toneFor(cell) {
332
+ if (cell.phase === PHASE.tool) {
333
+ const tone = ACTION_TONE[ruleOf(cell.toolName)?.key];
334
+ if (tone) return tone;
335
+ }
336
+ return PHASE_TONE[cell.phase] ?? "gray";
337
+ }
338
+ /**
339
+ * 动作文案:只有在动作比阶段名多说了点东西时才给。
340
+ * 思考、回答这类"正在做什么"和阶段名同义,一律留空,交给阶段名去念。
341
+ */
342
+ function hintOf(cell) {
343
+ switch (cell.phase) {
344
+ case PHASE.waiting: return "等你确认";
345
+ case PHASE.retry: return "失败,正在重试";
346
+ case PHASE.tool: return actionOf(cell.toolName);
347
+ default: return "";
348
+ }
349
+ }
350
+ /** 终态集合:这些阶段算收场了,等用户点掉。被打断也算一种收场。 */
351
+ const TERMINAL = /* @__PURE__ */ new Set([
352
+ PHASE.done,
353
+ PHASE.error,
354
+ PHASE.interrupted
355
+ ]);
356
+ /**
357
+ * Finch 的执行阶段 → 我们的阶段。
358
+ * kind 只说明"来了哪条消息",executionPhase 才说明"这一轮整体到哪一步了",
359
+ * 尤其是 interrupting 在 kind 里根本没有对应类型,只能从这里认。
360
+ */
361
+ const PHASE_OF_EXECUTION = {
362
+ starting: PHASE.thinking,
363
+ requesting: PHASE.thinking,
364
+ streaming: PHASE.answering,
365
+ tool_running: PHASE.tool,
366
+ waiting_user: PHASE.waiting,
367
+ retry_wait: PHASE.retry,
368
+ interrupting: PHASE.interrupting,
369
+ completed: PHASE.done,
370
+ interrupted: PHASE.interrupted,
371
+ failed: PHASE.error
372
+ };
373
+ /** 排序权重:等确认的永远排最前,用户最需要先看到它。 */
374
+ const waitingFirst = (cell) => cell.phase === PHASE.waiting ? 0 : 1;
375
+ /**
376
+ * 建一个看板归并器。
377
+ * @param {{ getTitle?: (sessionId: string) => string | undefined }} options getTitle 只对当前会话有值
378
+ */
379
+ function createBoard(options = {}) {
380
+ /** sessionId → 格子 */
381
+ const cells = /* @__PURE__ */ new Map();
382
+ /**
383
+ * 取格子的标题。
384
+ * Finch 只对自家会话开放标题,用户会话多半拿不到;拿不到就返回空串,
385
+ * 让渲染层留白——退到会话短码只会让人误以为那是标题。
386
+ */
387
+ const labelOf = (cell) => options.getTitle?.(cell.sessionId)?.trim() || "";
388
+ /** 按需创建格子;超过上限时淘汰最久没动静的那个。 */
389
+ const ensure = (sessionId, now) => {
390
+ const existing = cells.get(sessionId);
391
+ if (existing) return existing;
392
+ const cell = {
393
+ sessionId,
394
+ phase: PHASE.idle,
395
+ toolName: void 0,
396
+ startedAt: now,
397
+ updatedAt: now,
398
+ terminalAt: 0
399
+ };
400
+ cells.set(sessionId, cell);
401
+ if (cells.size > MAX_CELLS) {
402
+ let oldestId;
403
+ let oldestAt = Infinity;
404
+ for (const [id, item] of cells) if (item.updatedAt < oldestAt) {
405
+ oldestAt = item.updatedAt;
406
+ oldestId = id;
407
+ }
408
+ if (oldestId) cells.delete(oldestId);
409
+ }
410
+ return cell;
411
+ };
412
+ /**
413
+ * 写入一个阶段。
414
+ * @param {string} sessionId 会话 id
415
+ * @param {string} phase 目标阶段
416
+ * @param {number} now 当前时间
417
+ * @param {{ toolName?: string, startedAt?: number, terminal?: boolean }} [patch] 附带字段
418
+ * @returns {boolean} 是否产生了变化
419
+ */
420
+ const set = (sessionId, phase, now, patch = {}) => {
421
+ const cell = ensure(sessionId, now);
422
+ const changed = cell.phase !== phase || cell.toolName !== patch.toolName;
423
+ cell.phase = phase;
424
+ cell.updatedAt = now;
425
+ cell.terminalAt = patch.terminal ? now : 0;
426
+ if (patch.toolName !== void 0) cell.toolName = patch.toolName;
427
+ if (patch.startedAt !== void 0) cell.startedAt = patch.startedAt;
428
+ if (patch.toolName === void 0 && phase !== PHASE.tool) cell.toolName = void 0;
429
+ return changed;
430
+ };
431
+ /** 只更新心跳,不动阶段。未知事件用它兜底。 */
432
+ const touch = (sessionId, now) => {
433
+ const cell = cells.get(sessionId);
434
+ if (!cell) return false;
435
+ cell.updatedAt = now;
436
+ return false;
437
+ };
438
+ /**
439
+ * 用 delta 事件推断阶段:只在格子还没有明确阶段时生效。
440
+ * 冷启动时已经在跑的会话没有历史,靠持续冒出来的 delta 把它们认回来。
441
+ */
442
+ const infer = (sessionId, phase, now) => {
443
+ const cell = cells.get(sessionId);
444
+ if (!cell) return set(sessionId, phase, now);
445
+ cell.updatedAt = now;
446
+ if (cell.phase !== PHASE.idle) return false;
447
+ cell.phase = phase;
448
+ return true;
449
+ };
450
+ /**
451
+ * 吃一个 AgentEvent。
452
+ * @param {object} event Finch 的 AgentEvent
453
+ * @param {number} [now] 注入时间便于测试
454
+ * @returns {boolean} 看板是否需要重画
455
+ */
456
+ const apply = (event, now = Date.now()) => {
457
+ const sessionId = event?.sessionId;
458
+ if (!sessionId) return false;
459
+ if (event.retryState) return set(sessionId, PHASE.retry, now);
460
+ switch (event.kind) {
461
+ case "user": return set(sessionId, PHASE.thinking, now, {
462
+ startedAt: now,
463
+ toolName: void 0
464
+ });
465
+ case "thinking": return set(sessionId, PHASE.thinking, now, { toolName: void 0 });
466
+ case "assistant_text": return set(sessionId, PHASE.answering, now, { toolName: void 0 });
467
+ case "tool_use": return set(sessionId, PHASE.tool, now, { toolName: event.toolName });
468
+ case "tool_progress":
469
+ case "tool_input_delta": return set(sessionId, PHASE.tool, now);
470
+ case "tool_result": return set(sessionId, PHASE.thinking, now, { toolName: void 0 });
471
+ case "permission_request": return set(sessionId, PHASE.waiting, now);
472
+ case "result": return set(sessionId, PHASE.done, now, {
473
+ terminal: true,
474
+ toolName: void 0
475
+ });
476
+ case "error": return set(sessionId, PHASE.error, now, { terminal: true });
477
+ case "interrupted": return set(sessionId, PHASE.interrupted, now, {
478
+ terminal: true,
479
+ toolName: void 0
480
+ });
481
+ case "compact_boundary": return set(sessionId, PHASE.compacting, now);
482
+ case "session_status": return event.runStatus === "waiting" ? set(sessionId, PHASE.waiting, now) : touch(sessionId, now);
483
+ case "thinking_delta": return infer(sessionId, PHASE.thinking, now);
484
+ case "assistant_text_delta": return infer(sessionId, PHASE.answering, now);
485
+ default: {
486
+ const phase = PHASE_OF_EXECUTION[event.executionPhase];
487
+ if (phase === PHASE.interrupting) return set(sessionId, PHASE.interrupting, now);
488
+ return phase ? infer(sessionId, phase, now) : touch(sessionId, now);
489
+ }
490
+ }
491
+ };
492
+ /**
493
+ * 吃一条等待卡(来自 listWaits 或 onInteractionWait 的 pending)。
494
+ * 等待卡是唯一能认回"已经卡在等确认、但长时间没事件"的会话的来源。
495
+ */
496
+ const applyWait = (wait, now = Date.now()) => {
497
+ if (!wait?.sessionId) return false;
498
+ return set(wait.sessionId, PHASE.waiting, now);
499
+ };
500
+ /** 等待卡结算:会话回到思考,事件流随后会给出真实阶段。 */
501
+ const resolveWait = (sessionId, now = Date.now()) => {
502
+ if (!sessionId) return false;
503
+ const cell = cells.get(sessionId);
504
+ if (!cell || cell.phase !== PHASE.waiting) return false;
505
+ return set(sessionId, PHASE.thinking, now);
506
+ };
507
+ /**
508
+ * 推进时钟:淘汰长时间无事件的活跃会话。
509
+ * 已完成的记录不在这里清——它们要一直留着,直到用户自己点掉。
510
+ * @returns {boolean} 是否有记录被移除
511
+ */
512
+ const tick = (now = Date.now()) => {
513
+ let removed = false;
514
+ for (const [sessionId, cell] of cells) {
515
+ if (TERMINAL.has(cell.phase)) continue;
516
+ if (cell.phase === PHASE.waiting) continue;
517
+ if (now - cell.updatedAt >= IDLE_EVICT_MS) {
518
+ cells.delete(sessionId);
519
+ removed = true;
520
+ }
521
+ }
522
+ return removed;
523
+ };
524
+ /**
525
+ * 用户点掉一条记录:跑完的才算看完并移除,还在跑的只是跳过去,记录留着。
526
+ * @param {string} sessionId 会话 id
527
+ * @returns {boolean} 是否真的移除了一条
528
+ */
529
+ const dismiss = (sessionId) => {
530
+ const cell = cells.get(sessionId);
531
+ if (!cell || !TERMINAL.has(cell.phase)) return false;
532
+ return cells.delete(sessionId);
533
+ };
534
+ /**
535
+ * 一次清掉所有跑完的记录,进行中的不动——用户想清的是看完的结果,不是正在跑的任务。
536
+ * @returns {number} 被清掉的条数
537
+ */
538
+ const clearFinished = () => {
539
+ let removed = 0;
540
+ for (const [sessionId, cell] of cells) {
541
+ if (!TERMINAL.has(cell.phase)) continue;
542
+ cells.delete(sessionId);
543
+ removed += 1;
544
+ }
545
+ return removed;
546
+ };
547
+ /** 排序后的记录:等确认的永远在最前,其余按最近活动倒序,位置不会因为新旧任务穿插而乱跳。 */
548
+ const list = () => [...cells.values()].toSorted((a, b) => waitingFirst(a) - waitingFirst(b) || b.updatedAt - a.updatedAt);
549
+ /**
550
+ * 给渲染层的纯数据快照。
551
+ * 传绝对时间戳而不是时长:画布自己有帧循环,让它按当前时间算,时长弧才会走。
552
+ */
553
+ const snapshot = () => ({
554
+ total: cells.size,
555
+ waiting: [...cells.values()].filter((cell) => cell.phase === PHASE.waiting).length,
556
+ cells: list().map((cell) => {
557
+ const status = PHASE_TEXT[cell.phase] ?? cell.phase;
558
+ const hint = hintOf(cell);
559
+ return {
560
+ id: cell.sessionId,
561
+ label: labelOf(cell),
562
+ phase: cell.phase,
563
+ icon: iconFor(cell),
564
+ tone: toneFor(cell),
565
+ status,
566
+ hint,
567
+ text: hint || status,
568
+ startedAt: cell.startedAt,
569
+ endedAt: cell.terminalAt,
570
+ updatedAt: cell.updatedAt
571
+ };
572
+ })
573
+ });
574
+ return {
575
+ apply,
576
+ applyWait,
577
+ resolveWait,
578
+ tick,
579
+ dismiss,
580
+ clearFinished,
581
+ list,
582
+ snapshot,
583
+ labelOf
584
+ };
585
+ }
586
+ // finchapp/sessionBoard/src/index.js
587
+ /** 事件密集时的合并推送间隔:多个 delta 同时到,只画最后一种状态。 */
588
+ const PUSH_GAP_MS = 120;
589
+ /** 自带图标:内置图标库里没有"列表看板",注册一套最小的,避免图标退化成文字。 */
590
+ const ICONS = {
591
+ board: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 6h12\"/><path d=\"M9 12h12\"/><path d=\"M9 18h12\"/><circle cx=\"4.5\" cy=\"6\" r=\"1.5\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"4.5\" cy=\"12\" r=\"1.5\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"4.5\" cy=\"18\" r=\"1.5\" fill=\"currentColor\" stroke=\"none\"/></svg>",
592
+ show: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg>",
593
+ hide: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M10.73 5.08a10.74 10.74 0 0 1 11.2 6.57 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-1.44 2.49\"/><path d=\"M14.08 14.16a3 3 0 1 1-4.24-4.24\"/><path d=\"M17.48 17.5a10.75 10.75 0 0 1-15.42-5.15 1 1 0 0 1 0-.7 10.75 10.75 0 0 1 4.45-5.14\"/><path d=\"m2 2 20 20\"/></svg>",
594
+ clear: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 7h16\"/><path d=\"M10 11v6\"/><path d=\"M14 11v6\"/><path d=\"M6 7l1 13h10l1-13\"/><path d=\"M9 7V4h6v3\"/></svg>"
595
+ };
596
+ function activate(ctx) {
597
+ /** 诊断日志:开发档一次运行一份,正式档按天分片。 */
598
+ const log = createLog(ctx, "board");
599
+ ctx.subscriptions.push(ctx.icons.register("session-board", {
600
+ board: { svg: ICONS.board },
601
+ show: { svg: ICONS.show },
602
+ hide: { svg: ICONS.hide },
603
+ clear: { svg: ICONS.clear }
604
+ }));
605
+ /** 会话 id → 标题。ctx.session.title 只有当前会话、且常是空的,标题得从会话列表里取。 */
606
+ const titles = /* @__PURE__ */ new Map();
607
+ /**
608
+ * 会话 id → 右侧标签。
609
+ * 优先真标题;拿不到就退回 id 前八位——实测 sessions.list() 连小程序自建的会话也返回空 title,
610
+ * 七行全空看不出谁是谁,短码至少能区分。Space 名不兜底:七行都写同一个名字,等于没写。
611
+ */
612
+ const board = createBoard({ getTitle: (sessionId) => titles.get(sessionId) || String(sessionId).slice(0, 8) });
613
+ /** 当前窗口句柄,没打开时为 undefined。 */
614
+ let win;
615
+ /** 合并推送的定时器。 */
616
+ let pushTimer;
617
+ /** 推进时钟的定时器:只负责淘汰长时间没动静的会话。 */
618
+ let sweepTimer;
619
+ /** 用户的显示意图:关了窗口就别被下一次启动重新打开。 */
620
+ let visible = true;
621
+ /** 图标预览:整窗铺一张图标墙,用来确认 14px 下的实际观感。 */
622
+ let preview = false;
623
+ /** 窗口该多大:预览模式看固定尺寸,否则跟条数走。 */
624
+ const boardSize = () => preview ? previewOf() : layoutOf(board.snapshot().cells.length);
625
+ /** 窗口位置,拖动后持久化。 */
626
+ let position;
627
+ /** 窗口自身的事件释放器,关窗时统一收尾。 */
628
+ let windowSubscriptions = [];
629
+ /** 下发给画布的文案。 */
630
+ const strings = () => ({
631
+ total: ctx.i18n.t("board.total"),
632
+ waiting: ctx.i18n.t("board.waiting"),
633
+ preview: ctx.i18n.t("preview.title")
634
+ });
635
+ /**
636
+ * 刷新 id → 标题。
637
+ * Finch 只对"本小程序创建的会话"开放描述,用户自己的会话查不到标题;
638
+ * 能拿到的两处是当前会话快照和用户可见通知,这里先把这两处收进来。
639
+ */
640
+ const syncTitles = async () => {
641
+ const current = ctx.session?.title;
642
+ if (ctx.session?.id && current) titles.set(ctx.session.id, current);
643
+ try {
644
+ const list = await ctx.sessions.list();
645
+ if (!Array.isArray(list)) return;
646
+ let named = 0;
647
+ for (const item of list) if (item?.id && item.title) {
648
+ titles.set(item.id, item.title);
649
+ named += 1;
650
+ }
651
+ log.debug("刷新会话标题", {
652
+ total: list.length,
653
+ named,
654
+ cached: titles.size
655
+ });
656
+ } catch (error) {
657
+ log.info("读不到会话列表,标题退回短码", { reason: reasonOf(error) });
658
+ }
659
+ };
660
+ /**
661
+ * 把当前快照推给画布。
662
+ * 条数变化时先同步窗口高度——没有记录就只剩标题带,不留空窗。
663
+ * 判断依据是系统回来的实际 bounds,不是内存里记的目标高度:
664
+ * 一次 update 被系统吞掉后,缓存的"已经对了"会让窗口永远停在旧尺寸上。
665
+ */
666
+ const push = () => {
667
+ if (!win) return;
668
+ const snapshot = board.snapshot();
669
+ const layout = boardSize();
670
+ const target = `${layout.width}x${layout.height}`;
671
+ if (lastBounds !== target) {
672
+ log.debug("窗口高度跟随条数调整", {
673
+ count: snapshot.cells.length,
674
+ rows: layout.rows,
675
+ target,
676
+ actual: lastBounds || "未读取"
677
+ });
678
+ win.postMessage({
679
+ type: "size",
680
+ count: snapshot.cells.length
681
+ }).catch((error) => {
682
+ log.warn("看板尺寸消息失败", { reason: reasonOf(error) });
683
+ });
684
+ win.update({ bounds: {
685
+ width: layout.width,
686
+ height: layout.height
687
+ } }).then(() => verifyBounds("跟随条数")).catch((error) => {
688
+ log.warn("看板窗口重设尺寸失败", { reason: reasonOf(error) });
689
+ });
690
+ }
691
+ win.postMessage({
692
+ type: "board",
693
+ board: snapshot
694
+ }).catch((error) => {
695
+ log.warn("看板推送失败", { reason: reasonOf(error) });
696
+ });
697
+ };
698
+ /**
699
+ * 回读真实 bounds,把 update 到底有没有生效记下来。
700
+ * 窗口尺寸对不上画面时,这条日志能直接指出是没设下去,还是设了又被系统改回。
701
+ * @param {string} where 触发这次校验的动作
702
+ */
703
+ const verifyBounds = async (where) => {
704
+ if (!win) return;
705
+ try {
706
+ const bounds = await win.getBounds();
707
+ if (!bounds) return;
708
+ lastBounds = `${bounds.width}x${bounds.height}`;
709
+ const layout = boardSize();
710
+ log.debug("尺寸回读", {
711
+ where,
712
+ size: lastBounds,
713
+ expect: `${layout.width}x${layout.height}`,
714
+ ok: Math.abs(bounds.width - layout.width) <= 6 && Math.abs(bounds.height - layout.height) <= 6
715
+ });
716
+ } catch (error) {
717
+ log.warn("回读窗口尺寸失败", {
718
+ where,
719
+ reason: reasonOf(error)
720
+ });
721
+ }
722
+ };
723
+ /** 合并短时间内的多次变化,避免一次工具调用刷出几十帧。 */
724
+ const schedulePush = () => {
725
+ if (!win || pushTimer) return;
726
+ pushTimer = setTimeout(() => {
727
+ pushTimer = void 0;
728
+ push();
729
+ }, PUSH_GAP_MS);
730
+ };
731
+ /** 隔一段时间推进一次时钟:淘汰长时间没动静的会话,顺便刷新标题。 */
732
+ const scheduleSweep = () => {
733
+ clearTimeout(sweepTimer);
734
+ sweepTimer = setTimeout(() => {
735
+ sweepTimer = void 0;
736
+ board.tick();
737
+ syncTitles().then(() => schedulePush());
738
+ }, SWEEP_MS);
739
+ };
740
+ /** 保存窗口位置,节流到 50ms,拖动时不会每秒写几十次存储。 */
741
+ let positionTimer;
742
+ /** 上一次记录尺寸异常的时间,用来把拖动中的连续触发压成每秒一条。 */
743
+ let sizeWarnAt = 0;
744
+ /** 上一次记 bounds 明细时的尺寸,只在尺寸变化或位置大幅跳动时记一条。 */
745
+ let lastBounds = "";
746
+ /** 拖动中是否有一次 update 还在路上,用来防止 Promise 排队。 */
747
+ let movingFrame = false;
748
+ /**
749
+ * 记录窗口尺寸被系统改动。
750
+ * 这个回调在拖动时每帧都来,只比对不修正:正常情况一条日志都不会产生,
751
+ * 一旦出现就说明窗口尺寸确实被外部改过,是排查"越拖越大"的直接证据。
752
+ */
753
+ const watchSize = (bounds) => {
754
+ const layout = boardSize();
755
+ const size = `${bounds.width}x${bounds.height}`;
756
+ if (size !== lastBounds) {
757
+ lastBounds = size;
758
+ log.debug("窗口 bounds 尺寸变化", {
759
+ size,
760
+ pos: `${bounds.x},${bounds.y}`,
761
+ expect: `${layout.width}x${layout.height}`,
762
+ dw: bounds.width - layout.width,
763
+ dh: bounds.height - layout.height
764
+ });
765
+ }
766
+ if (Math.abs(bounds.width - layout.width) <= 6 && Math.abs(bounds.height - layout.height) <= 6) return;
767
+ if (Date.now() - sizeWarnAt < 1e3) return;
768
+ sizeWarnAt = Date.now();
769
+ log.warn("窗口尺寸被改动", {
770
+ width: bounds.width,
771
+ height: bounds.height,
772
+ expect: `${layout.width}x${layout.height}`
773
+ });
774
+ };
775
+ const savePosition = (bounds) => {
776
+ position = {
777
+ x: bounds.x,
778
+ y: bounds.y
779
+ };
780
+ watchSize(bounds);
781
+ clearTimeout(positionTimer);
782
+ positionTimer = setTimeout(() => {
783
+ positionTimer = void 0;
784
+ ctx.storage.set("window.position", position).catch((error) => {
785
+ log.warn("窗口位置保存失败", { reason: reasonOf(error) });
786
+ });
787
+ }, 50);
788
+ };
789
+ /** 打开看板窗口。已经开着就直接返回;失败时把原因弹出来,不留在控制台里。 */
790
+ const open = async () => {
791
+ if (win) return;
792
+ if (!ctx.api.supports("ui.createCanvasWindow")) {
793
+ log.warn("当前 Finch 不支持浮动窗口,看板无法打开");
794
+ await ctx.ui.showToast({
795
+ title: ctx.i18n.t("toast.unsupported"),
796
+ variant: "error",
797
+ position: "TC"
798
+ });
799
+ return;
800
+ }
801
+ const snapshot = board.snapshot();
802
+ const layout = boardSize();
803
+ log.info("创建看板窗口", {
804
+ count: snapshot.cells.length,
805
+ width: layout.width,
806
+ height: layout.height,
807
+ x: position?.x,
808
+ y: position?.y
809
+ });
810
+ try {
811
+ win = ctx.ui.createCanvasWindow({
812
+ entry: "dist/canvas.js",
813
+ width: layout.width,
814
+ height: layout.height,
815
+ x: position?.x,
816
+ y: position?.y,
817
+ alwaysOnTop: true,
818
+ transparent: true,
819
+ clickThrough: false,
820
+ resizable: false,
821
+ frameRate: 30,
822
+ maxDevicePixelRatio: 2,
823
+ initialData: {
824
+ count: snapshot.cells.length,
825
+ preview,
826
+ strings: strings(),
827
+ board: snapshot
828
+ }
829
+ });
830
+ const TIMEOUT = "timeout";
831
+ if (await Promise.race([win.ready.then(() => "ready"), new Promise((resolve) => {
832
+ setTimeout(() => resolve(TIMEOUT), 12e3);
833
+ })]) === TIMEOUT) {
834
+ log.warn("canvas 脚本 12 秒内没有就绪", {
835
+ state: win.state,
836
+ visible: win.visible
837
+ });
838
+ await ctx.ui.showToast({
839
+ title: ctx.i18n.t("toast.notReady"),
840
+ description: `state=${win.state} · visible=${win.visible}`,
841
+ variant: "error",
842
+ position: "TC"
843
+ });
844
+ } else log.debug("canvas 脚本已就绪");
845
+ } catch (error) {
846
+ log.error("看板窗口创建失败", error, { count: snapshot.cells.length });
847
+ win = void 0;
848
+ await ctx.ui.showToast({
849
+ title: ctx.i18n.t("toast.failed"),
850
+ description: reasonOf(error),
851
+ variant: "error",
852
+ position: "TC"
853
+ });
854
+ return;
855
+ }
856
+ windowSubscriptions = [
857
+ win.onDidChangeBounds(savePosition),
858
+ win.onDidReceiveMessage(handleCanvasMessage),
859
+ win.onDidChangeFocus((focused) => {
860
+ log.debug("窗口焦点变化", { focused });
861
+ }),
862
+ win.onDidDispose(() => {
863
+ log.info("看板窗口已销毁");
864
+ win = void 0;
865
+ action.notifyUpdate();
866
+ })
867
+ ];
868
+ lastBounds = "";
869
+ await syncWaits();
870
+ push();
871
+ };
872
+ /** 关掉窗口;释放器逐个兜住异常,窗口已经没了也不该把停用流程搞崩。 */
873
+ const close = () => {
874
+ if (!win) return;
875
+ clearTimeout(pushTimer);
876
+ pushTimer = void 0;
877
+ clearTimeout(sweepTimer);
878
+ sweepTimer = void 0;
879
+ win.dispose();
880
+ win = void 0;
881
+ for (const subscription of windowSubscriptions) try {
882
+ subscription.dispose();
883
+ } catch {}
884
+ windowSubscriptions = [];
885
+ };
886
+ /** 用户层面的开关。 */
887
+ const setVisible = async (next) => {
888
+ visible = next;
889
+ await ctx.storage.set("visible", next);
890
+ if (next) await open();
891
+ else close();
892
+ action.notifyUpdate();
893
+ };
894
+ /** 拉一次全量等待卡,补齐冷启动时看不到的 waiting 会话。没有权限时静默跳过。 */
895
+ const syncWaits = async () => {
896
+ try {
897
+ const waits = await ctx.sessions.listWaits();
898
+ for (const wait of waits) board.applyWait(wait);
899
+ } catch (error) {
900
+ log.info("读不到全局等待列表,等待状态只能靠事件流", { reason: reasonOf(error) });
901
+ }
902
+ };
903
+ /** 画布发来的消息:点击格子跳会话、拖动中移动、拖动结束校正、脚本内部错误回传。 */
904
+ const handleCanvasMessage = (message) => {
905
+ if (message?.type === "move") {
906
+ if (movingFrame) return;
907
+ movingFrame = true;
908
+ const layout = boardSize();
909
+ win?.update({ bounds: {
910
+ x: message.x,
911
+ y: message.y,
912
+ width: layout.width,
913
+ height: layout.height
914
+ } }).catch((error) => {
915
+ log.warn("拖动更新失败", { reason: reasonOf(error) });
916
+ }).finally(() => {
917
+ movingFrame = false;
918
+ });
919
+ return;
920
+ }
921
+ if (message?.type === "dragEnd") {
922
+ const layout = boardSize();
923
+ win?.update({ bounds: {
924
+ x: message.x ?? position?.x,
925
+ y: message.y ?? position?.y,
926
+ width: layout.width,
927
+ height: layout.height
928
+ } }).catch((error) => {
929
+ log.warn("拖动结束校正失败", { reason: reasonOf(error) });
930
+ });
931
+ log.info("拖动结束,已校正窗口", {
932
+ x: message.x,
933
+ y: message.y,
934
+ width: layout.width,
935
+ height: layout.height
936
+ });
937
+ lastBounds = "";
938
+ win?.getBounds().then((bounds) => {
939
+ log.debug("校正后 bounds", {
940
+ size: `${bounds.width}x${bounds.height}`,
941
+ pos: `${bounds.x},${bounds.y}`,
942
+ expect: `${layout.width}x${layout.height}`
943
+ });
944
+ }).catch((error) => {
945
+ log.warn("回读 bounds 失败", { reason: reasonOf(error) });
946
+ });
947
+ return;
948
+ }
949
+ if (message?.type === "log") {
950
+ const text = `[canvas] ${message.message ?? ""}`;
951
+ const detail = message.data === void 0 ? void 0 : { data: message.data };
952
+ if (message.level === "error") log.error(text, void 0, detail);
953
+ else if (message.level === "warn") log.warn(text, detail);
954
+ else log.debug(text, detail);
955
+ return;
956
+ }
957
+ if (message?.type === "hide") {
958
+ setVisible(false);
959
+ return;
960
+ }
961
+ if (message?.type === "clear-finished") {
962
+ const removed = board.clearFinished();
963
+ log.info("清空已完成", {
964
+ removed,
965
+ from: "titlebar"
966
+ });
967
+ push();
968
+ scheduleSweep();
969
+ action.notifyUpdate();
970
+ return;
971
+ }
972
+ if (message?.type !== "focus" || typeof message.sessionId !== "string") return;
973
+ if (board.dismiss(message.sessionId)) {
974
+ push();
975
+ scheduleSweep();
976
+ action.notifyUpdate();
977
+ }
978
+ ctx.navigation.openSession(message.sessionId).catch((error) => {
979
+ log.warn("跳转会话失败", {
980
+ sessionId: message.sessionId,
981
+ reason: reasonOf(error)
982
+ });
983
+ });
984
+ };
985
+ ctx.subscriptions.push(ctx.events.onAgentEvent((event) => {
986
+ if (board.apply(event)) schedulePush();
987
+ if (event.kind === "result" || event.kind === "error" || event.kind === "interrupted") scheduleSweep();
988
+ }));
989
+ ctx.subscriptions.push(ctx.notifications.onDidPost((event) => {
990
+ log.debug("收到用户通知", {
991
+ kind: event?.kind,
992
+ sessionId: event?.sessionId,
993
+ title: event?.title
994
+ });
995
+ if (!event?.sessionId || !event.title) return;
996
+ titles.set(event.sessionId, event.title);
997
+ schedulePush();
998
+ }));
999
+ try {
1000
+ ctx.subscriptions.push(ctx.events.onInteractionWait((event) => {
1001
+ if (event.phase === "waiting") {
1002
+ if (event.sessionTitle) titles.set(event.sessionId, event.sessionTitle);
1003
+ board.applyWait(event.wait);
1004
+ } else board.resolveWait(event.sessionId);
1005
+ schedulePush();
1006
+ }));
1007
+ } catch (error) {
1008
+ log.info("没有全局等待权限,等待状态改用事件流推断", { reason: reasonOf(error) });
1009
+ }
1010
+ ctx.subscriptions.push(ctx.status.onDidChange(() => {
1011
+ if (board.tick()) schedulePush();
1012
+ }));
1013
+ log.info("事件订阅完成");
1014
+ const action = ctx.composerActions.register("session-board", {
1015
+ async getBadge() {
1016
+ return {
1017
+ text: ctx.i18n.t("name"),
1018
+ active: visible
1019
+ };
1020
+ },
1021
+ async getMenu() {
1022
+ const snapshot = board.snapshot();
1023
+ return [
1024
+ {
1025
+ id: "__summary__",
1026
+ label: ctx.i18n.t("menu.summary", { count: snapshot.total }),
1027
+ iconName: "ext:board",
1028
+ disabled: true
1029
+ },
1030
+ {
1031
+ id: "__sep__",
1032
+ label: "",
1033
+ separator: true
1034
+ },
1035
+ {
1036
+ id: "toggle-visible",
1037
+ label: visible ? ctx.i18n.t("menu.hide") : ctx.i18n.t("menu.show"),
1038
+ iconName: visible ? "ext:hide" : "ext:show",
1039
+ checked: visible
1040
+ },
1041
+ {
1042
+ id: "toggle-preview",
1043
+ label: ctx.i18n.t("menu.preview"),
1044
+ iconName: "ext:board",
1045
+ checked: preview
1046
+ },
1047
+ {
1048
+ id: "clear-finished",
1049
+ label: ctx.i18n.t("menu.clearFinished"),
1050
+ iconName: "ext:clear",
1051
+ disabled: !snapshot.cells.some((cell) => cell.endedAt)
1052
+ }
1053
+ ];
1054
+ },
1055
+ async execute(context, itemId) {
1056
+ if (itemId === "toggle-visible") {
1057
+ await setVisible(!visible);
1058
+ return;
1059
+ }
1060
+ if (itemId === "toggle-preview") {
1061
+ preview = !preview;
1062
+ await ctx.storage.set("preview", preview);
1063
+ await win?.postMessage({
1064
+ type: "config",
1065
+ preview,
1066
+ strings: strings()
1067
+ });
1068
+ lastBounds = "";
1069
+ push();
1070
+ log.info("切换图标预览", { preview });
1071
+ action.notifyUpdate();
1072
+ return;
1073
+ }
1074
+ if (itemId === "clear-finished") {
1075
+ const removed = board.clearFinished();
1076
+ log.info("清空已完成", {
1077
+ removed,
1078
+ from: "composer"
1079
+ });
1080
+ push();
1081
+ scheduleSweep();
1082
+ action.notifyUpdate();
1083
+ }
1084
+ }
1085
+ });
1086
+ ctx.subscriptions.push(action);
1087
+ ctx.subscriptions.push(ctx.i18n.onDidChangeLocale(() => {
1088
+ if (win) win.postMessage({
1089
+ type: "config",
1090
+ preview,
1091
+ strings: strings()
1092
+ }).catch(() => {});
1093
+ action.notifyUpdate();
1094
+ }));
1095
+ /** 停用时关窗口,别把孤儿窗口留在桌面上。 */
1096
+ const shutdown = () => {
1097
+ log.info("看板已停用");
1098
+ close();
1099
+ clearTimeout(positionTimer);
1100
+ };
1101
+ ctx.subscriptions.push({ dispose: shutdown });
1102
+ /** 恢复持久化状态,然后按上次的意图决定是否打开。 */
1103
+ (async () => {
1104
+ try {
1105
+ const [savedVisible, savedPreview, savedPosition] = await Promise.all([
1106
+ ctx.storage.get("visible"),
1107
+ ctx.storage.get("preview"),
1108
+ ctx.storage.get("window.position")
1109
+ ]);
1110
+ visible = savedVisible !== false;
1111
+ preview = savedPreview === true;
1112
+ position = savedPosition ?? void 0;
1113
+ log.info("看板已激活", {
1114
+ dev: isDev(),
1115
+ visible,
1116
+ preview,
1117
+ logFile: log.file
1118
+ });
1119
+ await syncTitles();
1120
+ if (visible) await open();
1121
+ action.notifyUpdate();
1122
+ } catch (error) {
1123
+ log.error("看板初始化失败", error);
1124
+ await ctx.ui.showToast({
1125
+ title: ctx.i18n.t("toast.initFailed"),
1126
+ description: reasonOf(error),
1127
+ variant: "error",
1128
+ position: "TC"
1129
+ });
1130
+ }
1131
+ })();
1132
+ }
1133
+ function deactivate() {}
1134
+ export { activate, deactivate };