@sleep2agi/agent-network-dashboard 0.6.3-preview.51 → 0.6.3-preview.53
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_COMMIT +1 -1
- package/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +3 -3
- package/.next/diagnostics/route-bundle-stats.json +48 -48
- package/.next/fallback-build-manifest.json +3 -3
- package/.next/prerender-manifest.json +3 -3
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +2 -2
- package/.next/server/app/_not-found.rsc +13 -13
- package/.next/server/app/_not-found.segments/_full.segment.rsc +13 -13
- package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/server/app/_not-found.segments/_index.segment.rsc +8 -8
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/admin/page.js.nft.json +1 -1
- package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
- package/.next/server/app/admin.html +2 -2
- package/.next/server/app/admin.rsc +15 -15
- package/.next/server/app/admin.segments/_full.segment.rsc +15 -15
- package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
- package/.next/server/app/admin.segments/_index.segment.rsc +8 -8
- package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
- package/.next/server/app/index.html +2 -2
- package/.next/server/app/index.rsc +15 -15
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/server/app/index.segments/_index.segment.rsc +8 -8
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login/page.js.nft.json +1 -1
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/login.html +2 -2
- package/.next/server/app/login.rsc +15 -15
- package/.next/server/app/login.segments/_full.segment.rsc +15 -15
- package/.next/server/app/login.segments/_head.segment.rsc +4 -4
- package/.next/server/app/login.segments/_index.segment.rsc +8 -8
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/login.segments/login.segment.rsc +3 -3
- package/.next/server/app/logs/page.js.nft.json +1 -1
- package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
- package/.next/server/app/logs.html +2 -2
- package/.next/server/app/logs.rsc +15 -15
- package/.next/server/app/logs.segments/_full.segment.rsc +15 -15
- package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
- package/.next/server/app/logs.segments/_index.segment.rsc +8 -8
- package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
- package/.next/server/app/messages/page.js.nft.json +1 -1
- package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
- package/.next/server/app/messages.html +2 -2
- package/.next/server/app/messages.rsc +15 -15
- package/.next/server/app/messages.segments/_full.segment.rsc +15 -15
- package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
- package/.next/server/app/messages.segments/_index.segment.rsc +8 -8
- package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
- package/.next/server/app/node/page.js.nft.json +1 -1
- package/.next/server/app/node/page_client-reference-manifest.js +1 -1
- package/.next/server/app/node.html +2 -2
- package/.next/server/app/node.rsc +15 -15
- package/.next/server/app/node.segments/_full.segment.rsc +15 -15
- package/.next/server/app/node.segments/_head.segment.rsc +4 -4
- package/.next/server/app/node.segments/_index.segment.rsc +8 -8
- package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/node.segments/node.segment.rsc +3 -3
- package/.next/server/app/nodes/[alias]/page.js.nft.json +1 -1
- package/.next/server/app/nodes/[alias]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/nodes/page.js.nft.json +1 -1
- package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
- package/.next/server/app/nodes.html +2 -2
- package/.next/server/app/nodes.rsc +15 -15
- package/.next/server/app/nodes.segments/_full.segment.rsc +15 -15
- package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
- package/.next/server/app/nodes.segments/_index.segment.rsc +8 -8
- package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/providers/page.js.nft.json +1 -1
- package/.next/server/app/providers/page_client-reference-manifest.js +1 -1
- package/.next/server/app/providers.html +2 -2
- package/.next/server/app/providers.rsc +15 -15
- package/.next/server/app/providers.segments/_full.segment.rsc +15 -15
- package/.next/server/app/providers.segments/_head.segment.rsc +4 -4
- package/.next/server/app/providers.segments/_index.segment.rsc +8 -8
- package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/providers.segments/providers.segment.rsc +3 -3
- package/.next/server/app/server-logs/page.js.nft.json +1 -1
- package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
- package/.next/server/app/server-logs.html +2 -2
- package/.next/server/app/server-logs.rsc +15 -15
- package/.next/server/app/server-logs.segments/_full.segment.rsc +15 -15
- package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
- package/.next/server/app/server-logs.segments/_index.segment.rsc +8 -8
- package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
- package/.next/server/app/servers/page.js.nft.json +1 -1
- package/.next/server/app/servers/page_client-reference-manifest.js +1 -1
- package/.next/server/app/servers.html +2 -2
- package/.next/server/app/servers.rsc +15 -15
- package/.next/server/app/servers.segments/_full.segment.rsc +15 -15
- package/.next/server/app/servers.segments/_head.segment.rsc +4 -4
- package/.next/server/app/servers.segments/_index.segment.rsc +8 -8
- package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/servers.segments/servers.segment.rsc +3 -3
- package/.next/server/app/settings/networks/page.js.nft.json +1 -1
- package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/networks.html +2 -2
- package/.next/server/app/settings/networks.rsc +15 -15
- package/.next/server/app/settings/networks.segments/_full.segment.rsc +15 -15
- package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings/networks.segments/_index.segment.rsc +8 -8
- package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
- package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
- package/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/tokens/page.js.nft.json +1 -1
- package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/tokens.html +2 -2
- package/.next/server/app/settings/tokens.rsc +15 -15
- package/.next/server/app/settings/tokens.segments/_full.segment.rsc +15 -15
- package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings/tokens.segments/_index.segment.rsc +8 -8
- package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
- package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
- package/.next/server/app/settings.html +2 -2
- package/.next/server/app/settings.rsc +15 -15
- package/.next/server/app/settings.segments/_full.segment.rsc +15 -15
- package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings.segments/_index.segment.rsc +8 -8
- package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
- package/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
- package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/tasks/page.js.nft.json +1 -1
- package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
- package/.next/server/app/tasks.html +2 -2
- package/.next/server/app/tasks.rsc +15 -15
- package/.next/server/app/tasks.segments/_full.segment.rsc +15 -15
- package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
- package/.next/server/app/tasks.segments/_index.segment.rsc +8 -8
- package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
- package/.next/server/chunks/[root-of-the-server]__0t--g3k._.js +1 -1
- package/.next/server/chunks/[root-of-the-server]__0t--g3k._.js.map +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js.map +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0dcez4x._.js +11 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0dcez4x._.js.map +1 -0
- package/.next/server/chunks/ssr/{[root-of-the-server]__0jgqpfv._.js → [root-of-the-server]__0gyqilt._.js} +2 -2
- package/.next/server/chunks/ssr/[root-of-the-server]__0gyqilt._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0j-def.._.js +11 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0j-def.._.js.map +1 -0
- package/.next/server/chunks/ssr/{[root-of-the-server]__11wln8.._.js → [root-of-the-server]__0oavw6j._.js} +2 -2
- package/.next/server/chunks/ssr/[root-of-the-server]__0oavw6j._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0rct4.k._.js +3 -0
- package/.next/server/chunks/ssr/{[root-of-the-server]__02o~-py._.js.map → [root-of-the-server]__0rct4.k._.js.map} +1 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_05u4_h~._.js +2 -2
- package/.next/server/chunks/ssr/dash-stageb-wt_app_05u4_h~._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js +1 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js +1 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js +2 -2
- package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_TasksView_tsx_0pnz7dd._.js +1 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_TasksView_tsx_0pnz7dd._.js.map +1 -1
- package/.next/server/functions-config-manifest.json +4 -0
- 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/051a-ut_4_f~3.js +9 -0
- package/.next/static/chunks/{0advoo2tzdcyi.js → 0hhij-plsekos.js} +1 -1
- package/.next/static/chunks/{0_4ljrbp.stxy.js → 0i465ex5.l573.js} +1 -1
- package/.next/static/chunks/{10px7ehmo7eq0.js → 0jbkmykrnr~q6.js} +1 -1
- package/.next/static/chunks/0lc115vavown3.css +1 -0
- package/.next/static/chunks/{0z_4d13crd_af.js → 0n_7389-uuzik.js} +1 -1
- package/.next/static/chunks/0oai8y23-yy20.js +9 -0
- package/.next/static/chunks/{0dkk_l872ocgm.js → 0ohx4jpm9~61..js} +1 -1
- package/.next/static/chunks/{0uk67ty_0h_36.js → 0p3f6dx723h6w.js} +1 -1
- package/.next/static/chunks/0sy8dlbnq8b.5.js +2 -0
- package/.next/static/chunks/{0fz7zk.ek.154.js → 0u5y.gmi~jkw6.js} +1 -1
- package/.next/static/chunks/0ws0p1n62l~ub.js +1 -0
- package/.next/static/chunks/{06q7-vhy~1-y3.js → 0~rx9_~1wupcp.js} +2 -2
- package/.next/static/{JL_QjNKv1KhsCF68ZK28x → wbJm2a-VxMJqS77WvU7--}/_clientMiddlewareManifest.js +4 -0
- package/.next/trace +2 -2
- package/.next/trace-build +1 -1
- package/CHANGELOG.md +143 -0
- package/app/components/ChatPane.tsx +66 -25
- package/app/components/ChatPaneOverflowMenu.tsx +152 -0
- package/app/components/NodeList.tsx +14 -4
- package/app/components/TaskChatPanel.tsx +172 -31
- package/app/messages/page.tsx +22 -22
- package/app/nodes/NodesView.tsx +52 -11
- package/app/tasks/TasksView.tsx +12 -2
- package/package.json +1 -1
- package/proxy.ts +50 -1
- package/tests/e2e-9-nodes-team-tag-filter.spec.ts +14 -7
- package/tests/e2e-batch-list-actions.spec.ts +6 -0
- package/tests/e2e-chatpane-header-menu.spec.ts +259 -0
- package/tests/e2e-messages-spec-visual-align.spec.ts +244 -0
- package/tests/e2e-pin-mute-attr-always-present.spec.ts +171 -0
- package/tests/e2e-proxy-auth-boundary.spec.ts +196 -0
- package/tests/e2e-stage-a-node-list.spec.ts +14 -7
- package/tests/e2e-stage-b-chat-pane.spec.ts +74 -10
- package/tests/e2e-status-labels.spec.ts +218 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__02o~-py._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0jgqpfv._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0w7k~3f._.js +0 -9
- package/.next/server/chunks/ssr/[root-of-the-server]__0w7k~3f._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__11wln8.._.js.map +0 -1
- package/.next/static/chunks/05~lgfzsv.p~p.js +0 -2
- package/.next/static/chunks/092zqb~4un32l.css +0 -1
- package/.next/static/chunks/11zz15q37~bz5.js +0 -7
- package/.next/static/chunks/17qkc714xy04..js +0 -1
- /package/.next/static/{JL_QjNKv1KhsCF68ZK28x → wbJm2a-VxMJqS77WvU7--}/_buildManifest.js +0 -0
- /package/.next/static/{JL_QjNKv1KhsCF68ZK28x → wbJm2a-VxMJqS77WvU7--}/_ssgManifest.js +0 -0
package/app/messages/page.tsx
CHANGED
|
@@ -146,7 +146,7 @@ export default function MessagesPage() {
|
|
|
146
146
|
}, [filtered]);
|
|
147
147
|
|
|
148
148
|
return (
|
|
149
|
-
<div className="min-h-screen bg-[#
|
|
149
|
+
<div data-testid="messages-shell" className="min-h-screen bg-[#141826] text-gray-100 p-4 sm:p-6">
|
|
150
150
|
{/* #209 R31 (mobile vertical rhythm — same pattern as R28/R30):
|
|
151
151
|
header row + quick-from chips row both mb-6 → mb-4 sm:mb-6.
|
|
152
152
|
#209 R33: header restructured to mirror /nodes R32 — title
|
|
@@ -253,7 +253,7 @@ export default function MessagesPage() {
|
|
|
253
253
|
<button
|
|
254
254
|
type="button"
|
|
255
255
|
onClick={() => setSearch('')}
|
|
256
|
-
className="shrink-0 rounded-full border border-gray-700 px-3 py-1 text-
|
|
256
|
+
className="shrink-0 rounded-full border border-gray-700 px-3 py-1 text-[11px] text-[#64748B] hover:text-gray-200"
|
|
257
257
|
>
|
|
258
258
|
Clear
|
|
259
259
|
</button>
|
|
@@ -336,9 +336,9 @@ export default function MessagesPage() {
|
|
|
336
336
|
Halve it on mobile. */}
|
|
337
337
|
{gapExceeded && (
|
|
338
338
|
<div className="my-2 sm:my-4 flex items-center gap-3">
|
|
339
|
-
<div className="h-px flex-1 bg-[
|
|
340
|
-
<div className="text-[
|
|
341
|
-
<div className="h-px flex-1 bg-[
|
|
339
|
+
<div className="h-px flex-1 bg-white/[0.06]" />
|
|
340
|
+
<div className="text-[13px] font-medium text-[#94A3B8]">{formatDividerLabel(message.created_at)}</div>
|
|
341
|
+
<div className="h-px flex-1 bg-white/[0.06]" />
|
|
342
342
|
</div>
|
|
343
343
|
)}
|
|
344
344
|
|
|
@@ -348,21 +348,21 @@ export default function MessagesPage() {
|
|
|
348
348
|
390px. Desktop unchanged from sm: up. */}
|
|
349
349
|
{variant === 'broadcast' ? (
|
|
350
350
|
<div className={samePrev ? 'mt-0.5 sm:mt-1' : 'mt-2 sm:mt-3'}>
|
|
351
|
-
<div className="rounded-2xl border border-
|
|
351
|
+
<div className="rounded-2xl border border-white/[0.06] border-l-2 border-l-[#7DD3FC] bg-[#1E293B] px-3 py-2 sm:px-4 sm:py-3 shadow-sm w-full">
|
|
352
352
|
<div className="mb-1 sm:mb-2 flex flex-wrap items-center gap-2">
|
|
353
353
|
<span className={`text-xs px-2 py-0.5 rounded-md border ${TYPE_COLORS[message.type || ''] || 'bg-gray-500/10 text-gray-400 border-gray-500/20'}`}>
|
|
354
354
|
{message.type || 'unknown'}
|
|
355
355
|
</span>
|
|
356
|
-
<span className="text-sm font-medium text-
|
|
357
|
-
{message.to_alias && <span className="text-xs text-
|
|
358
|
-
{message.priority === 'high' && <span className="text-xs text-
|
|
359
|
-
<span className="ml-auto text-
|
|
356
|
+
<span className="text-sm font-medium text-[#E2E8F0]">{fromAlias}</span>
|
|
357
|
+
{message.to_alias && <span className="text-xs text-[#CBD5E1]">to {message.to_alias}</span>}
|
|
358
|
+
{message.priority === 'high' && <span className="text-xs text-[#EF4444]">HIGH</span>}
|
|
359
|
+
<span className="ml-auto text-[11px] text-[#64748B]">{message.created_at ? timeAgo(message.created_at) : '--'}</span>
|
|
360
360
|
</div>
|
|
361
361
|
{/* Round 79: break-words + overflow-wrap:anywhere so
|
|
362
362
|
long unbroken runs (URLs, ASCII rules like
|
|
363
363
|
`═══════════════`) wrap instead of pushing the
|
|
364
364
|
chat bubble past the mobile viewport. */}
|
|
365
|
-
<div className="whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm leading-snug sm:leading-relaxed text-
|
|
365
|
+
<div className="whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm leading-snug sm:leading-relaxed text-[#E2E8F0]">
|
|
366
366
|
{renderHighlighted(message.content, search)}
|
|
367
367
|
</div>
|
|
368
368
|
{/* #492: broadcast may carry attachments too. */}
|
|
@@ -377,23 +377,23 @@ export default function MessagesPage() {
|
|
|
377
377
|
<div className="w-8 shrink-0 pt-1">
|
|
378
378
|
{!samePrev && <AliasAvatar alias={fromAlias} size={32} />}
|
|
379
379
|
</div>
|
|
380
|
-
<div className={`min-w-0 max-w-
|
|
380
|
+
<div className={`min-w-0 max-w-[min(640px,65%)] rounded-2xl border border-white/[0.06] px-3 py-2 sm:px-4 sm:py-3 shadow-sm ${
|
|
381
381
|
variant === 'outgoing'
|
|
382
|
-
? '
|
|
383
|
-
: '
|
|
382
|
+
? 'bg-[#1E3A5F]'
|
|
383
|
+
: 'bg-[#1E293B]'
|
|
384
384
|
}`}>
|
|
385
385
|
{!samePrev && (
|
|
386
386
|
<div className="mb-1 sm:mb-2 flex flex-wrap items-center gap-2">
|
|
387
387
|
<span className={`text-xs px-2 py-0.5 rounded-md border ${TYPE_COLORS[message.type || ''] || 'bg-gray-500/10 text-gray-400 border-gray-500/20'}`}>
|
|
388
388
|
{message.type || 'unknown'}
|
|
389
389
|
</span>
|
|
390
|
-
<span className={`text-sm font-medium ${variant === 'outgoing' ? 'text-
|
|
390
|
+
<span className={`text-sm font-medium ${variant === 'outgoing' ? 'text-[#F1F5F9]' : 'text-[#E2E8F0]'}`}>
|
|
391
391
|
{fromAlias}
|
|
392
392
|
</span>
|
|
393
|
-
<span className="text-xs text-
|
|
394
|
-
<span className="text-sm text-
|
|
395
|
-
{message.priority === 'high' && <span className="text-xs text-
|
|
396
|
-
<span className="ml-auto text-
|
|
393
|
+
<span className="text-xs text-[#64748B]">→</span>
|
|
394
|
+
<span className="text-sm text-[#CBD5E1]">{message.to_alias || '--'}</span>
|
|
395
|
+
{message.priority === 'high' && <span className="text-xs text-[#EF4444]">HIGH</span>}
|
|
396
|
+
<span className="ml-auto text-[11px] text-[#64748B]">{message.created_at ? timeAgo(message.created_at) : '--'}</span>
|
|
397
397
|
</div>
|
|
398
398
|
)}
|
|
399
399
|
|
|
@@ -401,7 +401,7 @@ export default function MessagesPage() {
|
|
|
401
401
|
long unbroken runs (URLs, ASCII rules like
|
|
402
402
|
`═══════════════`) wrap instead of pushing the
|
|
403
403
|
chat bubble past the mobile viewport. */}
|
|
404
|
-
<div className="whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm leading-snug sm:leading-relaxed text-
|
|
404
|
+
<div className="whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm leading-snug sm:leading-relaxed text-[#E2E8F0]">
|
|
405
405
|
{renderHighlighted(message.content, search)}
|
|
406
406
|
</div>
|
|
407
407
|
|
|
@@ -417,13 +417,13 @@ export default function MessagesPage() {
|
|
|
417
417
|
|
|
418
418
|
{/* When in a streak, surface the timestamp inline-right so users still know cadence */}
|
|
419
419
|
{samePrev && (
|
|
420
|
-
<div className="mt-1 text-[
|
|
420
|
+
<div className="mt-1 text-[11px] text-[#64748B] text-right">
|
|
421
421
|
{message.created_at ? timeAgo(message.created_at) : ''}
|
|
422
422
|
</div>
|
|
423
423
|
)}
|
|
424
424
|
|
|
425
425
|
{debug && (
|
|
426
|
-
<div className="mt-3 border-t border-white/10 pt-3 text-
|
|
426
|
+
<div className="mt-3 border-t border-white/10 pt-3 text-[11px] text-[#64748B] space-y-1">
|
|
427
427
|
<div>ID: {message.id}</div>
|
|
428
428
|
{message.task_id && <div>Task ID: {message.task_id}</div>}
|
|
429
429
|
<div>Created: {message.created_at}</div>
|
package/app/nodes/NodesView.tsx
CHANGED
|
@@ -4,7 +4,6 @@ import { useState, useRef, useEffect, useSyncExternalStore } from 'react';
|
|
|
4
4
|
import { timeAgo } from '../components/utils';
|
|
5
5
|
import { useSessions, useHealth, useNodeLifecycle } from '../lib/hooks';
|
|
6
6
|
import { useSWRConfig } from 'swr';
|
|
7
|
-
import { TaskChatPanel } from '../components/TaskChatPanel';
|
|
8
7
|
import { CreateNodeWizard } from '../components/CreateNodeWizard';
|
|
9
8
|
import { NodeLifecycleMenu } from '../components/NodeLifecycleMenu';
|
|
10
9
|
import { EmptyState, NodesEmptyState } from '../components/EmptyState';
|
|
@@ -118,6 +117,10 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
|
|
|
118
117
|
const [chatAlias, setChatAlias] = useState<string | null>(initialAlias);
|
|
119
118
|
const isDesktop = useIsDesktop();
|
|
120
119
|
const selectNode = (alias: string) => {
|
|
120
|
+
try {
|
|
121
|
+
const el = document.querySelector('[data-testid="node-list-scroll"]');
|
|
122
|
+
if (el) savedListScrollRef.current = el.scrollTop;
|
|
123
|
+
} catch {}
|
|
121
124
|
setChatAlias(alias);
|
|
122
125
|
try { window.history.pushState({}, '', `/nodes/${encodeURIComponent(alias)}`); } catch {}
|
|
123
126
|
};
|
|
@@ -295,6 +298,31 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
|
|
|
295
298
|
// column rendered.
|
|
296
299
|
const chatOpenDesktop = !!chatAlias && isDesktop && sessions.length > 0;
|
|
297
300
|
|
|
301
|
+
// #Mobile two-level nav (Feishu phone form, 通信龙 07-31, breakpoint md
|
|
302
|
+
// per my own ruling — no third media query):
|
|
303
|
+
// < md unselected = conversation LIST fullscreen (not the table)
|
|
304
|
+
// < md selected = chat as a full PAGE with a back arrow
|
|
305
|
+
// manage mode = the old table, reachable via the same toggle
|
|
306
|
+
// Desktop auto-selects; mobile deliberately does NOT (the list IS the
|
|
307
|
+
// first screen — auto-select effect above is gated on isDesktop).
|
|
308
|
+
const mobileListLevel = !isDesktop && !chatAlias && !manageView && sessions.length > 0;
|
|
309
|
+
const mobileChatLevel = !isDesktop && !!chatAlias;
|
|
310
|
+
|
|
311
|
+
// #Mobile back restores the list scroll position (判据: enter from row
|
|
312
|
+
// ~50, come back, offset ≤200px — snapping to top is the annoying bug).
|
|
313
|
+
// Saved at selection time; restored when the list level remounts.
|
|
314
|
+
// Desktop is exempt: its rail never unmounts while chatting, and the
|
|
315
|
+
// user may have scrolled it meanwhile.
|
|
316
|
+
const savedListScrollRef = useRef(0);
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
if (isDesktop || chatAlias) return;
|
|
319
|
+
const id = requestAnimationFrame(() => {
|
|
320
|
+
const el = document.querySelector('[data-testid="node-list-scroll"]');
|
|
321
|
+
if (el) el.scrollTop = savedListScrollRef.current;
|
|
322
|
+
});
|
|
323
|
+
return () => cancelAnimationFrame(id);
|
|
324
|
+
}, [isDesktop, chatAlias]);
|
|
325
|
+
|
|
298
326
|
return (
|
|
299
327
|
<div
|
|
300
328
|
ref={rootRef}
|
|
@@ -310,10 +338,10 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
|
|
|
310
338
|
#Stage C: a selected node FORCES the rail on (even for dense-view
|
|
311
339
|
users with showRail=false) — in the list+chat layout the rail is
|
|
312
340
|
the only way to switch nodes, so it must be there. */}
|
|
313
|
-
{(showRail || chatOpenDesktop) && sessions.length > 0 && (
|
|
341
|
+
{(mobileListLevel || (isDesktop && (showRail || chatOpenDesktop) && sessions.length > 0)) && (
|
|
314
342
|
<aside
|
|
315
343
|
data-testid="node-list-rail"
|
|
316
|
-
className="
|
|
344
|
+
className="flex flex-col w-full md:w-[280px] shrink-0 h-full min-h-0 bg-[#0F1320]"
|
|
317
345
|
>
|
|
318
346
|
<NodeList
|
|
319
347
|
sessions={railSessions}
|
|
@@ -339,8 +367,20 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
|
|
|
339
367
|
{/* #Stage C: middle column (header + filters + table/grid) renders
|
|
340
368
|
only when NO node is selected on desktop — see chatOpenDesktop
|
|
341
369
|
note above. Mobile keeps it always (chat is an overlay there). */}
|
|
342
|
-
{!chatOpenDesktop && (
|
|
343
|
-
<div className="flex-1 min-w-0 max-w-full h-full overflow-y-auto overflow-x-hidden p-4 sm:p-6">
|
|
370
|
+
{(isDesktop ? !chatOpenDesktop : (manageView && !chatAlias)) && (
|
|
371
|
+
<div className="flex-1 min-w-0 max-w-full h-full overflow-y-auto overflow-x-hidden p-4 sm:p-6 pb-[calc(5rem+env(safe-area-inset-bottom))] lg:pb-6">
|
|
372
|
+
{/* #Mobile manage mode: way back to the conversation list — the rail
|
|
373
|
+
(which hosts the toggle) is hidden at this level on phones. */}
|
|
374
|
+
{!isDesktop && manageView && (
|
|
375
|
+
<button
|
|
376
|
+
type="button"
|
|
377
|
+
onClick={toggleManageView}
|
|
378
|
+
data-testid="manage-view-toggle-mobile"
|
|
379
|
+
className="md:hidden mb-3 rounded-lg border border-[rgba(255,255,255,0.08)] bg-[#0F1320] px-3 py-1.5 text-[12px] text-gray-400 hover:text-gray-200"
|
|
380
|
+
>
|
|
381
|
+
{t('≡ 返回对话视图', '≡ Back to conversations')}
|
|
382
|
+
</button>
|
|
383
|
+
)}
|
|
344
384
|
{/* #209 R31 (mobile vertical rhythm — goal "大幅提升移动端体验",
|
|
345
385
|
extending R28's Overview pattern + R30's /tasks pattern):
|
|
346
386
|
this header row + the status-bar wrapper + the filter row
|
|
@@ -760,18 +800,19 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
|
|
|
760
800
|
</div>
|
|
761
801
|
)}
|
|
762
802
|
|
|
763
|
-
{/* #Stage B
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
two are mutually exclusive via isDesktop so only ONE
|
|
767
|
-
TaskChatPanel is ever mounted (no double SSE / mark-read). */}
|
|
768
|
-
{chatAlias && !isDesktop && <TaskChatPanel alias={chatAlias} onClose={closeChat} />}
|
|
803
|
+
{/* (#Stage B's mobile overlay is gone — < md chat is now a full
|
|
804
|
+
PAGE, rendered as ChatPane variant="page" below. isDesktop
|
|
805
|
+
still guarantees only ONE chat body is ever mounted.) */}
|
|
769
806
|
</div>
|
|
770
807
|
)}
|
|
771
808
|
{/* #Stage B: persistent right chat column (md+) — see ChatPane.tsx
|
|
772
809
|
(ChatPopover minus the floating shell; TaskChatPanel inline body).
|
|
773
810
|
Switching nodes swaps `alias` in place (no remount). */}
|
|
774
811
|
{chatAlias && isDesktop && <ChatPane alias={chatAlias} onClose={closeChat} />}
|
|
812
|
+
{/* #Mobile two-level nav: chat is a full PAGE (not the old overlay).
|
|
813
|
+
Back arrow and browser back both land on the list (closeChat /
|
|
814
|
+
popstate) — 判据: 行为一致. */}
|
|
815
|
+
{mobileChatLevel && <ChatPane alias={chatAlias!} variant="page" onBack={closeChat} onClose={closeChat} />}
|
|
775
816
|
</div>
|
|
776
817
|
);
|
|
777
818
|
}
|
package/app/tasks/TasksView.tsx
CHANGED
|
@@ -72,8 +72,16 @@ function TasksViewInner({ initialSelectedId = null }: { initialSelectedId?: stri
|
|
|
72
72
|
const [error, setError] = useState('');
|
|
73
73
|
|
|
74
74
|
const [filterStatus, setFilterStatus] = useState(searchParams.get('status') || '');
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// 07-31 通信龙 dispatch (临时开放此文件): let ChatPane's "查看任务"
|
|
76
|
+
// button prefill the from/to filters via URL. Before this, the /tasks
|
|
77
|
+
// link on ChatPane header carried `?alias=` which was silently ignored
|
|
78
|
+
// (both filters init'd to '' regardless of URL) — user tapped, saw the
|
|
79
|
+
// full unfiltered list, thought "that's all the tasks for this alias".
|
|
80
|
+
// See feedback_verify_both_directions_with_real_data — this is a
|
|
81
|
+
// silent-half-broken feature that already shipped in preview.51 and
|
|
82
|
+
// needs both prefill directions.
|
|
83
|
+
const [filterFrom, setFilterFrom] = useState(searchParams.get('from_name') || '');
|
|
84
|
+
const [filterTo, setFilterTo] = useState(searchParams.get('to_name') || '');
|
|
77
85
|
|
|
78
86
|
// #Stage-tasks: selection is seeded from /tasks/[id] URL, then plain
|
|
79
87
|
// client state kept in sync with pushState (shallow — no route remount,
|
|
@@ -226,6 +234,7 @@ function TasksViewInner({ initialSelectedId = null }: { initialSelectedId?: stri
|
|
|
226
234
|
value={filterFrom}
|
|
227
235
|
onChange={(e) => setFilterFrom(e.target.value)}
|
|
228
236
|
placeholder="any"
|
|
237
|
+
data-testid="tasks-filter-from"
|
|
229
238
|
className="w-16 bg-transparent text-xs text-white placeholder-gray-700 focus:outline-none"
|
|
230
239
|
/>
|
|
231
240
|
</div>
|
|
@@ -236,6 +245,7 @@ function TasksViewInner({ initialSelectedId = null }: { initialSelectedId?: stri
|
|
|
236
245
|
value={filterTo}
|
|
237
246
|
onChange={(e) => setFilterTo(e.target.value)}
|
|
238
247
|
placeholder="any"
|
|
248
|
+
data-testid="tasks-filter-to"
|
|
239
249
|
className="w-16 bg-transparent text-xs text-white placeholder-gray-700 focus:outline-none"
|
|
240
250
|
/>
|
|
241
251
|
</div>
|
package/package.json
CHANGED
package/proxy.ts
CHANGED
|
@@ -18,6 +18,55 @@ export function proxy(request: NextRequest) {
|
|
|
18
18
|
return NextResponse.next();
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// ROUTE GATE — read carefully before editing.
|
|
22
|
+
//
|
|
23
|
+
// This is NOT authentication. It's a cookie-presence redirect:
|
|
24
|
+
// `request.cookies.has(SESSION_COOKIE)` at line 8 checks only that
|
|
25
|
+
// the cookie EXISTS, not that its value is valid. Any garbage
|
|
26
|
+
// (e.g. `anet_dashboard_session=xyz`) passes this gate and lands
|
|
27
|
+
// on the page. Real authentication lives in the /api/hub layer,
|
|
28
|
+
// which validates the token content when the page tries to fetch
|
|
29
|
+
// data.
|
|
30
|
+
//
|
|
31
|
+
// So this matcher's job is narrower than it looks: it decides
|
|
32
|
+
// which paths bounce no-cookie visitors to /login for a nicer UX.
|
|
33
|
+
// It does NOT prevent access to page bytes by a visitor who
|
|
34
|
+
// forges any cookie value ("has a cookie" is not "is authenticated").
|
|
35
|
+
// Do not write code that trusts this gate for authorization —
|
|
36
|
+
// always call the hub for data and check the response.
|
|
37
|
+
//
|
|
38
|
+
// 07-31 通信龙 catch: `/nodes/:path*` was missing. Every other
|
|
39
|
+
// page route with a dynamic child had it (`/tasks/:path*`,
|
|
40
|
+
// `/settings/:path*`), so this was a slip, not a design choice.
|
|
41
|
+
// Verified in prod: a no-session-cookie GET to `/nodes/<alias>`
|
|
42
|
+
// was REACHABLE — returned 200 with the full 36KB shell; every
|
|
43
|
+
// other matcher entry returned 307 → /login as intended.
|
|
44
|
+
//
|
|
45
|
+
// Blast radius today: /nodes/[alias] page happens to not run a
|
|
46
|
+
// server-side data fetch — 通信龙 hit it with a nonexistent alias
|
|
47
|
+
// `ZZZ-not-a-real-agent-9999` and got 200 + 36108 bytes back (only
|
|
48
|
+
// 16 bytes bigger = the alias-length difference echoed in the
|
|
49
|
+
// shell). Since the page doesn't query, no DB row leaks. This is
|
|
50
|
+
// STRUCTURAL, not sensitive: if the page ever gains a server-side
|
|
51
|
+
// data fetch, the leak begins immediately. Fix now, don't wait.
|
|
52
|
+
//
|
|
53
|
+
// If you add a new page route with a dynamic child, add its
|
|
54
|
+
// `:path*` variant here AND add its bare path to the test list
|
|
55
|
+
// in `tests/e2e-proxy-auth-boundary.spec.ts` — the test
|
|
56
|
+
// enumerates matcher entries and asserts each redirects the
|
|
57
|
+
// no-cookie case, so it catches this class of miss for the next
|
|
58
|
+
// time. (The test's filename says "auth-boundary" for continuity
|
|
59
|
+
// with #69; the actual behavior it tests is cookie-presence,
|
|
60
|
+
// same as this comment describes.)
|
|
21
61
|
export const config = {
|
|
22
|
-
matcher: [
|
|
62
|
+
matcher: [
|
|
63
|
+
'/',
|
|
64
|
+
'/node',
|
|
65
|
+
'/tasks', '/tasks/:path*',
|
|
66
|
+
'/nodes', '/nodes/:path*',
|
|
67
|
+
'/messages',
|
|
68
|
+
'/logs',
|
|
69
|
+
'/settings', '/settings/:path*',
|
|
70
|
+
'/admin',
|
|
71
|
+
],
|
|
23
72
|
};
|
|
@@ -80,16 +80,23 @@ const HUB_HEALTH_EMPTY = {
|
|
|
80
80
|
sse_sessions: {},
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
+
// Login rate limit is 10/15min/IP (app/lib/rate-limit.ts): with 4 spec
|
|
84
|
+
// files in one combined run, per-test logins blow the budget and late
|
|
85
|
+
// tests land on the sign-in page (false red). Login once per worker.
|
|
86
|
+
let cachedSessionCookie: string | null = null;
|
|
83
87
|
async function login(context: Page['context'] extends () => infer C ? C : never) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
if (!cachedSessionCookie) {
|
|
89
|
+
const res = await context.request.post(`${BASE}/api/auth/login`, {
|
|
90
|
+
data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
|
|
91
|
+
});
|
|
92
|
+
const setCookie = res.headers()['set-cookie'] || '';
|
|
93
|
+
const m = setCookie.match(/anet_dashboard_session=([^;]+)/);
|
|
94
|
+
if (m) cachedSessionCookie = decodeURIComponent(m[1]);
|
|
95
|
+
}
|
|
96
|
+
if (cachedSessionCookie) {
|
|
90
97
|
await context.addCookies([{
|
|
91
98
|
name: 'anet_dashboard_session',
|
|
92
|
-
value:
|
|
99
|
+
value: cachedSessionCookie,
|
|
93
100
|
domain: new URL(BASE).hostname,
|
|
94
101
|
path: '/',
|
|
95
102
|
}]);
|
|
@@ -110,6 +110,12 @@ test.describe('Batch — list actions (07-31)', () => {
|
|
|
110
110
|
await expect(chatInput).toHaveValue(DRAFT);
|
|
111
111
|
|
|
112
112
|
// Open settings drawer → close → text MUST still be there.
|
|
113
|
+
// 07-31 SPEC §12 batch: ⋮ now opens overflow menu (not settings
|
|
114
|
+
// direct). "会话设置" is one of four menu items and still carries
|
|
115
|
+
// data-testid="chat-pane-settings" so this assertion's target is
|
|
116
|
+
// preserved — but the click flow is now two steps.
|
|
117
|
+
await page.locator('[data-testid="chat-pane-more"]').click();
|
|
118
|
+
await expect(page.locator('[data-testid="chat-pane-overflow-menu"]')).toBeVisible({ timeout: 3000 });
|
|
113
119
|
await page.locator('[data-testid="chat-pane-settings"]').click();
|
|
114
120
|
await expect(page.locator('[role="dialog"][aria-label*="节点设置"]').first()).toBeVisible({ timeout: 5000 });
|
|
115
121
|
// Close via the backdrop (absolute-positioned inside chat pane).
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { test, expect, Page } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
const BASE = process.env.TEST_URL || 'http://localhost:3100';
|
|
4
|
+
|
|
5
|
+
// 通信龙 07-31 SPEC §12 remaining — ChatPane header ⋮/📋/🔍 wire-up.
|
|
6
|
+
//
|
|
7
|
+
// Each test double-asserts (positive + negative) per 通信龙 discipline
|
|
8
|
+
// "接上了(正向)+ 该不生效时不生效(反向)", and each will have a
|
|
9
|
+
// dedicated witnessed-red mutation applied after this file lands green.
|
|
10
|
+
//
|
|
11
|
+
// Covered:
|
|
12
|
+
// ① ⋮ opens overflow menu with 4 items (add tab / view tasks /
|
|
13
|
+
// settings / separator). Menu container reuses SPEC §10 tokens.
|
|
14
|
+
// Reverse: outside click closes the menu.
|
|
15
|
+
// ② 🔍 dispatches chat:open-search → TaskChatPanel search UI shows.
|
|
16
|
+
// Reverse: no click → search UI hidden.
|
|
17
|
+
// ③ 📋 link href resolves to `/tasks?to_name=<alias>` (URL param
|
|
18
|
+
// name must match TasksView's useSearchParams keys — 'to_name',
|
|
19
|
+
// not 'alias').
|
|
20
|
+
// ④ /tasks?from_name=<alias> filters list to matching from_name
|
|
21
|
+
// entries only. Reverse: without the param, list is unfiltered.
|
|
22
|
+
// ⑤ overflow "添加标签页" → row acquires data-node-pinned="true".
|
|
23
|
+
// Reverse: before click, attr is "false".
|
|
24
|
+
|
|
25
|
+
let cachedCookie: string | undefined;
|
|
26
|
+
|
|
27
|
+
async function login(context: Page['context'] extends () => infer C ? C : never) {
|
|
28
|
+
if (cachedCookie) {
|
|
29
|
+
await context.addCookies([{ name: 'anet_dashboard_session', value: cachedCookie,
|
|
30
|
+
domain: new URL(BASE).hostname, path: '/', httpOnly: true, sameSite: 'Lax' }]);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const res = await context.request.post(`${BASE}/api/auth/login`, {
|
|
34
|
+
data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
|
|
35
|
+
});
|
|
36
|
+
const setCookie = res.headers()['set-cookie'] || '';
|
|
37
|
+
const m = setCookie.match(/anet_dashboard_session=([^;]+)/);
|
|
38
|
+
if (m) {
|
|
39
|
+
cachedCookie = decodeURIComponent(m[1]);
|
|
40
|
+
await context.addCookies([{ name: 'anet_dashboard_session', value: cachedCookie,
|
|
41
|
+
domain: new URL(BASE).hostname, path: '/', httpOnly: true, sameSite: 'Lax' }]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const FIXTURE_SESSIONS = {
|
|
46
|
+
sessions: [
|
|
47
|
+
{ alias: 'node-alpha', status: 'idle', network_id: 'net_x' },
|
|
48
|
+
{ alias: 'node-beta', status: 'idle', network_id: 'net_x' },
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
const FIXTURE_NODES = {
|
|
52
|
+
nodes: [
|
|
53
|
+
{ alias: 'node-alpha', node_id: 'n_a', team: null, tags: [], attrs_revision: 0 },
|
|
54
|
+
{ alias: 'node-beta', node_id: 'n_b', team: null, tags: [], attrs_revision: 0 },
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// For /tasks filter test: 6 tasks total — 2 from node-alpha, 2 from
|
|
59
|
+
// node-beta, 2 with a third from_name. This makes "带 from_name 筛"
|
|
60
|
+
// vs "不带 → 全量" measurably different (2 vs 6).
|
|
61
|
+
const FIXTURE_TASKS_ALL = [
|
|
62
|
+
{ task_id: 't1', from_name: 'node-alpha', to_name: 'node-beta', status: 'replied', content: 'a1', result: 'ok', created_at: '2026-07-31T10:00:00', priority: 'normal' },
|
|
63
|
+
{ task_id: 't2', from_name: 'node-alpha', to_name: 'node-gamma', status: 'delivered', content: 'a2', result: '', created_at: '2026-07-31T10:01:00', priority: 'normal' },
|
|
64
|
+
{ task_id: 't3', from_name: 'node-beta', to_name: 'node-alpha', status: 'acked', content: 'b1', result: '', created_at: '2026-07-31T10:02:00', priority: 'normal' },
|
|
65
|
+
{ task_id: 't4', from_name: 'node-beta', to_name: 'node-alpha', status: 'replied', content: 'b2', result: 'ok', created_at: '2026-07-31T10:03:00', priority: 'normal' },
|
|
66
|
+
{ task_id: 't5', from_name: 'node-omega', to_name: 'node-alpha', status: 'queued', content: 'o1', result: '', created_at: '2026-07-31T10:04:00', priority: 'normal' },
|
|
67
|
+
{ task_id: 't6', from_name: 'node-omega', to_name: 'node-beta', status: 'failed', content: 'o2', result: 'x', created_at: '2026-07-31T10:05:00', priority: 'normal' },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
async function stubHubBase(page: Page) {
|
|
71
|
+
await page.route('**/api/hub/status**', r =>
|
|
72
|
+
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(FIXTURE_SESSIONS) }));
|
|
73
|
+
await page.route('**/api/hub/nodes**', r =>
|
|
74
|
+
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(FIXTURE_NODES) }));
|
|
75
|
+
await page.route('**/api/hub/networks**', r =>
|
|
76
|
+
r.fulfill({ status: 200, contentType: 'application/json',
|
|
77
|
+
body: JSON.stringify({ networks: [{ network_id: 'net_x', network_name: 'net_x' }] }) }));
|
|
78
|
+
await page.route('**/api/hub/health', r =>
|
|
79
|
+
r.fulfill({ status: 200, contentType: 'application/json',
|
|
80
|
+
body: JSON.stringify({ ok: true, version: '0.9.0-test', sse_connections: 0, sse_sessions: {} }) }));
|
|
81
|
+
await page.route('**/api/hub/stats**', r =>
|
|
82
|
+
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }));
|
|
83
|
+
await page.route('**/api/hub/messages**', r =>
|
|
84
|
+
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages: [] }) }));
|
|
85
|
+
await page.route('**/api/hub/events**', r =>
|
|
86
|
+
r.fulfill({ status: 200, contentType: 'text/event-stream', body: '' }));
|
|
87
|
+
await page.route('**/api/anet/node-config**', r =>
|
|
88
|
+
r.fulfill({ status: 200, contentType: 'application/json',
|
|
89
|
+
body: JSON.stringify({ model: '', flags: {}, channels: [], config_revision: 0 }) }));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// /api/hub/tasks stub that honors from_name/to_name query params —
|
|
93
|
+
// mirrors what the real hub does. If our tested URL-param wiring is
|
|
94
|
+
// wrong, the filter won't be applied and the test reds.
|
|
95
|
+
async function stubTasks(page: Page) {
|
|
96
|
+
await page.route('**/api/hub/tasks**', r => {
|
|
97
|
+
const url = new URL(r.request().url());
|
|
98
|
+
const from = url.searchParams.get('from_name');
|
|
99
|
+
const to = url.searchParams.get('to_name');
|
|
100
|
+
let out = FIXTURE_TASKS_ALL.slice();
|
|
101
|
+
if (from) out = out.filter(t => t.from_name === from);
|
|
102
|
+
if (to) out = out.filter(t => t.to_name === to);
|
|
103
|
+
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tasks: out, total: out.length }) });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
test.describe('ChatPane header — overflow menu / search event / /tasks URL params', () => {
|
|
108
|
+
test('① ⋮ opens overflow menu with 4 items; outside click closes it', async ({ page, context }) => {
|
|
109
|
+
await login(context);
|
|
110
|
+
await stubHubBase(page);
|
|
111
|
+
await page.goto(`${BASE}/nodes/node-alpha`);
|
|
112
|
+
await expect(page.locator('[data-testid="chat-pane"]').first()).toBeVisible({ timeout: 15000 });
|
|
113
|
+
|
|
114
|
+
const more = page.locator('[data-testid="chat-pane-more"]').first();
|
|
115
|
+
await expect(more).toBeVisible();
|
|
116
|
+
|
|
117
|
+
// Reverse: menu is NOT open before we click. If a prior render
|
|
118
|
+
// leaked open state, "click → check open" would still pass.
|
|
119
|
+
await expect(page.locator('[data-testid="chat-pane-overflow-menu"]')).toHaveCount(0);
|
|
120
|
+
|
|
121
|
+
await more.click();
|
|
122
|
+
const menu = page.locator('[data-testid="chat-pane-overflow-menu"]');
|
|
123
|
+
await expect(menu).toBeVisible({ timeout: 3000 });
|
|
124
|
+
|
|
125
|
+
// All 4 menu items are present. Each check pulls a distinct
|
|
126
|
+
// testid — if two collapsed (e.g. only 3 items rendered because
|
|
127
|
+
// one is conditional), this reds precisely on the missing one.
|
|
128
|
+
// Locale-agnostic assertions: Playwright defaults to en-US and
|
|
129
|
+
// t(zh, en) returns en. Accept both — the test is proving "this
|
|
130
|
+
// item exists with a non-empty user-facing label", not policing
|
|
131
|
+
// the exact copy (that job belongs to a separate copy-review).
|
|
132
|
+
await expect(menu.locator('[data-testid="chat-pane-overflow-pin"]')).toHaveText(/添加标签页|移除标签页|Add as tab|Remove tab/);
|
|
133
|
+
await expect(menu.locator('[data-testid="chat-pane-overflow-view-tasks"]')).toHaveText(/查看任务|View tasks/);
|
|
134
|
+
await expect(menu.locator('[data-testid="chat-pane-settings"]')).toHaveText(/会话设置|Conversation settings/);
|
|
135
|
+
await expect(menu.locator('[data-testid="chat-pane-overflow-separator"]')).toHaveCount(1);
|
|
136
|
+
|
|
137
|
+
// Reverse: outside click closes.
|
|
138
|
+
await page.mouse.click(10, 10);
|
|
139
|
+
await expect(page.locator('[data-testid="chat-pane-overflow-menu"]')).toHaveCount(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('② 🔍 dispatches chat:open-search → TaskChatPanel search UI mounts; without click stays hidden', async ({ page, context }) => {
|
|
143
|
+
await login(context);
|
|
144
|
+
await stubHubBase(page);
|
|
145
|
+
await page.goto(`${BASE}/nodes/node-alpha`);
|
|
146
|
+
await expect(page.locator('[data-testid="chat-pane"]').first()).toBeVisible({ timeout: 15000 });
|
|
147
|
+
|
|
148
|
+
// Reverse: search input is NOT present before click. TaskChatPanel
|
|
149
|
+
// has a search input (searchInputRef) that mounts only when
|
|
150
|
+
// searchOpen is true. Its DOM presence is our signal.
|
|
151
|
+
// We look for input[placeholder*="搜索"] scoped to the chat pane.
|
|
152
|
+
const searchInput = page.locator('[data-testid="chat-pane"] input[placeholder*="搜索"], [data-testid="chat-pane"] input[placeholder*="Search"]').first();
|
|
153
|
+
await expect(searchInput).toHaveCount(0);
|
|
154
|
+
|
|
155
|
+
// Confirm the header button is enabled (previously disabled).
|
|
156
|
+
const searchBtn = page.locator('[data-testid="chat-pane-search"]').first();
|
|
157
|
+
await expect(searchBtn).toBeVisible();
|
|
158
|
+
await expect(searchBtn).toBeEnabled();
|
|
159
|
+
await expect(searchBtn).toHaveAttribute('data-search-enabled', 'true');
|
|
160
|
+
|
|
161
|
+
await searchBtn.click();
|
|
162
|
+
// TaskChatPanel schedules focus with setTimeout(50); allow it.
|
|
163
|
+
await expect(searchInput).toBeVisible({ timeout: 3000 });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('③ 📋 view-tasks link points to /tasks?to_name=<alias> (encoded)', async ({ page, context }) => {
|
|
167
|
+
await login(context);
|
|
168
|
+
await stubHubBase(page);
|
|
169
|
+
await page.goto(`${BASE}/nodes/node-alpha`);
|
|
170
|
+
await expect(page.locator('[data-testid="chat-pane"]').first()).toBeVisible({ timeout: 15000 });
|
|
171
|
+
|
|
172
|
+
const link = page.locator('[data-testid="chat-pane-view-tasks"]').first();
|
|
173
|
+
await expect(link).toBeVisible();
|
|
174
|
+
// Assert the EXACT href, not just contains: catches both wrong
|
|
175
|
+
// param name (?alias= would still contain the alias) and wrong
|
|
176
|
+
// scheme/path (something like /tasks/#... won't match).
|
|
177
|
+
const href = await link.getAttribute('href');
|
|
178
|
+
expect(href).toBe('/tasks?to_name=node-alpha');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('④ /tasks reads from_name / to_name URL params → prefills filter inputs', async ({ page, context }) => {
|
|
182
|
+
// The wire we're testing is TasksView.tsx:75-76 —
|
|
183
|
+
// useState(searchParams.get('from_name') || '')
|
|
184
|
+
// The most direct fact-side check is: after navigating to
|
|
185
|
+
// /tasks?from_name=X, the visible "From" input's value === X.
|
|
186
|
+
// We DO NOT depend on the /api/hub/tasks fetch/response
|
|
187
|
+
// (previous version did — /tasks has a client-side "401 →
|
|
188
|
+
// /login" redirect in TasksView.tsx:126 that got tripped by
|
|
189
|
+
// rate-limit flakiness in local dev, red-herring for this
|
|
190
|
+
// wire). The input value assertion catches the exact bug M4
|
|
191
|
+
// targets: silent drop of URL param.
|
|
192
|
+
await login(context);
|
|
193
|
+
await stubHubBase(page);
|
|
194
|
+
await stubTasks(page);
|
|
195
|
+
|
|
196
|
+
// (a) Baseline — no URL param → both inputs empty.
|
|
197
|
+
await page.goto(`${BASE}/tasks`);
|
|
198
|
+
// Filter inputs render early in TasksView (not gated on a
|
|
199
|
+
// successful fetch), so waiting on them avoids the auth-redirect
|
|
200
|
+
// race entirely.
|
|
201
|
+
const fromInput = page.locator('[data-testid="tasks-filter-from"]');
|
|
202
|
+
const toInput = page.locator('[data-testid="tasks-filter-to"]');
|
|
203
|
+
await expect(fromInput).toBeVisible({ timeout: 15000 });
|
|
204
|
+
await expect(fromInput).toHaveValue('');
|
|
205
|
+
await expect(toInput).toHaveValue('');
|
|
206
|
+
|
|
207
|
+
// (b) With ?from_name=node-alpha → From input prefilled, To empty.
|
|
208
|
+
await page.goto(`${BASE}/tasks?from_name=node-alpha`);
|
|
209
|
+
await expect(fromInput).toBeVisible({ timeout: 15000 });
|
|
210
|
+
await expect(fromInput).toHaveValue('node-alpha');
|
|
211
|
+
await expect(toInput).toHaveValue('');
|
|
212
|
+
|
|
213
|
+
// (c) With ?to_name=node-beta → To input prefilled, From empty.
|
|
214
|
+
// This is the direction ChatPane's link uses (`?to_name=<alias>`),
|
|
215
|
+
// so both directions must work — not just one.
|
|
216
|
+
await page.goto(`${BASE}/tasks?to_name=node-beta`);
|
|
217
|
+
await expect(fromInput).toBeVisible({ timeout: 15000 });
|
|
218
|
+
await expect(toInput).toHaveValue('node-beta');
|
|
219
|
+
await expect(fromInput).toHaveValue('');
|
|
220
|
+
|
|
221
|
+
// (d) Both — belt and suspenders.
|
|
222
|
+
await page.goto(`${BASE}/tasks?from_name=node-alpha&to_name=node-beta`);
|
|
223
|
+
await expect(fromInput).toBeVisible({ timeout: 15000 });
|
|
224
|
+
await expect(fromInput).toHaveValue('node-alpha');
|
|
225
|
+
await expect(toInput).toHaveValue('node-beta');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('⑤ overflow "添加标签页" toggles pin (data-node-pinned false→true)', async ({ page, context }) => {
|
|
229
|
+
await login(context);
|
|
230
|
+
await stubHubBase(page);
|
|
231
|
+
// Clear localStorage so we start from unpinned.
|
|
232
|
+
await page.addInitScript(() => {
|
|
233
|
+
try { window.localStorage.removeItem('anet_chat_pin_v1'); } catch {}
|
|
234
|
+
});
|
|
235
|
+
await page.goto(`${BASE}/nodes/node-alpha`);
|
|
236
|
+
await expect(page.locator('[data-testid="chat-pane"]').first()).toBeVisible({ timeout: 15000 });
|
|
237
|
+
|
|
238
|
+
// Premise: node-alpha's rail row starts unpinned (attr === 'false').
|
|
239
|
+
const alphaRow = page.locator('[data-node-list-alias="node-alpha"]').first();
|
|
240
|
+
await expect(alphaRow).toHaveAttribute('data-node-pinned', 'false');
|
|
241
|
+
|
|
242
|
+
await page.locator('[data-testid="chat-pane-more"]').click();
|
|
243
|
+
await expect(page.locator('[data-testid="chat-pane-overflow-menu"]')).toBeVisible({ timeout: 3000 });
|
|
244
|
+
// "添加标签页" — label depends on state; assert menu item then click.
|
|
245
|
+
const pinItem = page.locator('[data-testid="chat-pane-overflow-pin"]');
|
|
246
|
+
// Locale-agnostic: could be zh or en; premise is "starts in the
|
|
247
|
+
// ADD (not REMOVE) state" — we cleared localStorage above so it
|
|
248
|
+
// should be Add. Assert against both language forms.
|
|
249
|
+
await expect(pinItem).toHaveText(/添加标签页|Add as tab/);
|
|
250
|
+
await pinItem.click();
|
|
251
|
+
|
|
252
|
+
// Menu should have auto-closed via onClose in the handler.
|
|
253
|
+
await expect(page.locator('[data-testid="chat-pane-overflow-menu"]')).toHaveCount(0);
|
|
254
|
+
|
|
255
|
+
// 🔴 Core: the rail row's data-node-pinned flipped to 'true'.
|
|
256
|
+
// useSyncExternalStore reacts to the storage event; give it a tick.
|
|
257
|
+
await expect(alphaRow).toHaveAttribute('data-node-pinned', 'true', { timeout: 2000 });
|
|
258
|
+
});
|
|
259
|
+
});
|