@geminilight/mindos 0.5.64 → 0.5.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +4 -0
  2. package/README_zh.md +4 -0
  3. package/app/app/api/ask/route.ts +12 -0
  4. package/app/app/api/file/route.ts +9 -0
  5. package/app/app/api/mcp/agents/route.ts +27 -1
  6. package/app/app/api/skills/route.ts +18 -2
  7. package/app/app/api/tree-version/route.ts +8 -0
  8. package/app/components/ActivityBar.tsx +2 -2
  9. package/app/components/Backlinks.tsx +5 -5
  10. package/app/components/CreateSpaceModal.tsx +3 -2
  11. package/app/components/DirPicker.tsx +1 -1
  12. package/app/components/DirView.tsx +2 -3
  13. package/app/components/EditorWrapper.tsx +3 -3
  14. package/app/components/FileTree.tsx +25 -10
  15. package/app/components/GuideCard.tsx +4 -4
  16. package/app/components/HomeContent.tsx +6 -11
  17. package/app/components/MarkdownView.tsx +2 -2
  18. package/app/components/OnboardingView.tsx +1 -1
  19. package/app/components/Panel.tsx +1 -1
  20. package/app/components/RightAgentDetailPanel.tsx +1 -1
  21. package/app/components/RightAskPanel.tsx +1 -1
  22. package/app/components/SearchModal.tsx +10 -2
  23. package/app/components/SidebarLayout.tsx +35 -10
  24. package/app/components/ThemeToggle.tsx +1 -1
  25. package/app/components/agents/AgentDetailContent.tsx +454 -59
  26. package/app/components/agents/AgentsContentPage.tsx +70 -5
  27. package/app/components/agents/AgentsMcpSection.tsx +474 -159
  28. package/app/components/agents/AgentsOverviewSection.tsx +418 -59
  29. package/app/components/agents/AgentsPrimitives.tsx +335 -0
  30. package/app/components/agents/AgentsSkillsSection.tsx +739 -121
  31. package/app/components/agents/SkillDetailPopover.tsx +416 -0
  32. package/app/components/agents/agents-content-model.ts +292 -10
  33. package/app/components/ask/AskContent.tsx +34 -5
  34. package/app/components/ask/FileChip.tsx +1 -0
  35. package/app/components/ask/MentionPopover.tsx +13 -1
  36. package/app/components/ask/MessageList.tsx +5 -7
  37. package/app/components/ask/ToolCallBlock.tsx +4 -4
  38. package/app/components/changes/ChangesBanner.tsx +1 -2
  39. package/app/components/echo/EchoHero.tsx +10 -24
  40. package/app/components/echo/EchoInsightCollapsible.tsx +52 -43
  41. package/app/components/echo/EchoPageSections.tsx +13 -9
  42. package/app/components/echo/EchoSegmentNav.tsx +14 -11
  43. package/app/components/echo/EchoSegmentPageClient.tsx +64 -43
  44. package/app/components/explore/ExploreContent.tsx +3 -7
  45. package/app/components/explore/UseCaseCard.tsx +4 -15
  46. package/app/components/panels/AgentsPanel.tsx +12 -104
  47. package/app/components/panels/AgentsPanelAgentDetail.tsx +2 -2
  48. package/app/components/panels/AgentsPanelAgentGroups.tsx +3 -7
  49. package/app/components/panels/AgentsPanelAgentListRow.tsx +9 -11
  50. package/app/components/panels/EchoPanel.tsx +8 -10
  51. package/app/components/panels/PanelNavRow.tsx +9 -2
  52. package/app/components/panels/PluginsPanel.tsx +2 -2
  53. package/app/components/renderers/agent-inspector/AgentInspectorRenderer.tsx +30 -8
  54. package/app/components/renderers/agent-inspector/manifest.ts +3 -3
  55. package/app/components/renderers/todo/manifest.ts +1 -0
  56. package/app/components/settings/AiTab.tsx +3 -3
  57. package/app/components/settings/AppearanceTab.tsx +2 -2
  58. package/app/components/settings/KnowledgeTab.tsx +3 -3
  59. package/app/components/settings/McpAgentInstall.tsx +3 -6
  60. package/app/components/settings/McpSkillCreateForm.tsx +2 -3
  61. package/app/components/settings/McpSkillRow.tsx +2 -3
  62. package/app/components/settings/McpSkillsSection.tsx +2 -2
  63. package/app/components/settings/McpTab.tsx +12 -13
  64. package/app/components/settings/MonitoringTab.tsx +13 -13
  65. package/app/components/settings/PluginsTab.tsx +2 -2
  66. package/app/components/settings/Primitives.tsx +3 -4
  67. package/app/components/settings/SettingsContent.tsx +3 -3
  68. package/app/components/settings/SyncTab.tsx +11 -17
  69. package/app/components/settings/UpdateTab.tsx +18 -21
  70. package/app/components/settings/types.ts +14 -0
  71. package/app/components/setup/StepKB.tsx +1 -1
  72. package/app/hooks/useMcpData.tsx +4 -2
  73. package/app/hooks/useMention.ts +25 -8
  74. package/app/lib/agent/log.ts +15 -18
  75. package/app/lib/agent/prompt.ts +17 -29
  76. package/app/lib/agent/stream-consumer.ts +3 -0
  77. package/app/lib/agent/to-agent-messages.ts +6 -4
  78. package/app/lib/core/agent-audit-log.ts +280 -0
  79. package/app/lib/core/index.ts +11 -0
  80. package/app/lib/fs.ts +9 -0
  81. package/app/lib/i18n-en.ts +259 -33
  82. package/app/lib/i18n-zh.ts +258 -32
  83. package/app/lib/mcp-agents.ts +231 -2
  84. package/app/lib/types.ts +2 -0
  85. package/package.json +1 -1
  86. package/scripts/migrate-agent-audit-log.js +170 -0
@@ -1,12 +1,32 @@
1
1
  'use client';
2
2
 
3
- import { useMemo, useState } from 'react';
3
+ import { useCallback, useMemo, useState } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { RefreshCw, Search, Server } from 'lucide-react';
6
6
  import type { McpContextValue } from '@/hooks/useMcpData';
7
- import type { AgentBuckets } from './agents-content-model';
8
- import type { AgentStatusFilter } from './agents-content-model';
9
- import { filterAgentsForMcpTable } from './agents-content-model';
7
+ import type { AgentBuckets, AgentStatusFilter, AgentTransportFilter } from './agents-content-model';
8
+ import {
9
+ ActionButton,
10
+ AddAvatarButton,
11
+ AgentAvatar,
12
+ AgentPickerPopover,
13
+ BulkMessage,
14
+ ConfirmDialog,
15
+ EmptyState,
16
+ PillButton,
17
+ SearchInput,
18
+ StatusDot,
19
+ } from './AgentsPrimitives';
20
+ import {
21
+ aggregateCrossAgentMcpServers,
22
+
23
+ filterAgentsForMcpWorkspace,
24
+ resolveAgentStatus,
25
+ sortAgentsByStatus,
26
+ summarizeMcpBulkReconnectResults,
27
+ } from './agents-content-model';
28
+
29
+ type McpView = 'byAgent' | 'byServer';
10
30
 
11
31
  export default function AgentsMcpSection({
12
32
  copy,
@@ -18,21 +38,34 @@ export default function AgentsMcpSection({
18
38
  copy: {
19
39
  title: string;
20
40
  refresh: string;
21
- connectionGraph: string;
22
- tabs: {
23
- manage: string;
24
- topology: string;
25
- };
41
+ tabs: { byAgent: string; byServer: string; [k: string]: string };
26
42
  searchPlaceholder: string;
27
43
  emptyState: string;
28
- filters: {
29
- all: string;
30
- connected: string;
31
- detected: string;
32
- notFound: string;
33
- };
34
- table: { agent: string; status: string; transport: string; actions: string };
35
- actions: { copySnippet: string; copied: string; testConnection: string; reconnect: string };
44
+ resultCount: (n: number) => string;
45
+ crossAgentServersEmpty: string;
46
+ riskMcpStopped: string;
47
+ bulkReconnectFiltered: string;
48
+ bulkRunning: string;
49
+ bulkSummary: (ok: number, failed: number) => string;
50
+ installMindos: string;
51
+ mcpServerLabel: string;
52
+ searchServersPlaceholder: string;
53
+ serverAgentCount: (n: number) => string;
54
+ addAgent: string;
55
+ removeFromServer: string;
56
+ confirmRemoveTitle: string;
57
+ confirmRemoveMessage: (agent: string, server: string) => string;
58
+ cancel: string;
59
+ noAvailableAgents: string;
60
+ manualRemoveHint: string;
61
+ reconnectAllInServer: string;
62
+ reconnectAllRunning: string;
63
+ reconnectAllDone: (ok: number, failed: number) => string;
64
+ serverTransport: (t: string) => string;
65
+ transportFilters: { all: string; stdio: string; http: string; other: string };
66
+ filters: { all: string; connected: string; detected: string; notFound: string };
67
+ table: { status: string; transport: string; [k: string]: string };
68
+ actions: { copySnippet: string; copied: string; reconnect: string; [k: string]: string };
36
69
  status: { connected: string; detected: string; notFound: string };
37
70
  };
38
71
  mcp: McpContextValue;
@@ -40,197 +73,479 @@ export default function AgentsMcpSection({
40
73
  copyState: string | null;
41
74
  onCopySnippet: (agentKey: string) => Promise<void>;
42
75
  }) {
76
+ const [view, setView] = useState<McpView>('byServer');
43
77
  const [query, setQuery] = useState('');
44
78
  const [statusFilter, setStatusFilter] = useState<AgentStatusFilter>('all');
79
+ const [transportFilter, setTransportFilter] = useState<AgentTransportFilter>('all');
45
80
  const [busyAction, setBusyAction] = useState<string | null>(null);
46
- const [view, setView] = useState<'manage' | 'topology'>('manage');
81
+ const [bulkMessage, setBulkMessage] = useState<string | null>(null);
82
+
83
+ const sortedAgents = useMemo(() => sortAgentsByStatus(mcp.agents), [mcp.agents]);
47
84
  const filteredAgents = useMemo(
48
- () => filterAgentsForMcpTable(mcp.agents, query, statusFilter),
49
- [mcp.agents, query, statusFilter],
85
+ () => sortAgentsByStatus(filterAgentsForMcpWorkspace(mcp.agents, { query, status: statusFilter, transport: transportFilter })),
86
+ [mcp.agents, query, statusFilter, transportFilter],
50
87
  );
88
+ const crossAgentServers = useMemo(() => aggregateCrossAgentMcpServers(mcp.agents), [mcp.agents]);
89
+ const filteredServers = useMemo(() => {
90
+ const q = query.trim().toLowerCase();
91
+ if (!q) return crossAgentServers;
92
+ return crossAgentServers.filter((srv) => srv.serverName.toLowerCase().includes(q));
93
+ }, [crossAgentServers, query]);
51
94
 
52
- async function handleTestConnection(agentKey: string) {
53
- setBusyAction(`test:${agentKey}`);
95
+ async function handleReconnect(agent: (typeof mcp.agents)[number]) {
96
+ setBusyAction(`reconnect:${agent.key}`);
54
97
  try {
98
+ const scope = agent.scope === 'project' ? 'project' : 'global';
99
+ const transport = agent.transport === 'http' ? 'http' : 'stdio';
100
+ await mcp.installAgent(agent.key, { scope, transport });
55
101
  await mcp.refresh();
102
+ } catch (err) {
103
+ console.error('[mcp] reconnect failed', err);
56
104
  } finally {
57
105
  setBusyAction(null);
58
106
  }
59
107
  }
60
108
 
61
- async function handleReconnect(agent: typeof mcp.agents[number]) {
62
- setBusyAction(`reconnect:${agent.key}`);
109
+ async function handleInstallMindos(agentKey: string) {
110
+ setBusyAction(`install:${agentKey}`);
63
111
  try {
64
- const scope = agent.scope === 'project' ? 'project' : 'global';
65
- const transport = agent.transport === 'http' ? 'http' : 'stdio';
66
- await mcp.installAgent(agent.key, { scope, transport });
112
+ await mcp.installAgent(agentKey, { scope: 'global', transport: 'stdio' });
67
113
  await mcp.refresh();
114
+ } catch (err) {
115
+ console.error('[mcp] install mindos failed', err);
68
116
  } finally {
69
117
  setBusyAction(null);
70
118
  }
71
119
  }
72
120
 
121
+ async function handleBulkReconnect() {
122
+ if (busyAction !== null || filteredAgents.length === 0) return;
123
+ setBusyAction('bulk');
124
+ setBulkMessage(copy.bulkRunning);
125
+ const results: Array<{ agentKey: string; ok: boolean }> = [];
126
+ for (const agent of filteredAgents) {
127
+ const scope = agent.scope === 'project' ? 'project' : 'global';
128
+ const transport = agent.transport === 'http' ? 'http' : 'stdio';
129
+ const ok = await mcp.installAgent(agent.key, { scope, transport });
130
+ results.push({ agentKey: agent.key, ok });
131
+ }
132
+ await mcp.refresh();
133
+ const summary = summarizeMcpBulkReconnectResults(results);
134
+ setBulkMessage(copy.bulkSummary(summary.succeeded, summary.failed));
135
+ setBusyAction(null);
136
+ }
137
+
138
+ const isRefreshing = busyAction === 'refresh';
139
+
73
140
  return (
74
- <section className="rounded-lg border border-border bg-card p-4 space-y-4">
75
- <div className="flex items-center justify-between">
76
- <div className="flex items-center gap-2">
141
+ <section className="space-y-4 overflow-hidden" aria-label={copy.title}>
142
+ {/* Header */}
143
+ <div className="flex items-center justify-between gap-2">
144
+ <div className="flex items-center gap-2.5">
77
145
  <h2 className="text-sm font-medium text-foreground flex items-center gap-2">
78
- <Server size={15} className="text-muted-foreground" />
146
+ <Server size={15} className="text-muted-foreground" aria-hidden="true" />
79
147
  {copy.title}
80
148
  </h2>
81
149
  <button
82
150
  type="button"
83
- onClick={() => void mcp.refresh()}
84
- className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
151
+ onClick={() => { setBusyAction('refresh'); void mcp.refresh().finally(() => setBusyAction(null)); }}
152
+ disabled={isRefreshing}
153
+ aria-label={copy.refresh}
154
+ className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-md px-1.5 py-1 hover:bg-muted disabled:opacity-50 transition-colors duration-150"
85
155
  >
86
- <RefreshCw size={13} />
156
+ <RefreshCw size={13} className={isRefreshing ? 'animate-spin' : ''} />
87
157
  {copy.refresh}
88
158
  </button>
89
159
  </div>
90
- <div className="flex items-center gap-1 rounded-md border border-border p-1 bg-background">
91
- <StatusFilterButton active={view === 'manage'} label={copy.tabs.manage} onClick={() => setView('manage')} />
92
- <StatusFilterButton active={view === 'topology'} label={copy.tabs.topology} onClick={() => setView('topology')} />
160
+ <div className="flex items-center gap-1 rounded-md border border-border p-0.5 bg-background" role="tablist" aria-label={copy.title}>
161
+ <PillButton active={view === 'byServer'} label={copy.tabs.byServer} onClick={() => setView('byServer')} />
162
+ <PillButton active={view === 'byAgent'} label={copy.tabs.byAgent} onClick={() => setView('byAgent')} />
93
163
  </div>
94
164
  </div>
95
165
 
96
- {view === 'topology' ? (
97
- <div className="rounded-md border border-border bg-background p-3">
98
- <p className="text-xs font-medium text-muted-foreground mb-2">{copy.connectionGraph}</p>
99
- <div className="flex flex-wrap items-center gap-2 text-xs">
100
- <NodePill label={copy.status.connected} count={buckets.connected.length} tone="ok" />
101
- <span className="text-muted-foreground">→</span>
102
- <NodePill label={copy.status.detected} count={buckets.detected.length} tone="warn" />
103
- <span className="text-muted-foreground">→</span>
104
- <NodePill label={copy.status.notFound} count={buckets.notFound.length} tone="neutral" />
105
- <span className="mx-2 text-muted-foreground">|</span>
106
- <NodePill label={copy.title} count={mcp.status?.running ? 1 : 0} tone={mcp.status?.running ? 'ok' : 'neutral'} />
107
- </div>
166
+ {/* Compact status strip + risk alerts */}
167
+ <div className="rounded-lg border border-border bg-card p-3">
168
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
169
+ <StatusDot tone="ok" label={copy.filters.connected} count={buckets.connected.length} />
170
+ <StatusDot tone="warn" label={copy.filters.detected} count={buckets.detected.length} />
171
+ {buckets.notFound.length > 0 && (
172
+ <StatusDot tone="neutral" label={copy.filters.notFound} count={buckets.notFound.length} />
173
+ )}
174
+ <span className="text-muted-foreground/40" aria-hidden="true">|</span>
175
+ <StatusDot tone={mcp.status?.running ? 'ok' : 'neutral'} label={copy.mcpServerLabel} count={mcp.status?.running ? 1 : 0} />
108
176
  </div>
109
- ) : (
110
- <>
111
- <div className="flex flex-col md:flex-row gap-2">
112
- <label className="relative flex-1">
113
- <Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
114
- <input
115
- value={query}
116
- onChange={(e) => setQuery(e.target.value)}
117
- placeholder={copy.searchPlaceholder}
118
- className="w-full h-9 rounded-md border border-border bg-background pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
119
- />
120
- </label>
121
- <div role="group" aria-label={copy.table.status} className="flex items-center gap-1 rounded-md border border-border p-1 bg-background">
122
- <StatusFilterButton active={statusFilter === 'all'} label={copy.filters.all} onClick={() => setStatusFilter('all')} />
123
- <StatusFilterButton active={statusFilter === 'connected'} label={copy.filters.connected} onClick={() => setStatusFilter('connected')} />
124
- <StatusFilterButton active={statusFilter === 'detected'} label={copy.filters.detected} onClick={() => setStatusFilter('detected')} />
125
- <StatusFilterButton active={statusFilter === 'notFound'} label={copy.filters.notFound} onClick={() => setStatusFilter('notFound')} />
126
- </div>
177
+ {!mcp.status?.running && (
178
+ <div className="mt-2 pt-2 border-t border-border/60" role="alert">
179
+ <p className="text-xs text-destructive flex items-center gap-1.5">
180
+ <span className="w-1.5 h-1.5 rounded-full bg-destructive shrink-0" aria-hidden="true" />
181
+ {copy.riskMcpStopped}
182
+ </p>
127
183
  </div>
184
+ )}
185
+ </div>
128
186
 
129
- <div className="overflow-x-auto">
130
- <table className="w-full text-sm">
131
- <thead>
132
- <tr className="text-left border-b border-border">
133
- <th className="py-2 font-medium text-muted-foreground">{copy.table.agent}</th>
134
- <th className="py-2 font-medium text-muted-foreground">{copy.table.status}</th>
135
- <th className="py-2 font-medium text-muted-foreground">{copy.table.transport}</th>
136
- <th className="py-2 font-medium text-muted-foreground">{copy.table.actions}</th>
137
- </tr>
138
- </thead>
139
- <tbody>
140
- {filteredAgents.map((agent) => (
141
- <tr key={agent.key} className="border-b border-border/60">
142
- <td className="py-2 text-foreground">
143
- <Link href={`/agents/${encodeURIComponent(agent.key)}`} className="hover:underline">{agent.name}</Link>
144
- </td>
145
- <td className="py-2 text-muted-foreground">{agent.present ? (agent.installed ? copy.status.connected : copy.status.detected) : copy.status.notFound}</td>
146
- <td className="py-2 text-muted-foreground">{agent.transport ?? agent.preferredTransport}</td>
147
- <td className="py-2">
148
- <div className="flex flex-wrap items-center gap-2">
149
- <button
150
- type="button"
151
- onClick={() => void onCopySnippet(agent.key)}
152
- className="text-xs px-2 py-1 rounded border border-border hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
153
- >
154
- {copyState === agent.key ? copy.actions.copied : copy.actions.copySnippet}
155
- </button>
156
- <button
157
- type="button"
158
- onClick={() => void handleTestConnection(agent.key)}
159
- disabled={!agent.installed || busyAction !== null}
160
- className="text-xs px-2 py-1 rounded border border-border hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
161
- >
162
- {copy.actions.testConnection}
163
- </button>
164
- <button
165
- type="button"
166
- onClick={() => void handleReconnect(agent)}
167
- disabled={busyAction !== null}
168
- className="text-xs px-2 py-1 rounded border border-border hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
169
- >
170
- {copy.actions.reconnect}
171
- </button>
172
- </div>
173
- </td>
174
- </tr>
175
- ))}
176
- {filteredAgents.length === 0 && (
177
- <tr>
178
- <td colSpan={4} className="py-4 text-sm text-muted-foreground text-center">
179
- {copy.emptyState}
180
- </td>
181
- </tr>
182
- )}
183
- </tbody>
184
- </table>
185
- </div>
186
- </>
187
+ {/* Search */}
188
+ <SearchInput
189
+ value={query}
190
+ onChange={setQuery}
191
+ placeholder={view === 'byAgent' ? copy.searchPlaceholder : copy.searchServersPlaceholder}
192
+ ariaLabel={copy.searchPlaceholder}
193
+ icon={Search}
194
+ />
195
+
196
+ {view === 'byAgent' ? (
197
+ <ByAgentView
198
+ copy={copy}
199
+ agents={filteredAgents}
200
+ busyAction={busyAction}
201
+ copyState={copyState}
202
+ bulkMessage={bulkMessage}
203
+ statusFilter={statusFilter}
204
+ transportFilter={transportFilter}
205
+ onStatusFilter={setStatusFilter}
206
+ onTransportFilter={setTransportFilter}
207
+ onCopySnippet={onCopySnippet}
208
+ onReconnect={handleReconnect}
209
+ onInstallMindos={handleInstallMindos}
210
+ onBulkReconnect={handleBulkReconnect}
211
+ />
212
+ ) : (
213
+ <ByServerView
214
+ copy={copy}
215
+ servers={filteredServers}
216
+ allAgents={sortedAgents}
217
+ busyAction={busyAction}
218
+ onInstallMindos={handleInstallMindos}
219
+ onReconnect={handleReconnect}
220
+ />
187
221
  )}
188
222
  </section>
189
223
  );
190
224
  }
191
225
 
192
- function StatusFilterButton({
193
- active,
194
- label,
195
- onClick,
226
+ /* ────────── By Agent View ────────── */
227
+
228
+ function ByAgentView({
229
+ copy,
230
+ agents,
231
+ busyAction,
232
+ copyState,
233
+ bulkMessage,
234
+ statusFilter,
235
+ transportFilter,
236
+ onStatusFilter,
237
+ onTransportFilter,
238
+ onCopySnippet,
239
+ onReconnect,
240
+ onInstallMindos,
241
+ onBulkReconnect,
196
242
  }: {
197
- active: boolean;
198
- label: string;
199
- onClick: () => void;
243
+ copy: Parameters<typeof AgentsMcpSection>[0]['copy'];
244
+ agents: ReturnType<typeof sortAgentsByStatus>;
245
+ busyAction: string | null;
246
+ copyState: string | null;
247
+ bulkMessage: string | null;
248
+ statusFilter: AgentStatusFilter;
249
+ transportFilter: AgentTransportFilter;
250
+ onStatusFilter: (f: AgentStatusFilter) => void;
251
+ onTransportFilter: (f: AgentTransportFilter) => void;
252
+ onCopySnippet: (agentKey: string) => Promise<void>;
253
+ onReconnect: (agent: ReturnType<typeof sortAgentsByStatus>[number]) => Promise<void>;
254
+ onInstallMindos: (agentKey: string) => Promise<void>;
255
+ onBulkReconnect: () => Promise<void>;
200
256
  }) {
201
257
  return (
202
- <button
203
- type="button"
204
- onClick={onClick}
205
- aria-pressed={active}
206
- className={`px-2.5 h-7 rounded text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
207
- active ? 'bg-[var(--amber-dim)] text-[var(--amber)]' : 'text-muted-foreground hover:text-foreground hover:bg-muted'
208
- }`}
209
- >
210
- {label}
211
- </button>
258
+ <>
259
+ {/* Filters */}
260
+ <div className="flex flex-wrap gap-2">
261
+ <div role="group" aria-label={copy.table.status} className="flex items-center gap-0.5 rounded-md border border-border p-0.5 bg-background">
262
+ <PillButton active={statusFilter === 'all'} label={copy.filters.all} onClick={() => onStatusFilter('all')} />
263
+ <PillButton active={statusFilter === 'connected'} label={copy.filters.connected} onClick={() => onStatusFilter('connected')} />
264
+ <PillButton active={statusFilter === 'detected'} label={copy.filters.detected} onClick={() => onStatusFilter('detected')} />
265
+ <PillButton active={statusFilter === 'notFound'} label={copy.filters.notFound} onClick={() => onStatusFilter('notFound')} />
266
+ </div>
267
+ <div role="group" aria-label={copy.table.transport} className="flex items-center gap-0.5 rounded-md border border-border p-0.5 bg-background">
268
+ <PillButton active={transportFilter === 'all'} label={copy.transportFilters.all} onClick={() => onTransportFilter('all')} />
269
+ <PillButton active={transportFilter === 'stdio'} label={copy.transportFilters.stdio} onClick={() => onTransportFilter('stdio')} />
270
+ <PillButton active={transportFilter === 'http'} label={copy.transportFilters.http} onClick={() => onTransportFilter('http')} />
271
+ <PillButton active={transportFilter === 'other'} label={copy.transportFilters.other} onClick={() => onTransportFilter('other')} />
272
+ </div>
273
+ </div>
274
+
275
+ {/* Bulk actions + result count */}
276
+ <div className="flex flex-wrap items-center gap-2">
277
+ <ActionButton
278
+ onClick={() => void onBulkReconnect()}
279
+ disabled={busyAction !== null || agents.length === 0}
280
+ busy={busyAction === 'bulk'}
281
+ label={copy.bulkReconnectFiltered}
282
+ />
283
+ <span className="text-2xs text-muted-foreground tabular-nums">{copy.resultCount(agents.length)}</span>
284
+ <BulkMessage message={bulkMessage} />
285
+ </div>
286
+
287
+ {/* Agent cards */}
288
+ {agents.length === 0 ? (
289
+ <EmptyState message={copy.emptyState} />
290
+ ) : (
291
+ <div className="space-y-2">
292
+ {agents.map((agent) => {
293
+ const status = resolveAgentStatus(agent);
294
+ const mcpServers = agent.configuredMcpServers ?? [];
295
+ const nativeSkillCount = (agent.installedSkillNames ?? []).length;
296
+ return (
297
+ <div key={agent.key} className="rounded-lg border border-border bg-card group hover:border-border/60 hover:shadow-sm transition-all duration-150">
298
+ {/* Card header with avatar */}
299
+ <div className="flex items-center gap-3 p-3">
300
+ <AgentAvatar name={agent.name} status={status} />
301
+ <div className="min-w-0 flex-1">
302
+ <div className="flex items-center gap-2">
303
+ <Link href={`/agents/${encodeURIComponent(agent.key)}`} className="text-sm font-medium text-foreground hover:underline cursor-pointer truncate">
304
+ {agent.name}
305
+ </Link>
306
+ <span className="text-2xs text-muted-foreground font-mono shrink-0">{agent.transport ?? agent.preferredTransport}</span>
307
+ <span className={`text-2xs px-1.5 py-0.5 rounded shrink-0 ${status === 'connected' ? 'bg-success/10 text-success' : status === 'detected' ? 'bg-[var(--amber-dim)] text-[var(--amber)]' : 'bg-muted text-muted-foreground'}`}>
308
+ {copy.status[status]}
309
+ </span>
310
+ </div>
311
+ <div className="flex items-center gap-2 mt-0.5 text-2xs text-muted-foreground">
312
+ <span className="tabular-nums">{mcpServers.length} MCP</span>
313
+ <span aria-hidden="true">·</span>
314
+ <span className="tabular-nums">{nativeSkillCount} skills</span>
315
+ </div>
316
+ </div>
317
+ <div className="flex items-center gap-1.5 shrink-0 md:opacity-0 md:group-hover:opacity-100 md:focus-within:opacity-100 transition-opacity duration-150">
318
+ <ActionButton
319
+ onClick={() => void onCopySnippet(agent.key)}
320
+ disabled={false}
321
+ busy={false}
322
+ label={copyState === agent.key ? copy.actions.copied : copy.actions.copySnippet}
323
+ />
324
+ {agent.installed ? (
325
+ <ActionButton
326
+ onClick={() => void onReconnect(agent)}
327
+ disabled={busyAction !== null}
328
+ busy={busyAction === `reconnect:${agent.key}`}
329
+ label={copy.actions.reconnect}
330
+ />
331
+ ) : (
332
+ <ActionButton
333
+ onClick={() => void onInstallMindos(agent.key)}
334
+ disabled={busyAction !== null}
335
+ busy={busyAction === `install:${agent.key}`}
336
+ label={copy.installMindos}
337
+ variant="primary"
338
+ />
339
+ )}
340
+ </div>
341
+ </div>
342
+
343
+ {/* MCP server chips */}
344
+ {mcpServers.length > 0 && (
345
+ <div className="flex flex-wrap gap-1 px-3 pb-3 ml-12">
346
+ {mcpServers.map((name) => (
347
+ <span key={name} className="inline-flex items-center gap-1 rounded-full bg-muted/60 px-2 py-0.5 text-2xs text-muted-foreground">
348
+ <Server size={9} className="text-[var(--amber)] shrink-0" aria-hidden="true" />
349
+ {name}
350
+ </span>
351
+ ))}
352
+ </div>
353
+ )}
354
+ </div>
355
+ );
356
+ })}
357
+ </div>
358
+ )}
359
+ </>
212
360
  );
213
361
  }
214
362
 
215
- function NodePill({
216
- label,
217
- count,
218
- tone,
363
+ /* ────────── By Server View (Avatar Grid) ────────── */
364
+
365
+ function ByServerView({
366
+ copy,
367
+ servers,
368
+ allAgents,
369
+ busyAction,
370
+ onInstallMindos,
371
+ onReconnect,
219
372
  }: {
220
- label: string;
221
- count: number;
222
- tone: 'ok' | 'warn' | 'neutral';
373
+ copy: Parameters<typeof AgentsMcpSection>[0]['copy'];
374
+ servers: ReturnType<typeof aggregateCrossAgentMcpServers>;
375
+ allAgents: ReturnType<typeof sortAgentsByStatus>;
376
+ busyAction: string | null;
377
+ onInstallMindos: (agentKey: string) => Promise<void>;
378
+ onReconnect: (agent: ReturnType<typeof sortAgentsByStatus>[number]) => Promise<void>;
223
379
  }) {
224
- const cls =
225
- tone === 'ok'
226
- ? 'bg-success/10 text-success'
227
- : tone === 'warn'
228
- ? 'bg-[var(--amber-dim)] text-[var(--amber)]'
229
- : 'bg-muted text-muted-foreground';
380
+ const [pickerServer, setPickerServer] = useState<string | null>(null);
381
+ const [confirmState, setConfirmState] = useState<{ agentName: string; serverName: string } | null>(null);
382
+ const [hintMessage, setHintMessage] = useState<string | null>(null);
383
+ const [reconnectingServer, setReconnectingServer] = useState<string | null>(null);
384
+ const [reconnectMsg, setReconnectMsg] = useState<Record<string, string>>({});
385
+
386
+ const isMindosServer = (name: string) => name.toLowerCase().includes('mindos');
387
+
388
+ const handleAddAgent = useCallback(
389
+ async (agentKey: string, serverName: string) => {
390
+ setPickerServer(null);
391
+ if (isMindosServer(serverName)) {
392
+ await onInstallMindos(agentKey);
393
+ }
394
+ },
395
+ [onInstallMindos],
396
+ );
397
+
398
+ const handleConfirmRemove = useCallback(() => {
399
+ setConfirmState(null);
400
+ setHintMessage(copy.manualRemoveHint);
401
+ setTimeout(() => setHintMessage(null), 4000);
402
+ }, [copy.manualRemoveHint]);
403
+
404
+ const handleReconnectAllInServer = useCallback(
405
+ async (serverName: string, agents: typeof allAgents) => {
406
+ setReconnectingServer(serverName);
407
+ setReconnectMsg((prev) => ({ ...prev, [serverName]: copy.reconnectAllRunning }));
408
+ let ok = 0;
409
+ let failed = 0;
410
+ for (const agent of agents) {
411
+ try {
412
+ await onReconnect(agent);
413
+ ok++;
414
+ } catch {
415
+ failed++;
416
+ }
417
+ }
418
+ setReconnectMsg((prev) => ({ ...prev, [serverName]: copy.reconnectAllDone(ok, failed) }));
419
+ setReconnectingServer(null);
420
+ setTimeout(() => setReconnectMsg((prev) => { const next = { ...prev }; delete next[serverName]; return next; }), 4000);
421
+ },
422
+ [copy, onReconnect],
423
+ );
424
+
425
+ if (servers.length === 0) {
426
+ return <EmptyState message={copy.crossAgentServersEmpty} />;
427
+ }
428
+
230
429
  return (
231
- <span className={`inline-flex items-center gap-1 px-2 py-1 rounded-md ${cls}`}>
232
- <span>{label}</span>
233
- <span className="tabular-nums">{count}</span>
234
- </span>
430
+ <>
431
+ <div className="space-y-3">
432
+ <p className="text-2xs text-muted-foreground tabular-nums">{copy.resultCount(servers.length)}</p>
433
+ {hintMessage && (
434
+ <div role="status" aria-live="polite" className="rounded-md border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground animate-in fade-in duration-200">
435
+ {hintMessage}
436
+ </div>
437
+ )}
438
+ {servers.map((srv) => {
439
+ const agentDetails = srv.agents
440
+ .map((name) => allAgents.find((a) => a.name === name))
441
+ .filter(Boolean) as typeof allAgents;
442
+ const orphanNames = srv.agents.filter((name) => !agentDetails.some((a) => a.name === name));
443
+ const canManage = isMindosServer(srv.serverName);
444
+ const availableToAdd = canManage
445
+ ? allAgents.filter((a) => !srv.agents.includes(a.name))
446
+ : [];
447
+
448
+ const connectedCount = agentDetails.filter((a) => resolveAgentStatus(a) === 'connected').length;
449
+ const detectedCount = agentDetails.filter((a) => resolveAgentStatus(a) === 'detected').length;
450
+ const notFoundCount = agentDetails.length - connectedCount - detectedCount + orphanNames.length;
451
+
452
+ return (
453
+ <div key={srv.serverName} className="rounded-lg border border-border bg-card p-4 hover:border-border/60 hover:shadow-sm transition-all duration-150">
454
+ {/* Server header */}
455
+ <div className="flex items-center justify-between gap-2 mb-2">
456
+ <div className="flex items-center gap-2 min-w-0">
457
+ <Server size={14} className="text-[var(--amber)] shrink-0" aria-hidden="true" />
458
+ <span className="text-sm font-medium text-foreground truncate">{srv.serverName}</span>
459
+ </div>
460
+ <div className="flex items-center gap-1.5 shrink-0">
461
+ {canManage && agentDetails.length > 0 && (
462
+ <ActionButton
463
+ onClick={() => void handleReconnectAllInServer(srv.serverName, agentDetails)}
464
+ disabled={reconnectingServer !== null || busyAction !== null}
465
+ busy={reconnectingServer === srv.serverName}
466
+ label={copy.reconnectAllInServer}
467
+ busyLabel={copy.reconnectAllRunning}
468
+ />
469
+ )}
470
+ {canManage && (
471
+ <div className="relative">
472
+ <AddAvatarButton
473
+ onClick={() => setPickerServer(pickerServer === srv.serverName ? null : srv.serverName)}
474
+ label={copy.addAgent}
475
+ size="sm"
476
+ />
477
+ <AgentPickerPopover
478
+ open={pickerServer === srv.serverName}
479
+ agents={availableToAdd.map((a) => ({ key: a.key, name: a.name }))}
480
+ emptyLabel={copy.noAvailableAgents}
481
+ onSelect={(key) => void handleAddAgent(key, srv.serverName)}
482
+ onClose={() => setPickerServer(null)}
483
+ />
484
+ </div>
485
+ )}
486
+ </div>
487
+ </div>
488
+
489
+ {/* Agent status breakdown */}
490
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-muted-foreground mb-3">
491
+ <span className="tabular-nums">{copy.serverAgentCount(srv.agents.length)}</span>
492
+ {connectedCount > 0 && (
493
+ <span className="inline-flex items-center gap-1">
494
+ <span className="w-1.5 h-1.5 rounded-full bg-[var(--success)]" aria-hidden="true" />
495
+ {connectedCount}
496
+ </span>
497
+ )}
498
+ {detectedCount > 0 && (
499
+ <span className="inline-flex items-center gap-1">
500
+ <span className="w-1.5 h-1.5 rounded-full bg-[var(--amber)]" aria-hidden="true" />
501
+ {detectedCount}
502
+ </span>
503
+ )}
504
+ {notFoundCount > 0 && (
505
+ <span className="inline-flex items-center gap-1">
506
+ <span className="w-1.5 h-1.5 rounded-full bg-muted-foreground" aria-hidden="true" />
507
+ {notFoundCount}
508
+ </span>
509
+ )}
510
+ </div>
511
+
512
+ {reconnectMsg[srv.serverName] && (
513
+ <p role="status" className="text-2xs text-muted-foreground mb-2 animate-in fade-in duration-200">{reconnectMsg[srv.serverName]}</p>
514
+ )}
515
+
516
+ {/* Agent avatar grid */}
517
+ <div className="flex flex-wrap items-center gap-2">
518
+ {agentDetails.map((agent) => {
519
+ const agentStatus = resolveAgentStatus(agent);
520
+ return (
521
+ <Link key={agent.key} href={`/agents/${encodeURIComponent(agent.key)}`} className="cursor-pointer">
522
+ <AgentAvatar
523
+ name={agent.name}
524
+ status={agentStatus}
525
+ onRemove={canManage ? () => setConfirmState({ agentName: agent.name, serverName: srv.serverName }) : undefined}
526
+ />
527
+ </Link>
528
+ );
529
+ })}
530
+ {orphanNames.map((name) => (
531
+ <AgentAvatar key={name} name={name} status="notFound" />
532
+ ))}
533
+ </div>
534
+ </div>
535
+ );
536
+ })}
537
+ </div>
538
+
539
+ <ConfirmDialog
540
+ open={confirmState !== null}
541
+ title={copy.confirmRemoveTitle}
542
+ message={confirmState ? copy.confirmRemoveMessage(confirmState.agentName, confirmState.serverName) : ''}
543
+ confirmLabel={copy.removeFromServer}
544
+ cancelLabel={copy.cancel}
545
+ onConfirm={handleConfirmRemove}
546
+ onCancel={() => setConfirmState(null)}
547
+ variant="destructive"
548
+ />
549
+ </>
235
550
  );
236
551
  }