botmux 2.71.4 → 2.72.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 (89) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +8 -93
  3. package/dist/cli.js.map +1 -1
  4. package/dist/core/ask-api.d.ts +2 -20
  5. package/dist/core/ask-api.d.ts.map +1 -1
  6. package/dist/core/ask-api.js +2 -25
  7. package/dist/core/ask-api.js.map +1 -1
  8. package/dist/core/ask-args.d.ts +3 -3
  9. package/dist/core/ask-args.js +3 -3
  10. package/dist/core/ask-broker.d.ts +3 -1
  11. package/dist/core/ask-broker.d.ts.map +1 -1
  12. package/dist/core/ask-broker.js +20 -5
  13. package/dist/core/ask-broker.js.map +1 -1
  14. package/dist/core/ask-types.d.ts +3 -8
  15. package/dist/core/ask-types.d.ts.map +1 -1
  16. package/dist/core/ask-types.js.map +1 -1
  17. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  18. package/dist/core/dashboard-ipc-server.js +134 -2
  19. package/dist/core/dashboard-ipc-server.js.map +1 -1
  20. package/dist/core/dashboard-rows.d.ts +4 -0
  21. package/dist/core/dashboard-rows.d.ts.map +1 -1
  22. package/dist/core/dashboard-rows.js +4 -0
  23. package/dist/core/dashboard-rows.js.map +1 -1
  24. package/dist/core/session-board.d.ts +9 -0
  25. package/dist/core/session-board.d.ts.map +1 -0
  26. package/dist/core/session-board.js +24 -0
  27. package/dist/core/session-board.js.map +1 -0
  28. package/dist/daemon.d.ts.map +1 -1
  29. package/dist/daemon.js +14 -35
  30. package/dist/daemon.js.map +1 -1
  31. package/dist/dashboard/federated-group-core.d.ts.map +1 -1
  32. package/dist/dashboard/federated-group-core.js +5 -0
  33. package/dist/dashboard/federated-group-core.js.map +1 -1
  34. package/dist/dashboard/federation-api.d.ts.map +1 -1
  35. package/dist/dashboard/federation-api.js +70 -1
  36. package/dist/dashboard/federation-api.js.map +1 -1
  37. package/dist/dashboard/federation-spoke-api.d.ts +16 -2
  38. package/dist/dashboard/federation-spoke-api.d.ts.map +1 -1
  39. package/dist/dashboard/federation-spoke-api.js +96 -3
  40. package/dist/dashboard/federation-spoke-api.js.map +1 -1
  41. package/dist/dashboard/web/app.d.ts.map +1 -1
  42. package/dist/dashboard/web/app.js +52 -0
  43. package/dist/dashboard/web/app.js.map +1 -1
  44. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  45. package/dist/dashboard/web/i18n.js +64 -0
  46. package/dist/dashboard/web/i18n.js.map +1 -1
  47. package/dist/dashboard/web/kanban-model.d.ts +19 -0
  48. package/dist/dashboard/web/kanban-model.d.ts.map +1 -0
  49. package/dist/dashboard/web/kanban-model.js +40 -0
  50. package/dist/dashboard/web/kanban-model.js.map +1 -0
  51. package/dist/dashboard/web/preferences.d.ts +12 -1
  52. package/dist/dashboard/web/preferences.d.ts.map +1 -1
  53. package/dist/dashboard/web/preferences.js +32 -1
  54. package/dist/dashboard/web/preferences.js.map +1 -1
  55. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  56. package/dist/dashboard/web/sessions.js +1059 -10
  57. package/dist/dashboard/web/sessions.js.map +1 -1
  58. package/dist/dashboard-web/app.js +519 -433
  59. package/dist/dashboard-web/index.html +3 -0
  60. package/dist/dashboard-web/style.css +692 -1
  61. package/dist/dashboard.js +72 -3
  62. package/dist/dashboard.js.map +1 -1
  63. package/dist/i18n/en.d.ts.map +1 -1
  64. package/dist/i18n/en.js +1 -0
  65. package/dist/i18n/en.js.map +1 -1
  66. package/dist/i18n/zh.d.ts.map +1 -1
  67. package/dist/i18n/zh.js +1 -0
  68. package/dist/i18n/zh.js.map +1 -1
  69. package/dist/im/lark/ask-card.js +4 -7
  70. package/dist/im/lark/ask-card.js.map +1 -1
  71. package/dist/im/lark/client.d.ts +8 -0
  72. package/dist/im/lark/client.d.ts.map +1 -1
  73. package/dist/im/lark/client.js +32 -0
  74. package/dist/im/lark/client.js.map +1 -1
  75. package/dist/services/team-board-store.d.ts +33 -0
  76. package/dist/services/team-board-store.d.ts.map +1 -0
  77. package/dist/services/team-board-store.js +88 -0
  78. package/dist/services/team-board-store.js.map +1 -0
  79. package/dist/services/team-groups-store.d.ts +8 -0
  80. package/dist/services/team-groups-store.d.ts.map +1 -0
  81. package/dist/services/team-groups-store.js +31 -0
  82. package/dist/services/team-groups-store.js.map +1 -0
  83. package/dist/setup/bot-config-editor.d.ts +3 -2
  84. package/dist/setup/bot-config-editor.d.ts.map +1 -1
  85. package/dist/setup/bot-config-editor.js +1 -2
  86. package/dist/setup/bot-config-editor.js.map +1 -1
  87. package/dist/types.d.ts +5 -0
  88. package/dist/types.d.ts.map +1 -1
  89. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
1
  // Sessions page: filter bar, status board/table, detail drawer with locate/resume/close.
2
- import { readStoredBoardOrder, readStoredSessionsViewMode, writeStoredBoardOrder, writeStoredSessionsViewMode, } from './preferences.js';
2
+ import { KANBAN_TEAM_STORAGE_KEY, normalizeSessionsViewMode, readStoredBoardOrder, readStoredKanbanGroupBy, readStoredSessionsViewMode, writeStoredBoardOrder, writeStoredKanbanGroupBy, writeStoredSessionsViewMode, } from './preferences.js';
3
+ import { computeDropPosition, deriveKanbanColumn, effectiveKanbanPosition, } from './kanban-model.js';
3
4
  import { store } from './store.js';
4
- import { botDisplayName, botAvatarHtml, chatDisplayTitle, attentionWaitSince, escapeHtml, loadNameMaps, relTime, stripMentionPrefix, t, ui, } from './ui.js';
5
+ import { botDisplayName, botAvatarHtml, chatAvatarHtml, chatDisplayTitle, attentionWaitSince, escapeHtml, loadNameMaps, relTime, stripMentionPrefix, t, ui, } from './ui.js';
5
6
  function th(sort, label) {
6
7
  return `<th data-sort="${sort}" data-label="${escapeHtml(label)}">${escapeHtml(label)}</th>`;
7
8
  }
@@ -36,6 +37,37 @@ const BOARD_COLUMNS = [
36
37
  { id: 'working', labelKey: 'sessions.board.working', hintKey: 'sessions.board.workingHint' },
37
38
  { id: 'idle', labelKey: 'sessions.board.idle', hintKey: 'sessions.board.idleHint' },
38
39
  ];
40
+ // ── 看板视图 ──────────────────────────────────────────────────────────────────
41
+ // 五列手动工作流(待办池/待办/进行中/待确认/已完成):卡片可拖拽换列与列内排序,
42
+ // 放置持久化在 Session 上(daemon /board 端点);未手动放置的会话按运行状态
43
+ // 推导默认列(kanban-model.ts),已关闭会话固定落「已完成」。
44
+ const KANBAN_COLUMNS = [
45
+ { id: 'backlog', labelKey: 'sessions.kanban.backlog' },
46
+ { id: 'todo', labelKey: 'sessions.kanban.todo' },
47
+ { id: 'in_progress', labelKey: 'sessions.kanban.inProgress' },
48
+ { id: 'in_review', labelKey: 'sessions.kanban.inReview' },
49
+ { id: 'done', labelKey: 'sessions.kanban.done' },
50
+ ];
51
+ // 「已完成」列收纳所有已关闭会话,可能积累上千条——只展示最前的一截,剩余计数提示。
52
+ const KANBAN_DONE_CAP = 50;
53
+ // 列状态图标:14x14 SVG,圆环 + 不同填充度的扇形/对勾表达工作流进度,
54
+ // 颜色由列容器的 currentColor 决定(CSS 里按列着色)。
55
+ function kanbanStatusIcon(id) {
56
+ const ring = (extra = '') => `<svg viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.4" fill="none" stroke="currentColor" stroke-width="1.6"${extra}/>`;
57
+ switch (id) {
58
+ case 'backlog': // 虚线圆环
59
+ return `${ring(' stroke-dasharray="1.6 2.1"')}</svg>`;
60
+ case 'in_progress': // 半圆填充
61
+ return `${ring()}<path d="M7,7 L7,3.6 A3.4,3.4 0 0 1 7,10.4 Z" fill="currentColor"/></svg>`;
62
+ case 'in_review': // 3/4 填充
63
+ return `${ring()}<path d="M7,7 L7,3.6 A3.4,3.4 0 1 1 3.6,7 Z" fill="currentColor"/></svg>`;
64
+ case 'done': // 实心圆 + 对勾
65
+ return `<svg viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="6.2" fill="currentColor"/><path d="M4.4 7.2 6.2 9 9.7 5.4" fill="none" stroke="var(--surface)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
66
+ case 'todo': // 空心圆环
67
+ default:
68
+ return `${ring()}</svg>`;
69
+ }
70
+ }
39
71
  function cssToken(value) {
40
72
  return String(value ?? 'unknown').toLowerCase().replace(/[^a-z0-9_-]/g, '-');
41
73
  }
@@ -65,6 +97,11 @@ const ICON = {
65
97
  terminal: '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="1.7" y="2.7" width="12.6" height="10.6" rx="2"/><path d="M4.4 6.3 6.4 8.1 4.4 9.9"/><path d="M8.2 10.2h3.4"/></svg>',
66
98
  key: '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="6.1" r="3"/><path d="M8.1 8.2 13 13.1"/><path d="M11.3 11.4 12.6 10.1"/><path d="M12.7 12.8 13.7 11.8"/></svg>',
67
99
  close: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.2 4.2 11.8 11.8"/><path d="M11.8 4.2 4.2 11.8"/></svg>',
100
+ edit: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.7 3.3 12.7 5.3 6.3 11.7 3.7 12.3 4.3 9.7 10.7 3.3z"/></svg>',
101
+ history: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.2 4.8a2 2 0 0 1 2-2h7.6a2 2 0 0 1 2 2v4.6a2 2 0 0 1-2 2H6.6l-2.9 2.4v-2.4h-.5a2 2 0 0 1-2-2z"/><path d="M5.2 6.2h5.6M5.2 8.4h3.6"/></svg>',
102
+ // 飞书:两片交叠的羽毛向右上展翅(还原 Lark 彩色 logo 的飞鸟造型),单色
103
+ // stroke:currentColor 适配本组线性图标,圆角端点呼应原 logo 的圆润羽尖。
104
+ feishu: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.6 4.4C6.4 4 10.4 5.4 13.4 8.2 9.8 7 6.4 6.6 3.4 7.4"/><path d="M13.4 8.2C9.6 8.7 6 10 2.9 12 5.6 9 8.8 7.6 13.4 8.2"/></svg>',
68
105
  };
69
106
  /** Compact icon action button for the card bar. `kind` adds a tint variant. */
70
107
  function cardActBtn(action, icon, label, kind = '') {
@@ -151,9 +188,19 @@ function pageHtml() {
151
188
  <h1>${t('sessions.title')}</h1>
152
189
  <p>${t('sessions.subtitle')}</p>
153
190
  </div>
154
- <div class="segmented sessions-view-toggle" role="group" aria-label="${t('sessions.viewMode')}">
155
- <button type="button" data-view="board">${t('sessions.viewBoard')}</button>
156
- <button type="button" data-view="table">${t('sessions.viewTable')}</button>
191
+ <div class="sessions-view-controls">
192
+ <span id="kanban-team-stats" class="kanban-team-stats" hidden></span>
193
+ <select id="kanban-team" class="kanban-team-select" aria-label="${t('sessions.kanban.groupTeam')}" hidden></select>
194
+ <div class="segmented kanban-groupby" id="kanban-groupby" role="group" aria-label="${t('sessions.kanban.groupBy')}" hidden>
195
+ <button type="button" data-groupby="flow">${t('sessions.kanban.groupFlow')}</button>
196
+ <button type="button" data-groupby="team">${t('sessions.kanban.groupTeam')}</button>
197
+ <button type="button" data-groupby="bot">${t('sessions.kanban.groupBot')}</button>
198
+ </div>
199
+ <div class="segmented sessions-view-toggle" role="group" aria-label="${t('sessions.viewMode')}">
200
+ <button type="button" data-view="kanban">${t('sessions.viewKanban')}</button>
201
+ <button type="button" data-view="board">${t('sessions.viewBoard')}</button>
202
+ <button type="button" data-view="table">${t('sessions.viewTable')}</button>
203
+ </div>
157
204
  </div>
158
205
  </div>
159
206
  <form id="filters" class="filters sessions-filters">
@@ -194,7 +241,10 @@ function pageHtml() {
194
241
  <tbody></tbody>
195
242
  </table>
196
243
  <div id="sessions-board" class="sessions-board" hidden></div>
244
+ <div id="sessions-kanban" class="sessions-kanban" hidden></div>
197
245
  <dialog id="drawer"></dialog>
246
+ <dialog id="term-modal" class="term-modal"></dialog>
247
+ <dialog id="history-modal" class="history-modal"></dialog>
198
248
  </section>`;
199
249
  }
200
250
  export function renderSessionsPage(root) {
@@ -209,7 +259,13 @@ export function renderSessionsPage(root) {
209
259
  const bulkClearBtn = root.querySelector('#bulk-clear');
210
260
  const table = root.querySelector('#sessions-table');
211
261
  const board = root.querySelector('#sessions-board');
212
- const viewButtons = root.querySelectorAll('[data-view]');
262
+ const kanban = root.querySelector('#sessions-kanban');
263
+ const termModal = root.querySelector('#term-modal');
264
+ const historyModal = root.querySelector('#history-modal');
265
+ const groupByBox = root.querySelector('#kanban-groupby');
266
+ const teamSelect = root.querySelector('#kanban-team');
267
+ const teamStats = root.querySelector('#kanban-team-stats');
268
+ const viewButtons = root.querySelectorAll('.sessions-view-toggle [data-view]');
213
269
  const selected = new Set();
214
270
  let sortKey = 'lastMessageAt';
215
271
  let sortDir = 'desc';
@@ -221,7 +277,208 @@ export function renderSessionsPage(root) {
221
277
  // 入场动画是否已播过(只在首次渲染播一轮)。
222
278
  let lastBoardHtml = '';
223
279
  let lastTableHtml = '';
280
+ let lastKanbanHtml = '';
224
281
  let boardAnimated = false;
282
+ // 看板交互态:拖拽中的卡片 id / 标题就地编辑中 —— 两者期间都跳过看板重绘,
283
+ // 否则 SSE 触发的 innerHTML 重建会把拖拽源/输入框拍没。
284
+ let kanbanDragId = null;
285
+ let kanbanEditing = false;
286
+ // 单击开终端 vs 双击改标题的仲裁:单击延迟 220ms 执行,双击先到就取消。
287
+ let kanbanOpenTimer = null;
288
+ // 上次渲染时每列的有序行(聚簇后的视觉平铺顺序)—— drop 落点据此找相邻卡片
289
+ // 算持久化位置。
290
+ let lastKanbanGroups = new Map();
291
+ // 看板分组维度:flow=工作流五列(可拖拽);team=选定团队的工作流看板(含
292
+ // 团队内所有 bot 的会话,可拖拽);bot=机器人视角列(只读总览)
293
+ let kanbanGroupBy = readStoredKanbanGroupBy(window.localStorage);
294
+ // 整簇拖拽:拖群组容器头部时记录 (chatId, 源列),drop 时整组搬运
295
+ let kanbanDragClusterChat = null;
296
+ let kanbanDragClusterCol = null;
297
+ // 团队清单(groupBy='team' 首次激活时懒加载:本地托管团队 + 远程 roster)。
298
+ // botNames 用来与 /introduce 记录的外部 bot 按名字匹配(introduce 只留
299
+ // openId+name,而 open_id 是 app-scoped 的,名字是两边唯一的公共标识)。
300
+ let kanbanTeams = [];
301
+ // 群 → { 在场自家 bot 集合, introduce 过的外部 bot 名字集合 }(/api/groups)。
302
+ let kanbanChatBots = null;
303
+ let kanbanTeamsLoaded = false;
304
+ let kanbanTeamsLoading = false;
305
+ let kanbanTeamKey = (() => {
306
+ try {
307
+ return window.localStorage.getItem(KANBAN_TEAM_STORAGE_KEY) ?? '';
308
+ }
309
+ catch {
310
+ return '';
311
+ }
312
+ })();
313
+ async function loadKanbanTeams() {
314
+ if (kanbanTeamsLoading || kanbanTeamsLoaded)
315
+ return;
316
+ kanbanTeamsLoading = true;
317
+ try {
318
+ const [hosted, remote, groups] = await Promise.all([
319
+ fetch('/api/team/hosted').then(r => r.json()).catch(() => null),
320
+ fetch('/api/team/remote-roster').then(r => r.json()).catch(() => null),
321
+ fetch('/api/groups').then(r => r.json()).catch(() => null),
322
+ ]);
323
+ if (Array.isArray(groups?.chats)) {
324
+ kanbanChatBots = new Map(groups.chats.map((c) => [
325
+ String(c.chatId),
326
+ {
327
+ botIds: new Set((c.memberBots ?? []).filter((mb) => mb.inChat).map((mb) => String(mb.larkAppId))),
328
+ observedNames: new Set((c.observedBotNames ?? []).map((n) => String(n))),
329
+ },
330
+ ]));
331
+ }
332
+ const rosterBots = (bots) => ({
333
+ ids: new Set(bots.map((b) => String(b.larkAppId))),
334
+ names: new Set(bots.map((b) => String(b.name ?? '')).filter(Boolean)),
335
+ });
336
+ const teams = [];
337
+ for (const tm of hosted?.teams ?? []) {
338
+ const { ids, names } = rosterBots(tm.bots ?? []);
339
+ teams.push({
340
+ key: `local:${tm.teamId}`,
341
+ label: tm.isDefault ? t('team.myHostedTeam') : String(tm.name ?? tm.teamId),
342
+ botIds: ids,
343
+ botNames: names,
344
+ groupChats: new Set((tm.groupChatIds ?? []).map((c) => String(c))),
345
+ });
346
+ }
347
+ for (const m of remote?.memberships ?? []) {
348
+ const { ids, names } = rosterBots(m.roster?.bots ?? []);
349
+ teams.push({
350
+ key: `${m.hubUrl}::${m.teamId}`,
351
+ label: String(m.teamName ?? m.teamId ?? m.hubUrl),
352
+ botIds: ids,
353
+ botNames: names,
354
+ // 远程团队发起的协作群绑定记录在 hub 侧,spoke 暂取不到
355
+ groupChats: new Set(),
356
+ });
357
+ }
358
+ kanbanTeams = teams;
359
+ }
360
+ finally {
361
+ kanbanTeamsLoaded = true;
362
+ kanbanTeamsLoading = false;
363
+ }
364
+ if (kanbanTeams.length && !kanbanTeams.some(tm => tm.key === kanbanTeamKey)) {
365
+ kanbanTeamKey = kanbanTeams[0].key;
366
+ }
367
+ delete teamSelect.dataset.loading;
368
+ teamSelect.disabled = kanbanTeams.length === 0;
369
+ teamSelect.innerHTML = kanbanTeams.length
370
+ ? kanbanTeams.map(tm => `<option value="${escapeHtml(tm.key)}"${tm.key === kanbanTeamKey ? ' selected' : ''}>${escapeHtml(tm.label)}</option>`).join('')
371
+ : `<option value="">${escapeHtml(t('sessions.kanban.noTeam'))}</option>`;
372
+ lastKanbanHtml = '';
373
+ rerender();
374
+ }
375
+ // ── hub 团队看板(共享编排 + 对方部署会话快照)────────────────────────────
376
+ // 编排存团队 host:托管团队读本地 /api/team/board/local/<id>,加入的远程团队
377
+ // 经 spoke 代理 /api/team/remote-board 到 hub。30s 软刷新。
378
+ let kanbanTeamBoardData = null;
379
+ let kanbanTeamBoardKey = '';
380
+ let kanbanTeamBoardFetchedAt = 0;
381
+ let kanbanTeamBoardLoading = false;
382
+ // 对方部署的行不在 store 里——拖拽落点查这里
383
+ let kanbanRemoteRows = new Map();
384
+ async function ensureTeamBoard(team) {
385
+ const fresh = kanbanTeamBoardKey === team.key && Date.now() - kanbanTeamBoardFetchedAt < 30_000;
386
+ if (kanbanTeamBoardLoading || fresh)
387
+ return;
388
+ kanbanTeamBoardLoading = true;
389
+ try {
390
+ const isLocal = team.key.startsWith('local:');
391
+ const u = isLocal
392
+ ? `/api/team/board/local/${encodeURIComponent(team.key.slice('local:'.length))}`
393
+ : `/api/team/remote-board?key=${encodeURIComponent(team.key)}`;
394
+ const r = await fetch(u);
395
+ const body = await r.json().catch(() => ({}));
396
+ if (!r.ok || body?.ok === false)
397
+ return;
398
+ const myDeploymentId = typeof body.deploymentId === 'string' ? body.deploymentId : null;
399
+ const remoteRows = [];
400
+ kanbanRemoteRows = new Map();
401
+ for (const rep of Array.isArray(body.reports) ? body.reports : []) {
402
+ // 远程团队的响应里含自己部署的上报——本地行走实时 store,跳过
403
+ if (myDeploymentId && rep.deploymentId === myDeploymentId)
404
+ continue;
405
+ for (const s of Array.isArray(rep.sessions) ? rep.sessions : []) {
406
+ const row = { ...s, remoteDeployment: rep.deploymentName || rep.deploymentId };
407
+ remoteRows.push(row);
408
+ kanbanRemoteRows.set(String(s.sessionId), row);
409
+ }
410
+ }
411
+ kanbanTeamBoardData = {
412
+ board: body.board && typeof body.board === 'object' ? body.board : {},
413
+ remoteRows,
414
+ };
415
+ kanbanTeamBoardKey = team.key;
416
+ kanbanTeamBoardFetchedAt = Date.now();
417
+ lastKanbanHtml = '';
418
+ rerender();
419
+ }
420
+ catch {
421
+ // 拉不到 hub 看板时退化为只看本地行
422
+ }
423
+ finally {
424
+ kanbanTeamBoardLoading = false;
425
+ }
426
+ }
427
+ /** 团队看板拖拽落盘:写 host 的共享编排(不动会话的个人看板字段)。 */
428
+ async function persistTeamBoardMove(teamKey, sessionId, column, position, prevEntry) {
429
+ try {
430
+ const isLocal = teamKey.startsWith('local:');
431
+ const r = isLocal
432
+ ? await fetch(`/api/team/board/local/${encodeURIComponent(teamKey.slice('local:'.length))}/move`, {
433
+ method: 'POST',
434
+ headers: { 'content-type': 'application/json' },
435
+ body: JSON.stringify({ sessionId, column, position }),
436
+ })
437
+ : await fetch('/api/team/remote-board-move', {
438
+ method: 'POST',
439
+ headers: { 'content-type': 'application/json' },
440
+ body: JSON.stringify({ key: teamKey, sessionId, column, position }),
441
+ });
442
+ const body = await r.json().catch(() => ({}));
443
+ if (!r.ok || body?.ok === false) {
444
+ if (kanbanTeamBoardData) {
445
+ if (prevEntry)
446
+ kanbanTeamBoardData.board[sessionId] = prevEntry;
447
+ else
448
+ delete kanbanTeamBoardData.board[sessionId];
449
+ }
450
+ lastKanbanHtml = '';
451
+ rerender();
452
+ if (r.status !== 401)
453
+ alert(`${t('sessions.kanban.moveFail')}: ${body?.error ?? r.status}`);
454
+ }
455
+ }
456
+ catch (e) {
457
+ if (kanbanTeamBoardData) {
458
+ if (prevEntry)
459
+ kanbanTeamBoardData.board[sessionId] = prevEntry;
460
+ else
461
+ delete kanbanTeamBoardData.board[sessionId];
462
+ }
463
+ lastKanbanHtml = '';
464
+ rerender();
465
+ alert(`${t('sessions.kanban.moveFail')}: ${e}`);
466
+ }
467
+ }
468
+ /** 团队模式拖拽的统一落点:乐观写本地缓存的共享编排 + POST host。 */
469
+ function applyTeamBoardMove(sessionId, column, position) {
470
+ const team = kanbanTeams.find(tm => tm.key === kanbanTeamKey) ?? kanbanTeams[0];
471
+ if (!team)
472
+ return;
473
+ if (!kanbanTeamBoardData || kanbanTeamBoardKey !== team.key) {
474
+ // 首次拉取尚未完成也允许拖:先建本地空编排缓存,写入照常进行
475
+ kanbanTeamBoardData = { board: {}, remoteRows: kanbanTeamBoardData?.remoteRows ?? [] };
476
+ kanbanTeamBoardKey = team.key;
477
+ }
478
+ const prev = kanbanTeamBoardData.board[sessionId];
479
+ kanbanTeamBoardData.board[sessionId] = { column, position };
480
+ void persistTeamBoardMove(team.key, sessionId, column, position, prev);
481
+ }
225
482
  function orderedBoardColumns() {
226
483
  return boardOrder
227
484
  .map(id => BOARD_COLUMNS.find(c => c.id === id))
@@ -277,7 +534,7 @@ export function renderSessionsPage(root) {
277
534
  if (s.scope !== 'chat' || !s.feishuChatLink)
278
535
  return null;
279
536
  const label = t('sessions.openChat');
280
- return `<a class="card-act" href="${escapeHtml(s.feishuChatLink)}" target="_blank" rel="noopener" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}">${ICON.openChat}</a>`;
537
+ return `<a class="card-act" href="${escapeHtml(s.feishuChatLink)}" target="_blank" rel="noopener" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}">${ICON.feishu}</a>`;
281
538
  }
282
539
  function boardSignalLabel(s) {
283
540
  // Agent-raised reason is the most informative — show it verbatim so the
@@ -380,6 +637,542 @@ export function renderSessionsPage(root) {
380
637
  board.classList.toggle('board-enter', !boardAnimated);
381
638
  boardAnimated = true;
382
639
  }
640
+ // ── 看板视图卡片 ─────────────────────────────────────────────────────────
641
+ // 卡片整体即点击目标:单击开页面内终端弹窗;铅笔改标题;「详情」进抽屉;
642
+ // 整卡可拖拽换列/排序。
643
+ function kanbanCardHtml(s) {
644
+ const title = stripMentionPrefix(s.title) || s.sessionId;
645
+ const botName = botDisplayName(s);
646
+ const chatTitle = chatDisplayTitle(s);
647
+ const repo = repoBasename(s.workingDir);
648
+ const signal = boardSignalLabel(s);
649
+ const desc = [chatTitle, repo !== '-' ? repo : null].filter(Boolean).join(' · ');
650
+ const status = String(s.status ?? 'unknown');
651
+ // 对方部署的会话:数据是 host 快照,终端/历史/改名都在对方机器上做不了——
652
+ // 只保留状态点与部署来源徽章,卡片仍可拖(团队共享编排)。
653
+ const remote = typeof s.remoteDeployment === 'string' ? s.remoteDeployment : '';
654
+ return `<article class="kanban-card${remote ? ' kanban-card-remote' : ''}" data-id="${escapeHtml(s.sessionId)}" tabindex="0" role="button" draggable="true">
655
+ <div class="kanban-card-top">
656
+ <span class="badge cli-${cssToken(s.cliId)}">${escapeHtml(s.cliId ?? 'unknown')}</span>
657
+ ${s.adopt ? '<span class="badge">adopt</span>' : ''}
658
+ ${remote ? `<span class="badge kanban-remote-badge" title="${escapeHtml(t('sessions.kanban.remoteHint', { name: remote }))}">${escapeHtml(remote)}</span>` : ''}
659
+ <span class="kanban-card-top-right">
660
+ <span class="kanban-card-dot" data-status="${escapeHtml(status)}" title="${escapeHtml(status)}"></span>
661
+ ${remote ? '' : `<button type="button" class="card-act kanban-card-act" data-action="history" title="${escapeHtml(t('sessions.history.title'))}" aria-label="${escapeHtml(t('sessions.history.title'))}">${ICON.history}</button>
662
+ ${s.feishuChatLink ? `<a class="card-act kanban-card-act" href="${escapeHtml(s.feishuChatLink)}" target="_blank" rel="noopener" title="${escapeHtml(t('sessions.kanban.openFeishu'))}" aria-label="${escapeHtml(t('sessions.kanban.openFeishu'))}">${ICON.feishu}</a>` : ''}
663
+ <button type="button" class="card-act kanban-card-act" data-action="details" title="${escapeHtml(t('sessions.details'))}" aria-label="${escapeHtml(t('sessions.details'))}">${ICON.details}</button>`}
664
+ </span>
665
+ </div>
666
+ <p class="kanban-card-title" title="${escapeHtml(String(s.title ?? title))}">${escapeHtml(String(title).slice(0, 140))}</p>
667
+ ${desc ? `<p class="kanban-card-desc" title="${escapeHtml(desc)}">${escapeHtml(desc)}</p>` : ''}
668
+ ${signal ? `<div class="session-signal" title="${escapeHtml(signal)}">${escapeHtml(signal)}</div>` : ''}
669
+ <div class="kanban-card-foot">
670
+ <span class="kanban-card-owner">${botAvatarHtml({ name: botName, larkAppId: s.larkAppId, size: 'sm' })}<span>${escapeHtml(botName)}</span></span>
671
+ <span class="kanban-card-updated">${escapeHtml(t('sessions.kanban.updated', { time: relTime(s.lastMessageAt) }))}</span>
672
+ </div>
673
+ </article>`;
674
+ }
675
+ /** 列内同群聚合:≥2 张同 chatId 的卡片折成群组容器(群头像 + 群名 + 计数),
676
+ * 一眼看出它们关联同一个群/话题群;簇按首个成员出现位置参与列内排序。
677
+ * 返回聚簇后 HTML 与视觉平铺顺序(drop 落点据此算相邻位置)。 */
678
+ function clusteredListHtml(columnRows) {
679
+ const order = [];
680
+ const byChat = new Map();
681
+ for (const r of columnRows) {
682
+ const key = String(r.chatId ?? r.sessionId);
683
+ let g = byChat.get(key);
684
+ if (!g) {
685
+ g = { chatId: key, rows: [] };
686
+ byChat.set(key, g);
687
+ order.push(g);
688
+ }
689
+ g.rows.push(r);
690
+ }
691
+ const flat = [];
692
+ const html = order.map(g => {
693
+ flat.push(...g.rows);
694
+ if (g.rows.length < 2)
695
+ return kanbanCardHtml(g.rows[0]);
696
+ const title = chatDisplayTitle(g.rows[0]) ?? g.chatId;
697
+ return `<div class="kanban-cluster" data-chat="${escapeHtml(g.chatId)}">
698
+ <header draggable="true" title="${escapeHtml(title)} · ${escapeHtml(t('sessions.kanban.clusterDragHint'))}">
699
+ ${chatAvatarHtml({ chatId: g.chatId, name: title, size: 'sm' })}
700
+ <span class="kanban-cluster-name">${escapeHtml(title)}</span>
701
+ <span class="kanban-cluster-count">${g.rows.length}</span>
702
+ </header>
703
+ ${g.rows.map(kanbanCardHtml).join('')}
704
+ </div>`;
705
+ }).join('');
706
+ return { html, flat };
707
+ }
708
+ /** 团队模式:列 = 团队里的每个 bot(按名字排序),卡片 = 它名下的活跃会话,
709
+ * 按最近活跃倒序、同群聚簇。协作总览视图——不支持拖拽(会话不能换 bot)。 */
710
+ function kanbanByBotHtml(rows) {
711
+ const bots = new Map();
712
+ for (const r of rows) {
713
+ if (r.status === 'closed')
714
+ continue;
715
+ const key = String(r.larkAppId || r.botName || 'unknown');
716
+ let b = bots.get(key);
717
+ if (!b) {
718
+ b = { name: botDisplayName(r), larkAppId: r.larkAppId, rows: [] };
719
+ bots.set(key, b);
720
+ }
721
+ b.rows.push(r);
722
+ }
723
+ const cols = [...bots.values()].sort((a, b) => a.name.localeCompare(b.name));
724
+ if (!cols.length)
725
+ return `<div class="kanban-col-empty">${t('sessions.board.emptyColumn')}</div>`;
726
+ return cols.map(col => {
727
+ const colRows = col.rows.sort((a, b) => Number(b.lastMessageAt ?? 0) - Number(a.lastMessageAt ?? 0));
728
+ const { html: listHtml } = clusteredListHtml(colRows);
729
+ return `<section class="kanban-column kanban-bot-col" data-bot="${escapeHtml(col.larkAppId ?? col.name)}">
730
+ <header>
731
+ <span class="kanban-col-avatar">${botAvatarHtml({ name: col.name, larkAppId: col.larkAppId, size: 'sm' })}</span>
732
+ <h2>${escapeHtml(col.name)}</h2>
733
+ <span class="kanban-col-count">${colRows.length}</span>
734
+ </header>
735
+ <div class="kanban-col-list">${listHtml}</div>
736
+ </section>`;
737
+ }).join('');
738
+ }
739
+ /** 工作流五列看板(flow/team 共用):聚簇 + 拖拽落点数据。 */
740
+ function kanbanFlowHtml(rows) {
741
+ const groups = new Map(KANBAN_COLUMNS.map(c => [c.id, []]));
742
+ for (const row of rows)
743
+ groups.get(deriveKanbanColumn(row)).push(row);
744
+ const html = KANBAN_COLUMNS.map(column => {
745
+ let columnRows = (groups.get(column.id) ?? [])
746
+ .sort((a, b) => effectiveKanbanPosition(a) - effectiveKanbanPosition(b));
747
+ let hiddenCount = 0;
748
+ if (column.id === 'done' && columnRows.length > KANBAN_DONE_CAP) {
749
+ hiddenCount = columnRows.length - KANBAN_DONE_CAP;
750
+ columnRows = columnRows.slice(0, KANBAN_DONE_CAP);
751
+ }
752
+ const { html: listHtml, flat } = clusteredListHtml(columnRows);
753
+ groups.set(column.id, flat);
754
+ return `<section class="kanban-column kanban-${column.id}" data-col="${column.id}">
755
+ <header>
756
+ <span class="kanban-col-icon">${kanbanStatusIcon(column.id)}</span>
757
+ <h2>${escapeHtml(t(column.labelKey))}</h2>
758
+ <span class="kanban-col-count">${columnRows.length + hiddenCount}</span>
759
+ </header>
760
+ <div class="kanban-col-list">
761
+ ${columnRows.length ? listHtml : `<div class="kanban-col-empty">${t('sessions.board.emptyColumn')}</div>`}
762
+ ${hiddenCount ? `<div class="kanban-col-more">${escapeHtml(t('sessions.kanban.moreHidden', { count: hiddenCount }))}</div>` : ''}
763
+ </div>
764
+ </section>`;
765
+ }).join('');
766
+ lastKanbanGroups = groups;
767
+ return html;
768
+ }
769
+ function renderKanban(rows) {
770
+ // 拖拽/编辑期间冻结 DOM —— innerHTML 重建会拍掉拖拽源和输入框。
771
+ if (kanbanDragId || kanbanDragClusterChat || kanbanEditing)
772
+ return;
773
+ kanban.classList.toggle('kanban-mode-bot', kanbanGroupBy === 'bot');
774
+ let html;
775
+ if (kanbanGroupBy === 'bot') {
776
+ html = kanbanByBotHtml(rows);
777
+ lastKanbanGroups = new Map(); // 机器人视角无拖拽,不需要落点数据
778
+ }
779
+ else if (kanbanGroupBy === 'team') {
780
+ if (!kanbanTeamsLoaded) {
781
+ html = `<div class="kanban-loading">${t('sessions.kanban.teamLoading')}</div>`;
782
+ lastKanbanGroups = new Map();
783
+ // 加载期间下拉显示占位项并禁用——空胶囊很难看,也防止误操作
784
+ if (!teamSelect.dataset.loading) {
785
+ teamSelect.dataset.loading = '1';
786
+ teamSelect.disabled = true;
787
+ teamSelect.innerHTML = `<option>${escapeHtml(t('sessions.kanban.teamLoading'))}</option>`;
788
+ }
789
+ void loadKanbanTeams();
790
+ }
791
+ else {
792
+ const team = kanbanTeams.find(tm => tm.key === kanbanTeamKey) ?? kanbanTeams[0];
793
+ // 「团队群」白名单(申晗定稿):
794
+ // A. dashboard 团队页发起的协作群(建群时落盘的 team↔chatId 绑定)
795
+ // B. 群里 /introduce 过该团队成员机器人的群——介绍记录按名字与团队
796
+ // roster 匹配;介绍过的若不是本团队成员,不算(防误筛)
797
+ // 命中群里所有 bot 的会话都展示(本质 = 同团队 bot 所在群/话题的会话)。
798
+ let teamRows = [];
799
+ const teamChats = new Set();
800
+ if (team) {
801
+ for (const chatId of team.groupChats)
802
+ teamChats.add(chatId);
803
+ if (kanbanChatBots) {
804
+ for (const [chatId, c] of kanbanChatBots) {
805
+ if (teamChats.has(chatId))
806
+ continue;
807
+ // 自家团队 bot 在场,且群里介绍过同团队的外部机器人
808
+ let hasTeamBot = false;
809
+ for (const id of team.botIds) {
810
+ if (c.botIds.has(id)) {
811
+ hasTeamBot = true;
812
+ break;
813
+ }
814
+ }
815
+ if (!hasTeamBot)
816
+ continue;
817
+ for (const n of c.observedNames) {
818
+ if (team.botNames.has(n)) {
819
+ teamChats.add(chatId);
820
+ break;
821
+ }
822
+ }
823
+ }
824
+ }
825
+ teamRows = rows.filter(r => teamChats.has(String(r.chatId)));
826
+ }
827
+ // ── hub 团队看板合并(申晗架构:编排存团队 host)──────────────────────
828
+ // 本地行(实时)+ 对方部署上报的裁剪行(host 快照);共享编排的列/排序
829
+ // 覆盖个人看板字段——团队视图里大家看到同一份摆放。
830
+ if (team)
831
+ void ensureTeamBoard(team);
832
+ const board = (kanbanTeamBoardKey === team?.key ? kanbanTeamBoardData?.board : null) ?? {};
833
+ const remoteRows = (kanbanTeamBoardKey === team?.key ? kanbanTeamBoardData?.remoteRows : null) ?? [];
834
+ const merged = [...teamRows, ...remoteRows].map(r => {
835
+ const e = board[r.sessionId];
836
+ return e ? { ...r, kanbanColumn: e.column, kanbanPosition: e.position } : r;
837
+ });
838
+ teamStats.textContent = t('sessions.kanban.teamScope', { chats: teamChats.size, sessions: merged.length });
839
+ html = kanbanFlowHtml(merged);
840
+ }
841
+ }
842
+ else {
843
+ html = kanbanFlowHtml(rows);
844
+ }
845
+ if (html === lastKanbanHtml)
846
+ return;
847
+ lastKanbanHtml = html;
848
+ // innerHTML 重建会把每列列表的滚动位置归零(改名失焦/SSE 更新时列表跳回
849
+ // 顶部)——重建前按列记录 scrollTop,重建后恢复。
850
+ const scrollTops = new Map();
851
+ kanban.querySelectorAll('.kanban-col-list').forEach(el => {
852
+ const col = el.closest('.kanban-column')?.dataset.col;
853
+ if (col && el.scrollTop)
854
+ scrollTops.set(col, el.scrollTop);
855
+ });
856
+ kanban.innerHTML = html;
857
+ if (scrollTops.size) {
858
+ kanban.querySelectorAll('.kanban-col-list').forEach(el => {
859
+ const col = el.closest('.kanban-column')?.dataset.col;
860
+ const top = col ? scrollTops.get(col) : undefined;
861
+ if (top)
862
+ el.scrollTop = top;
863
+ });
864
+ }
865
+ }
866
+ // ── 看板写操作:拖拽放置 / 重命名(乐观更新 + 失败回滚)────────────────────
867
+ async function persistBoardMove(s, column, position, prev) {
868
+ try {
869
+ const r = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/board`, {
870
+ method: 'POST',
871
+ headers: { 'content-type': 'application/json' },
872
+ body: JSON.stringify({ column, position }),
873
+ });
874
+ const body = await r.json().catch(() => ({}));
875
+ if (!r.ok || body?.ok === false) {
876
+ s.kanbanColumn = prev.column;
877
+ s.kanbanPosition = prev.position;
878
+ lastKanbanHtml = '';
879
+ rerender();
880
+ // 401(只读访客)由全局 fetch patch 弹只读 toast,这里只负责回滚。
881
+ if (r.status !== 401)
882
+ alert(`${t('sessions.kanban.moveFail')}: ${body?.error ?? r.status}`);
883
+ }
884
+ }
885
+ catch (e) {
886
+ s.kanbanColumn = prev.column;
887
+ s.kanbanPosition = prev.position;
888
+ lastKanbanHtml = '';
889
+ rerender();
890
+ alert(`${t('sessions.kanban.moveFail')}: ${e}`);
891
+ }
892
+ }
893
+ async function persistRename(s, title) {
894
+ const prevTitle = s.title;
895
+ s.title = title;
896
+ lastKanbanHtml = '';
897
+ rerender();
898
+ try {
899
+ const r = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/rename`, {
900
+ method: 'POST',
901
+ headers: { 'content-type': 'application/json' },
902
+ body: JSON.stringify({ title }),
903
+ });
904
+ const body = await r.json().catch(() => ({}));
905
+ if (!r.ok || body?.ok === false) {
906
+ s.title = prevTitle;
907
+ lastKanbanHtml = '';
908
+ rerender();
909
+ if (r.status !== 401)
910
+ alert(`${t('sessions.kanban.renameFail')}: ${body?.error ?? r.status}`);
911
+ }
912
+ }
913
+ catch (e) {
914
+ s.title = prevTitle;
915
+ lastKanbanHtml = '';
916
+ rerender();
917
+ alert(`${t('sessions.kanban.renameFail')}: ${e}`);
918
+ }
919
+ }
920
+ // ── 会话历史弹窗:实时拉取该会话所在飞书话题/群的消息,按聊天气泡渲染 ──────
921
+ /** Lark create_time 是毫秒 epoch(数字或数字字符串)——直接 new Date(字符串)
922
+ * 会得到 Invalid Date。统一转数字解析,解析不出就不显示。 */
923
+ function historyTime(v) {
924
+ if (v === undefined || v === null || v === '')
925
+ return '';
926
+ const n = Number(v);
927
+ const d = Number.isFinite(n) && n > 0 ? new Date(n) : new Date(String(v));
928
+ return Number.isNaN(d.getTime()) ? '' : d.toLocaleString();
929
+ }
930
+ function historyBubbleHtml(s, m, ownerOpenId) {
931
+ const mine = m.senderType === 'user';
932
+ // 后端经 contact API 补了 senderName/senderAvatar(可见范围内的真人);
933
+ // 拿不到回退「创建者/用户」占位 + 首字圆。
934
+ const name = mine
935
+ ? (m.senderName
936
+ || (ownerOpenId && m.senderId === ownerOpenId ? t('sessions.history.owner') : t('sessions.history.user')))
937
+ : botDisplayName(s);
938
+ const time = historyTime(m.createTime);
939
+ const content = String(m.content ?? '').trim() || `[${m.msgType ?? 'message'}]`;
940
+ const avatar = mine
941
+ ? (m.senderAvatar
942
+ ? `<img class="history-avatar-img" src="${escapeHtml(String(m.senderAvatar))}" alt="" decoding="async" referrerpolicy="no-referrer">`
943
+ : `<span class="history-avatar-user" aria-hidden="true">${escapeHtml(String(name).slice(0, 1))}</span>`)
944
+ : botAvatarHtml({ name: botDisplayName(s), larkAppId: s.larkAppId, size: 'sm' });
945
+ return `<div class="history-msg${mine ? ' mine' : ''}">
946
+ ${avatar}
947
+ <div class="history-msg-main">
948
+ <div class="history-msg-meta"><span>${escapeHtml(name)}</span><time>${escapeHtml(time)}</time></div>
949
+ <div class="history-bubble">${escapeHtml(content)}</div>
950
+ </div>
951
+ </div>`;
952
+ }
953
+ async function openHistoryModal(s) {
954
+ const botName = botDisplayName(s);
955
+ historyModal.innerHTML = `<div class="term-modal-head">
956
+ <span class="term-modal-title">
957
+ ${botAvatarHtml({ name: botName, larkAppId: s.larkAppId, size: 'sm' })}
958
+ <strong title="${escapeHtml(String(s.title ?? ''))}">${escapeHtml((stripMentionPrefix(s.title) || s.sessionId).slice(0, 60))}</strong>
959
+ <span class="history-scope-tag">${escapeHtml(t('sessions.history.title'))}</span>
960
+ </span>
961
+ <span class="term-modal-actions">
962
+ <button type="button" id="history-close" class="card-act" title="${escapeHtml(t('sessions.dismiss'))}" aria-label="${escapeHtml(t('sessions.dismiss'))}">${ICON.close}</button>
963
+ </span>
964
+ </div>
965
+ <div class="history-body"><div class="term-modal-loading">${t('sessions.history.loading')}</div></div>`;
966
+ historyModal.showModal();
967
+ historyModal.querySelector('#history-close').onclick = () => historyModal.close();
968
+ try {
969
+ const r = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/history?limit=80`);
970
+ const body = await r.json().catch(() => ({}));
971
+ if (!historyModal.open)
972
+ return;
973
+ const bodyEl = historyModal.querySelector('.history-body');
974
+ if (!r.ok || body?.ok === false) {
975
+ const errCode = String(body?.error ?? r.status);
976
+ // not_found_yet = dashboard 进程没有该路由;not_found = daemon 没有 ——
977
+ // 都是进程仍跑旧 build 的特征,明示重启而不是让人猜。
978
+ const stale = errCode === 'not_found_yet' || errCode === 'not_found';
979
+ bodyEl.innerHTML = `<div class="history-error">${escapeHtml(t('sessions.history.fail'))}: ${escapeHtml(errCode)}${stale ? `<br><span>${escapeHtml(t('sessions.history.staleHint'))}</span>` : ''}</div>`;
980
+ return;
981
+ }
982
+ const messages = Array.isArray(body.messages) ? body.messages : [];
983
+ if (!messages.length) {
984
+ bodyEl.innerHTML = `<div class="history-error">${t('sessions.history.empty')}</div>`;
985
+ return;
986
+ }
987
+ bodyEl.innerHTML = `<div class="history-list">${messages.map(m => historyBubbleHtml(s, m, body.ownerOpenId)).join('')}</div>`;
988
+ bodyEl.scrollTop = bodyEl.scrollHeight; // 默认停在最新一条
989
+ }
990
+ catch (e) {
991
+ if (!historyModal.open)
992
+ return;
993
+ const bodyEl = historyModal.querySelector('.history-body');
994
+ if (bodyEl)
995
+ bodyEl.innerHTML = `<div class="history-error">${escapeHtml(t('sessions.history.fail'))}: ${escapeHtml(String(e))}</div>`;
996
+ }
997
+ }
998
+ /** 把卡片标题就地换成输入框:Enter/失焦保存,Esc 取消。 */
999
+ function startKanbanRename(card, s) {
1000
+ const titleEl = card.querySelector('.kanban-card-title');
1001
+ if (!titleEl || card.querySelector('.kanban-rename-input'))
1002
+ return;
1003
+ kanbanEditing = true;
1004
+ const input = document.createElement('input');
1005
+ input.type = 'text';
1006
+ input.className = 'kanban-rename-input';
1007
+ input.maxLength = 200;
1008
+ input.value = stripMentionPrefix(s.title) || '';
1009
+ titleEl.replaceWith(input);
1010
+ input.focus();
1011
+ input.select();
1012
+ let settled = false;
1013
+ const finish = (commit) => {
1014
+ if (settled)
1015
+ return;
1016
+ settled = true;
1017
+ kanbanEditing = false;
1018
+ const next = input.value.trim();
1019
+ if (commit && next && next !== (stripMentionPrefix(s.title) || '')) {
1020
+ void persistRename(s, next);
1021
+ }
1022
+ else {
1023
+ lastKanbanHtml = '';
1024
+ rerender();
1025
+ }
1026
+ };
1027
+ input.addEventListener('keydown', ev => {
1028
+ ev.stopPropagation();
1029
+ if (ev.key === 'Enter') {
1030
+ ev.preventDefault();
1031
+ finish(true);
1032
+ }
1033
+ else if (ev.key === 'Escape') {
1034
+ ev.preventDefault();
1035
+ finish(false);
1036
+ }
1037
+ });
1038
+ input.addEventListener('blur', () => finish(true));
1039
+ input.addEventListener('click', ev => ev.stopPropagation());
1040
+ }
1041
+ /** 指针 Y 落点之下的第一张卡片(不含拖拽源)—— 新卡插它前面;null = 追加列尾。 */
1042
+ function kanbanInsertBeforeCard(column, clientY) {
1043
+ for (const card of column.querySelectorAll('.kanban-card:not(.dragging)')) {
1044
+ // 整簇拖拽时簇内成员不能当落点参照
1045
+ if (card.closest('.kanban-cluster.dragging'))
1046
+ continue;
1047
+ const rect = card.getBoundingClientRect();
1048
+ if (clientY < rect.top + rect.height / 2)
1049
+ return card;
1050
+ }
1051
+ return null;
1052
+ }
1053
+ function clearKanbanDragMarks() {
1054
+ kanban.querySelectorAll('.drag-over, .dragging, .drop-before')
1055
+ .forEach(el => el.classList.remove('drag-over', 'dragging', 'drop-before'));
1056
+ }
1057
+ // 终端弹窗标题就地改名:标题文本换输入框,Enter/失焦保存、Esc 取消;
1058
+ // 复用 persistRename(乐观更新 + 失败回滚 + 全视图同步)。
1059
+ function startTermTitleEdit(s) {
1060
+ const nameEl = termModal.querySelector('.term-modal-name');
1061
+ if (!nameEl || termModal.querySelector('.term-modal-name-input'))
1062
+ return;
1063
+ const input = document.createElement('input');
1064
+ input.type = 'text';
1065
+ input.className = 'term-modal-name-input';
1066
+ input.maxLength = 200;
1067
+ input.value = stripMentionPrefix(s.title) || '';
1068
+ nameEl.replaceWith(input);
1069
+ // 宽度贴合内容:按文本实测像素宽设宽,clamp 到 [80, 60vw],超长则到上限
1070
+ // 后内部横向滚动(与原标题省略号的「有上限」体感一致)。
1071
+ const fitInput = () => {
1072
+ const cs = getComputedStyle(input);
1073
+ const span = document.createElement('span');
1074
+ span.style.cssText = 'position:absolute;visibility:hidden;white-space:pre';
1075
+ span.style.fontSize = cs.fontSize;
1076
+ span.style.fontFamily = cs.fontFamily;
1077
+ span.style.fontWeight = cs.fontWeight;
1078
+ span.style.letterSpacing = cs.letterSpacing;
1079
+ span.textContent = input.value || ' ';
1080
+ document.body.appendChild(span);
1081
+ const w = span.offsetWidth;
1082
+ span.remove();
1083
+ const max = Math.round(window.innerWidth * 0.6);
1084
+ input.style.width = `${Math.min(Math.max(w + 22, 80), max)}px`;
1085
+ };
1086
+ fitInput();
1087
+ input.addEventListener('input', fitInput);
1088
+ input.focus();
1089
+ input.select();
1090
+ let settled = false;
1091
+ const finish = (commit) => {
1092
+ if (settled)
1093
+ return;
1094
+ settled = true;
1095
+ const next = input.value.trim();
1096
+ const cur = stripMentionPrefix(s.title) || '';
1097
+ if (commit && next && next !== cur) {
1098
+ s.title = next; // 弹窗内即时反映;persistRename 再发请求 + 背后 rerender
1099
+ const strong = document.createElement('strong');
1100
+ strong.className = 'term-modal-name';
1101
+ strong.title = next;
1102
+ strong.textContent = next.slice(0, 60);
1103
+ input.replaceWith(strong);
1104
+ void persistRename(s, next);
1105
+ }
1106
+ else {
1107
+ const strong = document.createElement('strong');
1108
+ strong.className = 'term-modal-name';
1109
+ strong.title = String(s.title ?? cur);
1110
+ strong.textContent = cur.slice(0, 60);
1111
+ input.replaceWith(strong);
1112
+ }
1113
+ };
1114
+ input.addEventListener('keydown', ev => {
1115
+ ev.stopPropagation();
1116
+ if (ev.key === 'Enter') {
1117
+ ev.preventDefault();
1118
+ finish(true);
1119
+ }
1120
+ else if (ev.key === 'Escape') {
1121
+ ev.preventDefault();
1122
+ finish(false);
1123
+ }
1124
+ });
1125
+ input.addEventListener('blur', () => finish(true));
1126
+ }
1127
+ // 页面内终端弹窗:默认嵌只读终端;已认证用户先 mint 可写链接(弹窗里能直接
1128
+ // 打字),拿不到再回退只读。没有 web 终端(挂起/已关闭)时退回详情抽屉。
1129
+ async function openTerminalModal(s) {
1130
+ const readonlyUrl = terminalHref(s);
1131
+ if (!readonlyUrl) {
1132
+ openDrawer(s);
1133
+ return;
1134
+ }
1135
+ const title = stripMentionPrefix(s.title) || s.sessionId;
1136
+ const feishu = s.feishuChatLink
1137
+ ? `<a class="card-act" href="${escapeHtml(s.feishuChatLink)}" target="_blank" rel="noopener" title="${escapeHtml(t('sessions.kanban.openFeishu'))}" aria-label="${escapeHtml(t('sessions.kanban.openFeishu'))}">${ICON.feishu}</a>`
1138
+ : '';
1139
+ termModal.innerHTML = `<div class="term-modal-head">
1140
+ <span class="term-modal-title">
1141
+ ${botAvatarHtml({ name: botDisplayName(s), larkAppId: s.larkAppId, size: 'sm' })}
1142
+ <strong class="term-modal-name" title="${escapeHtml(String(s.title ?? title))}">${escapeHtml(String(title).slice(0, 60))}</strong>
1143
+ <button type="button" id="term-modal-edit" class="card-act" title="${escapeHtml(t('sessions.kanban.rename'))}" aria-label="${escapeHtml(t('sessions.kanban.rename'))}">${ICON.edit}</button>
1144
+ <span class="status status-${escapeHtml(s.status ?? 'unknown')}">${escapeHtml(s.status ?? 'unknown')}</span>
1145
+ </span>
1146
+ <span class="term-modal-actions">
1147
+ ${feishu}
1148
+ <a id="term-modal-tab" class="card-act" href="${escapeHtml(readonlyUrl)}" target="_blank" rel="noopener" title="${escapeHtml(t('sessions.kanban.openTab'))}" aria-label="${escapeHtml(t('sessions.kanban.openTab'))}">${ICON.terminal}</a>
1149
+ <button type="button" id="term-modal-close" class="card-act" title="${escapeHtml(t('sessions.dismiss'))}" aria-label="${escapeHtml(t('sessions.dismiss'))}">${ICON.close}</button>
1150
+ </span>
1151
+ </div>
1152
+ <div class="term-modal-body"><div class="term-modal-loading">${t('sessions.kanban.terminalLoading')}</div></div>`;
1153
+ termModal.showModal();
1154
+ termModal.querySelector('#term-modal-close').onclick = () => termModal.close();
1155
+ termModal.querySelector('#term-modal-edit').onclick = () => startTermTitleEdit(s);
1156
+ let url = readonlyUrl;
1157
+ if (ui.authed) {
1158
+ try {
1159
+ const r = await fetch(`/api/sessions/${encodeURIComponent(s.sessionId)}/write-link`);
1160
+ const body = await r.json().catch(() => ({}));
1161
+ if (r.ok && body?.ok !== false && body?.url)
1162
+ url = body.url;
1163
+ }
1164
+ catch {
1165
+ // 可写链接拿不到就用只读链接,弹窗仍可观看
1166
+ }
1167
+ }
1168
+ if (!termModal.open)
1169
+ return; // 加载期间用户已关掉弹窗
1170
+ const bodyEl = termModal.querySelector('.term-modal-body');
1171
+ bodyEl.innerHTML = `<iframe class="term-modal-frame" src="${escapeHtml(url)}" allow="clipboard-read; clipboard-write"></iframe>`;
1172
+ const tab = termModal.querySelector('#term-modal-tab');
1173
+ if (tab)
1174
+ tab.href = url;
1175
+ }
383
1176
  function filtered() {
384
1177
  const f = new FormData(filtersForm);
385
1178
  const q = (f.get('q') ?? '').toLowerCase();
@@ -388,11 +1181,14 @@ export function renderSessionsPage(root) {
388
1181
  const status = f.get('status');
389
1182
  const adopt = f.get('adopt');
390
1183
  const active = !!f.get('active');
1184
+ // 看板视图的「已完成」列收纳已关闭会话——「仅活跃」开关不再把它们整体
1185
+ // 滤掉,否则该列永远是空的。
1186
+ const keepClosed = viewMode === 'kanban';
391
1187
  const rows = [...store.sessions.values()]
392
1188
  .filter(s => !cliFilterActive || cli.includes(s.cliId ?? 'unknown'))
393
1189
  .filter(s => !status || s.status === status)
394
1190
  .filter(s => !adopt || (adopt === 'yes') === !!s.adopt)
395
- .filter(s => !active || s.status !== 'closed')
1191
+ .filter(s => !active || keepClosed || s.status !== 'closed')
396
1192
  .filter(s => !q || JSON.stringify(s).toLowerCase().includes(q));
397
1193
  rows.sort(compareRows);
398
1194
  return rows;
@@ -452,6 +1248,14 @@ export function renderSessionsPage(root) {
452
1248
  btn.classList.toggle('active', active);
453
1249
  btn.setAttribute('aria-pressed', String(active));
454
1250
  });
1251
+ groupByBox.hidden = viewMode !== 'kanban';
1252
+ teamSelect.hidden = !(viewMode === 'kanban' && kanbanGroupBy === 'team');
1253
+ teamStats.hidden = teamSelect.hidden || !kanbanTeamsLoaded;
1254
+ groupByBox.querySelectorAll('[data-groupby]').forEach(btn => {
1255
+ const active = btn.dataset.groupby === kanbanGroupBy;
1256
+ btn.classList.toggle('active', active);
1257
+ btn.setAttribute('aria-pressed', String(active));
1258
+ });
455
1259
  }
456
1260
  // CLI 下拉 chip 上的已选计数:全选时显示「全部」,否则显示 N/总数
457
1261
  function paintCliFilterCount() {
@@ -471,9 +1275,10 @@ export function renderSessionsPage(root) {
471
1275
  selected.delete(sid);
472
1276
  }
473
1277
  const boardRows = rows.filter(r => r.status !== 'closed');
474
- const visibleRows = viewMode === 'board' ? boardRows : rows;
1278
+ const visibleRows = viewMode === 'table' ? rows : boardRows;
475
1279
  table.hidden = viewMode !== 'table';
476
1280
  board.hidden = viewMode !== 'board';
1281
+ kanban.hidden = viewMode !== 'kanban';
477
1282
  if (viewMode === 'table') {
478
1283
  const tableHtml = rows.length
479
1284
  ? rows.map(rowHtml).join('')
@@ -483,6 +1288,9 @@ export function renderSessionsPage(root) {
483
1288
  tbody.innerHTML = tableHtml;
484
1289
  }
485
1290
  }
1291
+ else if (viewMode === 'kanban') {
1292
+ renderKanban(rows);
1293
+ }
486
1294
  else {
487
1295
  renderBoard(boardRows);
488
1296
  }
@@ -576,6 +1384,7 @@ export function renderSessionsPage(root) {
576
1384
  <p><b>${t('sessions.workingDir')}:</b> ${escapeHtml(s.workingDir ?? '-')}</p>
577
1385
  <div class="actions">
578
1386
  ${chatScopeLink(s) ?? `<button id="locate-btn" type="button">${t('sessions.locate')}</button>`}
1387
+ <button id="history-drawer-btn" type="button">${t('sessions.history.title')}</button>
579
1388
  ${terminalControlsHtml(terminal)}
580
1389
  ${closed ? `<button id="resume-btn" type="button" class="primary">${t('sessions.resume')}</button>` : ''}
581
1390
  ${!closed ? `<button id="close-btn" type="button" class="contrast">${t('sessions.close')}</button>` : ''}
@@ -595,6 +1404,10 @@ export function renderSessionsPage(root) {
595
1404
  if (locateBtn) {
596
1405
  locateBtn.onclick = () => void locateSession(s, locateBtn);
597
1406
  }
1407
+ const historyBtn = drawer.querySelector('#history-drawer-btn');
1408
+ if (historyBtn) {
1409
+ historyBtn.onclick = () => void openHistoryModal(s);
1410
+ }
598
1411
  // Writable-terminal segment (.term-write) lives inside the drawer, outside
599
1412
  // the board's click delegation — wire it directly.
600
1413
  const writeBtn = drawer.querySelector('.term-write');
@@ -789,7 +1602,7 @@ export function renderSessionsPage(root) {
789
1602
  });
790
1603
  viewButtons.forEach(btn => {
791
1604
  btn.addEventListener('click', () => {
792
- const next = btn.dataset.view === 'table' ? 'table' : 'board';
1605
+ const next = normalizeSessionsViewMode(btn.dataset.view) ?? 'board';
793
1606
  if (next === viewMode)
794
1607
  return;
795
1608
  viewMode = next;
@@ -797,6 +1610,230 @@ export function renderSessionsPage(root) {
797
1610
  rerender();
798
1611
  });
799
1612
  });
1613
+ groupByBox.querySelectorAll('[data-groupby]').forEach(btn => {
1614
+ btn.addEventListener('click', () => {
1615
+ const raw = btn.dataset.groupby;
1616
+ const next = raw === 'bot' ? 'bot' : raw === 'team' ? 'team' : 'flow';
1617
+ if (next === kanbanGroupBy)
1618
+ return;
1619
+ kanbanGroupBy = next;
1620
+ writeStoredKanbanGroupBy(window.localStorage, next);
1621
+ lastKanbanHtml = '';
1622
+ rerender();
1623
+ });
1624
+ });
1625
+ teamSelect.addEventListener('change', () => {
1626
+ kanbanTeamKey = teamSelect.value;
1627
+ try {
1628
+ window.localStorage.setItem(KANBAN_TEAM_STORAGE_KEY, kanbanTeamKey);
1629
+ }
1630
+ catch { /* 仅当前页生效 */ }
1631
+ lastKanbanHtml = '';
1632
+ rerender();
1633
+ });
1634
+ // ── 看板交互:单击开终端弹窗(延迟仲裁让位双击)、铅笔/双击改标题、
1635
+ // 「详情」进抽屉、整卡拖拽换列与排序 ─────────────────────────────────────
1636
+ function cancelKanbanOpen() {
1637
+ if (kanbanOpenTimer !== null) {
1638
+ clearTimeout(kanbanOpenTimer);
1639
+ kanbanOpenTimer = null;
1640
+ }
1641
+ }
1642
+ kanban.addEventListener('click', e => {
1643
+ const target = e.target;
1644
+ const card = target.closest('.kanban-card[data-id]');
1645
+ if (!card)
1646
+ return;
1647
+ const s = store.sessions.get(card.dataset.id);
1648
+ if (!s)
1649
+ return;
1650
+ const actionButton = target.closest('button[data-action]');
1651
+ if (actionButton) {
1652
+ if (actionButton.dataset.action === 'details')
1653
+ openDrawer(s);
1654
+ else if (actionButton.dataset.action === 'rename')
1655
+ startKanbanRename(card, s);
1656
+ else if (actionButton.dataset.action === 'history')
1657
+ void openHistoryModal(s);
1658
+ return;
1659
+ }
1660
+ if (target.closest('a, button, input, label'))
1661
+ return;
1662
+ cancelKanbanOpen();
1663
+ kanbanOpenTimer = setTimeout(() => {
1664
+ kanbanOpenTimer = null;
1665
+ void openTerminalModal(s);
1666
+ }, 220);
1667
+ });
1668
+ kanban.addEventListener('dblclick', e => {
1669
+ const target = e.target;
1670
+ const titleEl = target.closest('.kanban-card-title');
1671
+ const card = target.closest('.kanban-card[data-id]');
1672
+ if (!titleEl || !card)
1673
+ return;
1674
+ cancelKanbanOpen();
1675
+ const s = store.sessions.get(card.dataset.id);
1676
+ if (s)
1677
+ startKanbanRename(card, s);
1678
+ });
1679
+ kanban.addEventListener('keydown', e => {
1680
+ if (e.key !== 'Enter' && e.key !== ' ')
1681
+ return;
1682
+ const target = e.target;
1683
+ if (!target.classList?.contains('kanban-card'))
1684
+ return;
1685
+ e.preventDefault();
1686
+ const s = store.sessions.get(target.dataset.id);
1687
+ if (s)
1688
+ void openTerminalModal(s);
1689
+ });
1690
+ // ── 看板卡片拖拽 ──────────────────────────────────────────────────────────
1691
+ kanban.addEventListener('dragstart', e => {
1692
+ if (kanbanGroupBy === 'bot')
1693
+ return; // 机器人视角只读:会话不能拖给别的 bot
1694
+ const target = e.target;
1695
+ // 拖群组容器头部 = 整簇搬运
1696
+ const clusterHeader = target.closest('.kanban-cluster > header[draggable]');
1697
+ if (clusterHeader) {
1698
+ const cluster = clusterHeader.closest('.kanban-cluster');
1699
+ const col = cluster.closest('.kanban-column')?.dataset.col;
1700
+ if (!cluster.dataset.chat || !col)
1701
+ return;
1702
+ cancelKanbanOpen();
1703
+ kanbanDragClusterChat = cluster.dataset.chat;
1704
+ kanbanDragClusterCol = col;
1705
+ cluster.classList.add('dragging');
1706
+ if (e.dataTransfer) {
1707
+ e.dataTransfer.effectAllowed = 'move';
1708
+ e.dataTransfer.setData('text/plain', `cluster:${kanbanDragClusterChat}`);
1709
+ }
1710
+ return;
1711
+ }
1712
+ const card = target.closest('.kanban-card[data-id]');
1713
+ if (!card)
1714
+ return;
1715
+ cancelKanbanOpen();
1716
+ kanbanDragId = card.dataset.id;
1717
+ card.classList.add('dragging');
1718
+ if (e.dataTransfer) {
1719
+ e.dataTransfer.effectAllowed = 'move';
1720
+ e.dataTransfer.setData('text/plain', kanbanDragId);
1721
+ }
1722
+ });
1723
+ kanban.addEventListener('dragover', e => {
1724
+ if (!kanbanDragId && !kanbanDragClusterChat)
1725
+ return;
1726
+ e.preventDefault();
1727
+ if (e.dataTransfer)
1728
+ e.dataTransfer.dropEffect = 'move';
1729
+ const column = e.target.closest('.kanban-column');
1730
+ kanban.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
1731
+ kanban.querySelectorAll('.drop-before').forEach(el => el.classList.remove('drop-before'));
1732
+ if (!column)
1733
+ return;
1734
+ column.classList.add('drag-over');
1735
+ kanbanInsertBeforeCard(column, e.clientY)?.classList.add('drop-before');
1736
+ });
1737
+ kanban.addEventListener('drop', e => {
1738
+ const clusterChat = kanbanDragClusterChat;
1739
+ const clusterCol = kanbanDragClusterCol;
1740
+ const dragId = kanbanDragId;
1741
+ if (!dragId && !clusterChat)
1742
+ return;
1743
+ e.preventDefault();
1744
+ kanbanDragId = null;
1745
+ kanbanDragClusterChat = null;
1746
+ kanbanDragClusterCol = null;
1747
+ clearKanbanDragMarks();
1748
+ const column = e.target.closest('.kanban-column');
1749
+ const targetCol = column?.dataset.col;
1750
+ if (!column || !targetCol)
1751
+ return;
1752
+ const beforeCard = kanbanInsertBeforeCard(column, e.clientY);
1753
+ // ── 整簇搬运:源列里该群的全部卡片保持相对顺序插到落点 ──────────────────
1754
+ if (clusterChat && clusterCol) {
1755
+ const members = (lastKanbanGroups.get(clusterCol) ?? [])
1756
+ .filter((r) => String(r.chatId) === clusterChat)
1757
+ // 已关闭会话固定在「已完成」:整簇挪去别的列时留下它们
1758
+ .filter((r) => !(r.status === 'closed' && targetCol !== 'done'));
1759
+ if (!members.length)
1760
+ return;
1761
+ const memberIds = new Set(members.map((r) => r.sessionId));
1762
+ const colRows = (lastKanbanGroups.get(targetCol) ?? []).filter((r) => !memberIds.has(r.sessionId));
1763
+ let index = beforeCard ? colRows.findIndex((r) => r.sessionId === beforeCard.dataset.id) : colRows.length;
1764
+ if (index < 0)
1765
+ index = colRows.length;
1766
+ const prevRow = index > 0 ? colRows[index - 1] : null;
1767
+ const nextRow = index < colRows.length ? colRows[index] : null;
1768
+ const base = computeDropPosition(prevRow ? effectiveKanbanPosition(prevRow) : null, nextRow ? effectiveKanbanPosition(nextRow) : null);
1769
+ members.forEach((m, i) => {
1770
+ const pos = base + i * 0.001;
1771
+ if (kanbanGroupBy === 'team') {
1772
+ // 团队模式写 host 的共享编排,不动各会话的个人看板字段
1773
+ applyTeamBoardMove(String(m.sessionId), targetCol, pos);
1774
+ }
1775
+ else {
1776
+ const prev = { column: m.kanbanColumn, position: m.kanbanPosition };
1777
+ m.kanbanColumn = targetCol;
1778
+ m.kanbanPosition = pos;
1779
+ void persistBoardMove(m, targetCol, pos, prev);
1780
+ }
1781
+ });
1782
+ lastKanbanHtml = '';
1783
+ rerender();
1784
+ return;
1785
+ }
1786
+ // ── 单卡搬运 ─────────────────────────────────────────────────────────────
1787
+ // 对方部署的行不在 store 里——团队看板的远程缓存兜底
1788
+ const s = store.sessions.get(dragId) ?? kanbanRemoteRows.get(dragId);
1789
+ if (!s)
1790
+ return;
1791
+ // 已关闭会话固定在「已完成」列,只允许列内重排。
1792
+ if (s.status === 'closed' && targetCol !== 'done')
1793
+ return;
1794
+ const colRows = (lastKanbanGroups.get(targetCol) ?? []).filter((r) => r.sessionId !== dragId);
1795
+ let index = beforeCard ? colRows.findIndex((r) => r.sessionId === beforeCard.dataset.id) : colRows.length;
1796
+ if (index < 0)
1797
+ index = colRows.length;
1798
+ const prevRow = index > 0 ? colRows[index - 1] : null;
1799
+ const nextRow = index < colRows.length ? colRows[index] : null;
1800
+ const position = computeDropPosition(prevRow ? effectiveKanbanPosition(prevRow) : null, nextRow ? effectiveKanbanPosition(nextRow) : null);
1801
+ if (kanbanGroupBy === 'team') {
1802
+ applyTeamBoardMove(String(s.sessionId), targetCol, position);
1803
+ lastKanbanHtml = '';
1804
+ rerender();
1805
+ return;
1806
+ }
1807
+ const prev = { column: s.kanbanColumn, position: s.kanbanPosition };
1808
+ s.kanbanColumn = targetCol;
1809
+ s.kanbanPosition = position;
1810
+ lastKanbanHtml = '';
1811
+ rerender();
1812
+ void persistBoardMove(s, targetCol, position, prev);
1813
+ });
1814
+ kanban.addEventListener('dragend', () => {
1815
+ kanbanDragId = null;
1816
+ kanbanDragClusterChat = null;
1817
+ kanbanDragClusterCol = null;
1818
+ clearKanbanDragMarks();
1819
+ lastKanbanHtml = '';
1820
+ rerender();
1821
+ });
1822
+ // 点弹窗 backdrop 关闭;关闭时清空内容,立刻断开 iframe 里的终端 WebSocket。
1823
+ termModal.addEventListener('click', e => {
1824
+ if (e.target === termModal)
1825
+ termModal.close();
1826
+ });
1827
+ termModal.addEventListener('close', () => {
1828
+ termModal.innerHTML = '';
1829
+ });
1830
+ historyModal.addEventListener('click', e => {
1831
+ if (e.target === historyModal)
1832
+ historyModal.close();
1833
+ });
1834
+ historyModal.addEventListener('close', () => {
1835
+ historyModal.innerHTML = '';
1836
+ });
800
1837
  selectAllBox.addEventListener('change', () => {
801
1838
  const rows = filtered().filter(r => r.status !== 'closed');
802
1839
  for (const row of rows) {
@@ -865,6 +1902,18 @@ export function renderSessionsPage(root) {
865
1902
  });
866
1903
  filtersForm.addEventListener('input', rerender);
867
1904
  store.on(rerender);
1905
+ // 团队看板 30s 软刷新(拉对方部署的会话快照与共享编排);页面切走后
1906
+ // kanban 脱离 DOM,定时器自清。
1907
+ const teamBoardTimer = setInterval(() => {
1908
+ if (!document.body.contains(kanban)) {
1909
+ clearInterval(teamBoardTimer);
1910
+ return;
1911
+ }
1912
+ if (viewMode === 'kanban' && kanbanGroupBy === 'team') {
1913
+ lastKanbanHtml = '';
1914
+ rerender();
1915
+ }
1916
+ }, 30_000);
868
1917
  rerender();
869
1918
  // bot 友好名 / 群聊标题异步解析,回来后补一次重绘(首帧先显示原值)
870
1919
  void loadNameMaps().then(rerender);