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

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 (179) 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 +10 -10
  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_app_0~.1~ew._.js +1 -1
  155. package/.next/server/chunks/ssr/dash-loop-wt_app_0~.1~ew._.js.map +1 -1
  156. package/.next/server/chunks/ssr/dash-loop-wt_app_13k3-ai._.js +1 -1
  157. package/.next/server/chunks/ssr/dash-loop-wt_app_13k3-ai._.js.map +1 -1
  158. package/.next/server/chunks/ssr/dash-loop-wt_app_nodes_page_tsx_0ma3oqt._.js +1 -1
  159. package/.next/server/chunks/ssr/dash-loop-wt_app_nodes_page_tsx_0ma3oqt._.js.map +1 -1
  160. package/.next/server/middleware-build-manifest.js +3 -3
  161. package/.next/server/pages/404.html +2 -2
  162. package/.next/server/pages/500.html +1 -1
  163. package/.next/server/server-reference-manifest.js +1 -1
  164. package/.next/server/server-reference-manifest.json +1 -1
  165. package/.next/static/chunks/0ibr--rd~v6.l.css +1 -0
  166. package/.next/static/chunks/{08cv_lh8tw-p7.js → 0ih12lups7yq-.js} +1 -1
  167. package/.next/static/chunks/{0p6os-912pt-t.js → 0rgwdv8ad23sz.js} +1 -1
  168. package/.next/static/chunks/{0w4ia6l0l2nx6.js → 0sy9m1ltl8su5.js} +1 -1
  169. package/.next/static/chunks/{0fow_ssfwva9g.js → 0wg01-6hp5w31.js} +2 -2
  170. package/.next/static/chunks/{06qwjph2.xkng.js → 18314oga3ygn-.js} +2 -2
  171. package/.next/trace +2 -2
  172. package/.next/trace-build +1 -1
  173. package/app/components/TaskChatPanel.tsx +220 -27
  174. package/app/nodes/page.tsx +10 -0
  175. package/package.json +1 -1
  176. package/.next/static/chunks/0xz6zlg032d_4.css +0 -1
  177. /package/.next/static/{waj5SPgHKhI524_18FcZK → TZI9jxdtoDCWPz_ajnyf9}/_buildManifest.js +0 -0
  178. /package/.next/static/{waj5SPgHKhI524_18FcZK → TZI9jxdtoDCWPz_ajnyf9}/_clientMiddlewareManifest.js +0 -0
  179. /package/.next/static/{waj5SPgHKhI524_18FcZK → TZI9jxdtoDCWPz_ajnyf9}/_ssgManifest.js +0 -0
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useRef, useCallback, Fragment } from 'react';
3
+ import { useState, useEffect, useRef, useCallback, useMemo, memo, Fragment } from 'react';
4
4
  import { AliasAvatar } from './AliasAvatar';
5
5
 
6
6
  interface ChatTask {
@@ -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
  );
@@ -150,13 +253,16 @@ import { markChatRead } from '../lib/chat-unread';
150
253
  import { readDraft, writeDraft } from '../lib/chat-drafts';
151
254
  import { formatWeChatTime } from '../lib/time';
152
255
 
153
- function MarkdownContent({ text }: { text: string }) {
154
- if (!text) return <span className="text-[var(--fg-dim)] italic">No content</span>;
155
- return (
156
- <div className="break-words leading-relaxed">
157
- <ReactMarkdown
158
- remarkPlugins={[remarkGfm]}
159
- components={{
256
+ /* Loop R14 (气泡子树稳定性): the renderer map used to be an OBJECT LITERAL
257
+ OF INLINE ARROWS inside MarkdownContent's JSX — every render produced new
258
+ component types, so React unmounted and remounted the entire rendered
259
+ markdown subtree on every soft refresh (measured: +36 ChatImage mounts in
260
+ 45s idle; any focused element inside a bubble fell back to <body>).
261
+ Hoisting the map to a factory + memoizing per onImageClick keeps types
262
+ stable; memo() on MarkdownContent then skips re-render entirely while
263
+ text is unchanged. */
264
+ function buildMdComponents(onImageClick?: (url: string) => void) {
265
+ return {
160
266
  h1: ({ children }) => <h1 className="text-base font-semibold text-[var(--fg)] mt-2 mb-1">{children}</h1>,
161
267
  h2: ({ children }) => <h2 className="text-sm font-semibold text-[var(--fg)] mt-2 mb-1">{children}</h2>,
162
268
  h3: ({ children }) => <h3 className="text-sm font-semibold text-cyan-300 mt-1.5 mb-0.5">{children}</h3>,
@@ -169,6 +275,12 @@ function MarkdownContent({ text }: { text: string }) {
169
275
  a: ({ href, children }) => <a href={href} target="_blank" rel="noreferrer" className="text-cyan-300 underline">{children}</a>,
170
276
  blockquote: ({ children }) => <blockquote className="border-l-2 border-cyan-500/30 pl-3 my-1 text-[var(--fg-muted)] italic">{children}</blockquote>,
171
277
  hr: () => <hr className="border-t border-[var(--border)] my-2" />,
278
+ img: ({ src, alt }) => {
279
+ const url = typeof src === 'string' ? src : '';
280
+ if (!url) return null;
281
+ if (!onImageClick) return <img src={url} alt={alt || ''} loading="lazy" className="my-1 max-h-48 rounded-lg" />;
282
+ return <ChatImage src={url} alt={alt} thumbClass="my-1 h-40 w-full max-w-[240px] rounded-lg" onOpen={onImageClick} />;
283
+ },
172
284
  code: ({ className, children }) => {
173
285
  const inline = !className?.startsWith('language-');
174
286
  if (inline) {
@@ -192,13 +304,23 @@ function MarkdownContent({ text }: { text: string }) {
192
304
  tr: ({ children }) => <tr className="border-b border-[var(--border)]">{children}</tr>,
193
305
  th: ({ children }) => <th className="text-left text-cyan-300 font-semibold px-2 py-1 border border-[var(--border)]">{children}</th>,
194
306
  td: ({ children }) => <td className="px-2 py-1 border border-[var(--border)] align-top">{children}</td>,
195
- }}
307
+ } satisfies React.ComponentProps<typeof ReactMarkdown>['components'];
308
+ }
309
+
310
+ const MarkdownContent = memo(function MarkdownContent({ text, onImageClick }: { text: string; onImageClick?: (url: string) => void }) {
311
+ const components = useMemo(() => buildMdComponents(onImageClick), [onImageClick]);
312
+ if (!text) return <span className="text-[var(--fg-dim)] italic">No content</span>;
313
+ return (
314
+ <div className="break-words leading-relaxed">
315
+ <ReactMarkdown
316
+ remarkPlugins={[remarkGfm]}
317
+ components={components}
196
318
  >
197
319
  {text}
198
320
  </ReactMarkdown>
199
321
  </div>
200
322
  );
201
- }
323
+ });
202
324
 
203
325
  interface TaskChatPanelProps {
204
326
  alias: string;
@@ -302,6 +424,18 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes, active }
302
424
  // would always look "not at bottom" because the new content itself
303
425
  // grew the distance).
304
426
  const [newBelow, setNewBelow] = useState(0);
427
+ const [lightboxUrl, setLightboxUrl] = useState<string | null>(null);
428
+ // Loop R15 (会话内搜索): client-side filter over the LOADED window —
429
+ // honest scope (the hint says so); load earlier messages to widen it.
430
+ const [searchOpen, setSearchOpen] = useState(false);
431
+ const [searchQuery, setSearchQuery] = useState('');
432
+ const [searchIdx, setSearchIdx] = useState(0);
433
+ const searchInputRef = useRef<HTMLInputElement>(null);
434
+ const lightboxOpenerRef = useRef<HTMLElement | null>(null);
435
+ const openLightbox = useCallback((url: string) => {
436
+ lightboxOpenerRef.current = document.activeElement as HTMLElement | null;
437
+ setLightboxUrl(url);
438
+ }, []);
305
439
  const atBottomRef = useRef(true);
306
440
  const prevBubblesRef = useRef(0);
307
441
  const forceScrollRef = useRef(false);
@@ -812,6 +946,29 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes, active }
812
946
  return a.kind === 'task' ? -1 : 1;
813
947
  });
814
948
 
949
+ const query = searchQuery.trim().toLowerCase();
950
+ const searchMatches = query
951
+ ? chatEvents
952
+ .filter((ev) => {
953
+ const text = ev.kind === 'reply' ? ev.task.result : ev.task.content;
954
+ return (text || '').toLowerCase().includes(query);
955
+ })
956
+ .map((ev) => `${ev.task.task_id}:${ev.kind}`)
957
+ : [];
958
+ const currentMatchKey = searchMatches.length ? searchMatches[Math.min(searchIdx, searchMatches.length - 1)] : null;
959
+ // New query starts at the NEWEST match (WeChat searches backwards in time).
960
+ useEffect(() => { setSearchIdx(Math.max(0, searchMatches.length - 1)); /* eslint-disable-line react-hooks/exhaustive-deps */ }, [query]);
961
+ useEffect(() => {
962
+ if (!currentMatchKey) return;
963
+ const el = scrollRef.current?.querySelector(`[data-ev="${CSS.escape(currentMatchKey)}"]`);
964
+ el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
965
+ }, [currentMatchKey]);
966
+ const stepMatch = (delta: number) => {
967
+ if (!searchMatches.length) return;
968
+ setSearchIdx((i) => (i + delta + searchMatches.length) % searchMatches.length);
969
+ };
970
+ const closeSearch = () => { setSearchOpen(false); setSearchQuery(''); };
971
+
815
972
  // Inline mode: just the chat content, no panel chrome
816
973
  const chatContent = (
817
974
  <>
@@ -824,6 +981,41 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes, active }
824
981
  to space-y-1.5 so messages read denser without losing the
825
982
  speaker-turn rhythm. Desktop unchanged at sm: and up. */}
826
983
  <div ref={scrollRef} onScroll={onMessagesScroll} className="flex-1 overflow-y-auto px-3 py-3 sm:px-4 sm:py-4 space-y-2 sm:space-y-4">
984
+ {searchOpen ? (
985
+ <div className="sticky top-0 z-20 -mx-1 flex items-center gap-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)]/95 px-2 py-1.5 shadow-lg shadow-black/30 backdrop-blur">
986
+ <input
987
+ ref={searchInputRef}
988
+ value={searchQuery}
989
+ onChange={(e) => setSearchQuery(e.target.value)}
990
+ onKeyDown={(e) => {
991
+ if (e.key === 'Escape') { e.stopPropagation(); closeSearch(); }
992
+ else if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); stepMatch(1); }
993
+ else if (e.key === 'Enter') { e.preventDefault(); stepMatch(-1); }
994
+ }}
995
+ placeholder="搜索会话…"
996
+ className="min-w-0 flex-1 bg-transparent text-xs text-[var(--fg)] placeholder-[var(--fg-dim)] focus:outline-none"
997
+ />
998
+ <span className="shrink-0 text-[10px] tabular-nums text-[var(--fg-dim)]">
999
+ {searchMatches.length ? `${Math.min(searchIdx, searchMatches.length - 1) + 1}/${searchMatches.length}` : searchQuery.trim() ? '0 条' : `${chatEvents.length} 条内`}
1000
+ </span>
1001
+ <button type="button" onClick={() => stepMatch(-1)} aria-label="上一条(更早)" className="shrink-0 rounded px-1 text-[var(--fg-dim)] hover:text-cyan-300">↑</button>
1002
+ <button type="button" onClick={() => stepMatch(1)} aria-label="下一条(更新)" className="shrink-0 rounded px-1 text-[var(--fg-dim)] hover:text-cyan-300">↓</button>
1003
+ <button type="button" onClick={closeSearch} aria-label="关闭搜索" className="shrink-0 rounded px-1 text-[var(--fg-dim)] hover:text-[var(--fg)]">×</button>
1004
+ </div>
1005
+ ) : (
1006
+ <div className="sticky top-0 z-20 flex justify-end pointer-events-none -mb-8">
1007
+ <button
1008
+ type="button"
1009
+ onClick={() => { setSearchOpen(true); setTimeout(() => searchInputRef.current?.focus(), 50); }}
1010
+ aria-label="搜索会话"
1011
+ className="pointer-events-auto rounded-full border border-[var(--border)] bg-[var(--bg-elevated)]/80 p-1.5 text-[var(--fg-dim)] shadow hover:text-cyan-300"
1012
+ >
1013
+ <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1014
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11a6 6 0 11-12 0 6 6 0 0112 0z" />
1015
+ </svg>
1016
+ </button>
1017
+ </div>
1018
+ )}
827
1019
  {!historyLoaded && (
828
1020
  <div className="flex justify-center py-8">
829
1021
  <div className="w-5 h-5 border-2 border-cyan-500/30 border-t-cyan-500 rounded-full animate-spin" />
@@ -896,8 +1088,8 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes, active }
896
1088
  return (
897
1089
  <Fragment key={`${m.task_id}:reply`}>
898
1090
  {sepEl}
899
- <div className="flex justify-start">
900
- <div title={event.at} className="max-w-[92%] sm:max-w-[min(85%,56ch)] bg-green-500/8 border border-green-500/15 rounded-2xl rounded-bl-md px-3 py-2.5 sm:px-4 shadow-sm">
1091
+ <div className="flex justify-start" data-ev={`${m.task_id}:reply`}>
1092
+ <div title={event.at} className={`max-w-[92%] sm:max-w-[min(85%,56ch)] bg-green-500/8 border border-green-500/15 rounded-2xl rounded-bl-md px-3 py-2.5 sm:px-4 shadow-sm ${currentMatchKey === `${m.task_id}:reply` ? 'ring-2 ring-yellow-400/60' : ''}`}>
901
1093
  <div className="flex items-center justify-between gap-2 mb-2">
902
1094
  <div className="flex items-center gap-1.5 min-w-0">
903
1095
  {m.to_name && <AliasAvatar alias={m.to_name} size={14} />}
@@ -914,8 +1106,8 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes, active }
914
1106
  </div>
915
1107
  </div>
916
1108
  <div className="text-[14px] leading-[1.65] text-[var(--fg)]">
917
- <CollapsibleText><MarkdownContent text={m.result} /></CollapsibleText>
918
- <AttachmentPreviews text={m.result} />
1109
+ <CollapsibleText><MarkdownContent text={m.result} onImageClick={openLightbox} /></CollapsibleText>
1110
+ <AttachmentPreviews text={m.result} onOpen={openLightbox} />
919
1111
  </div>
920
1112
  </div>
921
1113
  </div>
@@ -927,15 +1119,15 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes, active }
927
1119
  {sepEl}
928
1120
  <div className="space-y-1.5 sm:space-y-2">
929
1121
  {/* Outgoing task — labeled with origin so peer-forwarded tasks are obvious */}
930
- <div className="flex justify-end">
931
- <div title={m.created_at} className="max-w-[92%] sm:max-w-[min(85%,56ch)] bg-cyan-500/8 border border-cyan-500/15 rounded-2xl rounded-br-md px-3 py-2.5 sm:px-4 shadow-sm">
1122
+ <div className="flex justify-end" data-ev={`${m.task_id}:task`}>
1123
+ <div title={m.created_at} className={`max-w-[92%] sm:max-w-[min(85%,56ch)] bg-cyan-500/8 border border-cyan-500/15 rounded-2xl rounded-br-md px-3 py-2.5 sm:px-4 shadow-sm ${currentMatchKey === `${m.task_id}:task` ? 'ring-2 ring-yellow-400/60' : ''}`}>
932
1124
  <div className="flex items-center gap-2 mb-1">
933
1125
  <span className={`text-[10px] font-medium ${fromUser ? 'text-cyan-400' : 'text-purple-300'}`}>{senderLabel}</span>
934
1126
  {!fromUser && <span className="text-[9px] text-[var(--fg-dim)]">forwarded to {m.to_name}</span>}
935
1127
  </div>
936
1128
  <div className="text-[14px] leading-[1.65] text-[var(--fg)]">
937
- <CollapsibleText><MarkdownContent text={m.content} /></CollapsibleText>
938
- <AttachmentPreviews text={m.content} />
1129
+ <CollapsibleText><MarkdownContent text={m.content} onImageClick={openLightbox} /></CollapsibleText>
1130
+ <AttachmentPreviews text={m.content} onOpen={openLightbox} />
939
1131
  </div>
940
1132
  <div className="flex flex-wrap items-center justify-between mt-1.5 gap-2 sm:gap-3">
941
1133
  <StatusBar status={m.status} />
@@ -1072,6 +1264,7 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes, active }
1072
1264
  <span>Enter to send · paste image</span>
1073
1265
  </div>
1074
1266
  </div>
1267
+ {lightboxUrl && <ImageLightbox url={lightboxUrl} opener={lightboxOpenerRef.current} onClose={() => setLightboxUrl(null)} />}
1075
1268
  </>
1076
1269
  );
1077
1270
 
@@ -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.16",
3
+ "version": "0.6.3-preview.18",
4
4
  "description": "Agent Network Dashboard — Web UI for managing AI Agent networks",
5
5
  "main": "apps/desktop/electron/main.cjs",
6
6
  "scripts": {