@sleep2agi/agent-network-dashboard 0.6.3-preview.15 → 0.6.3-preview.17
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 +11 -11
- 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_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +2 -2
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- 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 +2 -2
- package/.next/server/app/admin.segments/_full.segment.rsc +2 -2
- package/.next/server/app/admin.segments/_head.segment.rsc +1 -1
- package/.next/server/app/admin.segments/_index.segment.rsc +2 -2
- package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/admin.segments/admin.segment.rsc +1 -1
- package/.next/server/app/index.html +2 -2
- package/.next/server/app/index.rsc +3 -3
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- 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 +3 -3
- package/.next/server/app/login.segments/_full.segment.rsc +3 -3
- package/.next/server/app/login.segments/_head.segment.rsc +1 -1
- package/.next/server/app/login.segments/_index.segment.rsc +2 -2
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/login.segments/login.segment.rsc +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 +2 -2
- package/.next/server/app/logs.segments/_full.segment.rsc +2 -2
- package/.next/server/app/logs.segments/_head.segment.rsc +1 -1
- package/.next/server/app/logs.segments/_index.segment.rsc +2 -2
- package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/logs.segments/logs.segment.rsc +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 +2 -2
- package/.next/server/app/messages.segments/_full.segment.rsc +2 -2
- package/.next/server/app/messages.segments/_head.segment.rsc +1 -1
- package/.next/server/app/messages.segments/_index.segment.rsc +2 -2
- package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/messages.segments/messages.segment.rsc +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 +3 -3
- package/.next/server/app/node.segments/_full.segment.rsc +3 -3
- package/.next/server/app/node.segments/_head.segment.rsc +1 -1
- package/.next/server/app/node.segments/_index.segment.rsc +2 -2
- package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/node.segments/node.segment.rsc +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 +3 -3
- package/.next/server/app/nodes.segments/_full.segment.rsc +3 -3
- package/.next/server/app/nodes.segments/_head.segment.rsc +1 -1
- package/.next/server/app/nodes.segments/_index.segment.rsc +2 -2
- package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/nodes.segments/nodes.segment.rsc +1 -1
- package/.next/server/app/page_client-reference-manifest.js +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 +2 -2
- package/.next/server/app/providers.segments/_full.segment.rsc +2 -2
- package/.next/server/app/providers.segments/_head.segment.rsc +1 -1
- package/.next/server/app/providers.segments/_index.segment.rsc +2 -2
- package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/providers.segments/providers.segment.rsc +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 +2 -2
- package/.next/server/app/server-logs.segments/_full.segment.rsc +2 -2
- package/.next/server/app/server-logs.segments/_head.segment.rsc +1 -1
- package/.next/server/app/server-logs.segments/_index.segment.rsc +2 -2
- package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/server-logs.segments/server-logs.segment.rsc +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 +2 -2
- package/.next/server/app/servers.segments/_full.segment.rsc +2 -2
- package/.next/server/app/servers.segments/_head.segment.rsc +1 -1
- package/.next/server/app/servers.segments/_index.segment.rsc +2 -2
- package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/servers.segments/servers.segment.rsc +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 +2 -2
- package/.next/server/app/settings/networks.segments/_full.segment.rsc +2 -2
- package/.next/server/app/settings/networks.segments/_head.segment.rsc +1 -1
- package/.next/server/app/settings/networks.segments/_index.segment.rsc +2 -2
- package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +1 -1
- package/.next/server/app/settings/networks.segments/settings.segment.rsc +1 -1
- package/.next/server/app/settings/page_client-reference-manifest.js +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 +2 -2
- package/.next/server/app/settings/tokens.segments/_full.segment.rsc +2 -2
- package/.next/server/app/settings/tokens.segments/_head.segment.rsc +1 -1
- package/.next/server/app/settings/tokens.segments/_index.segment.rsc +2 -2
- package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +1 -1
- package/.next/server/app/settings/tokens.segments/settings.segment.rsc +1 -1
- package/.next/server/app/settings.html +2 -2
- package/.next/server/app/settings.rsc +3 -3
- package/.next/server/app/settings.segments/_full.segment.rsc +3 -3
- package/.next/server/app/settings.segments/_head.segment.rsc +1 -1
- package/.next/server/app/settings.segments/_index.segment.rsc +2 -2
- package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/settings.segments/settings.segment.rsc +1 -1
- package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +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 +2 -2
- package/.next/server/app/tasks.segments/_full.segment.rsc +2 -2
- package/.next/server/app/tasks.segments/_head.segment.rsc +1 -1
- package/.next/server/app/tasks.segments/_index.segment.rsc +2 -2
- package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/tasks.segments/tasks.segment.rsc +1 -1
- 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_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_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/{0mh28v-4j9dng.js → 0.~m9qqwfhyh5.js} +1 -1
- package/.next/static/chunks/{0_87-ekkmdd5n.js → 07odbdjmv1fqn.js} +1 -1
- package/.next/static/chunks/09t~.chkcxn2g.js +4 -0
- package/.next/static/chunks/{0ck-p90gm54x_.js → 0n8tkdk74r.vt.js} +1 -1
- package/.next/static/chunks/{11q_-4rnr__r..js → 0udw~n2wgg_d1.js} +1 -1
- package/.next/static/chunks/10d-uor6dhc8s.css +1 -0
- package/.next/static/chunks/{06qwjph2.xkng.js → 18314oga3ygn-.js} +2 -2
- package/.next/trace +2 -2
- package/.next/trace-build +1 -1
- package/app/components/AgentCard.tsx +6 -0
- package/app/components/CommandCenter.tsx +13 -3
- package/app/components/TaskChatPanel.tsx +230 -20
- package/app/lib/chat-drafts.ts +46 -0
- package/app/nodes/page.tsx +10 -0
- package/package.json +1 -1
- package/.next/static/chunks/02w_tb38yr0xe.css +0 -1
- package/.next/static/chunks/0ljzzrki8sm14.js +0 -4
- /package/.next/static/{R5eL5ZqEdEjOLN_5qjTqM → J1Y93nS-feaCGcOYyyskP}/_buildManifest.js +0 -0
- /package/.next/static/{R5eL5ZqEdEjOLN_5qjTqM → J1Y93nS-feaCGcOYyyskP}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{R5eL5ZqEdEjOLN_5qjTqM → J1Y93nS-feaCGcOYyyskP}/_ssgManifest.js +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useCallback } from 'react';
|
|
4
4
|
import { useChatUnread } from '../lib/chat-unread';
|
|
5
|
+
import { useHasDraft } from '../lib/chat-drafts';
|
|
5
6
|
import { TaskChatPanel } from './TaskChatPanel';
|
|
6
7
|
|
|
7
8
|
interface CommandCenterProps {
|
|
@@ -46,6 +47,7 @@ export function CommandCenter({ tabs, activeTab, onOpenTab, onCloseTab, onSetAct
|
|
|
46
47
|
<span className="max-w-[80px] truncate">{alias}</span>
|
|
47
48
|
{/* R8: WeChat unread badge on BACKGROUND tabs (the active
|
|
48
49
|
tab is being read — no badge). */}
|
|
50
|
+
{activeTab !== alias && <TabDraftMark alias={alias} />}
|
|
49
51
|
{activeTab !== alias && unreadCount(alias) > 0 && (
|
|
50
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">
|
|
51
53
|
{unreadCount(alias) > 99 ? '99+' : unreadCount(alias)}
|
|
@@ -81,7 +83,7 @@ export function CommandCenter({ tabs, activeTab, onOpenTab, onCloseTab, onSetAct
|
|
|
81
83
|
<div className="flex-1 relative overflow-hidden">
|
|
82
84
|
{tabs.map(alias => (
|
|
83
85
|
<div key={alias} className={`absolute inset-0 ${activeTab === alias ? 'block' : 'hidden'}`}>
|
|
84
|
-
<InlineChat alias={alias} />
|
|
86
|
+
<InlineChat alias={alias} active={activeTab === alias} />
|
|
85
87
|
</div>
|
|
86
88
|
))}
|
|
87
89
|
</div>
|
|
@@ -100,10 +102,18 @@ export function CommandCenter({ tabs, activeTab, onOpenTab, onCloseTab, onSetAct
|
|
|
100
102
|
);
|
|
101
103
|
}
|
|
102
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
|
+
|
|
103
113
|
/** Inline chat without the panel chrome (used inside CommandCenter tabs) */
|
|
104
|
-
function InlineChat({ alias }: { alias: string }) {
|
|
114
|
+
function InlineChat({ alias, active }: { alias: string; active: boolean }) {
|
|
105
115
|
// Reuse TaskChatPanel's logic but render without the outer frame
|
|
106
|
-
return <TaskChatPanel alias={alias} onClose={() => {}} inline />;
|
|
116
|
+
return <TaskChatPanel alias={alias} onClose={() => {}} inline active={active} />;
|
|
107
117
|
}
|
|
108
118
|
|
|
109
119
|
/**
|
|
@@ -58,20 +58,123 @@ function extractAttachmentPreviews(text: string): string[] {
|
|
|
58
58
|
return Array.from(new Set(urls));
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
/* Loop R12 (微信式图片): thumbnails shimmer while loading and degrade to a
|
|
62
|
+
quiet "图片加载失败" tile on error; tapping opens an in-panel lightbox
|
|
63
|
+
(dark backdrop, fit-to-screen, Esc/tap closes) instead of navigating to
|
|
64
|
+
a raw browser tab. */
|
|
65
|
+
function ChatImage({ src, alt, thumbClass, onOpen }: { src: string; alt?: string; thumbClass: string; onOpen: (url: string) => void }) {
|
|
66
|
+
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
|
|
67
|
+
if (state === 'error') {
|
|
68
|
+
return (
|
|
69
|
+
<span className={`${thumbClass} flex items-center justify-center bg-black/20 border border-[var(--border)] text-[10px] text-[var(--fg-dim)]`}>
|
|
70
|
+
图片加载失败
|
|
71
|
+
</span>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return (
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={() => onOpen(src)}
|
|
78
|
+
aria-label={alt || '查看大图'}
|
|
79
|
+
className={`relative block overflow-hidden ${state === 'loading' ? 'animate-pulse bg-[var(--bg-elevated)]' : ''} ${thumbClass}`}
|
|
80
|
+
>
|
|
81
|
+
<img
|
|
82
|
+
src={src}
|
|
83
|
+
alt={alt || ''}
|
|
84
|
+
loading="lazy"
|
|
85
|
+
onLoad={() => setState('ready')}
|
|
86
|
+
onError={() => setState('error')}
|
|
87
|
+
className={`h-full w-full object-cover transition-opacity duration-200 ${state === 'ready' ? 'opacity-100' : 'opacity-0'}`}
|
|
88
|
+
/>
|
|
89
|
+
</button>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ImageLightbox({ url, onClose, opener }: { url: string; onClose: () => void; opener: HTMLElement | null }) {
|
|
94
|
+
const [loaded, setLoaded] = useState(false);
|
|
95
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
96
|
+
// onClose is recreated on every parent render (and the panel re-renders
|
|
97
|
+
// every few seconds from soft refreshes) — a [onClose] dep would tear
|
|
98
|
+
// down/re-run this effect mid-open and re-capture the "opener" as an
|
|
99
|
+
// element INSIDE the dialog. Mount-only + ref keeps the true opener.
|
|
100
|
+
const onCloseRef = useRef(onClose);
|
|
101
|
+
onCloseRef.current = onClose;
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const onClose = () => onCloseRef.current();
|
|
104
|
+
// R13 a11y debt: modal focus management — focus into the dialog, trap
|
|
105
|
+
// Tab, hand focus back on close. The opener is captured by the PANEL at
|
|
106
|
+
// activation time and passed in: by the time this passive effect runs,
|
|
107
|
+
// the thumbnail has already been blurred (measured: activeElement was
|
|
108
|
+
// <body> here), so capturing it locally restored nothing.
|
|
109
|
+
const focusables = () =>
|
|
110
|
+
Array.from(dialogRef.current?.querySelectorAll<HTMLElement>('a[href], button') || []);
|
|
111
|
+
focusables()[0]?.focus();
|
|
112
|
+
// Capture-phase so Escape closes the LIGHTBOX, not the chat panel
|
|
113
|
+
// behind it (panel hosts listen for Escape too).
|
|
114
|
+
const onKey = (e: KeyboardEvent) => {
|
|
115
|
+
if (e.key === 'Escape') { e.stopPropagation(); onClose(); return; }
|
|
116
|
+
if (e.key !== 'Tab') return;
|
|
117
|
+
const items = focusables();
|
|
118
|
+
if (items.length === 0) return;
|
|
119
|
+
const first = items[0], last = items[items.length - 1];
|
|
120
|
+
const active = document.activeElement;
|
|
121
|
+
if (e.shiftKey && (active === first || !dialogRef.current?.contains(active))) {
|
|
122
|
+
e.preventDefault(); last.focus();
|
|
123
|
+
} else if (!e.shiftKey && (active === last || !dialogRef.current?.contains(active))) {
|
|
124
|
+
e.preventDefault(); first.focus();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
window.addEventListener('keydown', onKey, true);
|
|
128
|
+
return () => {
|
|
129
|
+
window.removeEventListener('keydown', onKey, true);
|
|
130
|
+
// After this cleanup React removes the overlay; the browser then
|
|
131
|
+
// blurs to <body>. Restore on a macrotask so we run AFTER that blur.
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
// Soft refreshes rebuild bubble subtrees, so the opener node may be
|
|
134
|
+
// detached by now (focus() on a detached node is a silent no-op) —
|
|
135
|
+
// re-find its replacement by aria-label.
|
|
136
|
+
let el = opener;
|
|
137
|
+
if (el && !document.contains(el)) {
|
|
138
|
+
const label = el.getAttribute('aria-label');
|
|
139
|
+
if (label) el = document.querySelector<HTMLElement>(`[aria-label="${CSS.escape(label)}"]`);
|
|
140
|
+
}
|
|
141
|
+
el?.focus?.();
|
|
142
|
+
}, 0);
|
|
143
|
+
};
|
|
144
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
145
|
+
}, []); // opener/onCloseRef are stable for one open
|
|
146
|
+
return (
|
|
147
|
+
<div ref={dialogRef} className="fixed inset-0 z-[100] flex items-center justify-center bg-black/85" onClick={onClose} role="dialog" aria-modal="true" aria-label="图片预览">
|
|
148
|
+
{!loaded && <div className="absolute h-6 w-6 animate-spin rounded-full border-2 border-white/30 border-t-white" />}
|
|
149
|
+
<img
|
|
150
|
+
src={url}
|
|
151
|
+
alt=""
|
|
152
|
+
onLoad={() => setLoaded(true)}
|
|
153
|
+
className={`max-h-[92vh] max-w-[92vw] rounded object-contain transition-opacity duration-150 ${loaded ? 'opacity-100' : 'opacity-0'}`}
|
|
154
|
+
/>
|
|
155
|
+
<a
|
|
156
|
+
href={url}
|
|
157
|
+
target="_blank"
|
|
158
|
+
rel="noreferrer"
|
|
159
|
+
onClick={(e) => e.stopPropagation()}
|
|
160
|
+
className="absolute right-12 top-3 text-[11px] text-white/70 underline hover:text-white"
|
|
161
|
+
>
|
|
162
|
+
新标签打开
|
|
163
|
+
</a>
|
|
164
|
+
<button onClick={onClose} aria-label="关闭预览" className="absolute right-2 top-2 flex h-8 w-8 items-center justify-center text-lg text-white/80 hover:text-white">
|
|
165
|
+
×
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function AttachmentPreviews({ text, onOpen }: { text: string; onOpen: (url: string) => void }) {
|
|
62
172
|
const urls = extractAttachmentPreviews(text);
|
|
63
173
|
if (urls.length === 0) return null;
|
|
64
174
|
return (
|
|
65
175
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
|
66
176
|
{urls.map((url) => (
|
|
67
|
-
<
|
|
68
|
-
<img
|
|
69
|
-
src={url}
|
|
70
|
-
alt="Dashboard attachment preview"
|
|
71
|
-
className="h-28 w-full object-cover transition-transform duration-200 group-hover:scale-[1.02]"
|
|
72
|
-
loading="lazy"
|
|
73
|
-
/>
|
|
74
|
-
</a>
|
|
177
|
+
<ChatImage key={url} src={url} alt="Dashboard attachment" thumbClass="h-28 w-full rounded-lg border border-cyan-500/20" onOpen={onOpen} />
|
|
75
178
|
))}
|
|
76
179
|
</div>
|
|
77
180
|
);
|
|
@@ -147,9 +250,10 @@ import remarkGfm from 'remark-gfm';
|
|
|
147
250
|
import { useSSE } from '../lib/useSSE';
|
|
148
251
|
import { useNetworkId } from '../lib/network-context';
|
|
149
252
|
import { markChatRead } from '../lib/chat-unread';
|
|
253
|
+
import { readDraft, writeDraft } from '../lib/chat-drafts';
|
|
150
254
|
import { formatWeChatTime } from '../lib/time';
|
|
151
255
|
|
|
152
|
-
function MarkdownContent({ text }: { text: string }) {
|
|
256
|
+
function MarkdownContent({ text, onImageClick }: { text: string; onImageClick?: (url: string) => void }) {
|
|
153
257
|
if (!text) return <span className="text-[var(--fg-dim)] italic">No content</span>;
|
|
154
258
|
return (
|
|
155
259
|
<div className="break-words leading-relaxed">
|
|
@@ -168,6 +272,12 @@ function MarkdownContent({ text }: { text: string }) {
|
|
|
168
272
|
a: ({ href, children }) => <a href={href} target="_blank" rel="noreferrer" className="text-cyan-300 underline">{children}</a>,
|
|
169
273
|
blockquote: ({ children }) => <blockquote className="border-l-2 border-cyan-500/30 pl-3 my-1 text-[var(--fg-muted)] italic">{children}</blockquote>,
|
|
170
274
|
hr: () => <hr className="border-t border-[var(--border)] my-2" />,
|
|
275
|
+
img: ({ src, alt }) => {
|
|
276
|
+
const url = typeof src === 'string' ? src : '';
|
|
277
|
+
if (!url) return null;
|
|
278
|
+
if (!onImageClick) return <img src={url} alt={alt || ''} loading="lazy" className="my-1 max-h-48 rounded-lg" />;
|
|
279
|
+
return <ChatImage src={url} alt={alt} thumbClass="my-1 h-40 w-full max-w-[240px] rounded-lg" onOpen={onImageClick} />;
|
|
280
|
+
},
|
|
171
281
|
code: ({ className, children }) => {
|
|
172
282
|
const inline = !className?.startsWith('language-');
|
|
173
283
|
if (inline) {
|
|
@@ -206,6 +316,10 @@ interface TaskChatPanelProps {
|
|
|
206
316
|
inline?: boolean;
|
|
207
317
|
/** Available nodes for @ mention */
|
|
208
318
|
availableNodes?: string[];
|
|
319
|
+
/** Loop R11: false for background CommandCenter tabs — pauses the 15s
|
|
320
|
+
* soft-refresh AND the SSE-event soft-refresh (in-flight send patches
|
|
321
|
+
* stay live). Reactivation runs one immediate catch-up. Default true. */
|
|
322
|
+
active?: boolean;
|
|
209
323
|
}
|
|
210
324
|
|
|
211
325
|
/** P0 chat batch (Vincent: 长消息=无排版文字墙): message bodies taller than
|
|
@@ -256,7 +370,8 @@ function CollapsibleText({ children }: { children: React.ReactNode }) {
|
|
|
256
370
|
);
|
|
257
371
|
}
|
|
258
372
|
|
|
259
|
-
export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskChatPanelProps) {
|
|
373
|
+
export function TaskChatPanel({ alias, onClose, inline, availableNodes, active }: TaskChatPanelProps) {
|
|
374
|
+
const isActive = active !== false;
|
|
260
375
|
const { networkId } = useNetworkId();
|
|
261
376
|
const [messages, setMessages] = useState<ChatTask[]>([]);
|
|
262
377
|
const [input, setInput] = useState('');
|
|
@@ -289,6 +404,22 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
289
404
|
const anchoredRef = useRef(false);
|
|
290
405
|
const [hasOlder, setHasOlder] = useState(true);
|
|
291
406
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
407
|
+
// Loop R10 (微信式"下方有新消息"): while the user is scrolled up reading
|
|
408
|
+
// history, incoming messages must NOT yank the view to the bottom —
|
|
409
|
+
// they accumulate in a floating pill instead. atBottomRef mirrors the
|
|
410
|
+
// user's last real scroll position (checking the DOM after an append
|
|
411
|
+
// would always look "not at bottom" because the new content itself
|
|
412
|
+
// grew the distance).
|
|
413
|
+
const [newBelow, setNewBelow] = useState(0);
|
|
414
|
+
const [lightboxUrl, setLightboxUrl] = useState<string | null>(null);
|
|
415
|
+
const lightboxOpenerRef = useRef<HTMLElement | null>(null);
|
|
416
|
+
const openLightbox = useCallback((url: string) => {
|
|
417
|
+
lightboxOpenerRef.current = document.activeElement as HTMLElement | null;
|
|
418
|
+
setLightboxUrl(url);
|
|
419
|
+
}, []);
|
|
420
|
+
const atBottomRef = useRef(true);
|
|
421
|
+
const prevBubblesRef = useRef(0);
|
|
422
|
+
const forceScrollRef = useRef(false);
|
|
292
423
|
|
|
293
424
|
// Load available nodes for @ mention
|
|
294
425
|
// alias → network_id map for send scoping (P0 network_id fix): the
|
|
@@ -309,6 +440,29 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
309
440
|
// Reset target when alias changes
|
|
310
441
|
useEffect(() => { setTargetAlias(alias); }, [alias]);
|
|
311
442
|
|
|
443
|
+
// Loop R9 (微信草稿): unsent text survives closing the panel, switching
|
|
444
|
+
// conversations and refreshing, per conversation. Text only — File
|
|
445
|
+
// attachments can't be serialized. The debounced write covers "typing,
|
|
446
|
+
// then refresh"; the effect cleanup covers "typing, then close within
|
|
447
|
+
// the debounce window" (the timer dies with the unmount, so the tail
|
|
448
|
+
// would otherwise be lost). Empty input deletes the draft, so sending
|
|
449
|
+
// or clearing the box leaves no stale draft behind.
|
|
450
|
+
const inputRef = useRef(input);
|
|
451
|
+
inputRef.current = input;
|
|
452
|
+
useEffect(() => {
|
|
453
|
+
if (!alias) return;
|
|
454
|
+
const draft = readDraft(alias);
|
|
455
|
+
if (draft) {
|
|
456
|
+
setInput(draft);
|
|
457
|
+
requestAnimationFrame(() => { if (textareaRef.current) autoResize(textareaRef.current); });
|
|
458
|
+
}
|
|
459
|
+
return () => { writeDraft(alias, inputRef.current); };
|
|
460
|
+
}, [alias]);
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
const timer = setTimeout(() => writeDraft(alias, input), 300);
|
|
463
|
+
return () => clearTimeout(timer);
|
|
464
|
+
}, [input, alias]);
|
|
465
|
+
|
|
312
466
|
useEffect(() => {
|
|
313
467
|
if (!alias) return;
|
|
314
468
|
markChatRead(alias, networkId);
|
|
@@ -387,6 +541,9 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
387
541
|
histLimitRef.current = HISTORY_PAGE;
|
|
388
542
|
anchoredRef.current = false;
|
|
389
543
|
firstAnchorRef.current = true;
|
|
544
|
+
atBottomRef.current = true;
|
|
545
|
+
prevBubblesRef.current = 0;
|
|
546
|
+
setNewBelow(0);
|
|
390
547
|
setHasOlder(true);
|
|
391
548
|
loadInitial();
|
|
392
549
|
// Focus textarea
|
|
@@ -412,12 +569,15 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
412
569
|
const onMessagesScroll = useCallback(() => {
|
|
413
570
|
const el = scrollRef.current;
|
|
414
571
|
if (!el) return;
|
|
572
|
+
const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
573
|
+
atBottomRef.current = dist < 80;
|
|
574
|
+
if (atBottomRef.current) setNewBelow(0);
|
|
415
575
|
// The initial bottom-anchor is a smooth scroll, so its own scroll
|
|
416
576
|
// events pass through scrollTop≈0 territory. Arm the older-page
|
|
417
577
|
// trigger only after the container has actually reached the bottom
|
|
418
578
|
// once — everything before that is layout/auto-scroll noise.
|
|
419
579
|
if (!anchoredRef.current) {
|
|
420
|
-
if (
|
|
580
|
+
if (dist < 80) anchoredRef.current = true;
|
|
421
581
|
return;
|
|
422
582
|
}
|
|
423
583
|
if (el.scrollTop < 40) loadOlder();
|
|
@@ -431,12 +591,24 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
431
591
|
// loaded at once". Subsequent new-message anchors stay smooth.
|
|
432
592
|
const firstAnchorRef = useRef(true);
|
|
433
593
|
useEffect(() => {
|
|
594
|
+
const bubbles = messages.reduce((n, m) => n + (m.result ? 2 : 1), 0);
|
|
595
|
+
const prevBubbles = prevBubblesRef.current;
|
|
596
|
+
prevBubblesRef.current = bubbles;
|
|
434
597
|
if (skipAutoScrollRef.current) {
|
|
435
598
|
skipAutoScrollRef.current = false;
|
|
436
599
|
return;
|
|
437
600
|
}
|
|
601
|
+
if (messages.length === 0) return;
|
|
602
|
+
// R10: scrolled-up readers keep their place — count the arrivals
|
|
603
|
+
// into the pill instead of yanking to the bottom. Own sends
|
|
604
|
+
// (forceScrollRef) and the first anchor always jump.
|
|
605
|
+
if (!firstAnchorRef.current && !forceScrollRef.current && !atBottomRef.current) {
|
|
606
|
+
if (bubbles > prevBubbles) setNewBelow(n => n + (bubbles - prevBubbles));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
438
609
|
const behavior: ScrollBehavior = firstAnchorRef.current ? 'auto' : 'smooth';
|
|
439
|
-
|
|
610
|
+
firstAnchorRef.current = false;
|
|
611
|
+
forceScrollRef.current = false;
|
|
440
612
|
messagesEndRef.current?.scrollIntoView({ behavior });
|
|
441
613
|
}, [messages]);
|
|
442
614
|
|
|
@@ -455,6 +627,7 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
455
627
|
// window (debounced 1.5s trailing; runFetch merge-by-id keeps
|
|
456
628
|
// optimistic bubbles, so a refresh never drops an in-flight send).
|
|
457
629
|
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
630
|
+
const lastSoftRefreshRef = useRef(0);
|
|
458
631
|
useEffect(() => () => { if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); }, []);
|
|
459
632
|
useSSE({
|
|
460
633
|
url: '/api/hub/events',
|
|
@@ -477,10 +650,18 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
477
650
|
}
|
|
478
651
|
if (!['new_task', 'new_message', 'new_reply', 'broadcast'].includes(t)) return;
|
|
479
652
|
if (!historyLoaded) return; // initial load owns the first paint
|
|
653
|
+
if (!isActive) return; // R11: background tabs catch up on activation instead
|
|
654
|
+
// R11: floor between soft refreshes — on a busy fleet (192 agents)
|
|
655
|
+
// events arrive faster than the 1.5s debounce can coalesce, and the
|
|
656
|
+
// active panel refetched its window ~25×/min. One event batch every
|
|
657
|
+
// ≥6s is plenty for a 12-item history window; sends still patch
|
|
658
|
+
// instantly via the fast path above.
|
|
480
659
|
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
660
|
+
const wait = Math.max(1500, 6000 - (Date.now() - lastSoftRefreshRef.current));
|
|
481
661
|
refreshTimerRef.current = setTimeout(() => {
|
|
662
|
+
lastSoftRefreshRef.current = Date.now();
|
|
482
663
|
runFetch(histLimitRef.current, 12_000);
|
|
483
|
-
},
|
|
664
|
+
}, wait);
|
|
484
665
|
},
|
|
485
666
|
});
|
|
486
667
|
|
|
@@ -491,14 +672,25 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
491
672
|
// stream (ask filed), an OPEN panel soft-refreshes every 15s — paused
|
|
492
673
|
// while the tab is hidden — so incoming messages appear without a
|
|
493
674
|
// reopen. runFetch's merge-by-id keeps optimistic bubbles intact.
|
|
675
|
+
// R11 (多面板降频): CommandCenter keeps EVERY tab mounted, so N tabs
|
|
676
|
+
// used to mean N pollers AND N per-SSE-event soft-refreshers — measured
|
|
677
|
+
// 40 history-window fetches/min with 4 tabs on a busy fleet. Only the
|
|
678
|
+
// visible tab refreshes now; a tab returning to the foreground does one
|
|
679
|
+
// immediate catch-up fetch so it never shows stale history.
|
|
680
|
+
const wasActiveRef = useRef(isActive);
|
|
494
681
|
useEffect(() => {
|
|
495
|
-
if (!historyLoaded)
|
|
682
|
+
if (!historyLoaded || !isActive) {
|
|
683
|
+
wasActiveRef.current = isActive;
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (!wasActiveRef.current) runFetch(histLimitRef.current, 12_000);
|
|
687
|
+
wasActiveRef.current = true;
|
|
496
688
|
const id = setInterval(() => {
|
|
497
689
|
if (document.visibilityState === 'hidden') return;
|
|
498
690
|
runFetch(histLimitRef.current, 12_000);
|
|
499
691
|
}, 15_000);
|
|
500
692
|
return () => clearInterval(id);
|
|
501
|
-
}, [historyLoaded, runFetch]);
|
|
693
|
+
}, [historyLoaded, isActive, runFetch]);
|
|
502
694
|
|
|
503
695
|
// Polling fallback (SSE-unavailable / proxies stripping events).
|
|
504
696
|
// Slow cadence so we're not the bulk of server load — SSE handles the fast path.
|
|
@@ -561,6 +753,8 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
561
753
|
|
|
562
754
|
setSending(true);
|
|
563
755
|
setInput('');
|
|
756
|
+
writeDraft(alias, '');
|
|
757
|
+
forceScrollRef.current = true;
|
|
564
758
|
setAttachedFiles([]);
|
|
565
759
|
setShowMentions(false);
|
|
566
760
|
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
|
@@ -835,8 +1029,8 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
835
1029
|
</div>
|
|
836
1030
|
</div>
|
|
837
1031
|
<div className="text-[14px] leading-[1.65] text-[var(--fg)]">
|
|
838
|
-
<CollapsibleText><MarkdownContent text={m.result} /></CollapsibleText>
|
|
839
|
-
<AttachmentPreviews text={m.result} />
|
|
1032
|
+
<CollapsibleText><MarkdownContent text={m.result} onImageClick={openLightbox} /></CollapsibleText>
|
|
1033
|
+
<AttachmentPreviews text={m.result} onOpen={openLightbox} />
|
|
840
1034
|
</div>
|
|
841
1035
|
</div>
|
|
842
1036
|
</div>
|
|
@@ -855,8 +1049,8 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
855
1049
|
{!fromUser && <span className="text-[9px] text-[var(--fg-dim)]">forwarded to {m.to_name}</span>}
|
|
856
1050
|
</div>
|
|
857
1051
|
<div className="text-[14px] leading-[1.65] text-[var(--fg)]">
|
|
858
|
-
<CollapsibleText><MarkdownContent text={m.content} /></CollapsibleText>
|
|
859
|
-
<AttachmentPreviews text={m.content} />
|
|
1052
|
+
<CollapsibleText><MarkdownContent text={m.content} onImageClick={openLightbox} /></CollapsibleText>
|
|
1053
|
+
<AttachmentPreviews text={m.content} onOpen={openLightbox} />
|
|
860
1054
|
</div>
|
|
861
1055
|
<div className="flex flex-wrap items-center justify-between mt-1.5 gap-2 sm:gap-3">
|
|
862
1056
|
<StatusBar status={m.status} />
|
|
@@ -899,6 +1093,21 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
899
1093
|
);
|
|
900
1094
|
})}
|
|
901
1095
|
<div ref={messagesEndRef} />
|
|
1096
|
+
{newBelow > 0 && (
|
|
1097
|
+
<div className="sticky bottom-1 z-10 flex justify-center pointer-events-none">
|
|
1098
|
+
<button
|
|
1099
|
+
type="button"
|
|
1100
|
+
onClick={() => {
|
|
1101
|
+
setNewBelow(0);
|
|
1102
|
+
atBottomRef.current = true;
|
|
1103
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
1104
|
+
}}
|
|
1105
|
+
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"
|
|
1106
|
+
>
|
|
1107
|
+
↓ {newBelow} 条新消息
|
|
1108
|
+
</button>
|
|
1109
|
+
</div>
|
|
1110
|
+
)}
|
|
902
1111
|
</div>
|
|
903
1112
|
|
|
904
1113
|
{/* Input area */}
|
|
@@ -978,6 +1187,7 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes }: TaskCh
|
|
|
978
1187
|
<span>Enter to send · paste image</span>
|
|
979
1188
|
</div>
|
|
980
1189
|
</div>
|
|
1190
|
+
{lightboxUrl && <ImageLightbox url={lightboxUrl} opener={lightboxOpenerRef.current} onClose={() => setLightboxUrl(null)} />}
|
|
981
1191
|
</>
|
|
982
1192
|
);
|
|
983
1193
|
|
|
@@ -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/nodes/page.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { CreateNodeWizard } from '../components/CreateNodeWizard';
|
|
|
9
9
|
import { NodeLifecycleMenu } from '../components/NodeLifecycleMenu';
|
|
10
10
|
import { EmptyState, NodesEmptyState } from '../components/EmptyState';
|
|
11
11
|
import { AliasAvatar } from '../components/AliasAvatar';
|
|
12
|
+
import { useHasDraft } from '../lib/chat-drafts';
|
|
12
13
|
import { useCollapsibleSearch } from '../components/CollapsibleSearch';
|
|
13
14
|
import type { Session } from '../components/types';
|
|
14
15
|
import { SESSION_STATUS_CHIP_CLASS as STATUS_COLORS } from '../lib/status';
|
|
@@ -25,6 +26,13 @@ function shortServer(server: string | null | undefined): string {
|
|
|
25
26
|
type ViewMode = 'list' | 'grid';
|
|
26
27
|
type SessionRow = Session & { online: boolean };
|
|
27
28
|
|
|
29
|
+
/** R13 (草稿闭环还债): same [草稿] mark as AgentCard/CommandCenter tabs. */
|
|
30
|
+
function DraftMark({ alias }: { alias: string }) {
|
|
31
|
+
const hasDraft = useHasDraft(alias);
|
|
32
|
+
if (!hasDraft) return null;
|
|
33
|
+
return <span className="shrink-0 text-[9px] text-red-400/90" aria-label="有未发送草稿">[草稿]</span>;
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
export default function NodesPage() {
|
|
29
37
|
const { sessions, isLoading: loading } = useSessions();
|
|
30
38
|
const { health } = useHealth();
|
|
@@ -223,6 +231,7 @@ export default function NodesPage() {
|
|
|
223
231
|
<div className="min-w-0 flex-1">
|
|
224
232
|
<div className="flex items-center gap-2">
|
|
225
233
|
<span className="truncate text-base font-semibold text-white">{s.alias}</span>
|
|
234
|
+
<DraftMark alias={s.alias} />
|
|
226
235
|
{unread && <span className="shrink-0 rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-red-300">New</span>}
|
|
227
236
|
<span className={`shrink-0 rounded-md border px-2 py-0.5 text-[10px] uppercase tracking-wide ${STATUS_COLORS[statusKey] || STATUS_COLORS.offline}`}>
|
|
228
237
|
{statusKey}
|
|
@@ -310,6 +319,7 @@ export default function NodesPage() {
|
|
|
310
319
|
{unread && <span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full border border-[#161618] bg-red-500" />}
|
|
311
320
|
</div>
|
|
312
321
|
<span className="truncate text-sm font-medium text-white">{s.alias}</span>
|
|
322
|
+
<DraftMark alias={s.alias} />
|
|
313
323
|
{unread && <span className="shrink-0 rounded-full bg-red-500/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-red-300">New</span>}
|
|
314
324
|
</div>
|
|
315
325
|
</div>
|
package/package.json
CHANGED