@sleep2agi/agent-network-dashboard 0.6.3-preview.13 → 0.6.3-preview.15
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.
- package/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +3 -3
- package/.next/diagnostics/route-bundle-stats.json +73 -73
- package/.next/fallback-build-manifest.json +3 -3
- package/.next/prerender-manifest.json +3 -3
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +2 -2
- package/.next/server/app/_not-found.rsc +13 -13
- package/.next/server/app/_not-found.segments/_full.segment.rsc +13 -13
- package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/server/app/_not-found.segments/_index.segment.rsc +8 -8
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/admin/page.js.nft.json +1 -1
- package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
- package/.next/server/app/admin.html +2 -2
- package/.next/server/app/admin.rsc +15 -15
- package/.next/server/app/admin.segments/_full.segment.rsc +15 -15
- package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
- package/.next/server/app/admin.segments/_index.segment.rsc +8 -8
- package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
- package/.next/server/app/index.html +2 -2
- package/.next/server/app/index.rsc +15 -15
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/server/app/index.segments/_index.segment.rsc +8 -8
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login/page.js.nft.json +1 -1
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/login.html +2 -2
- package/.next/server/app/login.rsc +15 -15
- package/.next/server/app/login.segments/_full.segment.rsc +15 -15
- package/.next/server/app/login.segments/_head.segment.rsc +4 -4
- package/.next/server/app/login.segments/_index.segment.rsc +8 -8
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/login.segments/login.segment.rsc +3 -3
- package/.next/server/app/logs/page.js.nft.json +1 -1
- package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
- package/.next/server/app/logs.html +2 -2
- package/.next/server/app/logs.rsc +15 -15
- package/.next/server/app/logs.segments/_full.segment.rsc +15 -15
- package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
- package/.next/server/app/logs.segments/_index.segment.rsc +8 -8
- package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
- package/.next/server/app/messages/page.js.nft.json +1 -1
- package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
- package/.next/server/app/messages.html +2 -2
- package/.next/server/app/messages.rsc +15 -15
- package/.next/server/app/messages.segments/_full.segment.rsc +15 -15
- package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
- package/.next/server/app/messages.segments/_index.segment.rsc +8 -8
- package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
- package/.next/server/app/node/page.js.nft.json +1 -1
- package/.next/server/app/node/page_client-reference-manifest.js +1 -1
- package/.next/server/app/node.html +2 -2
- package/.next/server/app/node.rsc +15 -15
- package/.next/server/app/node.segments/_full.segment.rsc +15 -15
- package/.next/server/app/node.segments/_head.segment.rsc +4 -4
- package/.next/server/app/node.segments/_index.segment.rsc +8 -8
- package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/node.segments/node.segment.rsc +3 -3
- package/.next/server/app/nodes/page.js.nft.json +1 -1
- package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
- package/.next/server/app/nodes.html +2 -2
- package/.next/server/app/nodes.rsc +15 -15
- package/.next/server/app/nodes.segments/_full.segment.rsc +15 -15
- package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
- package/.next/server/app/nodes.segments/_index.segment.rsc +8 -8
- package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/providers/page.js.nft.json +1 -1
- package/.next/server/app/providers/page_client-reference-manifest.js +1 -1
- package/.next/server/app/providers.html +2 -2
- package/.next/server/app/providers.rsc +15 -15
- package/.next/server/app/providers.segments/_full.segment.rsc +15 -15
- package/.next/server/app/providers.segments/_head.segment.rsc +4 -4
- package/.next/server/app/providers.segments/_index.segment.rsc +8 -8
- package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/providers.segments/providers.segment.rsc +3 -3
- package/.next/server/app/server-logs/page.js.nft.json +1 -1
- package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
- package/.next/server/app/server-logs.html +2 -2
- package/.next/server/app/server-logs.rsc +15 -15
- package/.next/server/app/server-logs.segments/_full.segment.rsc +15 -15
- package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
- package/.next/server/app/server-logs.segments/_index.segment.rsc +8 -8
- package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
- package/.next/server/app/servers/page.js.nft.json +1 -1
- package/.next/server/app/servers/page_client-reference-manifest.js +1 -1
- package/.next/server/app/servers.html +2 -2
- package/.next/server/app/servers.rsc +15 -15
- package/.next/server/app/servers.segments/_full.segment.rsc +15 -15
- package/.next/server/app/servers.segments/_head.segment.rsc +4 -4
- package/.next/server/app/servers.segments/_index.segment.rsc +8 -8
- package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/servers.segments/servers.segment.rsc +3 -3
- package/.next/server/app/settings/networks/page.js.nft.json +1 -1
- package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/networks.html +2 -2
- package/.next/server/app/settings/networks.rsc +15 -15
- package/.next/server/app/settings/networks.segments/_full.segment.rsc +15 -15
- package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings/networks.segments/_index.segment.rsc +8 -8
- package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
- package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
- package/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/tokens/page.js.nft.json +1 -1
- package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/tokens.html +2 -2
- package/.next/server/app/settings/tokens.rsc +15 -15
- package/.next/server/app/settings/tokens.segments/_full.segment.rsc +15 -15
- package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings/tokens.segments/_index.segment.rsc +8 -8
- package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
- package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
- package/.next/server/app/settings.html +2 -2
- package/.next/server/app/settings.rsc +15 -15
- package/.next/server/app/settings.segments/_full.segment.rsc +15 -15
- package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings.segments/_index.segment.rsc +8 -8
- package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
- package/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
- package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/tasks/page.js.nft.json +1 -1
- package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
- package/.next/server/app/tasks.html +2 -2
- package/.next/server/app/tasks.rsc +15 -15
- package/.next/server/app/tasks.segments/_full.segment.rsc +15 -15
- package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
- package/.next/server/app/tasks.segments/_index.segment.rsc +8 -8
- package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__03jniap._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__03jniap._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__091su60._.js +2 -2
- package/.next/server/chunks/ssr/[root-of-the-server]__091su60._.js.map +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0vc.3k7._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0vc.3k7._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-loop-wt_0rh8p9o._.js +1 -1
- package/.next/server/chunks/ssr/dash-loop-wt_0rh8p9o._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-loop-wt_app_0mxr2l9._.js +2 -2
- package/.next/server/chunks/ssr/dash-loop-wt_app_0mxr2l9._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-loop-wt_app_0~.1~ew._.js +1 -1
- package/.next/server/chunks/ssr/dash-loop-wt_app_0~.1~ew._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-loop-wt_app_13k3-ai._.js +1 -1
- package/.next/server/chunks/ssr/dash-loop-wt_app_13k3-ai._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-loop-wt_app_components_0cym4cd._.js +1 -1
- package/.next/server/chunks/ssr/dash-loop-wt_app_components_0cym4cd._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-loop-wt_app_nodes_page_tsx_0ma3oqt._.js +1 -1
- package/.next/server/chunks/ssr/dash-loop-wt_app_nodes_page_tsx_0ma3oqt._.js.map +1 -1
- package/.next/server/middleware-build-manifest.js +3 -3
- package/.next/server/pages/404.html +2 -2
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.js +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/02w_tb38yr0xe.css +1 -0
- package/.next/static/chunks/{0v-2rx4h02~2h.js → 06qwjph2.xkng.js} +2 -2
- package/.next/static/chunks/{0-7o8cy_tbz2u.js → 0_87-ekkmdd5n.js} +1 -1
- package/.next/static/chunks/{0g23p135n0swi.js → 0a229b11_ks8h.js} +2 -2
- package/.next/static/chunks/{0z-4v9hqzypm-.js → 0ck-p90gm54x_.js} +1 -1
- package/.next/static/chunks/0imomomvqzqmm.js +1 -0
- package/.next/static/chunks/0ljzzrki8sm14.js +4 -0
- package/.next/static/chunks/{0lxlyi7jro74u.js → 0mh28v-4j9dng.js} +1 -1
- package/.next/static/chunks/{16_v-g.qr-.xa.js → 0qgybbsb9e6q4.js} +1 -1
- package/.next/static/chunks/11.o41gzk3vi4.js +1 -0
- package/.next/static/chunks/{03f7qjy0ruw9t.js → 11q_-4rnr__r..js} +1 -1
- package/.next/trace +2 -2
- package/.next/trace-build +1 -1
- package/app/components/AgentCard.tsx +34 -3
- package/app/components/CommandCenter.tsx +14 -3
- package/app/components/MobileNav.tsx +14 -3
- package/app/components/TaskChatPanel.tsx +103 -33
- package/app/lib/chat-unread.ts +70 -41
- package/app/lib/time.ts +27 -0
- package/app/messages/page.tsx +5 -3
- package/app/nodes/page.tsx +17 -3
- package/app/page.tsx +4 -1
- package/package.json +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0gv51is._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0gv51is._.js.map +0 -1
- package/.next/static/chunks/0_51bg9gfw9i..js +0 -4
- package/.next/static/chunks/0lgvx1l4roc~..css +0 -1
- package/.next/static/chunks/0vz2kzk6pucsq.js +0 -1
- package/.next/static/chunks/14d2lfzm8pyqn.js +0 -1
- /package/.next/static/{0BXnhObFcvXecoQLBxzRv → R5eL5ZqEdEjOLN_5qjTqM}/_buildManifest.js +0 -0
- /package/.next/static/{0BXnhObFcvXecoQLBxzRv → R5eL5ZqEdEjOLN_5qjTqM}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{0BXnhObFcvXecoQLBxzRv → R5eL5ZqEdEjOLN_5qjTqM}/_ssgManifest.js +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState } from 'react';
|
|
3
|
+
import { useState, memo } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { Session } from './types';
|
|
6
6
|
import { timeAgo } from './utils';
|
|
@@ -12,6 +12,10 @@ interface AgentCardProps {
|
|
|
12
12
|
hasSse: boolean;
|
|
13
13
|
sseCount: number;
|
|
14
14
|
onChat?: (alias: string) => void;
|
|
15
|
+
/** R8 (Vincent: 微信式未读角标): unread count for this conversation.
|
|
16
|
+
* Passed as a prop (computed once page-level) so the R6 memo keeps
|
|
17
|
+
* protecting against fleet-wide re-render storms. */
|
|
18
|
+
unreadCount?: number;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
const STATUS_CONFIG: Record<string, { bg: string; text: string; dot: string; glow: string }> = {
|
|
@@ -23,7 +27,7 @@ const STATUS_CONFIG: Record<string, { bg: string; text: string; dot: string; glo
|
|
|
23
27
|
|
|
24
28
|
const DEFAULT_STATUS = { bg: 'bg-gray-800/50 border-gray-700/30', text: 'text-gray-500', dot: 'bg-gray-500', glow: '' };
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
function AgentCardInner({ session: s, hasSse, sseCount, onChat, unreadCount = 0 }: AgentCardProps) {
|
|
27
31
|
const cfg = hasSse ? (STATUS_CONFIG[s.status] || DEFAULT_STATUS) : DEFAULT_STATUS;
|
|
28
32
|
// #260: per-node settings panel (UI-only stub). Local open state — the
|
|
29
33
|
// panel itself is presentational and issues no backend calls.
|
|
@@ -65,7 +69,17 @@ export function AgentCard({ session: s, hasSse, sseCount, onChat }: AgentCardPro
|
|
|
65
69
|
tightens on narrow viewports. */}
|
|
66
70
|
<div className="flex items-center justify-between mb-2 sm:mb-3">
|
|
67
71
|
<div className="flex items-center gap-2 min-w-0">
|
|
68
|
-
<
|
|
72
|
+
<span className="relative inline-flex shrink-0">
|
|
73
|
+
<AliasAvatar alias={s.alias} size={22} />
|
|
74
|
+
{unreadCount > 0 && (
|
|
75
|
+
<span
|
|
76
|
+
aria-label={`${unreadCount} 条未读`}
|
|
77
|
+
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]"
|
|
78
|
+
>
|
|
79
|
+
{unreadCount > 99 ? '99+' : unreadCount}
|
|
80
|
+
</span>
|
|
81
|
+
)}
|
|
82
|
+
</span>
|
|
69
83
|
<span className="font-semibold text-white truncate text-sm" title={s.alias}>{s.alias}</span>
|
|
70
84
|
<span className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${cfg.dot} ${hasSse && s.status === 'working' ? 'animate-pulse' : ''}`} />
|
|
71
85
|
</div>
|
|
@@ -182,3 +196,20 @@ export function AgentCard({ session: s, hasSse, sseCount, onChat }: AgentCardPro
|
|
|
182
196
|
</>
|
|
183
197
|
);
|
|
184
198
|
}
|
|
199
|
+
|
|
200
|
+
/* Loop R6 (性能主线): SWR revalidation hands each card a NEW session object
|
|
201
|
+
every poll/SSE mutate even when nothing changed, so all ~190 cards
|
|
202
|
+
re-rendered on every event. memo + field comparison skips cards whose
|
|
203
|
+
visible data (and the fields the settings panel reads) are unchanged.
|
|
204
|
+
onChat is stable (useCallback in useCommandCenter). */
|
|
205
|
+
const CARD_FIELDS = [
|
|
206
|
+
'alias', 'status', 'agent', 'task', 'progress', 'server', 'updated_at',
|
|
207
|
+
'node_id', 'network_id', 'model', 'runtime', 'channels', 'session_id',
|
|
208
|
+
] as const;
|
|
209
|
+
|
|
210
|
+
export const AgentCard = memo(AgentCardInner, (prev, next) => {
|
|
211
|
+
if (prev.hasSse !== next.hasSse || prev.sseCount !== next.sseCount || prev.onChat !== next.onChat || prev.unreadCount !== next.unreadCount) return false;
|
|
212
|
+
const a = prev.session as unknown as Record<string, unknown>, b = next.session as unknown as Record<string, unknown>;
|
|
213
|
+
for (const k of CARD_FIELDS) if (a[k] !== b[k]) return false;
|
|
214
|
+
return true;
|
|
215
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState } from 'react';
|
|
3
|
+
import { useState, useCallback } from 'react';
|
|
4
|
+
import { useChatUnread } from '../lib/chat-unread';
|
|
4
5
|
import { TaskChatPanel } from './TaskChatPanel';
|
|
5
6
|
|
|
6
7
|
interface CommandCenterProps {
|
|
@@ -18,6 +19,7 @@ interface CommandCenterProps {
|
|
|
18
19
|
* Wraps TaskChatPanel with a tab bar for switching between agents.
|
|
19
20
|
*/
|
|
20
21
|
export function CommandCenter({ tabs, activeTab, onOpenTab, onCloseTab, onSetActive, onClose }: CommandCenterProps) {
|
|
22
|
+
const { unreadCount } = useChatUnread();
|
|
21
23
|
if (tabs.length === 0) return null;
|
|
22
24
|
|
|
23
25
|
return (
|
|
@@ -42,6 +44,13 @@ export function CommandCenter({ tabs, activeTab, onOpenTab, onCloseTab, onSetAct
|
|
|
42
44
|
>
|
|
43
45
|
<div className={`w-2 h-2 rounded-full ${activeTab === alias ? 'bg-cyan-400' : 'bg-gray-600'}`} />
|
|
44
46
|
<span className="max-w-[80px] truncate">{alias}</span>
|
|
47
|
+
{/* R8: WeChat unread badge on BACKGROUND tabs (the active
|
|
48
|
+
tab is being read — no badge). */}
|
|
49
|
+
{activeTab !== alias && unreadCount(alias) > 0 && (
|
|
50
|
+
<span className="min-w-[15px] h-[15px] px-1 rounded-full bg-red-500 text-white text-[9px] font-semibold leading-[15px] text-center">
|
|
51
|
+
{unreadCount(alias) > 99 ? '99+' : unreadCount(alias)}
|
|
52
|
+
</span>
|
|
53
|
+
)}
|
|
45
54
|
{/* R21 of #190 mobile a11y: nested close had no aria-label
|
|
46
55
|
(screen-readers spoke just "button") and an ~8 px
|
|
47
56
|
tap target (p-0.5 + text "×"). Both fixed; nested
|
|
@@ -104,10 +113,12 @@ export function useCommandCenter() {
|
|
|
104
113
|
const [tabs, setTabs] = useState<string[]>([]);
|
|
105
114
|
const [activeTab, setActiveTab] = useState('');
|
|
106
115
|
|
|
107
|
-
|
|
116
|
+
// Loop R6: stable identities so memo'd consumers (AgentCard) don't
|
|
117
|
+
// re-render just because the Overview re-rendered.
|
|
118
|
+
const openTab = useCallback((alias: string) => {
|
|
108
119
|
setTabs(prev => prev.includes(alias) ? prev : [...prev, alias]);
|
|
109
120
|
setActiveTab(alias);
|
|
110
|
-
};
|
|
121
|
+
}, []);
|
|
111
122
|
|
|
112
123
|
const closeTab = (alias: string) => {
|
|
113
124
|
setTabs(prev => {
|
|
@@ -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
|
-
<
|
|
56
|
-
<
|
|
57
|
-
|
|
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
|
);
|
|
@@ -28,22 +28,6 @@ type ChatEvent =
|
|
|
28
28
|
older with the year. Exact per-message time survives in the bubble's
|
|
29
29
|
title tooltip. */
|
|
30
30
|
const TIME_GROUP_GAP_MS = 5 * 60 * 1000;
|
|
31
|
-
const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
|
|
32
|
-
|
|
33
|
-
function formatWeChatTime(value: string): string {
|
|
34
|
-
const t = eventTime(value);
|
|
35
|
-
if (!t) return '';
|
|
36
|
-
const d = new Date(t);
|
|
37
|
-
const now = new Date();
|
|
38
|
-
const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
39
|
-
const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
40
|
-
const dayDiff = Math.round((startOfDay(now) - startOfDay(d)) / 86400000);
|
|
41
|
-
if (dayDiff === 0) return hm;
|
|
42
|
-
if (dayDiff === 1) return `昨天 ${hm}`;
|
|
43
|
-
if (dayDiff > 1 && dayDiff < 7) return `${WEEKDAYS[d.getDay()]} ${hm}`;
|
|
44
|
-
if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日 ${hm}`;
|
|
45
|
-
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${hm}`;
|
|
46
|
-
}
|
|
47
31
|
|
|
48
32
|
function eventTime(value?: string) {
|
|
49
33
|
if (!value) return 0;
|
|
@@ -163,6 +147,7 @@ import remarkGfm from 'remark-gfm';
|
|
|
163
147
|
import { useSSE } from '../lib/useSSE';
|
|
164
148
|
import { useNetworkId } from '../lib/network-context';
|
|
165
149
|
import { markChatRead } from '../lib/chat-unread';
|
|
150
|
+
import { formatWeChatTime } from '../lib/time';
|
|
166
151
|
|
|
167
152
|
function MarkdownContent({ text }: { text: string }) {
|
|
168
153
|
if (!text) return <span className="text-[var(--fg-dim)] italic">No content</span>;
|
|
@@ -460,25 +445,61 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
460
445
|
// them and patch the matching bubble immediately. No polling needed for
|
|
461
446
|
// the normal path. The polling block below remains as a safety-net for
|
|
462
447
|
// SSE-unavailable environments / disconnect windows.
|
|
448
|
+
// Loop R5 (实时收消息): the subscription used to be gated on
|
|
449
|
+
// pollingIds>0 — an IDLE panel was deaf, so messages sent to this alias
|
|
450
|
+
// by OTHERS (or replies to tasks sent before mount) only appeared on
|
|
451
|
+
// reopen. R1's shared channel makes an always-on subscription free (no
|
|
452
|
+
// extra connection), so:
|
|
453
|
+
// · new_reply for OUR in-flight sends keeps the fast targeted patch;
|
|
454
|
+
// · any other new_task/new_message/new_reply soft-refreshes the
|
|
455
|
+
// window (debounced 1.5s trailing; runFetch merge-by-id keeps
|
|
456
|
+
// optimistic bubbles, so a refresh never drops an in-flight send).
|
|
457
|
+
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
458
|
+
useEffect(() => () => { if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); }, []);
|
|
463
459
|
useSSE({
|
|
464
460
|
url: '/api/hub/events',
|
|
465
|
-
enabled:
|
|
461
|
+
enabled: true,
|
|
466
462
|
onEvent: async (e: { type?: string; in_reply_to?: string; message_id?: string }) => {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
463
|
+
const t = e?.type || '';
|
|
464
|
+
if (t === 'new_reply' && e.in_reply_to && pollingIds.has(e.in_reply_to)) {
|
|
465
|
+
// Fast path: a reply to a task WE sent from this panel.
|
|
466
|
+
try {
|
|
467
|
+
const res = await fetch(`/api/hub/tasks?task_id=${encodeURIComponent(e.in_reply_to)}`);
|
|
468
|
+
const data = await res.json();
|
|
469
|
+
const updated = data?.tasks?.[0];
|
|
470
|
+
if (!updated) return;
|
|
471
|
+
setMessages(prev => prev.map(m => m.task_id === e.in_reply_to ? { ...m, ...updated } : m));
|
|
472
|
+
if (['replied', 'failed', 'closed', 'expired', 'cancelled'].includes(updated.status)) {
|
|
473
|
+
setPollingIds(prev => { const n = new Set(prev); n.delete(e.in_reply_to!); return n; });
|
|
474
|
+
}
|
|
475
|
+
} catch {}
|
|
476
|
+
return; // handled — don't ALSO soft-refresh for the same event
|
|
477
|
+
}
|
|
478
|
+
if (!['new_task', 'new_message', 'new_reply', 'broadcast'].includes(t)) return;
|
|
479
|
+
if (!historyLoaded) return; // initial load owns the first paint
|
|
480
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
481
|
+
refreshTimerRef.current = setTimeout(() => {
|
|
482
|
+
runFetch(histLimitRef.current, 12_000);
|
|
483
|
+
}, 1500);
|
|
479
484
|
},
|
|
480
485
|
});
|
|
481
486
|
|
|
487
|
+
// Loop R5b (honest finding): the hub only pushes events on the
|
|
488
|
+
// sender's/recipient's OWN channels — a dashboard observer's channel gets
|
|
489
|
+
// NOTHING for third-party traffic (verified: 12s capture during an
|
|
490
|
+
// api→node send = only "connected"). Until the hub grows an observer
|
|
491
|
+
// stream (ask filed), an OPEN panel soft-refreshes every 15s — paused
|
|
492
|
+
// while the tab is hidden — so incoming messages appear without a
|
|
493
|
+
// reopen. runFetch's merge-by-id keeps optimistic bubbles intact.
|
|
494
|
+
useEffect(() => {
|
|
495
|
+
if (!historyLoaded) return;
|
|
496
|
+
const id = setInterval(() => {
|
|
497
|
+
if (document.visibilityState === 'hidden') return;
|
|
498
|
+
runFetch(histLimitRef.current, 12_000);
|
|
499
|
+
}, 15_000);
|
|
500
|
+
return () => clearInterval(id);
|
|
501
|
+
}, [historyLoaded, runFetch]);
|
|
502
|
+
|
|
482
503
|
// Polling fallback (SSE-unavailable / proxies stripping events).
|
|
483
504
|
// Slow cadence so we're not the bulk of server load — SSE handles the fast path.
|
|
484
505
|
useEffect(() => {
|
|
@@ -580,6 +601,21 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
580
601
|
m.task_id === localTaskId ? { ...m, content: taskContent } : m,
|
|
581
602
|
));
|
|
582
603
|
}
|
|
604
|
+
await postTask(taskContent, sendTo, localTaskId, uploaded);
|
|
605
|
+
} catch (e: unknown) {
|
|
606
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
607
|
+
setMessages(prev => prev.map(m =>
|
|
608
|
+
m.task_id === localTaskId ? { ...m, status: 'failed', result: `❌ network error: ${msg}` } : m,
|
|
609
|
+
));
|
|
610
|
+
}
|
|
611
|
+
setSending(false);
|
|
612
|
+
textareaRef.current?.focus();
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
// Core POST shared by send() and the failed-bubble retry (R4: WeChat-style
|
|
616
|
+
// resend — a failed message shouldn't force the user to retype it).
|
|
617
|
+
const postTask = async (taskContent: string, sendTo: string, localTaskId: string, uploaded: { path: string; url: string }[] = []) => {
|
|
618
|
+
try {
|
|
583
619
|
const res = await fetch('/api/hub/send', {
|
|
584
620
|
method: 'POST',
|
|
585
621
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -607,8 +643,25 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
607
643
|
m.task_id === localTaskId ? { ...m, status: 'failed', result: `❌ network error: ${msg}` } : m,
|
|
608
644
|
));
|
|
609
645
|
}
|
|
610
|
-
|
|
611
|
-
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
// R4: tap-to-retry a failed send — reset the SAME bubble to in-flight and
|
|
649
|
+
// re-POST with identical content/target; no retyping (WeChat red-! flow).
|
|
650
|
+
const retryLockRef = useRef<Set<string>>(new Set());
|
|
651
|
+
const retrySend = async (m: ChatTask) => {
|
|
652
|
+
// R5 (R4 self-review debt): double-click guard — a second tap while the
|
|
653
|
+
// retry POST is in flight must not fire a duplicate send.
|
|
654
|
+
if (retryLockRef.current.has(m.task_id)) return;
|
|
655
|
+
retryLockRef.current.add(m.task_id);
|
|
656
|
+
const cleanContent = m.content;
|
|
657
|
+
setMessages(prev => prev.map(x =>
|
|
658
|
+
x.task_id === m.task_id ? { ...x, status: 'created', result: '' } : x,
|
|
659
|
+
));
|
|
660
|
+
try {
|
|
661
|
+
await postTask(cleanContent, m.to_name, m.task_id);
|
|
662
|
+
} finally {
|
|
663
|
+
retryLockRef.current.delete(m.task_id);
|
|
664
|
+
}
|
|
612
665
|
};
|
|
613
666
|
|
|
614
667
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
@@ -750,7 +803,7 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
750
803
|
const showSep = !prevAt || eventTime(event.at) - eventTime(prevAt) > TIME_GROUP_GAP_MS;
|
|
751
804
|
const sepEl = showSep ? (
|
|
752
805
|
<div className="flex justify-center py-1.5">
|
|
753
|
-
<span className="text-[10px] text-[var(--fg-dim)] bg-
|
|
806
|
+
<span className="text-[10px] text-[var(--fg-dim)] bg-[var(--bg-elevated)] rounded px-2 py-0.5">{formatWeChatTime(event.at)}</span>
|
|
754
807
|
</div>
|
|
755
808
|
) : null;
|
|
756
809
|
// Distinguish task source so users can tell when a peer agent
|
|
@@ -807,7 +860,24 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
807
860
|
</div>
|
|
808
861
|
<div className="flex flex-wrap items-center justify-between mt-1.5 gap-2 sm:gap-3">
|
|
809
862
|
<StatusBar status={m.status} />
|
|
810
|
-
|
|
863
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
864
|
+
{/* R4: WeChat send-feedback loop — spinner while the
|
|
865
|
+
optimistic bubble is still in flight; tap-to-resend
|
|
866
|
+
on failure (same bubble, no retyping). */}
|
|
867
|
+
{m.task_id.startsWith('tmp-') && m.status !== 'failed' && (
|
|
868
|
+
<span aria-label="发送中" className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-cyan-500/30 border-t-cyan-400" />
|
|
869
|
+
)}
|
|
870
|
+
{m.status === 'failed' && fromUser && (
|
|
871
|
+
<button
|
|
872
|
+
type="button"
|
|
873
|
+
onClick={() => retrySend(m)}
|
|
874
|
+
className="inline-flex items-center gap-1 rounded-md border border-red-500/40 bg-red-500/10 px-1.5 py-0.5 text-[10px] font-medium text-red-300 hover:bg-red-500/20"
|
|
875
|
+
>
|
|
876
|
+
<span aria-hidden>↻</span> 重发
|
|
877
|
+
</button>
|
|
878
|
+
)}
|
|
879
|
+
{senderBadge}
|
|
880
|
+
</div>
|
|
811
881
|
</div>
|
|
812
882
|
</div>
|
|
813
883
|
</div>
|
package/app/lib/chat-unread.ts
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
44
|
-
if (!raw)
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
128
|
-
latestByAlias.set(alias, Math.max(latestByAlias.get(alias) || 0, at));
|
|
155
|
+
if (at) bump(alias, at);
|
|
129
156
|
}
|
|
130
|
-
|
|
131
|
-
|
|
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)
|
|
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
|
+
}
|
package/app/messages/page.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { useMessages } from '../lib/hooks';
|
|
5
5
|
import { timeAgo, previewContent } from '../components/utils';
|
|
6
|
+
import { formatWeChatTime } from '../lib/time';
|
|
6
7
|
import { EmptyState } from '../components/EmptyState';
|
|
7
8
|
import { AliasAvatar } from '../components/AliasAvatar';
|
|
8
9
|
import { useCollapsibleSearch } from '../components/CollapsibleSearch';
|
|
@@ -30,10 +31,11 @@ function normalizeDate(value?: string) {
|
|
|
30
31
|
return new Date(value.replace(' ', 'T') + 'Z').getTime();
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
// Loop R7: divider label now shares the chat panel's WeChat-style
|
|
35
|
+
// contextual formatter (was a raw toLocaleString() dump) — one time
|
|
36
|
+
// language across every conversation surface.
|
|
33
37
|
function formatDividerLabel(value?: string) {
|
|
34
|
-
|
|
35
|
-
const date = new Date(value.replace(' ', 'T') + 'Z');
|
|
36
|
-
return date.toLocaleString();
|
|
38
|
+
return formatWeChatTime(value) || 'Time gap';
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
function bubbleVariant(message: MessageItem) {
|
package/app/nodes/page.tsx
CHANGED
|
@@ -29,7 +29,7 @@ export default function NodesPage() {
|
|
|
29
29
|
const { sessions, isLoading: loading } = useSessions();
|
|
30
30
|
const { health } = useHealth();
|
|
31
31
|
const { lifecycleByAlias, nodeIdByAlias } = useNodeLifecycle();
|
|
32
|
-
const { hasUnread } = useChatUnread();
|
|
32
|
+
const { hasUnread, unreadCount } = useChatUnread();
|
|
33
33
|
const { mutate } = useSWRConfig();
|
|
34
34
|
// RFC-027 PR2 — after a stop/delete/restart dispatch lands, refresh
|
|
35
35
|
// /api/hub/nodes (lifecycle_state) AND /api/hub/status (sessions).
|
|
@@ -210,7 +210,14 @@ export default function NodesPage() {
|
|
|
210
210
|
)}
|
|
211
211
|
<div className="flex items-start gap-3">
|
|
212
212
|
<div className="relative shrink-0">
|
|
213
|
-
<
|
|
213
|
+
<span className="relative inline-flex shrink-0">
|
|
214
|
+
<AliasAvatar alias={s.alias} size={36} />
|
|
215
|
+
{unreadCount(s.alias) > 0 && (
|
|
216
|
+
<span aria-label={`${unreadCount(s.alias)} 条未读`} className="absolute -top-1 -right-1.5 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]">
|
|
217
|
+
{unreadCount(s.alias) > 99 ? '99+' : unreadCount(s.alias)}
|
|
218
|
+
</span>
|
|
219
|
+
)}
|
|
220
|
+
</span>
|
|
214
221
|
{unread && <span className="absolute -right-1 -top-1 h-3 w-3 rounded-full border-2 border-[#161618] bg-red-500" />}
|
|
215
222
|
</div>
|
|
216
223
|
<div className="min-w-0 flex-1">
|
|
@@ -292,7 +299,14 @@ export default function NodesPage() {
|
|
|
292
299
|
<div className="col-span-2 min-w-0">
|
|
293
300
|
<div className="flex items-center gap-2 min-w-0">
|
|
294
301
|
<div className="relative shrink-0">
|
|
295
|
-
<
|
|
302
|
+
<span className="relative inline-flex shrink-0">
|
|
303
|
+
<AliasAvatar alias={s.alias} size={20} />
|
|
304
|
+
{unreadCount(s.alias) > 0 && (
|
|
305
|
+
<span aria-label={`${unreadCount(s.alias)} 条未读`} className="absolute -top-1.5 -right-2 min-w-[15px] h-[15px] px-1 rounded-full bg-red-500 text-white text-[9px] font-semibold leading-[15px] text-center">
|
|
306
|
+
{unreadCount(s.alias) > 99 ? '99+' : unreadCount(s.alias)}
|
|
307
|
+
</span>
|
|
308
|
+
)}
|
|
309
|
+
</span>
|
|
296
310
|
{unread && <span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full border border-[#161618] bg-red-500" />}
|
|
297
311
|
</div>
|
|
298
312
|
<span className="truncate text-sm font-medium text-white">{s.alias}</span>
|
package/app/page.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { LoadingSkeleton } from './components/LoadingSkeleton';
|
|
|
9
9
|
import { NodesEmptyState as EmptyState } from './components/EmptyState';
|
|
10
10
|
import { CommandCenter, useCommandCenter } from './components/CommandCenter';
|
|
11
11
|
import { useSessions, useHealth, useTasks, useStats } from './lib/hooks';
|
|
12
|
+
import { useChatUnread } from './lib/chat-unread';
|
|
12
13
|
import { useSSE } from './lib/useSSE';
|
|
13
14
|
import { useSWRConfig } from 'swr';
|
|
14
15
|
|
|
@@ -28,6 +29,8 @@ export default function Dashboard() {
|
|
|
28
29
|
const { stats } = useStats();
|
|
29
30
|
const [showTopo, setShowTopo] = useState(typeof window !== 'undefined' && window.innerWidth >= 1024);
|
|
30
31
|
const cmd = useCommandCenter();
|
|
32
|
+
// R8: one page-level unread computation; counts flow to memo'd cards as props.
|
|
33
|
+
const { unreadCount } = useChatUnread();
|
|
31
34
|
const [agentFilter, setAgentFilter] = useState<'all' | 'working' | 'idle' | 'offline'>('all');
|
|
32
35
|
// #84: last node.renamed event — passed to TopoGraph so an open chat
|
|
33
36
|
// popover follows the rename instead of pointing at a dead alias. `ts`
|
|
@@ -232,7 +235,7 @@ export default function Dashboard() {
|
|
|
232
235
|
xl breakpoint auto-inherits lg=3 cols; 2xl bumps to 4. */}
|
|
233
236
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3 sm:gap-4">
|
|
234
237
|
{filtered.map(s => (
|
|
235
|
-
<AgentCard key={s.alias} session={s} hasSse={isOnline(s)} sseCount={sseLookup(s) || 0} onChat={cmd.openTab} />
|
|
238
|
+
<AgentCard key={s.alias} session={s} hasSse={isOnline(s)} sseCount={sseLookup(s) || 0} onChat={cmd.openTab} unreadCount={unreadCount(s.alias)} />
|
|
236
239
|
))}
|
|
237
240
|
</div>
|
|
238
241
|
{filtered.length === 0 && (
|
package/package.json
CHANGED