@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,11 +1,44 @@
1
1
  'use client';
2
2
 
3
- import { useMemo, useState } from 'react';
4
- import { Search } from 'lucide-react';
3
+ import { useCallback, useMemo, useState } from 'react';
4
+ import Link from 'next/link';
5
+ import { ChevronDown, ChevronRight, Search, Trash2, Zap } from 'lucide-react';
5
6
  import { Toggle } from '@/components/settings/Primitives';
7
+ import { apiFetch } from '@/lib/api';
6
8
  import type { McpContextValue } from '@/hooks/useMcpData';
7
- import type { AgentBuckets, SkillSourceFilter } from './agents-content-model';
8
- import { filterSkills, groupSkillsByCapability } from './agents-content-model';
9
+ import type {
10
+ AgentBuckets,
11
+ SkillCapabilityFilter,
12
+ SkillWorkspaceStatusFilter,
13
+ UnifiedSourceFilter,
14
+ } from './agents-content-model';
15
+ import {
16
+ ActionButton,
17
+ AddAvatarButton,
18
+ AgentAvatar,
19
+ AgentPickerPopover,
20
+ BulkMessage,
21
+ ConfirmDialog,
22
+ EmptyState,
23
+ PillButton,
24
+ SearchInput,
25
+ } from './AgentsPrimitives';
26
+ import SkillDetailPopover from './SkillDetailPopover';
27
+ import {
28
+ aggregateCrossAgentSkills,
29
+ buildSkillAttentionSet,
30
+ buildUnifiedSkillList,
31
+ capabilityForSkill,
32
+ createBulkUnifiedTogglePlan,
33
+ filterUnifiedSkills,
34
+ groupUnifiedSkills,
35
+ resolveAgentStatus,
36
+ sortAgentsByStatus,
37
+ summarizeBulkSkillToggleResults,
38
+ type UnifiedSkillItem,
39
+ } from './agents-content-model';
40
+
41
+ type SkillView = 'bySkill' | 'byAgent';
9
42
 
10
43
  export default function AgentsSkillsSection({
11
44
  copy,
@@ -14,156 +47,741 @@ export default function AgentsSkillsSection({
14
47
  }: {
15
48
  copy: {
16
49
  title: string;
17
- capabilityGroups: string;
18
- tabs: {
19
- manage: string;
20
- matrix: string;
21
- };
50
+ summaryEnabled: (n: number) => string;
51
+ summaryDisabled: (n: number) => string;
52
+ summaryAttention: (n: number) => string;
53
+ summaryNative: (n: number) => string;
54
+ tabs: { bySkill: string; byAgent: string; [k: string]: string };
22
55
  searchPlaceholder: string;
23
56
  sourceAll: string;
24
57
  sourceBuiltin: string;
25
58
  sourceUser: string;
26
- emptyGroup: string;
59
+ sourceNative: string;
60
+ statusAll: string;
61
+ noSkillsMatchFilter: string;
62
+ statusEnabled: string;
63
+ statusDisabled: string;
64
+ statusAttention: string;
65
+ capabilityAll: string;
66
+ bulkEnableFiltered: string;
67
+ bulkDisableFiltered: string;
68
+ bulkRunning: string;
69
+ bulkNoChanges: string;
70
+ bulkAllSucceeded: (n: number) => string;
71
+ bulkPartialFailed: (ok: number, failed: number) => string;
72
+ resultCount: (n: number) => string;
73
+ agentNativeSkills: string;
74
+ agentMindosSkills: string;
75
+ noAgentsYet: string;
76
+ moreSkills: (n: number) => string;
77
+ addAgentToSkill: string;
78
+ removeAgentFromSkill: string;
79
+ confirmRemoveAgentTitle: string;
80
+ confirmRemoveAgentMessage: (agent: string, skill: string) => string;
81
+ cancelSkillAction: string;
82
+ noAvailableAgentsForSkill: string;
83
+ manualSkillHint: string;
84
+ skillDescription: string;
85
+ skillNoDescription: string;
86
+ skillAgentCount: (n: number) => string;
87
+ skillDeleteAction: string;
88
+ confirmDeleteSkillTitle: string;
89
+ confirmDeleteSkillMessage: (name: string) => string;
90
+ skillDeleted: string;
91
+ skillDeleteFailed: string;
92
+ copyInstallCmd: string;
93
+ installCmdCopied: string;
94
+ quickStatsMcp: (n: number) => string;
95
+ quickStatsSkills: (n: number) => string;
96
+ showAllNative: (n: number) => string;
97
+ collapseNative: string;
27
98
  groupLabels: Record<'research' | 'coding' | 'docs' | 'ops' | 'memory', string>;
99
+ skillPopover: {
100
+ close: string;
101
+ source: string;
102
+ sourceBuiltin: string;
103
+ sourceUser: string;
104
+ sourceNative: string;
105
+ capability: string;
106
+ path: string;
107
+ enabled: string;
108
+ disabled: string;
109
+ agents: string;
110
+ noAgents: string;
111
+ content: string;
112
+ loading: string;
113
+ loadFailed: string;
114
+ retry: string;
115
+ copyContent: string;
116
+ copied: string;
117
+ noDescription: string;
118
+ deleteSkill: string;
119
+ confirmDeleteTitle: string;
120
+ confirmDeleteMessage: (name: string) => string;
121
+ confirmDeleteAction: string;
122
+ cancelAction: string;
123
+ deleted: string;
124
+ deleteFailed: string;
125
+ };
126
+ [k: string]: unknown;
28
127
  };
29
128
  mcp: McpContextValue;
30
129
  buckets: AgentBuckets;
31
130
  }) {
131
+ const [view, setView] = useState<SkillView>('bySkill');
32
132
  const [query, setQuery] = useState('');
33
- const [source, setSource] = useState<SkillSourceFilter>('all');
34
- const [view, setView] = useState<'manage' | 'matrix'>('manage');
133
+ const [source, setSource] = useState<UnifiedSourceFilter>('all');
134
+ const [status, setStatus] = useState<SkillWorkspaceStatusFilter>('all');
135
+ const [capability, setCapability] = useState<SkillCapabilityFilter>('all');
136
+ const [bulkRunning, setBulkRunning] = useState(false);
137
+ const [bulkMessage, setBulkMessage] = useState<string | null>(null);
138
+ const [detailSkill, setDetailSkill] = useState<string | null>(null);
139
+
140
+ const crossAgentSkills = useMemo(() => aggregateCrossAgentSkills(mcp.agents), [mcp.agents]);
141
+ const sortedAgents = useMemo(() => sortAgentsByStatus(mcp.agents), [mcp.agents]);
142
+
143
+ const unified = useMemo(
144
+ () => buildUnifiedSkillList(mcp.skills, crossAgentSkills),
145
+ [mcp.skills, crossAgentSkills],
146
+ );
147
+
148
+ const enabledCount = useMemo(() => mcp.skills.filter((s) => s.enabled).length, [mcp.skills]);
149
+ const disabledCount = useMemo(() => mcp.skills.filter((s) => !s.enabled).length, [mcp.skills]);
150
+ const attentionCount = useMemo(() => buildSkillAttentionSet(mcp.skills).size, [mcp.skills]);
151
+ const nativeCount = useMemo(() => unified.filter((s) => s.kind === 'native').length, [unified]);
152
+
153
+ const filtered = useMemo(
154
+ () => filterUnifiedSkills(unified, { query, source, status, capability }),
155
+ [unified, query, source, status, capability],
156
+ );
157
+ const grouped = useMemo(() => groupUnifiedSkills(filtered), [filtered]);
158
+
159
+ const capabilityOptions = useMemo(
160
+ () => (['research', 'coding', 'docs', 'ops', 'memory'] as const).map((key) => ({ key, label: copy.groupLabels[key] })),
161
+ [copy.groupLabels],
162
+ );
163
+
164
+ const detailUnified = useMemo(
165
+ () => (detailSkill ? unified.find((s) => s.name === detailSkill) ?? null : null),
166
+ [detailSkill, unified],
167
+ );
168
+ const detailSkillInfo = useMemo(
169
+ () => (detailSkill ? mcp.skills.find((s) => s.name === detailSkill) ?? null : null),
170
+ [detailSkill, mcp.skills],
171
+ );
172
+
173
+ const handleDeleteFromPopover = useCallback(async (name: string) => {
174
+ await apiFetch('/api/skills', {
175
+ method: 'POST',
176
+ headers: { 'Content-Type': 'application/json' },
177
+ body: JSON.stringify({ action: 'delete', name }),
178
+ });
179
+ window.dispatchEvent(new Event('mindos:skills-changed'));
180
+ await mcp.refresh();
181
+ }, [mcp]);
35
182
 
36
- const filtered = useMemo(() => filterSkills(mcp.skills, query, source), [mcp.skills, query, source]);
37
- const grouped = useMemo(() => groupSkillsByCapability(filtered), [filtered]);
38
- const knownAgents = useMemo(() => [...buckets.connected, ...buckets.detected, ...buckets.notFound], [buckets]);
183
+ const runBulkToggle = async (targetEnabled: boolean) => {
184
+ if (bulkRunning) return;
185
+ const plan = createBulkUnifiedTogglePlan(filtered, targetEnabled);
186
+ if (plan.length === 0) {
187
+ setBulkMessage(copy.bulkNoChanges);
188
+ return;
189
+ }
190
+ setBulkRunning(true);
191
+ setBulkMessage(copy.bulkRunning);
192
+ const results: Array<{ skillName: string; ok: boolean }> = [];
193
+ for (const skillName of plan) {
194
+ const ok = await mcp.toggleSkill(skillName, targetEnabled);
195
+ results.push({ skillName, ok });
196
+ }
197
+ const summary = summarizeBulkSkillToggleResults(results);
198
+ setBulkMessage(
199
+ summary.failed === 0
200
+ ? copy.bulkAllSucceeded(summary.succeeded)
201
+ : copy.bulkPartialFailed(summary.succeeded, summary.failed),
202
+ );
203
+ setBulkRunning(false);
204
+ };
39
205
 
40
206
  return (
41
- <section className="rounded-lg border border-border bg-card p-4 space-y-4">
207
+ <section className="space-y-4 overflow-hidden" aria-label={copy.title}>
208
+ {/* Header */}
42
209
  <div className="flex items-center justify-between gap-3">
43
- <h2 className="text-sm font-medium text-foreground mb-1">{copy.title}</h2>
44
- <div className="flex items-center gap-1 rounded-md border border-border p-1 bg-background">
45
- <SectionTabButton active={view === 'manage'} label={copy.tabs.manage} onClick={() => setView('manage')} />
46
- <SectionTabButton active={view === 'matrix'} label={copy.tabs.matrix} onClick={() => setView('matrix')} />
210
+ <h2 className="text-sm font-medium text-foreground">{copy.title}</h2>
211
+ <div className="flex items-center gap-1 rounded-md border border-border p-0.5 bg-background" role="tablist" aria-label={copy.title}>
212
+ <PillButton active={view === 'bySkill'} label={copy.tabs.bySkill} onClick={() => setView('bySkill')} />
213
+ <PillButton active={view === 'byAgent'} label={copy.tabs.byAgent} onClick={() => setView('byAgent')} />
47
214
  </div>
48
215
  </div>
49
- <p className="text-xs text-muted-foreground">{copy.capabilityGroups}</p>
50
-
51
- {view === 'manage' ? (
52
- <>
53
- <div className="flex flex-col md:flex-row gap-2">
54
- <label className="relative flex-1">
55
- <Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
56
- <input
57
- value={query}
58
- onChange={(e) => setQuery(e.target.value)}
59
- placeholder={copy.searchPlaceholder}
60
- 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"
61
- />
62
- </label>
63
- <div className="flex items-center gap-1 rounded-md border border-border p-1 bg-background">
64
- <SourceFilterButton active={source === 'all'} label={copy.sourceAll} onClick={() => setSource('all')} />
65
- <SourceFilterButton active={source === 'builtin'} label={copy.sourceBuiltin} onClick={() => setSource('builtin')} />
66
- <SourceFilterButton active={source === 'user'} label={copy.sourceUser} onClick={() => setSource('user')} />
67
- </div>
68
- </div>
69
216
 
70
- <div className="space-y-4">
71
- {Object.entries(grouped).map(([groupKey, skills]) => (
72
- <div key={groupKey} className="rounded-md border border-border p-3">
73
- <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
74
- {copy.groupLabels[groupKey as keyof typeof copy.groupLabels]} ({skills.length})
75
- </div>
76
- {skills.length === 0 ? (
77
- <p className="text-xs text-muted-foreground">{copy.emptyGroup}</p>
78
- ) : (
79
- <div className="space-y-1.5">
80
- {skills.map((skill) => (
81
- <div key={skill.name} className="flex items-center justify-between gap-2">
82
- <div className="min-w-0">
83
- <p className="text-sm text-foreground truncate">{skill.name}</p>
84
- <p className="text-2xs text-muted-foreground">{skill.source === 'builtin' ? copy.sourceBuiltin : copy.sourceUser}</p>
217
+ {/* Compact status strip */}
218
+ <div className="rounded-lg border border-border bg-card p-3">
219
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
220
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
221
+ <span className="w-1.5 h-1.5 rounded-full bg-[var(--success)]" aria-hidden="true" />
222
+ {copy.summaryEnabled(enabledCount)}
223
+ </span>
224
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
225
+ <span className="w-1.5 h-1.5 rounded-full bg-muted-foreground" aria-hidden="true" />
226
+ {copy.summaryDisabled(disabledCount)}
227
+ </span>
228
+ {attentionCount > 0 && (
229
+ <span className="inline-flex items-center gap-1.5 text-[var(--amber)]">
230
+ <span className="w-1.5 h-1.5 rounded-full bg-[var(--amber)]" aria-hidden="true" />
231
+ {copy.summaryAttention(attentionCount)}
232
+ </span>
233
+ )}
234
+ {nativeCount > 0 && (
235
+ <>
236
+ <span className="text-muted-foreground/40" aria-hidden="true">|</span>
237
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
238
+ <span className="w-1.5 h-1.5 rounded-full bg-[var(--amber)]" aria-hidden="true" />
239
+ {copy.summaryNative(nativeCount)}
240
+ </span>
241
+ </>
242
+ )}
243
+ </div>
244
+ </div>
245
+
246
+ {/* Search */}
247
+ <SearchInput
248
+ value={query}
249
+ onChange={setQuery}
250
+ placeholder={copy.searchPlaceholder}
251
+ ariaLabel={copy.searchPlaceholder}
252
+ icon={Search}
253
+ />
254
+
255
+ {view === 'bySkill' ? (
256
+ <BySkillView
257
+ copy={copy}
258
+ filtered={filtered}
259
+ grouped={grouped}
260
+ allAgents={sortedAgents}
261
+ source={source}
262
+ status={status}
263
+ capability={capability}
264
+ capabilityOptions={capabilityOptions}
265
+ bulkRunning={bulkRunning}
266
+ bulkMessage={bulkMessage}
267
+ onSourceChange={setSource}
268
+ onStatusChange={setStatus}
269
+ onCapabilityChange={setCapability}
270
+ onBulkToggle={runBulkToggle}
271
+ onToggleSkill={mcp.toggleSkill}
272
+ onRefresh={mcp.refresh}
273
+ onOpenDetail={setDetailSkill}
274
+ />
275
+ ) : (
276
+ <ByAgentView
277
+ copy={copy}
278
+ agents={sortedAgents}
279
+ skills={mcp.skills}
280
+ crossAgentSkills={crossAgentSkills}
281
+ query={query}
282
+ onToggleSkill={mcp.toggleSkill}
283
+ onOpenDetail={setDetailSkill}
284
+ />
285
+ )}
286
+
287
+ {/* Skill detail popover */}
288
+ <SkillDetailPopover
289
+ open={detailSkill !== null}
290
+ skillName={detailSkill}
291
+ skill={detailSkillInfo}
292
+ agentNames={detailUnified?.agents ?? []}
293
+ isNative={detailUnified?.kind === 'native'}
294
+ nativeSourcePath={
295
+ detailUnified?.kind === 'native' && detailUnified.agents.length > 0
296
+ ? mcp.agents.find((a) => a.name === detailUnified.agents[0])?.installedSkillSourcePath
297
+ : undefined
298
+ }
299
+ copy={copy.skillPopover}
300
+ onClose={() => setDetailSkill(null)}
301
+ onToggle={mcp.toggleSkill}
302
+ onDelete={handleDeleteFromPopover}
303
+ onRefresh={mcp.refresh}
304
+ />
305
+ </section>
306
+ );
307
+ }
308
+
309
+ /* ────────── By Skill View (Unified: MindOS + Native) ────────── */
310
+
311
+ function BySkillView({
312
+ copy,
313
+ filtered,
314
+ grouped,
315
+ allAgents,
316
+ source,
317
+ status,
318
+ capability,
319
+ capabilityOptions,
320
+ bulkRunning,
321
+ bulkMessage,
322
+ onSourceChange,
323
+ onStatusChange,
324
+ onCapabilityChange,
325
+ onBulkToggle,
326
+ onToggleSkill,
327
+ onRefresh,
328
+ onOpenDetail,
329
+ }: {
330
+ copy: Parameters<typeof AgentsSkillsSection>[0]['copy'];
331
+ filtered: UnifiedSkillItem[];
332
+ grouped: ReturnType<typeof groupUnifiedSkills>;
333
+ allAgents: ReturnType<typeof sortAgentsByStatus>;
334
+ source: UnifiedSourceFilter;
335
+ status: SkillWorkspaceStatusFilter;
336
+ capability: SkillCapabilityFilter;
337
+ capabilityOptions: Array<{ key: string; label: string }>;
338
+ bulkRunning: boolean;
339
+ bulkMessage: string | null;
340
+ onSourceChange: (s: UnifiedSourceFilter) => void;
341
+ onStatusChange: (s: SkillWorkspaceStatusFilter) => void;
342
+ onCapabilityChange: (s: SkillCapabilityFilter) => void;
343
+ onBulkToggle: (enabled: boolean) => Promise<void>;
344
+ onToggleSkill: (name: string, enabled: boolean) => Promise<boolean>;
345
+ onRefresh: () => Promise<void>;
346
+ onOpenDetail: (name: string) => void;
347
+ }) {
348
+ const [confirmAgentRemove, setConfirmAgentRemove] = useState<{ agentName: string; skillName: string } | null>(null);
349
+ const [confirmSkillDelete, setConfirmSkillDelete] = useState<string | null>(null);
350
+ const [pickerSkill, setPickerSkill] = useState<string | null>(null);
351
+ const [hintMessage, setHintMessage] = useState<string | null>(null);
352
+ const [deleteBusy, setDeleteBusy] = useState<string | null>(null);
353
+
354
+ const handleConfirmAgentRemove = useCallback(() => {
355
+ setConfirmAgentRemove(null);
356
+ setHintMessage(copy.manualSkillHint);
357
+ setTimeout(() => setHintMessage(null), 4000);
358
+ }, [copy.manualSkillHint]);
359
+
360
+ const handleDeleteSkill = useCallback(async (name: string) => {
361
+ setConfirmSkillDelete(null);
362
+ setDeleteBusy(name);
363
+ try {
364
+ await apiFetch('/api/skills', {
365
+ method: 'POST',
366
+ headers: { 'Content-Type': 'application/json' },
367
+ body: JSON.stringify({ action: 'delete', name }),
368
+ });
369
+ setHintMessage(copy.skillDeleted);
370
+ window.dispatchEvent(new Event('mindos:skills-changed'));
371
+ await onRefresh();
372
+ } catch {
373
+ setHintMessage(copy.skillDeleteFailed);
374
+ } finally {
375
+ setDeleteBusy(null);
376
+ setTimeout(() => setHintMessage(null), 3000);
377
+ }
378
+ }, [copy.skillDeleted, copy.skillDeleteFailed, onRefresh]);
379
+
380
+ const sortedGrouped = useMemo(() => {
381
+ const entries: Array<[string, UnifiedSkillItem[]]> = [];
382
+ for (const [key, skills] of Object.entries(grouped)) {
383
+ if (skills.length === 0) continue;
384
+ const sorted = [...skills].sort((a, b) => {
385
+ if (a.kind !== b.kind) return a.kind === 'mindos' ? -1 : 1;
386
+ if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
387
+ return a.name.localeCompare(b.name);
388
+ });
389
+ entries.push([key, sorted]);
390
+ }
391
+ return entries;
392
+ }, [grouped]);
393
+
394
+ return (
395
+ <>
396
+ {/* Filters */}
397
+ <div className="flex flex-wrap gap-2">
398
+ <div role="group" aria-label="Source" className="flex items-center gap-0.5 rounded-md border border-border p-0.5 bg-background">
399
+ <PillButton active={source === 'all'} label={copy.sourceAll} onClick={() => onSourceChange('all')} />
400
+ <PillButton active={source === 'builtin'} label={copy.sourceBuiltin} onClick={() => onSourceChange('builtin')} />
401
+ <PillButton active={source === 'user'} label={copy.sourceUser} onClick={() => onSourceChange('user')} />
402
+ <PillButton active={source === 'native'} label={copy.sourceNative} onClick={() => onSourceChange('native')} />
403
+ </div>
404
+ <div role="group" aria-label="Status" className="flex items-center gap-0.5 rounded-md border border-border p-0.5 bg-background">
405
+ <PillButton active={status === 'all'} label={copy.statusAll} onClick={() => onStatusChange('all')} />
406
+ <PillButton active={status === 'enabled'} label={copy.statusEnabled} onClick={() => onStatusChange('enabled')} />
407
+ <PillButton active={status === 'disabled'} label={copy.statusDisabled} onClick={() => onStatusChange('disabled')} />
408
+ <PillButton active={status === 'attention'} label={copy.statusAttention} onClick={() => onStatusChange('attention')} />
409
+ </div>
410
+ <div role="group" aria-label="Capability" className="flex items-center gap-0.5 rounded-md border border-border p-0.5 bg-background">
411
+ <PillButton active={capability === 'all'} label={copy.capabilityAll} onClick={() => onCapabilityChange('all')} />
412
+ {capabilityOptions.map((opt) => (
413
+ <PillButton key={opt.key} active={capability === opt.key} label={opt.label} onClick={() => onCapabilityChange(opt.key as SkillCapabilityFilter)} />
414
+ ))}
415
+ </div>
416
+ </div>
417
+
418
+ {/* Bulk actions (only affect MindOS skills) */}
419
+ <div className="flex flex-wrap items-center gap-2">
420
+ <ActionButton
421
+ onClick={() => void onBulkToggle(true)}
422
+ disabled={bulkRunning}
423
+ busy={bulkRunning}
424
+ label={copy.bulkEnableFiltered}
425
+ />
426
+ <ActionButton
427
+ onClick={() => void onBulkToggle(false)}
428
+ disabled={bulkRunning}
429
+ busy={false}
430
+ label={copy.bulkDisableFiltered}
431
+ />
432
+ <span className="text-2xs text-muted-foreground tabular-nums">{copy.resultCount(filtered.length)}</span>
433
+ <BulkMessage message={bulkMessage} />
434
+ </div>
435
+
436
+ {hintMessage && (
437
+ <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">
438
+ {hintMessage}
439
+ </div>
440
+ )}
441
+
442
+ {/* Grouped unified skill list */}
443
+ {sortedGrouped.length === 0 ? (
444
+ <EmptyState message={copy.noSkillsMatchFilter} />
445
+ ) : (
446
+ <div className="space-y-3">
447
+ {sortedGrouped.map(([groupKey, sortedSkills]) => (
448
+ <div key={groupKey}>
449
+ <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
450
+ {copy.groupLabels[groupKey as keyof typeof copy.groupLabels]} <span className="tabular-nums">({sortedSkills.length})</span>
451
+ </div>
452
+ <div className="space-y-3">
453
+ {sortedSkills.map((skill) => {
454
+ const availableAgents = allAgents
455
+ .filter((a) => !skill.agents.includes(a.name))
456
+ .map((a) => ({ key: a.key, name: a.name }));
457
+ const isUserSkill = skill.kind === 'mindos' && skill.source === 'user';
458
+
459
+ return (
460
+ <div key={skill.name} className="rounded-lg border border-border bg-card p-4 hover:border-border/60 hover:shadow-sm transition-all duration-150">
461
+ {/* Skill header */}
462
+ <div className="flex items-center justify-between gap-2 mb-2">
463
+ <div className="flex items-center gap-2 min-w-0">
464
+ <Zap size={14} className={`shrink-0 ${skill.enabled ? 'text-[var(--amber)]' : 'text-muted-foreground/50'}`} aria-hidden="true" />
465
+ <button
466
+ type="button"
467
+ onClick={() => onOpenDetail(skill.name)}
468
+ className="text-sm font-medium text-foreground truncate hover:text-[var(--amber)] cursor-pointer transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded text-left"
469
+ >
470
+ {skill.name}
471
+ </button>
472
+ <span className={`text-2xs shrink-0 px-1.5 py-0.5 rounded ${
473
+ skill.kind === 'native'
474
+ ? 'bg-muted text-muted-foreground'
475
+ : skill.source === 'builtin'
476
+ ? 'bg-muted text-muted-foreground'
477
+ : 'bg-[var(--amber-dim)] text-[var(--amber)]'
478
+ }`}>
479
+ {skill.kind === 'native' ? copy.sourceNative : skill.source === 'builtin' ? copy.sourceBuiltin : copy.sourceUser}
480
+ </span>
481
+ </div>
482
+ <div className="flex items-center gap-1.5 shrink-0">
483
+ {isUserSkill && (
484
+ <button
485
+ type="button"
486
+ onClick={(e) => { e.stopPropagation(); setConfirmSkillDelete(skill.name); }}
487
+ disabled={deleteBusy === skill.name}
488
+ className="text-muted-foreground hover:text-destructive cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded p-1 disabled:opacity-50 transition-colors duration-150"
489
+ aria-label={`${copy.skillDeleteAction} ${skill.name}`}
490
+ >
491
+ <Trash2 size={14} />
492
+ </button>
493
+ )}
494
+ {skill.kind === 'mindos' ? (
495
+ <Toggle size="sm" checked={skill.enabled} onChange={(v) => void onToggleSkill(skill.name, v)} />
496
+ ) : (
497
+ <span className="text-2xs text-muted-foreground/60 select-none" aria-label="read-only">—</span>
498
+ )}
499
+ <div className="relative">
500
+ <AddAvatarButton
501
+ onClick={() => setPickerSkill(pickerSkill === skill.name ? null : skill.name)}
502
+ label={copy.addAgentToSkill}
503
+ size="sm"
504
+ />
505
+ <AgentPickerPopover
506
+ open={pickerSkill === skill.name}
507
+ agents={availableAgents}
508
+ emptyLabel={copy.noAvailableAgentsForSkill}
509
+ onSelect={() => {
510
+ setPickerSkill(null);
511
+ setHintMessage(copy.manualSkillHint);
512
+ setTimeout(() => setHintMessage(null), 4000);
513
+ }}
514
+ onClose={() => setPickerSkill(null)}
515
+ />
516
+ </div>
85
517
  </div>
86
- <Toggle size="sm" checked={skill.enabled} onChange={(v) => void mcp.toggleSkill(skill.name, v)} />
87
518
  </div>
88
- ))}
89
- </div>
90
- )}
519
+
520
+ {/* Agent count */}
521
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-muted-foreground mb-3">
522
+ <span className="tabular-nums">{copy.skillAgentCount(skill.agents.length)}</span>
523
+ </div>
524
+
525
+ {/* Agent avatar grid */}
526
+ <div className="flex flex-wrap items-center gap-2">
527
+ {skill.agents.map((name) => (
528
+ <AgentAvatar
529
+ key={name}
530
+ name={name}
531
+ onRemove={() => setConfirmAgentRemove({ agentName: name, skillName: skill.name })}
532
+ />
533
+ ))}
534
+ </div>
535
+ </div>
536
+ );
537
+ })}
91
538
  </div>
92
- ))}
93
- </div>
94
- </>
95
- ) : (
96
- <div className="rounded-md border border-border bg-background p-3">
97
- <div className="overflow-x-auto">
98
- <table className="w-full text-xs">
99
- <thead>
100
- <tr className="border-b border-border">
101
- <th className="text-left py-2 text-muted-foreground font-medium">Skill</th>
102
- {knownAgents.map((agent) => (
103
- <th key={agent.key} className="text-left py-2 text-muted-foreground font-medium pr-3">{agent.name}</th>
104
- ))}
105
- </tr>
106
- </thead>
107
- <tbody>
108
- {filtered.map((skill) => (
109
- <tr key={skill.name} className="border-b border-border/40">
110
- <td className="py-2 text-foreground pr-3">{skill.name}</td>
111
- {knownAgents.map((agent) => (
112
- <td key={`${skill.name}:${agent.key}`} className="py-2 pr-3 text-muted-foreground">
113
- {agent.present ? (skill.enabled ? 'Enabled' : 'Disabled') : 'Unsupported'}
114
- </td>
115
- ))}
116
- </tr>
117
- ))}
118
- </tbody>
119
- </table>
120
- </div>
539
+ </div>
540
+ ))}
121
541
  </div>
122
542
  )}
123
- </section>
543
+
544
+ {/* Confirm: remove agent from skill */}
545
+ <ConfirmDialog
546
+ open={confirmAgentRemove !== null}
547
+ title={copy.confirmRemoveAgentTitle}
548
+ message={confirmAgentRemove ? copy.confirmRemoveAgentMessage(confirmAgentRemove.agentName, confirmAgentRemove.skillName) : ''}
549
+ confirmLabel={copy.removeAgentFromSkill}
550
+ cancelLabel={copy.cancelSkillAction}
551
+ onConfirm={handleConfirmAgentRemove}
552
+ onCancel={() => setConfirmAgentRemove(null)}
553
+ variant="destructive"
554
+ />
555
+
556
+ {/* Confirm: delete skill */}
557
+ <ConfirmDialog
558
+ open={confirmSkillDelete !== null}
559
+ title={copy.confirmDeleteSkillTitle}
560
+ message={confirmSkillDelete ? copy.confirmDeleteSkillMessage(confirmSkillDelete) : ''}
561
+ confirmLabel={copy.skillDeleteAction}
562
+ cancelLabel={copy.cancelSkillAction}
563
+ onConfirm={() => confirmSkillDelete && void handleDeleteSkill(confirmSkillDelete)}
564
+ onCancel={() => setConfirmSkillDelete(null)}
565
+ variant="destructive"
566
+ />
567
+ </>
124
568
  );
125
569
  }
126
570
 
127
- function SourceFilterButton({
128
- active,
129
- label,
130
- onClick,
571
+ /* ────────── By Agent View ────────── */
572
+
573
+ function ByAgentView({
574
+ copy,
575
+ agents,
576
+ skills,
577
+ crossAgentSkills,
578
+ query,
579
+ onToggleSkill,
580
+ onOpenDetail,
131
581
  }: {
132
- active: boolean;
133
- label: string;
134
- onClick: () => void;
582
+ copy: Parameters<typeof AgentsSkillsSection>[0]['copy'];
583
+ agents: ReturnType<typeof sortAgentsByStatus>;
584
+ skills: McpContextValue['skills'];
585
+ crossAgentSkills: ReturnType<typeof aggregateCrossAgentSkills>;
586
+ query: string;
587
+ onToggleSkill: (name: string, enabled: boolean) => Promise<boolean>;
588
+ onOpenDetail: (name: string) => void;
135
589
  }) {
590
+ const q = query.trim().toLowerCase();
591
+
592
+ const filteredAgents = useMemo(() => {
593
+ if (!q) return agents;
594
+ return agents.filter((a) => {
595
+ const haystack = `${a.name} ${a.key}`.toLowerCase();
596
+ if (haystack.includes(q)) return true;
597
+ const nativeSkills = a.installedSkillNames ?? [];
598
+ return nativeSkills.some((s) => s.toLowerCase().includes(q));
599
+ });
600
+ }, [agents, q]);
601
+
602
+ const filteredSkills = useMemo(() => {
603
+ if (!q) return skills;
604
+ return skills.filter((s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
605
+ }, [skills, q]);
606
+
607
+ if (filteredAgents.length === 0) {
608
+ return <EmptyState message={copy.noAgentsYet} />;
609
+ }
610
+
136
611
  return (
137
- <button
138
- type="button"
139
- onClick={onClick}
140
- className={`px-2.5 h-7 rounded text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
141
- active ? 'bg-[var(--amber-dim)] text-[var(--amber)]' : 'text-muted-foreground hover:text-foreground hover:bg-muted'
142
- }`}
143
- >
144
- {label}
145
- </button>
612
+ <div className="space-y-3">
613
+ {filteredAgents.map((agent) => {
614
+ const agentStatus = resolveAgentStatus(agent);
615
+ const nativeSkills = (agent.installedSkillNames ?? []).sort();
616
+ const mcpServers = agent.configuredMcpServers ?? [];
617
+ const agentMindosSkills = (agent.present ? filteredSkills : [])
618
+ .slice()
619
+ .sort((a, b) => {
620
+ if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
621
+ return a.name.localeCompare(b.name);
622
+ });
623
+ const totalSkills = nativeSkills.length + agentMindosSkills.length;
624
+
625
+ return (
626
+ <AgentCard
627
+ key={agent.key}
628
+ agentKey={agent.key}
629
+ name={agent.name}
630
+ status={agentStatus}
631
+ skillMode={agent.skillMode}
632
+ mcpCount={mcpServers.length}
633
+ totalSkills={totalSkills}
634
+ nativeSkills={nativeSkills}
635
+ mindosSkills={agentMindosSkills}
636
+ copy={copy}
637
+ onToggleSkill={onToggleSkill}
638
+ onOpenDetail={onOpenDetail}
639
+ />
640
+ );
641
+ })}
642
+ </div>
146
643
  );
147
644
  }
148
645
 
149
- function SectionTabButton({
150
- active,
151
- label,
152
- onClick,
646
+ /* ────────── Agent Card (Skills ByAgent) ────────── */
647
+
648
+ function AgentCard({
649
+ agentKey,
650
+ name,
651
+ status,
652
+ skillMode,
653
+ mcpCount,
654
+ totalSkills,
655
+ nativeSkills,
656
+ mindosSkills,
657
+ copy,
658
+ onToggleSkill,
659
+ onOpenDetail,
153
660
  }: {
154
- active: boolean;
155
- label: string;
156
- onClick: () => void;
661
+ agentKey: string;
662
+ name: string;
663
+ status: 'connected' | 'detected' | 'notFound';
664
+ skillMode: string | undefined;
665
+ mcpCount: number;
666
+ totalSkills: number;
667
+ nativeSkills: string[];
668
+ mindosSkills: McpContextValue['skills'];
669
+ copy: Parameters<typeof AgentsSkillsSection>[0]['copy'];
670
+ onToggleSkill: (name: string, enabled: boolean) => Promise<boolean>;
671
+ onOpenDetail: (name: string) => void;
157
672
  }) {
673
+ const [nativeExpanded, setNativeExpanded] = useState(false);
674
+ const NATIVE_COLLAPSE_THRESHOLD = 6;
675
+ const showNativeToggle = nativeSkills.length > NATIVE_COLLAPSE_THRESHOLD;
676
+ const visibleNative = nativeExpanded ? nativeSkills : nativeSkills.slice(0, NATIVE_COLLAPSE_THRESHOLD);
677
+
158
678
  return (
159
- <button
160
- type="button"
161
- onClick={onClick}
162
- className={`px-2.5 h-7 rounded text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
163
- active ? 'bg-[var(--amber-dim)] text-[var(--amber)]' : 'text-muted-foreground hover:text-foreground hover:bg-muted'
164
- }`}
165
- >
166
- {label}
167
- </button>
679
+ <div className="rounded-lg border border-border bg-card hover:border-border/60 hover:shadow-sm transition-all duration-150 overflow-hidden">
680
+ {/* Card header with avatar */}
681
+ <div className="flex items-center gap-3 p-4 pb-0">
682
+ <AgentAvatar name={name} status={status} />
683
+ <div className="min-w-0 flex-1">
684
+ <div className="flex items-center gap-2">
685
+ <Link href={`/agents/${encodeURIComponent(agentKey)}`} className="text-sm font-medium text-foreground hover:underline cursor-pointer truncate">
686
+ {name}
687
+ </Link>
688
+ {skillMode && (
689
+ <span className={`text-2xs px-1.5 py-0.5 rounded shrink-0 ${
690
+ skillMode === 'universal' ? 'bg-success/10 text-success'
691
+ : skillMode === 'additional' ? 'bg-[var(--amber-dim)] text-[var(--amber)]'
692
+ : 'bg-muted text-muted-foreground'
693
+ }`}>
694
+ {skillMode}
695
+ </span>
696
+ )}
697
+ </div>
698
+ <div className="flex items-center gap-2 mt-0.5 text-2xs text-muted-foreground">
699
+ <span className="tabular-nums">{copy.quickStatsMcp(mcpCount)}</span>
700
+ <span aria-hidden="true">·</span>
701
+ <span className="tabular-nums">{copy.quickStatsSkills(totalSkills)}</span>
702
+ </div>
703
+ </div>
704
+ </div>
705
+
706
+ {/* Skill sections */}
707
+ <div className="p-4 pt-3 space-y-3">
708
+ {/* Native skills */}
709
+ <div>
710
+ <div className="flex items-center justify-between mb-2">
711
+ <p className="text-2xs font-medium text-muted-foreground uppercase tracking-wider">
712
+ {copy.agentNativeSkills} <span className="tabular-nums">({nativeSkills.length})</span>
713
+ </p>
714
+ {showNativeToggle && (
715
+ <button
716
+ type="button"
717
+ onClick={() => setNativeExpanded(!nativeExpanded)}
718
+ className="text-2xs text-muted-foreground hover:text-foreground cursor-pointer flex items-center gap-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded transition-colors duration-150"
719
+ >
720
+ {nativeExpanded ? (
721
+ <><ChevronDown size={12} />{copy.collapseNative}</>
722
+ ) : (
723
+ <><ChevronRight size={12} />{copy.showAllNative(nativeSkills.length)}</>
724
+ )}
725
+ </button>
726
+ )}
727
+ </div>
728
+ {nativeSkills.length === 0 ? (
729
+ <p className="text-2xs text-muted-foreground/60">—</p>
730
+ ) : (
731
+ <div className="space-y-0.5 max-h-[200px] overflow-y-auto">
732
+ {visibleNative.map((n) => (
733
+ <button
734
+ key={n}
735
+ type="button"
736
+ onClick={() => onOpenDetail(n)}
737
+ className="w-full flex items-center gap-1.5 py-1 min-h-[28px] hover:bg-muted/30 -mx-1.5 px-1.5 rounded transition-colors duration-100 cursor-pointer text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
738
+ >
739
+ <Zap size={12} className="shrink-0 text-muted-foreground/50" aria-hidden="true" />
740
+ <span className="text-xs text-foreground truncate hover:text-[var(--amber)] transition-colors duration-150">{n}</span>
741
+ </button>
742
+ ))}
743
+ {!nativeExpanded && nativeSkills.length > NATIVE_COLLAPSE_THRESHOLD && (
744
+ <button
745
+ type="button"
746
+ onClick={() => setNativeExpanded(true)}
747
+ className="w-full text-left text-2xs text-muted-foreground hover:text-foreground py-1 px-1.5 -mx-1.5 cursor-pointer transition-colors duration-150"
748
+ >
749
+ +{nativeSkills.length - NATIVE_COLLAPSE_THRESHOLD}
750
+ </button>
751
+ )}
752
+ </div>
753
+ )}
754
+ </div>
755
+
756
+ {/* MindOS skills */}
757
+ {mindosSkills.length > 0 && (
758
+ <div>
759
+ <p className="text-2xs font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
760
+ {copy.agentMindosSkills} <span className="tabular-nums">({mindosSkills.filter((s) => s.enabled).length}/{mindosSkills.length})</span>
761
+ </p>
762
+ <div className="space-y-0.5 max-h-[240px] overflow-y-auto">
763
+ {mindosSkills.map((skill) => (
764
+ <div key={skill.name} className="flex items-center justify-between gap-2 py-1 min-h-[32px] hover:bg-muted/30 -mx-1.5 px-1.5 rounded transition-colors duration-100">
765
+ <div className="flex items-center gap-1.5 min-w-0">
766
+ <Zap size={12} className={`shrink-0 ${skill.enabled ? 'text-[var(--amber)]' : 'text-muted-foreground/50'}`} aria-hidden="true" />
767
+ <button
768
+ type="button"
769
+ onClick={() => onOpenDetail(skill.name)}
770
+ className="text-xs text-foreground truncate hover:text-[var(--amber)] cursor-pointer transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded text-left"
771
+ >
772
+ {skill.name}
773
+ </button>
774
+ <span className="text-2xs text-muted-foreground shrink-0">
775
+ {copy.groupLabels[capabilityForSkill(skill) as keyof typeof copy.groupLabels]}
776
+ </span>
777
+ </div>
778
+ <Toggle size="sm" checked={skill.enabled} onChange={(v) => void onToggleSkill(skill.name, v)} />
779
+ </div>
780
+ ))}
781
+ </div>
782
+ </div>
783
+ )}
784
+ </div>
785
+ </div>
168
786
  );
169
787
  }