zcode-pro 0.20.3 → 1.0.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.
@@ -17,6 +17,7 @@
17
17
  // 恢复,探查会打扰(实测首启会出现项目逐个展开又收起的干扰)。
18
18
  // 开关:helper wsRunningSpin(默认 true)。
19
19
  import { rpc } from '../core.js';
20
+ import { projectRows, findProjectRow, rowPath, projectRowSelector } from './ws-rows.js';
20
21
 
21
22
  let installed = false;
22
23
  let enabled = true;
@@ -33,7 +34,80 @@ const CONFIG_POLL_MIN_MS = 5000; // 配置未变化时逐次拉长轮询间隔
33
34
  const CONFIG_POLL_MAX_MS = 60000;
34
35
 
35
36
  const TASK_SEL = '[data-task-item-key]';
36
- const WS_SEL = '[data-testid^="workspace-item-"]';
37
+ // 项目行选择器双结构(3.15.x 旧 testid 行 / 3.16.1 起 section[data-project-id])
38
+ const WS_SEL = () => projectRowSelector();
39
+
40
+ function findWsRow(ws) {
41
+ return findProjectRow(ws);
42
+ }
43
+
44
+ // 折叠且运行中时,项目图标换成与任务行运行中同款的 loader(lucide 圆弧 +
45
+ // 应用自带的 animate-spin 类,旋转速度与任务行完全一致);结束后还原 folder。
46
+ // React 重渲染会重建 folder svg,apply 周期(观察器 + 兜底轮询)会重新换上
47
+ // 与任务行同款 loader:优先克隆页面上官方 loader 图标(形状/粗细/段数自动跟随
48
+ // 应用版本),找不到再用内置的 lucide loader 多段短辐条图形(不是单圆弧)
49
+ const LOADER_FALLBACK_PATHS = [
50
+ 'M12 2v4', 'm16.2 7.8 2.9-2.9', 'M18 12h4', 'm19.8 16.2-2.9 2.9',
51
+ 'M12 18v4', 'm7.8 19.8-2.9-2.9', 'M6 12H2', 'm4.2 7.8 2.9-2.9',
52
+ ];
53
+ function makeLoaderSvg(baseClass) {
54
+ const ns = 'http://www.w3.org/2000/svg';
55
+ const proto = document.querySelector('svg.lucide-loader');
56
+ if (proto) {
57
+ const clone = proto.cloneNode(true);
58
+ const origClass = proto.getAttribute('class') || '';
59
+ // 保留尺寸/颜色类,去掉官方动画(显隐由我们控制,animate 统一自己加)
60
+ const keep = origClass.split(/\s+/).filter((c) => /^(size-|text-)/.test(c)).join(' ');
61
+ clone.setAttribute('class', ((keep || baseClass) + ' animate-spin zcodepro-ws-loader').trim());
62
+ return clone;
63
+ }
64
+ const svg = document.createElementNS(ns, 'svg');
65
+ svg.setAttribute('viewBox', '0 0 24 24');
66
+ svg.setAttribute('fill', 'none');
67
+ svg.setAttribute('stroke', 'currentColor');
68
+ svg.setAttribute('stroke-width', '2');
69
+ svg.setAttribute('stroke-linecap', 'round');
70
+ svg.setAttribute('stroke-linejoin', 'round');
71
+ svg.setAttribute('class', (baseClass + ' animate-spin zcodepro-ws-loader').trim());
72
+ for (const d of LOADER_FALLBACK_PATHS) {
73
+ const path = document.createElementNS(ns, 'path');
74
+ path.setAttribute('d', d);
75
+ svg.append(path);
76
+ }
77
+ return svg;
78
+ }
79
+ function setRunningIcon(holder, on) {
80
+ if (!holder) return;
81
+ if (on) {
82
+ // folder 的隐藏由 CSS 完成(span.zcodepro-ws-running 类稳定,React 重建的
83
+ // folder 也会被规则罩住);这里只负责确保 loader 在场
84
+ if (holder.querySelector(':scope > svg.zcodepro-ws-loader')) return;
85
+ const orig0 = holder.querySelector(':scope > svg:not(.zcodepro-ws-loader)');
86
+ const base = (orig0 ? (orig0.getAttribute('class') || '') : 'size-4').replace(/lucide-[a-z-]*/g, '').trim() || 'size-4';
87
+ holder.append(makeLoaderSvg(base));
88
+ } else {
89
+ holder.querySelectorAll(':scope > svg.zcodepro-ws-loader').forEach((el) => el.remove());
90
+ }
91
+ }
92
+
93
+ // 行首图标容器(旋转图标的挂点):
94
+ // 旧结构:行.firstElementChild 里的 span;新结构:virtual-project-toggle 的首个 span(folder 图标容器)
95
+ function iconHolderOf(row) {
96
+ if (row.hasAttribute && row.hasAttribute('data-project-id')) {
97
+ const toggle = row.querySelector('[data-testid="virtual-project-toggle"]');
98
+ return toggle ? toggle.querySelector(':scope > span') : null;
99
+ }
100
+ return row.firstElementChild && row.firstElementChild.querySelector(':scope > span');
101
+ }
102
+
103
+ // 运行中的行首图标是 loader;空闲行该位置为空或错误/未读圆点。只认行首图标位
104
+ // (旧结构为 span>svg 嵌套,新结构槽 span 直接含 svg,两者都在 firstElementChild 子树内)
105
+ function isRunningRow(el) {
106
+ const scope = el.firstElementChild;
107
+ if (!scope) return false;
108
+ const svg = scope.querySelector('svg[class*="lucide-loader"]');
109
+ return !!svg;
110
+ }
37
111
 
38
112
  const runningByWs = new Map(); // 工作区路径(原样,来自会话键)→ Set(会话键)
39
113
  const peekTimers = new Map(); // 工作区 → 复核定时器
@@ -44,6 +118,78 @@ let wsWithRows = new Set(); // 最近一次扫描时仍有会话行在 DOM
44
118
 
45
119
  // —— 工具 ——
46
120
 
121
+ // 应用内部服务对象(含 zcodeTaskService.releaseWorkspacePreparation):从 React
122
+ // fiber 树上找持有它的组件(file-menu.js 同款取 fiber 的思路),找到后缓存
123
+ let servicesRef = null;
124
+ export function findServices() {
125
+ if (servicesRef) return servicesRef;
126
+ try {
127
+ const rootEl = document.getElementById('root');
128
+ const containerKey = rootEl && Object.keys(rootEl).find((k) => k.startsWith('__reactContainer$'));
129
+ if (!containerKey) return null;
130
+ const seen = new Set();
131
+ let found = null;
132
+ const scanObj = (o) => {
133
+ if (!o || typeof o !== 'object' || seen.has(o) || found) return;
134
+ seen.add(o);
135
+ const ts = o.zcodeTaskService;
136
+ if (ts && typeof ts === 'object' && typeof ts.releaseWorkspacePreparation === 'function') found = o;
137
+ };
138
+ let visited = 0;
139
+ const visit = (f) => {
140
+ if (!f || found || visited > 60000) return;
141
+ visited++;
142
+ for (const holder of [f.memoizedProps, f.memoizedState]) {
143
+ if (!holder || typeof holder !== 'object') continue;
144
+ scanObj(holder);
145
+ for (const v of Object.values(holder)) {
146
+ if (v && typeof v === 'object') scanObj(v);
147
+ }
148
+ }
149
+ visit(f.child);
150
+ visit(f.sibling);
151
+ };
152
+ visit(rootEl[containerKey]);
153
+ if (found) servicesRef = found;
154
+ return found;
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ // 权威空闲判定:官方 zcodeTaskService.listActiveTaskIds 返回该工作区仍处非终态
161
+ // (运行/创建/流式等)的会话,来自 CLI 实时活动集合——比侧栏 DOM 的 loader 可靠
162
+ // (DOM 有“提交消息到 loader 渲染”与页面刷新后折叠项目的盲区,曾致误杀)。
163
+ // workspacePath 的尾斜杠形式必须与订阅时一致(roots 两种都有),两种各查一次。
164
+ // 返回 true=确认空闲、false=仍在运行、null=接口不可用或查询失败(按忙处理)
165
+ export async function authoritativeIdle(ws) {
166
+ const services = findServices();
167
+ const ts = services && services.zcodeTaskService;
168
+ if (!ts || typeof ts.listActiveTaskIds !== 'function') return null;
169
+ try {
170
+ const base = String(ws).replace(/[\\/]+$/, '');
171
+ const a = await ts.listActiveTaskIds({ workspacePath: base });
172
+ const b = await ts.listActiveTaskIds({ workspacePath: base + '/' });
173
+ const ids = [...(Array.isArray(a) ? a : []), ...(Array.isArray(b) ? b : [])].map(String);
174
+ if (!Array.isArray(a) || !Array.isArray(b)) return null;
175
+ if (!ids.length) return true;
176
+ // 终态过滤与 globalAnyActive 同一判据:syncer 卡滞的已完成会话不算运行
177
+ //(DB status 终态且近 5 分钟无更新 = 卡滞;有更新 = 复活在跑)
178
+ const st = await rpc('/task-statuses?ids=' + encodeURIComponent(ids.join(','))).catch(() => null);
179
+ const TERMINAL = new Set(['completed', 'error', 'interrupted']);
180
+ const live = (id) => {
181
+ const rec = st && st.ok && st.available ? st.statuses[id] : undefined;
182
+ if (rec === undefined) return true;
183
+ if (!TERMINAL.has(rec.status)) return true;
184
+ return Date.now() - rec.updatedAt < 300_000;
185
+ };
186
+ return !ids.some(live);
187
+ } catch {
188
+ return null; // 方法不存在(≤3.15.x 的服务对象)或查询失败:一律按忙
189
+ }
190
+ }
191
+
192
+
47
193
  function workspaceOf(key) {
48
194
  // 会话键格式 <workspace_path>:<sess_id>;sess 段固定以 "sess_" 开头,
49
195
  // 按它定位分隔符(Windows 盘符路径里也有冒号,不能按第一个 ':' 切)
@@ -51,22 +197,11 @@ function workspaceOf(key) {
51
197
  return i > 0 ? key.slice(0, i) : key;
52
198
  }
53
199
 
54
- function findWsRow(ws) {
55
- return document.querySelector(`[data-testid="workspace-item-${CSS.escape(ws)}"]`);
56
- }
57
-
58
200
  function wsHead(row) {
201
+ // 旧结构行容器自身带 aria-expanded;新结构在 virtual-project-toggle 按钮上
59
202
  return row.matches('[aria-expanded]') ? row : row.querySelector('[aria-expanded]');
60
203
  }
61
204
 
62
- // 运行中的行首图标是 loader;空闲行该位置为空 span(无 svg)。只认行首图标位
63
- // (span 的直接子级),行内操作按钮等处将来即使出现 loader 也不算
64
- function isRunningRow(el) {
65
- const holder = el.firstElementChild && el.firstElementChild.querySelector(':scope > span');
66
- if (!holder) return false;
67
- return [...holder.children].some((c) => c.tagName === 'svg' && (c.getAttribute('class') || '').includes('lucide-loader'));
68
- }
69
-
70
205
  function domRowsOf(ws) {
71
206
  return [...document.querySelectorAll(TASK_SEL)]
72
207
  .filter((e) => workspaceOf(e.getAttribute('data-task-item-key') || '') === ws);
@@ -168,20 +303,219 @@ function handleRemovals(muts) {
168
303
 
169
304
  function apply() {
170
305
  const seen = new Set();
171
- for (const row of document.querySelectorAll(WS_SEL)) {
172
- const path = row.getAttribute('data-testid').slice('workspace-item-'.length);
306
+ for (const row of document.querySelectorAll(WS_SEL())) {
307
+ const path = rowPath(row);
308
+ if (!path) continue;
173
309
  seen.add(path);
174
310
  const head = wsHead(row);
175
311
  const collapsed = !head || head.getAttribute('aria-expanded') === 'false';
176
- const running = !!(enabled && collapsed && runningByWs.get(path) && runningByWs.get(path).size);
177
- // 类挂在图标的外层 span 上:展开/收起时 React 会重建 svg,span 是稳定节点
178
- const icon = row.firstElementChild && row.firstElementChild.querySelector(':scope > span');
179
- if (icon) icon.classList.toggle('zcodepro-ws-running', running);
312
+ // 折叠态优先权威扫描结果(多目录项目的行匹配在 DOM 侧不可靠);
313
+ // 权威不可用(≤3.15.x)时回退 DOM 运行记录
314
+ const domRunning = !!(runningByWs.get(path) && runningByWs.get(path).size);
315
+ // 路径两侧都去尾分隔符再比(任务键前缀可能带尾斜杠,权威集合不带)
316
+ const normP = (v) => String(v || '').replace(/[\\/]+$/, '');
317
+ const running = enabled && collapsed && (activeProjectPaths.size ? [...activeProjectPaths].some((p2) => normP(p2) === normP(path)) : domRunning);
318
+ // 换同款 loader 图标(类标记仅用于 CSS 隐藏 folder)
319
+ const icon = iconHolderOf(row);
320
+ icon?.classList.toggle('zcodepro-ws-running', running);
321
+ setRunningIcon(icon, running);
180
322
  }
323
+ // 分区头(“任务”“项目”官方分区按钮与“已置顶”标题):分区内有任务在跑时
324
+ // 头部行首挂同款 loader(分区折叠后任务行不在场,检测不到自然熄灭)
325
+ applySectionSpin();
181
326
  // 侧栏里已不存在的项目清掉状态
182
327
  for (const ws of [...runningByWs.keys()]) if (!seen.has(ws)) runningByWs.delete(ws);
183
328
  }
184
329
 
330
+ // 分区头识别:官方分区头按钮(含 chevron svg)与“已置顶”标题(h3)。
331
+ // 归属(project/task)与容器引用只在分区展开时解析一次并缓存(WeakMap):
332
+ // 分区折叠后其容器整体卸载,向上重解析会落到别的分区容器(如展开着的
333
+ // “任务”分区),把“项目”头误判成 task——折叠期间一律用缓存的归属,
334
+ // 不再重解析;React 重建头部元素时缓存失效,展开态会重新解析补上
335
+ const headInfoCache = new WeakMap(); // head → { cont, kind }
336
+ // 文本兜底:分区头按钮被界面重建且其分区正折叠(自有容器已卸载、无法结构
337
+ // 解析)时,按分区名定归属——向外层猜会落到含项目行的容器,把“任务”头
338
+ // 误判成“项目”(实测)。分区名跟随界面语言,zh/en 双语覆盖
339
+ // 官方分区标题(i18n 键 project.projects / project.tasks 的实际文案,随界面
340
+ // 语言):zh 项目/任务、en Projects/Tasks。语言包再加语言时同步此处
341
+ const PROJECT_HEAD_TEXTS = new Set(['项目', 'projects']);
342
+ const TASK_HEAD_TEXTS = new Set(['任务', 'tasks']);
343
+ function kindFromHeadText(head) {
344
+ const text = (head.textContent || '').trim().toLowerCase();
345
+ if (PROJECT_HEAD_TEXTS.has(text)) return 'project';
346
+ if (TASK_HEAD_TEXTS.has(text)) return 'task';
347
+ return null;
348
+ }
349
+ function sectionHeads() {
350
+ const heads = [];
351
+ for (const sv of document.querySelectorAll('svg[data-purpose-section-chevron]')) {
352
+ const btn = sv.closest('button');
353
+ if (btn) heads.push(btn);
354
+ }
355
+ for (const h3 of document.querySelectorAll('h3')) heads.push(h3);
356
+ return heads.map((head) => {
357
+ const cached = headInfoCache.get(head);
358
+ if (cached) return { head, cont: cached.cont, kind: cached.kind };
359
+ // 官方分区头(BUTTON)的归属以分区名为准(“项目”/“任务”是官方固定分区,
360
+ // 文本即真值);结构解析只在文本不认识时兜底。已置顶标题(h3)恒属 task。
361
+ const textKind = head.tagName === 'BUTTON' ? kindFromHeadText(head) : 'task';
362
+ let cont = head.parentElement;
363
+ while (cont && !cont.querySelector('li[data-task-item-key]') && cont !== document.body) cont = cont.parentElement;
364
+ const contOk = cont && cont !== document.body;
365
+ const kind = textKind || (contOk ? (cont.querySelector('section[data-project-id]') ? 'project' : 'task') : null);
366
+ headInfoCache.set(head, { cont: contOk ? cont : null, kind });
367
+ return { head, cont: contOk ? cont : null, kind };
368
+ }).filter((x) => x.head);
369
+ }
370
+
371
+ const SEC_SPIN_CLASS = 'zcodepro-ws-sec-spin';
372
+
373
+ // 全局权威扫描:listActiveTaskIds 遍历侧栏全部项目(/projects-v2 提供路径,
374
+ // 尾斜杠两种形式各查一次),任一非终态会话即“有任务在跑”。10 秒节流缓存;
375
+ // 接口不可用(≤3.15.x)时无缓存,分区头退回 DOM 检测
376
+ let anyActiveCache = { at: 0, value: null, busy: false };
377
+ // 活跃按分区归属:task = “任务”分区(非项目工作区),project = “项目”分区
378
+ const sectionActive = { task: false, project: false };
379
+ // 权威扫描发现的有活跃任务的项目路径集合(全部 root);项目行折叠态判定用
380
+ let activeProjectPaths = new Set();
381
+ // 周期复查:缓存到期就无条件重扫(不看当前值)——只在 true 时扫会有两个缺口:
382
+ // 空闲后新任务启动无人触发(折叠分区头不亮);任务结束但数据库完成状态尚未
383
+ // 写入的过渡窗口里旧值一直被沿用。每 10 秒全量一次,扫描本身是内存级过滤
384
+ setInterval(() => {
385
+ if (!anyActiveCache.busy && Date.now() - anyActiveCache.at >= 10_000) globalAnyActive();
386
+ }, 10_000);
387
+ function globalAnyActive() {
388
+ if (anyActiveCache.value !== null && Date.now() - anyActiveCache.at < 10_000) return anyActiveCache.value;
389
+ if (anyActiveCache.busy) return anyActiveCache.value === true;
390
+ anyActiveCache.busy = true;
391
+ sectionActive.task = false;
392
+ sectionActive.project = false;
393
+ activeProjectPaths = new Set();
394
+ void (async () => {
395
+ let hit = null;
396
+ try {
397
+ const res = await rpc('/projects-v2').catch(() => null);
398
+ const services = findServices();
399
+ const ts = services && services.zcodeTaskService;
400
+ if (res && res.ok && res.available && ts && typeof ts.listActiveTaskIds === 'function') {
401
+ hit = false;
402
+ // 项目路径集合(任一 root):活跃任务命中即属“项目”分区;非项目工作区
403
+ //(draft/独立任务)的活跃属“任务”分区——两个分区头分别提示
404
+ const projPaths = new Set();
405
+ for (const proj of res.projects || []) {
406
+ for (const r of proj.roots || []) {
407
+ const b = String(r.path || '').replace(/[\\/]+$/, '');
408
+ if (b) projPaths.add(b);
409
+ }
410
+ }
411
+ // 非项目工作区清单收紧:任务索引的 workspace_path 含历史残留(旧机器/
412
+ // 已删目录),syncer 可能在其上误报活跃;只认“任务”分区实际会显示的——
413
+ // 即侧栏当前可见的任务行所在工作区(从 DOM 任务行键取,分区展开时可靠)
414
+ const taskSidePaths = new Set();
415
+ for (const el of document.querySelectorAll('[data-task-item-key]')) {
416
+ const sec = el.closest('section[data-project-id]');
417
+ if (sec) continue; // 项目内任务的运行由项目侧覆盖
418
+ const k = el.getAttribute('data-task-item-key') || '';
419
+ const cut = k.lastIndexOf(':');
420
+ if (cut > 1) taskSidePaths.add(k.slice(0, cut).replace(/[\\/]+$/, ''));
421
+ }
422
+ const scanPaths = new Set([...projPaths, ...taskSidePaths]);
423
+ const byKind = { task: new Set(), project: new Set() };
424
+ const byKindByPath = new Map(); // base → 活跃 id 数组
425
+ const rootToProject = new Map(); // root 路径 → 项目名
426
+ const projectPaths = new Map(); // 项目名 → 全部 root 路径
427
+ for (const proj of res.projects || []) {
428
+ const names = [];
429
+ for (const r of proj.roots || []) {
430
+ const b = String(r.path || '').replace(/[\\/]+$/, '');
431
+ if (b) { rootToProject.set(b, proj.name); names.push(b); }
432
+ }
433
+ projectPaths.set(proj.name, names);
434
+ }
435
+ for (const base of scanPaths) {
436
+ const a = await ts.listActiveTaskIds({ workspacePath: base });
437
+ const b = await ts.listActiveTaskIds({ workspacePath: base + '/' });
438
+ const ids = [...(Array.isArray(a) ? a : []), ...(Array.isArray(b) ? b : [])].map(String);
439
+ if (!ids.length) continue;
440
+ byKindByPath.set(base, ids);
441
+ const kind = projPaths.has(base) ? 'project' : 'task';
442
+ for (const id of ids) byKind[kind].add(id);
443
+ }
444
+ const allIds = [...byKind.task, ...byKind.project];
445
+ // 终态过滤:官方 syncer 可能卡滞(已完成仍报活跃,任务行 loader 卡转),
446
+ // 到任务索引核对,已终态的不计
447
+ const st = allIds.length ? await rpc('/task-statuses?ids=' + encodeURIComponent(allIds.join(','))).catch(() => null) : null;
448
+ // 终态判据须加新近度:DB 的 status 是“最后结局”,会话被复活继续用时
449
+ // 不回滚(实测本会话 interrupted 后复跑)——终态但 updated_at 近 5 分钟内
450
+ // 有动静的按真活跃;长期不动的才是 syncer 卡滞误报
451
+ const TERMINAL = new Set(['completed', 'error', 'interrupted']);
452
+ const live = (id) => {
453
+ const rec = st && st.ok && st.available ? st.statuses[id] : undefined;
454
+ if (rec === undefined) return true;
455
+ if (!TERMINAL.has(rec.status)) return true;
456
+ return Date.now() - rec.updatedAt < 300_000;
457
+ };
458
+ // 活跃的项目路径集合(项目行折叠态判定用):按 root→项目聚合,
459
+ // 该项目任一 root 上有活跃任务即记入
460
+ activeProjectPaths = new Set();
461
+ for (const [base, ids] of byKindByPath) {
462
+ if (!projPaths.has(base)) continue;
463
+ if (ids.some(live)) {
464
+ const projName = rootToProject.get(base);
465
+ if (projName) for (const p2 of projectPaths.get(projName) || []) activeProjectPaths.add(p2);
466
+ }
467
+ }
468
+ // 任务侧再加一道 DOM 确认:非项目任务行上必须真有 loader 在转——
469
+ // 侧栏显示的就是真相,syncer 报告的活跃(尤其历史残留工作区)不足为凭
470
+ const taskDomRunning = !!document.querySelector('[data-task-item-key]:not(section[data-project-id] *) svg[class*="lucide-loader"]');
471
+ if (taskDomRunning && [...byKind.task].some(live)) sectionActive.task = true;
472
+ if ([...byKind.project].some(live)) sectionActive.project = true;
473
+ hit = sectionActive.task || sectionActive.project;
474
+ }
475
+ } catch { /* 查询失败保持旧值 */ }
476
+ if (hit !== null) anyActiveCache = { at: Date.now(), value: hit, busy: false };
477
+ else anyActiveCache.busy = false;
478
+ apply();
479
+ })();
480
+ return anyActiveCache.value === true;
481
+ }
482
+ function applySectionSpin() {
483
+ if (!enabled) {
484
+ document.querySelectorAll('.' + SEC_SPIN_CLASS).forEach((el) => el.remove());
485
+ return;
486
+ }
487
+ for (const { head, cont, kind } of sectionHeads()) {
488
+ const running = !!(cont && cont.querySelector('li[data-task-item-key] svg[class*="lucide-loader"]'));
489
+ // “任务”分区与“已置顶”标题:展开时任务行的 loader 直接可见,头部不再重复
490
+ // 提示,仅在分区折叠时挂。“项目”分区头(容器含项目行 section)不受此限——
491
+ // 折叠的单个项目仍由项目行的权威复核提示。
492
+ // 仅分区折叠时显示:展开时任务行的 loader 直接可见,头部不重复。
493
+ // 分区折叠会把内容行全部卸载(DOM 检测失效),改看权威扫描按归属分类的活跃:
494
+ // “项目”分区头看项目侧,“任务”分区与“已置顶”标题看非项目工作区(draft 等)
495
+ let expanded = true;
496
+ if (head.tagName === 'BUTTON') expanded = head.getAttribute('aria-expanded') !== 'false';
497
+ else {
498
+ // 已置顶标题(h3):折叠是藏起任务列表,按列表实际显示判定
499
+ const list = head.nextElementSibling;
500
+ expanded = !list || list.offsetHeight > 0;
501
+ }
502
+ if (!kind) return; // 折叠后解析不到归属且无缓存:跳过(不动现有图标)
503
+ const scoped = kind === 'project' ? sectionActive.project : sectionActive.task;
504
+ // 折叠态一律只信权威扫描:DOM 检测在折叠后不可靠(容器卸载后解析会落到
505
+ // 别的分区,多目录项目的行匹配也套不上),且权威扫描周期覆盖刷新
506
+ const want = !expanded && scoped;
507
+ const existing = head.querySelector(':scope > .' + SEC_SPIN_CLASS);
508
+ if (want && !existing) {
509
+ const span = document.createElement('span');
510
+ span.className = 'flex size-4 shrink-0 items-center justify-center ' + SEC_SPIN_CLASS;
511
+ span.append(makeLoaderSvg('size-4'));
512
+ head.prepend(span);
513
+ } else if (!want && existing) {
514
+ existing.remove();
515
+ }
516
+ }
517
+ }
518
+
185
519
  // —— 无感探查 ——
186
520
 
187
521
  function schedulePeek(ws, delay) {
@@ -215,6 +549,23 @@ function syncPeeks() {
215
549
  let peekChain = Promise.resolve();
216
550
 
217
551
  async function peek(ws, opts) {
552
+ // 折叠期间的周期复核(非 manual)优先走官方权威判定:不展开项目、没有
553
+ // “先展开再缩起”的高度起伏;接口不可用(≤3.15.x)时走原 DOM 无感探查
554
+ if (!opts?.manual) {
555
+ const auth = await authoritativeIdle(ws);
556
+ if (auth !== null) {
557
+ if (auth) {
558
+ runningByWs.delete(ws); // 空闲:熄灭旋转
559
+ } else {
560
+ let set = runningByWs.get(ws);
561
+ if (!set) { set = new Set(); runningByWs.set(ws, set); }
562
+ set.add(ws + ':active'); // 占位键:apply 只看集合大小
563
+ schedulePeek(ws, PEEK_REPEAT_MS);
564
+ }
565
+ apply();
566
+ return true;
567
+ }
568
+ }
218
569
  const run = () => peekInner(ws, opts);
219
570
  const p = peekChain.then(run, run);
220
571
  peekChain = p.catch(() => {});
@@ -252,9 +603,10 @@ async function peekInner(ws, opts) {
252
603
  // 会话行隐藏改走 style 元素(见 installPeekHider):挂载当帧即 display:none,
253
604
  // 与观察器回调时序、列表容器结构均无关
254
605
  const hider = installPeekHider(ws);
606
+ let readOk = false;
255
607
  try {
256
608
  toggleNavSafe(head);
257
- await settleAndRead(ws);
609
+ readOk = await settleAndRead(ws);
258
610
  // 读完后收起;用户在探查期间点了项目行就不再干预
259
611
  const rowNow = findWsRow(ws) || row;
260
612
  const headNow = wsHead(rowNow);
@@ -273,7 +625,8 @@ async function peekInner(ws, opts) {
273
625
  apply();
274
626
  const set = runningByWs.get(ws);
275
627
  if (set && set.size && enabled) schedulePeek(ws, PEEK_REPEAT_MS);
276
- return true;
628
+ // 一个任务行都没读到(展开没生效/行未渲染)= 未验证成,调用方按“忙”处理
629
+ return readOk;
277
630
  }
278
631
 
279
632
  // —— 供 idle-reclaim 复用 ——
@@ -308,11 +661,13 @@ export async function verifyWorkspaceNotRunning(ws) {
308
661
  }
309
662
 
310
663
  // 等该项目的会话行渲染稳定后读取运行状态;行始终没出现视为项目无会话,清除记录
664
+ // resolve(true)=读到行并稳定;resolve(false)=一个行都没读到(未验证成)
311
665
  function settleAndRead(ws) {
312
666
  return new Promise((resolve) => {
313
667
  const t0 = Date.now();
314
668
  let lastCount = -1;
315
669
  let stableAt = 0;
670
+ let zeroRows = false;
316
671
  const timer = setInterval(() => {
317
672
  const rows = domRowsOf(ws);
318
673
  const elapsed = Date.now() - t0;
@@ -321,7 +676,9 @@ function settleAndRead(ws) {
321
676
  if (!settled && elapsed < PEEK_MAX_MS) return;
322
677
  clearInterval(timer);
323
678
  if (lastCount === 0) {
324
- runningByWs.delete(ws);
679
+ // 一个任务行都没读到:多半是展开没生效或行尚未渲染,不能当作“项目无
680
+ // 会话”清掉运行记录(idle-reclaim 复核据此判空闲会误杀运行中会话)
681
+ zeroRows = true;
325
682
  } else {
326
683
  let set = runningByWs.get(ws);
327
684
  if (!set) { set = new Set(); runningByWs.set(ws, set); }
@@ -331,7 +688,7 @@ function settleAndRead(ws) {
331
688
  else set.delete(key);
332
689
  }
333
690
  }
334
- resolve();
691
+ resolve(!zeroRows);
335
692
  }, 80);
336
693
  });
337
694
  }
@@ -398,12 +755,12 @@ function relevant(muts) {
398
755
  // 增删、React 重建行内元素),聊天流式输出的 characterData/class 高频变动
399
756
  // 一概不进回调(实测后台态全页 5 秒仅 5 个变动、全为非 childList)
400
757
  for (const m of muts) {
401
- if (inSel(m.target, TASK_SEL) || inSel(m.target, WS_SEL)) return true;
758
+ if (inSel(m.target, TASK_SEL) || inSel(m.target, WS_SEL())) return true;
402
759
  for (const n of m.addedNodes) {
403
- if (n instanceof Element && (n.matches(TASK_SEL) || n.matches(WS_SEL) || n.querySelector(TASK_SEL) || n.querySelector(WS_SEL))) return true;
760
+ if (n instanceof Element && (n.matches(TASK_SEL) || n.matches(WS_SEL()) || n.querySelector(TASK_SEL) || n.querySelector(WS_SEL()))) return true;
404
761
  }
405
762
  for (const n of m.removedNodes) {
406
- if (n instanceof Element && (n.matches(TASK_SEL) || n.matches(WS_SEL) || n.querySelector(TASK_SEL))) return true;
763
+ if (n instanceof Element && (n.matches(TASK_SEL) || n.matches(WS_SEL()) || n.querySelector(TASK_SEL))) return true;
407
764
  }
408
765
  }
409
766
  return false;
@@ -466,7 +823,7 @@ export function startWsRunningSpin() {
466
823
  // 只认真实点击:探查自己派发的合成点击(isTrusted=false)不算用户操作
467
824
  document.addEventListener('click', (e) => {
468
825
  if (!e.isTrusted) return;
469
- const row = e.target instanceof Element && e.target.closest(WS_SEL);
826
+ const row = e.target instanceof Element && e.target.closest(WS_SEL());
470
827
  if (row) wsTouchAt.set(row, Date.now());
471
828
  }, true);
472
829
  }
@@ -7,11 +7,11 @@ import { handleFileMenu } from './features/file-menu.js';
7
7
  import { startImageMenu } from './features/image-menu.js';
8
8
  import { startSettingsEntry } from './features/settings-entry.js';
9
9
  import { startAliasWatcher } from './features/alias.js';
10
+ import { startAliasMigrationWatcher } from './features/alias-migration.js';
10
11
  import { startTaskOrderWatcher } from './features/task-order.js';
11
12
  import { startSessionSwitch } from './features/session-switch.js';
12
13
  import { startTitleLock } from './features/title-lock.js';
13
14
  import { startTitleGen } from './features/title-gen.js';
14
- import { startPinnedExpandSuppression } from './features/pinned-expand.js';
15
15
  import { startWsRunningSpin } from './features/ws-running.js';
16
16
  import { startIdleReclaim } from './features/idle-reclaim.js';
17
17
  import { startToolbarIcons } from './features/toolbar-icons.js';
@@ -48,12 +48,13 @@ import { ensureStyle } from './ui.js';
48
48
  try { handleFileMenu(content); } catch { /* ignore */ }
49
49
  });
50
50
  try { void startAliasWatcher(); } catch { /* ignore */ }
51
+ // 别名迁移弹窗(3.16.1+ 且仍有存量别名时才动作),内部自带等待与窗口聚焦判断
52
+ try { startAliasMigrationWatcher(); } catch { /* ignore */ }
51
53
  try { startTaskOrderWatcher(); } catch { /* ignore */ }
52
54
  try { startSessionSwitch(); } catch { /* ignore */ }
53
55
  try { startTitleLock(); } catch { /* ignore */ }
54
56
  try { startTitleGen(); } catch { /* ignore */ }
55
57
  try { startImageMenu(); } catch { /* ignore */ }
56
- try { startPinnedExpandSuppression(); } catch { /* ignore */ }
57
58
  try { startWsRunningSpin(); } catch { /* ignore */ }
58
59
  try { startIdleReclaim(); } catch { /* ignore */ }
59
60
  try { startToolbarIcons(); } catch { /* ignore */ }
package/src/inject/ui.js CHANGED
@@ -38,7 +38,7 @@ const contentClass = 'w-full sm:max-w-md rounded-2xl border-none bg-popover/98 p
38
38
  // Esc 只关最上层,避免一次 Esc 把两层一起关掉
39
39
  let dialogStack = [];
40
40
 
41
- export function openDialog({ title, description, onMount, onClose, width = 'sm:max-w-md', overlay = 'dim', draggable = false, posKey = '', dismissOnOutside = true, bodyClass = '' }) {
41
+ export function openDialog({ title, description, onMount, onClose, width = 'sm:max-w-md', overlay = 'dim', draggable = false, posKey = '', dismissOnOutside = true, bodyClass = '', closable = false }) {
42
42
  const L = t();
43
43
  const prevActive = document.activeElement;
44
44
  let cleanupDrag = null;
@@ -72,8 +72,21 @@ export function openDialog({ title, description, onMount, onClose, width = 'sm:m
72
72
  }
73
73
 
74
74
  const titleEl = h('h2', { class: 'text-lg font-semibold leading-none tracking-tight text-foreground' }, title);
75
+ // closable:标题行右侧加 ✕(样式与设置弹窗的自绘关闭钮同款);拖动把手仍是标题文字本身
76
+ const titleRow = closable
77
+ ? h('div', { class: 'flex items-start justify-between gap-2' },
78
+ titleEl,
79
+ h('button', {
80
+ type: 'button',
81
+ class: 'zcodepro-close',
82
+ title: L.close,
83
+ 'aria-label': L.close,
84
+ onMousedown: (e) => { e.stopPropagation(); },
85
+ onClick: () => { close(); },
86
+ }, '✕'))
87
+ : titleEl;
75
88
  content.append(
76
- titleEl,
89
+ titleRow,
77
90
  // 注意不能把 null 直接传给 append:DOM 会把 null 渲染成字面量 "null" 文本
78
91
  ...(description ? [h('p', { class: 'mt-2 text-ui-sm/relaxed text-foreground-subtle' }, description)] : [])
79
92
  );
@@ -345,7 +358,7 @@ export function settingRow(name, desc, checked, onToggle, extra) {
345
358
  },
346
359
  h('div', { class: 'min-w-0 flex-1' },
347
360
  h('div', { class: 'text-ui-sm font-medium text-foreground' }, name),
348
- ...(desc ? [h('div', { class: 'mt-0.5 text-ui-xs/relaxed text-foreground-subtle' }, desc)] : [])),
361
+ h('div', { class: 'mt-0.5 text-ui-xs/relaxed text-foreground-subtle', 'data-zcodepro-row-desc': '' }, desc ?? '')),
349
362
  ...(extraEl ? [extraEl] : []),
350
363
  h('button', {
351
364
  type: 'button',
@@ -623,13 +636,9 @@ export function ensureStyle() {
623
636
  .zcodepro-imgmenu-item:hover {
624
637
  background-color: var(--color-accent, color-mix(in oklab, var(--color-foreground, #888) 10%, transparent));
625
638
  }
626
- /* 折叠项目运行提示(ws-running.js):项目图标旋转。动画类挂在图标外层的
627
- 稳定 span 上,展开/收起时应用重建 svg 也不受影响;时长与应用 loader 一致 */
628
- @keyframes zcodepro-ws-spin { to { transform: rotate(360deg); } }
629
- span.zcodepro-ws-running > svg {
630
- animation: zcodepro-ws-spin 1s linear infinite;
631
- transform-origin: center;
632
- }
639
+ /* 折叠项目运行提示:folder 图标让位给 loader(loader 由脚本插入,
640
+ folder 可能被 React 随时重建,靠类选择器统一隐藏,后代写法兼容包装层) */
641
+ span.zcodepro-ws-running svg:not(.zcodepro-ws-loader) { display: none !important; }
633
642
  /* 无感探查期间盖在项目行上的冻结克隆:固定定位、不响应指针 */
634
643
  .zcodepro-ws-frozen {
635
644
  position: fixed;
@@ -743,7 +752,7 @@ export function ensureStyle() {
743
752
  }
744
753
  .zcodepro-pin-head:hover .zcodepro-pin-chevron svg { opacity: 1; }
745
754
  .zcodepro-pin-collapsed .zcodepro-pin-chevron svg { transform: rotate(-90deg); }
746
- .zcodepro-pin-collapsed > ul { display: none !important; }
755
+ .zcodepro-pin-collapsed > :is(ul, div):has(li[data-task-item-key]) { display: none !important; }
747
756
  /* 折叠后去掉标题自带的底部留白,与下一分区的间隔和其他分区节奏一致 */
748
757
  .zcodepro-pin-collapsed .zcodepro-pin-head { padding-bottom: 0; }
749
758
  `));