@sleep2agi/agent-network-dashboard 0.6.3-preview.50 → 0.6.3-preview.51
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 +14 -14
- 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 +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/admin/page.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 +2 -2
- package/.next/server/app/admin.segments/_full.segment.rsc +2 -2
- package/.next/server/app/admin.segments/_head.segment.rsc +1 -1
- package/.next/server/app/admin.segments/_index.segment.rsc +2 -2
- package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/admin.segments/admin.segment.rsc +1 -1
- package/.next/server/app/index.html +2 -2
- package/.next/server/app/index.rsc +3 -3
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login/page.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 +3 -3
- package/.next/server/app/login.segments/_full.segment.rsc +3 -3
- package/.next/server/app/login.segments/_head.segment.rsc +1 -1
- package/.next/server/app/login.segments/_index.segment.rsc +2 -2
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/login.segments/login.segment.rsc +1 -1
- package/.next/server/app/logs/page.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 +2 -2
- package/.next/server/app/logs.segments/_full.segment.rsc +2 -2
- package/.next/server/app/logs.segments/_head.segment.rsc +1 -1
- package/.next/server/app/logs.segments/_index.segment.rsc +2 -2
- package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/logs.segments/logs.segment.rsc +1 -1
- package/.next/server/app/messages/page.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 +2 -2
- package/.next/server/app/messages.segments/_full.segment.rsc +2 -2
- package/.next/server/app/messages.segments/_head.segment.rsc +1 -1
- package/.next/server/app/messages.segments/_index.segment.rsc +2 -2
- package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/messages.segments/messages.segment.rsc +1 -1
- package/.next/server/app/node/page.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 +2 -2
- package/.next/server/app/node.segments/_full.segment.rsc +2 -2
- package/.next/server/app/node.segments/_head.segment.rsc +1 -1
- package/.next/server/app/node.segments/_index.segment.rsc +2 -2
- package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/node.segments/node.segment.rsc +1 -1
- 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 +3 -3
- package/.next/server/app/nodes.segments/_full.segment.rsc +3 -3
- package/.next/server/app/nodes.segments/_head.segment.rsc +1 -1
- package/.next/server/app/nodes.segments/_index.segment.rsc +2 -2
- package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/nodes.segments/nodes.segment.rsc +1 -1
- package/.next/server/app/page.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 +2 -2
- package/.next/server/app/providers.segments/_full.segment.rsc +2 -2
- package/.next/server/app/providers.segments/_head.segment.rsc +1 -1
- package/.next/server/app/providers.segments/_index.segment.rsc +2 -2
- package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/providers.segments/providers.segment.rsc +1 -1
- package/.next/server/app/server-logs/page.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 +2 -2
- package/.next/server/app/server-logs.segments/_full.segment.rsc +2 -2
- package/.next/server/app/server-logs.segments/_head.segment.rsc +1 -1
- package/.next/server/app/server-logs.segments/_index.segment.rsc +2 -2
- package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/server-logs.segments/server-logs.segment.rsc +1 -1
- package/.next/server/app/servers/page.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 +2 -2
- package/.next/server/app/servers.segments/_full.segment.rsc +2 -2
- package/.next/server/app/servers.segments/_head.segment.rsc +1 -1
- package/.next/server/app/servers.segments/_index.segment.rsc +2 -2
- package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/servers.segments/servers.segment.rsc +1 -1
- package/.next/server/app/settings/networks/page.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 +2 -2
- package/.next/server/app/settings/networks.segments/_full.segment.rsc +2 -2
- package/.next/server/app/settings/networks.segments/_head.segment.rsc +1 -1
- package/.next/server/app/settings/networks.segments/_index.segment.rsc +2 -2
- package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +1 -1
- package/.next/server/app/settings/networks.segments/settings.segment.rsc +1 -1
- package/.next/server/app/settings/page.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 +2 -2
- package/.next/server/app/settings/tokens.segments/_full.segment.rsc +2 -2
- package/.next/server/app/settings/tokens.segments/_head.segment.rsc +1 -1
- package/.next/server/app/settings/tokens.segments/_index.segment.rsc +2 -2
- package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +1 -1
- package/.next/server/app/settings/tokens.segments/settings.segment.rsc +1 -1
- package/.next/server/app/settings.html +2 -2
- package/.next/server/app/settings.rsc +3 -3
- package/.next/server/app/settings.segments/_full.segment.rsc +3 -3
- package/.next/server/app/settings.segments/_head.segment.rsc +1 -1
- package/.next/server/app/settings.segments/_index.segment.rsc +2 -2
- package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/settings.segments/settings.segment.rsc +1 -1
- package/.next/server/app/tasks/[id]/page.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 +2 -2
- package/.next/server/app/tasks.segments/_full.segment.rsc +2 -2
- package/.next/server/app/tasks.segments/_head.segment.rsc +1 -1
- package/.next/server/app/tasks.segments/_index.segment.rsc +2 -2
- package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/tasks.segments/tasks.segment.rsc +1 -1
- package/.next/server/chunks/ssr/{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]__02o~-py._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__02o~-py._.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]__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_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 +1 -1
- 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/05~lgfzsv.p~p.js +2 -0
- package/.next/static/chunks/092zqb~4un32l.css +1 -0
- package/.next/static/chunks/0aaakzcw7tey0.js +0 -0
- package/.next/static/chunks/0advoo2tzdcyi.js +1 -0
- package/.next/static/chunks/0dkk_l872ocgm.js +1 -0
- package/.next/static/chunks/0fz7zk.ek.154.js +1 -0
- package/.next/static/chunks/0njt8wek.9aq6.js +1 -0
- package/.next/static/chunks/{0hm52b0y~k0~z.js → 0uk67ty_0h_36.js} +1 -1
- package/.next/static/chunks/{0ep78sq90x_yc.js → 0z_4d13crd_af.js} +1 -1
- package/.next/static/chunks/0zljk8~2jqw93.js +4 -0
- package/.next/trace +2 -2
- package/.next/trace-build +1 -1
- package/app/components/ChatPane.tsx +108 -16
- package/app/components/NodeList.tsx +198 -46
- package/app/components/NodeListContextMenu.tsx +187 -0
- package/app/components/NodeSettingsPanel.tsx +29 -3
- package/app/nodes/NodesView.tsx +58 -0
- package/package.json +1 -1
- package/tests/e2e-9-nodes-team-tag-filter.spec.ts +3 -0
- package/tests/e2e-batch-list-actions.spec.ts +234 -0
- package/tests/e2e-stage-a-node-list.spec.ts +4 -0
- package/tests/e2e-stage-b-chat-pane.spec.ts +103 -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/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/14y4x3de1lbug.js +0 -1
- package/.next/static/chunks/15ka68xi.w-i-.js +0 -1
- /package/.next/static/{vc_aJHtYwefsVcl2_f14Z → JL_QjNKv1KhsCF68ZK28x}/_buildManifest.js +0 -0
- /package/.next/static/{vc_aJHtYwefsVcl2_f14Z → JL_QjNKv1KhsCF68ZK28x}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{vc_aJHtYwefsVcl2_f14Z → JL_QjNKv1KhsCF68ZK28x}/_ssgManifest.js +0 -0
|
@@ -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,111 @@ 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
|
+
data-node-pinned={pinned ? 'true' : undefined}
|
|
83
|
+
data-node-muted={muted ? 'true' : undefined}
|
|
84
|
+
onClick={() => onSelect(s.alias)}
|
|
85
|
+
onContextMenu={e => onContextMenu(s.alias, e)}
|
|
86
|
+
// SPEC §11.1: pinned row = whole-row light background
|
|
87
|
+
// rgba(125,211,252,0.04) (hover 0.08); no pin ICON (Feishu
|
|
88
|
+
// pattern). Selected wins over both.
|
|
89
|
+
className={`w-full flex items-center gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-[#1a1a1d] ${
|
|
90
|
+
selected
|
|
91
|
+
? 'bg-cyan-500/8'
|
|
92
|
+
: pinned
|
|
93
|
+
? 'bg-[rgba(125,211,252,0.04)] hover:bg-[rgba(125,211,252,0.08)]'
|
|
94
|
+
: 'hover:bg-[#161618]'
|
|
95
|
+
}`}
|
|
96
|
+
>
|
|
97
|
+
<div className="relative shrink-0">
|
|
98
|
+
<AliasAvatar alias={s.alias} size={32} />
|
|
99
|
+
{s.online && (
|
|
100
|
+
<span
|
|
101
|
+
aria-hidden
|
|
102
|
+
className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-400 ring-2 ring-[#0F1320]"
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
<div className="flex-1 min-w-0">
|
|
107
|
+
<div className="flex items-center justify-between gap-2">
|
|
108
|
+
{/* SPEC §11: alias + optional bell-slash icon at RIGHT of
|
|
109
|
+
alias when muted (12×12, #64748B). NO pin icon — pinned
|
|
110
|
+
state is signaled by row background per §11.1. */}
|
|
111
|
+
<span className={`text-[13px] truncate flex items-center gap-1.5 min-w-0 ${selected ? 'text-cyan-200 font-medium' : 'text-gray-200'}`}>
|
|
112
|
+
<span className="truncate">{s.alias}</span>
|
|
113
|
+
{muted && (
|
|
114
|
+
<svg
|
|
115
|
+
aria-label={t('已免打扰', 'Muted')}
|
|
116
|
+
data-mute-icon
|
|
117
|
+
width="12"
|
|
118
|
+
height="12"
|
|
119
|
+
viewBox="0 0 24 24"
|
|
120
|
+
fill="none"
|
|
121
|
+
stroke="#64748B"
|
|
122
|
+
strokeWidth={1.5}
|
|
123
|
+
strokeLinecap="round"
|
|
124
|
+
strokeLinejoin="round"
|
|
125
|
+
className="shrink-0"
|
|
126
|
+
>
|
|
127
|
+
<path d="M15 17h5l-1.4-1.4A2 2 0 0118 14.2V11a6 6 0 00-4-5.7" />
|
|
128
|
+
<path d="M6 8v3.2c0 .5-.2 1-.6 1.4L4 14h9" />
|
|
129
|
+
<path d="M13.7 21a2 2 0 01-3.4 0" />
|
|
130
|
+
<path d="M3 3l18 18" />
|
|
131
|
+
</svg>
|
|
132
|
+
)}
|
|
133
|
+
</span>
|
|
134
|
+
{/* SPEC §11.3: unread badge color depends on mute:
|
|
135
|
+
- not-muted → red (#EF4444 / white)
|
|
136
|
+
- muted → gray (#475569 / #CBD5E1) — count still shown,
|
|
137
|
+
per "static + silent" (mute ≠ mark-read).
|
|
138
|
+
Kept as one <span> with data-unread-badge on both
|
|
139
|
+
variants so tests can find it regardless of state. */}
|
|
140
|
+
{unread > 0 && (
|
|
141
|
+
<span
|
|
142
|
+
aria-label={muted
|
|
143
|
+
? t(`已免打扰,未读 ${unread}`, `Muted, ${unread} unread`)
|
|
144
|
+
: t(`${unread} 条未读`, `${unread} unread`)
|
|
145
|
+
}
|
|
146
|
+
data-unread-badge
|
|
147
|
+
data-badge-variant={muted ? 'muted' : 'default'}
|
|
148
|
+
className={`shrink-0 min-w-[16px] h-4 px-1 rounded-full text-[9px] font-semibold leading-4 text-center ${
|
|
149
|
+
muted
|
|
150
|
+
? 'bg-[#475569] text-[#CBD5E1]'
|
|
151
|
+
: 'bg-[#EF4444] text-white'
|
|
152
|
+
}`}
|
|
153
|
+
>
|
|
154
|
+
{unread > 99 ? '99+' : unread}
|
|
155
|
+
</span>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
<div className="mt-0.5 flex items-center gap-1.5">
|
|
159
|
+
<LastLine alias={s.alias} session={s} />
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</button>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
51
166
|
function LastLine({ alias, session }: { alias: string; session: SessionRow }) {
|
|
52
167
|
const draft = useHasDraft(alias);
|
|
53
168
|
if (draft) {
|
|
@@ -68,6 +183,69 @@ function LastLine({ alias, session }: { alias: string; session: SessionRow }) {
|
|
|
68
183
|
|
|
69
184
|
export function NodeList({ sessions, selectedAlias, onSelect, search, onSearchChange }: NodeListProps) {
|
|
70
185
|
const { unreadCount } = useChatUnread();
|
|
186
|
+
// Batch (07-31) segment 2: right-click context menu state. `null` when
|
|
187
|
+
// closed; `{alias, x, y}` when open. Positioning is at the mouse
|
|
188
|
+
// coords from the contextmenu event so the menu appears where the
|
|
189
|
+
// user right-clicked, not at a static offset.
|
|
190
|
+
const [menu, setMenu] = useState<{ alias: string; x: number; y: number } | null>(null);
|
|
191
|
+
|
|
192
|
+
// Batch (07-31) segment 3: keyboard ↑↓ walks the list, Enter opens
|
|
193
|
+
// the focused entry (URL changes via parent's onSelect → router.push).
|
|
194
|
+
//
|
|
195
|
+
// Focus discipline: window-level keydown, guarded to skip when the
|
|
196
|
+
// user is typing in a text control — the "chat input" case Vincent
|
|
197
|
+
// explicitly named, plus any generic input/textarea/contenteditable
|
|
198
|
+
// so we don't hijack keystrokes anywhere in the app.
|
|
199
|
+
//
|
|
200
|
+
// Ref pattern: parent's `onSelect` and `sessions` change reference
|
|
201
|
+
// every render (arrow fn / filtered array). If we used them in the
|
|
202
|
+
// deps array, the effect would re-attach the listener on every
|
|
203
|
+
// render — brief windows with no listener could miss keydowns.
|
|
204
|
+
// Instead: attach ONCE (empty deps), read latest values via ref.
|
|
205
|
+
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
206
|
+
const stateRef = useRef({ sessions, selectedAlias, onSelect });
|
|
207
|
+
stateRef.current = { sessions, selectedAlias, onSelect };
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (typeof window === 'undefined') return;
|
|
210
|
+
const onKey = (e: KeyboardEvent) => {
|
|
211
|
+
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown' && e.key !== 'Enter') return;
|
|
212
|
+
const active = document.activeElement as HTMLElement | null;
|
|
213
|
+
if (active) {
|
|
214
|
+
const tag = active.tagName;
|
|
215
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
|
216
|
+
if (active.isContentEditable) return;
|
|
217
|
+
}
|
|
218
|
+
const { sessions: s, selectedAlias: sel, onSelect: pick } = stateRef.current;
|
|
219
|
+
if (s.length === 0) return;
|
|
220
|
+
const currentIdx = sel ? s.findIndex(x => x.alias === sel) : -1;
|
|
221
|
+
if (e.key === 'Enter') {
|
|
222
|
+
if (currentIdx === -1) {
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
pick(s[0].alias);
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
const nextIdx = currentIdx === -1
|
|
230
|
+
? (e.key === 'ArrowDown' ? 0 : s.length - 1)
|
|
231
|
+
: (e.key === 'ArrowDown'
|
|
232
|
+
? Math.min(s.length - 1, currentIdx + 1)
|
|
233
|
+
: Math.max(0, currentIdx - 1));
|
|
234
|
+
if (nextIdx !== currentIdx) pick(s[nextIdx].alias);
|
|
235
|
+
};
|
|
236
|
+
window.addEventListener('keydown', onKey);
|
|
237
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
238
|
+
}, []);
|
|
239
|
+
|
|
240
|
+
// Auto-scroll: after selection change, bring the selected entry into
|
|
241
|
+
// the rail's viewport. Runs on every render — cheap and safe. Uses
|
|
242
|
+
// scrollIntoView block:'nearest' so it doesn't jerk the list when
|
|
243
|
+
// the selected entry is already visible.
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (!selectedAlias) return;
|
|
246
|
+
const el = scrollRef.current?.querySelector(`[data-node-list-alias="${CSS.escape(selectedAlias)}"]`) as HTMLElement | null;
|
|
247
|
+
if (el) el.scrollIntoView({ block: 'nearest' });
|
|
248
|
+
}, [selectedAlias]);
|
|
71
249
|
|
|
72
250
|
return (
|
|
73
251
|
<div
|
|
@@ -90,7 +268,15 @@ export function NodeList({ sessions, selectedAlias, onSelect, search, onSearchCh
|
|
|
90
268
|
<div className="px-3 py-2 text-[11px] text-gray-500 tracking-wide uppercase border-b border-[#1e1e22] shrink-0">
|
|
91
269
|
{t(`节点 ${sessions.length}`, `Nodes · ${sessions.length}`)}
|
|
92
270
|
</div>
|
|
93
|
-
|
|
271
|
+
{menu && (
|
|
272
|
+
<NodeListContextMenu
|
|
273
|
+
alias={menu.alias}
|
|
274
|
+
x={menu.x}
|
|
275
|
+
y={menu.y}
|
|
276
|
+
onClose={() => setMenu(null)}
|
|
277
|
+
/>
|
|
278
|
+
)}
|
|
279
|
+
<div ref={scrollRef} data-testid="node-list-scroll" className="flex-1 min-h-0 overflow-y-auto">
|
|
94
280
|
{sessions.length === 0 ? (
|
|
95
281
|
search ? (
|
|
96
282
|
/* #Stage D (Vincent ③ 判据): a no-match search MUST look
|
|
@@ -112,51 +298,17 @@ export function NodeList({ sessions, selectedAlias, onSelect, search, onSearchCh
|
|
|
112
298
|
</div>
|
|
113
299
|
)
|
|
114
300
|
) : (
|
|
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
|
-
})
|
|
301
|
+
sessions.map(s => <NodeListRow
|
|
302
|
+
key={s.alias}
|
|
303
|
+
session={s}
|
|
304
|
+
selected={s.alias === selectedAlias}
|
|
305
|
+
unread={unreadCount(s.alias)}
|
|
306
|
+
onSelect={onSelect}
|
|
307
|
+
onContextMenu={(alias, e) => {
|
|
308
|
+
e.preventDefault();
|
|
309
|
+
setMenu({ alias, x: e.clientX, y: e.clientY });
|
|
310
|
+
}}
|
|
311
|
+
/>)
|
|
160
312
|
)}
|
|
161
313
|
</div>
|
|
162
314
|
</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
|
+
}
|
|
@@ -311,7 +311,20 @@ function NumberField({ label, value, onChange, placeholder }: { label: string; v
|
|
|
311
311
|
);
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
-
export function NodeSettingsPanel({ session: s, onClose
|
|
314
|
+
export function NodeSettingsPanel({ session: s, onClose, positioning = 'fixed' }: {
|
|
315
|
+
session: Session;
|
|
316
|
+
onClose: () => void;
|
|
317
|
+
/** 07-31 通信龙 判定: 'fixed' (default) is the legacy fullscreen
|
|
318
|
+
* drawer used by AgentCard (covers whole viewport). 'absolute'
|
|
319
|
+
* scopes the drawer to its nearest positioned ancestor + shrinks
|
|
320
|
+
* to 360px, used by ChatPane so it only covers the chat column
|
|
321
|
+
* (SPEC §12: settings should not steal from the reclaimed 1104px
|
|
322
|
+
* chat width; fourth-column would push chat back to 664px).
|
|
323
|
+
* Absolute mode also drops the black backdrop — clicking outside
|
|
324
|
+
* the drawer inside the chat pane still closes, but the rest of
|
|
325
|
+
* the app stays interactive. */
|
|
326
|
+
positioning?: 'fixed' | 'absolute';
|
|
327
|
+
}) {
|
|
315
328
|
const runtime = s.runtime || '—';
|
|
316
329
|
const initialModel = s.model || s.agent || '';
|
|
317
330
|
// imageCapable: read-only, auto from model (MiniMax-M3 / Claude = yes).
|
|
@@ -640,14 +653,27 @@ export function NodeSettingsPanel({ session: s, onClose }: { session: Session; o
|
|
|
640
653
|
}
|
|
641
654
|
}
|
|
642
655
|
|
|
656
|
+
const scoped = positioning === 'absolute';
|
|
643
657
|
return (
|
|
644
658
|
<>
|
|
645
|
-
|
|
659
|
+
{/* Backdrop — 'fixed' mode covers the whole viewport;
|
|
660
|
+
'absolute' mode scopes to the positioned ancestor
|
|
661
|
+
(ChatPane's <aside>) so it only dims the chat column, not
|
|
662
|
+
the sidebar or rail. */}
|
|
663
|
+
<div
|
|
664
|
+
className={scoped
|
|
665
|
+
? 'absolute inset-0 bg-black/40 z-40'
|
|
666
|
+
: 'fixed inset-0 bg-black/40 z-40'}
|
|
667
|
+
onClick={onClose}
|
|
668
|
+
aria-hidden
|
|
669
|
+
/>
|
|
646
670
|
|
|
647
671
|
<div
|
|
648
672
|
role="dialog"
|
|
649
673
|
aria-label={`${s.alias} 节点设置`}
|
|
650
|
-
className=
|
|
674
|
+
className={scoped
|
|
675
|
+
? 'absolute top-0 right-0 h-full w-full sm:w-[360px] max-w-[360px] bg-[#0f0f11] border-l border-[#26262b] z-50 flex flex-col animate-slide-in'
|
|
676
|
+
: 'fixed top-0 right-0 h-[100dvh] w-full sm:w-[440px] bg-[#0f0f11] border-l border-[#26262b] z-50 flex flex-col shadow-2xl shadow-black/60 animate-slide-in'}
|
|
651
677
|
>
|
|
652
678
|
{/* Header */}
|
|
653
679
|
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-[#26262b]">
|
package/app/nodes/NodesView.tsx
CHANGED
|
@@ -122,6 +122,7 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
|
|
|
122
122
|
try { window.history.pushState({}, '', `/nodes/${encodeURIComponent(alias)}`); } catch {}
|
|
123
123
|
};
|
|
124
124
|
const closeChat = () => {
|
|
125
|
+
userClosedRef.current = true; // ③ explicit close wins over auto-select
|
|
125
126
|
setChatAlias(null);
|
|
126
127
|
try { window.history.pushState({}, '', '/nodes'); } catch {}
|
|
127
128
|
};
|
|
@@ -157,6 +158,42 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
|
|
|
157
158
|
ro.observe(document.body);
|
|
158
159
|
return () => { window.removeEventListener('resize', apply); ro.disconnect(); };
|
|
159
160
|
}, []);
|
|
161
|
+
// #Vincent 插单 (07-31): entering /nodes with nothing selected showed the
|
|
162
|
+
// management table full-bleed — "这样特别难看". Feishu opens INTO a
|
|
163
|
+
// conversation, so desktop /nodes auto-selects the first (sorted) session.
|
|
164
|
+
// Three traps, each deliberately handled:
|
|
165
|
+
// ① URL via history.REPLACE, never push — push would trap the browser
|
|
166
|
+
// back button (/nodes → auto-select → /nodes/x → back → /nodes →
|
|
167
|
+
// auto-select again, forever).
|
|
168
|
+
// ② the table is NOT gone — it's the "manage all nodes" view, reachable
|
|
169
|
+
// via the explicit 管理视图 toggle below (localStorage-persisted; a
|
|
170
|
+
// user who prefers the table keeps it as their default and auto-select
|
|
171
|
+
// stays off).
|
|
172
|
+
// ③ an explicit ✕ close must NOT be answered by an immediate re-select
|
|
173
|
+
// (the ✕ would look broken) — userClosedRef gates auto-select off for
|
|
174
|
+
// the rest of this mount.
|
|
175
|
+
const [manageView, setManageView] = useState<boolean>(() => {
|
|
176
|
+
if (typeof window === 'undefined') return false;
|
|
177
|
+
return window.localStorage.getItem('anet-nodes-manage-view') === '1';
|
|
178
|
+
});
|
|
179
|
+
const autoSelectedRef = useRef(false);
|
|
180
|
+
const userClosedRef = useRef(false);
|
|
181
|
+
const toggleManageView = () => {
|
|
182
|
+
setManageView(v => {
|
|
183
|
+
const next = !v;
|
|
184
|
+
try { window.localStorage.setItem('anet-nodes-manage-view', next ? '1' : '0'); } catch {}
|
|
185
|
+
if (next) {
|
|
186
|
+
// entering manage view: drop any open chat, land on the table
|
|
187
|
+
userClosedRef.current = true;
|
|
188
|
+
if (chatAlias) closeChat();
|
|
189
|
+
} else {
|
|
190
|
+
// back to conversation view: let auto-select pick up again
|
|
191
|
+
userClosedRef.current = false;
|
|
192
|
+
autoSelectedRef.current = false;
|
|
193
|
+
}
|
|
194
|
+
return next;
|
|
195
|
+
});
|
|
196
|
+
};
|
|
160
197
|
const [showCreate, setShowCreate] = useState(false);
|
|
161
198
|
// #Stage A (dashboard 3-col, 07-31): persistent narrow node list rail
|
|
162
199
|
// to the left of the existing table. Default on; user can hide to
|
|
@@ -230,6 +267,15 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
|
|
|
230
267
|
// "rail count == table count" true at all times.
|
|
231
268
|
const railSessions = filtered;
|
|
232
269
|
|
|
270
|
+
const firstAlias = filtered.length > 0 ? filtered[0].alias : null;
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (!isDesktop || manageView || chatAlias || userClosedRef.current || autoSelectedRef.current) return;
|
|
273
|
+
if (!firstAlias) return; // empty list: nothing to select, no loop
|
|
274
|
+
autoSelectedRef.current = true;
|
|
275
|
+
setChatAlias(firstAlias);
|
|
276
|
+
try { window.history.replaceState({}, '', `/nodes/${encodeURIComponent(firstAlias)}`); } catch {}
|
|
277
|
+
}, [isDesktop, manageView, chatAlias, firstAlias]);
|
|
278
|
+
|
|
233
279
|
// #Stage C: with a node selected on desktop, the page is LIST + CHAT —
|
|
234
280
|
// the middle table does not render (通信龙 production screenshot catch:
|
|
235
281
|
// rail and table were painting the same 199 nodes twice, squeezing the
|
|
@@ -276,6 +322,18 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
|
|
|
276
322
|
search={search}
|
|
277
323
|
onSearchChange={setSearch}
|
|
278
324
|
/>
|
|
325
|
+
{/* #Vincent 插单 ②: explicit doorway between conversation-first
|
|
326
|
+
and table-first ("manage all nodes") modes. Lives in the
|
|
327
|
+
aside (NodesView's JSX) — NOT in NodeList, which IM马 is
|
|
328
|
+
editing concurrently. */}
|
|
329
|
+
<button
|
|
330
|
+
type="button"
|
|
331
|
+
onClick={toggleManageView}
|
|
332
|
+
data-testid="manage-view-toggle"
|
|
333
|
+
className="shrink-0 border-t border-[rgba(255,255,255,0.06)] bg-[#0F1320] px-3 py-2 text-left text-[11px] text-gray-500 hover:text-gray-200 transition-colors"
|
|
334
|
+
>
|
|
335
|
+
{manageView ? t('≡ 对话视图', '≡ Conversation view') : t('☷ 管理视图', '☷ Manage view')}
|
|
336
|
+
</button>
|
|
279
337
|
</aside>
|
|
280
338
|
)}
|
|
281
339
|
{/* #Stage C: middle column (header + filters + table/grid) renders
|
package/package.json
CHANGED
|
@@ -97,6 +97,9 @@ async function login(context: Page['context'] extends () => infer C ? C : never)
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
async function mockCommon(page: Page, statusBody: object, nodesBody: object) {
|
|
100
|
+
// #Vincent auto-select (07-31): filter suite exercises the manage/table
|
|
101
|
+
// view — opt in explicitly now that /nodes defaults to conversation-first.
|
|
102
|
+
await page.addInitScript(() => window.localStorage.setItem('anet-nodes-manage-view', '1'));
|
|
100
103
|
await page.route('**/api/hub/status**', r =>
|
|
101
104
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(statusBody) }));
|
|
102
105
|
await page.route('**/api/hub/nodes**', r =>
|