@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.
- 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/{0v-2rx4h02~2h.js → 06qwjph2.xkng.js} +2 -2
- package/.next/static/chunks/{1566p5peao240.js → 08cv_lh8tw-p7.js} +1 -1
- package/.next/static/chunks/{0g23p135n0swi.js → 0a229b11_ks8h.js} +2 -2
- package/.next/static/chunks/{0wd05udmyiwvk.js → 0fow_ssfwva9g.js} +2 -2
- package/.next/static/chunks/0imomomvqzqmm.js +1 -0
- package/.next/static/chunks/{146p-0l764ojl.js → 0n8tkdk74r.vt.js} +1 -1
- package/.next/static/chunks/{00_m_epf7h806.js → 0p6os-912pt-t.js} +1 -1
- package/.next/static/chunks/{16_v-g.qr-.xa.js → 0qgybbsb9e6q4.js} +1 -1
- package/.next/static/chunks/{0_4kwsiltvp~l.js → 0w4ia6l0l2nx6.js} +1 -1
- package/.next/static/chunks/0xz6zlg032d_4.css +1 -0
- package/.next/static/chunks/11.o41gzk3vi4.js +1 -0
- package/.next/trace +2 -2
- package/.next/trace-build +1 -1
- package/app/components/AgentCard.tsx +23 -3
- package/app/components/CommandCenter.tsx +22 -3
- package/app/components/MobileNav.tsx +14 -3
- package/app/components/TaskChatPanel.tsx +101 -30
- package/app/lib/chat-drafts.ts +46 -0
- 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/0vz2kzk6pucsq.js +0 -1
- package/.next/static/chunks/14d2lfzm8pyqn.js +0 -1
- package/.next/static/chunks/180t32do_.c~c.css +0 -1
- /package/.next/static/{qpprKezd_zujs8ouGr3LY → waj5SPgHKhI524_18FcZK}/_buildManifest.js +0 -0
- /package/.next/static/{qpprKezd_zujs8ouGr3LY → waj5SPgHKhI524_18FcZK}/_clientMiddlewareManifest.js +0 -0
- /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
|
-
<
|
|
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
|
-
<
|
|
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
|
);
|
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
},
|
|
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)
|
|
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
|
+
}
|
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
|
+
}
|