wezard 2.3.0 → 2.4.1

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 (70) 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 +103 -15
  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 +281 -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 +17 -14
  19. package/dist/daemon/inbound.js.map +1 -1
  20. package/dist/daemon/index.js +957 -225
  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 +97 -84
  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 +172 -33
  29. package/dist/daemon/notices.js.map +1 -1
  30. package/dist/daemon/peers.js +105 -78
  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 +58 -24
  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/top-only-nudge.js +29 -0
  43. package/dist/daemon/top-only-nudge.js.map +1 -0
  44. package/dist/daemon/wizard.js +114 -61
  45. package/dist/daemon/wizard.js.map +1 -1
  46. package/dist/mcp/server.js +75 -25
  47. package/dist/mcp/server.js.map +1 -1
  48. package/dist/shared/chat-http.js +23 -13
  49. package/dist/shared/chat-http.js.map +1 -1
  50. package/dist/shared/config-meta.js +91 -0
  51. package/dist/shared/config-meta.js.map +1 -0
  52. package/dist/shared/config-writer.js +5 -6
  53. package/dist/shared/config-writer.js.map +1 -1
  54. package/dist/shared/config.js +279 -294
  55. package/dist/shared/config.js.map +1 -1
  56. package/dist/shared/detail-render.js +121 -32
  57. package/dist/shared/detail-render.js.map +1 -1
  58. package/dist/shared/detail-store.js.map +1 -1
  59. package/dist/shared/keepalive.js +5 -5
  60. package/dist/shared/keepalive.js.map +1 -1
  61. package/dist/shared/role-render.js +52 -12
  62. package/dist/shared/role-render.js.map +1 -1
  63. package/dist/shared/role-view.js +84 -26
  64. package/dist/shared/role-view.js.map +1 -1
  65. package/dist/shared/world.js +82 -13
  66. package/dist/shared/world.js.map +1 -1
  67. package/hooks/pre-tool-use.sh +14 -12
  68. package/package.json +1 -1
  69. package/web/chat.css +63 -37
  70. package/web/chat.js +513 -283
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,11 +100,17 @@
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;
104
107
  };
108
+ // 摘要另带未读基线 (定了才带): 服务端据此只给基线之后的他人未读账。不进 URL。
109
+ var roleParams = function (extra) {
110
+ var p = viewParams(extra);
111
+ if (BASE) p.since = BASE;
112
+ return p;
113
+ };
105
114
  // 视角状态回写 URL (replaceState: 不在历史里留一串中间态)。
106
115
  var syncUrl = function () {
107
116
  var p = new URLSearchParams({ id: TOKEN });
@@ -184,7 +193,10 @@
184
193
  scope.querySelectorAll('.bubble, .tg-job').forEach(function (b) {
185
194
  var src = b.querySelector('script.md-src'), body = b.querySelector('.md-body');
186
195
  if (src && body && body.dataset.rendered !== '1') {
196
+ // 「发给谁」先挂在空 .md-body 里; 填进 markdown 后把它塞进第一段, 才能和正文第一行同行。
197
+ var to = body.querySelector(':scope > .to-in');
187
198
  body.innerHTML = md.render(src.textContent || '');
199
+ if (to) { var p = body.firstElementChild; (p && p.tagName === 'P' ? p : body).prepend(to, ' '); }
188
200
  body.dataset.rendered = '1';
189
201
  }
190
202
  });
@@ -317,7 +329,7 @@
317
329
  r.classList.toggle('raw');
318
330
  });
319
331
  inner.addEventListener('click', function (e) {
320
- var c = e.target.closest && e.target.closest('.mchip.job[data-job]');
332
+ var c = e.target.closest && e.target.closest('.mchip.job[data-job], .tg-job .jc[data-job], .jcrumb [data-job]');
321
333
  if (c) openJob(c.getAttribute('data-job'));
322
334
  });
323
335
  // 回执 chip / 移交行的活号: 跳到派活那句 (同一个活号的入消息)。先在眼前这个窗口里找;
@@ -334,22 +346,36 @@
334
346
  var conv = ch ? 'c:' + ch : ROLE === from ? 'p:' + to : ROLE === to ? 'p:' + from : 'a:' + from + '|' + to;
335
347
  jumpMsg({ conv: conv, id: c.getAttribute('data-gid'), ts: Number(c.getAttribute('data-gts')) });
336
348
  });
337
- // 移交行整行: 侧栏选中移交双方之间的那一项 (群里的一对 / 私聊; 视角不是两端之一就开
338
- // 「这两方之间」的往来), 有派活那句就落在这一行自己的那句上。不换视角 (节点随 reconcile 换, 所以在根上委托)。
349
+ // 移交行整行: 侧栏选中移交双方之间的那一项 (群里的一对 / 私聊; 页面视角不是两端之一就开
350
+ // 「这两方之间」的往来), 有派活那句就落在这一行自己的那句上。页面视角不动, 详情区的 viewpoint
351
+ // 换成移交人 (派活那一方)。节点随 reconcile 换, 所以在根上委托。
339
352
  // 行尾的 chevron 只管展开原文, 走 <summary> 自己的开合。
340
353
  inner.addEventListener('click', function (e) {
341
- var b = e.target.closest && e.target.closest('.handoff .ho-line[data-hto]');
354
+ var b = e.target.closest && e.target.closest('.handoff .ho-line[data-hto], .to-in[data-hto]');
342
355
  if (!b || e.target.closest('.ho-chev')) return;
343
356
  e.preventDefault(); // 行是 <summary>: 不顺带展开原文
357
+ e.stopPropagation();
344
358
  var from = b.getAttribute('data-hfrom'), to = b.getAttribute('data-hto'), ch = b.getAttribute('data-hch');
345
359
  var peer = ROLE === from ? to : ROLE === to ? from : '';
346
360
  var conv = !peer ? 'a:' + from + '|' + to : ch ? 'c:' + ch : 'p:' + peer;
347
361
  var w = peer && ch ? peer : '';
348
- var id = b.getAttribute('data-gid');
349
362
  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);
363
+ jumpMsg({ conv: conv, with: w, id: b.getAttribute('data-gid'), ts: Number(b.getAttribute('data-gts')), viewpoint: from });
364
+ });
365
+
366
+ // 其余 wezard 调用的提示行: 新生 / 被收的 wizard 开它的全部往来、viewpoint 站到它身上 (页面视角不动),
367
+ // 工单行进工单页, 定时行开日程 —— 日程只有页面视角自己的, 别人的定时行不跳 (换页面视角只走头像 / 名字)。
368
+ // 同移交行: 行是 <summary> 就不顺带展开原文, 行尾 chevron 照旧开合。
369
+ inner.addEventListener('click', function (e) {
370
+ var b = e.target.closest && e.target.closest('.handoff .ho-line[data-hrole], .handoff .ho-line[data-job], .handoff .ho-line[data-hplan]');
371
+ if (!b || e.target.closest('.ho-chev')) return;
372
+ e.preventDefault();
373
+ var role = b.getAttribute('data-hrole'), job = b.getAttribute('data-job'), plan = b.getAttribute('data-hplan');
374
+ if (job) return openJob(job);
375
+ if (plan) return plan === ROLE && setView('plan');
376
+ var conv = 'a:' + role;
377
+ markBack(function () { return VIEW === 'msgs' && CONV === conv; });
378
+ selectConv(conv, '', undefined, role);
353
379
  });
354
380
 
355
381
  // ── 左栏: 当前 role 的名片 + 会话列表 ──
@@ -359,6 +385,36 @@
359
385
  var jobTag = function (j) {
360
386
  return j ? '<span class="jtag' + (j.status === 'open' ? ' open' : '') + '">' + (j.status === 'open' ? j.done + '/' + j.total : '收工') + '</span>' : '';
361
387
  };
388
+ // 阶段 (daemon 现算后推来): 缺省 (老快照) = 不标。
389
+ var STAGE = { plan: '规划', build: '实施', review: '评审', clarify: '澄清', stalled: '卡住', shelved: '搁置' };
390
+ var stageText = function (j) {
391
+ if (!j) return '';
392
+ if (j.status === 'closed') return j.kind === 'req' ? (j.end === 'cancel' ? '取消' : '归档') : '';
393
+ return j.stage === 'deliver' ? (j.kind === 'req' ? '等验收' : '待收工') : STAGE[j.stage] || '';
394
+ };
395
+ var stageTag = function (j) {
396
+ var t = stageText(j);
397
+ return t ? '<span class="jstage' + (j.stage === 'deliver' || j.stage === 'clarify' || j.stage === 'stalled' ? ' wait' : '') + '">' + t + '</span>' : '';
398
+ };
399
+ // 祖先链 (根在前): 按服务端的账走 parent; 环 / 缺失就停。
400
+ var jobAncestors = function (id) {
401
+ var idx = R.jobIndex || {}, out = [], seen = {}, cur = idx[id] && idx[id].parent;
402
+ while (cur && idx[cur] && !seen[cur]) { seen[cur] = 1; out.unshift(cur); cur = idx[cur].parent; }
403
+ return out;
404
+ };
405
+ var jobCrumbs = function (id) {
406
+ var up = jobAncestors(id);
407
+ return up.length ? '<span class="jcrumb">' + up.map(function (a) {
408
+ return '<button data-job="' + esc(a) + '" title="' + esc(a) + '">' + esc((R.jobIndex[a] || {}).title || a) + '</button>';
409
+ }).join('<i>›</i>') + '<i>›</i></span>' : '';
410
+ };
411
+ // 工单列表的树序: 父在前、子缩进跟在后; 父不在这一段里的当根。
412
+ var jobTreeOrder = function (list) {
413
+ var ids = {}; list.forEach(function (c) { ids[c.job.id] = 1; });
414
+ var kids = function (id) { return list.filter(function (c) { return c.job.parent === id; }); };
415
+ 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)); }, [])); };
416
+ return list.filter(function (c) { return !c.job.parent || !ids[c.job.parent]; }).reduce(function (a, c) { return a.concat(walk(c, 0)); }, []);
417
+ };
362
418
  // 工单里有人卡住 (停在审批卡 / 反问待答 / 报错停了) 时标出几份卡着 —— 要人或开单者动手的。
363
419
  var stuckTag = function (j) {
364
420
  var n = j && j.status === 'open' ? (R.inflight || []).filter(function (x) {
@@ -376,7 +432,7 @@
376
432
  return {
377
433
  key: key, kind: 'job', name: j ? j.title : id, label: '📋', base: j ? j.base : '', subs: [], heard: [],
378
434
  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 },
435
+ 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
436
  };
381
437
  };
382
438
  /** 一个工单号 → 它的一行: 视角有份的取会话项 (带预览), 其余按账现造。 */
@@ -449,11 +505,6 @@
449
505
  ms.map(function (m) { return '<i>' + esc(m.label || roleLabel(m.role)) + '</i>'; }).join('') + '</span>';
450
506
  };
451
507
 
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
508
  var line = function (title, ts, pv, lamp, unread) {
458
509
  return '<span class="b"><span class="l1"><span class="t">' + title + '</span>' + (lamp || '') +
459
510
  '<span class="ts">' + esc(fmtAgo(ts)) + '</span></span>' +
@@ -461,93 +512,114 @@
461
512
  (unread ? '<b class="ub">' + (unread > 99 ? '99+' : unread) + '</b>' : '') + '</span></span>';
462
513
  };
463
514
 
464
- // ── 未读: 别人说完、我还没读到的话 ──
465
- // 服务端给每个会话一份 heard = [说完的时刻, 发话方, 收信方], 以及我在群里 / 每一对里
466
- // 最后开口的时刻 (mine)。说给我的那句属于「我与发话方」那一对。一句话的已读水位 = 它所在那一处
467
- // 「我最后开口」与「我看过」的较晚者: 属于某一对的看那一对 (群里说过话不等于读过别的线程), 不属于任何一对的看群。
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) { } };
476
- var readKey = function (key, withRole) { return ROLE + '|' + key + '|' + (withRole || ''); };
477
- var seenAt = function (key, withRole) { return READ.at[readKey(key, withRole)] || 0; };
478
- var unreadOf = function (c, withRole) {
479
- var g = seenAt(c.key);
480
- 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);
483
- return (c.heard || []).filter(function (h) {
484
- var p = h[2] === ROLE ? h[1] : '';
485
- if (withRole && p !== withRole) return false;
486
- return h[0] > (p && mark[p] !== undefined ? mark[p] : groupMark);
487
- }).length;
488
- };
489
- var reading = function () { return VIEW === 'msgs' && !document.hidden; };
490
- // 窗口是 `a:<x>|<peers>` (关系图卡片) 时, 视角与 p 的往来在不在里面 —— 与服务端 talkOf 同一口径:
491
- // 一端是 x、另一端是 peers 之一 (peers 空 = 不限), 不分频道。
492
- var talkCovers = function (p, c) {
493
- var t = convOf(CONV);
494
- if (!t || t.kind !== 'all') return false;
495
- // 限定了频道的窗口 (某 role 在一个群里的全部记录) 只覆盖那个群里的往来。
496
- if (t.chat && (!c || c.base !== t.chat)) return false;
497
- var hit = function (a, b) { return t.who === a && (!t.peers.length || t.peers.indexOf(b) >= 0); };
498
- return hit(ROLE, p) || hit(p, ROLE);
499
- };
500
- // 一处会话里这次读到了哪几对 (存水位用的 withRole): 侧栏选中的就是那一项; 关系图卡片打开的
501
- // 对话横跨会话, 每个会话里落在那段对话里的那几对都算 —— 私聊的那一对记在会话本身 ('')。
502
- var readPairs = function (c) {
503
- if (c.key === CONV) return [WITH];
504
- if (c.kind !== 'group') return c.peer && talkCovers(c.peer, c) ? [''] : [];
505
- return (c.subs || []).filter(function (s) { return talkCovers(s.role, c); }).map(function (s) { return s.role; });
506
- };
507
- // 读整个群 = 群的水位推到此刻 (子项随之全清); 只读一对 = 只推那一对的。
508
- var markRead = function (c) {
515
+ // ── 未读: 别人说完、还没在详情里读到的话 ──
516
+ // 服务端给每个窗口 (侧栏会话项 / 子项、关系图卡片) 一份 heard = [说完的时刻, 发话方, 收信方, 消息 id]:
517
+ // 看的那个 role 在点开这一项会看到的消息里听得到、且晚于它在那一处最后开口的 (回过话 = 读到了那里)。
518
+ // 已读只有一份 (SEEN): 这句话说完的样子在详情里上过屏 (页面可见、正在看消息) 就记下它的 id ——
519
+ // 与页面视角、详情视角、侧栏 / 关系图无关, 哪一处读到, 所有视图里同一句话一起消失, 不必等下一份摘要。
520
+ // 基线是「打开页面这一刻」(BASE, 服务端时钟, 首次拿到快照时定): 此前已有的话不算未读, 刷新即重置 ——
521
+ // 所以 SEEN 只活在内存里, 不落本地。
522
+ var BASE = 0;
523
+ var SEEN = {};
524
+ var unreadIn = function (hs, skip) {
525
+ return (hs || []).filter(function (h) { return h[0] > BASE && !((SEEN[h[3]] || 0) >= h[0]) && !(skip && skip[h[3]]); });
526
+ };
527
+ // 群里的成对子项 = 说给视角的、出自它的那几句; 不给 withRole = 整个会话。
528
+ var unreadHeard = function (c, withRole) {
529
+ return unreadIn(withRole ? (c.heard || []).filter(function (h) { return h[1] === withRole && h[2] === ROLE; }) : c.heard);
530
+ };
531
+ var unreadOf = function (c, withRole) { return unreadHeard(c, withRole).length; };
532
+ // 名字旁的他人未读: 点名字切到 id 的视角会看到的未读 (它全部会话项的, 服务端 peerUnread 按同一口径给),
533
+ // 减去这一行右边红点已在数的那几句。已读同样取自 SEEN —— 哪一处读到, 这里一起消。
534
+ var idsOf = function (hs) { return hs.reduce(function (m, h) { m[h[3]] = 1; return m; }, {}); };
535
+ var theirsOf = function (id, counted) {
536
+ return id && id !== ROLE ? unreadIn((R.peerUnread || {})[id], idsOf(counted || [])).length : 0;
537
+ };
538
+ var peerBadge = function (id, n) {
539
+ return n && id !== ROLE ? '<b class="pub" title="' + esc(nameOf(id) + ' 的视角下还有 ' + n + ' 条未读') + '">' + (n > 99 ? '99+' : n) + '</b>' : '';
540
+ };
541
+ // 详情区真在屏上: 窄屏退回列表时它整个 display:none (offsetParent 为空)。
542
+ var reading = function () { return VIEW === 'msgs' && !document.hidden && thread.offsetParent !== null; };
543
+ // 上屏的片段记成已读 (正在看消息时): 记的是它说完的时刻, 还在吐字的那一版不算读到终句。
544
+ // 有新记的就重画侧栏 / 关系图 —— 两边从同一个 SEEN 派生, 同一拍变。
545
+ var see = function (frags) {
509
546
  if (!reading()) return;
510
- var h = c.heard && c.heard[c.heard.length - 1];
511
- var hit = readPairs(c).filter(function (w) { return unreadOf(c, w || (c.kind !== 'group' ? c.peer : '')); });
512
- hit.forEach(function (w) { READ.at[readKey(c.key, w)] = Math.max(R.at, h ? h[0] : 0); });
513
- if (hit.length) saveRead();
547
+ var hit = frags.filter(function (m) { return m && m.fin && !((SEEN[m.id] || 0) >= m.fin); });
548
+ hit.forEach(function (m) { SEEN[m.id] = m.fin; });
549
+ if (hit.length && R.convs) renderConvs();
550
+ };
551
+ var seeAll = function () { see(Object.keys(S.frags || {}).map(function (k) { return S.frags[k]; })); };
552
+
553
+ // 跨会话通用的个人偏好 (看全部、列表 / 关系、侧栏群节点的展开、session 选择): 记在本地, 刷新后还在。
554
+ // 取不到 / 写不进 (隐私窗口、禁存) 就按默认值, 页面照常。
555
+ var pref = function (key, def) {
556
+ try { var v = localStorage.getItem(key); return v === null ? def : JSON.parse(v); } catch (e) { return def; }
557
+ };
558
+ var setPref = function (key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch (e) { } };
559
+ var prefObj = function (key) { var v = pref(key, {}); return v && typeof v === 'object' ? v : {}; };
560
+ var WORLD_KEY = 'wezard.role.world';
561
+ if (!qs.has('side')) WORLD = pref(WORLD_KEY, false) === true;
562
+ // 早先也记过 ping 与日程卡片的展开 (只活在内存里了)、「往来 / 相关 / 全部」开关 (已去掉, 固定看全部 / 相关) —— 旧 key 顺手清掉。
563
+ try { ['wezard.role.pingOpen', 'wezard.role.planOpen', 'wezard.role.showAll', 'wezard.role.subScope'].forEach(function (k) { localStorage.removeItem(k); }); } catch (e) { }
564
+
565
+ // 展开态按群各记各的 (键 = 群节点的会话键): 点开一个不收起别的, 轮询重画也不动它。
566
+ // OPEN_AT = 已经替它自动展开过的那个 CONV —— 选中新会话时展开一次, 之后折不折由人说了算; null = 刚载入。
567
+ // 默认只展开选中的那个群; 本地只记偏离默认的: 没选中却展开着的 = true, 选中却被收起的 = false
568
+ // (载入时自动展开要让着它)。回到默认的那一项写的时候就删, 不越存越多。
569
+ var OPEN_KEY = 'wezard.role.groupOpen';
570
+ var OPEN = prefObj(OPEN_KEY), OPEN_AT = null;
571
+ // 子项默认只露前 SUB_FOLD 个, 其余收在一条展开/折叠条后面; 展开态同样按群记, 只留展开着的。
572
+ var MORE_KEY = 'wezard.role.groupMore';
573
+ var SUB_FOLD = 5, MORE = prefObj(MORE_KEY);
574
+ // 某 role 在一个群里的全部记录挂在那个群下: CONV 落在哪个群节点上。
575
+ var groupOf = function (key) { var t = key && convOf(key); return t && t.chat ? 'c:' + t.chat : key; };
576
+ var saveOpen = function () {
577
+ var sel = groupOf(CONV);
578
+ setPref(OPEN_KEY, Object.keys(OPEN).reduce(function (m, k) {
579
+ if (!!OPEN[k] !== (k === sel)) m[k] = !!OPEN[k];
580
+ return m;
581
+ }, {}));
514
582
  };
515
-
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
- }
583
+ var setMore = function (key, v) {
584
+ if (v) MORE[key] = true; else delete MORE[key];
585
+ setPref(MORE_KEY, MORE);
527
586
  };
528
- // 点侧栏项: 没选中它 → 选中 (会话顺带展开); 已选中 → 不再选一遍, 右边的正文一个字不动 ——
529
- // 会话只折叠/展开, 子项什么都不做。
530
- var clickItem = function (key, withRole) {
531
- if (key !== CONV || withRole !== WITH || VIEW !== 'msgs' || EYE) return selectConv(key, withRole);
532
- // 窄屏退回列表后再点它是要回去读, 不是要折叠。
533
- if (!app.classList.contains('reading')) return app.classList.add('reading');
534
- if (withRole) return;
535
- OPEN[key] = !OPEN[key];
536
- renderConvs();
587
+ var reveal = function () {
588
+ if (!CONV || CONV === OPEN_AT) return;
589
+ var g = groupOf(CONV);
590
+ if (!(OPEN_AT === null && OPEN[g] === false)) OPEN[g] = true;
591
+ OPEN_AT = CONV;
592
+ saveOpen();
593
+ };
594
+ // 点侧栏项 / 关系图卡片 (两边同一份): 没选中它 → 选中 (会话顺带展开); 已选中再点 → 取消选中, 右边回到
595
+ // 页面视角的全部对话 (`a:<role>`, 即「看全部」那一份), 页面视角不动; 取消的是会话本身就顺带收起它。
596
+ // viewpoint: 站在这一项的主人身上 —— 会话与成对的子项是页面视角自己的, .far 子项与卡片是那个 role 的。
597
+ var clickItem = function (key, withRole, viewpoint) {
598
+ var vp = viewpoint && viewpoint !== ROLE ? viewpoint : '';
599
+ if (key !== CONV || (withRole || '') !== WITH || VIEW !== 'msgs' || VIEWPOINT !== vp) return selectConv(key, withRole, undefined, viewpoint);
600
+ // 窄屏退回列表后再点它是要回去读, 不是要取消。
601
+ if (!app.classList.contains('reading')) { app.classList.add('reading'); return seeAll(); }
602
+ var fold = !withRole && OPEN[key];
603
+ if (fold) OPEN[key] = false;
604
+ selectConv('a:' + ROLE, '', undefined, '');
605
+ if (fold) saveOpen(); // 选中项换过之后再记: 没选中的收着是默认态, 不占一条
537
606
  };
538
607
 
539
608
  // ── 会话项 / 子项 / 关系图卡片共用的三样: 一行的数据 (glance)、一行的画法 (roleRow)、排序 (recentFirst) ──
540
609
  // glance = 时刻 · 最近一句 · 未读, 取自会话本身 (子项 = 我与它在这个群里的那一对)。
610
+ // theirs = 这一行主人的未读 (名字旁): 子项是它, 私聊项是对端, 群项没有。
541
611
  var glance = function (c, s) {
542
- var x = s || c;
543
- return { lastTs: x.lastTs, preview: x.preview, unread: unreadOf(c, s ? s.role : undefined) };
612
+ var x = s || c, mine = unreadHeard(c, s ? s.role : undefined);
613
+ return { lastTs: x.lastTs, preview: x.preview, unread: mine.length, theirs: theirsOf(s ? s.role : c.peer, mine) };
544
614
  };
545
615
  // 同层按最近活动排, 有新话就上浮。
546
616
  var recentFirst = function (a, b) { return b.lastTs - a.lastTs; };
547
617
  // 一个 role 的一行: 头像 · 名字 (+tail) + 忙闲灯 · 时刻 / 最近一句 + 未读。
618
+ // 名字悬停时紧跟一枚 ⇄ —— 同名片上名字悬停露出 ⧉: 点名字做的事, 先亮给人看。
619
+ var SWAP = '<i class="sw" aria-hidden="true">⇄</i>';
548
620
  var roleRow = function (id, name, label, g, status, tail, badge) {
549
621
  return goSpan('av', id, esc(label)) +
550
- line(nm(id, name, true, badge) + (tail || ''), g.lastTs, g.preview, stTag(status, true), g.unread);
622
+ line(nm(id, name, true, peerBadge(id, g.theirs) + SWAP + (badge || '')) + (tail || ''), g.lastTs, g.preview, stTag(status, true), g.unread);
551
623
  };
552
624
  // 名字旁的「+N」: 视图外还有 N 个 —— 同一个徽标, 数什么由所在的视图注入 (会话列表数会话, 关系图数关系)。
553
625
  var moreTag = function (n, tip) {
@@ -564,7 +636,7 @@
564
636
  var dmKey = function (peer) { return 'p:' + pairKey(peer); };
565
637
  var convRow = function (c) {
566
638
  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));
639
+ 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
640
  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
641
  };
570
642
  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))); };
@@ -575,18 +647,16 @@
575
647
  var farKey = function (c, s) { return 'a:' + s.role + '||' + c.base; };
576
648
  var farRow = function (s) {
577
649
  var w = s.whole;
578
- return roleRow(s.role, s.name, s.label, { lastTs: w.lastTs, preview: w.preview, unread: 0 }, s.status, '', otherChats(s.role, ''));
650
+ return roleRow(s.role, s.name, s.label, { lastTs: w.lastTs, preview: w.preview, unread: 0, theirs: theirsOf(s.role) }, s.status, '', otherChats(s.role, ''));
579
651
  };
580
652
 
581
653
  var convItem = function (c) {
582
654
  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
- : [];
655
+ // 与我有往来的成对子项在前; chat 内其余有记录的接在后面 (.far), 各自按最近排。
656
+ // 时间范围按子项自己的时刻裁 (成对 = 我与它的往来, .far = 它在群里的全部) —— 群的时刻是群里任何人的动静, 放不得行。
657
+ var pairs = c.subs.filter(function (s) { return s.count && inWin(s.lastTs); }).sort(recentFirst);
658
+ var fars = c.subs.filter(function (s) { return !s.count && s.whole && s.whole.count && inWin(s.whole.lastTs); })
659
+ .sort(function (a, b) { return recentFirst(a.whole, b.whole); });
590
660
  var talked = OPEN[c.key] ? pairs.concat(fars) : [];
591
661
  var hidden = talked.length - SUB_FOLD;
592
662
  var bar = hidden > 0
@@ -596,7 +666,7 @@
596
666
  var sub = function (s) {
597
667
  if (!s.count) {
598
668
  var k = farKey(c, s);
599
- return '<button class="si far' + (CONV === k ? ' on' : '') + '" data-conv="' + esc(k) + '" data-with="" ' +
669
+ return '<button class="si far' + (CONV === k ? ' on' : '') + '" data-conv="' + esc(k) + '" data-with="" data-vp="' + esc(s.role) + '" ' +
600
670
  'title="' + esc(nameOf(s.role) + ' 在这里的 ' + s.whole.count + ' 条记录 (与我无往来)') + '">' + farRow(s) + '</button>';
601
671
  }
602
672
  var sel = on && s.role === WITH;
@@ -609,6 +679,16 @@
609
679
  ? '<div class="subs">' + talked.slice(0, SUB_FOLD).map(sub).join('') + bar + rest + '</div>'
610
680
  : '';
611
681
  var sel = on && !WITH;
682
+
683
+ // 群聊项特殊处理: 把 chevron 挪到行内部, 在时间左边
684
+ if (c.kind === 'group' && c.subs && c.subs.length) {
685
+ // 不能是 <button>: 嵌在外层 .ci 按钮里会被解析器提前闭合外层, chevron 就掉到卡片外
686
+ var chevron = '<span role="button" class="ci-chevron' + (OPEN[c.key] ? ' on' : '') + '" data-toggle="' + esc(c.key) + '" title="' + (OPEN[c.key] ? '折叠群' : '展开群') + '"></span>';
687
+ 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>' +
688
+ '<span class="l2"><span class="pv">' + esc(c.preview) + '</span>' + (unreadOf(c) ? '<b class="ub">' + (unreadOf(c) > 99 ? '99+' : unreadOf(c)) + '</b>' : '') + '</span></span>';
689
+ return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' + content + '</button>' + subs;
690
+ }
691
+
612
692
  return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' +
613
693
  convRow(c) + '</button>' + subs;
614
694
  };
@@ -625,9 +705,8 @@
625
705
  // 老外壳 (daemon 未重启) 里还留着名片下那条入口栏 —— 日程已并进名片, 它整条不要了。
626
706
  if ($('#rb-acts')) $('#rb-acts').remove();
627
707
  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);
630
- R.convs.forEach(markRead);
708
+ var groups = R.convs.filter(function (c) { return c.kind === 'group' && inWin(c.lastTs); }).sort(recentFirst);
709
+ var dms = R.convs.filter(function (c) { return c.kind === 'wizard' && inWin(c.lastTs); }).sort(recentFirst);
631
710
  if (WORLD) return renderWorld();
632
711
  // 空着的那一栏不画 —— 对谁都一样: 人没有私聊只是这条规则的一个特例。
633
712
  var sec = function (title, list) {
@@ -635,22 +714,23 @@
635
714
  };
636
715
  // 没变就不碰 DOM: 心跳每 3s 来一次, 重建会把列表的滚动与焦点蹭掉。
637
716
  // 开关挂在第一个标题行的右端; 一个会话都没有也留一行标题给它。
638
- var html = (sec('群聊', groups) + sec('私聊', dms) || '<h2>会话<span>0</span></h2>').replace('</h2>', scopeToggle() + worldToggle() + '</h2>');
717
+ var html = (sec('群聊', groups) + sec('私聊', dms) || '<h2>会话<span>0</span></h2>').replace('</h2>', winSelect() + worldToggle() + '</h2>');
639
718
  if (convsEl._html === html) return;
640
719
  convsEl._html = html; convsEl.innerHTML = html;
641
720
  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); };
721
+ var key = b.getAttribute('data-conv'), w = b.getAttribute('data-with') || '', vp = b.getAttribute('data-vp') || '';
722
+ b.onclick = function () { clickItem(key, w, vp); };
644
723
  });
645
724
  convsEl.querySelectorAll('[data-more]').forEach(function (b) {
646
725
  var key = b.getAttribute('data-more');
647
- b.onclick = function () { MORE[key] = !MORE[key]; renderConvs(); };
726
+ b.onclick = function () { setMore(key, !MORE[key]); renderConvs(); };
648
727
  });
649
- bindGo(convsEl);
650
- bindWorldToggle(convsEl);
651
- convsEl.querySelectorAll('[data-scope]').forEach(function (b) {
652
- b.onclick = function () { setScope(b.getAttribute('data-scope')); };
728
+ convsEl.querySelectorAll('[data-toggle]').forEach(function (b) {
729
+ var key = b.getAttribute('data-toggle');
730
+ b.onclick = function (e) { e.stopPropagation(); OPEN[key] = !OPEN[key]; saveOpen(); renderConvs(); };
653
731
  });
732
+ bindGo(convsEl);
733
+ bindWorldToggle(convsEl); bindWin(convsEl);
654
734
  };
655
735
 
656
736
  var shortCwd = function (p) {
@@ -659,73 +739,113 @@
659
739
  };
660
740
  // session 只靠两样认: 何时开始、跑了几轮。最新那段直接叫「最新」。
661
741
  var latestSess = function () { return R.sessions[R.sessions.length - 1]; };
742
+ // 选中的 session 按「role + 会话 (+ 群里的对端)」记; 选回默认 (全部时间, 不切) 就删那一项。
743
+ // 只在 URL 没指定 session 时恢复 —— 落地窗口要等第一份摘要才定, SESS_RESTORE 守到那时。
744
+ var SESS_KEY = 'wezard.role.session';
745
+ var SESS_RESTORE = !qs.get('session');
746
+ var sessKey = function () { return ROLE + '\n' + CONV + (WITH ? '\n' + WITH : ''); };
747
+ var savedSess = function () { return prefObj(SESS_KEY)[sessKey()] || ''; };
748
+ var saveSess = function (sid) {
749
+ var m = prefObj(SESS_KEY);
750
+ if (sid && sid !== 'all') m[sessKey()] = sid; else delete m[sessKey()];
751
+ setPref(SESS_KEY, m);
752
+ };
753
+ // 第一份摘要到手: 记下的那段还在列表里就换过去 (换了要重取一次), 不在了就回默认并删掉。
754
+ var restoreSess = function () {
755
+ if (!SESS_RESTORE) return false;
756
+ SESS_RESTORE = false;
757
+ var want = savedSess();
758
+ if (!want || want === SESSION) return false;
759
+ if (R.sessions.some(function (x) { return x.sessionId === want; })) { SESSION = want; return true; }
760
+ saveSess('');
761
+ return false;
762
+ };
662
763
  var sessWhen = function (s) { return s.start ? fmtClock(s.start) : '(无时刻)'; };
663
764
 
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);
765
+ // 自定义下拉: 小号文字触发器 + 浮层列表 (.sp)。名片里的 session 与侧栏标题行的时间范围共用这一份。
766
+ // 宿主每次刷新都重画, 展开的是哪一个记在 DD_OPEN 里 (按 id) 才不会被轮询收起。
767
+ // items: { v, html, on, cls?, tip? }; `ic` = 触发器左侧的图标; `end` = 浮层以触发器右沿为锚向左下展开 (贴着侧栏右沿的那种)。
768
+ var DD_OPEN = '';
769
+ var dropdown = function (id, o) {
770
+ var open = DD_OPEN === id;
771
+ return '<span class="sp' + (o.end ? ' end' : '') + (open ? ' open' : '') + '" data-dd="' + id + '">' +
772
+ (o.ic ? '<span class="sp-ic" aria-hidden="true">' + o.ic + '</span>' : '') +
773
+ '<button class="sp-btn" aria-haspopup="listbox" aria-expanded="' + open + '" title="' + esc(o.title) + '">' +
774
+ esc(o.label) + '<span class="car" aria-hidden="true"></span></button>' +
775
+ '<span class="sp-list" role="listbox">' + o.items.map(function (it) {
776
+ return '<button class="sp-it' + (it.cls ? ' ' + it.cls : '') + (it.on ? ' on' : '') + '" role="option" aria-selected="' + !!it.on + '" data-v="' + esc(it.v) + '"' +
777
+ (it.tip ? ' title="' + esc(it.tip) + '"' : '') + '>' + it.html + '</button>';
778
+ }).join('') + '</span></span>';
779
+ };
780
+ var setDdOpen = function (id) {
781
+ DD_OPEN = id;
782
+ document.querySelectorAll('.sp[data-dd]').forEach(function (sp) {
783
+ var on = sp.getAttribute('data-dd') === id;
784
+ sp.classList.toggle('open', on);
785
+ sp.querySelector('.sp-btn').setAttribute('aria-expanded', on);
786
+ });
692
787
  };
693
- var bindSessPicker = function () {
694
- var sp = $('#rb-sp .sp');
788
+ var bindDropdown = function (root, id, pick) {
789
+ var sp = root && root.querySelector('.sp[data-dd="' + id + '"]');
695
790
  if (!sp) return;
696
- sp.querySelector('.sp-btn').onclick = function () { setSessOpen(!SESS_OPEN); };
791
+ sp.querySelector('.sp-btn').onclick = function () { setDdOpen(DD_OPEN === id ? '' : id); };
697
792
  sp.querySelectorAll('.sp-it').forEach(function (it) {
698
- it.onclick = function () { setSessOpen(false); SESSION = it.getAttribute('data-s'); refresh(); };
793
+ it.onclick = function () { setDdOpen(''); pick(it.getAttribute('data-v')); };
699
794
  });
700
795
  };
701
796
  document.addEventListener('click', function (e) {
702
- if (SESS_OPEN && !e.target.closest('#rb-sp .sp')) setSessOpen(false);
797
+ if (DD_OPEN && !e.target.closest('.sp[data-dd="' + DD_OPEN + '"]')) setDdOpen('');
703
798
  });
704
799
  document.addEventListener('keydown', function (e) {
705
- if (SESS_OPEN && e.key === 'Escape') setSessOpen(false);
800
+ if (DD_OPEN && e.key === 'Escape') setDdOpen('');
706
801
  });
707
802
 
803
+ // session 切换: name 右边那个下拉。
804
+ var sessItem = function (s, on) {
805
+ var last = s === latestSess();
806
+ return { v: s.sessionId || '', on: on, tip: s.sessionId || '',
807
+ html: '<span class="id">' + (last ? '最新 · ' : '') + esc(sessWhen(s)) + '</span><span class="n">' + s.turns + ' 轮</span>' };
808
+ };
809
+ var sessPicker = function () {
810
+ var cur = R.sessions.filter(function (s) { return s.sessionId === SESSION; })[0];
811
+ return dropdown('sess', {
812
+ title: '切换 session',
813
+ label: !cur ? '全部' : cur === latestSess() ? '最新' : sessWhen(cur),
814
+ items: [{ v: 'all', on: !cur, cls: 'all', html: '<span class="id">全部</span><span class="n">' + R.sessions.length + ' 段</span>' }]
815
+ .concat(R.sessions.slice().reverse().map(function (s) { return sessItem(s, s.sessionId === SESSION); })),
816
+ });
817
+ };
818
+ var bindSessPicker = function () {
819
+ bindDropdown($('#rb-sp'), 'sess', function (v) { SESSION = v; saveSess(SESSION); refresh(); });
820
+ };
821
+
708
822
  // 会话列表 ↔ 关系图: 侧栏第一行右上角同一个小开关, 标的是「点了去哪」。
709
823
  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
824
  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>';
825
+ // 时间范围: 只看最近 N 分钟内有动静的 (0 = 全部)。会话列表与关系图共用这一个值。
826
+ var WIN_KEY = 'wezard.role.winMin';
827
+ var WINS = [[0, '全部'], [30, '半小时'], [60, '1小时'], [120, '2小时'], [1440, '1天']];
828
+ var WIN = pref(WIN_KEY, 0);
829
+ if (!WINS.some(function (w) { return w[0] === WIN; })) WIN = 0;
830
+ var winCut = function () { return WIN ? srvNow() - WIN * 60000 : 0; };
831
+ /** 一个时刻落没落在时间范围里 —— 侧栏的会话 / 子项与关系图的边 (rangeOf) 都按 winCut 这一刀。 */
832
+ var inWin = function (ts) { return !WIN || ts >= winCut(); };
833
+ var winSelect = function () {
834
+ return dropdown('win', {
835
+ ic: '⏱', end: true, title: '时间范围: 只看这段时间内有动静的',
836
+ label: (WINS.filter(function (w) { return w[0] === WIN; })[0] || WINS[0])[1],
837
+ items: WINS.map(function (w) { return { v: String(w[0]), on: w[0] === WIN, html: '<span class="id">' + w[1] + '</span>' }; }),
838
+ });
839
+ };
840
+ var bindWin = function (scope) {
841
+ bindDropdown(scope, 'win', function (v) {
842
+ WIN = +v; W.treeFor = ''; setPref(WIN_KEY, WIN);
843
+ convsEl._html = ''; convsEl._tree = '';
844
+ renderConvs();
845
+ });
726
846
  };
727
847
  var worldToggle = function () {
728
- return '<button class="vt" data-vt title="' + (WORLD ? '换回会话列表' : '换成关系') + '">' + (WORLD ? LIST_SVG + '列表' : TREE_SVG + '关系') + '</button>';
848
+ return '<button class="vt" data-vt title="' + (WORLD ? '换回聊天' : '换成关系') + '">' + (WORLD ? LIST_SVG + '聊天' : TREE_SVG + '关系') + '</button>';
729
849
  };
730
850
  var bindWorldToggle = function (scope) {
731
851
  var b = scope.querySelector('[data-vt]');
@@ -952,14 +1072,14 @@
952
1072
  // ── 右栏头: 这个群聊 / 私聊是什么 (关系 / 日程视图时是视图名) ──
953
1073
  // 跳走之前在看的那段会话 —— 顶栏的「‹ 回到 …」回这里。on: 在哪些窗口上露出这个按钮;
954
1074
  // at: 跳走时点的那条消息, 回来落在它上面。进工单页 (只在从非工单的窗口进来时记) 与
955
- // 移交行点对方都记它, 同一个按钮。
1075
+ // 移交行 / 提示行点过去都记它, 同一个按钮。点过去会换详情区的 viewpoint, 所以连它一起记 (页面视角不动)。
956
1076
  var BACK = null;
957
1077
  var inJobs = function () { return VIEW === 'jobs' || CONV.indexOf('j:') === 0; };
958
- // 回来 = 原样恢复那一页: 会话、视角、滚动位置 (按视窗顶上那一行的偏移记, 不闪不吸)。吸在底部就不记锚点。
1078
+ // 回来 = 原样恢复那一页: 会话、viewpoint、滚动位置 (按视窗顶上那一行的偏移记, 不闪不吸)。吸在底部就不记锚点。
959
1079
  var markBack = function (on) {
960
1080
  var r = !atBottom() && firstShown();
961
1081
  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 };
1082
+ BACK = { conv: CONV, with: WITH, session: SESSION, viewpoint: VIEWPOINT, at: at || null, on: on };
963
1083
  };
964
1084
  var restoreAt = function (at) {
965
1085
  if (!at) return undefined;
@@ -992,6 +1112,16 @@
992
1112
  e.stopPropagation(); e.preventDefault();
993
1113
  openJobs(m.getAttribute('data-jobs').split(' ').filter(Boolean));
994
1114
  }, true);
1115
+ // 关系图的 clone 徽标: 开 origin 的全部往来、落到它分叉出这个分身的那一处; viewpoint 站到 origin, 页面视角不动。
1116
+ // 同 📋: 长在卡片按钮里, 捕获阶段截下来。
1117
+ document.addEventListener('click', function (e) {
1118
+ var m = e.target.closest && e.target.closest('.ek.clone[data-cpoint]');
1119
+ if (!m) return;
1120
+ e.stopPropagation(); e.preventDefault();
1121
+ var pt = JSON.parse(m.getAttribute('data-cpoint')), conv = 'a:' + pt.origin;
1122
+ markBack(function () { return VIEW === 'msgs' && CONV === conv; });
1123
+ jumpMsg({ conv: conv, id: pt.id, use: pt.use, ts: pt.ts, viewpoint: pt.origin });
1124
+ }, true);
995
1125
  var backBtn = function () {
996
1126
  var c = BACK && BACK.on() && convOf(BACK.conv);
997
1127
  return c ? '<button class="back jb" id="ch-jback" title="' + esc('回到 ' + (BACK.with ? nameOf(BACK.with) : c.name)) + '" aria-label="返回">‹</button>' : '';
@@ -1001,8 +1131,10 @@
1001
1131
  var h = backBtn();
1002
1132
  if (h) $('#ch-who').insertAdjacentHTML('afterbegin', h);
1003
1133
  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); };
1134
+ if (b) b.onclick = function () { var x = BACK; BACK = null; SESSION = x.session; selectConv(x.conv, x.with, restoreAt(x.at), x.viewpoint); };
1005
1135
  };
1136
+ // 一对一的标题: 与关系图卡片窗口 (convOf 的 a:) 同一种写法。
1137
+ var pairTitle = function (peer) { return nameOf(ROLE) + ' 与 ' + nameOf(peer) + ' 的对话'; };
1006
1138
  var renderHead = function () {
1007
1139
  var who = $('#ch-who'), acts = $('#ch-acts');
1008
1140
  if (VIEW === 'jobs') {
@@ -1022,29 +1154,29 @@
1022
1154
  }
1023
1155
  var c = convOf(CONV);
1024
1156
  if (!c) { who.innerHTML = ''; acts.innerHTML = ''; return; }
1025
- // 某 role 在一个群里的全部记录 (「chat 内全部」下与我无往来的子项): 顶栏与成对子项同一种写法 —— 只写它,
1026
- // 「在 <群>」点回整个群。
1157
+ // 某 role 在一个群里的全部记录 (「chat 内全部」下与我无往来的子项): 顶栏只写它, 副标题「在 <群> 的全部对话」——
1158
+ // 与成对子项 (「我 ⇄ 它」) 一眼分得开。群名点回整个群。
1027
1159
  if (c.kind === 'all' && c.chat) {
1028
1160
  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>';
1161
+ '<span class="sub">在 <button type="button" class="up" id="ch-up" title="' + esc('看 ' + chatTitle(c.chat) + ' 的全部记录') + '">' + esc(chatTitle(c.chat)) + '</button> 的全部对话</span>';
1030
1162
  acts.innerHTML = '';
1031
1163
  bindGo(who);
1032
1164
  $('#ch-up').onclick = function () { selectConv('c:' + c.chat, ''); };
1033
1165
  return;
1034
1166
  }
1035
1167
  if (c.kind === 'all') {
1036
- who.innerHTML = pairOf([[c.who]]) + '<span class="t" title="' + esc(c.name) + '"></span>';
1168
+ who.innerHTML = '<span class="t" title="' + esc(c.name) + '"></span>';
1037
1169
  acts.innerHTML = '';
1038
1170
  bindBack();
1039
1171
  fitTalk(who.querySelector('.t'), c);
1040
1172
  bindGo(who);
1041
1173
  return;
1042
1174
  }
1043
- // 群里选中一个子项: 窗口是「我与它」在这个群里的往来, 顶栏只写对方 (视角自己左栏名片上已有), 不画头像;
1175
+ // 群里选中一个子项: 窗口是「我与它」在这个群里的往来, 顶栏同关系图卡片的窗口写成「我 与 它 的对话」, 不画头像;
1044
1176
  // 群名退到副标题, 点它 = 选回这个群本身 (整个群的视图)。
1045
1177
  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>' +
1178
+ var withName = pairTitle(WITH);
1179
+ who.innerHTML = '<span class="t" title="' + esc(withName) + '">' + esc(withName) + '</span>' +
1048
1180
  '<span class="sub">在 <button type="button" class="up" id="ch-up" title="' + esc('看 ' + c.name + ' 的全部记录') + '">' + esc(c.name) + '</button></span>';
1049
1181
  acts.innerHTML = '';
1050
1182
  bindBack();
@@ -1053,19 +1185,19 @@
1053
1185
  return;
1054
1186
  }
1055
1187
  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>';
1188
+ who.innerHTML = '<span class="av">📋</span><span class="t">' + jobCrumbs(c.key.slice(2)) + esc(c.name) + '</span>' +
1189
+ '<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>';
1190
+ who.querySelectorAll('.jcrumb [data-job]').forEach(function (b) { b.onclick = function () { openJob(b.getAttribute('data-job')); }; });
1058
1191
  acts.innerHTML = '';
1059
1192
  bindBack();
1060
1193
  bindGo(who);
1061
1194
  return;
1062
1195
  }
1063
- // 一对一 (私聊, 或群里「只看我与 X」) 两端都亮头像: 我在前, 对端在后, 各自是切视角的入口。
1064
- var dm = dmOf(c);
1196
+ // 一对一 (私聊, 或群里「只看我与 X」) 与关系图卡片的窗口同一种写法: 「我 与 它 的对话」, 不画头像。
1065
1197
  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>';
1198
+ var pairName = peer && pairTitle(peer);
1199
+ who.innerHTML = peer ? '<span class="t" title="' + esc(pairName) + '">' + esc(pairName) + '</span>'
1200
+ : '<span class="t">' + (c.kind === 'wizard' ? nm(c.peer, c.name, true) : '<span class="nm chat">' + esc(c.name) + '</span>') + '</span>';
1069
1201
  acts.innerHTML = '';
1070
1202
  bindBack();
1071
1203
  bindGo(who);
@@ -1124,45 +1256,53 @@
1124
1256
  if (m.dir === 'mark') {
1125
1257
  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
1258
  }
1127
- var me = eyeOf();
1259
+ var me = viewpointOf();
1128
1260
  var mine = m.from === me;
1129
1261
  var other = mine ? m.to : m.from;
1130
1262
  // 看 key 而不是会话列表: 换视角就地重包时, 列表还是上一个 role 的。
1131
1263
  var group = CONV.indexOf('p:') !== 0;
1132
- // 顶栏那个对端是相对 ROLE 的; 消息区换成了它 (EYE) 的视角时, 它的对端就是 ROLE。
1264
+ // 顶栏那个对端是相对 ROLE 的; 详情区的 viewpoint 换成了它时, 它的对端就是 ROLE。
1133
1265
  var peer = group ? pairPeer(convOf(CONV)) : CONV.slice(2);
1134
- if (EYE && peer === EYE) peer = ROLE;
1266
+ if (VIEWPOINT && peer === VIEWPOINT) peer = ROLE;
1135
1267
  // 说给谁: 群里我不是收信方的那条 (X → Y); 我发的那条, 收信方不是顶栏那个对端时
1136
1268
  // (一对一里收信方就是顶栏那个对端, 不再重复写)。
1137
- var dst = mine
1269
+ // 两个 role 之间的对话 (标题「X 与 Y 的对话」) 里对象就是另一方, 不标; 多方视图才标。
1270
+ var c = convOf(CONV);
1271
+ var two = !group || !!pairPeer(c) || (!!c && c.kind === 'all' && c.peers.length === 1);
1272
+ var dst = two ? '' : mine
1138
1273
  ? (m.to && m.to !== 'human:' && m.to !== peer ? m.to : '')
1139
1274
  : (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>'
1275
+ // 说给谁画进文本泡泡开头 (头像 + 名字); 点它走移交行同一套跳转 (data-hfrom/hto/hch + gid/gts), 不换页面视角。
1276
+ var toIn = dst
1277
+ ? '<span class="to-in" role="button" tabindex="0" data-hfrom="' + esc(m.from) + '" data-hto="' + esc(m.to) + '" data-hch="' + esc(m.channel || '') +
1278
+ '" data-gid="' + esc(m.id) + '" data-gts="' + m.ts + '" title="' + esc('发给 ' + nameOf(m.to) + ' —— 看这段往来') + '"><span class="av">' +
1279
+ esc(roleLabel(m.to)) + '</span><span class="nm ' + kindOf(m.to) + '">' + esc(nameOf(m.to)) + '</span></span>'
1148
1280
  : '';
1281
+ var withTo = function (html) {
1282
+ if (!toIn) return html;
1283
+ // 只挂进这条消息自己的正文: 移交体也是 .md-body, 但它发给被移交的那个 wizard, 服务端已挂好它自己的 .to-in。
1284
+ var t = document.createElement('template');
1285
+ t.innerHTML = html;
1286
+ var body = [].find.call(t.content.querySelectorAll('.q-body, .md-body'), function (b) { return !b.closest('.handoff'); });
1287
+ if (!body) return '<div class="to-row">' + toIn + '</div>' + html;
1288
+ body.insertAdjacentHTML('afterbegin', toIn);
1289
+ return t.innerHTML;
1290
+ };
1149
1291
  var priv = !m.channel && group ? '<span class="ch priv">私聊</span>' : '';
1150
1292
  // 本轮的账 (呼吸点 + 模型 / 上下文 / 耗时) 跟在时刻后面 —— 片段是服务端渲染好的。
1151
1293
  // 模型 / ctx 只在多方会话里有用 (分得清谁跑的什么模型); 两个 role 之间的对话里整行挂 .two, 头像行 (.mstat)、
1152
1294
  // 终句气泡的时间行 (.say-cap) 与移交行的账 (.ho-acct) 里藏掉这两样, 耗时、呼吸点与回执留着。
1153
- var c = convOf(CONV);
1154
- var two = !group || !!pairPeer(c) || (!!c && c.kind === 'all' && c.peers.length === 1);
1155
1295
  var stat = m.meta ? '<span class="mstat">' + jobChips(m.meta) + '</span>' : '';
1156
1296
  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;
1297
+ ? stamp(m.ts) + stat + avBtn(m.from)
1298
+ : avBtn(m.from) + nm(m.from, '', true) + priv + stamp(m.ts) + stat;
1159
1299
  var sw = canSwitch(other);
1160
1300
  var flip = '<button class="flip" data-r="' + esc(other) + '"' + (sw ? '' : ' disabled tabindex="-1"') +
1161
1301
  ' aria-label="' + esc(sw ? '切到 ' + nameOf(other) + ' 的视角' : '') + '">' +
1162
- dest + (sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
1302
+ (sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
1163
1303
  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
1304
  (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>' +
1305
+ '<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
1306
  flip +
1167
1307
  '</div>';
1168
1308
  };
@@ -1218,6 +1358,13 @@
1218
1358
 
1219
1359
  var bindRow = function (el) {
1220
1360
  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]');
1361
+ // 定时行开的是页面视角自己的日程: 片段不随视角 (服务端只标发话方), 能不能点在这里按 ROLE 定;
1362
+ // 别人的那行不可点, 只剩 chevron 展开 prompt。换视角就地重包时也走这里。
1363
+ el.querySelectorAll('.handoff .ho-line[data-hplan]').forEach(function (b) {
1364
+ var mine = b.getAttribute('data-hplan') === ROLE;
1365
+ b.classList.toggle('plan-go', mine);
1366
+ if (mine) b.title = '打开 ' + nameOf(ROLE) + ' 的日程'; else b.removeAttribute('title');
1367
+ });
1221
1368
  };
1222
1369
  var rowNode = function (id) {
1223
1370
  var list = inner.querySelectorAll('.mrow');
@@ -1274,14 +1421,16 @@
1274
1421
  foldPings(inner);
1275
1422
  expireRows();
1276
1423
  if (keep) keep();
1424
+ see(fresh);
1277
1425
  }).catch(retry);
1278
1426
  };
1279
1427
 
1280
1428
  // land: 换视角时由它来定位 (锚住一条旧消息), 返回 false 才照常吸底。
1281
- var loadMsgs = function (limit, land) {
1429
+ // early: 换视角时与摘要并行发出的同一个请求 (见 refresh) —— 窗口参数没变才拿来用。
1430
+ var loadMsgs = function (limit, land, early) {
1282
1431
  var gen = S.gen;
1283
1432
  if (!CONV) { inner.innerHTML = '<div class="empty">选一个会话</div>'; return Promise.resolve(); }
1284
- return api('api/msgs', viewParams(limit ? { limit: limit } : {})).then(function (d) {
1433
+ return (early || api('api/msgs', viewParams(limit ? { limit: limit } : {}))).then(function (d) {
1285
1434
  if (!d.ok || gen !== S.gen) return;
1286
1435
  S.frags = {};
1287
1436
  d.msgs.forEach(function (m) { S.frags[m.id] = m; });
@@ -1294,6 +1443,7 @@
1294
1443
  expireRows();
1295
1444
  settleAbove();
1296
1445
  applyFold(inner);
1446
+ see(d.msgs);
1297
1447
  if (land && land()) return;
1298
1448
  S.pinned = true; toBottom(true);
1299
1449
  // CDN 字体/代码高亮加载完会改变高度, 再吸一次底。
@@ -1306,6 +1456,7 @@
1306
1456
  var stick = S.pinned;
1307
1457
  var cur = rowNode(m.id);
1308
1458
  S.frags[m.id] = m;
1459
+ see([m]);
1309
1460
  if (cur && cur.getAttribute('data-sig') === m.sig) return;
1310
1461
  // 折行会把 .mrow 收进 details, 而下面的按时间插位假定它们都是 inner 的直接子节点。
1311
1462
  unfoldPings(inner);
@@ -1357,7 +1508,8 @@
1357
1508
  // 收下摘要 (视角、会话列表、落地窗口) 与按它画页头侧栏分开: 换视角整窗重拉时, 画要等到新行到手的同一帧。
1358
1509
  var takeRole = function (d) {
1359
1510
  R.at = d.at || Date.now(); R.recvAt = Date.now();
1360
- R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || []; R.chatKeys = d.chatKeys;
1511
+ if (!BASE) BASE = R.at;
1512
+ R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || []; R.chatKeys = d.chatKeys; R.peerUnread = d.peerUnread;
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 || [];
1363
1515
  // 工单的账变了 (或比消息晚到): 已上屏的工单 badge 按新账重填一遍 —— 片段里只写了工单号。
@@ -1381,13 +1533,13 @@
1381
1533
  };
1382
1534
 
1383
1535
  // 切回标签页时正在读的那项就算读过了。
1384
- document.addEventListener('visibilitychange', function () { if (!document.hidden) renderConvs(); });
1536
+ document.addEventListener('visibilitychange', function () { if (!document.hidden) seeAll(); });
1385
1537
 
1386
1538
  // ── SSE: role 摘要 + 当前窗口的消息增量。断线指数退避重连。 ──
1387
1539
  var backoff = 1000;
1388
1540
  var connect = function () {
1389
1541
  if (S.es) { S.es.close(); S.es = null; }
1390
- var p = new URLSearchParams(viewParams({ id: TOKEN }));
1542
+ var p = new URLSearchParams(roleParams({ id: TOKEN }));
1391
1543
  var es = new EventSource('api/role-events?' + p.toString());
1392
1544
  S.es = es;
1393
1545
  es.addEventListener('role', function (e) { try { applyRole(JSON.parse(e.data)); } catch (err) { } });
@@ -1406,17 +1558,32 @@
1406
1558
  syncUrl();
1407
1559
  // 要做 FLIP 就把旧行留到新行到来 —— 中间闪一下「加载中」就量不到旧位置了。
1408
1560
  if (!land) inner.innerHTML = '<div class="empty">加载中…</div>';
1409
- return api('api/role', viewParams()).then(function (d) {
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;
1569
+ return api('api/role', roleParams()).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)。
@@ -1590,7 +1757,7 @@
1590
1757
  S.gen++;
1591
1758
  syncUrl();
1592
1759
  var gen = S.gen;
1593
- api('api/role', viewParams()).then(function (d) {
1760
+ api('api/role', roleParams()).then(function (d) {
1594
1761
  if (!d.ok || gen !== S.gen) return;
1595
1762
  applyRole(d);
1596
1763
  // 断点 (/clear /new) 是旧视角 role 自己的, 新视角的由下面补拉。
@@ -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
  };
@@ -1791,15 +1982,18 @@
1791
1982
  // 而不是节点自己最后的动静 —— 卡片承载的是边。
1792
1983
  // 身份 (头像 / 名字 / 状态灯) 永远是卡片自己的节点: 会话项的身份是「视角的对端」, 视角自己那张卡片
1793
1984
  // (边的下端是视角) 的对端是它的父亲 —— 拿会话项的身份去画, 这张卡片就成了父亲的分身。
1794
- // 时刻与最近一句取自卡片自己的窗口 (点它打开的那一份, 服务端 /api/glance 用同一个 talkOf 算, 说话人前缀
1795
- // 也在那里定); 窗口还没取到 (或第一遍量树时) 退回视角与对端的会话项 / 节点。未读跟着视角与对端的会话项走。
1796
- var cardGlance = function (F, t) {
1797
- var hit = edgeConv(F, t), c = hit && convOf(hit[0]);
1985
+ // 时刻、最近一句与未读取自卡片自己的窗口 (点它打开的那一份, 服务端 /api/glance 用同一个 talkOf 算, 说话人前缀
1986
+ // 与未读账也在那里定); 窗口还没取到 (或第一遍量树时) 退回视角与对端的会话项 / 节点。
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];
1800
- var base = c ? glance(c, s || undefined) : { lastTs: p ? p.last : n.lastTs || 0, preview: n.preview || '', unread: 0 };
1990
+ var n = nodeOf(t) || {}, p = e || F.pp[t];
1991
+ var base = c ? glance(c, s || undefined) : { lastTs: p ? p.last : n.lastTs || 0, preview: n.preview || '', unread: 0, theirs: theirsOf(t) };
1801
1992
  var w = F.links && W.glance[talkKey(F.links, t)];
1802
- return w ? { lastTs: w.lastTs, preview: w.preview, unread: base.unread } : base;
1993
+ if (!w) return base;
1994
+ // 未读也取自卡片自己的窗口 (与侧栏同一个 unreadIn): 点开它看得到的才数; 名字旁的是卡片主人整个视角的 (theirsOf)。
1995
+ var mine = unreadIn(w.mine);
1996
+ return { lastTs: w.lastTs, preview: w.preview, unread: mine.length, theirs: theirsOf(t, mine) };
1803
1997
  };
1804
1998
  // 卡片窗口的 glance: 树里画了谁变了就取一次, 世界快照每次轮询也顺带刷新。
1805
1999
  var loadGlance = function () {
@@ -1827,14 +2021,15 @@
1827
2021
  var n = Object.keys(all).filter(function (o) { return o !== t && !on[o]; }).length;
1828
2022
  return moreTag(n, nameOf(t) + ' 还和 ' + n + ' 个 role 有关系, 不在这张图上, 切到它的视角可见');
1829
2023
  };
1830
- var tnodeHTML = function (F, n) {
2024
+ // e = 引用卡承载的那条边 (人 → 它); 没有 = 本位卡片, 承载主父亲边。
2025
+ var tnodeHTML = function (F, n, e) {
1831
2026
  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));
2027
+ var p = e || F.pp[n.target];
2028
+ var row = roleRow(n.target, n.name, n.label, cardGlance(F, n.target, e), n, '', otherRels(F, n.target));
1834
2029
  var via = F.vis && F.vis[n.target] === 'via';
1835
2030
  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) + ' 没有直接关系, 留着是为了连到它下面有关系的') + '"' : '') + '>' +
2031
+ 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) + '"' +
2032
+ (n.ghost ? ' title="' + esc(nameOf(n.target) + ' 已停且没有记录, 这里只是占位, 代表它挂着下面的后代') + '"' : via ? ' title="' + esc(nameOf(n.target) + ' 和 ' + nameOf(ROLE) + ' 没有直接关系, 留着是为了连到它下面有关系的') + '"' : '') + '>' +
1838
2033
  labelHTML(p) + row + '</button>';
1839
2034
  };
1840
2035
 
@@ -1844,13 +2039,19 @@
1844
2039
  if (!n || seen[t] || depth > 32) return '';
1845
2040
  seen[t] = 1;
1846
2041
  shown.push(n);
1847
- var kids = (F.kids[t] || []).filter(function (k) { return shows(F, k); }).sort(byCard(F));
2042
+ // 孩子 = 本位的主孩子 + 引用卡 (它是人时, 它直接说过话、但主父亲另有其人的 wizard), 一起按卡片时刻排。
2043
+ var kids = (F.kids[t] || []).filter(function (k) { return shows(F, k); }).map(function (k) { return { t: k }; })
2044
+ .concat((F.refs[t] || []).filter(function (p) { return shows(F, p.to) && nodeOf(p.to); }).map(function (p) { return { t: p.to, e: p }; }))
2045
+ .sort(function (a, b) { return recentFirst(cardGlance(F, a.t, a.e), cardGlance(F, b.t, b.e)); });
1848
2046
  // 选中工单时: 两端都是它的当事人 (含开单者) 的那道线才亮。
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) +
2047
+ var jl = function (u, p) { return F.job ? (F.job.in[u] && p && F.job.in[p.from] ? ' jline' : ' jdim') : ''; };
2048
+ var zs = function (z) { return z ? ' style="z-index:' + z + '"' : ''; };
2049
+ return '<li class="' + domKind(F.pp[t]) + jl(t, F.pp[t]) + '"' + zs(z) + '>' + tnodeHTML(F, n) +
1851
2050
  // 兄弟的线共用一段竖干, 越往下的越长: 短的叠在上面 (z 随序号递减), 每条线的末段都看得见自己的颜色。
1852
2051
  (kids.length ? '<ul>' + kids.map(function (k, i) {
1853
- return treeHTML(F, k, depth + 1, shown, seen, kids.length - i);
2052
+ return k.e
2053
+ ? '<li class="' + domKind(k.e) + jl(k.t, k.e) + '"' + zs(kids.length - i) + '>' + tnodeHTML(F, nodeOf(k.t), k.e) + '</li>'
2054
+ : treeHTML(F, k.t, depth + 1, shown, seen, kids.length - i);
1854
2055
  }).join('') + '</ul>' : '') +
1855
2056
  '</li>';
1856
2057
  };
@@ -1858,7 +2059,23 @@
1858
2059
  // 只看相关时画谁: 视角自己 + 和视角有直接关系的 (任一种, 任一方向; 'rel'); 它们通向根的主父亲链上
1859
2060
  // 与视角无关的祖先留作连接 ('via', 画淡) —— 抹掉它, 有关系的后代就从家谱上脱开, 成了一棵来历不明的孤树。
1860
2061
  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]; });
2062
+ // 相关 = 沿关系边 (任一种, 任一方向) 一路走得到的整个连通块, 不止一跳: 间接、多跳的也展开。
2063
+ var nbr = Object.keys(F.pairs).reduce(function (m, k) {
2064
+ var p = F.pairs[k];
2065
+ (m[p.from] = m[p.from] || []).push(p.to);
2066
+ (m[p.to] = m[p.to] || []).push(p.from);
2067
+ return m;
2068
+ }, {});
2069
+ var reach = function (seen, frontier) {
2070
+ if (!frontier.length) return seen;
2071
+ var next = frontier.reduce(function (acc, t) {
2072
+ (nbr[t] || []).forEach(function (o) { if (!seen[o]) { seen[o] = 1; acc.push(o); } });
2073
+ return acc;
2074
+ }, []);
2075
+ return reach(seen, next);
2076
+ };
2077
+ var linked = reach((function (o) { o[ROLE] = 1; return o; })({}), [ROLE]);
2078
+ var keep = Object.keys(F.ends).filter(function (t) { return t === ROLE || linked[t]; });
1862
2079
  var vis = keep.reduce(function (m, t) { m[t] = 'rel'; return m; }, {});
1863
2080
  if (!vis[ROLE]) vis[ROLE] = 'rel';
1864
2081
  keep.forEach(function (t) { chainUp(F, t).forEach(function (u) { if (!vis[u]) vis[u] = 'via'; }); });
@@ -1879,10 +2096,10 @@
1879
2096
  var F = forestOf(relations(rg));
1880
2097
  F.job = jobOfConv();
1881
2098
  var me = nodeOf(ROLE);
1882
- var all = W.treeAll || !me;
2099
+ // 只画相关的 (visibleOf); 视角不是图上的节点 (没有关系可言) 才全画。
2100
+ var all = !me;
1883
2101
  var shown = [];
1884
2102
  var path = me ? chainUp(F, ROLE) : [];
1885
- // 和视角没有直接关系的只在「看全部」里画。
1886
2103
  F.vis = all ? null : visibleOf(F);
1887
2104
  var draw = function (into) {
1888
2105
  return F.roots.filter(function (r) { return shows(F, r); }).sort(function (a, b) {
@@ -1900,16 +2117,17 @@
1900
2117
  if (W.pickSelf === ROLE && W.role === ROLE && R.role && R.role.id === ROLE) {
1901
2118
  W.pickSelf = '';
1902
2119
  var self = talkKey(F.links, ROLE);
1903
- if (CONV !== self) return selectConv(self, '');
2120
+ // refresh 正等着它定窗口 (W.pickHold): 只改窗口, 正文由 refresh 取一次 —— 不再先取全部对话、再收窄重取。
2121
+ if (CONV !== self && W.pickHold) { CONV = self; WITH = ''; }
2122
+ else if (CONV !== self) return selectConv(self, '');
1904
2123
  }
1905
2124
  var trees = draw([]);
1906
- var span = rg ? (rg.s === R.sessions[R.sessions.length - 1] ? '最新 session' : 'session ' + fmtClock(rg.from)) : '全部时间';
2125
+ 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
2126
  var alone = !all && shown.length < 2;
1908
2127
  var html = '<div class="tview">' +
1909
- '<h2>关系<span title="在名片里的 session 下拉切换范围">' + esc(span) + ' · ' + (all ? Object.keys(F.ends).length : shown.length) + ' 个</span>' +
2128
+ '<h2>关系<span title="在名片里的 session 下拉切换范围">' + (span ? esc(span) + ' · ' : '') + (all ? Object.keys(F.ends).length : shown.length) + ' 个</span>' +
1910
2129
  (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>' +
2130
+ winSelect() + worldToggle() + '</h2>' +
1913
2131
  (alone ? '<div class="tsolo">' + esc(nameOf(ROLE)) + (rg ? ' 在这段 session 里' : '') + ' 没和谁有关系</div>' : '') +
1914
2132
  (shown.length ? '<ul class="tree' + (all ? ' all' : '') + (F.job ? ' jsel' : '') + '">' + trees + '</ul>' : '<div class="pempty">这段时间里没有任何关系</div>') +
1915
2133
  '</div>';
@@ -1918,18 +2136,11 @@
1918
2136
  convsEl._tree = html; convsEl._html = '';
1919
2137
  convsEl.innerHTML = html;
1920
2138
  convsEl.querySelectorAll('.tci').forEach(function (el) {
1921
- el.onclick = function () { selectConv(talkKey(F.links, el.getAttribute('data-t')), ''); };
2139
+ el.onclick = function () { var t = el.getAttribute('data-t'); clickItem(talkKey(F.links, t), '', t); };
1922
2140
  });
1923
2141
  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');
2142
+ bindWorldToggle(convsEl); bindWin(convsEl);
2143
+ var cur = convsEl.querySelector('.tci.me:not(.ref)');
1933
2144
  if (cur && W.treeFor !== ROLE) { W.treeFor = ROLE; cur.scrollIntoView({ block: 'nearest' }); }
1934
2145
  };
1935
2146
 
@@ -2115,7 +2326,7 @@
2115
2326
  // 手机上关系/日程占满主区 —— 进入即阅读态。
2116
2327
  if (v !== 'msgs') app.classList.add('reading');
2117
2328
  renderRole(); renderHead(); renderUsage();
2118
- if (v === 'msgs') { toBottom(true); renderConvs(); }
2329
+ if (v === 'msgs') { toBottom(true); renderConvs(); seeAll(); }
2119
2330
  else if (v === 'charter') loadCharter();
2120
2331
  else if (v === 'jobs') renderJobList();
2121
2332
  else {
@@ -2149,10 +2360,14 @@
2149
2360
  var renderJobList = function () {
2150
2361
  var el = jobsPane().firstChild;
2151
2362
  var js = jobListRows();
2363
+ // 视角里没有的祖先也照账补上, 树才连得起来 (JOB_ONLY 是点名的几张, 不补)。
2364
+ if (!JOB_ONLY) js = js.concat(js.reduce(function (a, c) {
2365
+ return a.concat(jobAncestors(c.job.id).filter(function (id) { return !js.some(function (x) { return x.job.id === id; }); }));
2366
+ }, []).filter(function (id, i, a) { return a.indexOf(id) === i; }).map(jobRowOf).filter(function (c) { return c.job; }));
2152
2367
  var open = js.filter(function (c) { return c.job.status === 'open'; }).sort(recentFirst);
2153
2368
  var closed = js.filter(function (c) { return c.job.status !== 'open'; }).sort(recentFirst);
2154
2369
  var sec = function (title, list, cls) {
2155
- return '<h2>' + title + '<span>' + list.length + '</span></h2>' + '<div class="' + cls + '">' + list.map(convItem).join('') + '</div>';
2370
+ 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
2371
  };
2157
2372
  var html = js.length
2158
2373
  ? sec('进行中', open, 'jopen') + (closed.length ? sec('已收工', closed, 'jclosed') : '')
@@ -2212,9 +2427,10 @@
2212
2427
  all().forEach(function (x) { x.ontoggle = sync; });
2213
2428
  };
2214
2429
 
2215
- // 侧栏换成关系图 / 换回会话列表。换回时选中的仍是在关系图里点开的那一项, 并把它滚进视野。
2430
+ // 侧栏换成关系图 / 换回聊天。换回时选中的仍是在关系图里点开的那一项, 并把它滚进视野。
2216
2431
  var setWorld = function (on) {
2217
2432
  WORLD = on; W.treeFor = ''; W.pickSelf = '';
2433
+ setPref(WORLD_KEY, on);
2218
2434
  convsEl._html = convsEl._tree = '';
2219
2435
  syncUrl();
2220
2436
  // 手机上侧栏与主区二选一 —— 开关在侧栏里, 结果也在侧栏里。
@@ -2225,9 +2441,9 @@
2225
2441
  // 选中的子项得露出来: 群展开, 排在折叠条后面的连折叠条一起展开。
2226
2442
  var c = convOf(CONV);
2227
2443
  if (c) {
2228
- OPEN[CONV] = true; OPEN_AT = CONV;
2444
+ OPEN[CONV] = true; OPEN_AT = CONV; saveOpen();
2229
2445
  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;
2446
+ if (rank >= SUB_FOLD) setMore(CONV, true);
2231
2447
  }
2232
2448
  renderConvs();
2233
2449
  var sel = convsEl.querySelector('.si.on') || convsEl.querySelector('.ci.on');
@@ -2335,20 +2551,32 @@
2335
2551
  row.classList.remove('hit'); void row.offsetWidth; row.classList.add('hit');
2336
2552
  return true;
2337
2553
  };
2338
- var landOn = function (id) {
2554
+ // 落到一行里的某一次工具调用 (`use`): 它所在的过程框摊开, 调用框滚到中间闪一下。
2555
+ var focusUse = function (id, use) {
2556
+ if (!focusRow(id)) return false;
2557
+ var b = use && rowNode(id).querySelector('[data-use="' + cssEsc(use) + '"]');
2558
+ if (!b) return true;
2559
+ var g = b.closest('.steps');
2560
+ if (g) { g.classList.remove('folded'); FOLD[g.getAttribute('data-key')] = false; }
2561
+ b.scrollIntoView({ block: 'center' });
2562
+ S.pinned = atBottom();
2563
+ b.classList.remove('hit'); void b.offsetWidth; b.classList.add('hit');
2564
+ return true;
2565
+ };
2566
+ var landOn = function (id, use) {
2339
2567
  return function () {
2340
- if (focusRow(id)) return true;
2341
- loadMsgs('0', function () { return focusRow(id); });
2568
+ if (focusUse(id, use)) return true;
2569
+ loadMsgs('0', function () { return focusUse(id, use); });
2342
2570
  return true;
2343
2571
  };
2344
2572
  };
2345
2573
  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 : '';
2574
+ 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);
2575
+ SESSION = 'all'; CONV = h.conv; WITH = h.with || ''; VIEWPOINT = h.viewpoint && h.viewpoint !== ROLE ? h.viewpoint : '';
2348
2576
  if (VIEW !== 'msgs') setView('msgs');
2349
2577
  app.classList.add('reading');
2350
2578
  reveal();
2351
- refresh(landOn(h.id));
2579
+ refresh(h.id ? landOn(h.id, h.use) : undefined);
2352
2580
  };
2353
2581
 
2354
2582
  var paintSearch = function (sections) {
@@ -2448,5 +2676,7 @@
2448
2676
  // ── boot ──
2449
2677
  // 带着 role / conv 来的链接直接进阅读态 (手机上不先落在会话列表)。
2450
2678
  if ((ROLE || CONV) && !WORLD) app.classList.add('reading');
2679
+ // 链接已带着 role 与会话: 记下的 session 先放进第一次请求, 省一次重取 (不在列表里由 restoreSess 收拾)。
2680
+ if (SESS_RESTORE && ROLE && CONV && savedSess()) SESSION = savedSess();
2451
2681
  refresh();
2452
2682
  })();