dsh-project-room 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/client.js +169 -50
  2. package/package.json +1 -1
package/client.js CHANGED
@@ -387,15 +387,44 @@ window.__ModuleLoader__.load({
387
387
  }
388
388
  .pr-loose-msg { font-size: 10px; color: var(--dsw-alias-label-secondary); padding: 0 6px 6px; line-height: 1.5; flex: 0 0 auto; }
389
389
 
390
- /* 运行中快捷栏(对齐 AI-Agent-Hub):一行可折行的 pill,完成未读排在前面 */
391
- .pr-runbar { display: flex; flex-wrap: wrap; gap: 3px; padding: 2px 4px 6px; flex: 0 0 auto; }
390
+ /* 运行中快捷栏(对齐 AI-Agent-Hub):两段竖排 —— 上「运行中」、下「完成未读」 */
391
+ .pr-runbar { display: flex; flex-direction: column; gap: 4px; padding: 2px 4px 6px; flex: 0 0 auto; min-width: 0; }
392
+ /* 2026-11-11 用户令「快捷栏正在运行和完成未读的分开放」:
393
+ 每段 = 一行小标题(色点/✓ + 文案 + 计数)+ 可折行的 pill 区,段间用极细线分隔。 */
394
+ .pr-runbar-sec { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
395
+ .pr-runbar-sec + .pr-runbar-sec {
396
+ border-top: 1px solid var(--dsw-alias-border-l1);
397
+ padding-top: 3px;
398
+ }
399
+ .pr-runbar-sec-title {
400
+ display: flex; align-items: center; gap: 4px; min-width: 0;
401
+ padding: 0 2px; font-size: 9px; line-height: 1.4;
402
+ color: var(--dsw-alias-label-secondary); letter-spacing: .03em;
403
+ }
404
+ .pr-runbar-sec[data-kind="done"] .pr-runbar-sec-title { color: var(--dsw-alias-brand-primary); }
405
+ .pr-runbar-sec-dot {
406
+ width: 5px; height: 5px; border-radius: 50%; flex: 0 0 auto;
407
+ background: var(--dsw-alias-state-success-primary);
408
+ }
409
+ .pr-runbar-sec-check {
410
+ width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto;
411
+ display: inline-flex; align-items: center; justify-content: center;
412
+ background: var(--dsw-alias-brand-primary); color: #fff;
413
+ font-size: 8px; font-weight: 700; line-height: 1;
414
+ }
415
+ .pr-runbar-sec-label { flex: 0 0 auto; white-space: nowrap; }
416
+ .pr-runbar-sec-count {
417
+ flex: 0 0 auto; font-variant-numeric: tabular-nums; opacity: .75;
418
+ }
419
+ .pr-runbar-sec-body { display: flex; flex-wrap: wrap; gap: 3px; min-width: 0; }
392
420
  /* 快捷栏 pill 也显示分身:每个 pill 变成「按钮 + 树状子行」的竖排小单元,
393
421
  子行样式与侧栏芯片的 pr-clone-row / pr-tree-rail 完全一致。 */
394
422
  .pr-run-wrap { display: inline-flex; flex-direction: column; gap: 1px; align-items: flex-start; }
395
423
  .pr-run-clones { display: flex; flex-direction: column; gap: 1px; padding-left: 6px; margin: 0 0 1px; }
396
424
  .pr-run-clones .pr-clone-row { font-size: 9px; line-height: 1.4; }
397
425
  .pr-readall {
398
- flex: 0 0 auto; margin: 0 2px 0 0; padding: 1px 6px; border-radius: 8px;
426
+ /* 2026-11-11:一键已读搬进「完成未读」区的小标题里,靠右对齐 */
427
+ flex: 0 0 auto; margin: 0 0 0 auto; padding: 1px 6px; border-radius: 8px;
399
428
  border: 1px solid var(--dsw-alias-border-l1); background: transparent;
400
429
  color: var(--dsw-alias-brand-primary); font-size: 9.5px; line-height: 1.4;
401
430
  white-space: nowrap; cursor: pointer;
@@ -819,23 +848,40 @@ function summarizeMessage(raw) {
819
848
  return () => { cancelled = true; clearInterval(timer); };
820
849
 
821
850
  }, []);
822
- // 磁盘活会话白名单:侧栏里有些条目只存在于前端快照(磁盘目录早已消失),
823
- // 归档必然报 no such session(实测 25/25 全失败)。这类幽灵无法删除,只能不显示。
824
- const [aliveIds, setAliveIds] = React.useState(null);
825
- React.useEffect(() => {
826
- let cancelled = false;
827
- const load = async () => {
851
+ // ── 幽灵会话「事后隐藏」(2026-11-10 用户令:白名单改事后隐藏)──────────────
852
+ // 背景:曾经用「磁盘活会话白名单」全量过滤(每 120s 拉 /project-room/alive,
853
+ // 不在白名单里的会话直接不显示)。它确实能藏住「前端快照有、磁盘目录没了」的幽灵
854
+ // (归档必报 no such session,DSH 又没有删除 API),但代价是**新建会话要等下一次
855
+ // 轮询才认识** —— 分诊分身只活 30 秒,正好卡在两次轮询之间,侧栏从来没显示过它。
856
+ //
857
+ // 用户 2026-11-10 明确选了「事后隐藏」:默认**不过滤任何会话**,只有真的点归档且
858
+ // 失败时,才把那个 id 记进 localStorage 的隐藏集,下次渲染跳过它。
859
+ // 好处:新会话零延迟显示;幽灵删一次之后就消失。
860
+ const HIDDEN_KEY = 'project-room:hidden-sessions';
861
+ const bareId = (v) => String(v || '').replace(/^session-/, '').toLowerCase();
862
+ const [hiddenIds, setHiddenIds] = React.useState(() => {
863
+ try {
864
+ const raw = window.localStorage.getItem(HIDDEN_KEY);
865
+ const arr = raw ? JSON.parse(raw) : [];
866
+ return new Set(Array.isArray(arr) ? arr.map(bareId) : []);
867
+ } catch (error) { return new Set(); }
868
+ });
869
+ const hideSession = React.useCallback((id) => {
870
+ const bare = bareId(id);
871
+ if (!bare) return;
872
+ setHiddenIds((prev) => {
873
+ if (prev.has(bare)) return prev;
874
+ const next = new Set(prev);
875
+ next.add(bare);
828
876
  try {
829
- const res = await fetch(`${API}/alive`);
830
- const data = await res.json();
831
- if (!cancelled && data && data.ok && Array.isArray(data.ids)) {
832
- setAliveIds(new Set(data.ids.map((v) => String(v).toLowerCase())));
833
- }
834
- } catch { /* 拿不到白名单就不过滤 */ }
835
- };
836
- load();
837
- const timer = setInterval(load, 120000);
838
- return () => { cancelled = true; clearInterval(timer); };
877
+ window.localStorage.setItem(HIDDEN_KEY, JSON.stringify([...next]));
878
+ } catch (error) { /* 存不下也不影响本次隐藏 */ }
879
+ return next;
880
+ });
881
+ }, []);
882
+ const unhideAll = React.useCallback(() => {
883
+ setHiddenIds(new Set());
884
+ try { window.localStorage.removeItem(HIDDEN_KEY); } catch (error) { /* ignore */ }
839
885
  }, []);
840
886
 
841
887
  // 1s 心跳:驱动「今日运行时长」与相对时间
@@ -853,10 +899,9 @@ function summarizeMessage(raw) {
853
899
  for (const [id, session] of Object.entries(byId)) {
854
900
  if (!session || session.blank) continue;
855
901
  if (archived.has(String(id))) continue;
856
- // 2026-11-10 用户报「自测乙 / 权限自测 一直删不掉」:这类条目只活在前端快照里,
857
- // 磁盘会话目录早已消失 → 归档必然报 no such session,永远清不掉。
858
- // 「未分组」区早已按 alive 白名单过滤(见 looseMembers),项目分组这里漏了,补上。
859
- if (aliveIds && !aliveIds.has(String(id).replace(/^session-/, '').toLowerCase())) continue;
902
+ // 2026-11-10(用户令):白名单改「事后隐藏」——只跳过**归档失败过**的会话,
903
+ // 不再用磁盘白名单全量过滤(那样会让新建会话因轮询延迟而整段生命期不可见)。
904
+ if (hiddenIds.has(bareId(id))) continue;
860
905
  const parsed = projectOfTitle(session.displayTitle || session.title);
861
906
  const project = parsed ? parsed.project : LOOSE_GROUP;
862
907
  const member = parsed ? parsed.member : (session.displayTitle || session.title || String(id).slice(-6));
@@ -878,7 +923,7 @@ function summarizeMessage(raw) {
878
923
  });
879
924
  }
880
925
  return map;
881
- }, [byId, archived, aliveIds]);
926
+ }, [byId, archived, hiddenIds]);
882
927
 
883
928
  // 空闲时长补全(2026-11-09):对还没有 lastActive 的会话拉 /hub/session-events?limit=1
884
929
  // 取最后一条事件的 time。分身(标题带|)优先——用户最常盯的就是分身行。
@@ -999,10 +1044,9 @@ function summarizeMessage(raw) {
999
1044
  // 「未分组」= 标题里没有分隔符的会话(历史测试/一次性对话)。
1000
1045
  // 默认不显示,避免一大坨散落会话把项目列表挤下去;需要时点头部按钮展开。
1001
1046
  const looseAll = groups.get(LOOSE_GROUP) || [];
1002
- // 有白名单时丢掉磁盘上已不存在的幽灵条目(它们永远归档不了)
1003
- const looseMembers = aliveIds
1004
- ? looseAll.filter((m) => aliveIds.has(String(m.id).replace(/^session-/, '').toLowerCase()))
1005
- : looseAll;
1047
+ // 2026-11-10(用户令):白名单改「事后隐藏」后,groups 里已经按 hiddenIds 过滤过了,
1048
+ // 这里不再二次过滤(否则又回到「新建会话要等白名单」的老问题)。
1049
+ const looseMembers = looseAll;
1006
1050
  const shownProjects = React.useMemo(
1007
1051
  () => projectNames.filter((name) => showLoose || name !== LOOSE_GROUP),
1008
1052
  [projectNames, showLoose],
@@ -1063,8 +1107,13 @@ function summarizeMessage(raw) {
1063
1107
  });
1064
1108
  }, [groups, unreadMap]);
1065
1109
 
1110
+ // 2026-11-11 用户令「快捷栏正在运行和完成未读的分开放」:
1111
+ // 原来两种 pill 混在一个 flex 行里(完成未读还置顶排最前),用户扫一眼分不清谁在跑。
1112
+ // 现在拆成两个区、各带标题;渲染顺序按用户第二次要求 = **完成未读在上、运行中在下**。
1113
+ const runningGroups = runGroups.filter((group) => group.mode === 'running');
1114
+ const doneUnreadGroups = runGroups.filter((group) => group.mode === 'done');
1066
1115
  // 完成未读的组数(决定是否显示「全部已读」按钮)
1067
- const doneGroups = runGroups.filter((group) => group.mode === 'done').length;
1116
+ const doneGroups = doneUnreadGroups.length;
1068
1117
 
1069
1118
  const setPin = (name) => {
1070
1119
  const next = pinned.includes(name) ? pinned.filter((n) => n !== name) : [...pinned, name];
@@ -1090,11 +1139,25 @@ function summarizeMessage(raw) {
1090
1139
  const bodyIds = group.members.filter((m) => !m.isClone).map((m) => String(m.id));
1091
1140
  const isCurrent = Boolean(cursorSessionId) && bodyIds.includes(String(cursorSessionId));
1092
1141
  const done = group.mode === 'done';
1093
- const runMember = (group.running.length ? group.running : group.members)
1094
- .slice()
1142
+ // 2026-11-11 修「本体没有运行、快捷栏还在显示计时,而侧边栏正常」(用户截图):
1143
+ // 这个 pill 代表的是**本体**——名字取 group.name(本体名)、点击开本体、data-session-ids
1144
+ // 也只有本体。所以绿点与已运行时长必须来自**本体自己的 running**。
1145
+ // 原实现取 `group.running` 里最近活跃的那个成员:分身还在跑时,那一项就是分身,
1146
+ // 于是分身的已运行时长被画到本体 pill 上(截图里 pill 的 2m29s 与分身子行的 2m29s
1147
+ // 完全相等,而侧栏本体芯片没有时长 = 本体当时是空闲的)。
1148
+ // 侧栏一直是对的:本体芯片按 m.running 判、分身行按 c.running 判,两条各算各的。
1149
+ // 分身的运行状态由下面 .pr-run-clones 子行自己显示,信息不丢。
1150
+ // 孤儿分身组(本体已归档/改名,group.name 就是分身名)没有本体可参照 → 仍用组内运行成员。
1151
+ const bodyMemberOfGroup = (() => {
1152
+ const list = (groups && typeof groups.get === 'function') ? (groups.get(group.project) || []) : [];
1153
+ return list.find((m) => !m.isClone && m.name === group.name) || null;
1154
+ })();
1155
+ const runMember = (bodyMemberOfGroup ? [bodyMemberOfGroup] : (group.running.length ? group.running : group.members))
1156
+ .filter((m) => m && m.running)
1095
1157
  .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))[0];
1158
+ const bodyRunning = Boolean(runMember);
1096
1159
  const startedAt = runMember ? runStartRef.current.get(runMember.id) : undefined;
1097
- const elapsed = !done && runMember && startedAt ? formatDuration(tick - startedAt) : '';
1160
+ const elapsed = !done && bodyRunning && startedAt ? formatDuration(tick - startedAt) : '';
1098
1161
  const emoji = avatars && typeof avatars[group.name] === 'string' ? avatars[group.name].trim() : '';
1099
1162
  const hue = hashHue(group.project || group.name);
1100
1163
  return h('div', { className: 'pr-run-wrap', key: 'wrap-' + group.key }, h('button', {
@@ -1106,7 +1169,9 @@ function summarizeMessage(raw) {
1106
1169
  // 原来放全组成员 → 点分身时 pill(代表本体)也一起高亮。
1107
1170
  'data-session-ids': group.members.filter((mb) => !mb.isClone).map((mb) => String(mb.id)).join(','),
1108
1171
  title: `${group.project ? group.project + ' · ' : ''}${group.name}`
1109
- + (done ? ' · 完成待查看' : (elapsed ? ` · 运行中 ${elapsed}` : ' · 运行中'))
1172
+ // 2026-11-11:不再对空闲的本体谎称「运行中」——本体没跑时 pill 只报「分身运行中」。
1173
+ + (done ? ' · 完成待查看'
1174
+ : (bodyRunning ? (elapsed ? ` · 运行中 ${elapsed}` : ' · 运行中') : ' · 本体空闲(分身运行中)'))
1110
1175
  + (group.extra ? `(含 ${group.extra} 个分身)` : ''),
1111
1176
  onClick: (event) => {
1112
1177
  event.stopPropagation();
@@ -1153,7 +1218,9 @@ function summarizeMessage(raw) {
1153
1218
  group.extra > 0 ? h('span', { className: 'pr-run-badge', title: `${group.extra} 个分身` }, `+${group.extra}`) : null,
1154
1219
  done
1155
1220
  ? h('span', { className: 'pr-run-done', title: '已完成,点开查看' }, h('span', { className: 'pr-run-check' }, '✓'), '完成')
1156
- : h('span', { className: 'pr-running-dot', title: '运行中' }),
1221
+ // 2026-11-11:绿点也只跟本体走——本体空闲、只剩分身在跑时不给本体挂绿点
1222
+ // (分身子行自己有 pr-clone-dot.on,不丢状态)。
1223
+ : (bodyRunning ? h('span', { className: 'pr-running-dot', title: '运行中' }) : null),
1157
1224
  elapsed ? h('span', { className: 'pr-run-elapsed' }, elapsed) : null,
1158
1225
  ),
1159
1226
  group.clones && group.clones.length
@@ -1217,8 +1284,12 @@ function summarizeMessage(raw) {
1217
1284
  });
1218
1285
  const data = await res.json();
1219
1286
  if (data && data.ok) {
1220
- const failNote = Array.isArray(data.failed) && data.failed.length
1221
- ? `,失败 ${data.failed.length}(${String((data.failed[0] && data.failed[0].error) || '').slice(0, 60)})`
1287
+ // 2026-11-10(用户令「事后隐藏」):归档失败 = 磁盘上根本没这个会话(幽灵)。
1288
+ // 既然删不掉,就记进隐藏集,下次渲染跳过它。
1289
+ const failedList = Array.isArray(data.failed) ? data.failed : [];
1290
+ for (const f of failedList) hideSession(f && f.id);
1291
+ const failNote = failedList.length
1292
+ ? `,失败 ${failedList.length}(${String((failedList[0] && failedList[0].error) || '').slice(0, 60)};已隐藏)`
1222
1293
  : '';
1223
1294
  setClearMsg(`已归档 ${data.archived}/${data.total}${failNote}`);
1224
1295
  console.log('[project-room] archive result', data);
@@ -1260,8 +1331,20 @@ function summarizeMessage(raw) {
1260
1331
  body: JSON.stringify({ sessionIds: [menu.id] }),
1261
1332
  });
1262
1333
  const data = await res.json();
1263
- setMenuMsg(data && data.ok && data.archived ? '已归档' : `失败:${(data && data.failed && data.failed[0] && data.failed[0].error) || (data && data.error) || '未知'}`.slice(0, 80));
1264
- } catch (error) { setMenuMsg(`失败:${String((error && error.message) || error)}`.slice(0, 80)); }
1334
+ const okArchived = Boolean(data && data.ok && data.archived);
1335
+ if (okArchived) {
1336
+ setMenuMsg('已归档');
1337
+ } else {
1338
+ // 2026-11-10(用户令「事后隐藏」):单个归档失败 = 幽灵会话,记进隐藏集。
1339
+ hideSession(menu.id);
1340
+ const reason = (data && data.failed && data.failed[0] && data.failed[0].error)
1341
+ || (data && data.error) || '未知';
1342
+ setMenuMsg(`失败:${reason}(已隐藏)`.slice(0, 90));
1343
+ }
1344
+ } catch (error) {
1345
+ hideSession(menu.id);
1346
+ setMenuMsg(`失败:${String((error && error.message) || error)}(已隐藏)`.slice(0, 90));
1347
+ }
1265
1348
  },
1266
1349
  }, '归档这个会话'),
1267
1350
  h('button', { className: 'pr-menu-item', onClick: () => { copy(menu.id, ' sessionId'); } }, '复制 sessionId'),
@@ -1296,6 +1379,24 @@ function summarizeMessage(raw) {
1296
1379
  '注意:这只影响本插件的排序与默认名。若要让「管理者协议注入」也认这个名字,'
1297
1380
  + '还需要改 hub-desktop-ui 里的 MANAGER_NAMES(那是另一个插件的硬编码)。'),
1298
1381
  settings.msg ? h('div', { className: 'pr-err' }, settings.msg) : null,
1382
+ // 2026-11-10(用户令「事后隐藏」):归档失败过的会话被记进 localStorage 隐藏集,
1383
+ // 这里提供恢复入口,免得误隐藏之后找不回来。
1384
+ h('div', { className: 'pr-hint', style: { marginTop: '10px' } },
1385
+ `已隐藏的会话:${hiddenIds.size} 个`),
1386
+ h('button', {
1387
+ type: 'button',
1388
+ disabled: hiddenIds.size === 0,
1389
+ style: {
1390
+ marginTop: '4px', padding: '4px 10px', borderRadius: '6px', fontSize: '11px',
1391
+ border: '1px solid var(--dsw-alias-border-secondary, #3a3a42)',
1392
+ background: 'transparent', color: 'inherit',
1393
+ cursor: hiddenIds.size === 0 ? 'default' : 'pointer',
1394
+ opacity: hiddenIds.size === 0 ? 0.45 : 1,
1395
+ },
1396
+ onClick: () => { unhideAll(); setSettings({ ...settings, msg: '已恢复全部隐藏的会话' }); },
1397
+ }, '恢复全部隐藏的会话'),
1398
+ h('div', { className: 'pr-hint', style: { fontSize: '10px' } },
1399
+ '(这些是归档时报 no such session 的幽灵会话,磁盘上已不存在;恢复后会重新出现在侧栏)'),
1299
1400
  h('div', { className: 'pr-hint', style: { marginTop: '10px' } }, '显示'),
1300
1401
  h('label', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px', cursor: 'pointer', padding: '3px 0' } },
1301
1402
  h('input', {
@@ -1427,19 +1528,37 @@ function summarizeMessage(raw) {
1427
1528
  renderNewProject(),
1428
1529
  renderSettings(),
1429
1530
  h('div', { className: 'pr-runbar' },
1430
- // 一键已读:仅当存在「✓完成」pill 时出现,点一下清空未读表(等价逐个点开)
1431
- doneGroups > 0
1432
- ? h('button', {
1433
- className: 'pr-readall',
1434
- title: `全部已读(清掉 ${doneGroups} 个完成待查看)`,
1435
- onClick: (event) => {
1436
- event.stopPropagation();
1437
- saveJson('projectRoom.unread', {});
1438
- setUnread({});
1439
- },
1440
- }, '全部已读')
1531
+ // ── 区①:完成未读(2026-11-11 用户令:这一区排在运行中前面;一键已读只清这一区)──
1532
+ doneUnreadGroups.length
1533
+ ? h('div', { className: 'pr-runbar-sec', 'data-kind': 'done' },
1534
+ h('div', { className: 'pr-runbar-sec-title' },
1535
+ h('span', { className: 'pr-runbar-sec-check' }, '✓'),
1536
+ h('span', { className: 'pr-runbar-sec-label' }, '完成未读'),
1537
+ h('span', { className: 'pr-runbar-sec-count' }, String(doneUnreadGroups.length)),
1538
+ h('button', {
1539
+ className: 'pr-readall',
1540
+ title: `全部已读(清掉 ${doneGroups} 个完成待查看)`,
1541
+ onClick: (event) => {
1542
+ event.stopPropagation();
1543
+ saveJson('projectRoom.unread', {});
1544
+ setUnread({});
1545
+ },
1546
+ }, '全部已读'),
1547
+ ),
1548
+ h('div', { className: 'pr-runbar-sec-body' }, doneUnreadGroups.map(renderRunPill)),
1549
+ )
1550
+ : null,
1551
+ // ── 区②:运行中 ──
1552
+ runningGroups.length
1553
+ ? h('div', { className: 'pr-runbar-sec', 'data-kind': 'running' },
1554
+ h('div', { className: 'pr-runbar-sec-title' },
1555
+ h('span', { className: 'pr-runbar-sec-dot', title: '有 agent 正在运行' }),
1556
+ h('span', { className: 'pr-runbar-sec-label' }, '运行中'),
1557
+ h('span', { className: 'pr-runbar-sec-count' }, String(runningGroups.length)),
1558
+ ),
1559
+ h('div', { className: 'pr-runbar-sec-body' }, runningGroups.map(renderRunPill)),
1560
+ )
1441
1561
  : null,
1442
- runGroups.map(renderRunPill),
1443
1562
  ),
1444
1563
  h('div', { className: 'pr-head' },
1445
1564
  h('span', { className: 'pr-head-title' }, 'Projects'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-project-room",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "Project rooms for DeepSeek Harness: group sessions into projects, see per-project runtime, and open a room with a shared board, task list, todo queue and message delivery to member sessions.",
6
6
  "main": "index.js",