@sleep2agi/agent-network-dashboard 0.6.3-preview.14 → 0.6.3-preview.16

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 (220) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +3 -3
  3. package/.next/diagnostics/route-bundle-stats.json +73 -73
  4. package/.next/fallback-build-manifest.json +3 -3
  5. package/.next/prerender-manifest.json +3 -3
  6. package/.next/server/app/_global-error.html +1 -1
  7. package/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  14. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  15. package/.next/server/app/_not-found.html +2 -2
  16. package/.next/server/app/_not-found.rsc +13 -13
  17. package/.next/server/app/_not-found.segments/_full.segment.rsc +13 -13
  18. package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  19. package/.next/server/app/_not-found.segments/_index.segment.rsc +8 -8
  20. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  21. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  22. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/server/app/admin/page.js.nft.json +1 -1
  24. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  25. package/.next/server/app/admin.html +2 -2
  26. package/.next/server/app/admin.rsc +15 -15
  27. package/.next/server/app/admin.segments/_full.segment.rsc +15 -15
  28. package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
  29. package/.next/server/app/admin.segments/_index.segment.rsc +8 -8
  30. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  31. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
  32. package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
  33. package/.next/server/app/index.html +2 -2
  34. package/.next/server/app/index.rsc +15 -15
  35. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  36. package/.next/server/app/index.segments/_full.segment.rsc +15 -15
  37. package/.next/server/app/index.segments/_head.segment.rsc +4 -4
  38. package/.next/server/app/index.segments/_index.segment.rsc +8 -8
  39. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  40. package/.next/server/app/login/page.js.nft.json +1 -1
  41. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  42. package/.next/server/app/login.html +2 -2
  43. package/.next/server/app/login.rsc +15 -15
  44. package/.next/server/app/login.segments/_full.segment.rsc +15 -15
  45. package/.next/server/app/login.segments/_head.segment.rsc +4 -4
  46. package/.next/server/app/login.segments/_index.segment.rsc +8 -8
  47. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  48. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  49. package/.next/server/app/login.segments/login.segment.rsc +3 -3
  50. package/.next/server/app/logs/page.js.nft.json +1 -1
  51. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  52. package/.next/server/app/logs.html +2 -2
  53. package/.next/server/app/logs.rsc +15 -15
  54. package/.next/server/app/logs.segments/_full.segment.rsc +15 -15
  55. package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
  56. package/.next/server/app/logs.segments/_index.segment.rsc +8 -8
  57. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  58. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
  59. package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
  60. package/.next/server/app/messages/page.js.nft.json +1 -1
  61. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  62. package/.next/server/app/messages.html +2 -2
  63. package/.next/server/app/messages.rsc +15 -15
  64. package/.next/server/app/messages.segments/_full.segment.rsc +15 -15
  65. package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
  66. package/.next/server/app/messages.segments/_index.segment.rsc +8 -8
  67. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  68. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
  69. package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
  70. package/.next/server/app/node/page.js.nft.json +1 -1
  71. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  72. package/.next/server/app/node.html +2 -2
  73. package/.next/server/app/node.rsc +15 -15
  74. package/.next/server/app/node.segments/_full.segment.rsc +15 -15
  75. package/.next/server/app/node.segments/_head.segment.rsc +4 -4
  76. package/.next/server/app/node.segments/_index.segment.rsc +8 -8
  77. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  78. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
  79. package/.next/server/app/node.segments/node.segment.rsc +3 -3
  80. package/.next/server/app/nodes/page.js.nft.json +1 -1
  81. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  82. package/.next/server/app/nodes.html +2 -2
  83. package/.next/server/app/nodes.rsc +15 -15
  84. package/.next/server/app/nodes.segments/_full.segment.rsc +15 -15
  85. package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
  86. package/.next/server/app/nodes.segments/_index.segment.rsc +8 -8
  87. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  88. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
  89. package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
  90. package/.next/server/app/page.js.nft.json +1 -1
  91. package/.next/server/app/page_client-reference-manifest.js +1 -1
  92. package/.next/server/app/providers/page.js.nft.json +1 -1
  93. package/.next/server/app/providers/page_client-reference-manifest.js +1 -1
  94. package/.next/server/app/providers.html +2 -2
  95. package/.next/server/app/providers.rsc +15 -15
  96. package/.next/server/app/providers.segments/_full.segment.rsc +15 -15
  97. package/.next/server/app/providers.segments/_head.segment.rsc +4 -4
  98. package/.next/server/app/providers.segments/_index.segment.rsc +8 -8
  99. package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
  100. package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +4 -4
  101. package/.next/server/app/providers.segments/providers.segment.rsc +3 -3
  102. package/.next/server/app/server-logs/page.js.nft.json +1 -1
  103. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  104. package/.next/server/app/server-logs.html +2 -2
  105. package/.next/server/app/server-logs.rsc +15 -15
  106. package/.next/server/app/server-logs.segments/_full.segment.rsc +15 -15
  107. package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
  108. package/.next/server/app/server-logs.segments/_index.segment.rsc +8 -8
  109. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  110. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
  111. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
  112. package/.next/server/app/servers/page.js.nft.json +1 -1
  113. package/.next/server/app/servers/page_client-reference-manifest.js +1 -1
  114. package/.next/server/app/servers.html +2 -2
  115. package/.next/server/app/servers.rsc +15 -15
  116. package/.next/server/app/servers.segments/_full.segment.rsc +15 -15
  117. package/.next/server/app/servers.segments/_head.segment.rsc +4 -4
  118. package/.next/server/app/servers.segments/_index.segment.rsc +8 -8
  119. package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
  120. package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +4 -4
  121. package/.next/server/app/servers.segments/servers.segment.rsc +3 -3
  122. package/.next/server/app/settings/networks/page.js.nft.json +1 -1
  123. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  124. package/.next/server/app/settings/networks.html +2 -2
  125. package/.next/server/app/settings/networks.rsc +15 -15
  126. package/.next/server/app/settings/networks.segments/_full.segment.rsc +15 -15
  127. package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
  128. package/.next/server/app/settings/networks.segments/_index.segment.rsc +8 -8
  129. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  130. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
  131. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
  132. package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
  133. package/.next/server/app/settings/page.js.nft.json +1 -1
  134. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  135. package/.next/server/app/settings/tokens/page.js.nft.json +1 -1
  136. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  137. package/.next/server/app/settings/tokens.html +2 -2
  138. package/.next/server/app/settings/tokens.rsc +15 -15
  139. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +15 -15
  140. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
  141. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +8 -8
  142. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  143. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
  144. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
  145. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
  146. package/.next/server/app/settings.html +2 -2
  147. package/.next/server/app/settings.rsc +15 -15
  148. package/.next/server/app/settings.segments/_full.segment.rsc +15 -15
  149. package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  150. package/.next/server/app/settings.segments/_index.segment.rsc +8 -8
  151. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  152. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  153. package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  154. package/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  155. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  156. package/.next/server/app/tasks/page.js.nft.json +1 -1
  157. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  158. package/.next/server/app/tasks.html +2 -2
  159. package/.next/server/app/tasks.rsc +15 -15
  160. package/.next/server/app/tasks.segments/_full.segment.rsc +15 -15
  161. package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
  162. package/.next/server/app/tasks.segments/_index.segment.rsc +8 -8
  163. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  164. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
  165. package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
  166. package/.next/server/chunks/ssr/[root-of-the-server]__03jniap._.js +3 -0
  167. package/.next/server/chunks/ssr/[root-of-the-server]__03jniap._.js.map +1 -0
  168. package/.next/server/chunks/ssr/[root-of-the-server]__091su60._.js +2 -2
  169. package/.next/server/chunks/ssr/[root-of-the-server]__091su60._.js.map +1 -1
  170. package/.next/server/chunks/ssr/[root-of-the-server]__0vc.3k7._.js +1 -1
  171. package/.next/server/chunks/ssr/[root-of-the-server]__0vc.3k7._.js.map +1 -1
  172. package/.next/server/chunks/ssr/dash-loop-wt_0rh8p9o._.js +1 -1
  173. package/.next/server/chunks/ssr/dash-loop-wt_0rh8p9o._.js.map +1 -1
  174. package/.next/server/chunks/ssr/dash-loop-wt_app_0mxr2l9._.js +2 -2
  175. package/.next/server/chunks/ssr/dash-loop-wt_app_0mxr2l9._.js.map +1 -1
  176. package/.next/server/chunks/ssr/dash-loop-wt_app_0~.1~ew._.js +1 -1
  177. package/.next/server/chunks/ssr/dash-loop-wt_app_0~.1~ew._.js.map +1 -1
  178. package/.next/server/chunks/ssr/dash-loop-wt_app_13k3-ai._.js +1 -1
  179. package/.next/server/chunks/ssr/dash-loop-wt_app_13k3-ai._.js.map +1 -1
  180. package/.next/server/chunks/ssr/dash-loop-wt_app_components_0cym4cd._.js +1 -1
  181. package/.next/server/chunks/ssr/dash-loop-wt_app_components_0cym4cd._.js.map +1 -1
  182. package/.next/server/chunks/ssr/dash-loop-wt_app_nodes_page_tsx_0ma3oqt._.js +1 -1
  183. package/.next/server/chunks/ssr/dash-loop-wt_app_nodes_page_tsx_0ma3oqt._.js.map +1 -1
  184. package/.next/server/middleware-build-manifest.js +3 -3
  185. package/.next/server/pages/404.html +2 -2
  186. package/.next/server/pages/500.html +1 -1
  187. package/.next/server/server-reference-manifest.js +1 -1
  188. package/.next/server/server-reference-manifest.json +1 -1
  189. package/.next/static/chunks/{0v-2rx4h02~2h.js → 06qwjph2.xkng.js} +2 -2
  190. package/.next/static/chunks/{1566p5peao240.js → 08cv_lh8tw-p7.js} +1 -1
  191. package/.next/static/chunks/{0g23p135n0swi.js → 0a229b11_ks8h.js} +2 -2
  192. package/.next/static/chunks/{0wd05udmyiwvk.js → 0fow_ssfwva9g.js} +2 -2
  193. package/.next/static/chunks/0imomomvqzqmm.js +1 -0
  194. package/.next/static/chunks/{146p-0l764ojl.js → 0n8tkdk74r.vt.js} +1 -1
  195. package/.next/static/chunks/{00_m_epf7h806.js → 0p6os-912pt-t.js} +1 -1
  196. package/.next/static/chunks/{16_v-g.qr-.xa.js → 0qgybbsb9e6q4.js} +1 -1
  197. package/.next/static/chunks/{0_4kwsiltvp~l.js → 0w4ia6l0l2nx6.js} +1 -1
  198. package/.next/static/chunks/0xz6zlg032d_4.css +1 -0
  199. package/.next/static/chunks/11.o41gzk3vi4.js +1 -0
  200. package/.next/trace +2 -2
  201. package/.next/trace-build +1 -1
  202. package/app/components/AgentCard.tsx +23 -3
  203. package/app/components/CommandCenter.tsx +22 -3
  204. package/app/components/MobileNav.tsx +14 -3
  205. package/app/components/TaskChatPanel.tsx +101 -30
  206. package/app/lib/chat-drafts.ts +46 -0
  207. package/app/lib/chat-unread.ts +70 -41
  208. package/app/lib/time.ts +27 -0
  209. package/app/messages/page.tsx +5 -3
  210. package/app/nodes/page.tsx +17 -3
  211. package/app/page.tsx +4 -1
  212. package/package.json +1 -1
  213. package/.next/server/chunks/ssr/[root-of-the-server]__0gv51is._.js +0 -3
  214. package/.next/server/chunks/ssr/[root-of-the-server]__0gv51is._.js.map +0 -1
  215. package/.next/static/chunks/0vz2kzk6pucsq.js +0 -1
  216. package/.next/static/chunks/14d2lfzm8pyqn.js +0 -1
  217. package/.next/static/chunks/180t32do_.c~c.css +0 -1
  218. /package/.next/static/{qpprKezd_zujs8ouGr3LY → waj5SPgHKhI524_18FcZK}/_buildManifest.js +0 -0
  219. /package/.next/static/{qpprKezd_zujs8ouGr3LY → waj5SPgHKhI524_18FcZK}/_clientMiddlewareManifest.js +0 -0
  220. /package/.next/static/{qpprKezd_zujs8ouGr3LY → waj5SPgHKhI524_18FcZK}/_ssgManifest.js +0 -0
@@ -5,6 +5,7 @@ import Link from 'next/link';
5
5
  import { Session } from './types';
6
6
  import { timeAgo } from './utils';
7
7
  import { AliasAvatar } from './AliasAvatar';
8
+ import { useHasDraft } from '../lib/chat-drafts';
8
9
  import { NodeSettingsPanel } from './NodeSettingsPanel';
9
10
 
10
11
  interface AgentCardProps {
@@ -12,6 +13,10 @@ interface AgentCardProps {
12
13
  hasSse: boolean;
13
14
  sseCount: number;
14
15
  onChat?: (alias: string) => void;
16
+ /** R8 (Vincent: 微信式未读角标): unread count for this conversation.
17
+ * Passed as a prop (computed once page-level) so the R6 memo keeps
18
+ * protecting against fleet-wide re-render storms. */
19
+ unreadCount?: number;
15
20
  }
16
21
 
17
22
  const STATUS_CONFIG: Record<string, { bg: string; text: string; dot: string; glow: string }> = {
@@ -23,7 +28,11 @@ const STATUS_CONFIG: Record<string, { bg: string; text: string; dot: string; glo
23
28
 
24
29
  const DEFAULT_STATUS = { bg: 'bg-gray-800/50 border-gray-700/30', text: 'text-gray-500', dot: 'bg-gray-500', glow: '' };
25
30
 
26
- function AgentCardInner({ session: s, hasSse, sseCount, onChat }: AgentCardProps) {
31
+ function AgentCardInner({ session: s, hasSse, sseCount, onChat, unreadCount = 0 }: AgentCardProps) {
32
+ // R10 微信草稿闭环: list surfaces show which conversation has an unsent
33
+ // draft. Internal subscription (useSyncExternalStore) so the memo
34
+ // comparator stays untouched — a draft change re-renders only this card.
35
+ const hasDraft = useHasDraft(s.alias);
27
36
  const cfg = hasSse ? (STATUS_CONFIG[s.status] || DEFAULT_STATUS) : DEFAULT_STATUS;
28
37
  // #260: per-node settings panel (UI-only stub). Local open state — the
29
38
  // panel itself is presentational and issues no backend calls.
@@ -65,8 +74,19 @@ function AgentCardInner({ session: s, hasSse, sseCount, onChat }: AgentCardProps
65
74
  tightens on narrow viewports. */}
66
75
  <div className="flex items-center justify-between mb-2 sm:mb-3">
67
76
  <div className="flex items-center gap-2 min-w-0">
68
- <AliasAvatar alias={s.alias} size={22} />
77
+ <span className="relative inline-flex shrink-0">
78
+ <AliasAvatar alias={s.alias} size={22} />
79
+ {unreadCount > 0 && (
80
+ <span
81
+ aria-label={`${unreadCount} 条未读`}
82
+ className="absolute -top-1.5 -right-2 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-semibold leading-4 text-center ring-2 ring-[#161618]"
83
+ >
84
+ {unreadCount > 99 ? '99+' : unreadCount}
85
+ </span>
86
+ )}
87
+ </span>
69
88
  <span className="font-semibold text-white truncate text-sm" title={s.alias}>{s.alias}</span>
89
+ {hasDraft && <span className="shrink-0 text-[9px] text-red-400/90" aria-label="有未发送草稿">[草稿]</span>}
70
90
  <span className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${cfg.dot} ${hasSse && s.status === 'working' ? 'animate-pulse' : ''}`} />
71
91
  </div>
72
92
  <div className="flex items-center gap-1 shrink-0">
@@ -194,7 +214,7 @@ const CARD_FIELDS = [
194
214
  ] as const;
195
215
 
196
216
  export const AgentCard = memo(AgentCardInner, (prev, next) => {
197
- if (prev.hasSse !== next.hasSse || prev.sseCount !== next.sseCount || prev.onChat !== next.onChat) return false;
217
+ if (prev.hasSse !== next.hasSse || prev.sseCount !== next.sseCount || prev.onChat !== next.onChat || prev.unreadCount !== next.unreadCount) return false;
198
218
  const a = prev.session as unknown as Record<string, unknown>, b = next.session as unknown as Record<string, unknown>;
199
219
  for (const k of CARD_FIELDS) if (a[k] !== b[k]) return false;
200
220
  return true;
@@ -1,6 +1,8 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useCallback } from 'react';
4
+ import { useChatUnread } from '../lib/chat-unread';
5
+ import { useHasDraft } from '../lib/chat-drafts';
4
6
  import { TaskChatPanel } from './TaskChatPanel';
5
7
 
6
8
  interface CommandCenterProps {
@@ -18,6 +20,7 @@ interface CommandCenterProps {
18
20
  * Wraps TaskChatPanel with a tab bar for switching between agents.
19
21
  */
20
22
  export function CommandCenter({ tabs, activeTab, onOpenTab, onCloseTab, onSetActive, onClose }: CommandCenterProps) {
23
+ const { unreadCount } = useChatUnread();
21
24
  if (tabs.length === 0) return null;
22
25
 
23
26
  return (
@@ -42,6 +45,14 @@ export function CommandCenter({ tabs, activeTab, onOpenTab, onCloseTab, onSetAct
42
45
  >
43
46
  <div className={`w-2 h-2 rounded-full ${activeTab === alias ? 'bg-cyan-400' : 'bg-gray-600'}`} />
44
47
  <span className="max-w-[80px] truncate">{alias}</span>
48
+ {/* R8: WeChat unread badge on BACKGROUND tabs (the active
49
+ tab is being read — no badge). */}
50
+ {activeTab !== alias && <TabDraftMark alias={alias} />}
51
+ {activeTab !== alias && unreadCount(alias) > 0 && (
52
+ <span className="min-w-[15px] h-[15px] px-1 rounded-full bg-red-500 text-white text-[9px] font-semibold leading-[15px] text-center">
53
+ {unreadCount(alias) > 99 ? '99+' : unreadCount(alias)}
54
+ </span>
55
+ )}
45
56
  {/* R21 of #190 mobile a11y: nested close had no aria-label
46
57
  (screen-readers spoke just "button") and an ~8 px
47
58
  tap target (p-0.5 + text "×"). Both fixed; nested
@@ -72,7 +83,7 @@ export function CommandCenter({ tabs, activeTab, onOpenTab, onCloseTab, onSetAct
72
83
  <div className="flex-1 relative overflow-hidden">
73
84
  {tabs.map(alias => (
74
85
  <div key={alias} className={`absolute inset-0 ${activeTab === alias ? 'block' : 'hidden'}`}>
75
- <InlineChat alias={alias} />
86
+ <InlineChat alias={alias} active={activeTab === alias} />
76
87
  </div>
77
88
  ))}
78
89
  </div>
@@ -91,10 +102,18 @@ export function CommandCenter({ tabs, activeTab, onOpenTab, onCloseTab, onSetAct
91
102
  );
92
103
  }
93
104
 
105
+ /** R10: "[草稿]" mark on background tabs — per-alias hook needs its own
106
+ * component (hooks can't run inside the tabs.map callback). */
107
+ function TabDraftMark({ alias }: { alias: string }) {
108
+ const hasDraft = useHasDraft(alias);
109
+ if (!hasDraft) return null;
110
+ return <span className="text-[9px] text-red-400/90 shrink-0">[草稿]</span>;
111
+ }
112
+
94
113
  /** Inline chat without the panel chrome (used inside CommandCenter tabs) */
95
- function InlineChat({ alias }: { alias: string }) {
114
+ function InlineChat({ alias, active }: { alias: string; active: boolean }) {
96
115
  // Reuse TaskChatPanel's logic but render without the outer frame
97
- return <TaskChatPanel alias={alias} onClose={() => {}} inline />;
116
+ return <TaskChatPanel alias={alias} onClose={() => {}} inline active={active} />;
98
117
  }
99
118
 
100
119
  /**
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import Link from 'next/link';
4
+ import { useChatUnread, badgeLabel } from '../lib/chat-unread';
4
5
  import { usePathname } from 'next/navigation';
5
6
 
6
7
  // #209 R25 (Vincent msg 529 "command 留着干嘛" / 530 "Agents 放到最前面?"
@@ -25,6 +26,9 @@ const MOBILE_NAV_ITEMS = [
25
26
 
26
27
  export function MobileNav() {
27
28
  const pathname = usePathname();
29
+ // R8: WeChat-style total unread on the Agents tab (a glance answers
30
+ // "有没有人找我" from anywhere in the app).
31
+ const { totalUnread } = useChatUnread();
28
32
  // Match Sidebar.tsx isActive — /settings must be exact-match because
29
33
  // Settings subpages (/settings/tokens, /settings/networks) have their
30
34
  // own headers; startsWith would keep the bottom-nav highlighted there
@@ -52,9 +56,16 @@ export function MobileNav() {
52
56
  : 'text-gray-500 active:bg-[#232327] active:text-gray-200'
53
57
  }`}
54
58
  >
55
- <svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.6}>
56
- <path strokeLinecap="round" strokeLinejoin="round" d={item.icon} />
57
- </svg>
59
+ <span className="relative inline-flex">
60
+ <svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.6}>
61
+ <path strokeLinecap="round" strokeLinejoin="round" d={item.icon} />
62
+ </svg>
63
+ {item.href === '/nodes' && totalUnread > 0 && (
64
+ <span className="absolute -top-1 -right-2.5 min-w-[15px] h-[15px] px-1 rounded-full bg-red-500 text-white text-[9px] font-semibold leading-[15px] text-center">
65
+ {badgeLabel(totalUnread)}
66
+ </span>
67
+ )}
68
+ </span>
58
69
  <span className="max-w-full truncate">{item.label}</span>
59
70
  </Link>
60
71
  );
@@ -29,30 +29,6 @@ type ChatEvent =
29
29
  title tooltip. */
30
30
  const TIME_GROUP_GAP_MS = 5 * 60 * 1000;
31
31
 
32
- // R4 polish: locale-aware (R3 hardcoded Chinese weekdays — English users saw
33
- // 星期二). zh keeps the native WeChat wording; other locales get Intl output.
34
- function formatWeChatTime(value: string): string {
35
- const t = eventTime(value);
36
- if (!t) return '';
37
- const d = new Date(t);
38
- const now = new Date();
39
- const zh = typeof navigator !== 'undefined' && (navigator.language || '').toLowerCase().startsWith('zh');
40
- const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
41
- const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
42
- const dayDiff = Math.round((startOfDay(now) - startOfDay(d)) / 86400000);
43
- if (dayDiff === 0) return hm;
44
- if (dayDiff === 1) return zh ? `昨天 ${hm}` : `Yesterday ${hm}`;
45
- if (dayDiff > 1 && dayDiff < 7) {
46
- const wd = new Intl.DateTimeFormat(zh ? 'zh-CN' : undefined, { weekday: zh ? 'long' : 'short' }).format(d);
47
- return `${wd} ${hm}`;
48
- }
49
- const md = new Intl.DateTimeFormat(zh ? 'zh-CN' : undefined, {
50
- month: zh ? 'long' : 'short', day: 'numeric',
51
- ...(d.getFullYear() !== now.getFullYear() ? { year: 'numeric' } : {}),
52
- }).format(d);
53
- return `${md} ${hm}`;
54
- }
55
-
56
32
  function eventTime(value?: string) {
57
33
  if (!value) return 0;
58
34
  return new Date(value.replace(' ', 'T') + (value.includes('T') ? '' : 'Z')).getTime() || 0;
@@ -171,6 +147,8 @@ import remarkGfm from 'remark-gfm';
171
147
  import { useSSE } from '../lib/useSSE';
172
148
  import { useNetworkId } from '../lib/network-context';
173
149
  import { markChatRead } from '../lib/chat-unread';
150
+ import { readDraft, writeDraft } from '../lib/chat-drafts';
151
+ import { formatWeChatTime } from '../lib/time';
174
152
 
175
153
  function MarkdownContent({ text }: { text: string }) {
176
154
  if (!text) return <span className="text-[var(--fg-dim)] italic">No content</span>;
@@ -229,6 +207,10 @@ interface TaskChatPanelProps {
229
207
  inline?: boolean;
230
208
  /** Available nodes for @ mention */
231
209
  availableNodes?: string[];
210
+ /** Loop R11: false for background CommandCenter tabs — pauses the 15s
211
+ * soft-refresh AND the SSE-event soft-refresh (in-flight send patches
212
+ * stay live). Reactivation runs one immediate catch-up. Default true. */
213
+ active?: boolean;
232
214
  }
233
215
 
234
216
  /** P0 chat batch (Vincent: 长消息=无排版文字墙): message bodies taller than
@@ -279,7 +261,8 @@ function CollapsibleText({ children }: { children: React.ReactNode }) {
279
261
  );
280
262
  }
281
263
 
282
- export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskChatPanelProps) {
264
+ export function TaskChatPanel({ alias, onClose, inline, availableNodes, active }: TaskChatPanelProps) {
265
+ const isActive = active !== false;
283
266
  const { networkId } = useNetworkId();
284
267
  const [messages, setMessages] = useState<ChatTask[]>([]);
285
268
  const [input, setInput] = useState('');
@@ -312,6 +295,16 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
312
295
  const anchoredRef = useRef(false);
313
296
  const [hasOlder, setHasOlder] = useState(true);
314
297
  const [loadingOlder, setLoadingOlder] = useState(false);
298
+ // Loop R10 (微信式"下方有新消息"): while the user is scrolled up reading
299
+ // history, incoming messages must NOT yank the view to the bottom —
300
+ // they accumulate in a floating pill instead. atBottomRef mirrors the
301
+ // user's last real scroll position (checking the DOM after an append
302
+ // would always look "not at bottom" because the new content itself
303
+ // grew the distance).
304
+ const [newBelow, setNewBelow] = useState(0);
305
+ const atBottomRef = useRef(true);
306
+ const prevBubblesRef = useRef(0);
307
+ const forceScrollRef = useRef(false);
315
308
 
316
309
  // Load available nodes for @ mention
317
310
  // alias → network_id map for send scoping (P0 network_id fix): the
@@ -332,6 +325,29 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
332
325
  // Reset target when alias changes
333
326
  useEffect(() => { setTargetAlias(alias); }, [alias]);
334
327
 
328
+ // Loop R9 (微信草稿): unsent text survives closing the panel, switching
329
+ // conversations and refreshing, per conversation. Text only — File
330
+ // attachments can't be serialized. The debounced write covers "typing,
331
+ // then refresh"; the effect cleanup covers "typing, then close within
332
+ // the debounce window" (the timer dies with the unmount, so the tail
333
+ // would otherwise be lost). Empty input deletes the draft, so sending
334
+ // or clearing the box leaves no stale draft behind.
335
+ const inputRef = useRef(input);
336
+ inputRef.current = input;
337
+ useEffect(() => {
338
+ if (!alias) return;
339
+ const draft = readDraft(alias);
340
+ if (draft) {
341
+ setInput(draft);
342
+ requestAnimationFrame(() => { if (textareaRef.current) autoResize(textareaRef.current); });
343
+ }
344
+ return () => { writeDraft(alias, inputRef.current); };
345
+ }, [alias]);
346
+ useEffect(() => {
347
+ const timer = setTimeout(() => writeDraft(alias, input), 300);
348
+ return () => clearTimeout(timer);
349
+ }, [input, alias]);
350
+
335
351
  useEffect(() => {
336
352
  if (!alias) return;
337
353
  markChatRead(alias, networkId);
@@ -410,6 +426,9 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
410
426
  histLimitRef.current = HISTORY_PAGE;
411
427
  anchoredRef.current = false;
412
428
  firstAnchorRef.current = true;
429
+ atBottomRef.current = true;
430
+ prevBubblesRef.current = 0;
431
+ setNewBelow(0);
413
432
  setHasOlder(true);
414
433
  loadInitial();
415
434
  // Focus textarea
@@ -435,12 +454,15 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
435
454
  const onMessagesScroll = useCallback(() => {
436
455
  const el = scrollRef.current;
437
456
  if (!el) return;
457
+ const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
458
+ atBottomRef.current = dist < 80;
459
+ if (atBottomRef.current) setNewBelow(0);
438
460
  // The initial bottom-anchor is a smooth scroll, so its own scroll
439
461
  // events pass through scrollTop≈0 territory. Arm the older-page
440
462
  // trigger only after the container has actually reached the bottom
441
463
  // once — everything before that is layout/auto-scroll noise.
442
464
  if (!anchoredRef.current) {
443
- if (el.scrollHeight - el.scrollTop - el.clientHeight < 80) anchoredRef.current = true;
465
+ if (dist < 80) anchoredRef.current = true;
444
466
  return;
445
467
  }
446
468
  if (el.scrollTop < 40) loadOlder();
@@ -454,12 +476,24 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
454
476
  // loaded at once". Subsequent new-message anchors stay smooth.
455
477
  const firstAnchorRef = useRef(true);
456
478
  useEffect(() => {
479
+ const bubbles = messages.reduce((n, m) => n + (m.result ? 2 : 1), 0);
480
+ const prevBubbles = prevBubblesRef.current;
481
+ prevBubblesRef.current = bubbles;
457
482
  if (skipAutoScrollRef.current) {
458
483
  skipAutoScrollRef.current = false;
459
484
  return;
460
485
  }
486
+ if (messages.length === 0) return;
487
+ // R10: scrolled-up readers keep their place — count the arrivals
488
+ // into the pill instead of yanking to the bottom. Own sends
489
+ // (forceScrollRef) and the first anchor always jump.
490
+ if (!firstAnchorRef.current && !forceScrollRef.current && !atBottomRef.current) {
491
+ if (bubbles > prevBubbles) setNewBelow(n => n + (bubbles - prevBubbles));
492
+ return;
493
+ }
461
494
  const behavior: ScrollBehavior = firstAnchorRef.current ? 'auto' : 'smooth';
462
- if (messages.length > 0) firstAnchorRef.current = false;
495
+ firstAnchorRef.current = false;
496
+ forceScrollRef.current = false;
463
497
  messagesEndRef.current?.scrollIntoView({ behavior });
464
498
  }, [messages]);
465
499
 
@@ -478,6 +512,7 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
478
512
  // window (debounced 1.5s trailing; runFetch merge-by-id keeps
479
513
  // optimistic bubbles, so a refresh never drops an in-flight send).
480
514
  const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
515
+ const lastSoftRefreshRef = useRef(0);
481
516
  useEffect(() => () => { if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); }, []);
482
517
  useSSE({
483
518
  url: '/api/hub/events',
@@ -500,10 +535,18 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
500
535
  }
501
536
  if (!['new_task', 'new_message', 'new_reply', 'broadcast'].includes(t)) return;
502
537
  if (!historyLoaded) return; // initial load owns the first paint
538
+ if (!isActive) return; // R11: background tabs catch up on activation instead
539
+ // R11: floor between soft refreshes — on a busy fleet (192 agents)
540
+ // events arrive faster than the 1.5s debounce can coalesce, and the
541
+ // active panel refetched its window ~25×/min. One event batch every
542
+ // ≥6s is plenty for a 12-item history window; sends still patch
543
+ // instantly via the fast path above.
503
544
  if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
545
+ const wait = Math.max(1500, 6000 - (Date.now() - lastSoftRefreshRef.current));
504
546
  refreshTimerRef.current = setTimeout(() => {
547
+ lastSoftRefreshRef.current = Date.now();
505
548
  runFetch(histLimitRef.current, 12_000);
506
- }, 1500);
549
+ }, wait);
507
550
  },
508
551
  });
509
552
 
@@ -514,14 +557,25 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
514
557
  // stream (ask filed), an OPEN panel soft-refreshes every 15s — paused
515
558
  // while the tab is hidden — so incoming messages appear without a
516
559
  // reopen. runFetch's merge-by-id keeps optimistic bubbles intact.
560
+ // R11 (多面板降频): CommandCenter keeps EVERY tab mounted, so N tabs
561
+ // used to mean N pollers AND N per-SSE-event soft-refreshers — measured
562
+ // 40 history-window fetches/min with 4 tabs on a busy fleet. Only the
563
+ // visible tab refreshes now; a tab returning to the foreground does one
564
+ // immediate catch-up fetch so it never shows stale history.
565
+ const wasActiveRef = useRef(isActive);
517
566
  useEffect(() => {
518
- if (!historyLoaded) return;
567
+ if (!historyLoaded || !isActive) {
568
+ wasActiveRef.current = isActive;
569
+ return;
570
+ }
571
+ if (!wasActiveRef.current) runFetch(histLimitRef.current, 12_000);
572
+ wasActiveRef.current = true;
519
573
  const id = setInterval(() => {
520
574
  if (document.visibilityState === 'hidden') return;
521
575
  runFetch(histLimitRef.current, 12_000);
522
576
  }, 15_000);
523
577
  return () => clearInterval(id);
524
- }, [historyLoaded, runFetch]);
578
+ }, [historyLoaded, isActive, runFetch]);
525
579
 
526
580
  // Polling fallback (SSE-unavailable / proxies stripping events).
527
581
  // Slow cadence so we're not the bulk of server load — SSE handles the fast path.
@@ -584,6 +638,8 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
584
638
 
585
639
  setSending(true);
586
640
  setInput('');
641
+ writeDraft(alias, '');
642
+ forceScrollRef.current = true;
587
643
  setAttachedFiles([]);
588
644
  setShowMentions(false);
589
645
  if (textareaRef.current) textareaRef.current.style.height = 'auto';
@@ -922,6 +978,21 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
922
978
  );
923
979
  })}
924
980
  <div ref={messagesEndRef} />
981
+ {newBelow > 0 && (
982
+ <div className="sticky bottom-1 z-10 flex justify-center pointer-events-none">
983
+ <button
984
+ type="button"
985
+ onClick={() => {
986
+ setNewBelow(0);
987
+ atBottomRef.current = true;
988
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
989
+ }}
990
+ className="pointer-events-auto flex items-center gap-1 rounded-full border border-cyan-500/30 bg-[var(--bg-elevated)] px-3 py-1 text-[11px] text-cyan-300 shadow-lg shadow-black/40 hover:bg-cyan-500/10"
991
+ >
992
+ ↓ {newBelow} 条新消息
993
+ </button>
994
+ </div>
995
+ )}
925
996
  </div>
926
997
 
927
998
  {/* Input area */}
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ import { useSyncExternalStore } from 'react';
4
+
5
+ // Loop R10 (微信草稿闭环): draft storage shared between the chat panel
6
+ // (write/restore) and conversation-list surfaces (the "[草稿]" mark).
7
+ // Same-tab reactivity via a custom event, cross-tab via the storage event.
8
+ export const DRAFT_PREFIX = 'anet_chat_draft_v1:';
9
+ const DRAFT_EVENT = 'anet-chat-draft-updated';
10
+
11
+ export function readDraft(alias?: string | null): string {
12
+ if (typeof window === 'undefined' || !alias) return '';
13
+ try {
14
+ return window.localStorage.getItem(DRAFT_PREFIX + alias) || '';
15
+ } catch {
16
+ return '';
17
+ }
18
+ }
19
+
20
+ /** Empty/whitespace text deletes the draft — sending or clearing the box
21
+ * leaves no stale key behind. */
22
+ export function writeDraft(alias: string, text: string) {
23
+ if (typeof window === 'undefined' || !alias) return;
24
+ try {
25
+ if (text.trim()) window.localStorage.setItem(DRAFT_PREFIX + alias, text);
26
+ else window.localStorage.removeItem(DRAFT_PREFIX + alias);
27
+ window.dispatchEvent(new CustomEvent(DRAFT_EVENT, { detail: { alias } }));
28
+ } catch {}
29
+ }
30
+
31
+ function subscribe(callback: () => void) {
32
+ window.addEventListener(DRAFT_EVENT, callback);
33
+ window.addEventListener('storage', callback);
34
+ return () => {
35
+ window.removeEventListener(DRAFT_EVENT, callback);
36
+ window.removeEventListener('storage', callback);
37
+ };
38
+ }
39
+
40
+ export function useHasDraft(alias?: string | null): boolean {
41
+ return useSyncExternalStore(
42
+ subscribe,
43
+ () => (alias ? Boolean(readDraft(alias)) : false),
44
+ () => false,
45
+ );
46
+ }
@@ -9,17 +9,14 @@ const UNREAD_EVENT = 'anet-chat-read-updated';
9
9
 
10
10
  interface ChatMessage {
11
11
  from_alias?: string | null;
12
+ to_alias?: string | null;
12
13
  created_at?: string | null;
13
14
  }
14
15
 
15
- interface ChatTask {
16
- to_name?: string | null;
17
- status?: string | null;
18
- result?: string | null;
19
- created_at?: string | null;
20
- completed_at?: string | null;
21
- delivered_at?: string | null;
22
- }
16
+ /** Senders/recipients that mean "the dashboard user" in hub data. The chat
17
+ * panel uses the same set to decide which bubbles are "You". */
18
+ const USERISH = new Set(['', 'dashboard', 'admin', 'api', 'hub']);
19
+ const isUserish = (v?: string | null) => USERISH.has((v || '').trim().toLowerCase());
23
20
 
24
21
  interface ReadMap {
25
22
  [alias: string]: string;
@@ -37,13 +34,32 @@ function parseHubTime(value?: string | null): number {
37
34
  return Number.isFinite(ts) ? ts : 0;
38
35
  }
39
36
 
37
+ // Loop R8 (Vincent: 未读数字角标): last-read moved sessionStorage →
38
+ // localStorage so read state survives refresh/new tabs/restart (微信预期).
39
+ // One-time migration pulls any old session value forward.
40
+ const BASELINE_KEY = '__baseline';
41
+
40
42
  function readStoredMap(networkId: string): ReadMap {
41
43
  if (typeof window === 'undefined') return {};
42
44
  try {
43
- const raw = window.sessionStorage.getItem(storageKey(networkId));
44
- if (!raw) return {};
45
- const parsed = JSON.parse(raw);
46
- return parsed && typeof parsed === 'object' ? parsed : {};
45
+ let raw = window.localStorage.getItem(storageKey(networkId));
46
+ if (!raw) {
47
+ raw = window.sessionStorage.getItem(storageKey(networkId));
48
+ if (raw) window.localStorage.setItem(storageKey(networkId), raw);
49
+ }
50
+ const parsed = raw ? JSON.parse(raw) : {};
51
+ const map: ReadMap = parsed && typeof parsed === 'object' ? parsed : {};
52
+ // R8 first-visit floor, written SYNCHRONOUSLY on first read: an async
53
+ // (useEffect) baseline lost the race against the first SWR render and
54
+ // let a badge storm flash — and stick, since nothing re-triggered the
55
+ // count. Ensuring it here means no compute can ever see a missing
56
+ // baseline. No event dispatch: every hook instance does the same
57
+ // idempotent write.
58
+ if (!map[BASELINE_KEY]) {
59
+ map[BASELINE_KEY] = new Date().toISOString();
60
+ window.localStorage.setItem(storageKey(networkId), JSON.stringify(map));
61
+ }
62
+ return map;
47
63
  } catch {
48
64
  return {};
49
65
  }
@@ -52,7 +68,7 @@ function readStoredMap(networkId: string): ReadMap {
52
68
  function writeStoredMap(networkId: string, next: ReadMap) {
53
69
  if (typeof window === 'undefined') return;
54
70
  try {
55
- window.sessionStorage.setItem(storageKey(networkId), JSON.stringify(next));
71
+ window.localStorage.setItem(storageKey(networkId), JSON.stringify(next));
56
72
  window.dispatchEvent(new CustomEvent(UNREAD_EVENT, { detail: { networkId } }));
57
73
  } catch {}
58
74
  }
@@ -83,16 +99,15 @@ export function markChatRead(alias: string, networkId: string) {
83
99
 
84
100
  export function useChatUnread() {
85
101
  const { networkId } = useNetworkId();
102
+ // Messages are the ONLY count source: the hub mirrors every task reply as
103
+ // a message to the originator (verified live: replied task + same-second
104
+ // message, both present), so counting tasks too double-counted every
105
+ // reply (E2E showed 4 where 2 was right).
86
106
  const { data: messagesData } = useSWR<{ messages?: ChatMessage[] }>(
87
107
  withNetwork('/api/hub/messages?limit=200', networkId),
88
108
  fetcher,
89
109
  { refreshInterval: 5000, dedupingInterval: 3000 },
90
110
  );
91
- const { data: tasksData } = useSWR<{ tasks?: ChatTask[] }>(
92
- withNetwork('/api/hub/tasks?limit=200', networkId),
93
- fetcher,
94
- { refreshInterval: 5000, dedupingInterval: 3000 },
95
- );
96
111
  const [readVersion, setReadVersion] = useState(0);
97
112
 
98
113
  useEffect(() => {
@@ -119,40 +134,54 @@ export function useChatUnread() {
119
134
  }, [networkId, readVersion]);
120
135
 
121
136
  const unreadMap = useMemo(() => {
122
- const latestByAlias = new Map<string, number>();
137
+ // R8: count EVERY unread item per alias (Vincent: "有几条消息显示数字"),
138
+ // not just a latest-activity boolean. A message from the alias counts;
139
+ // a task's REPLY counts once when it lands after last-read. Self-sent
140
+ // items never count (they're keyed by the counterpart alias).
141
+ const counts = new Map<string, number>();
142
+ const baseline = parseHubTime(readMap[BASELINE_KEY]);
143
+ const bump = (alias: string, at: number) => {
144
+ const readAt = Math.max(parseHubTime(readMap[alias]), baseline);
145
+ if (at > readAt) counts.set(alias, (counts.get(alias) || 0) + 1);
146
+ };
147
+ // Scope: only the USER'S conversation — a badge on X means "X has
148
+ // something FOR YOU". Fleet chatter (X's messages to other agents)
149
+ // must NOT inflate it.
123
150
  for (const message of messagesData?.messages || []) {
124
151
  const alias = message.from_alias?.trim();
125
- if (!alias) continue;
152
+ if (!alias || isUserish(alias)) continue; // own sends never count
153
+ if (!isUserish(message.to_alias)) continue; // must be addressed to the user
126
154
  const at = parseHubTime(message.created_at);
127
- if (!at) continue;
128
- latestByAlias.set(alias, Math.max(latestByAlias.get(alias) || 0, at));
155
+ if (at) bump(alias, at);
129
156
  }
130
- for (const task of tasksData?.tasks || []) {
131
- const alias = task.to_name?.trim();
132
- if (!alias) continue;
133
- const hasReply = Boolean(task.result) || ['replied', 'failed', 'closed', 'expired', 'cancelled'].includes(task.status || '');
134
- if (!hasReply) continue;
135
- const at = parseHubTime(task.completed_at) || parseHubTime(task.delivered_at) || parseHubTime(task.created_at);
136
- if (!at) continue;
137
- latestByAlias.set(alias, Math.max(latestByAlias.get(alias) || 0, at));
138
- }
139
-
140
- const next = new Map<string, boolean>();
141
- for (const [alias, latestAt] of latestByAlias) {
142
- const readAt = parseHubTime(readMap[alias]);
143
- if (latestAt > readAt) next.set(alias, true);
144
- }
145
- return next;
146
- }, [messagesData, readMap, tasksData]);
157
+ return counts;
158
+ }, [messagesData, readMap]);
147
159
 
148
160
  const hasUnread = useCallback((alias?: string | null) => {
149
161
  if (!alias) return false;
150
- return unreadMap.get(alias) === true;
162
+ return (unreadMap.get(alias) || 0) > 0;
163
+ }, [unreadMap]);
164
+
165
+ const unreadCount = useCallback((alias?: string | null) => {
166
+ if (!alias) return 0;
167
+ return unreadMap.get(alias) || 0;
168
+ }, [unreadMap]);
169
+
170
+ const totalUnread = useMemo(() => {
171
+ let t = 0;
172
+ for (const n of unreadMap.values()) t += n;
173
+ return t;
151
174
  }, [unreadMap]);
152
175
 
153
176
  const clearUnread = useCallback((alias: string) => {
154
177
  markChatRead(alias, networkId);
155
178
  }, [networkId]);
156
179
 
157
- return { unreadMap, hasUnread, clearUnread };
180
+ return { unreadMap, hasUnread, unreadCount, totalUnread, clearUnread };
181
+ }
182
+
183
+ /** WeChat badge label: 1-99 as-is, above → "99+"; 0/negative → '' (hide). */
184
+ export function badgeLabel(n: number): string {
185
+ if (n <= 0) return '';
186
+ return n > 99 ? '99+' : String(n);
158
187
  }
package/app/lib/time.ts CHANGED
@@ -34,3 +34,30 @@ export function relativeAgo(dateStr: string | null | undefined): string | null {
34
34
  if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
35
35
  return `${Math.floor(s / 86400)}d ago`;
36
36
  }
37
+
38
+ /** Loop R7 (跨面一致性): WeChat-style contextual timestamp shared by the
39
+ * chat panel (R3) and /messages dividers. zh locales keep the native
40
+ * wording (昨天/星期X); others get Intl output ("Yesterday"/"Tue").
41
+ * Today → "14:32" · yesterday → "昨天 14:32" · <7d → "星期二 14:32" ·
42
+ * this year → "7月16日 14:32" · older adds the year. */
43
+ export function formatWeChatTime(value: string | null | undefined): string {
44
+ const t = parseHubTime(value);
45
+ if (t === null) return '';
46
+ const d = new Date(t);
47
+ const now = new Date();
48
+ const zh = typeof navigator !== 'undefined' && (navigator.language || '').toLowerCase().startsWith('zh');
49
+ const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
50
+ const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
51
+ const dayDiff = Math.round((startOfDay(now) - startOfDay(d)) / 86400000);
52
+ if (dayDiff === 0) return hm;
53
+ if (dayDiff === 1) return zh ? `昨天 ${hm}` : `Yesterday ${hm}`;
54
+ if (dayDiff > 1 && dayDiff < 7) {
55
+ const wd = new Intl.DateTimeFormat(zh ? 'zh-CN' : undefined, { weekday: zh ? 'long' : 'short' }).format(d);
56
+ return `${wd} ${hm}`;
57
+ }
58
+ const md = new Intl.DateTimeFormat(zh ? 'zh-CN' : undefined, {
59
+ month: zh ? 'long' : 'short', day: 'numeric',
60
+ ...(d.getFullYear() !== now.getFullYear() ? { year: 'numeric' } : {}),
61
+ }).format(d);
62
+ return `${md} ${hm}`;
63
+ }