@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.
Files changed (186) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +3 -3
  3. package/.next/diagnostics/route-bundle-stats.json +11 -11
  4. package/.next/fallback-build-manifest.json +3 -3
  5. package/.next/prerender-manifest.json +3 -3
  6. package/.next/server/app/_global-error.html +1 -1
  7. package/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/server/app/_not-found.html +2 -2
  15. package/.next/server/app/_not-found.rsc +2 -2
  16. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  17. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  18. package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  19. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  20. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  21. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  22. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  23. package/.next/server/app/admin.html +2 -2
  24. package/.next/server/app/admin.rsc +2 -2
  25. package/.next/server/app/admin.segments/_full.segment.rsc +2 -2
  26. package/.next/server/app/admin.segments/_head.segment.rsc +1 -1
  27. package/.next/server/app/admin.segments/_index.segment.rsc +2 -2
  28. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  29. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +1 -1
  30. package/.next/server/app/admin.segments/admin.segment.rsc +1 -1
  31. package/.next/server/app/index.html +2 -2
  32. package/.next/server/app/index.rsc +3 -3
  33. package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  34. package/.next/server/app/index.segments/_full.segment.rsc +3 -3
  35. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  36. package/.next/server/app/index.segments/_index.segment.rsc +2 -2
  37. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  38. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  39. package/.next/server/app/login.html +2 -2
  40. package/.next/server/app/login.rsc +3 -3
  41. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  42. package/.next/server/app/login.segments/_head.segment.rsc +1 -1
  43. package/.next/server/app/login.segments/_index.segment.rsc +2 -2
  44. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  45. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
  46. package/.next/server/app/login.segments/login.segment.rsc +1 -1
  47. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  48. package/.next/server/app/logs.html +2 -2
  49. package/.next/server/app/logs.rsc +2 -2
  50. package/.next/server/app/logs.segments/_full.segment.rsc +2 -2
  51. package/.next/server/app/logs.segments/_head.segment.rsc +1 -1
  52. package/.next/server/app/logs.segments/_index.segment.rsc +2 -2
  53. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  54. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +1 -1
  55. package/.next/server/app/logs.segments/logs.segment.rsc +1 -1
  56. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  57. package/.next/server/app/messages.html +2 -2
  58. package/.next/server/app/messages.rsc +2 -2
  59. package/.next/server/app/messages.segments/_full.segment.rsc +2 -2
  60. package/.next/server/app/messages.segments/_head.segment.rsc +1 -1
  61. package/.next/server/app/messages.segments/_index.segment.rsc +2 -2
  62. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  63. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +1 -1
  64. package/.next/server/app/messages.segments/messages.segment.rsc +1 -1
  65. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  66. package/.next/server/app/node.html +2 -2
  67. package/.next/server/app/node.rsc +3 -3
  68. package/.next/server/app/node.segments/_full.segment.rsc +3 -3
  69. package/.next/server/app/node.segments/_head.segment.rsc +1 -1
  70. package/.next/server/app/node.segments/_index.segment.rsc +2 -2
  71. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  72. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +2 -2
  73. package/.next/server/app/node.segments/node.segment.rsc +1 -1
  74. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  75. package/.next/server/app/nodes.html +2 -2
  76. package/.next/server/app/nodes.rsc +3 -3
  77. package/.next/server/app/nodes.segments/_full.segment.rsc +3 -3
  78. package/.next/server/app/nodes.segments/_head.segment.rsc +1 -1
  79. package/.next/server/app/nodes.segments/_index.segment.rsc +2 -2
  80. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  81. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +2 -2
  82. package/.next/server/app/nodes.segments/nodes.segment.rsc +1 -1
  83. package/.next/server/app/page_client-reference-manifest.js +1 -1
  84. package/.next/server/app/providers/page_client-reference-manifest.js +1 -1
  85. package/.next/server/app/providers.html +2 -2
  86. package/.next/server/app/providers.rsc +2 -2
  87. package/.next/server/app/providers.segments/_full.segment.rsc +2 -2
  88. package/.next/server/app/providers.segments/_head.segment.rsc +1 -1
  89. package/.next/server/app/providers.segments/_index.segment.rsc +2 -2
  90. package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
  91. package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +1 -1
  92. package/.next/server/app/providers.segments/providers.segment.rsc +1 -1
  93. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  94. package/.next/server/app/server-logs.html +2 -2
  95. package/.next/server/app/server-logs.rsc +2 -2
  96. package/.next/server/app/server-logs.segments/_full.segment.rsc +2 -2
  97. package/.next/server/app/server-logs.segments/_head.segment.rsc +1 -1
  98. package/.next/server/app/server-logs.segments/_index.segment.rsc +2 -2
  99. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  100. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +1 -1
  101. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +1 -1
  102. package/.next/server/app/servers/page_client-reference-manifest.js +1 -1
  103. package/.next/server/app/servers.html +2 -2
  104. package/.next/server/app/servers.rsc +2 -2
  105. package/.next/server/app/servers.segments/_full.segment.rsc +2 -2
  106. package/.next/server/app/servers.segments/_head.segment.rsc +1 -1
  107. package/.next/server/app/servers.segments/_index.segment.rsc +2 -2
  108. package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
  109. package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +1 -1
  110. package/.next/server/app/servers.segments/servers.segment.rsc +1 -1
  111. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  112. package/.next/server/app/settings/networks.html +2 -2
  113. package/.next/server/app/settings/networks.rsc +2 -2
  114. package/.next/server/app/settings/networks.segments/_full.segment.rsc +2 -2
  115. package/.next/server/app/settings/networks.segments/_head.segment.rsc +1 -1
  116. package/.next/server/app/settings/networks.segments/_index.segment.rsc +2 -2
  117. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  118. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +1 -1
  119. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +1 -1
  120. package/.next/server/app/settings/networks.segments/settings.segment.rsc +1 -1
  121. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  122. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  123. package/.next/server/app/settings/tokens.html +2 -2
  124. package/.next/server/app/settings/tokens.rsc +2 -2
  125. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +2 -2
  126. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +1 -1
  127. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +2 -2
  128. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  129. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +1 -1
  130. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +1 -1
  131. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +1 -1
  132. package/.next/server/app/settings.html +2 -2
  133. package/.next/server/app/settings.rsc +3 -3
  134. package/.next/server/app/settings.segments/_full.segment.rsc +3 -3
  135. package/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  136. package/.next/server/app/settings.segments/_index.segment.rsc +2 -2
  137. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  138. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
  139. package/.next/server/app/settings.segments/settings.segment.rsc +1 -1
  140. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  141. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  142. package/.next/server/app/tasks.html +2 -2
  143. package/.next/server/app/tasks.rsc +2 -2
  144. package/.next/server/app/tasks.segments/_full.segment.rsc +2 -2
  145. package/.next/server/app/tasks.segments/_head.segment.rsc +1 -1
  146. package/.next/server/app/tasks.segments/_index.segment.rsc +2 -2
  147. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  148. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +1 -1
  149. package/.next/server/app/tasks.segments/tasks.segment.rsc +1 -1
  150. package/.next/server/chunks/ssr/[root-of-the-server]__091su60._.js +2 -2
  151. package/.next/server/chunks/ssr/[root-of-the-server]__091su60._.js.map +1 -1
  152. package/.next/server/chunks/ssr/[root-of-the-server]__0vc.3k7._.js +1 -1
  153. package/.next/server/chunks/ssr/[root-of-the-server]__0vc.3k7._.js.map +1 -1
  154. package/.next/server/chunks/ssr/dash-loop-wt_0rh8p9o._.js +1 -1
  155. package/.next/server/chunks/ssr/dash-loop-wt_0rh8p9o._.js.map +1 -1
  156. package/.next/server/chunks/ssr/dash-loop-wt_app_0~.1~ew._.js +1 -1
  157. package/.next/server/chunks/ssr/dash-loop-wt_app_0~.1~ew._.js.map +1 -1
  158. package/.next/server/chunks/ssr/dash-loop-wt_app_13k3-ai._.js +1 -1
  159. package/.next/server/chunks/ssr/dash-loop-wt_app_13k3-ai._.js.map +1 -1
  160. package/.next/server/chunks/ssr/dash-loop-wt_app_nodes_page_tsx_0ma3oqt._.js +1 -1
  161. package/.next/server/chunks/ssr/dash-loop-wt_app_nodes_page_tsx_0ma3oqt._.js.map +1 -1
  162. package/.next/server/middleware-build-manifest.js +3 -3
  163. package/.next/server/pages/404.html +2 -2
  164. package/.next/server/pages/500.html +1 -1
  165. package/.next/server/server-reference-manifest.js +1 -1
  166. package/.next/server/server-reference-manifest.json +1 -1
  167. package/.next/static/chunks/{0mh28v-4j9dng.js → 0.~m9qqwfhyh5.js} +1 -1
  168. package/.next/static/chunks/{0_87-ekkmdd5n.js → 07odbdjmv1fqn.js} +1 -1
  169. package/.next/static/chunks/09t~.chkcxn2g.js +4 -0
  170. package/.next/static/chunks/{0ck-p90gm54x_.js → 0n8tkdk74r.vt.js} +1 -1
  171. package/.next/static/chunks/{11q_-4rnr__r..js → 0udw~n2wgg_d1.js} +1 -1
  172. package/.next/static/chunks/10d-uor6dhc8s.css +1 -0
  173. package/.next/static/chunks/{06qwjph2.xkng.js → 18314oga3ygn-.js} +2 -2
  174. package/.next/trace +2 -2
  175. package/.next/trace-build +1 -1
  176. package/app/components/AgentCard.tsx +6 -0
  177. package/app/components/CommandCenter.tsx +13 -3
  178. package/app/components/TaskChatPanel.tsx +230 -20
  179. package/app/lib/chat-drafts.ts +46 -0
  180. package/app/nodes/page.tsx +10 -0
  181. package/package.json +1 -1
  182. package/.next/static/chunks/02w_tb38yr0xe.css +0 -1
  183. package/.next/static/chunks/0ljzzrki8sm14.js +0 -4
  184. /package/.next/static/{R5eL5ZqEdEjOLN_5qjTqM → J1Y93nS-feaCGcOYyyskP}/_buildManifest.js +0 -0
  185. /package/.next/static/{R5eL5ZqEdEjOLN_5qjTqM → J1Y93nS-feaCGcOYyyskP}/_clientMiddlewareManifest.js +0 -0
  186. /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
- function AttachmentPreviews({ text }: { text: string }) {
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
- <a key={url} href={url} target="_blank" rel="noreferrer" className="group block overflow-hidden rounded-lg border border-cyan-500/20 bg-black/20">
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 (el.scrollHeight - el.scrollTop - el.clientHeight < 80) anchoredRef.current = true;
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
- if (messages.length > 0) firstAnchorRef.current = false;
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
- }, 1500);
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) return;
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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/agent-network-dashboard",
3
- "version": "0.6.3-preview.15",
3
+ "version": "0.6.3-preview.17",
4
4
  "description": "Agent Network Dashboard — Web UI for managing AI Agent networks",
5
5
  "main": "apps/desktop/electron/main.cjs",
6
6
  "scripts": {