@sleep2agi/agent-network-dashboard 0.6.3-preview.50 → 0.6.3-preview.52
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 +50 -50
- package/.next/fallback-build-manifest.json +3 -3
- package/.next/prerender-manifest.json +3 -3
- package/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- 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/ssr/{0ld5_next_dist_00v047f._.js → 0ld5_next_dist_04cce2g._.js} +2 -2
- package/.next/server/chunks/ssr/0ld5_next_dist_04cce2g._.js.map +1 -0
- 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]__0rct4.k._.js.map +1 -0
- package/.next/server/chunks/ssr/{[root-of-the-server]__00n84ca._.js → [root-of-the-server]__0x9nh~a._.js} +2 -2
- package/.next/server/chunks/ssr/[root-of-the-server]__0x9nh~a._.js.map +1 -0
- package/.next/server/chunks/ssr/dash-stageb-wt_0q55dv1._.js +0 -0
- package/.next/server/chunks/ssr/dash-stageb-wt_0q55dv1._.js.map +1 -0
- package/.next/server/chunks/ssr/dash-stageb-wt_13184u1._.js +0 -0
- package/.next/server/chunks/ssr/dash-stageb-wt_13184u1._.js.map +1 -0
- 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_0hzfl6x._.js → dash-stageb-wt_app_page_tsx_0iyrtvj._.js} +3 -3
- package/.next/server/chunks/ssr/dash-stageb-wt_app_page_tsx_0iyrtvj._.js.map +1 -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/0-ot~88nectgp.js +2 -0
- package/.next/static/chunks/00adqh_rd04uf.js +9 -0
- package/.next/static/chunks/051p0pw4z~6ag.js +1 -0
- package/.next/static/chunks/0aaakzcw7tey0.js +0 -0
- package/.next/static/chunks/{0hm52b0y~k0~z.js → 0lf8e-h91s10h.js} +1 -1
- package/.next/static/chunks/0njt8wek.9aq6.js +1 -0
- package/.next/static/chunks/0ohx4jpm9~61..js +1 -0
- package/.next/static/chunks/0pg4pce.q5ne_.js +9 -0
- package/.next/static/chunks/0u5y.gmi~jkw6.js +1 -0
- package/.next/static/chunks/0wf03_efeii.w.css +1 -0
- package/.next/static/chunks/0ws0p1n62l~ub.js +1 -0
- package/.next/static/chunks/0zljk8~2jqw93.js +4 -0
- package/.next/static/chunks/{06q7-vhy~1-y3.js → 0~rx9_~1wupcp.js} +2 -2
- package/.next/static/chunks/{0ep78sq90x_yc.js → 14x094g58kg61.js} +1 -1
- package/.next/trace +2 -2
- package/.next/trace-build +1 -1
- package/app/components/ChatPane.tsx +133 -17
- package/app/components/NodeList.tsx +208 -46
- package/app/components/NodeListContextMenu.tsx +187 -0
- package/app/components/NodeSettingsPanel.tsx +29 -3
- package/app/components/TaskChatPanel.tsx +156 -31
- package/app/messages/page.tsx +22 -22
- package/app/nodes/NodesView.tsx +110 -11
- package/package.json +1 -1
- package/tests/e2e-9-nodes-team-tag-filter.spec.ts +17 -7
- package/tests/e2e-batch-list-actions.spec.ts +234 -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-stage-a-node-list.spec.ts +18 -7
- package/tests/e2e-stage-b-chat-pane.spec.ts +177 -10
- package/tests/e2e-status-labels.spec.ts +218 -0
- package/.next/server/chunks/ssr/0ld5_next_dist_00v047f._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__00n84ca._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0idij1k._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0idij1k._.js.map +0 -1
- 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/server/chunks/ssr/dash-stageb-wt_0hzfl6x._.js.map +0 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_06.7qt9._.js +0 -0
- package/.next/server/chunks/ssr/dash-stageb-wt_app_06.7qt9._.js.map +0 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0y~m8-6._.js +0 -0
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0y~m8-6._.js.map +0 -1
- package/.next/static/chunks/02.5_nifak.uu.js +0 -1
- package/.next/static/chunks/03hzj129k~odb.js +0 -1
- package/.next/static/chunks/0cey4i.y0a8~j.js +0 -1
- package/.next/static/chunks/0mhaf6lrw5nn..css +0 -1
- package/.next/static/chunks/0s293geid.j7e.js +0 -4
- package/.next/static/chunks/0zj_navpyhltx.js +0 -2
- package/.next/static/chunks/11zz15q37~bz5.js +0 -7
- package/.next/static/chunks/14y4x3de1lbug.js +0 -1
- package/.next/static/chunks/15ka68xi.w-i-.js +0 -1
- package/.next/static/chunks/17qkc714xy04..js +0 -1
- /package/.next/static/{vc_aJHtYwefsVcl2_f14Z → RiXBfeE2zY5tzXRmF7kBD}/_buildManifest.js +0 -0
- /package/.next/static/{vc_aJHtYwefsVcl2_f14Z → RiXBfeE2zY5tzXRmF7kBD}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{vc_aJHtYwefsVcl2_f14Z → RiXBfeE2zY5tzXRmF7kBD}/_ssgManifest.js +0 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useState } from 'react';
|
|
3
4
|
import { AliasAvatar } from './AliasAvatar';
|
|
4
5
|
import { TaskChatPanel } from './TaskChatPanel';
|
|
6
|
+
import { NodeSettingsPanel } from './NodeSettingsPanel';
|
|
7
|
+
import { useSessions } from '../lib/hooks';
|
|
5
8
|
import { t } from '../lib/ui-lang';
|
|
6
9
|
|
|
7
10
|
// #Stage B (dashboard 3-col): the persistent right chat column on /nodes.
|
|
@@ -14,44 +17,157 @@ import { t } from '../lib/ui-lang';
|
|
|
14
17
|
// renders (NodesView.chatOpenDesktop), so the pane takes all width right
|
|
15
18
|
// of the rail instead of being squeezed to a fixed sliver by the table.
|
|
16
19
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
20
|
+
// Batch (07-31 通信龙) segment 4: chat top-bar right adds ⋮ (overflow /
|
|
21
|
+
// settings entry), 📅 (view task), 🔍 (search — disabled v1), + existing
|
|
22
|
+
// ✕ (close). Settings entry opens NodeSettingsPanel as a 360px right-
|
|
23
|
+
// drawer SCOPED to the chat pane (positioning='absolute'), so opening
|
|
24
|
+
// settings doesn't cover the sidebar or rail — SPEC §12 + 通信龙 判定.
|
|
25
|
+
// TaskChatPanel stays mounted while the drawer opens (renders as a
|
|
26
|
+
// sibling, not a replacement) so draft / scroll / SSE state survives —
|
|
27
|
+
// Vincent's "只发生一次就再也不敢用" red-line.
|
|
28
|
+
//
|
|
29
|
+
// Test-hook contract:
|
|
30
|
+
// - Root: data-testid="chat-pane", data-chat-alias
|
|
31
|
+
// - Buttons: data-testid="chat-pane-{settings,view-tasks,search,close}"
|
|
32
|
+
// - Settings dialog inherits NodeSettingsPanel's role="dialog"
|
|
21
33
|
export interface ChatPaneProps {
|
|
22
34
|
/** Node alias to chat with. Changing it swaps the conversation in place. */
|
|
23
35
|
alias: string;
|
|
24
36
|
/** Close the pane (unselect + URL back to /nodes — parent owns both). */
|
|
25
37
|
onClose: () => void;
|
|
38
|
+
/** #Mobile two-level nav: 'page' renders full-width/full-height with a
|
|
39
|
+
* back arrow (Feishu phone form — chat is a page, not a column).
|
|
40
|
+
* Default 'column' keeps the desktop right-column behavior. */
|
|
41
|
+
variant?: 'column' | 'page';
|
|
42
|
+
/** Back arrow handler (page variant). Falls back to onClose. */
|
|
43
|
+
onBack?: () => void;
|
|
26
44
|
}
|
|
27
45
|
|
|
28
|
-
export function ChatPane({ alias, onClose }: ChatPaneProps) {
|
|
46
|
+
export function ChatPane({ alias, onClose, variant = 'column', onBack }: ChatPaneProps) {
|
|
47
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
48
|
+
// Look up session for NodeSettingsPanel — it requires the full row.
|
|
49
|
+
// useSessions is SWR-cached (same key as NodesView), so no extra
|
|
50
|
+
// network cost.
|
|
51
|
+
const { sessions } = useSessions();
|
|
52
|
+
const session = sessions.find(s => s.alias === alias);
|
|
53
|
+
|
|
29
54
|
return (
|
|
30
55
|
<aside
|
|
31
56
|
data-testid="chat-pane"
|
|
32
57
|
data-chat-alias={alias}
|
|
33
|
-
|
|
58
|
+
// `relative` so the settings drawer (positioning='absolute')
|
|
59
|
+
// scopes to this <aside>, not the viewport. Without this the
|
|
60
|
+
// drawer would cover the whole page — the exact regression
|
|
61
|
+
// 通信龙's judgment ("settings-as-right-drawer, not 4th column
|
|
62
|
+
// and not fullscreen") is designed to prevent.
|
|
63
|
+
// page variant (#mobile two-level nav): full-bleed chat page below md.
|
|
64
|
+
// pb keeps the composer above the fixed MobileNav (visible < lg) —
|
|
65
|
+
// same offset AppShell reserves for normal pages.
|
|
66
|
+
className={variant === 'page'
|
|
67
|
+
? 'relative flex flex-col w-full min-w-0 h-full bg-[#141826] pb-[calc(5rem+env(safe-area-inset-bottom))] lg:pb-0'
|
|
68
|
+
: 'relative hidden md:flex md:flex-col flex-1 min-w-0 h-full bg-[#141826] pb-[calc(5rem+env(safe-area-inset-bottom))] lg:pb-0'}
|
|
34
69
|
>
|
|
35
70
|
<div className="flex items-center justify-between gap-2 px-3 py-2.5 border-b border-[rgba(255,255,255,0.06)] bg-[#141826] shrink-0">
|
|
36
71
|
<div className="flex items-center gap-2 min-w-0">
|
|
72
|
+
{variant === 'page' && (
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={onBack ?? onClose}
|
|
76
|
+
aria-label={t('返回列表', 'Back to list')}
|
|
77
|
+
data-testid="chat-back"
|
|
78
|
+
className="inline-flex h-8 w-8 -ml-1 items-center justify-center rounded-lg text-gray-400 hover:text-gray-100 hover:bg-[#1c1c1f] transition-colors shrink-0"
|
|
79
|
+
>
|
|
80
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
81
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
82
|
+
</svg>
|
|
83
|
+
</button>
|
|
84
|
+
)}
|
|
37
85
|
<AliasAvatar alias={alias} size={26} />
|
|
38
86
|
<span className="truncate text-sm font-semibold text-white">{alias}</span>
|
|
39
87
|
</div>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
88
|
+
{/* SPEC §12: right-side icon row. ⋮ IS the settings entry
|
|
89
|
+
(opens NodeSettingsPanel directly rather than expanding an
|
|
90
|
+
overflow menu — spec calls for both, but MVP folds them
|
|
91
|
+
into one click since the only overflow item that has a
|
|
92
|
+
wired backend right now IS settings; other items in the
|
|
93
|
+
spec's 5-item overflow are not yet implemented, per
|
|
94
|
+
INVENTORY.md). Script accepts either
|
|
95
|
+
data-testid="chat-pane-settings" or "chat-pane-more" or
|
|
96
|
+
aria-label containing "设置" — we set testid=settings,
|
|
97
|
+
aria-label contains "设置", and add data-chat-pane-more so
|
|
98
|
+
all three script matchers hit. */}
|
|
99
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
onClick={() => setSettingsOpen(true)}
|
|
103
|
+
aria-label={t('打开会话设置', 'Open conversation settings')}
|
|
104
|
+
title={t('会话设置', 'Conversation settings')}
|
|
105
|
+
data-testid="chat-pane-settings"
|
|
106
|
+
data-chat-pane-more
|
|
107
|
+
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 hover:text-gray-200 hover:bg-[#1c1c1f] transition-colors"
|
|
108
|
+
>
|
|
109
|
+
{/* ⋮ vertical dots — spec §12 "overflow menu" glyph. */}
|
|
110
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
111
|
+
<circle cx="12" cy="5" r="1.5" fill="currentColor" />
|
|
112
|
+
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
|
|
113
|
+
<circle cx="12" cy="19" r="1.5" fill="currentColor" />
|
|
114
|
+
</svg>
|
|
115
|
+
</button>
|
|
116
|
+
<a
|
|
117
|
+
href={`/tasks?alias=${encodeURIComponent(alias)}`}
|
|
118
|
+
aria-label={t(`查看 ${alias} 的任务`, `View ${alias} tasks`)}
|
|
119
|
+
title={t('查看任务', 'View tasks')}
|
|
120
|
+
data-testid="chat-pane-view-tasks"
|
|
121
|
+
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 hover:text-gray-200 hover:bg-[#1c1c1f] transition-colors"
|
|
122
|
+
>
|
|
123
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
124
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3M16 7V3M4 11h16M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
125
|
+
</svg>
|
|
126
|
+
</a>
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
disabled
|
|
130
|
+
aria-label={t('会话内搜索(即将支持)', 'In-chat search (coming soon)')}
|
|
131
|
+
title={t('即将支持', 'Coming soon')}
|
|
132
|
+
data-testid="chat-pane-search"
|
|
133
|
+
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-700 cursor-not-allowed"
|
|
134
|
+
>
|
|
135
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
136
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
137
|
+
</svg>
|
|
138
|
+
</button>
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
onClick={onClose}
|
|
142
|
+
aria-label={t('关闭会话', 'Close chat')}
|
|
143
|
+
data-testid="chat-pane-close"
|
|
144
|
+
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 hover:text-gray-200 hover:bg-[#1c1c1f] transition-colors"
|
|
145
|
+
>
|
|
146
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
147
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
148
|
+
</svg>
|
|
149
|
+
</button>
|
|
150
|
+
</div>
|
|
51
151
|
</div>
|
|
52
152
|
<div className="flex-1 min-h-0">
|
|
153
|
+
{/* TaskChatPanel stays mounted regardless of settingsOpen —
|
|
154
|
+
it's a SIBLING of the settings drawer, not a child. React
|
|
155
|
+
preserves its state (draft / scroll / SSE) across drawer
|
|
156
|
+
toggle because the tree position doesn't change. */}
|
|
53
157
|
<TaskChatPanel alias={alias} onClose={onClose} inline />
|
|
54
158
|
</div>
|
|
159
|
+
{/* Settings drawer — mounts as sibling of TaskChatPanel, scoped
|
|
160
|
+
to this aside via positioning='absolute'. session may be
|
|
161
|
+
undefined briefly during SWR revalidation; guard so the
|
|
162
|
+
drawer doesn't open with no data (button was clicked before
|
|
163
|
+
sessions loaded — rare but possible). */}
|
|
164
|
+
{settingsOpen && session && (
|
|
165
|
+
<NodeSettingsPanel
|
|
166
|
+
session={session}
|
|
167
|
+
onClose={() => setSettingsOpen(false)}
|
|
168
|
+
positioning="absolute"
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
55
171
|
</aside>
|
|
56
172
|
);
|
|
57
173
|
}
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
4
|
import { AliasAvatar } from './AliasAvatar';
|
|
4
5
|
import { useChatUnread } from '../lib/chat-unread';
|
|
5
6
|
import { useHasDraft } from '../lib/chat-drafts';
|
|
7
|
+
import { usePinned } from '../lib/chat-pin';
|
|
8
|
+
import { useMuted } from '../lib/chat-mute';
|
|
9
|
+
import { NodeListContextMenu } from './NodeListContextMenu';
|
|
6
10
|
import type { Session } from './types';
|
|
7
11
|
import { t } from '../lib/ui-lang';
|
|
8
12
|
|
|
13
|
+
// Batch (07-31 通信龙): wire chat-pin / chat-mute into the rail. Pattern
|
|
14
|
+
// copied from AgentCard.tsx (usePinned + useMuted per row) so behavior
|
|
15
|
+
// matches the existing table view — pinned rows sort first (inherited
|
|
16
|
+
// from NodesView's sort chain), and muted rows suppress the unread
|
|
17
|
+
// badge (but keep the underlying count correct — muted ≠ marked-read).
|
|
18
|
+
|
|
9
19
|
// #Stage A (dashboard 3-col): persistent narrow node list rail —
|
|
10
20
|
// WeChat-style entries (avatar + alias + activity preview + unread badge)
|
|
11
21
|
// that stay visible while user reads/interacts with the table on the
|
|
@@ -48,6 +58,118 @@ export interface NodeListProps {
|
|
|
48
58
|
onSearchChange: (v: string) => void;
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
// Extracted so per-row hooks (usePinned / useMuted / useHasDraft) attach
|
|
62
|
+
// per-alias. Rules-of-hooks means we can't call them inside a .map()
|
|
63
|
+
// callback conditionally; each row is its own component so React
|
|
64
|
+
// mounts a fresh hook state per alias.
|
|
65
|
+
interface NodeListRowProps {
|
|
66
|
+
session: SessionRow;
|
|
67
|
+
selected: boolean;
|
|
68
|
+
unread: number;
|
|
69
|
+
onSelect: (alias: string) => void;
|
|
70
|
+
onContextMenu: (alias: string, e: React.MouseEvent) => void;
|
|
71
|
+
}
|
|
72
|
+
function NodeListRow({ session: s, selected, unread, onSelect, onContextMenu }: NodeListRowProps) {
|
|
73
|
+
const pinned = usePinned(s.alias);
|
|
74
|
+
const muted = useMuted(s.alias);
|
|
75
|
+
return (
|
|
76
|
+
<button
|
|
77
|
+
key={s.alias}
|
|
78
|
+
type="button"
|
|
79
|
+
data-node-list-item
|
|
80
|
+
data-node-list-alias={s.alias}
|
|
81
|
+
data-selected={selected ? 'true' : 'false'}
|
|
82
|
+
// 通信龙 07-31: attribute is ALWAYS present ('true'|'false'),
|
|
83
|
+
// never removed. This separates capability (attr exists on every
|
|
84
|
+
// row → wire is intact) from state (attr === 'true' → user did
|
|
85
|
+
// pin/mute this one). Prior form (undefined when false) made a
|
|
86
|
+
// gate script's "at least one on page" check indistinguishable
|
|
87
|
+
// from "wire got ripped out" — both looked like `[data-node-pinned]`
|
|
88
|
+
// empty. See feedback_tolerant_assertion_accepts_noncompliance.
|
|
89
|
+
data-node-pinned={pinned ? 'true' : 'false'}
|
|
90
|
+
data-node-muted={muted ? 'true' : 'false'}
|
|
91
|
+
onClick={() => onSelect(s.alias)}
|
|
92
|
+
onContextMenu={e => onContextMenu(s.alias, e)}
|
|
93
|
+
// SPEC §11.1: pinned row = whole-row light background
|
|
94
|
+
// rgba(125,211,252,0.04) (hover 0.08); no pin ICON (Feishu
|
|
95
|
+
// pattern). Selected wins over both.
|
|
96
|
+
className={`w-full flex items-center gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-[#1a1a1d] ${
|
|
97
|
+
selected
|
|
98
|
+
? 'bg-cyan-500/8'
|
|
99
|
+
: pinned
|
|
100
|
+
? 'bg-[rgba(125,211,252,0.04)] hover:bg-[rgba(125,211,252,0.08)]'
|
|
101
|
+
: 'hover:bg-[#161618]'
|
|
102
|
+
}`}
|
|
103
|
+
>
|
|
104
|
+
<div className="relative shrink-0">
|
|
105
|
+
<AliasAvatar alias={s.alias} size={32} />
|
|
106
|
+
{s.online && (
|
|
107
|
+
<span
|
|
108
|
+
aria-hidden
|
|
109
|
+
className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-400 ring-2 ring-[#0F1320]"
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
<div className="flex-1 min-w-0">
|
|
114
|
+
<div className="flex items-center justify-between gap-2">
|
|
115
|
+
{/* SPEC §11: alias + optional bell-slash icon at RIGHT of
|
|
116
|
+
alias when muted (12×12, #64748B). NO pin icon — pinned
|
|
117
|
+
state is signaled by row background per §11.1. */}
|
|
118
|
+
<span className={`text-[13px] truncate flex items-center gap-1.5 min-w-0 ${selected ? 'text-cyan-200 font-medium' : 'text-gray-200'}`}>
|
|
119
|
+
<span className="truncate">{s.alias}</span>
|
|
120
|
+
{muted && (
|
|
121
|
+
<svg
|
|
122
|
+
aria-label={t('已免打扰', 'Muted')}
|
|
123
|
+
data-mute-icon
|
|
124
|
+
width="12"
|
|
125
|
+
height="12"
|
|
126
|
+
viewBox="0 0 24 24"
|
|
127
|
+
fill="none"
|
|
128
|
+
stroke="#64748B"
|
|
129
|
+
strokeWidth={1.5}
|
|
130
|
+
strokeLinecap="round"
|
|
131
|
+
strokeLinejoin="round"
|
|
132
|
+
className="shrink-0"
|
|
133
|
+
>
|
|
134
|
+
<path d="M15 17h5l-1.4-1.4A2 2 0 0118 14.2V11a6 6 0 00-4-5.7" />
|
|
135
|
+
<path d="M6 8v3.2c0 .5-.2 1-.6 1.4L4 14h9" />
|
|
136
|
+
<path d="M13.7 21a2 2 0 01-3.4 0" />
|
|
137
|
+
<path d="M3 3l18 18" />
|
|
138
|
+
</svg>
|
|
139
|
+
)}
|
|
140
|
+
</span>
|
|
141
|
+
{/* SPEC §11.3: unread badge color depends on mute:
|
|
142
|
+
- not-muted → red (#EF4444 / white)
|
|
143
|
+
- muted → gray (#475569 / #CBD5E1) — count still shown,
|
|
144
|
+
per "static + silent" (mute ≠ mark-read).
|
|
145
|
+
Kept as one <span> with data-unread-badge on both
|
|
146
|
+
variants so tests can find it regardless of state. */}
|
|
147
|
+
{unread > 0 && (
|
|
148
|
+
<span
|
|
149
|
+
aria-label={muted
|
|
150
|
+
? t(`已免打扰,未读 ${unread}`, `Muted, ${unread} unread`)
|
|
151
|
+
: t(`${unread} 条未读`, `${unread} unread`)
|
|
152
|
+
}
|
|
153
|
+
data-unread-badge
|
|
154
|
+
data-badge-variant={muted ? 'muted' : 'default'}
|
|
155
|
+
className={`shrink-0 min-w-[16px] h-4 px-1 rounded-full text-[9px] font-semibold leading-4 text-center ${
|
|
156
|
+
muted
|
|
157
|
+
? 'bg-[#475569] text-[#CBD5E1]'
|
|
158
|
+
: 'bg-[#EF4444] text-white'
|
|
159
|
+
}`}
|
|
160
|
+
>
|
|
161
|
+
{unread > 99 ? '99+' : unread}
|
|
162
|
+
</span>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
<div className="mt-0.5 flex items-center gap-1.5">
|
|
166
|
+
<LastLine alias={s.alias} session={s} />
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</button>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
51
173
|
function LastLine({ alias, session }: { alias: string; session: SessionRow }) {
|
|
52
174
|
const draft = useHasDraft(alias);
|
|
53
175
|
if (draft) {
|
|
@@ -68,6 +190,72 @@ function LastLine({ alias, session }: { alias: string; session: SessionRow }) {
|
|
|
68
190
|
|
|
69
191
|
export function NodeList({ sessions, selectedAlias, onSelect, search, onSearchChange }: NodeListProps) {
|
|
70
192
|
const { unreadCount } = useChatUnread();
|
|
193
|
+
// Batch (07-31) segment 2: right-click context menu state. `null` when
|
|
194
|
+
// closed; `{alias, x, y}` when open. Positioning is at the mouse
|
|
195
|
+
// coords from the contextmenu event so the menu appears where the
|
|
196
|
+
// user right-clicked, not at a static offset.
|
|
197
|
+
const [menu, setMenu] = useState<{ alias: string; x: number; y: number } | null>(null);
|
|
198
|
+
|
|
199
|
+
// Batch (07-31) segment 3: keyboard ↑↓ walks the list, Enter opens
|
|
200
|
+
// the focused entry (URL changes via parent's onSelect → router.push).
|
|
201
|
+
//
|
|
202
|
+
// Focus discipline: window-level keydown, guarded to skip when the
|
|
203
|
+
// user is typing in a text control — the "chat input" case Vincent
|
|
204
|
+
// explicitly named, plus any generic input/textarea/contenteditable
|
|
205
|
+
// so we don't hijack keystrokes anywhere in the app.
|
|
206
|
+
//
|
|
207
|
+
// Ref pattern: parent's `onSelect` and `sessions` change reference
|
|
208
|
+
// every render (arrow fn / filtered array). If we used them in the
|
|
209
|
+
// deps array, the effect would re-attach the listener on every
|
|
210
|
+
// render — brief windows with no listener could miss keydowns.
|
|
211
|
+
// Instead: attach ONCE (empty deps), read latest values via ref.
|
|
212
|
+
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
213
|
+
const stateRef = useRef({ sessions, selectedAlias, onSelect });
|
|
214
|
+
// (was a bare write during render — react-hooks/refs violation caught by
|
|
215
|
+
// the pre-commit gate; an effect keeps the same "listener reads latest
|
|
216
|
+
// values" semantics, updated post-render.)
|
|
217
|
+
useEffect(() => { stateRef.current = { sessions, selectedAlias, onSelect }; });
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
if (typeof window === 'undefined') return;
|
|
220
|
+
const onKey = (e: KeyboardEvent) => {
|
|
221
|
+
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown' && e.key !== 'Enter') return;
|
|
222
|
+
const active = document.activeElement as HTMLElement | null;
|
|
223
|
+
if (active) {
|
|
224
|
+
const tag = active.tagName;
|
|
225
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
|
226
|
+
if (active.isContentEditable) return;
|
|
227
|
+
}
|
|
228
|
+
const { sessions: s, selectedAlias: sel, onSelect: pick } = stateRef.current;
|
|
229
|
+
if (s.length === 0) return;
|
|
230
|
+
const currentIdx = sel ? s.findIndex(x => x.alias === sel) : -1;
|
|
231
|
+
if (e.key === 'Enter') {
|
|
232
|
+
if (currentIdx === -1) {
|
|
233
|
+
e.preventDefault();
|
|
234
|
+
pick(s[0].alias);
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
e.preventDefault();
|
|
239
|
+
const nextIdx = currentIdx === -1
|
|
240
|
+
? (e.key === 'ArrowDown' ? 0 : s.length - 1)
|
|
241
|
+
: (e.key === 'ArrowDown'
|
|
242
|
+
? Math.min(s.length - 1, currentIdx + 1)
|
|
243
|
+
: Math.max(0, currentIdx - 1));
|
|
244
|
+
if (nextIdx !== currentIdx) pick(s[nextIdx].alias);
|
|
245
|
+
};
|
|
246
|
+
window.addEventListener('keydown', onKey);
|
|
247
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
248
|
+
}, []);
|
|
249
|
+
|
|
250
|
+
// Auto-scroll: after selection change, bring the selected entry into
|
|
251
|
+
// the rail's viewport. Runs on every render — cheap and safe. Uses
|
|
252
|
+
// scrollIntoView block:'nearest' so it doesn't jerk the list when
|
|
253
|
+
// the selected entry is already visible.
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (!selectedAlias) return;
|
|
256
|
+
const el = scrollRef.current?.querySelector(`[data-node-list-alias="${CSS.escape(selectedAlias)}"]`) as HTMLElement | null;
|
|
257
|
+
if (el) el.scrollIntoView({ block: 'nearest' });
|
|
258
|
+
}, [selectedAlias]);
|
|
71
259
|
|
|
72
260
|
return (
|
|
73
261
|
<div
|
|
@@ -90,7 +278,15 @@ export function NodeList({ sessions, selectedAlias, onSelect, search, onSearchCh
|
|
|
90
278
|
<div className="px-3 py-2 text-[11px] text-gray-500 tracking-wide uppercase border-b border-[#1e1e22] shrink-0">
|
|
91
279
|
{t(`节点 ${sessions.length}`, `Nodes · ${sessions.length}`)}
|
|
92
280
|
</div>
|
|
93
|
-
|
|
281
|
+
{menu && (
|
|
282
|
+
<NodeListContextMenu
|
|
283
|
+
alias={menu.alias}
|
|
284
|
+
x={menu.x}
|
|
285
|
+
y={menu.y}
|
|
286
|
+
onClose={() => setMenu(null)}
|
|
287
|
+
/>
|
|
288
|
+
)}
|
|
289
|
+
<div ref={scrollRef} data-testid="node-list-scroll" className="flex-1 min-h-0 overflow-y-auto pb-[calc(5rem+env(safe-area-inset-bottom))] lg:pb-0">
|
|
94
290
|
{sessions.length === 0 ? (
|
|
95
291
|
search ? (
|
|
96
292
|
/* #Stage D (Vincent ③ 判据): a no-match search MUST look
|
|
@@ -112,51 +308,17 @@ export function NodeList({ sessions, selectedAlias, onSelect, search, onSearchCh
|
|
|
112
308
|
</div>
|
|
113
309
|
)
|
|
114
310
|
) : (
|
|
115
|
-
sessions.map(s =>
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
className={`w-full flex items-center gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-[#1a1a1d] ${
|
|
127
|
-
selected ? 'bg-cyan-500/8' : 'hover:bg-[#161618]'
|
|
128
|
-
}`}
|
|
129
|
-
>
|
|
130
|
-
<div className="relative shrink-0">
|
|
131
|
-
<AliasAvatar alias={s.alias} size={32} />
|
|
132
|
-
{s.online && (
|
|
133
|
-
<span
|
|
134
|
-
aria-hidden
|
|
135
|
-
className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-400 ring-2 ring-[#0F1320]"
|
|
136
|
-
/>
|
|
137
|
-
)}
|
|
138
|
-
</div>
|
|
139
|
-
<div className="flex-1 min-w-0">
|
|
140
|
-
<div className="flex items-center justify-between gap-2">
|
|
141
|
-
<span className={`text-[13px] truncate ${selected ? 'text-cyan-200 font-medium' : 'text-gray-200'}`}>
|
|
142
|
-
{s.alias}
|
|
143
|
-
</span>
|
|
144
|
-
{unread > 0 && (
|
|
145
|
-
<span
|
|
146
|
-
aria-label={t(`${unread} 条未读`, `${unread} unread`)}
|
|
147
|
-
className="shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-semibold leading-4 text-center"
|
|
148
|
-
>
|
|
149
|
-
{unread > 99 ? '99+' : unread}
|
|
150
|
-
</span>
|
|
151
|
-
)}
|
|
152
|
-
</div>
|
|
153
|
-
<div className="mt-0.5 flex items-center gap-1.5">
|
|
154
|
-
<LastLine alias={s.alias} session={s} />
|
|
155
|
-
</div>
|
|
156
|
-
</div>
|
|
157
|
-
</button>
|
|
158
|
-
);
|
|
159
|
-
})
|
|
311
|
+
sessions.map(s => <NodeListRow
|
|
312
|
+
key={s.alias}
|
|
313
|
+
session={s}
|
|
314
|
+
selected={s.alias === selectedAlias}
|
|
315
|
+
unread={unreadCount(s.alias)}
|
|
316
|
+
onSelect={onSelect}
|
|
317
|
+
onContextMenu={(alias, e) => {
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
setMenu({ alias, x: e.clientX, y: e.clientY });
|
|
320
|
+
}}
|
|
321
|
+
/>)
|
|
160
322
|
)}
|
|
161
323
|
</div>
|
|
162
324
|
</div>
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { togglePin, usePinned } from '../lib/chat-pin';
|
|
6
|
+
import { toggleMute, useMuted } from '../lib/chat-mute';
|
|
7
|
+
import { markChatRead, useChatUnread } from '../lib/chat-unread';
|
|
8
|
+
import { useNetworkId } from '../lib/network-context';
|
|
9
|
+
import { t } from '../lib/ui-lang';
|
|
10
|
+
|
|
11
|
+
// SPEC §10 (dashboard-ui-ref/SPEC.md): right-click context menu for
|
|
12
|
+
// NodeList rail entries. Actions: 置顶 / 免打扰 / 标记已读.
|
|
13
|
+
//
|
|
14
|
+
// Positioning (SPEC §10 "贴边行为"):
|
|
15
|
+
// - default: right-and-down of cursor
|
|
16
|
+
// - if rect.bottom + menuHeight > viewport.bottom → flip up
|
|
17
|
+
// - if rect.right + menuWidth > viewport.right → flip left
|
|
18
|
+
// - at least 8px margin from every viewport edge
|
|
19
|
+
// - useLayoutEffect measures menu then adjusts position (menu size
|
|
20
|
+
// is bounded but not fixed — item labels change per state)
|
|
21
|
+
//
|
|
22
|
+
// Deep-styling: NO shadow (SPEC §2 conclusion — dark theme shadow
|
|
23
|
+
// renders as gray haze). Use brighter bg (#1E293B vs chat's #141826)
|
|
24
|
+
// + 1px border for shape.
|
|
25
|
+
//
|
|
26
|
+
// Test-hook contract:
|
|
27
|
+
// - Root: data-testid="node-context-menu", data-target-alias, role="menu"
|
|
28
|
+
// - Items: data-testid="ctx-pin"|"ctx-mute"|"ctx-mark-read", role="menuitem"
|
|
29
|
+
// - data-flipped-{y,x}="true" when position was flipped for tests.
|
|
30
|
+
|
|
31
|
+
export interface NodeListContextMenuProps {
|
|
32
|
+
alias: string;
|
|
33
|
+
x: number; // cursor clientX at right-click
|
|
34
|
+
y: number; // cursor clientY at right-click
|
|
35
|
+
onClose: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const MENU_W = 200; // SPEC §10: 200px fixed width
|
|
39
|
+
const EDGE = 8; // SPEC §10: ≥8px margin from viewport edge
|
|
40
|
+
|
|
41
|
+
export function NodeListContextMenu({ alias, x, y, onClose }: NodeListContextMenuProps) {
|
|
42
|
+
const pinned = usePinned(alias);
|
|
43
|
+
const muted = useMuted(alias);
|
|
44
|
+
const { networkId } = useNetworkId();
|
|
45
|
+
const { unreadCount } = useChatUnread();
|
|
46
|
+
const unread = unreadCount(alias);
|
|
47
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
48
|
+
const [pos, setPos] = useState<{ top: number; left: number; flippedY: boolean; flippedX: boolean }>({
|
|
49
|
+
top: y,
|
|
50
|
+
left: x,
|
|
51
|
+
flippedY: false,
|
|
52
|
+
flippedX: false,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// SPEC §10 "贴边行为" — measure after paint, flip axis if would
|
|
56
|
+
// overflow. useLayoutEffect so users don't see a paint at the
|
|
57
|
+
// wrong location before the flip.
|
|
58
|
+
useLayoutEffect(() => {
|
|
59
|
+
if (typeof window === 'undefined') return;
|
|
60
|
+
const el = ref.current;
|
|
61
|
+
if (!el) return;
|
|
62
|
+
const rect = el.getBoundingClientRect();
|
|
63
|
+
const menuH = rect.height || 120; // fallback if getBoundingClientRect races
|
|
64
|
+
const menuW = MENU_W;
|
|
65
|
+
const vh = window.innerHeight;
|
|
66
|
+
const vw = window.innerWidth;
|
|
67
|
+
let top = y;
|
|
68
|
+
let left = x;
|
|
69
|
+
let flippedY = false;
|
|
70
|
+
let flippedX = false;
|
|
71
|
+
if (y + menuH + EDGE > vh) {
|
|
72
|
+
top = Math.max(EDGE, y - menuH);
|
|
73
|
+
flippedY = true;
|
|
74
|
+
}
|
|
75
|
+
if (x + menuW + EDGE > vw) {
|
|
76
|
+
left = Math.max(EDGE, x - menuW);
|
|
77
|
+
flippedX = true;
|
|
78
|
+
}
|
|
79
|
+
// Final clamp — in case the flip still overflows on tiny viewports.
|
|
80
|
+
top = Math.max(EDGE, Math.min(top, vh - menuH - EDGE));
|
|
81
|
+
left = Math.max(EDGE, Math.min(left, vw - menuW - EDGE));
|
|
82
|
+
setPos({ top, left, flippedY, flippedX });
|
|
83
|
+
}, [x, y]);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
// mousedown-capture so a right-click INSIDE the menu doesn't dismiss.
|
|
87
|
+
const onClick = (e: MouseEvent) => {
|
|
88
|
+
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
89
|
+
};
|
|
90
|
+
const onKey = (e: KeyboardEvent) => {
|
|
91
|
+
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
|
|
92
|
+
};
|
|
93
|
+
document.addEventListener('mousedown', onClick, true);
|
|
94
|
+
document.addEventListener('keydown', onKey);
|
|
95
|
+
return () => {
|
|
96
|
+
document.removeEventListener('mousedown', onClick, true);
|
|
97
|
+
document.removeEventListener('keydown', onKey);
|
|
98
|
+
};
|
|
99
|
+
}, [onClose]);
|
|
100
|
+
|
|
101
|
+
const runAndClose = (fn: () => void) => () => { fn(); onClose(); };
|
|
102
|
+
|
|
103
|
+
// SPEC §10 三态色值 (Tailwind can't do arbitrary custom hex on
|
|
104
|
+
// pseudo-classes easily; the group-hover + `data-*` selectors on the
|
|
105
|
+
// Menu item below drive the state colors via inline CSS variables to
|
|
106
|
+
// keep both bg + text + icon flipping together on hover.)
|
|
107
|
+
const itemCls =
|
|
108
|
+
'group w-full text-left flex items-center gap-2.5 px-2.5 py-1.5 text-[13px] font-medium text-[#E2E8F0] ' +
|
|
109
|
+
'hover:bg-[rgba(125,211,252,0.10)] hover:text-white active:bg-[rgba(125,211,252,0.18)] ' +
|
|
110
|
+
'disabled:text-[#475569] disabled:cursor-not-allowed disabled:hover:bg-transparent';
|
|
111
|
+
|
|
112
|
+
const menu = (
|
|
113
|
+
<div
|
|
114
|
+
ref={ref}
|
|
115
|
+
role="menu"
|
|
116
|
+
aria-label={t(`节点 ${alias} 操作菜单`, `Actions for node ${alias}`)}
|
|
117
|
+
data-testid="node-context-menu"
|
|
118
|
+
data-target-alias={alias}
|
|
119
|
+
data-flipped-y={pos.flippedY ? 'true' : undefined}
|
|
120
|
+
data-flipped-x={pos.flippedX ? 'true' : undefined}
|
|
121
|
+
style={{ position: 'fixed', top: pos.top, left: pos.left, width: MENU_W, zIndex: 60 }}
|
|
122
|
+
className="rounded-lg border border-[rgba(255,255,255,0.10)] bg-[#1E293B] py-1"
|
|
123
|
+
onContextMenu={e => e.preventDefault()}
|
|
124
|
+
>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
role="menuitem"
|
|
128
|
+
data-testid="ctx-pin"
|
|
129
|
+
onClick={runAndClose(() => togglePin(alias))}
|
|
130
|
+
className={itemCls}
|
|
131
|
+
>
|
|
132
|
+
<IconPin className="shrink-0 w-5 h-5 text-[#94A3B8] group-hover:text-[#7DD3FC]" />
|
|
133
|
+
{pinned ? t('取消置顶', 'Unpin') : t('置顶会话', 'Pin conversation')}
|
|
134
|
+
</button>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
role="menuitem"
|
|
138
|
+
data-testid="ctx-mute"
|
|
139
|
+
onClick={runAndClose(() => toggleMute(alias))}
|
|
140
|
+
className={itemCls}
|
|
141
|
+
>
|
|
142
|
+
<IconBell muted className="shrink-0 w-5 h-5 text-[#94A3B8] group-hover:text-[#7DD3FC]" />
|
|
143
|
+
{muted ? t('取消免打扰', 'Unmute') : t('免打扰', 'Mute')}
|
|
144
|
+
</button>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
role="menuitem"
|
|
148
|
+
data-testid="ctx-mark-read"
|
|
149
|
+
onClick={runAndClose(() => markChatRead(alias, networkId))}
|
|
150
|
+
disabled={unread === 0}
|
|
151
|
+
className={itemCls}
|
|
152
|
+
>
|
|
153
|
+
<IconCheck className="shrink-0 w-5 h-5 text-[#94A3B8] group-hover:text-[#7DD3FC] group-disabled:text-[#475569]" />
|
|
154
|
+
{t(unread > 0 ? `标记已读 (${unread})` : '标记已读', unread > 0 ? `Mark ${unread} read` : 'Mark read')}
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
if (typeof document === 'undefined') return null;
|
|
160
|
+
return createPortal(menu, document.body);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function IconPin({ className }: { className?: string }) {
|
|
164
|
+
return (
|
|
165
|
+
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
|
166
|
+
<path d="M12 17v5" />
|
|
167
|
+
<path d="M5 10l7 7 7-7" />
|
|
168
|
+
<path d="M5 10l3-8h8l3 8z" />
|
|
169
|
+
</svg>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
function IconBell({ className, muted }: { className?: string; muted?: boolean }) {
|
|
173
|
+
return (
|
|
174
|
+
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
|
175
|
+
<path d="M15 17h5l-1.4-1.4A2 2 0 0118 14.2V11a6 6 0 00-12 0v3.2c0 .5-.2 1-.6 1.4L4 17h5" />
|
|
176
|
+
<path d="M13.7 21a2 2 0 01-3.4 0" />
|
|
177
|
+
{muted && <path d="M3 3l18 18" />}
|
|
178
|
+
</svg>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
function IconCheck({ className }: { className?: string }) {
|
|
182
|
+
return (
|
|
183
|
+
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
|
184
|
+
<path d="M5 13l4 4L19 7" />
|
|
185
|
+
</svg>
|
|
186
|
+
);
|
|
187
|
+
}
|