@geminilight/mindos 0.5.62 → 0.5.64

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 (41) hide show
  1. package/app/app/api/changes/route.ts +7 -1
  2. package/app/app/api/mcp/install-skill/route.ts +9 -24
  3. package/app/app/api/mcp/status/route.ts +1 -1
  4. package/app/app/layout.tsx +1 -0
  5. package/app/app/page.tsx +1 -2
  6. package/app/app/view/[...path]/ViewPageClient.tsx +0 -1
  7. package/app/components/HomeContent.tsx +41 -6
  8. package/app/components/RightAgentDetailPanel.tsx +1 -0
  9. package/app/components/SidebarLayout.tsx +1 -0
  10. package/app/components/agents/AgentsContentPage.tsx +20 -16
  11. package/app/components/agents/AgentsMcpSection.tsx +178 -65
  12. package/app/components/agents/AgentsOverviewSection.tsx +1 -1
  13. package/app/components/agents/AgentsSkillsSection.tsx +78 -55
  14. package/app/components/agents/agents-content-model.ts +16 -0
  15. package/app/components/changes/ChangesBanner.tsx +90 -13
  16. package/app/components/changes/ChangesContentPage.tsx +134 -51
  17. package/app/components/panels/AgentsPanel.tsx +14 -28
  18. package/app/components/panels/AgentsPanelAgentDetail.tsx +5 -4
  19. package/app/components/panels/AgentsPanelAgentGroups.tsx +5 -6
  20. package/app/components/panels/AgentsPanelAgentListRow.tsx +30 -5
  21. package/app/components/panels/AgentsPanelHubNav.tsx +12 -12
  22. package/app/components/panels/PluginsPanel.tsx +3 -3
  23. package/app/components/renderers/agent-inspector/manifest.ts +2 -0
  24. package/app/components/renderers/config/manifest.ts +1 -0
  25. package/app/components/renderers/csv/manifest.ts +1 -0
  26. package/app/components/settings/PluginsTab.tsx +4 -3
  27. package/app/hooks/useMcpData.tsx +3 -2
  28. package/app/lib/core/content-changes.ts +148 -8
  29. package/app/lib/fs.ts +7 -1
  30. package/app/lib/i18n-en.ts +58 -3
  31. package/app/lib/i18n-zh.ts +58 -3
  32. package/app/lib/mcp-agents.ts +42 -0
  33. package/app/lib/renderers/registry.ts +10 -0
  34. package/app/next-env.d.ts +1 -1
  35. package/bin/lib/mcp-agents.js +38 -13
  36. package/package.json +1 -1
  37. package/scripts/migrate-agent-diff.js +146 -0
  38. package/scripts/setup.js +12 -17
  39. package/skills/plugin-core-builtin-migration/SKILL.md +178 -0
  40. package/app/components/renderers/diff/DiffRenderer.tsx +0 -311
  41. package/app/components/renderers/diff/manifest.ts +0 -14
@@ -22,10 +22,16 @@ export async function GET(req: NextRequest) {
22
22
 
23
23
  if (op === 'list') {
24
24
  const path = req.nextUrl.searchParams.get('path') ?? undefined;
25
+ const sourceParam = req.nextUrl.searchParams.get('source');
26
+ const source = sourceParam === 'user' || sourceParam === 'agent' || sourceParam === 'system'
27
+ ? sourceParam
28
+ : undefined;
29
+ const opFilter = req.nextUrl.searchParams.get('event_op') ?? undefined;
30
+ const q = req.nextUrl.searchParams.get('q') ?? undefined;
25
31
  const limitParam = req.nextUrl.searchParams.get('limit');
26
32
  const limit = limitParam ? Number(limitParam) : 50;
27
33
  if (!Number.isFinite(limit) || limit <= 0) return err('invalid limit');
28
- return NextResponse.json({ events: listContentChanges({ path, limit }) });
34
+ return NextResponse.json({ events: listContentChanges({ path, source, op: opFilter, q, limit }) });
29
35
  }
30
36
 
31
37
  return err(`unknown op: ${op}`);
@@ -3,35 +3,15 @@ import { NextRequest, NextResponse } from 'next/server';
3
3
  import { execSync } from 'child_process';
4
4
  import path from 'path';
5
5
  import fs from 'fs';
6
+ import { SKILL_AGENT_REGISTRY } from '@/lib/mcp-agents';
6
7
 
7
8
  /* ── Constants ────────────────────────────────────────────────── */
8
9
 
9
10
  const GITHUB_SOURCE = 'GeminiLight/MindOS';
10
11
 
11
- // Universal agents read directly from ~/.agents/skills/ — no symlink needed.
12
- const UNIVERSAL_AGENTS = new Set([
13
- 'amp', 'cline', 'codex', 'cursor', 'gemini-cli',
14
- 'github-copilot', 'kimi-cli', 'opencode', 'warp',
15
- ]);
16
-
17
12
  // Agents that do NOT support Skills at all
18
13
  const SKILL_UNSUPPORTED = new Set<string>([]);
19
14
 
20
- // MCP agent key → npx skills agent name (for non-universal agents)
21
- const AGENT_NAME_MAP: Record<string, string> = {
22
- 'claude-code': 'claude-code',
23
- 'windsurf': 'windsurf',
24
- 'trae': 'trae',
25
- 'openclaw': 'openclaw',
26
- 'codebuddy': 'codebuddy',
27
- 'iflow-cli': 'iflow-cli',
28
- 'pi': 'pi',
29
- 'augment': 'augment',
30
- 'qwen-code': 'qwen-code',
31
- 'trae-cn': 'trae-cn',
32
- 'roo': 'roo',
33
- };
34
-
35
15
  /* ── Helpers ──────────────────────────────────────────────────── */
36
16
 
37
17
  /** Fallback: find local skills directory for offline installs */
@@ -78,9 +58,14 @@ export async function POST(req: NextRequest) {
78
58
  return NextResponse.json({ error: 'Invalid skill name' }, { status: 400 });
79
59
  }
80
60
 
81
- const additionalAgents = (agents || [])
82
- .filter(key => !UNIVERSAL_AGENTS.has(key) && !SKILL_UNSUPPORTED.has(key))
83
- .map(key => AGENT_NAME_MAP[key] || key);
61
+ const additionalAgents = (agents || []).flatMap((key) => {
62
+ if (SKILL_UNSUPPORTED.has(key)) return [];
63
+ const reg = SKILL_AGENT_REGISTRY[key];
64
+ if (!reg) return [key]; // Forward-compatible fallback for unknown keys.
65
+ if (reg.mode === 'unsupported') return [];
66
+ if (reg.mode === 'universal') return [];
67
+ return [reg.skillAgentName || key];
68
+ });
84
69
 
85
70
  // Try GitHub source first, fall back to local path
86
71
  const sources = [GITHUB_SOURCE];
@@ -17,7 +17,7 @@ function parseHostname(host: string): string {
17
17
  export async function GET(req: NextRequest) {
18
18
  try {
19
19
  const settings = readSettings();
20
- const port = settings.mcpPort ?? 8781;
20
+ const port = Number(process.env.MINDOS_MCP_PORT) || settings.mcpPort || 8781;
21
21
  const token = settings.authToken ?? '';
22
22
  const authConfigured = !!token;
23
23
 
@@ -11,6 +11,7 @@ import UpdateBanner from '@/components/UpdateBanner';
11
11
  import UpdateOverlay from '@/components/UpdateOverlay';
12
12
  import { cookies } from 'next/headers';
13
13
  import type { Locale } from '@/lib/i18n';
14
+ import '@/lib/renderers/index'; // globally register built-in renderers once
14
15
 
15
16
  const geistSans = Inter({
16
17
  variable: '--font-geist-sans',
package/app/app/page.tsx CHANGED
@@ -2,7 +2,6 @@ import { redirect } from 'next/navigation';
2
2
  import { readSettings } from '@/lib/settings';
3
3
  import { getRecentlyModified, getFileContent, getFileTree } from '@/lib/fs';
4
4
  import { getAllRenderers } from '@/lib/renderers/registry';
5
- import '@/lib/renderers/index'; // registers all renderers
6
5
  import HomeContent from '@/components/HomeContent';
7
6
  import type { FileNode } from '@/lib/core/types';
8
7
 
@@ -83,7 +82,7 @@ export default function HomePage() {
83
82
  console.error('[HomePage] Failed to load recent files:', err);
84
83
  }
85
84
 
86
- // Derive plugin entry paths from registry — no hardcoded list needed
85
+ // Derive renderer entry paths from registry — used by plugin and app-builtin sections on home.
87
86
  const entryPaths = getAllRenderers()
88
87
  .map(r => r.entryPath)
89
88
  .filter((p): p is string => !!p);
@@ -17,7 +17,6 @@ import { resolveRenderer, isRendererEnabled } from '@/lib/renderers/registry';
17
17
  import { encodePath } from '@/lib/utils';
18
18
  import { useLocale } from '@/lib/LocaleContext';
19
19
  import DirPicker from '@/components/DirPicker';
20
- import '@/lib/renderers/index'; // registers all renderers
21
20
 
22
21
  interface ViewPageClientProps {
23
22
  filePath: string;
@@ -5,8 +5,7 @@ import { FileText, Table, Clock, Sparkles, ArrowRight, FilePlus, Search, Chevron
5
5
  import { useState, useEffect, useMemo } from 'react';
6
6
  import { useLocale } from '@/lib/LocaleContext';
7
7
  import { encodePath, relativeTime, extractEmoji, stripEmoji } from '@/lib/utils';
8
- import { getAllRenderers } from '@/lib/renderers/registry';
9
- import '@/lib/renderers/index'; // registers all renderers
8
+ import { getAllRenderers, getPluginRenderers } from '@/lib/renderers/registry';
10
9
  import OnboardingView from './OnboardingView';
11
10
  import GuideCard from './GuideCard';
12
11
  import CreateSpaceModal from './CreateSpaceModal';
@@ -124,8 +123,10 @@ export default function HomeContent({ recent, existingFiles, spaces, dirPaths }:
124
123
 
125
124
  const formatTime = (mtime: number) => relativeTime(mtime, t.home.relativeTime);
126
125
 
127
- // Only show renderers that are available (have entryPath + file exists) as quick-access chips
128
- const availablePlugins = getAllRenderers().filter(r => r.entryPath && existingSet.has(r.entryPath));
126
+ // User-manageable plugins: only show available entry files
127
+ const availablePlugins = getPluginRenderers().filter(r => r.entryPath && existingSet.has(r.entryPath));
128
+ // App-builtin features: always visible, with active/inactive state
129
+ const builtinFeatures = getAllRenderers().filter((r) => r.appBuiltinFeature && r.id !== 'csv');
129
130
 
130
131
  const lastFile = recent[0];
131
132
 
@@ -275,7 +276,41 @@ export default function HomeContent({ recent, existingFiles, spaces, dirPaths }:
275
276
  <CreateSpaceModal t={t} dirPaths={dirPaths ?? []} />
276
277
  </section>
277
278
 
278
- {/* ── Section 2: Extensions ── */}
279
+ {/* ── Section 2: Built-in capabilities ── */}
280
+ {builtinFeatures.length > 0 && (
281
+ <section className="mb-8">
282
+ <SectionTitle icon={<Puzzle size={13} />} count={builtinFeatures.length}>
283
+ {t.home.builtinFeatures}
284
+ </SectionTitle>
285
+ <div className="flex flex-wrap gap-2">
286
+ {builtinFeatures.map((r) => {
287
+ const active = !!r.entryPath && existingSet.has(r.entryPath);
288
+ if (active && r.entryPath) {
289
+ return (
290
+ <Link key={r.id} href={`/view/${encodePath(r.entryPath)}`}>
291
+ <span className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-xs transition-all duration-150 hover:border-amber-500/30 hover:bg-muted/60">
292
+ <span className="text-sm leading-none" suppressHydrationWarning>{r.icon}</span>
293
+ <span className="font-medium text-foreground">{r.name}</span>
294
+ </span>
295
+ </Link>
296
+ );
297
+ }
298
+ return (
299
+ <span
300
+ key={r.id}
301
+ className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-border text-xs text-muted-foreground opacity-70"
302
+ title={r.entryPath ? t.home.createToActivate.replace('{file}', r.entryPath) : t.home.builtinInactive}
303
+ >
304
+ <span className="text-sm leading-none" suppressHydrationWarning>{r.icon}</span>
305
+ <span className="font-medium">{r.name}</span>
306
+ </span>
307
+ );
308
+ })}
309
+ </div>
310
+ </section>
311
+ )}
312
+
313
+ {/* ── Section 3: Extensions ── */}
279
314
  {availablePlugins.length > 0 && (
280
315
  <section className="mb-8">
281
316
  <SectionTitle
@@ -310,7 +345,7 @@ export default function HomeContent({ recent, existingFiles, spaces, dirPaths }:
310
345
  </section>
311
346
  )}
312
347
 
313
- {/* ── Section 3: Recently Edited ── */}
348
+ {/* ── Section 4: Recently Edited ── */}
314
349
  {recent.length > 0 && (
315
350
  <section className="mb-12">
316
351
  <SectionTitle icon={<Clock size={13} />} count={recent.length}>{t.home.recentlyEdited}</SectionTitle>
@@ -70,6 +70,7 @@ export default function RightAgentDetailPanel({
70
70
  connected: p.connected,
71
71
  installing: p.installing,
72
72
  install: p.install,
73
+ installFailed: p.installFailed,
73
74
  copyConfig: p.copyConfig,
74
75
  copied: p.copied,
75
76
  transportLocal: p.transportLocal,
@@ -31,6 +31,7 @@ import { FileNode } from '@/lib/types';
31
31
  import { useLocale } from '@/lib/LocaleContext';
32
32
  import { WalkthroughProvider } from './walkthrough';
33
33
  import McpProvider from '@/hooks/useMcpData';
34
+ import '@/lib/renderers/index'; // client-side renderer registration source of truth
34
35
  import { useLeftPanel } from '@/hooks/useLeftPanel';
35
36
  import { useAskPanel } from '@/hooks/useAskPanel';
36
37
  import type { Tab } from './settings/types';
@@ -1,7 +1,6 @@
1
1
  'use client';
2
2
 
3
3
  import { useMemo, useState } from 'react';
4
- import Link from 'next/link';
5
4
  import { useLocale } from '@/lib/LocaleContext';
6
5
  import { useMcpData } from '@/hooks/useMcpData';
7
6
  import { copyToClipboard } from '@/lib/clipboard';
@@ -20,6 +19,24 @@ export default function AgentsContentPage({ tab }: { tab: AgentsDashboardTab })
20
19
  const a = t.agentsContent;
21
20
  const mcp = useMcpData();
22
21
  const [copyState, setCopyState] = useState<string | null>(null);
22
+ const pageHeader = useMemo(() => {
23
+ if (tab === 'skills') {
24
+ return {
25
+ title: a.navSkills,
26
+ subtitle: a.skills.capabilityGroups,
27
+ };
28
+ }
29
+ if (tab === 'mcp') {
30
+ return {
31
+ title: a.navMcp,
32
+ subtitle: a.mcp.connectionGraph,
33
+ };
34
+ }
35
+ return {
36
+ title: a.title,
37
+ subtitle: a.subtitle,
38
+ };
39
+ }, [a, tab]);
23
40
 
24
41
  const buckets = useMemo(() => bucketAgents(mcp.agents), [mcp.agents]);
25
42
  const riskQueue = useMemo(
@@ -33,13 +50,6 @@ export default function AgentsContentPage({ tab }: { tab: AgentsDashboardTab })
33
50
  [mcp.skills, mcp.status?.running, buckets.detected.length, buckets.notFound.length],
34
51
  );
35
52
 
36
- const navClass = (target: AgentsDashboardTab) =>
37
- `px-3 py-1.5 text-xs rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
38
- tab === target
39
- ? 'border-border bg-[var(--amber-dim)] text-[var(--amber)]'
40
- : 'border-border text-muted-foreground hover:text-foreground hover:bg-muted'
41
- }`;
42
-
43
53
  const copySnippet = async (agentKey: string) => {
44
54
  const agent = mcp.agents.find((item) => item.key === agentKey);
45
55
  if (!agent) return;
@@ -53,16 +63,10 @@ export default function AgentsContentPage({ tab }: { tab: AgentsDashboardTab })
53
63
  return (
54
64
  <div className="content-width px-4 md:px-6 py-8 md:py-10">
55
65
  <header className="mb-6">
56
- <h1 className="text-2xl font-semibold tracking-tight font-display text-foreground">{a.title}</h1>
57
- <p className="mt-1 text-sm text-muted-foreground">{a.subtitle}</p>
66
+ <h1 className="text-2xl font-semibold tracking-tight font-display text-foreground">{pageHeader.title}</h1>
67
+ <p className="mt-1 text-sm text-muted-foreground">{pageHeader.subtitle}</p>
58
68
  </header>
59
69
 
60
- <div className="mb-6 flex items-center gap-2 border-b border-border pb-3" role="tablist" aria-label={a.title}>
61
- <Link href="/agents" role="tab" id="agents-tab-overview" aria-controls="agents-panel-overview" aria-selected={tab === 'overview'} className={navClass('overview')}>{a.navOverview}</Link>
62
- <Link href="/agents?tab=mcp" role="tab" id="agents-tab-mcp" aria-controls="agents-panel-mcp" aria-selected={tab === 'mcp'} className={navClass('mcp')}>{a.navMcp}</Link>
63
- <Link href="/agents?tab=skills" role="tab" id="agents-tab-skills" aria-controls="agents-panel-skills" aria-selected={tab === 'skills'} className={navClass('skills')}>{a.navSkills}</Link>
64
- </div>
65
-
66
70
  {tab === 'overview' && (
67
71
  <AgentsOverviewSection
68
72
  copy={a.overview}
@@ -1,9 +1,12 @@
1
1
  'use client';
2
2
 
3
+ import { useMemo, useState } from 'react';
3
4
  import Link from 'next/link';
4
- import { RefreshCw, Server } from 'lucide-react';
5
+ import { RefreshCw, Search, Server } from 'lucide-react';
5
6
  import type { McpContextValue } from '@/hooks/useMcpData';
6
7
  import type { AgentBuckets } from './agents-content-model';
8
+ import type { AgentStatusFilter } from './agents-content-model';
9
+ import { filterAgentsForMcpTable } from './agents-content-model';
7
10
 
8
11
  export default function AgentsMcpSection({
9
12
  copy,
@@ -16,6 +19,18 @@ export default function AgentsMcpSection({
16
19
  title: string;
17
20
  refresh: string;
18
21
  connectionGraph: string;
22
+ tabs: {
23
+ manage: string;
24
+ topology: string;
25
+ };
26
+ searchPlaceholder: string;
27
+ emptyState: string;
28
+ filters: {
29
+ all: string;
30
+ connected: string;
31
+ detected: string;
32
+ notFound: string;
33
+ };
19
34
  table: { agent: string; status: string; transport: string; actions: string };
20
35
  actions: { copySnippet: string; copied: string; testConnection: string; reconnect: string };
21
36
  status: { connected: string; detected: string; notFound: string };
@@ -25,80 +40,178 @@ export default function AgentsMcpSection({
25
40
  copyState: string | null;
26
41
  onCopySnippet: (agentKey: string) => Promise<void>;
27
42
  }) {
43
+ const [query, setQuery] = useState('');
44
+ const [statusFilter, setStatusFilter] = useState<AgentStatusFilter>('all');
45
+ const [busyAction, setBusyAction] = useState<string | null>(null);
46
+ const [view, setView] = useState<'manage' | 'topology'>('manage');
47
+ const filteredAgents = useMemo(
48
+ () => filterAgentsForMcpTable(mcp.agents, query, statusFilter),
49
+ [mcp.agents, query, statusFilter],
50
+ );
51
+
52
+ async function handleTestConnection(agentKey: string) {
53
+ setBusyAction(`test:${agentKey}`);
54
+ try {
55
+ await mcp.refresh();
56
+ } finally {
57
+ setBusyAction(null);
58
+ }
59
+ }
60
+
61
+ async function handleReconnect(agent: typeof mcp.agents[number]) {
62
+ setBusyAction(`reconnect:${agent.key}`);
63
+ 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 });
67
+ await mcp.refresh();
68
+ } finally {
69
+ setBusyAction(null);
70
+ }
71
+ }
72
+
28
73
  return (
29
- <section role="tabpanel" id="agents-panel-mcp" aria-labelledby="agents-tab-mcp" className="rounded-lg border border-border bg-card p-4 space-y-4">
74
+ <section className="rounded-lg border border-border bg-card p-4 space-y-4">
30
75
  <div className="flex items-center justify-between">
31
- <h2 className="text-sm font-medium text-foreground flex items-center gap-2">
32
- <Server size={15} className="text-muted-foreground" />
33
- {copy.title}
34
- </h2>
35
- <button
36
- type="button"
37
- onClick={() => void mcp.refresh()}
38
- 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"
39
- >
40
- <RefreshCw size={13} />
41
- {copy.refresh}
42
- </button>
76
+ <div className="flex items-center gap-2">
77
+ <h2 className="text-sm font-medium text-foreground flex items-center gap-2">
78
+ <Server size={15} className="text-muted-foreground" />
79
+ {copy.title}
80
+ </h2>
81
+ <button
82
+ 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"
85
+ >
86
+ <RefreshCw size={13} />
87
+ {copy.refresh}
88
+ </button>
89
+ </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')} />
93
+ </div>
43
94
  </div>
44
95
 
45
- <div className="rounded-md border border-border bg-background p-3">
46
- <p className="text-xs font-medium text-muted-foreground mb-2">{copy.connectionGraph}</p>
47
- <div className="flex flex-wrap items-center gap-2 text-xs">
48
- <NodePill label={copy.status.connected} count={buckets.connected.length} tone="ok" />
49
- <span className="text-muted-foreground">→</span>
50
- <NodePill label={copy.status.detected} count={buckets.detected.length} tone="warn" />
51
- <span className="text-muted-foreground">→</span>
52
- <NodePill label={copy.status.notFound} count={buckets.notFound.length} tone="neutral" />
53
- <span className="mx-2 text-muted-foreground">|</span>
54
- <NodePill label={copy.title} count={mcp.status?.running ? 1 : 0} tone={mcp.status?.running ? 'ok' : 'neutral'} />
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>
55
108
  </div>
56
- </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>
127
+ </div>
57
128
 
58
- <div className="overflow-x-auto">
59
- <table className="w-full text-sm">
60
- <thead>
61
- <tr className="text-left border-b border-border">
62
- <th className="py-2 font-medium text-muted-foreground">{copy.table.agent}</th>
63
- <th className="py-2 font-medium text-muted-foreground">{copy.table.status}</th>
64
- <th className="py-2 font-medium text-muted-foreground">{copy.table.transport}</th>
65
- <th className="py-2 font-medium text-muted-foreground">{copy.table.actions}</th>
66
- </tr>
67
- </thead>
68
- <tbody>
69
- {mcp.agents.map((agent) => (
70
- <tr key={agent.key} className="border-b border-border/60">
71
- <td className="py-2 text-foreground">
72
- <Link href={`/agents/${encodeURIComponent(agent.key)}`} className="hover:underline">{agent.name}</Link>
73
- </td>
74
- <td className="py-2 text-muted-foreground">{agent.present ? (agent.installed ? copy.status.connected : copy.status.detected) : copy.status.notFound}</td>
75
- <td className="py-2 text-muted-foreground">{agent.transport ?? agent.preferredTransport}</td>
76
- <td className="py-2">
77
- <div className="flex flex-wrap items-center gap-2">
78
- <button
79
- type="button"
80
- onClick={() => void onCopySnippet(agent.key)}
81
- 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"
82
- >
83
- {copyState === agent.key ? copy.actions.copied : copy.actions.copySnippet}
84
- </button>
85
- <button type="button" 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">
86
- {copy.actions.testConnection}
87
- </button>
88
- <button type="button" 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">
89
- {copy.actions.reconnect}
90
- </button>
91
- </div>
92
- </td>
93
- </tr>
94
- ))}
95
- </tbody>
96
- </table>
97
- </div>
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
+ )}
98
188
  </section>
99
189
  );
100
190
  }
101
191
 
192
+ function StatusFilterButton({
193
+ active,
194
+ label,
195
+ onClick,
196
+ }: {
197
+ active: boolean;
198
+ label: string;
199
+ onClick: () => void;
200
+ }) {
201
+ 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>
212
+ );
213
+ }
214
+
102
215
  function NodePill({
103
216
  label,
104
217
  count,
@@ -32,7 +32,7 @@ export default function AgentsOverviewSection({
32
32
  failedAgentsValue: string;
33
33
  }) {
34
34
  return (
35
- <div className="space-y-4" role="tabpanel" id="agents-panel-overview" aria-labelledby="agents-tab-overview">
35
+ <div className="space-y-4">
36
36
  <section className="grid grid-cols-1 md:grid-cols-3 gap-3">
37
37
  <StatCard title={copy.connected} value={String(buckets.connected.length)} tone="ok" />
38
38
  <StatCard title={copy.detected} value={String(buckets.detected.length)} tone="warn" />