wezard 2.3.0 → 2.4.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.
Files changed (68) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.md +5 -5
  3. package/config.example.jsonc +12 -0
  4. package/dist/daemon/approval.js +84 -2
  5. package/dist/daemon/approval.js.map +1 -1
  6. package/dist/daemon/chat-name.js +15 -0
  7. package/dist/daemon/chat-name.js.map +1 -1
  8. package/dist/daemon/config-api.js +265 -70
  9. package/dist/daemon/config-api.js.map +1 -1
  10. package/dist/daemon/danger.js +5 -7
  11. package/dist/daemon/danger.js.map +1 -1
  12. package/dist/daemon/detail.js +10 -2
  13. package/dist/daemon/detail.js.map +1 -1
  14. package/dist/daemon/handoff.js +4 -3
  15. package/dist/daemon/handoff.js.map +1 -1
  16. package/dist/daemon/idle-nudge.js +55 -0
  17. package/dist/daemon/idle-nudge.js.map +1 -0
  18. package/dist/daemon/inbound.js +10 -10
  19. package/dist/daemon/inbound.js.map +1 -1
  20. package/dist/daemon/index.js +872 -214
  21. package/dist/daemon/index.js.map +1 -1
  22. package/dist/daemon/jobs.js +122 -13
  23. package/dist/daemon/jobs.js.map +1 -1
  24. package/dist/daemon/mirror-bridge.js +95 -83
  25. package/dist/daemon/mirror-bridge.js.map +1 -1
  26. package/dist/daemon/model-select.js +3 -0
  27. package/dist/daemon/model-select.js.map +1 -1
  28. package/dist/daemon/notices.js +145 -33
  29. package/dist/daemon/notices.js.map +1 -1
  30. package/dist/daemon/peers.js +88 -74
  31. package/dist/daemon/peers.js.map +1 -1
  32. package/dist/daemon/pending-items.js +171 -0
  33. package/dist/daemon/pending-items.js.map +1 -0
  34. package/dist/daemon/receipts.js +54 -20
  35. package/dist/daemon/receipts.js.map +1 -1
  36. package/dist/daemon/route.js +76 -15
  37. package/dist/daemon/route.js.map +1 -1
  38. package/dist/daemon/spawn-tmux.js +2 -2
  39. package/dist/daemon/spawn-tmux.js.map +1 -1
  40. package/dist/daemon/steward-guard.js +32 -0
  41. package/dist/daemon/steward-guard.js.map +1 -0
  42. package/dist/daemon/wizard.js +104 -61
  43. package/dist/daemon/wizard.js.map +1 -1
  44. package/dist/mcp/server.js +75 -25
  45. package/dist/mcp/server.js.map +1 -1
  46. package/dist/shared/chat-http.js +8 -8
  47. package/dist/shared/chat-http.js.map +1 -1
  48. package/dist/shared/config-meta.js +91 -0
  49. package/dist/shared/config-meta.js.map +1 -0
  50. package/dist/shared/config-writer.js +5 -6
  51. package/dist/shared/config-writer.js.map +1 -1
  52. package/dist/shared/config.js +269 -294
  53. package/dist/shared/config.js.map +1 -1
  54. package/dist/shared/detail-render.js +121 -32
  55. package/dist/shared/detail-render.js.map +1 -1
  56. package/dist/shared/detail-store.js.map +1 -1
  57. package/dist/shared/keepalive.js +5 -5
  58. package/dist/shared/keepalive.js.map +1 -1
  59. package/dist/shared/role-render.js +49 -11
  60. package/dist/shared/role-render.js.map +1 -1
  61. package/dist/shared/role-view.js +18 -4
  62. package/dist/shared/role-view.js.map +1 -1
  63. package/dist/shared/world.js +82 -13
  64. package/dist/shared/world.js.map +1 -1
  65. package/hooks/pre-tool-use.sh +5 -6
  66. package/package.json +1 -1
  67. package/web/chat.css +57 -37
  68. package/web/chat.js +454 -227
package/web/chat.js CHANGED
@@ -19,10 +19,12 @@
19
19
  var WITH = qs.get('with') || '';
20
20
  // '' = 未指定 (服务端按全部时间, 回包里认领成 'all'); 'all' = 全部时间; 其他 = 那一段的 sid。
21
21
  var SESSION = qs.get('session') || '';
22
- // 消息区的视角 (谁算「自己」靠右、描述行以谁为准): 只在看移交详情时才有 —— 移交人 (派活那一方);
23
- // 平时不存在, 消息区就用 ROLE。侧栏与名片永远是 ROLE 的。
24
- var EYE = qs.get('eye') || '';
25
- var eyeOf = function () { return EYE || ROLE; };
22
+ // 页面上两个独立的视角: ROLE 是页面视角 (名片、侧栏、标题、关系图中心), 只有点头像 / 名字及其装饰 (⇄ 等) 换它;
23
+ // VIEWPOINT 是 chat detail 的视角 (谁算「自己」靠右、描述行以谁为准), 点卡片 / 会话项的其余地方与详情区里的行设它 ——
24
+ // 关系图卡片 = 被点的那个 role, .far 子项 = 那个 role, 移交行 = 移交人 (派活那一方),
25
+ // 新生 / 被收的提示行 = 那个 wizard; 会话与成对的子项 = 页面视角自己。'' = 与页面视角相同。老链接的 `eye=` 照读。
26
+ var VIEWPOINT = qs.get('viewpoint') || qs.get('eye') || '';
27
+ var viewpointOf = function () { return VIEWPOINT || ROLE; };
26
28
  var TICK_MS = 3000;
27
29
 
28
30
  // at / recvAt: 服务端快照时刻与本地收到时刻。所有"现在几点"的判断都换算到
@@ -30,13 +32,14 @@
30
32
  var R = { at: 0, recvAt: 0, role: null, sessions: [], convs: [], relations: false, schedules: 0, plan: null, charter: null, stats: null, winStats: null };
31
33
  // frags: 当前窗口的原始片段 (id → 片段)。片段不带方向, 换视角时拿它就地重包左右。
32
34
  var S = { es: null, pinned: true, gen: 0, frags: {} };
33
- // 关系/日程两栏共用的世界快照。treeAll = 关系树展开全部; treeFor = 已把谁滚进过视野。
35
+ // 关系/日程两栏共用的世界快照。treeFor = 已把谁滚进过视野。
34
36
  var W = {
35
37
  at: 0, nodes: [], edges: [], chats: [], jobs: [], schedules: [],
36
- degraded: false, loaded: false, treeAll: false, treeFor: '', glance: {}, gKeys: '',
38
+ degraded: false, loaded: false, treeFor: '', glance: {}, gKeys: '',
37
39
  };
38
40
  var VIEW = 'msgs';
39
41
  // 侧栏是会话列表还是关系图 —— 与右边的 VIEW 无关: 关系图下右边照样是选中的那段对话。
42
+ // URL 带了 side 以它为准; 没带 (新链接) 用本地记下的, 见 WORLD_KEY。
40
43
  var WORLD = qs.get('side') === 'world';
41
44
  var $ = function (s) { return document.querySelector(s); };
42
45
  var app = $('#app'), thread = $('#thread'), inner = $('#thread-in'), convsEl = $('#convs');
@@ -97,7 +100,7 @@
97
100
  if (CONV) p.conv = CONV;
98
101
  if (WITH) p['with'] = WITH;
99
102
  if (SESSION) p.session = SESSION;
100
- if (EYE) p.eye = EYE;
103
+ if (VIEWPOINT) p.viewpoint = VIEWPOINT;
101
104
  if (WORLD) p.side = 'world';
102
105
  Object.keys(extra || {}).forEach(function (k) { p[k] = extra[k]; });
103
106
  return p;
@@ -184,7 +187,10 @@
184
187
  scope.querySelectorAll('.bubble, .tg-job').forEach(function (b) {
185
188
  var src = b.querySelector('script.md-src'), body = b.querySelector('.md-body');
186
189
  if (src && body && body.dataset.rendered !== '1') {
190
+ // 「发给谁」先挂在空 .md-body 里; 填进 markdown 后把它塞进第一段, 才能和正文第一行同行。
191
+ var to = body.querySelector(':scope > .to-in');
187
192
  body.innerHTML = md.render(src.textContent || '');
193
+ if (to) { var p = body.firstElementChild; (p && p.tagName === 'P' ? p : body).prepend(to, ' '); }
188
194
  body.dataset.rendered = '1';
189
195
  }
190
196
  });
@@ -317,7 +323,7 @@
317
323
  r.classList.toggle('raw');
318
324
  });
319
325
  inner.addEventListener('click', function (e) {
320
- var c = e.target.closest && e.target.closest('.mchip.job[data-job]');
326
+ var c = e.target.closest && e.target.closest('.mchip.job[data-job], .tg-job .jc[data-job], .jcrumb [data-job]');
321
327
  if (c) openJob(c.getAttribute('data-job'));
322
328
  });
323
329
  // 回执 chip / 移交行的活号: 跳到派活那句 (同一个活号的入消息)。先在眼前这个窗口里找;
@@ -334,22 +340,36 @@
334
340
  var conv = ch ? 'c:' + ch : ROLE === from ? 'p:' + to : ROLE === to ? 'p:' + from : 'a:' + from + '|' + to;
335
341
  jumpMsg({ conv: conv, id: c.getAttribute('data-gid'), ts: Number(c.getAttribute('data-gts')) });
336
342
  });
337
- // 移交行整行: 侧栏选中移交双方之间的那一项 (群里的一对 / 私聊; 视角不是两端之一就开
338
- // 「这两方之间」的往来), 有派活那句就落在这一行自己的那句上。不换视角 (节点随 reconcile 换, 所以在根上委托)。
343
+ // 移交行整行: 侧栏选中移交双方之间的那一项 (群里的一对 / 私聊; 页面视角不是两端之一就开
344
+ // 「这两方之间」的往来), 有派活那句就落在这一行自己的那句上。页面视角不动, 详情区的 viewpoint
345
+ // 换成移交人 (派活那一方)。节点随 reconcile 换, 所以在根上委托。
339
346
  // 行尾的 chevron 只管展开原文, 走 <summary> 自己的开合。
340
347
  inner.addEventListener('click', function (e) {
341
- var b = e.target.closest && e.target.closest('.handoff .ho-line[data-hto]');
348
+ var b = e.target.closest && e.target.closest('.handoff .ho-line[data-hto], .to-in[data-hto]');
342
349
  if (!b || e.target.closest('.ho-chev')) return;
343
350
  e.preventDefault(); // 行是 <summary>: 不顺带展开原文
351
+ e.stopPropagation();
344
352
  var from = b.getAttribute('data-hfrom'), to = b.getAttribute('data-hto'), ch = b.getAttribute('data-hch');
345
353
  var peer = ROLE === from ? to : ROLE === to ? from : '';
346
354
  var conv = !peer ? 'a:' + from + '|' + to : ch ? 'c:' + ch : 'p:' + peer;
347
355
  var w = peer && ch ? peer : '';
348
- var id = b.getAttribute('data-gid');
349
356
  markBack(function () { return VIEW === 'msgs' && CONV === conv && WITH === w; });
350
- // 消息区换成移交人 (派活那一方) 的视角 (侧栏、名片不动; 视角就是它时不带)。
351
- if (id) return jumpMsg({ conv: conv, with: w, id: id, ts: Number(b.getAttribute('data-gts')), eye: from });
352
- selectConv(conv, w, undefined, from);
357
+ jumpMsg({ conv: conv, with: w, id: b.getAttribute('data-gid'), ts: Number(b.getAttribute('data-gts')), viewpoint: from });
358
+ });
359
+
360
+ // 其余 wezard 调用的提示行: 新生 / 被收的 wizard 开它的全部往来、viewpoint 站到它身上 (页面视角不动),
361
+ // 工单行进工单页, 定时行开日程 —— 日程只有页面视角自己的, 别人的定时行不跳 (换页面视角只走头像 / 名字)。
362
+ // 同移交行: 行是 <summary> 就不顺带展开原文, 行尾 chevron 照旧开合。
363
+ inner.addEventListener('click', function (e) {
364
+ var b = e.target.closest && e.target.closest('.handoff .ho-line[data-hrole], .handoff .ho-line[data-job], .handoff .ho-line[data-hplan]');
365
+ if (!b || e.target.closest('.ho-chev')) return;
366
+ e.preventDefault();
367
+ var role = b.getAttribute('data-hrole'), job = b.getAttribute('data-job'), plan = b.getAttribute('data-hplan');
368
+ if (job) return openJob(job);
369
+ if (plan) return plan === ROLE && setView('plan');
370
+ var conv = 'a:' + role;
371
+ markBack(function () { return VIEW === 'msgs' && CONV === conv; });
372
+ selectConv(conv, '', undefined, role);
353
373
  });
354
374
 
355
375
  // ── 左栏: 当前 role 的名片 + 会话列表 ──
@@ -359,6 +379,36 @@
359
379
  var jobTag = function (j) {
360
380
  return j ? '<span class="jtag' + (j.status === 'open' ? ' open' : '') + '">' + (j.status === 'open' ? j.done + '/' + j.total : '收工') + '</span>' : '';
361
381
  };
382
+ // 阶段 (daemon 现算后推来): 缺省 (老快照) = 不标。
383
+ var STAGE = { plan: '规划', build: '实施', review: '评审', clarify: '澄清', stalled: '卡住', shelved: '搁置' };
384
+ var stageText = function (j) {
385
+ if (!j) return '';
386
+ if (j.status === 'closed') return j.kind === 'req' ? (j.end === 'cancel' ? '取消' : '归档') : '';
387
+ return j.stage === 'deliver' ? (j.kind === 'req' ? '等验收' : '待收工') : STAGE[j.stage] || '';
388
+ };
389
+ var stageTag = function (j) {
390
+ var t = stageText(j);
391
+ return t ? '<span class="jstage' + (j.stage === 'deliver' || j.stage === 'clarify' || j.stage === 'stalled' ? ' wait' : '') + '">' + t + '</span>' : '';
392
+ };
393
+ // 祖先链 (根在前): 按服务端的账走 parent; 环 / 缺失就停。
394
+ var jobAncestors = function (id) {
395
+ var idx = R.jobIndex || {}, out = [], seen = {}, cur = idx[id] && idx[id].parent;
396
+ while (cur && idx[cur] && !seen[cur]) { seen[cur] = 1; out.unshift(cur); cur = idx[cur].parent; }
397
+ return out;
398
+ };
399
+ var jobCrumbs = function (id) {
400
+ var up = jobAncestors(id);
401
+ return up.length ? '<span class="jcrumb">' + up.map(function (a) {
402
+ return '<button data-job="' + esc(a) + '" title="' + esc(a) + '">' + esc((R.jobIndex[a] || {}).title || a) + '</button>';
403
+ }).join('<i>›</i>') + '<i>›</i></span>' : '';
404
+ };
405
+ // 工单列表的树序: 父在前、子缩进跟在后; 父不在这一段里的当根。
406
+ var jobTreeOrder = function (list) {
407
+ var ids = {}; list.forEach(function (c) { ids[c.job.id] = 1; });
408
+ var kids = function (id) { return list.filter(function (c) { return c.job.parent === id; }); };
409
+ var walk = function (c, d) { return [{ c: c, d: d }].concat(kids(c.job.id).reduce(function (a, k) { return a.concat(walk(k, d + 1)); }, [])); };
410
+ return list.filter(function (c) { return !c.job.parent || !ids[c.job.parent]; }).reduce(function (a, c) { return a.concat(walk(c, 0)); }, []);
411
+ };
362
412
  // 工单里有人卡住 (停在审批卡 / 反问待答 / 报错停了) 时标出几份卡着 —— 要人或开单者动手的。
363
413
  var stuckTag = function (j) {
364
414
  var n = j && j.status === 'open' ? (R.inflight || []).filter(function (x) {
@@ -376,7 +426,7 @@
376
426
  return {
377
427
  key: key, kind: 'job', name: j ? j.title : id, label: '📋', base: j ? j.base : '', subs: [], heard: [],
378
428
  lastTs: j ? j.closedAt || j.openedAt : 0, preview: '',
379
- job: j && { id: id, owner: j.owner, status: j.status, done: j.done, total: j.total },
429
+ job: j && { id: id, owner: j.owner, status: j.status, done: j.done, total: j.total, parent: j.parent, kind: j.kind, stage: j.stage },
380
430
  };
381
431
  };
382
432
  /** 一个工单号 → 它的一行: 视角有份的取会话项 (带预览), 其余按账现造。 */
@@ -449,11 +499,6 @@
449
499
  ms.map(function (m) { return '<i>' + esc(m.label || roleLabel(m.role)) + '</i>'; }).join('') + '</span>';
450
500
  };
451
501
 
452
- // ends = [[id, 已知头像]]; 没带头像的按 role 查。
453
- var pairOf = function (ends) {
454
- return '<span class="pair">' + ends.map(function (e) { return goSpan('av', e[0], esc(e[1] || roleLabel(e[0]))); }).join('') + '</span>';
455
- };
456
-
457
502
  var line = function (title, ts, pv, lamp, unread) {
458
503
  return '<span class="b"><span class="l1"><span class="t">' + title + '</span>' + (lamp || '') +
459
504
  '<span class="ts">' + esc(fmtAgo(ts)) + '</span></span>' +
@@ -465,21 +510,17 @@
465
510
  // 服务端给每个会话一份 heard = [说完的时刻, 发话方, 收信方], 以及我在群里 / 每一对里
466
511
  // 最后开口的时刻 (mine)。说给我的那句属于「我与发话方」那一对。一句话的已读水位 = 它所在那一处
467
512
  // 「我最后开口」与「我看过」的较晚者: 属于某一对的看那一对 (群里说过话不等于读过别的线程), 不属于任何一对的看群。
468
- // 已读记录 `视角|会话|对端` 的键一直没变: 子项曾短暂是「对端在群里的全部记录」, 那时看过的覆盖了这一对, 照旧算数。
469
- // 「看过」记在本地 (刷新 / 换视角都还在), 按视角分账 —— 同一个群换个 role 看, 未读是另一回事。
470
- var READ_KEY = 'wezard.role.read';
471
- var READ = (function () {
472
- try { var v = JSON.parse(localStorage.getItem(READ_KEY) || '{}'); if (v && v.at) return v; } catch (e) { }
473
- return { at: {} };
474
- })();
475
- var saveRead = function () { try { localStorage.setItem(READ_KEY, JSON.stringify(READ)); } catch (e) { } };
513
+ // 基线是「打开页面这一刻」(BASE, 服务端时钟, 首次拿到快照时定): 此前已有的话不算未读, 之后新说完的才累加,
514
+ // 刷新即重置 —— 所以「看过」只活在内存里, 不落本地。按视角分账 (同一个群换个 role 看, 未读是另一回事)。
515
+ var BASE = 0;
516
+ var READ = { at: {} };
476
517
  var readKey = function (key, withRole) { return ROLE + '|' + key + '|' + (withRole || ''); };
477
518
  var seenAt = function (key, withRole) { return READ.at[readKey(key, withRole)] || 0; };
478
519
  var unreadOf = function (c, withRole) {
479
520
  var g = seenAt(c.key);
480
521
  var mark = {};
481
- (c.subs || []).forEach(function (s) { mark[s.role] = Math.max(g, s.mine || 0, seenAt(c.key, s.role)); });
482
- var groupMark = Math.max(g, c.mine || 0);
522
+ (c.subs || []).forEach(function (s) { mark[s.role] = Math.max(g, BASE, s.mine || 0, seenAt(c.key, s.role)); });
523
+ var groupMark = Math.max(g, BASE, c.mine || 0);
483
524
  return (c.heard || []).filter(function (h) {
484
525
  var p = h[2] === ROLE ? h[1] : '';
485
526
  if (withRole && p !== withRole) return false;
@@ -510,30 +551,61 @@
510
551
  var h = c.heard && c.heard[c.heard.length - 1];
511
552
  var hit = readPairs(c).filter(function (w) { return unreadOf(c, w || (c.kind !== 'group' ? c.peer : '')); });
512
553
  hit.forEach(function (w) { READ.at[readKey(c.key, w)] = Math.max(R.at, h ? h[0] : 0); });
513
- if (hit.length) saveRead();
514
554
  };
515
555
 
516
- // 展开态按会话各记各的: 点开一个不收起别的, 轮询重画也不动它。
517
- // OPEN_AT = 已经替它自动展开过的那个 CONV —— 选中新会话时展开一次, 之后折不折由人说了算。
518
- var OPEN = {}, OPEN_AT = '';
519
- // 子项默认只露前 SUB_FOLD 个, 其余收在一条展开/折叠条后面; 展开态同样按会话各记各的。
520
- var SUB_FOLD = 5, MORE = {};
521
- var reveal = function () {
522
- if (CONV && CONV !== OPEN_AT) {
523
- // 某 role 在一个群里的全部记录挂在那个群下: 展开的是那个群。
524
- var t = convOf(CONV);
525
- OPEN[t && t.chat ? 'c:' + t.chat : CONV] = true; OPEN_AT = CONV;
526
- }
556
+ // 跨会话通用的个人偏好 (看全部、列表 / 关系、侧栏群节点的展开、session 选择): 记在本地, 刷新后还在。
557
+ // 取不到 / 写不进 (隐私窗口、禁存) 就按默认值, 页面照常。
558
+ var pref = function (key, def) {
559
+ try { var v = localStorage.getItem(key); return v === null ? def : JSON.parse(v); } catch (e) { return def; }
560
+ };
561
+ var setPref = function (key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch (e) { } };
562
+ var prefObj = function (key) { var v = pref(key, {}); return v && typeof v === 'object' ? v : {}; };
563
+ var WORLD_KEY = 'wezard.role.world';
564
+ if (!qs.has('side')) WORLD = pref(WORLD_KEY, false) === true;
565
+ // 早先也记过 ping 与日程卡片的展开 (只活在内存里了)、「往来 / 相关 / 全部」开关 (已去掉, 固定看全部 / 相关) —— 旧 key 顺手清掉。
566
+ try { ['wezard.role.pingOpen', 'wezard.role.planOpen', 'wezard.role.showAll', 'wezard.role.subScope'].forEach(function (k) { localStorage.removeItem(k); }); } catch (e) { }
567
+
568
+ // 展开态按群各记各的 (键 = 群节点的会话键): 点开一个不收起别的, 轮询重画也不动它。
569
+ // OPEN_AT = 已经替它自动展开过的那个 CONV —— 选中新会话时展开一次, 之后折不折由人说了算; null = 刚载入。
570
+ // 默认只展开选中的那个群; 本地只记偏离默认的: 没选中却展开着的 = true, 选中却被收起的 = false
571
+ // (载入时自动展开要让着它)。回到默认的那一项写的时候就删, 不越存越多。
572
+ var OPEN_KEY = 'wezard.role.groupOpen';
573
+ var OPEN = prefObj(OPEN_KEY), OPEN_AT = null;
574
+ // 子项默认只露前 SUB_FOLD 个, 其余收在一条展开/折叠条后面; 展开态同样按群记, 只留展开着的。
575
+ var MORE_KEY = 'wezard.role.groupMore';
576
+ var SUB_FOLD = 5, MORE = prefObj(MORE_KEY);
577
+ // 某 role 在一个群里的全部记录挂在那个群下: CONV 落在哪个群节点上。
578
+ var groupOf = function (key) { var t = key && convOf(key); return t && t.chat ? 'c:' + t.chat : key; };
579
+ var saveOpen = function () {
580
+ var sel = groupOf(CONV);
581
+ setPref(OPEN_KEY, Object.keys(OPEN).reduce(function (m, k) {
582
+ if (!!OPEN[k] !== (k === sel)) m[k] = !!OPEN[k];
583
+ return m;
584
+ }, {}));
585
+ };
586
+ var setMore = function (key, v) {
587
+ if (v) MORE[key] = true; else delete MORE[key];
588
+ setPref(MORE_KEY, MORE);
527
589
  };
528
- // 点侧栏项: 没选中它 → 选中 (会话顺带展开); 已选中 → 不再选一遍, 右边的正文一个字不动 ——
529
- // 会话只折叠/展开, 子项什么都不做。
530
- var clickItem = function (key, withRole) {
531
- if (key !== CONV || withRole !== WITH || VIEW !== 'msgs' || EYE) return selectConv(key, withRole);
532
- // 窄屏退回列表后再点它是要回去读, 不是要折叠。
590
+ var reveal = function () {
591
+ if (!CONV || CONV === OPEN_AT) return;
592
+ var g = groupOf(CONV);
593
+ if (!(OPEN_AT === null && OPEN[g] === false)) OPEN[g] = true;
594
+ OPEN_AT = CONV;
595
+ saveOpen();
596
+ };
597
+ // 点侧栏项 / 关系图卡片 (两边同一份): 没选中它 → 选中 (会话顺带展开); 已选中再点 → 取消选中, 右边回到
598
+ // 页面视角的全部对话 (`a:<role>`, 即「看全部」那一份), 页面视角不动; 取消的是会话本身就顺带收起它。
599
+ // viewpoint: 站在这一项的主人身上 —— 会话与成对的子项是页面视角自己的, .far 子项与卡片是那个 role 的。
600
+ var clickItem = function (key, withRole, viewpoint) {
601
+ var vp = viewpoint && viewpoint !== ROLE ? viewpoint : '';
602
+ if (key !== CONV || (withRole || '') !== WITH || VIEW !== 'msgs' || VIEWPOINT !== vp) return selectConv(key, withRole, undefined, viewpoint);
603
+ // 窄屏退回列表后再点它是要回去读, 不是要取消。
533
604
  if (!app.classList.contains('reading')) return app.classList.add('reading');
534
- if (withRole) return;
535
- OPEN[key] = !OPEN[key];
536
- renderConvs();
605
+ var fold = !withRole && OPEN[key];
606
+ if (fold) OPEN[key] = false;
607
+ selectConv('a:' + ROLE, '', undefined, '');
608
+ if (fold) saveOpen(); // 选中项换过之后再记: 没选中的收着是默认态, 不占一条
537
609
  };
538
610
 
539
611
  // ── 会话项 / 子项 / 关系图卡片共用的三样: 一行的数据 (glance)、一行的画法 (roleRow)、排序 (recentFirst) ──
@@ -545,9 +617,11 @@
545
617
  // 同层按最近活动排, 有新话就上浮。
546
618
  var recentFirst = function (a, b) { return b.lastTs - a.lastTs; };
547
619
  // 一个 role 的一行: 头像 · 名字 (+tail) + 忙闲灯 · 时刻 / 最近一句 + 未读。
620
+ // 名字悬停时紧跟一枚 ⇄ —— 同名片上名字悬停露出 ⧉: 点名字做的事, 先亮给人看。
621
+ var SWAP = '<i class="sw" aria-hidden="true">⇄</i>';
548
622
  var roleRow = function (id, name, label, g, status, tail, badge) {
549
623
  return goSpan('av', id, esc(label)) +
550
- line(nm(id, name, true, badge) + (tail || ''), g.lastTs, g.preview, stTag(status, true), g.unread);
624
+ line(nm(id, name, true, SWAP + (badge || '')) + (tail || ''), g.lastTs, g.preview, stTag(status, true), g.unread);
551
625
  };
552
626
  // 名字旁的「+N」: 视图外还有 N 个 —— 同一个徽标, 数什么由所在的视图注入 (会话列表数会话, 关系图数关系)。
553
627
  var moreTag = function (n, tip) {
@@ -564,7 +638,7 @@
564
638
  var dmKey = function (peer) { return 'p:' + pairKey(peer); };
565
639
  var convRow = function (c) {
566
640
  if (c.kind === 'wizard') return roleRow(c.peer, c.name, c.label, glance(c), c.status, jobMark(c.jobs), otherChats(c.peer, dmKey(c.peer)));
567
- if (c.kind === 'job') return '<span class="av">' + esc(c.label) + '</span>' + line('<span class="nm chat">' + esc(c.name) + '</span>' + jobTag(c.job) + stuckTag(c.job), c.lastTs, c.preview, '', unreadOf(c));
641
+ if (c.kind === 'job') return '<span class="av">' + esc(c.label) + '</span>' + line('<span class="nm chat">' + esc(c.name) + '</span>' + stageTag(c.job) + jobTag(c.job) + stuckTag(c.job), c.lastTs, c.preview, '', unreadOf(c));
568
642
  return avatarOf(c) + line('<span class="nm chat">' + esc(c.name) + '</span>' + jobMark(c.jobs), c.lastTs, c.preview, stTag(c.status, true), unreadOf(c));
569
643
  };
570
644
  var subRow = function (c, s) { return roleRow(s.role, s.name, s.label, glance(c, s), s.status, jobMark(s.jobs), otherChats(s.role, c.key + '|' + pairKey(s.role))); };
@@ -580,13 +654,11 @@
580
654
 
581
655
  var convItem = function (c) {
582
656
  var on = c.key === CONV;
583
- // 成对: 只列与我有往来的 —— 在群里但没和我说过话的人, 点进去也是空的。
584
- // 「chat 内全部」再把其余有记录的接在成对的后面 (.far), 各自按最近排。
585
- var pairs = c.subs.filter(function (s) { return s.count; }).sort(recentFirst);
586
- var fars = SCOPE === 'all'
587
- ? c.subs.filter(function (s) { return !s.count && s.whole && s.whole.count; })
588
- .sort(function (a, b) { return recentFirst(a.whole, b.whole); })
589
- : [];
657
+ // 与我有往来的成对子项在前; chat 内其余有记录的接在后面 (.far), 各自按最近排。
658
+ // 时间范围按子项自己的时刻裁 (成对 = 我与它的往来, .far = 它在群里的全部) —— 群的时刻是群里任何人的动静, 放不得行。
659
+ var pairs = c.subs.filter(function (s) { return s.count && inWin(s.lastTs); }).sort(recentFirst);
660
+ var fars = c.subs.filter(function (s) { return !s.count && s.whole && s.whole.count && inWin(s.whole.lastTs); })
661
+ .sort(function (a, b) { return recentFirst(a.whole, b.whole); });
590
662
  var talked = OPEN[c.key] ? pairs.concat(fars) : [];
591
663
  var hidden = talked.length - SUB_FOLD;
592
664
  var bar = hidden > 0
@@ -596,7 +668,7 @@
596
668
  var sub = function (s) {
597
669
  if (!s.count) {
598
670
  var k = farKey(c, s);
599
- return '<button class="si far' + (CONV === k ? ' on' : '') + '" data-conv="' + esc(k) + '" data-with="" ' +
671
+ return '<button class="si far' + (CONV === k ? ' on' : '') + '" data-conv="' + esc(k) + '" data-with="" data-vp="' + esc(s.role) + '" ' +
600
672
  'title="' + esc(nameOf(s.role) + ' 在这里的 ' + s.whole.count + ' 条记录 (与我无往来)') + '">' + farRow(s) + '</button>';
601
673
  }
602
674
  var sel = on && s.role === WITH;
@@ -609,6 +681,16 @@
609
681
  ? '<div class="subs">' + talked.slice(0, SUB_FOLD).map(sub).join('') + bar + rest + '</div>'
610
682
  : '';
611
683
  var sel = on && !WITH;
684
+
685
+ // 群聊项特殊处理: 把 chevron 挪到行内部, 在时间左边
686
+ if (c.kind === 'group' && c.subs && c.subs.length) {
687
+ // 不能是 <button>: 嵌在外层 .ci 按钮里会被解析器提前闭合外层, chevron 就掉到卡片外
688
+ var chevron = '<span role="button" class="ci-chevron' + (OPEN[c.key] ? ' on' : '') + '" data-toggle="' + esc(c.key) + '" title="' + (OPEN[c.key] ? '折叠群' : '展开群') + '"></span>';
689
+ var content = avatarOf(c) + '<span class="b"><span class="l1"><span class="t"><span class="nm chat">' + esc(c.name) + '</span>' + jobMark(c.jobs) + '</span>' + chevron + '<span class="ts">' + esc(fmtAgo(c.lastTs)) + '</span></span>' +
690
+ '<span class="l2"><span class="pv">' + esc(c.preview) + '</span>' + (unreadOf(c) ? '<b class="ub">' + (unreadOf(c) > 99 ? '99+' : unreadOf(c)) + '</b>' : '') + '</span></span>';
691
+ return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' + content + '</button>' + subs;
692
+ }
693
+
612
694
  return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' +
613
695
  convRow(c) + '</button>' + subs;
614
696
  };
@@ -625,8 +707,8 @@
625
707
  // 老外壳 (daemon 未重启) 里还留着名片下那条入口栏 —— 日程已并进名片, 它整条不要了。
626
708
  if ($('#rb-acts')) $('#rb-acts').remove();
627
709
  var renderConvs = function () {
628
- var groups = R.convs.filter(function (c) { return c.kind === 'group'; }).sort(recentFirst);
629
- var dms = R.convs.filter(function (c) { return c.kind === 'wizard'; }).sort(recentFirst);
710
+ var groups = R.convs.filter(function (c) { return c.kind === 'group' && inWin(c.lastTs); }).sort(recentFirst);
711
+ var dms = R.convs.filter(function (c) { return c.kind === 'wizard' && inWin(c.lastTs); }).sort(recentFirst);
630
712
  R.convs.forEach(markRead);
631
713
  if (WORLD) return renderWorld();
632
714
  // 空着的那一栏不画 —— 对谁都一样: 人没有私聊只是这条规则的一个特例。
@@ -635,22 +717,23 @@
635
717
  };
636
718
  // 没变就不碰 DOM: 心跳每 3s 来一次, 重建会把列表的滚动与焦点蹭掉。
637
719
  // 开关挂在第一个标题行的右端; 一个会话都没有也留一行标题给它。
638
- var html = (sec('群聊', groups) + sec('私聊', dms) || '<h2>会话<span>0</span></h2>').replace('</h2>', scopeToggle() + worldToggle() + '</h2>');
720
+ var html = (sec('群聊', groups) + sec('私聊', dms) || '<h2>会话<span>0</span></h2>').replace('</h2>', winSelect() + worldToggle() + '</h2>');
639
721
  if (convsEl._html === html) return;
640
722
  convsEl._html = html; convsEl.innerHTML = html;
641
723
  convsEl.querySelectorAll('[data-conv]').forEach(function (b) {
642
- var key = b.getAttribute('data-conv'), w = b.getAttribute('data-with') || '';
643
- b.onclick = function () { clickItem(key, w); };
724
+ var key = b.getAttribute('data-conv'), w = b.getAttribute('data-with') || '', vp = b.getAttribute('data-vp') || '';
725
+ b.onclick = function () { clickItem(key, w, vp); };
644
726
  });
645
727
  convsEl.querySelectorAll('[data-more]').forEach(function (b) {
646
728
  var key = b.getAttribute('data-more');
647
- b.onclick = function () { MORE[key] = !MORE[key]; renderConvs(); };
729
+ b.onclick = function () { setMore(key, !MORE[key]); renderConvs(); };
648
730
  });
649
- bindGo(convsEl);
650
- bindWorldToggle(convsEl);
651
- convsEl.querySelectorAll('[data-scope]').forEach(function (b) {
652
- b.onclick = function () { setScope(b.getAttribute('data-scope')); };
731
+ convsEl.querySelectorAll('[data-toggle]').forEach(function (b) {
732
+ var key = b.getAttribute('data-toggle');
733
+ b.onclick = function (e) { e.stopPropagation(); OPEN[key] = !OPEN[key]; saveOpen(); renderConvs(); };
653
734
  });
735
+ bindGo(convsEl);
736
+ bindWorldToggle(convsEl); bindWin(convsEl);
654
737
  };
655
738
 
656
739
  var shortCwd = function (p) {
@@ -659,70 +742,110 @@
659
742
  };
660
743
  // session 只靠两样认: 何时开始、跑了几轮。最新那段直接叫「最新」。
661
744
  var latestSess = function () { return R.sessions[R.sessions.length - 1]; };
745
+ // 选中的 session 按「role + 会话 (+ 群里的对端)」记; 选回默认 (全部时间, 不切) 就删那一项。
746
+ // 只在 URL 没指定 session 时恢复 —— 落地窗口要等第一份摘要才定, SESS_RESTORE 守到那时。
747
+ var SESS_KEY = 'wezard.role.session';
748
+ var SESS_RESTORE = !qs.get('session');
749
+ var sessKey = function () { return ROLE + '\n' + CONV + (WITH ? '\n' + WITH : ''); };
750
+ var savedSess = function () { return prefObj(SESS_KEY)[sessKey()] || ''; };
751
+ var saveSess = function (sid) {
752
+ var m = prefObj(SESS_KEY);
753
+ if (sid && sid !== 'all') m[sessKey()] = sid; else delete m[sessKey()];
754
+ setPref(SESS_KEY, m);
755
+ };
756
+ // 第一份摘要到手: 记下的那段还在列表里就换过去 (换了要重取一次), 不在了就回默认并删掉。
757
+ var restoreSess = function () {
758
+ if (!SESS_RESTORE) return false;
759
+ SESS_RESTORE = false;
760
+ var want = savedSess();
761
+ if (!want || want === SESSION) return false;
762
+ if (R.sessions.some(function (x) { return x.sessionId === want; })) { SESSION = want; return true; }
763
+ saveSess('');
764
+ return false;
765
+ };
662
766
  var sessWhen = function (s) { return s.start ? fmtClock(s.start) : '(无时刻)'; };
663
767
 
664
- // session 切换: name 右边一个小号文字触发器 + 浮层列表。
665
- // renderRole 每次刷新都重画, 展开态记在 SESS_OPEN 里才不会被轮询收起。
666
- var SESS_OPEN = false;
667
- var sessRow = function (s, on) {
668
- var last = s === latestSess();
669
- return '<button class="sp-it' + (on ? ' on' : '') + '" role="option" aria-selected="' + on + '" data-s="' + esc(s.sessionId || '') + '" ' +
670
- 'title="' + esc(s.sessionId || '') + '">' +
671
- '<span class="id">' + (last ? '最新 · ' : '') + esc(sessWhen(s)) + '</span>' +
672
- '<span class="n">' + s.turns + ' 轮</span></button>';
673
- };
674
- var sessPicker = function () {
675
- var cur = R.sessions.filter(function (s) { return s.sessionId === SESSION; })[0];
676
- var all = '<button class="sp-it all' + (cur ? '' : ' on') + '" role="option" aria-selected="' + !cur + '" data-s="all">' +
677
- '<span class="id">全部</span><span class="n">' + R.sessions.length + ' 段</span></button>';
678
- var v = !cur ? '全部' : cur === latestSess() ? '最新' : sessWhen(cur);
679
- return '<span class="sp' + (SESS_OPEN ? ' open' : '') + '">' +
680
- '<button class="sp-btn" aria-haspopup="listbox" aria-expanded="' + SESS_OPEN + '" title="切换 session">' +
681
- esc(v) + '<span class="car" aria-hidden="true"></span></button>' +
682
- '<span class="sp-list" role="listbox">' + all +
683
- R.sessions.slice().reverse().map(function (s) { return sessRow(s, s.sessionId === SESSION); }).join('') +
684
- '</span></span>';
685
- };
686
- var setSessOpen = function (open) {
687
- SESS_OPEN = open;
688
- var sp = $('#rb-sp .sp');
689
- if (!sp) return;
690
- sp.classList.toggle('open', open);
691
- sp.querySelector('.sp-btn').setAttribute('aria-expanded', open);
768
+ // 自定义下拉: 小号文字触发器 + 浮层列表 (.sp)。名片里的 session 与侧栏标题行的时间范围共用这一份。
769
+ // 宿主每次刷新都重画, 展开的是哪一个记在 DD_OPEN 里 (按 id) 才不会被轮询收起。
770
+ // items: { v, html, on, cls?, tip? }; `ic` = 触发器左侧的图标; `end` = 浮层以触发器右沿为锚向左下展开 (贴着侧栏右沿的那种)。
771
+ var DD_OPEN = '';
772
+ var dropdown = function (id, o) {
773
+ var open = DD_OPEN === id;
774
+ return '<span class="sp' + (o.end ? ' end' : '') + (open ? ' open' : '') + '" data-dd="' + id + '">' +
775
+ (o.ic ? '<span class="sp-ic" aria-hidden="true">' + o.ic + '</span>' : '') +
776
+ '<button class="sp-btn" aria-haspopup="listbox" aria-expanded="' + open + '" title="' + esc(o.title) + '">' +
777
+ esc(o.label) + '<span class="car" aria-hidden="true"></span></button>' +
778
+ '<span class="sp-list" role="listbox">' + o.items.map(function (it) {
779
+ return '<button class="sp-it' + (it.cls ? ' ' + it.cls : '') + (it.on ? ' on' : '') + '" role="option" aria-selected="' + !!it.on + '" data-v="' + esc(it.v) + '"' +
780
+ (it.tip ? ' title="' + esc(it.tip) + '"' : '') + '>' + it.html + '</button>';
781
+ }).join('') + '</span></span>';
782
+ };
783
+ var setDdOpen = function (id) {
784
+ DD_OPEN = id;
785
+ document.querySelectorAll('.sp[data-dd]').forEach(function (sp) {
786
+ var on = sp.getAttribute('data-dd') === id;
787
+ sp.classList.toggle('open', on);
788
+ sp.querySelector('.sp-btn').setAttribute('aria-expanded', on);
789
+ });
692
790
  };
693
- var bindSessPicker = function () {
694
- var sp = $('#rb-sp .sp');
791
+ var bindDropdown = function (root, id, pick) {
792
+ var sp = root && root.querySelector('.sp[data-dd="' + id + '"]');
695
793
  if (!sp) return;
696
- sp.querySelector('.sp-btn').onclick = function () { setSessOpen(!SESS_OPEN); };
794
+ sp.querySelector('.sp-btn').onclick = function () { setDdOpen(DD_OPEN === id ? '' : id); };
697
795
  sp.querySelectorAll('.sp-it').forEach(function (it) {
698
- it.onclick = function () { setSessOpen(false); SESSION = it.getAttribute('data-s'); refresh(); };
796
+ it.onclick = function () { setDdOpen(''); pick(it.getAttribute('data-v')); };
699
797
  });
700
798
  };
701
799
  document.addEventListener('click', function (e) {
702
- if (SESS_OPEN && !e.target.closest('#rb-sp .sp')) setSessOpen(false);
800
+ if (DD_OPEN && !e.target.closest('.sp[data-dd="' + DD_OPEN + '"]')) setDdOpen('');
703
801
  });
704
802
  document.addEventListener('keydown', function (e) {
705
- if (SESS_OPEN && e.key === 'Escape') setSessOpen(false);
803
+ if (DD_OPEN && e.key === 'Escape') setDdOpen('');
706
804
  });
707
805
 
806
+ // session 切换: name 右边那个下拉。
807
+ var sessItem = function (s, on) {
808
+ var last = s === latestSess();
809
+ return { v: s.sessionId || '', on: on, tip: s.sessionId || '',
810
+ html: '<span class="id">' + (last ? '最新 · ' : '') + esc(sessWhen(s)) + '</span><span class="n">' + s.turns + ' 轮</span>' };
811
+ };
812
+ var sessPicker = function () {
813
+ var cur = R.sessions.filter(function (s) { return s.sessionId === SESSION; })[0];
814
+ return dropdown('sess', {
815
+ title: '切换 session',
816
+ label: !cur ? '全部' : cur === latestSess() ? '最新' : sessWhen(cur),
817
+ items: [{ v: 'all', on: !cur, cls: 'all', html: '<span class="id">全部</span><span class="n">' + R.sessions.length + ' 段</span>' }]
818
+ .concat(R.sessions.slice().reverse().map(function (s) { return sessItem(s, s.sessionId === SESSION); })),
819
+ });
820
+ };
821
+ var bindSessPicker = function () {
822
+ bindDropdown($('#rb-sp'), 'sess', function (v) { SESSION = v; saveSess(SESSION); refresh(); });
823
+ };
824
+
708
825
  // 会话列表 ↔ 关系图: 侧栏第一行右上角同一个小开关, 标的是「点了去哪」。
709
826
  var LIST_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M3 4h10M3 8h10M3 12h10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
710
827
  var TREE_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M4 3v10M4 6h5M4 11h5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="11" cy="6" r="1.6" fill="currentColor"/><circle cx="11" cy="11" r="1.6" fill="currentColor"/></svg>';
711
- // 群下子项的范围: 「仅与我有往来」(成对) / 「chat 内全部」(再列出群里与我无往来的 role)。
712
- // 只是个人的看法偏好 —— 记在本地, 取不到就按成对。
713
- var SCOPE_KEY = 'wezard.role.subScope';
714
- var SCOPE = (function () { try { return localStorage.getItem(SCOPE_KEY) === 'all' ? 'all' : 'pair'; } catch (e) { return 'pair'; } })();
715
- var setScope = function (v) {
716
- if (v === SCOPE) return;
717
- SCOPE = v;
718
- try { localStorage.setItem(SCOPE_KEY, v); } catch (e) { }
719
- renderConvs();
720
- };
721
- // 单个文字按钮: 字是当前范围, 点一下切到另一种 (样式与旁边的「关系图」按钮同一个 .vt)。
722
- var scopeToggle = function () {
723
- var pair = SCOPE === 'pair';
724
- return '<button class="vt" data-scope="' + (pair ? 'all' : 'pair') + '" title="' +
725
- (pair ? '群下只列与我有往来的 role (点击改为列出 chat 内全部)' : '群下列出 chat 内全部 role (点击改为只列有往来的)') + '">' + (pair ? '往来' : '全部') + '</button>';
828
+ // 时间范围: 只看最近 N 分钟内有动静的 (0 = 全部)。会话列表与关系图共用这一个值。
829
+ var WIN_KEY = 'wezard.role.winMin';
830
+ var WINS = [[0, '全部'], [30, '半小时'], [60, '1小时'], [120, '2小时'], [1440, '1天']];
831
+ var WIN = pref(WIN_KEY, 0);
832
+ if (!WINS.some(function (w) { return w[0] === WIN; })) WIN = 0;
833
+ var winCut = function () { return WIN ? srvNow() - WIN * 60000 : 0; };
834
+ /** 一个时刻落没落在时间范围里 —— 侧栏的会话 / 子项与关系图的边 (rangeOf) 都按 winCut 这一刀。 */
835
+ var inWin = function (ts) { return !WIN || ts >= winCut(); };
836
+ var winSelect = function () {
837
+ return dropdown('win', {
838
+ ic: '⏱', end: true, title: '时间范围: 只看这段时间内有动静的',
839
+ label: (WINS.filter(function (w) { return w[0] === WIN; })[0] || WINS[0])[1],
840
+ items: WINS.map(function (w) { return { v: String(w[0]), on: w[0] === WIN, html: '<span class="id">' + w[1] + '</span>' }; }),
841
+ });
842
+ };
843
+ var bindWin = function (scope) {
844
+ bindDropdown(scope, 'win', function (v) {
845
+ WIN = +v; W.treeFor = ''; setPref(WIN_KEY, WIN);
846
+ convsEl._html = ''; convsEl._tree = '';
847
+ renderConvs();
848
+ });
726
849
  };
727
850
  var worldToggle = function () {
728
851
  return '<button class="vt" data-vt title="' + (WORLD ? '换回会话列表' : '换成关系') + '">' + (WORLD ? LIST_SVG + '列表' : TREE_SVG + '关系') + '</button>';
@@ -952,14 +1075,14 @@
952
1075
  // ── 右栏头: 这个群聊 / 私聊是什么 (关系 / 日程视图时是视图名) ──
953
1076
  // 跳走之前在看的那段会话 —— 顶栏的「‹ 回到 …」回这里。on: 在哪些窗口上露出这个按钮;
954
1077
  // at: 跳走时点的那条消息, 回来落在它上面。进工单页 (只在从非工单的窗口进来时记) 与
955
- // 移交行点对方都记它, 同一个按钮。
1078
+ // 移交行 / 提示行点过去都记它, 同一个按钮。点过去会换详情区的 viewpoint, 所以连它一起记 (页面视角不动)。
956
1079
  var BACK = null;
957
1080
  var inJobs = function () { return VIEW === 'jobs' || CONV.indexOf('j:') === 0; };
958
- // 回来 = 原样恢复那一页: 会话、视角、滚动位置 (按视窗顶上那一行的偏移记, 不闪不吸)。吸在底部就不记锚点。
1081
+ // 回来 = 原样恢复那一页: 会话、viewpoint、滚动位置 (按视窗顶上那一行的偏移记, 不闪不吸)。吸在底部就不记锚点。
959
1082
  var markBack = function (on) {
960
1083
  var r = !atBottom() && firstShown();
961
1084
  var at = r && { id: r.getAttribute('data-id'), off: r.getBoundingClientRect().top - thread.getBoundingClientRect().top };
962
- BACK = { conv: CONV, with: WITH, session: SESSION, eye: EYE, at: at || null, on: on };
1085
+ BACK = { conv: CONV, with: WITH, session: SESSION, viewpoint: VIEWPOINT, at: at || null, on: on };
963
1086
  };
964
1087
  var restoreAt = function (at) {
965
1088
  if (!at) return undefined;
@@ -992,6 +1115,16 @@
992
1115
  e.stopPropagation(); e.preventDefault();
993
1116
  openJobs(m.getAttribute('data-jobs').split(' ').filter(Boolean));
994
1117
  }, true);
1118
+ // 关系图的 clone 徽标: 开 origin 的全部往来、落到它分叉出这个分身的那一处; viewpoint 站到 origin, 页面视角不动。
1119
+ // 同 📋: 长在卡片按钮里, 捕获阶段截下来。
1120
+ document.addEventListener('click', function (e) {
1121
+ var m = e.target.closest && e.target.closest('.ek.clone[data-cpoint]');
1122
+ if (!m) return;
1123
+ e.stopPropagation(); e.preventDefault();
1124
+ var pt = JSON.parse(m.getAttribute('data-cpoint')), conv = 'a:' + pt.origin;
1125
+ markBack(function () { return VIEW === 'msgs' && CONV === conv; });
1126
+ jumpMsg({ conv: conv, id: pt.id, use: pt.use, ts: pt.ts, viewpoint: pt.origin });
1127
+ }, true);
995
1128
  var backBtn = function () {
996
1129
  var c = BACK && BACK.on() && convOf(BACK.conv);
997
1130
  return c ? '<button class="back jb" id="ch-jback" title="' + esc('回到 ' + (BACK.with ? nameOf(BACK.with) : c.name)) + '" aria-label="返回">‹</button>' : '';
@@ -1001,8 +1134,10 @@
1001
1134
  var h = backBtn();
1002
1135
  if (h) $('#ch-who').insertAdjacentHTML('afterbegin', h);
1003
1136
  var b = $('#ch-jback');
1004
- if (b) b.onclick = function () { var x = BACK; BACK = null; SESSION = x.session; selectConv(x.conv, x.with, restoreAt(x.at), x.eye); };
1137
+ if (b) b.onclick = function () { var x = BACK; BACK = null; SESSION = x.session; selectConv(x.conv, x.with, restoreAt(x.at), x.viewpoint); };
1005
1138
  };
1139
+ // 一对一的标题: 与关系图卡片窗口 (convOf 的 a:) 同一种写法。
1140
+ var pairTitle = function (peer) { return nameOf(ROLE) + ' 与 ' + nameOf(peer) + ' 的对话'; };
1006
1141
  var renderHead = function () {
1007
1142
  var who = $('#ch-who'), acts = $('#ch-acts');
1008
1143
  if (VIEW === 'jobs') {
@@ -1022,29 +1157,29 @@
1022
1157
  }
1023
1158
  var c = convOf(CONV);
1024
1159
  if (!c) { who.innerHTML = ''; acts.innerHTML = ''; return; }
1025
- // 某 role 在一个群里的全部记录 (「chat 内全部」下与我无往来的子项): 顶栏与成对子项同一种写法 —— 只写它,
1026
- // 「在 <群>」点回整个群。
1160
+ // 某 role 在一个群里的全部记录 (「chat 内全部」下与我无往来的子项): 顶栏只写它, 副标题「在 <群> 的全部对话」——
1161
+ // 与成对子项 (「我 ⇄ 它」) 一眼分得开。群名点回整个群。
1027
1162
  if (c.kind === 'all' && c.chat) {
1028
1163
  who.innerHTML = '<span class="t">' + nm(c.who, '', true) + '</span>' +
1029
- '<span class="sub">在 <button type="button" class="up" id="ch-up" title="' + esc('看 ' + chatTitle(c.chat) + ' 的全部记录') + '">' + esc(chatTitle(c.chat)) + '</button></span>';
1164
+ '<span class="sub">在 <button type="button" class="up" id="ch-up" title="' + esc('看 ' + chatTitle(c.chat) + ' 的全部记录') + '">' + esc(chatTitle(c.chat)) + '</button> 的全部对话</span>';
1030
1165
  acts.innerHTML = '';
1031
1166
  bindGo(who);
1032
1167
  $('#ch-up').onclick = function () { selectConv('c:' + c.chat, ''); };
1033
1168
  return;
1034
1169
  }
1035
1170
  if (c.kind === 'all') {
1036
- who.innerHTML = pairOf([[c.who]]) + '<span class="t" title="' + esc(c.name) + '"></span>';
1171
+ who.innerHTML = '<span class="t" title="' + esc(c.name) + '"></span>';
1037
1172
  acts.innerHTML = '';
1038
1173
  bindBack();
1039
1174
  fitTalk(who.querySelector('.t'), c);
1040
1175
  bindGo(who);
1041
1176
  return;
1042
1177
  }
1043
- // 群里选中一个子项: 窗口是「我与它」在这个群里的往来, 顶栏只写对方 (视角自己左栏名片上已有), 不画头像;
1178
+ // 群里选中一个子项: 窗口是「我与它」在这个群里的往来, 顶栏同关系图卡片的窗口写成「我 与 它 的对话」, 不画头像;
1044
1179
  // 群名退到副标题, 点它 = 选回这个群本身 (整个群的视图)。
1045
1180
  if (WITH && c.kind === 'group') {
1046
- var s = (c.subs || []).filter(function (x) { return x.role === WITH; })[0];
1047
- who.innerHTML = '<span class="t">' + nm(WITH, s && s.name, true) + '</span>' +
1181
+ var withName = pairTitle(WITH);
1182
+ who.innerHTML = '<span class="t" title="' + esc(withName) + '">' + esc(withName) + '</span>' +
1048
1183
  '<span class="sub">在 <button type="button" class="up" id="ch-up" title="' + esc('看 ' + c.name + ' 的全部记录') + '">' + esc(c.name) + '</button></span>';
1049
1184
  acts.innerHTML = '';
1050
1185
  bindBack();
@@ -1053,19 +1188,19 @@
1053
1188
  return;
1054
1189
  }
1055
1190
  if (c.kind === 'job') {
1056
- who.innerHTML = '<span class="av">📋</span><span class="t">' + esc(c.name) + '</span>' +
1057
- '<span class="sub">' + esc(c.key.slice(2)) + (c.job ? ' · 开单 ' + nm(c.job.owner, '', true) : '') + jobTag(c.job) + stuckTag(c.job) + '</span>';
1191
+ who.innerHTML = '<span class="av">📋</span><span class="t">' + jobCrumbs(c.key.slice(2)) + esc(c.name) + '</span>' +
1192
+ '<span class="sub">' + esc(c.key.slice(2)) + (c.job ? ' · 开单 ' + nm(c.job.owner, '', true) : '') + stageTag(c.job) + jobTag(c.job) + stuckTag(c.job) + '</span>';
1193
+ who.querySelectorAll('.jcrumb [data-job]').forEach(function (b) { b.onclick = function () { openJob(b.getAttribute('data-job')); }; });
1058
1194
  acts.innerHTML = '';
1059
1195
  bindBack();
1060
1196
  bindGo(who);
1061
1197
  return;
1062
1198
  }
1063
- // 一对一 (私聊, 或群里「只看我与 X」) 两端都亮头像: 我在前, 对端在后, 各自是切视角的入口。
1064
- var dm = dmOf(c);
1199
+ // 一对一 (私聊, 或群里「只看我与 X」) 与关系图卡片的窗口同一种写法: 「我 与 它 的对话」, 不画头像。
1065
1200
  var peer = pairPeer(c);
1066
- var peerLabel = c.kind !== 'group' ? c.label : dm && peer === dm.role ? dm.label : '';
1067
- who.innerHTML = (peer ? pairOf([[ROLE, R.role && R.role.label], [peer, peerLabel]]) : '') +
1068
- '<span class="t">' + (c.kind === 'wizard' ? nm(c.peer, c.name, true) : '<span class="nm chat">' + esc(c.name) + '</span>') + '</span>';
1201
+ var pairName = peer && pairTitle(peer);
1202
+ who.innerHTML = peer ? '<span class="t" title="' + esc(pairName) + '">' + esc(pairName) + '</span>'
1203
+ : '<span class="t">' + (c.kind === 'wizard' ? nm(c.peer, c.name, true) : '<span class="nm chat">' + esc(c.name) + '</span>') + '</span>';
1069
1204
  acts.innerHTML = '';
1070
1205
  bindBack();
1071
1206
  bindGo(who);
@@ -1124,45 +1259,53 @@
1124
1259
  if (m.dir === 'mark') {
1125
1260
  return '<div class="mrow mark" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '" data-sig="' + esc(m.sig) + '">' + signCut(m.html, m.from) + '</div>';
1126
1261
  }
1127
- var me = eyeOf();
1262
+ var me = viewpointOf();
1128
1263
  var mine = m.from === me;
1129
1264
  var other = mine ? m.to : m.from;
1130
1265
  // 看 key 而不是会话列表: 换视角就地重包时, 列表还是上一个 role 的。
1131
1266
  var group = CONV.indexOf('p:') !== 0;
1132
- // 顶栏那个对端是相对 ROLE 的; 消息区换成了它 (EYE) 的视角时, 它的对端就是 ROLE。
1267
+ // 顶栏那个对端是相对 ROLE 的; 详情区的 viewpoint 换成了它时, 它的对端就是 ROLE。
1133
1268
  var peer = group ? pairPeer(convOf(CONV)) : CONV.slice(2);
1134
- if (EYE && peer === EYE) peer = ROLE;
1269
+ if (VIEWPOINT && peer === VIEWPOINT) peer = ROLE;
1135
1270
  // 说给谁: 群里我不是收信方的那条 (X → Y); 我发的那条, 收信方不是顶栏那个对端时
1136
1271
  // (一对一里收信方就是顶栏那个对端, 不再重复写)。
1137
- var dst = mine
1272
+ // 两个 role 之间的对话 (标题「X 与 Y 的对话」) 里对象就是另一方, 不标; 多方视图才标。
1273
+ var c = convOf(CONV);
1274
+ var two = !group || !!pairPeer(c) || (!!c && c.kind === 'all' && c.peers.length === 1);
1275
+ var dst = two ? '' : mine
1138
1276
  ? (m.to && m.to !== 'human:' && m.to !== peer ? m.to : '')
1139
1277
  : (m.to !== me && group ? m.to : '');
1140
- // 宽屏写在气泡对面的空白里 (.dest, 在换视角的门里但不吃点击); 窄屏空白太窄, 退回消息头 (.to)。
1141
- var to = dst ? '<span class="to">→ ' + avBtn(m.to) + nm(m.to, '', true) + '</span>' : '';
1142
- // 箭头顺着「发话人 → 收件人」: 气泡是发话人, 描述在哪一侧箭头就背着气泡指向哪一侧 ——
1143
- // 我的消息描述在左 (🦉 .x ←), 别人的在右 (→ 🦉 .x)。
1144
- var dest = dst
1145
- ? '<span class="dest" aria-hidden="true">' + (mine ? '' : '<i class="arr">→</i>') +
1146
- '<span class="dav">' + esc(roleLabel(dst)) + '</span><span class="dn">' + esc(nameOf(dst)) + '</span>' +
1147
- (mine ? '<i class="arr">←</i>' : '') + '</span>'
1278
+ // 说给谁画进文本泡泡开头 (头像 + 名字); 点它走移交行同一套跳转 (data-hfrom/hto/hch + gid/gts), 不换页面视角。
1279
+ var toIn = dst
1280
+ ? '<span class="to-in" role="button" tabindex="0" data-hfrom="' + esc(m.from) + '" data-hto="' + esc(m.to) + '" data-hch="' + esc(m.channel || '') +
1281
+ '" data-gid="' + esc(m.id) + '" data-gts="' + m.ts + '" title="' + esc('发给 ' + nameOf(m.to) + ' —— 看这段往来') + '"><span class="av">' +
1282
+ esc(roleLabel(m.to)) + '</span><span class="nm ' + kindOf(m.to) + '">' + esc(nameOf(m.to)) + '</span></span>'
1148
1283
  : '';
1284
+ var withTo = function (html) {
1285
+ if (!toIn) return html;
1286
+ // 只挂进这条消息自己的正文: 移交体也是 .md-body, 但它发给被移交的那个 wizard, 服务端已挂好它自己的 .to-in。
1287
+ var t = document.createElement('template');
1288
+ t.innerHTML = html;
1289
+ var body = [].find.call(t.content.querySelectorAll('.q-body, .md-body'), function (b) { return !b.closest('.handoff'); });
1290
+ if (!body) return '<div class="to-row">' + toIn + '</div>' + html;
1291
+ body.insertAdjacentHTML('afterbegin', toIn);
1292
+ return t.innerHTML;
1293
+ };
1149
1294
  var priv = !m.channel && group ? '<span class="ch priv">私聊</span>' : '';
1150
1295
  // 本轮的账 (呼吸点 + 模型 / 上下文 / 耗时) 跟在时刻后面 —— 片段是服务端渲染好的。
1151
1296
  // 模型 / ctx 只在多方会话里有用 (分得清谁跑的什么模型); 两个 role 之间的对话里整行挂 .two, 头像行 (.mstat)、
1152
1297
  // 终句气泡的时间行 (.say-cap) 与移交行的账 (.ho-acct) 里藏掉这两样, 耗时、呼吸点与回执留着。
1153
- var c = convOf(CONV);
1154
- var two = !group || !!pairPeer(c) || (!!c && c.kind === 'all' && c.peers.length === 1);
1155
1298
  var stat = m.meta ? '<span class="mstat">' + jobChips(m.meta) + '</span>' : '';
1156
1299
  var who = mine
1157
- ? to + stamp(m.ts) + stat + avBtn(m.from)
1158
- : avBtn(m.from) + nm(m.from, '', true) + to + priv + stamp(m.ts) + stat;
1300
+ ? stamp(m.ts) + stat + avBtn(m.from)
1301
+ : avBtn(m.from) + nm(m.from, '', true) + priv + stamp(m.ts) + stat;
1159
1302
  var sw = canSwitch(other);
1160
1303
  var flip = '<button class="flip" data-r="' + esc(other) + '"' + (sw ? '' : ' disabled tabindex="-1"') +
1161
1304
  ' aria-label="' + esc(sw ? '切到 ' + nameOf(other) + ' 的视角' : '') + '">' +
1162
- dest + (sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
1305
+ (sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
1163
1306
  return '<div class="mrow ' + (mine ? 'mine' : 'them') + (two ? ' two' : '') + '" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '"' +
1164
1307
  (m.ping ? ' data-ping="1" data-ping-who="' + esc(roleName(m.dir === 'in' ? m.to : m.from)) + '"' : '') + ' data-sig="' + esc(m.sig) + '" data-stale-at="' + (m.staleAt || 0) + '">' +
1165
- '<div class="mcol"><div class="mwho">' + who + '</div><div class="mb">' + jobChips(m.dir === 'out' ? signCut(m.html, m.from) : m.html) + '</div></div>' +
1308
+ '<div class="mcol"><div class="mwho">' + who + '</div><div class="mb">' + jobChips(withTo(m.dir === 'out' ? signCut(m.html, m.from) : m.html)) + '</div></div>' +
1166
1309
  flip +
1167
1310
  '</div>';
1168
1311
  };
@@ -1218,6 +1361,13 @@
1218
1361
 
1219
1362
  var bindRow = function (el) {
1220
1363
  bindGo(el, '.flip[data-r]:not([disabled]), .mwho .go[data-r], .tg-cut .go[data-r], .tg-mark .go[data-r], .tg-job .go[data-r]');
1364
+ // 定时行开的是页面视角自己的日程: 片段不随视角 (服务端只标发话方), 能不能点在这里按 ROLE 定;
1365
+ // 别人的那行不可点, 只剩 chevron 展开 prompt。换视角就地重包时也走这里。
1366
+ el.querySelectorAll('.handoff .ho-line[data-hplan]').forEach(function (b) {
1367
+ var mine = b.getAttribute('data-hplan') === ROLE;
1368
+ b.classList.toggle('plan-go', mine);
1369
+ if (mine) b.title = '打开 ' + nameOf(ROLE) + ' 的日程'; else b.removeAttribute('title');
1370
+ });
1221
1371
  };
1222
1372
  var rowNode = function (id) {
1223
1373
  var list = inner.querySelectorAll('.mrow');
@@ -1278,10 +1428,11 @@
1278
1428
  };
1279
1429
 
1280
1430
  // land: 换视角时由它来定位 (锚住一条旧消息), 返回 false 才照常吸底。
1281
- var loadMsgs = function (limit, land) {
1431
+ // early: 换视角时与摘要并行发出的同一个请求 (见 refresh) —— 窗口参数没变才拿来用。
1432
+ var loadMsgs = function (limit, land, early) {
1282
1433
  var gen = S.gen;
1283
1434
  if (!CONV) { inner.innerHTML = '<div class="empty">选一个会话</div>'; return Promise.resolve(); }
1284
- return api('api/msgs', viewParams(limit ? { limit: limit } : {})).then(function (d) {
1435
+ return (early || api('api/msgs', viewParams(limit ? { limit: limit } : {}))).then(function (d) {
1285
1436
  if (!d.ok || gen !== S.gen) return;
1286
1437
  S.frags = {};
1287
1438
  d.msgs.forEach(function (m) { S.frags[m.id] = m; });
@@ -1357,6 +1508,7 @@
1357
1508
  // 收下摘要 (视角、会话列表、落地窗口) 与按它画页头侧栏分开: 换视角整窗重拉时, 画要等到新行到手的同一帧。
1358
1509
  var takeRole = function (d) {
1359
1510
  R.at = d.at || Date.now(); R.recvAt = Date.now();
1511
+ if (!BASE) BASE = R.at;
1360
1512
  R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || []; R.chatKeys = d.chatKeys;
1361
1513
  SESSION = d.session || '';
1362
1514
  R.relations = !!d.relations; R.schedules = d.schedules || 0; R.plan = d.plan || null; R.inflight = d.inflight || [];
@@ -1406,17 +1558,32 @@
1406
1558
  syncUrl();
1407
1559
  // 要做 FLIP 就把旧行留到新行到来 —— 中间闪一下「加载中」就量不到旧位置了。
1408
1560
  if (!land) inner.innerHTML = '<div class="empty">加载中…</div>';
1561
+ // 关系图上换视角: 自己那张卡片的窗口要等新树画出来才知道 (W.pickSelf), 正文等它定了只取一次;
1562
+ // 否则窗口已定, 正文与摘要并行取 —— 摘要改了窗口 (落点 / session) 就作废重取。
1563
+ var hold = WORLD && W.pickSelf === ROLE;
1564
+ // 窗口的指纹: 不带 session 与 session=all 取的是同一份 (摘要会把空的回填成 all)。
1565
+ var winSig = function () { var p = viewParams(); if (p.session === 'all') delete p.session; return new URLSearchParams(p).toString(); };
1566
+ var want = hold || !CONV ? '' : winSig();
1567
+ var early = want ? api('api/msgs', viewParams()) : null;
1568
+ W.pickHold = hold;
1409
1569
  return api('api/role', viewParams()).then(function (d) {
1410
1570
  if (!d.ok) {
1411
1571
  document.body.innerHTML = '<div class="empty" style="padding:80px">' + esc(d.error || 'not found') + '</div>';
1412
1572
  return;
1413
1573
  }
1414
- // 带 land 的 (换视角) 先不画: 页头换了高度, 旧行会在新行到来前先被顶一下, FLIP 的起点就错了。
1415
- if (land) takeRole(d); else applyRole(d);
1574
+ // 带 land 的 (换视角) 只先画侧栏 (名片 + 会话列表, 不碰详情区的几何): 页头 / 页脚换了高度,
1575
+ // 旧行会在新行到来前先被顶一下, FLIP 的起点就错了 —— 它们等新行到手的同一帧画。
1576
+ if (land) { takeRole(d); reveal(); renderRole(); if (!WORLD) renderConvs(); } else applyRole(d);
1577
+ if (restoreSess()) return refresh(land);
1416
1578
  syncUrl();
1417
- if (WORLD || VIEW === 'plan') loadWorld();
1579
+ var world = WORLD || VIEW === 'plan' ? loadWorld() : null;
1418
1580
  if (VIEW === 'charter') loadCharter();
1419
- return loadMsgs(undefined, land && function () { paintRole(); return land(); }).then(function () {
1581
+ return Promise.resolve(hold && world).then(function () {
1582
+ W.pickHold = false;
1583
+ syncUrl();
1584
+ var fresh = early && want === winSig() ? early : null;
1585
+ return loadMsgs(undefined, land && function () { paintRole(); return land(); }, fresh);
1586
+ }).then(function () {
1420
1587
  if (land && !inner.querySelector('.mrow')) paintRole(); // 空窗口不走 land, 页头照样要画
1421
1588
  connect();
1422
1589
  });
@@ -1424,8 +1591,8 @@
1424
1591
  };
1425
1592
 
1426
1593
  // land: 见 loadMsgs —— 搜索跳到一条具体消息时由它来定位, 不吸底。
1427
- var selectConv = function (key, withRole, land, eye) {
1428
- CONV = key; WITH = withRole || ''; EYE = eye && eye !== ROLE ? eye : '';
1594
+ var selectConv = function (key, withRole, land, viewpoint) {
1595
+ CONV = key; WITH = withRole || ''; VIEWPOINT = viewpoint && viewpoint !== ROLE ? viewpoint : '';
1429
1596
  reveal();
1430
1597
  if (VIEW !== 'msgs') setView('msgs');
1431
1598
  app.classList.add('reading');
@@ -1450,7 +1617,7 @@
1450
1617
  var withBack = keep && WITH === id ? from : '';
1451
1618
  // 整个频道 / 两人私聊 / 群里的一对, 消息集合都与视角无关 —— 集合没变就就地翻面。
1452
1619
  var same = VIEW === 'msgs' && (!WITH || withBack) && !SESSION && (keep || CONV.indexOf('p:') === 0);
1453
- ROLE = id; WITH = withBack; SESSION = ''; EYE = '';
1620
+ ROLE = id; WITH = withBack; SESSION = ''; VIEWPOINT = '';
1454
1621
  CONV = keep || (CONV.indexOf('p:') === 0 ? 'p:' + from : '');
1455
1622
  // 停在关系图时换视角 = 选中新视角自己那张卡片: 先开它的全部对话 (不留旧选中 / 空白),
1456
1623
  // 新树画出来知道它连着谁后, renderWorld 再收窄成那张卡片的窗口 (W.pickSelf)。
@@ -1625,13 +1792,16 @@
1625
1792
  // · 只算选中 session 时间范围内发生的 —— 边的每次发生都带着时刻 (WorldEdge.ts)
1626
1793
  // 每种关系一种线色 (CSS 里 li.<kind> / .ek.<kind> 同色), 画在树线、卡片入口的 label 与图例上。
1627
1794
  var KIND = {
1628
- spawn: { mark: '子', tip: 'spawn 的白板 wizard, 只有出身、没有继承上下文' },
1629
- clone: { mark: '分身', tip: 'fork 自父亲的 session, 开局带着那一刻的上下文' },
1630
- peer: { mark: '对话', tip: 'send_peer 发起的对话' },
1795
+ // 生它、归它管的那一位下面挂的是 clone (带上下文) 或 spawn (白板) —— 与调用框下的提示行同一套字。
1796
+ // clone 徽标点了落到 origin 里的分叉处。
1797
+ clone: { mark: 'clone', tip: 'clone_wizard 生的分身: fork 了上下文, 开局带着那一刻读过的一切; 归生它的那位管 —— 点了看它在哪一处分叉' },
1798
+ spawn: { mark: 'spawn', tip: 'spawn_wizard / dispatch 白板生的: 不继承上下文, 只是归生它的那位管 (不带 detached)' },
1799
+ fork: { mark: '⑂ 上下文', tip: '分身的上下文 fork 自它 —— 生它、归它管的是另一位' },
1800
+ peer: { mark: '💬', tip: 'send_peer 发起的对话' },
1631
1801
  job: { mark: '工单', tip: '它开的工单里有这位成员' },
1632
1802
  graph: { mark: '流水线', tip: '流水线里上一步喂给下一步' },
1633
1803
  };
1634
- var LINEAGE = { spawn: 1, clone: 1 };
1804
+ var LINEAGE = { spawn: 1, clone: 1, fork: 1 };
1635
1805
 
1636
1806
  // 老 webview 未必有 CSS.escape, 而 target 里带着 `:` 和 `#` —— 不转义选择器直接抛异常。
1637
1807
  var cssEsc = function (v) {
@@ -1647,7 +1817,8 @@
1647
1817
  // ── 时间窗: profile 里选中的 session; 全部 = 不设限 ──
1648
1818
  var rangeOf = function () {
1649
1819
  var s = R.sessions.filter(function (x) { return x.sessionId === SESSION; })[0];
1650
- return s ? { from: s.start, to: s.end || Infinity, s: s } : null;
1820
+ var cut = winCut();
1821
+ return s ? { from: Math.max(s.start, cut), to: s.end || Infinity, s: s } : cut ? { from: cut, to: Infinity, s: null } : null;
1651
1822
  };
1652
1823
  var inRange = function (rg, t) { return !rg || (t >= rg.from && t <= rg.to); };
1653
1824
 
@@ -1663,12 +1834,19 @@
1663
1834
 
1664
1835
  /** 窗内的关系: 一对有向 (a→b) 一条, kinds = 各种类在窗内发生几次。 */
1665
1836
  var relations = function (rg) {
1666
- var pairs = W.edges.map(function (e) {
1667
- return { kind: e.kind, from: e.from, to: e.to, cross: e.cross, jobs: e.jobs || [], ts: e.ts && e.ts.length ? e.ts : [e.lastTs] };
1668
- }).concat(jobEdges()).reduce(function (m, e) {
1669
- // 家谱是身份, 不是发生在某段 session 里的事 —— 不受时间窗裁剪, 否则切到子 wizard 时
1670
- // 它的出生早于自己的 session, 父亲那条边就被裁掉了。
1671
- var ts = LINEAGE[e.kind] ? e.ts : e.ts.filter(function (t) { return inRange(rg, t); });
1837
+ var all = W.edges.map(function (e) {
1838
+ return { kind: e.kind, from: e.from, to: e.to, cross: e.cross, jobs: e.jobs || [], ts: e.ts && e.ts.length ? e.ts : [e.lastTs], point: e.point };
1839
+ }).concat(jobEdges());
1840
+ // 时间范围里有过往来 (任一种非家谱边, 任一端) 的 role。
1841
+ var active = all.reduce(function (m, e) {
1842
+ if (!LINEAGE[e.kind] && e.ts.some(function (t) { return inRange(rg, t); })) { m[e.from] = 1; m[e.to] = 1; }
1843
+ return m;
1844
+ }, {});
1845
+ var pairs = all.reduce(function (m, e) {
1846
+ // 家谱是身份, 不是发生在某段 session 里的事 —— 不受 session 裁剪, 否则切到子 wizard 时
1847
+ // 它的出生早于自己的 session, 父亲那条边就被裁掉了。时间范围 (WIN) 照裁: 子 wizard 在范围里没有
1848
+ // 往来, 它那条家谱边也不画 —— 与侧栏同一刀 (inWin), 闲了一小时的分身不该因为出身留在「半小时」里。
1849
+ var ts = LINEAGE[e.kind] ? (!WIN || active[e.to] ? e.ts : []) : e.ts.filter(function (t) { return inRange(rg, t); });
1672
1850
  if (!ts.length) return m;
1673
1851
  var k = e.from + '\u0000' + e.to;
1674
1852
  var p = m[k] || (m[k] = { from: e.from, to: e.to, kinds: {}, n: 0, first: Infinity, last: 0, cross: e.cross, jobs: [] });
@@ -1677,6 +1855,7 @@
1677
1855
  p.first = Math.min(p.first, Math.min.apply(null, ts));
1678
1856
  p.last = Math.max(p.last, Math.max.apply(null, ts));
1679
1857
  e.jobs.forEach(function (id) { if (p.jobs.indexOf(id) < 0) p.jobs.push(id); });
1858
+ if (e.point) p.point = e.point;
1680
1859
  return m;
1681
1860
  }, {});
1682
1861
  // 派活本身带着工单号时, 「工单」标记就是重复的。
@@ -1688,7 +1867,8 @@
1688
1867
  var forestOf = function (pairs) {
1689
1868
  var list = Object.keys(pairs).map(function (k) { return pairs[k]; });
1690
1869
  var inc = list.reduce(function (m, p) { (m[p.to] = m[p.to] || []).push(p); return m; }, {});
1691
- var weight = function (p) { return (p.kinds.spawn ? 3e6 : p.kinds.clone ? 2e6 : 0) + p.n; };
1870
+ // 主父亲: 生它的那位 (spawn / clone) 先于上下文来源 (fork), 再先于派活。
1871
+ var weight = function (p) { return (p.kinds.spawn || p.kinds.clone ? 3e6 : p.kinds.fork ? 2e6 : 0) + p.n; };
1692
1872
  var pp = Object.keys(inc).reduce(function (m, t) {
1693
1873
  m[t] = inc[t].slice().sort(function (a, b) { return weight(b) - weight(a) || a.first - b.first; })[0];
1694
1874
  return m;
@@ -1724,7 +1904,11 @@
1724
1904
  close();
1725
1905
  // 兄弟的顺序在画的时候定 (按卡片的时刻, 同会话列表), 这里不排。
1726
1906
  var kids = kidsIn();
1727
- return { pairs: pairs, inc: inc, pp: pp, kids: kids, roots: roots, ends: ends };
1907
+ // 人直接对 wizard 说话的那条边不是它的主父亲 (主父亲多是生它的那位) 时, 在人下面再挂一张它的引用卡:
1908
+ // 同一个节点、同一个窗口, 只是承载的是人 → 它这条边; 引用卡不再往下展开 (后代在它的本位上画)。
1909
+ var refs = list.filter(function (p) { return kindOf(p.from) === 'human' && pp[p.to] !== p; })
1910
+ .reduce(function (m, p) { (m[p.from] = m[p.from] || []).push(p); return m; }, {});
1911
+ return { pairs: pairs, inc: inc, pp: pp, kids: kids, refs: refs, roots: roots, ends: ends };
1728
1912
  };
1729
1913
 
1730
1914
  // F.vis: 只看相关时画哪些 (见 visibleOf); 没有 = 全画。
@@ -1743,19 +1927,19 @@
1743
1927
  return '<span class="tlab"' + (p.jobs.length ? ' title="经手的工单: ' + esc(p.jobs.join(' ')) + '"' : '') + '>' +
1744
1928
  // 工单这一种关系由 📋 那一枚说 (可点, 列的就是这几张), 不再另挂「工单 ×N」重复一遍。
1745
1929
  Object.keys(KIND).filter(function (k) { return p.kinds[k] && !(k === 'job' && p.jobs.length); }).map(function (k) {
1746
- return '<span class="ek ' + k + '" title="' + KIND[k].tip + '">' + KIND[k].mark +
1747
- (!LINEAGE[k] ? ' ×' + p.kinds[k] : '') + '</span>';
1930
+ return '<span class="ek ' + k + '"' + (k === 'clone' && p.point ? ' data-cpoint="' + esc(JSON.stringify(p.point)) + '"' : '') + ' title="' + KIND[k].tip + '">' + KIND[k].mark +
1931
+ (!LINEAGE[k] ? ' ' + p.kinds[k] : '') + '</span>';
1748
1932
  }).join('') +
1749
1933
  (p.cross ? '<span class="ek cross" title="跨群的关系">⇄ 跨群</span>' : '') +
1750
- (p.jobs.length ? '<span class="ek jmk" data-jobs="' + esc(p.jobs.join(' ')) + '" title="' + esc('经手的工单: ' + p.jobs.join(' ') + ' —— 点开') + '">📋' + (p.jobs.length > 1 ? ' ×' + p.jobs.length : '') + '</span>' : '') + '</span>';
1934
+ (p.jobs.length ? '<span class="ek jmk" data-jobs="' + esc(p.jobs.join(' ')) + '" title="' + esc('经手的工单: ' + p.jobs.join(' ') + ' —— 点开') + '">📋' + (p.jobs.length > 1 ? ' ' + p.jobs.length : '') + '</span>' : '') + '</span>';
1751
1935
  };
1752
1936
  // 一条边的主色: 家谱优先, 决定树上那道线的颜色。
1753
1937
  var domKind = function (p) {
1754
- return !p ? 'root' : ['spawn', 'clone', 'peer', 'job', 'graph'].filter(function (k) { return p.kinds[k]; })[0] || 'peer';
1938
+ return !p ? 'root' : ['spawn', 'clone', 'fork', 'peer', 'job', 'graph'].filter(function (k) { return p.kinds[k]; })[0] || 'peer';
1755
1939
  };
1756
1940
 
1757
1941
  // ── 卡片承载指进来的那条边 (主父亲 → 它): 预览取视角与对端在侧栏里现成的那一项 ──
1758
- var edgeEnds = function (F, t) { var p = F.pp[t]; return p ? [p.from, t] : [t]; };
1942
+ var edgeEnds = function (F, t, p) { p = p || F.pp[t]; return p ? [p.from, t] : [t]; };
1759
1943
  // 视角 role 与 other 之间的那一项: 私聊优先, 否则挑最近说过话的那个群里的「只看我与它」。
1760
1944
  var pairConv = function (other) {
1761
1945
  if (convOf('p:' + other)) return ['p:' + other, ''];
@@ -1765,23 +1949,30 @@
1765
1949
  return hit ? [hit[0].key, dmOf(hit[0]) ? '' : other] : null;
1766
1950
  };
1767
1951
  // 视角这一端与边另一端之间、侧栏里现成的那一项 (视角不在边上 / 这段里没说过话 = 没有)。
1768
- var edgeConv = function (F, t) {
1769
- var ends = edgeEnds(F, t);
1952
+ var edgeConv = function (F, t, e) {
1953
+ var ends = edgeEnds(F, t, e);
1770
1954
  var other = ends.length === 2 && ends.indexOf(ROLE) >= 0 && ends.filter(function (x) { return x !== ROLE; })[0];
1771
1955
  return other ? pairConv(other) : null;
1772
1956
  };
1773
- // 点任何一张卡片 (视角自己也一样) 看的窗口: 它与图上和它相连的那几个 role 之间的对话, 不换视角。
1957
+ // 点任何一张卡片 (视角自己也一样) 看的窗口: 它与图上和它相连的那几个 role 之间的对话。整页视角不换,
1958
+ // 只有详情区的 viewpoint 站到被点的那个 role 上: 这段往来是它的, 它说的靠右。侧栏子项是页面视角自己与对端的那一对, 不带 viewpoint。
1774
1959
  // links = 这一次画出来的树里, 它的父亲与孩子 (见 linksOf); 一个都没连着 = 它的全部对话。
1775
1960
  var talkKey = function (links, t) {
1776
1961
  var ls = links[t] || [];
1777
1962
  return 'a:' + t + (ls.length ? '|' + ls.join(',') : '');
1778
1963
  };
1779
- // 图上相连 = 画出来的主父亲边: 它的父亲 + 它的孩子, 两端都画出来了才算。
1964
+ // 图上相连 = 画出来的边: 它的父亲 + 它的孩子 + 引用卡两端 (人 ↔ 它), 两端都画出来了才算。
1780
1965
  var linksOf = function (F, shown) {
1781
1966
  var on = shown.reduce(function (m, n) { m[n.target] = 1; return m; }, {});
1967
+ var refIn = Object.keys(F.refs).reduce(function (m, h) {
1968
+ F.refs[h].forEach(function (p) { (m[p.to] = m[p.to] || []).push(h); });
1969
+ return m;
1970
+ }, {});
1971
+ var refOut = function (t) { return (F.refs[t] || []).map(function (p) { return p.to; }); };
1782
1972
  return shown.reduce(function (m, n) {
1783
1973
  var t = n.target, p = F.pp[t];
1784
- m[t] = (p && on[p.from] ? [p.from] : []).concat((F.kids[t] || []).filter(function (k) { return on[k]; }));
1974
+ m[t] = (p && on[p.from] ? [p.from] : []).concat(F.kids[t] || [], refIn[t] || [], refOut(t))
1975
+ .filter(function (k, i, a) { return on[k] && a.indexOf(k) === i; });
1785
1976
  return m;
1786
1977
  }, {});
1787
1978
  };
@@ -1793,10 +1984,10 @@
1793
1984
  // (边的下端是视角) 的对端是它的父亲 —— 拿会话项的身份去画, 这张卡片就成了父亲的分身。
1794
1985
  // 时刻与最近一句取自卡片自己的窗口 (点它打开的那一份, 服务端 /api/glance 用同一个 talkOf 算, 说话人前缀
1795
1986
  // 也在那里定); 窗口还没取到 (或第一遍量树时) 退回视角与对端的会话项 / 节点。未读跟着视角与对端的会话项走。
1796
- var cardGlance = function (F, t) {
1797
- var hit = edgeConv(F, t), c = hit && convOf(hit[0]);
1987
+ var cardGlance = function (F, t, e) {
1988
+ var hit = edgeConv(F, t, e), c = hit && convOf(hit[0]);
1798
1989
  var s = c && hit[1] && (c.subs || []).filter(function (x) { return x.role === hit[1]; })[0];
1799
- var n = nodeOf(t) || {}, p = F.pp[t];
1990
+ var n = nodeOf(t) || {}, p = e || F.pp[t];
1800
1991
  var base = c ? glance(c, s || undefined) : { lastTs: p ? p.last : n.lastTs || 0, preview: n.preview || '', unread: 0 };
1801
1992
  var w = F.links && W.glance[talkKey(F.links, t)];
1802
1993
  return w ? { lastTs: w.lastTs, preview: w.preview, unread: base.unread } : base;
@@ -1827,14 +2018,15 @@
1827
2018
  var n = Object.keys(all).filter(function (o) { return o !== t && !on[o]; }).length;
1828
2019
  return moreTag(n, nameOf(t) + ' 还和 ' + n + ' 个 role 有关系, 不在这张图上, 切到它的视角可见');
1829
2020
  };
1830
- var tnodeHTML = function (F, n) {
2021
+ // e = 引用卡承载的那条边 (人 → 它); 没有 = 本位卡片, 承载主父亲边。
2022
+ var tnodeHTML = function (F, n, e) {
1831
2023
  var me = n.target === ROLE;
1832
- var p = F.pp[n.target];
1833
- var row = roleRow(n.target, n.name, n.label, cardGlance(F, n.target), n, '', otherRels(F, n.target));
2024
+ var p = e || F.pp[n.target];
2025
+ var row = roleRow(n.target, n.name, n.label, cardGlance(F, n.target, e), n, '', otherRels(F, n.target));
1834
2026
  var via = F.vis && F.vis[n.target] === 'via';
1835
2027
  var jin = F.job && (F.job.in[n.target] ? ' jin' : ' jout');
1836
- return '<button class="ci tci' + (me ? ' me' : '') + (via ? ' via' : '') + (jin || '') + (F.links && CONV === talkKey(F.links, n.target) ? ' on' : '') + '" data-t="' + esc(n.target) + '"' +
1837
- (via ? ' title="' + esc(nameOf(n.target) + ' 和 ' + nameOf(ROLE) + ' 没有直接关系, 留着是为了连到它下面有关系的') + '"' : '') + '>' +
2028
+ return '<button class="ci tci' + (me ? ' me' : '') + (e ? ' ref' : '') + (via ? ' via' : '') + (n.ghost ? ' ghost' : '') + (jin || '') + (F.links && CONV === talkKey(F.links, n.target) ? ' on' : '') + '" data-t="' + esc(n.target) + '"' +
2029
+ (n.ghost ? ' title="' + esc(nameOf(n.target) + ' 已停且没有记录, 这里只是占位, 代表它挂着下面的后代') + '"' : via ? ' title="' + esc(nameOf(n.target) + ' 和 ' + nameOf(ROLE) + ' 没有直接关系, 留着是为了连到它下面有关系的') + '"' : '') + '>' +
1838
2030
  labelHTML(p) + row + '</button>';
1839
2031
  };
1840
2032
 
@@ -1844,13 +2036,19 @@
1844
2036
  if (!n || seen[t] || depth > 32) return '';
1845
2037
  seen[t] = 1;
1846
2038
  shown.push(n);
1847
- var kids = (F.kids[t] || []).filter(function (k) { return shows(F, k); }).sort(byCard(F));
2039
+ // 孩子 = 本位的主孩子 + 引用卡 (它是人时, 它直接说过话、但主父亲另有其人的 wizard), 一起按卡片时刻排。
2040
+ var kids = (F.kids[t] || []).filter(function (k) { return shows(F, k); }).map(function (k) { return { t: k }; })
2041
+ .concat((F.refs[t] || []).filter(function (p) { return shows(F, p.to) && nodeOf(p.to); }).map(function (p) { return { t: p.to, e: p }; }))
2042
+ .sort(function (a, b) { return recentFirst(cardGlance(F, a.t, a.e), cardGlance(F, b.t, b.e)); });
1848
2043
  // 选中工单时: 两端都是它的当事人 (含开单者) 的那道线才亮。
1849
- var jl = F.job ? (F.job.in[t] && F.pp[t] && F.job.in[F.pp[t].from] ? ' jline' : ' jdim') : '';
1850
- return '<li class="' + domKind(F.pp[t]) + jl + '"' + (z ? ' style="z-index:' + z + '"' : '') + '>' + tnodeHTML(F, n) +
2044
+ var jl = function (u, p) { return F.job ? (F.job.in[u] && p && F.job.in[p.from] ? ' jline' : ' jdim') : ''; };
2045
+ var zs = function (z) { return z ? ' style="z-index:' + z + '"' : ''; };
2046
+ return '<li class="' + domKind(F.pp[t]) + jl(t, F.pp[t]) + '"' + zs(z) + '>' + tnodeHTML(F, n) +
1851
2047
  // 兄弟的线共用一段竖干, 越往下的越长: 短的叠在上面 (z 随序号递减), 每条线的末段都看得见自己的颜色。
1852
2048
  (kids.length ? '<ul>' + kids.map(function (k, i) {
1853
- return treeHTML(F, k, depth + 1, shown, seen, kids.length - i);
2049
+ return k.e
2050
+ ? '<li class="' + domKind(k.e) + jl(k.t, k.e) + '"' + zs(kids.length - i) + '>' + tnodeHTML(F, nodeOf(k.t), k.e) + '</li>'
2051
+ : treeHTML(F, k.t, depth + 1, shown, seen, kids.length - i);
1854
2052
  }).join('') + '</ul>' : '') +
1855
2053
  '</li>';
1856
2054
  };
@@ -1858,7 +2056,23 @@
1858
2056
  // 只看相关时画谁: 视角自己 + 和视角有直接关系的 (任一种, 任一方向; 'rel'); 它们通向根的主父亲链上
1859
2057
  // 与视角无关的祖先留作连接 ('via', 画淡) —— 抹掉它, 有关系的后代就从家谱上脱开, 成了一棵来历不明的孤树。
1860
2058
  var visibleOf = function (F) {
1861
- var keep = Object.keys(F.ends).filter(function (t) { return t === ROLE || F.pairs[ROLE + '\u0000' + t] || F.pairs[t + '\u0000' + ROLE]; });
2059
+ // 相关 = 沿关系边 (任一种, 任一方向) 一路走得到的整个连通块, 不止一跳: 间接、多跳的也展开。
2060
+ var nbr = Object.keys(F.pairs).reduce(function (m, k) {
2061
+ var p = F.pairs[k];
2062
+ (m[p.from] = m[p.from] || []).push(p.to);
2063
+ (m[p.to] = m[p.to] || []).push(p.from);
2064
+ return m;
2065
+ }, {});
2066
+ var reach = function (seen, frontier) {
2067
+ if (!frontier.length) return seen;
2068
+ var next = frontier.reduce(function (acc, t) {
2069
+ (nbr[t] || []).forEach(function (o) { if (!seen[o]) { seen[o] = 1; acc.push(o); } });
2070
+ return acc;
2071
+ }, []);
2072
+ return reach(seen, next);
2073
+ };
2074
+ var linked = reach((function (o) { o[ROLE] = 1; return o; })({}), [ROLE]);
2075
+ var keep = Object.keys(F.ends).filter(function (t) { return t === ROLE || linked[t]; });
1862
2076
  var vis = keep.reduce(function (m, t) { m[t] = 'rel'; return m; }, {});
1863
2077
  if (!vis[ROLE]) vis[ROLE] = 'rel';
1864
2078
  keep.forEach(function (t) { chainUp(F, t).forEach(function (u) { if (!vis[u]) vis[u] = 'via'; }); });
@@ -1879,10 +2093,10 @@
1879
2093
  var F = forestOf(relations(rg));
1880
2094
  F.job = jobOfConv();
1881
2095
  var me = nodeOf(ROLE);
1882
- var all = W.treeAll || !me;
2096
+ // 只画相关的 (visibleOf); 视角不是图上的节点 (没有关系可言) 才全画。
2097
+ var all = !me;
1883
2098
  var shown = [];
1884
2099
  var path = me ? chainUp(F, ROLE) : [];
1885
- // 和视角没有直接关系的只在「看全部」里画。
1886
2100
  F.vis = all ? null : visibleOf(F);
1887
2101
  var draw = function (into) {
1888
2102
  return F.roots.filter(function (r) { return shows(F, r); }).sort(function (a, b) {
@@ -1900,16 +2114,17 @@
1900
2114
  if (W.pickSelf === ROLE && W.role === ROLE && R.role && R.role.id === ROLE) {
1901
2115
  W.pickSelf = '';
1902
2116
  var self = talkKey(F.links, ROLE);
1903
- if (CONV !== self) return selectConv(self, '');
2117
+ // refresh 正等着它定窗口 (W.pickHold): 只改窗口, 正文由 refresh 取一次 —— 不再先取全部对话、再收窄重取。
2118
+ if (CONV !== self && W.pickHold) { CONV = self; WITH = ''; }
2119
+ else if (CONV !== self) return selectConv(self, '');
1904
2120
  }
1905
2121
  var trees = draw([]);
1906
- var span = rg ? (rg.s === R.sessions[R.sessions.length - 1] ? '最新 session' : 'session ' + fmtClock(rg.from)) : '全部时间';
2122
+ var span = rg ? (!rg.s ? '最近' + (WINS.filter(function (w) { return w[0] === WIN; })[0] || [0, ''])[1] : rg.s === R.sessions[R.sessions.length - 1] ? '最新 session' : 'session ' + fmtClock(rg.from)) : '';
1907
2123
  var alone = !all && shown.length < 2;
1908
2124
  var html = '<div class="tview">' +
1909
- '<h2>关系<span title="在名片里的 session 下拉切换范围">' + esc(span) + ' · ' + (all ? Object.keys(F.ends).length : shown.length) + ' 个</span>' +
2125
+ '<h2>关系<span title="在名片里的 session 下拉切换范围">' + (span ? esc(span) + ' · ' : '') + (all ? Object.keys(F.ends).length : shown.length) + ' 个</span>' +
1910
2126
  (W.degraded ? '<span class="warn" title="没拿到 wizard 注册表 (svr 还没收到 daemon 的快照), 只画观测到的往来">名册缺席</span>' : '') +
1911
- (me ? '<button class="vt" data-tall="' + (all ? 'rel' : 'all') + '" title="' +
1912
- (all ? '画出范围内所有有关系的 wizard (点击改为只画相关的)' : '只画它自己、和它有直接关系的, 以及连到它们的上游链 (点击改为画全部)') + '">' + (all ? '全部' : '相关') + '</button>' : '') + worldToggle() + '</h2>' +
2127
+ winSelect() + worldToggle() + '</h2>' +
1913
2128
  (alone ? '<div class="tsolo">' + esc(nameOf(ROLE)) + (rg ? ' 在这段 session 里' : '') + ' 没和谁有关系</div>' : '') +
1914
2129
  (shown.length ? '<ul class="tree' + (all ? ' all' : '') + (F.job ? ' jsel' : '') + '">' + trees + '</ul>' : '<div class="pempty">这段时间里没有任何关系</div>') +
1915
2130
  '</div>';
@@ -1918,18 +2133,11 @@
1918
2133
  convsEl._tree = html; convsEl._html = '';
1919
2134
  convsEl.innerHTML = html;
1920
2135
  convsEl.querySelectorAll('.tci').forEach(function (el) {
1921
- el.onclick = function () { selectConv(talkKey(F.links, el.getAttribute('data-t')), ''); };
2136
+ el.onclick = function () { var t = el.getAttribute('data-t'); clickItem(talkKey(F.links, t), '', t); };
1922
2137
  });
1923
2138
  bindGo(convsEl);
1924
- convsEl.querySelectorAll('[data-tall]').forEach(function (b) {
1925
- b.onclick = function () {
1926
- var on = b.getAttribute('data-tall') === 'all';
1927
- if (on === !!W.treeAll) return;
1928
- W.treeAll = on; W.treeFor = ''; renderWorld();
1929
- };
1930
- });
1931
- bindWorldToggle(convsEl);
1932
- var cur = convsEl.querySelector('.tci.me');
2139
+ bindWorldToggle(convsEl); bindWin(convsEl);
2140
+ var cur = convsEl.querySelector('.tci.me:not(.ref)');
1933
2141
  if (cur && W.treeFor !== ROLE) { W.treeFor = ROLE; cur.scrollIntoView({ block: 'nearest' }); }
1934
2142
  };
1935
2143
 
@@ -2149,10 +2357,14 @@
2149
2357
  var renderJobList = function () {
2150
2358
  var el = jobsPane().firstChild;
2151
2359
  var js = jobListRows();
2360
+ // 视角里没有的祖先也照账补上, 树才连得起来 (JOB_ONLY 是点名的几张, 不补)。
2361
+ if (!JOB_ONLY) js = js.concat(js.reduce(function (a, c) {
2362
+ return a.concat(jobAncestors(c.job.id).filter(function (id) { return !js.some(function (x) { return x.job.id === id; }); }));
2363
+ }, []).filter(function (id, i, a) { return a.indexOf(id) === i; }).map(jobRowOf).filter(function (c) { return c.job; }));
2152
2364
  var open = js.filter(function (c) { return c.job.status === 'open'; }).sort(recentFirst);
2153
2365
  var closed = js.filter(function (c) { return c.job.status !== 'open'; }).sort(recentFirst);
2154
2366
  var sec = function (title, list, cls) {
2155
- return '<h2>' + title + '<span>' + list.length + '</span></h2>' + '<div class="' + cls + '">' + list.map(convItem).join('') + '</div>';
2367
+ return '<h2>' + title + '<span>' + list.length + '</span></h2>' + '<div class="' + cls + '">' + jobTreeOrder(list).map(function (x) { return x.d ? '<div class="jind" style="margin-left:' + (x.d * 16) + 'px">' + convItem(x.c) + '</div>' : convItem(x.c); }).join('') + '</div>';
2156
2368
  };
2157
2369
  var html = js.length
2158
2370
  ? sec('进行中', open, 'jopen') + (closed.length ? sec('已收工', closed, 'jclosed') : '')
@@ -2215,6 +2427,7 @@
2215
2427
  // 侧栏换成关系图 / 换回会话列表。换回时选中的仍是在关系图里点开的那一项, 并把它滚进视野。
2216
2428
  var setWorld = function (on) {
2217
2429
  WORLD = on; W.treeFor = ''; W.pickSelf = '';
2430
+ setPref(WORLD_KEY, on);
2218
2431
  convsEl._html = convsEl._tree = '';
2219
2432
  syncUrl();
2220
2433
  // 手机上侧栏与主区二选一 —— 开关在侧栏里, 结果也在侧栏里。
@@ -2225,9 +2438,9 @@
2225
2438
  // 选中的子项得露出来: 群展开, 排在折叠条后面的连折叠条一起展开。
2226
2439
  var c = convOf(CONV);
2227
2440
  if (c) {
2228
- OPEN[CONV] = true; OPEN_AT = CONV;
2441
+ OPEN[CONV] = true; OPEN_AT = CONV; saveOpen();
2229
2442
  var rank = c.subs.filter(function (x) { return x.count; }).map(function (x) { return x.role; }).indexOf(WITH);
2230
- if (rank >= SUB_FOLD) MORE[CONV] = true;
2443
+ if (rank >= SUB_FOLD) setMore(CONV, true);
2231
2444
  }
2232
2445
  renderConvs();
2233
2446
  var sel = convsEl.querySelector('.si.on') || convsEl.querySelector('.ci.on');
@@ -2335,20 +2548,32 @@
2335
2548
  row.classList.remove('hit'); void row.offsetWidth; row.classList.add('hit');
2336
2549
  return true;
2337
2550
  };
2338
- var landOn = function (id) {
2551
+ // 落到一行里的某一次工具调用 (`use`): 它所在的过程框摊开, 调用框滚到中间闪一下。
2552
+ var focusUse = function (id, use) {
2553
+ if (!focusRow(id)) return false;
2554
+ var b = use && rowNode(id).querySelector('[data-use="' + cssEsc(use) + '"]');
2555
+ if (!b) return true;
2556
+ var g = b.closest('.steps');
2557
+ if (g) { g.classList.remove('folded'); FOLD[g.getAttribute('data-key')] = false; }
2558
+ b.scrollIntoView({ block: 'center' });
2559
+ S.pinned = atBottom();
2560
+ b.classList.remove('hit'); void b.offsetWidth; b.classList.add('hit');
2561
+ return true;
2562
+ };
2563
+ var landOn = function (id, use) {
2339
2564
  return function () {
2340
- if (focusRow(id)) return true;
2341
- loadMsgs('0', function () { return focusRow(id); });
2565
+ if (focusUse(id, use)) return true;
2566
+ loadMsgs('0', function () { return focusUse(id, use); });
2342
2567
  return true;
2343
2568
  };
2344
2569
  };
2345
2570
  var jumpMsg = function (h) {
2346
- if (inSession(h.ts) && convOf(h.conv)) return selectConv(h.conv, h.with || '', landOn(h.id), h.eye);
2347
- SESSION = 'all'; CONV = h.conv; WITH = h.with || ''; EYE = h.eye && h.eye !== ROLE ? h.eye : '';
2571
+ if ((!h.id || inSession(h.ts)) && convOf(h.conv)) return selectConv(h.conv, h.with || '', h.id ? landOn(h.id, h.use) : undefined, h.viewpoint);
2572
+ SESSION = 'all'; CONV = h.conv; WITH = h.with || ''; VIEWPOINT = h.viewpoint && h.viewpoint !== ROLE ? h.viewpoint : '';
2348
2573
  if (VIEW !== 'msgs') setView('msgs');
2349
2574
  app.classList.add('reading');
2350
2575
  reveal();
2351
- refresh(landOn(h.id));
2576
+ refresh(h.id ? landOn(h.id, h.use) : undefined);
2352
2577
  };
2353
2578
 
2354
2579
  var paintSearch = function (sections) {
@@ -2448,5 +2673,7 @@
2448
2673
  // ── boot ──
2449
2674
  // 带着 role / conv 来的链接直接进阅读态 (手机上不先落在会话列表)。
2450
2675
  if ((ROLE || CONV) && !WORLD) app.classList.add('reading');
2676
+ // 链接已带着 role 与会话: 记下的 session 先放进第一次请求, 省一次重取 (不在列表里由 restoreSess 收拾)。
2677
+ if (SESS_RESTORE && ROLE && CONV && savedSess()) SESSION = savedSess();
2451
2678
  refresh();
2452
2679
  })();