@geminilight/mindos 0.6.32 → 0.6.33

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.
@@ -0,0 +1,318 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useMemo } from 'react';
4
+ import Link from 'next/link';
5
+ import { Bot, ChevronDown, ChevronUp, Wifi, WifiOff, Zap, ArrowRight } from 'lucide-react';
6
+ import { useMcpDataOptional } from '@/hooks/useMcpData';
7
+ import { useLocale } from '@/lib/LocaleContext';
8
+ import type { AgentInfo } from '@/components/settings/types';
9
+
10
+ /* ── Constants ── */
11
+
12
+ const COLLAPSE_KEY = 'mindos:pulse-collapsed';
13
+ const VISIBLE_AGENTS = 3;
14
+
15
+ /* ── Helpers ── */
16
+
17
+ /** Sort: connected first (by recent activity), then detected, then rest */
18
+ function sortAgents(agents: AgentInfo[]): AgentInfo[] {
19
+ return [...agents].sort((a, b) => {
20
+ const score = (ag: AgentInfo) => {
21
+ if (ag.installed) return 3;
22
+ if (ag.present) return 2;
23
+ return 0;
24
+ };
25
+ const diff = score(b) - score(a);
26
+ if (diff !== 0) return diff;
27
+ // Within same tier, sort by last activity
28
+ const ta = a.runtimeLastActivityAt ? new Date(a.runtimeLastActivityAt).getTime() : 0;
29
+ const tb = b.runtimeLastActivityAt ? new Date(b.runtimeLastActivityAt).getTime() : 0;
30
+ return tb - ta;
31
+ });
32
+ }
33
+
34
+ function activityAge(isoStr?: string): string | null {
35
+ if (!isoStr) return null;
36
+ const ms = Date.now() - new Date(isoStr).getTime();
37
+ if (ms < 60_000) return '<1m';
38
+ if (ms < 3600_000) return `${Math.floor(ms / 60_000)}m`;
39
+ if (ms < 86400_000) return `${Math.floor(ms / 3600_000)}h`;
40
+ return `${Math.floor(ms / 86400_000)}d`;
41
+ }
42
+
43
+ /** First letter(s) for avatar */
44
+ function initials(name: string): string {
45
+ const words = name.split(/[\s-]+/);
46
+ if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase();
47
+ return name.slice(0, 2).toUpperCase();
48
+ }
49
+
50
+ /* ── Tiny sub-components ── */
51
+
52
+ function AgentDot({ agent }: { agent: AgentInfo }) {
53
+ const isActive = agent.installed;
54
+ const isDetected = agent.present && !agent.installed;
55
+ return (
56
+ <div
57
+ className={`relative w-8 h-8 rounded-lg flex items-center justify-center text-xs font-semibold font-display shrink-0 transition-all duration-150 ${
58
+ isActive
59
+ ? 'bg-[var(--amber)]/10 text-[var(--amber-text)] ring-1 ring-[var(--amber)]/20'
60
+ : isDetected
61
+ ? 'bg-muted/80 text-muted-foreground ring-1 ring-border'
62
+ : 'bg-muted/40 text-muted-foreground/50 ring-1 ring-border/50'
63
+ }`}
64
+ title={agent.name}
65
+ >
66
+ {initials(agent.name)}
67
+ {/* Live indicator */}
68
+ {isActive && (
69
+ <span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-500 ring-2 ring-card" />
70
+ )}
71
+ </div>
72
+ );
73
+ }
74
+
75
+ function AgentRow({ agent, pulse }: { agent: AgentInfo; pulse: Record<string, any> }) {
76
+ const isActive = agent.installed;
77
+ const isDetected = agent.present && !agent.installed;
78
+ const age = activityAge(agent.runtimeLastActivityAt);
79
+
80
+ return (
81
+ <div className="flex items-center gap-3 py-1.5">
82
+ <AgentDot agent={agent} />
83
+ <div className="flex-1 min-w-0">
84
+ <span className={`text-sm font-medium block truncate ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}>
85
+ {agent.name}
86
+ </span>
87
+ <span className="text-xs text-muted-foreground/60 block">
88
+ {isActive ? pulse.active : isDetected ? pulse.detected : pulse.notFound}
89
+ {age && isActive ? ` · ${age}` : ''}
90
+ {isActive && agent.installedSkillCount ? ` · ${agent.installedSkillCount} skills` : ''}
91
+ </span>
92
+ </div>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ function StatChip({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
98
+ return (
99
+ <div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/30 min-w-0">
100
+ <span className="shrink-0 text-muted-foreground/60">{icon}</span>
101
+ <div className="min-w-0">
102
+ <span className="text-sm font-semibold tabular-nums text-foreground block">{value}</span>
103
+ <span className="text-xs text-muted-foreground/60 block">{label}</span>
104
+ </div>
105
+ </div>
106
+ );
107
+ }
108
+
109
+ /* ── Main Component ── */
110
+
111
+ export default function SystemPulse() {
112
+ const mcp = useMcpDataOptional();
113
+ const { t } = useLocale();
114
+ const pulse = t.pulse;
115
+ const [collapsed, setCollapsed] = useState(false);
116
+ const [showAll, setShowAll] = useState(false);
117
+
118
+ // Hydrate collapse state from localStorage
119
+ useEffect(() => {
120
+ const stored = localStorage.getItem(COLLAPSE_KEY);
121
+ if (stored !== null) setCollapsed(stored === '1');
122
+ }, []);
123
+
124
+ // All hooks MUST be above any early return (Rules of Hooks)
125
+ const agents = mcp?.agents ?? [];
126
+ const status = mcp?.status ?? null;
127
+ const skills = mcp?.skills ?? [];
128
+ const sorted = useMemo(() => sortAgents(agents), [agents]);
129
+ const connectedAgents = sorted.filter(a => a.installed);
130
+ const otherAgents = sorted.filter(a => !a.installed && a.present);
131
+ const mcpRunning = status?.running ?? false;
132
+ const enabledSkills = skills.filter(s => s.enabled).length;
133
+
134
+ const toggleCollapsed = () => {
135
+ const next = !collapsed;
136
+ setCollapsed(next);
137
+ localStorage.setItem(COLLAPSE_KEY, next ? '1' : '0');
138
+ };
139
+
140
+ // Don't render while loading
141
+ if (!mcp || mcp.loading) return null;
142
+
143
+ // ── State 0: No agents detected at all ──
144
+ if (agents.every(a => !a.present)) {
145
+ return (
146
+ <div className="mb-8 rounded-xl border border-dashed border-border/60 bg-gradient-to-r from-card to-card/60 p-5 transition-colors">
147
+ <div className="flex items-center gap-4">
148
+ <div className="w-10 h-10 rounded-xl bg-[var(--amber)]/10 flex items-center justify-center shrink-0">
149
+ <Bot size={18} className="text-[var(--amber)]" />
150
+ </div>
151
+ <div className="flex-1 min-w-0">
152
+ <p className="text-sm font-semibold text-foreground font-display">{pulse.connectTitle}</p>
153
+ <p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{pulse.connectDesc}</p>
154
+ </div>
155
+ <Link
156
+ href="/agents"
157
+ className="shrink-0 inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-semibold bg-[var(--amber)] text-[var(--amber-foreground)] transition-all duration-150 hover:opacity-90 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
158
+ >
159
+ {pulse.connectAction}
160
+ <ArrowRight size={12} />
161
+ </Link>
162
+ </div>
163
+ </div>
164
+ );
165
+ }
166
+
167
+ // Count for summary
168
+ const totalConnected = connectedAgents.length;
169
+ const totalDetected = otherAgents.length;
170
+
171
+ // ── Collapsed: elegant single-line ──
172
+ if (collapsed) {
173
+ return (
174
+ <button
175
+ onClick={toggleCollapsed}
176
+ className="mb-8 w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 bg-card/60 backdrop-blur-sm transition-all duration-150 hover:border-[var(--amber)]/30 hover:bg-card hover:shadow-sm cursor-pointer group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
177
+ >
178
+ {/* Agent avatars stack */}
179
+ <div className="flex items-center -space-x-1.5">
180
+ {connectedAgents.slice(0, 3).map(agent => (
181
+ <div
182
+ key={agent.key}
183
+ className="relative w-6 h-6 rounded-md bg-[var(--amber)]/10 text-[var(--amber-text)] ring-1 ring-[var(--amber)]/20 ring-offset-1 ring-offset-card flex items-center justify-center text-2xs font-bold font-display"
184
+ title={agent.name}
185
+ >
186
+ {initials(agent.name)}
187
+ </div>
188
+ ))}
189
+ {totalConnected > 3 && (
190
+ <div className="w-6 h-6 rounded-md bg-muted text-muted-foreground ring-1 ring-border ring-offset-1 ring-offset-card flex items-center justify-center text-2xs font-medium">
191
+ +{totalConnected - 3}
192
+ </div>
193
+ )}
194
+ </div>
195
+
196
+ <div className="flex items-center gap-2 text-xs text-muted-foreground min-w-0 flex-1">
197
+ <span className="font-medium text-foreground/80">
198
+ {pulse.summary(totalConnected, enabledSkills)}
199
+ </span>
200
+ {mcpRunning && (
201
+ <>
202
+ <span className="w-1 h-1 rounded-full bg-emerald-500 shrink-0" />
203
+ <span className="text-muted-foreground/50 hidden sm:inline">MCP</span>
204
+ </>
205
+ )}
206
+ </div>
207
+
208
+ <ChevronDown size={14} className="text-muted-foreground/30 group-hover:text-[var(--amber)] transition-colors shrink-0" />
209
+ </button>
210
+ );
211
+ }
212
+
213
+ // ── Expanded: polished card ──
214
+ const visibleAgents = showAll ? connectedAgents : connectedAgents.slice(0, VISIBLE_AGENTS);
215
+ const hiddenCount = connectedAgents.length - VISIBLE_AGENTS;
216
+
217
+ return (
218
+ <div className="mb-8 rounded-xl border border-border/40 bg-card overflow-hidden shadow-sm transition-all duration-200">
219
+
220
+ {/* ── Header ── */}
221
+ <div className="flex items-center gap-2.5 px-4 py-3">
222
+ <div className="flex items-center gap-2">
223
+ <div className="w-6 h-6 rounded-md bg-[var(--amber)]/10 flex items-center justify-center">
224
+ <Bot size={12} className="text-[var(--amber)]" />
225
+ </div>
226
+ <span className="text-xs font-semibold font-display text-foreground tracking-wide">
227
+ {pulse.title}
228
+ </span>
229
+ </div>
230
+
231
+ <div className="flex items-center gap-2 ml-auto">
232
+ <Link
233
+ href="/agents"
234
+ className="text-xs font-medium text-[var(--amber)] hover:opacity-80 transition-opacity font-display hidden sm:inline"
235
+ >
236
+ {pulse.manage} →
237
+ </Link>
238
+ <button
239
+ onClick={toggleCollapsed}
240
+ className="p-1.5 -mr-1 rounded-md hover:bg-muted transition-colors text-muted-foreground/60 hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
241
+ aria-label="Collapse"
242
+ >
243
+ <ChevronUp size={14} />
244
+ </button>
245
+ </div>
246
+ </div>
247
+
248
+ {/* ── Agent list (max 3, expandable) ── */}
249
+ <div className="px-4 pb-2">
250
+ <div className="space-y-0.5">
251
+ {visibleAgents.map(agent => (
252
+ <AgentRow key={agent.key} agent={agent} pulse={pulse} />
253
+ ))}
254
+ </div>
255
+
256
+ {/* Show more / less toggle */}
257
+ {hiddenCount > 0 && (
258
+ <button
259
+ onClick={() => setShowAll(v => !v)}
260
+ className="flex items-center gap-1.5 mt-1.5 text-xs font-medium text-[var(--amber)] hover:opacity-80 transition-opacity cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
261
+ >
262
+ <ChevronDown size={12} className={`transition-transform duration-200 ${showAll ? 'rotate-180' : ''}`} />
263
+ <span>{showAll ? pulse.showLess : pulse.showMore(hiddenCount)}</span>
264
+ </button>
265
+ )}
266
+
267
+ {/* Detected but not connected */}
268
+ {otherAgents.length > 0 && (
269
+ <div className="mt-2 pt-2 border-t border-border/20">
270
+ <div className="flex items-center gap-1.5 mb-1">
271
+ <span className="text-2xs font-medium text-muted-foreground/50 uppercase tracking-wider">{pulse.otherDetected}</span>
272
+ </div>
273
+ <div className="flex flex-wrap gap-1.5">
274
+ {otherAgents.map(agent => (
275
+ <span
276
+ key={agent.key}
277
+ className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/40 text-xs text-muted-foreground/70"
278
+ title={agent.name}
279
+ >
280
+ <span className="w-1.5 h-1.5 rounded-full bg-amber-400/60 shrink-0" />
281
+ {agent.name}
282
+ </span>
283
+ ))}
284
+ </div>
285
+ </div>
286
+ )}
287
+ </div>
288
+
289
+ {/* ── Stats footer ── */}
290
+ <div className="px-4 py-2.5 border-t border-border/20 bg-muted/10">
291
+ <div className="flex items-center gap-3 text-xs">
292
+ {/* MCP status */}
293
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
294
+ {mcpRunning
295
+ ? <><span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /><span>MCP {pulse.running}</span></>
296
+ : <><WifiOff size={11} className="text-muted-foreground/40" /><span>MCP {pulse.offline}</span></>
297
+ }
298
+ </span>
299
+
300
+ <span className="w-px h-3 bg-border/40" aria-hidden="true" />
301
+
302
+ {/* Skills */}
303
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
304
+ <Zap size={11} className="text-[var(--amber)]/60" />
305
+ <span className="tabular-nums">{pulse.skillCount(enabledSkills, skills.length)}</span>
306
+ </span>
307
+
308
+ {status?.port && (
309
+ <>
310
+ <span className="w-px h-3 bg-border/40 hidden sm:block" aria-hidden="true" />
311
+ <span className="tabular-nums text-muted-foreground/30 hidden sm:inline">:{status.port}</span>
312
+ </>
313
+ )}
314
+ </div>
315
+ </div>
316
+ </div>
317
+ );
318
+ }
@@ -9,15 +9,15 @@ import { ConfirmDialog } from '@/components/agents/AgentsPrimitives';
9
9
  import { toast } from '@/lib/toast';
10
10
  import type { TrashMeta } from '@/lib/core/trash';
11
11
 
12
- function relativeTimeShort(iso: string): string {
12
+ function relativeTimeShort(iso: string, t: { justNow?: string; minutesAgo?: (m: number) => string; hoursAgo?: (h: number) => string; daysAgo?: (d: number) => string }): string {
13
13
  const delta = Date.now() - new Date(iso).getTime();
14
14
  const mins = Math.floor(delta / 60000);
15
- if (mins < 1) return 'just now';
16
- if (mins < 60) return `${mins}m ago`;
15
+ if (mins < 1) return t.justNow ?? 'just now';
16
+ if (mins < 60) return t.minutesAgo?.(mins) ?? `${mins}m ago`;
17
17
  const hours = Math.floor(mins / 60);
18
- if (hours < 24) return `${hours}h ago`;
18
+ if (hours < 24) return t.hoursAgo?.(hours) ?? `${hours}h ago`;
19
19
  const days = Math.floor(hours / 24);
20
- return `${days}d ago`;
20
+ return t.daysAgo?.(days) ?? `${days}d ago`;
21
21
  }
22
22
 
23
23
  function daysUntil(iso: string): number {
@@ -162,7 +162,7 @@ export default function TrashPageClient({ initialItems }: { initialItems: TrashM
162
162
  <div className="flex items-center justify-between">
163
163
  <div className="flex flex-col gap-0.5">
164
164
  <span className="text-2xs text-muted-foreground">
165
- {t.trash.deletedAgo(relativeTimeShort(item.deletedAt))}
165
+ {t.trash.deletedAgo(relativeTimeShort(item.deletedAt, t.trash))}
166
166
  </span>
167
167
  <span className={`text-2xs ${isExpiring ? 'text-error' : 'text-muted-foreground/60'}`}>
168
168
  {isExpiring && <AlertTriangle size={9} className="inline mr-0.5" />}
@@ -203,7 +203,7 @@ export default function TrashPageClient({ initialItems }: { initialItems: TrashM
203
203
  title={t.trash.emptyTrash}
204
204
  message={t.trash.emptyTrashConfirm}
205
205
  confirmLabel={t.trash.emptyTrash}
206
- cancelLabel={t.export?.cancel ?? 'Cancel'}
206
+ cancelLabel={t.trash.cancel ?? 'Cancel'}
207
207
  onConfirm={() => void handleEmptyTrash()}
208
208
  onCancel={() => setConfirmEmpty(false)}
209
209
  variant="destructive"
@@ -215,7 +215,7 @@ export default function TrashPageClient({ initialItems }: { initialItems: TrashM
215
215
  title={t.trash.deletePermanently}
216
216
  message={confirmDelete ? t.trash.deletePermanentlyConfirm(confirmDelete.fileName) : ''}
217
217
  confirmLabel={t.trash.deletePermanently}
218
- cancelLabel={t.export?.cancel ?? 'Cancel'}
218
+ cancelLabel={t.trash.cancel ?? 'Cancel'}
219
219
  onConfirm={() => void handlePermanentDelete()}
220
220
  onCancel={() => setConfirmDelete(null)}
221
221
  variant="destructive"
@@ -238,7 +238,7 @@ export default function TrashPageClient({ initialItems }: { initialItems: TrashM
238
238
  onClick={() => setConflictItem(null)}
239
239
  className="px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
240
240
  >
241
- {t.export?.cancel ?? 'Cancel'}
241
+ {t.trash.cancel ?? 'Cancel'}
242
242
  </button>
243
243
  <button
244
244
  type="button"
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useMemo, useState } from 'react';
3
+ import { useCallback, useMemo, useRef, useState } from 'react';
4
+ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
4
5
  import Link from 'next/link';
5
6
  import { ChevronDown, ChevronRight, Search, Trash2, Zap } from 'lucide-react';
6
7
  import { Toggle } from '@/components/settings/Primitives';
@@ -445,110 +446,23 @@ function BySkillView({
445
446
  </div>
446
447
  )}
447
448
 
448
- {/* Grouped unified skill list */}
449
+ {/* Grouped unified skill list (virtualized) */}
449
450
  {sortedGrouped.length === 0 ? (
450
451
  <EmptyState message={copy.noSkillsMatchFilter} />
451
452
  ) : (
452
- <div className="space-y-3">
453
- {sortedGrouped.map(([groupKey, sortedSkills]) => (
454
- <div key={groupKey}>
455
- <div className="flex items-center gap-2 mb-2.5">
456
- <span className="w-1 h-4 rounded-full bg-[var(--amber)]/40" aria-hidden="true" />
457
- <span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
458
- {copy.groupLabels[groupKey as keyof typeof copy.groupLabels]}
459
- </span>
460
- <span className="text-2xs tabular-nums text-muted-foreground/50 font-medium">({sortedSkills.length})</span>
461
- </div>
462
- <div className="space-y-3">
463
- {sortedSkills.map((skill) => {
464
- const availableAgents = allAgents
465
- .filter((a) => !skill.agents.includes(a.name))
466
- .map((a) => ({ key: a.key, name: a.name }));
467
- const isUserSkill = skill.kind === 'mindos' && skill.source === 'user';
468
-
469
- return (
470
- <div key={skill.name} className="rounded-xl border border-border bg-card p-4 hover:shadow-[0_2px_8px_rgba(0,0,0,0.04)] transition-all duration-200">
471
- {/* Skill header */}
472
- <div className="flex items-center justify-between gap-2 mb-2">
473
- <div className="flex items-center gap-2 min-w-0">
474
- <Zap size={14} className={`shrink-0 ${skill.enabled ? 'text-[var(--amber)]' : 'text-muted-foreground/50'}`} aria-hidden="true" />
475
- <button
476
- type="button"
477
- onClick={() => onOpenDetail(skill.name)}
478
- 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"
479
- >
480
- {skill.name}
481
- </button>
482
- <span className={`text-2xs shrink-0 px-1.5 py-0.5 rounded ${
483
- skill.kind === 'native'
484
- ? 'bg-muted text-muted-foreground'
485
- : skill.source === 'builtin'
486
- ? 'bg-muted text-muted-foreground'
487
- : 'bg-[var(--amber-dim)] text-[var(--amber-text)]'
488
- }`}>
489
- {skill.kind === 'native' ? copy.sourceNative : skill.source === 'builtin' ? copy.sourceBuiltin : copy.sourceUser}
490
- </span>
491
- </div>
492
- <div className="flex items-center gap-1.5 shrink-0">
493
- {isUserSkill && (
494
- <button
495
- type="button"
496
- onClick={(e) => { e.stopPropagation(); setConfirmSkillDelete(skill.name); }}
497
- disabled={deleteBusy === skill.name}
498
- 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"
499
- aria-label={`${copy.skillDeleteAction} ${skill.name}`}
500
- >
501
- <Trash2 size={14} />
502
- </button>
503
- )}
504
- {skill.kind === 'mindos' ? (
505
- <Toggle size="sm" checked={skill.enabled} onChange={(v) => void onToggleSkill(skill.name, v)} />
506
- ) : (
507
- <span className="text-2xs text-muted-foreground/60 select-none" aria-label="read-only">—</span>
508
- )}
509
- <div className="relative">
510
- <AddAvatarButton
511
- onClick={() => setPickerSkill(pickerSkill === skill.name ? null : skill.name)}
512
- label={copy.addAgentToSkill}
513
- size="sm"
514
- />
515
- <AgentPickerPopover
516
- open={pickerSkill === skill.name}
517
- agents={availableAgents}
518
- emptyLabel={copy.noAvailableAgentsForSkill}
519
- onSelect={() => {
520
- setPickerSkill(null);
521
- setHintMessage(copy.manualSkillHint);
522
- setTimeout(() => setHintMessage(null), 4000);
523
- }}
524
- onClose={() => setPickerSkill(null)}
525
- />
526
- </div>
527
- </div>
528
- </div>
529
-
530
- {/* Agent count */}
531
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-muted-foreground mb-3">
532
- <span className="tabular-nums">{copy.skillAgentCount(skill.agents.length)}</span>
533
- </div>
534
-
535
- {/* Agent avatar grid */}
536
- <div className="flex flex-wrap items-center gap-2">
537
- {skill.agents.map((name) => (
538
- <AgentAvatar
539
- key={name}
540
- name={name}
541
- onRemove={() => setConfirmAgentRemove({ agentName: name, skillName: skill.name })}
542
- />
543
- ))}
544
- </div>
545
- </div>
546
- );
547
- })}
548
- </div>
549
- </div>
550
- ))}
551
- </div>
453
+ <VirtualizedSkillList
454
+ sortedGrouped={sortedGrouped}
455
+ allAgents={allAgents}
456
+ copy={copy}
457
+ pickerSkill={pickerSkill}
458
+ deleteBusy={deleteBusy}
459
+ onOpenDetail={onOpenDetail}
460
+ onToggleSkill={onToggleSkill}
461
+ setPickerSkill={setPickerSkill}
462
+ setHintMessage={setHintMessage}
463
+ setConfirmSkillDelete={setConfirmSkillDelete}
464
+ setConfirmAgentRemove={setConfirmAgentRemove}
465
+ />
552
466
  )}
553
467
 
554
468
  {/* Confirm: remove agent from skill */}
@@ -578,6 +492,163 @@ function BySkillView({
578
492
  );
579
493
  }
580
494
 
495
+ /* ────────── Virtualized Skill List (BySkill) ────────── */
496
+
497
+ type FlatItem =
498
+ | { type: 'header'; groupKey: string; count: number }
499
+ | { type: 'card'; skill: UnifiedSkillItem; isLast: boolean };
500
+
501
+ function VirtualizedSkillList({
502
+ sortedGrouped,
503
+ allAgents,
504
+ copy,
505
+ pickerSkill,
506
+ deleteBusy,
507
+ onOpenDetail,
508
+ onToggleSkill,
509
+ setPickerSkill,
510
+ setHintMessage,
511
+ setConfirmSkillDelete,
512
+ setConfirmAgentRemove,
513
+ }: {
514
+ sortedGrouped: Array<[string, UnifiedSkillItem[]]>;
515
+ allAgents: ReturnType<typeof sortAgentsByStatus>;
516
+ copy: Parameters<typeof AgentsSkillsSection>[0]['copy'];
517
+ pickerSkill: string | null;
518
+ deleteBusy: string | null;
519
+ onOpenDetail: (name: string) => void;
520
+ onToggleSkill: (name: string, enabled: boolean) => Promise<boolean>;
521
+ setPickerSkill: (name: string | null) => void;
522
+ setHintMessage: (msg: string | null) => void;
523
+ setConfirmSkillDelete: (name: string | null) => void;
524
+ setConfirmAgentRemove: (v: { agentName: string; skillName: string } | null) => void;
525
+ }) {
526
+ const virtuosoRef = useRef<VirtuosoHandle>(null);
527
+
528
+ const flatItems = useMemo<FlatItem[]>(() => {
529
+ const items: FlatItem[] = [];
530
+ for (const [groupKey, skills] of sortedGrouped) {
531
+ items.push({ type: 'header', groupKey, count: skills.length });
532
+ skills.forEach((skill, i) => {
533
+ items.push({ type: 'card', skill, isLast: i === skills.length - 1 });
534
+ });
535
+ }
536
+ return items;
537
+ }, [sortedGrouped]);
538
+
539
+ const renderItem = useCallback((_index: number, item: FlatItem) => {
540
+ if (item.type === 'header') {
541
+ return (
542
+ <div className="flex items-center gap-2 pt-3 pb-2.5">
543
+ <span className="w-1 h-4 rounded-full bg-[var(--amber)]/40" aria-hidden="true" />
544
+ <span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
545
+ {copy.groupLabels[item.groupKey as keyof typeof copy.groupLabels]}
546
+ </span>
547
+ <span className="text-2xs tabular-nums text-muted-foreground/50 font-medium">({item.count})</span>
548
+ </div>
549
+ );
550
+ }
551
+
552
+ const { skill } = item;
553
+ const availableAgents = allAgents
554
+ .filter((a) => !skill.agents.includes(a.name))
555
+ .map((a) => ({ key: a.key, name: a.name }));
556
+ const isUserSkill = skill.kind === 'mindos' && skill.source === 'user';
557
+
558
+ return (
559
+ <div className={item.isLast ? 'pb-0' : 'pb-3'}>
560
+ <div className="rounded-xl border border-border bg-card p-4 hover:shadow-[0_2px_8px_rgba(0,0,0,0.04)] transition-all duration-200">
561
+ {/* Skill header */}
562
+ <div className="flex items-center justify-between gap-2 mb-2">
563
+ <div className="flex items-center gap-2 min-w-0">
564
+ <Zap size={14} className={`shrink-0 ${skill.enabled ? 'text-[var(--amber)]' : 'text-muted-foreground/50'}`} aria-hidden="true" />
565
+ <button
566
+ type="button"
567
+ onClick={() => onOpenDetail(skill.name)}
568
+ 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"
569
+ >
570
+ {skill.name}
571
+ </button>
572
+ <span className={`text-2xs shrink-0 px-1.5 py-0.5 rounded ${
573
+ skill.kind === 'native'
574
+ ? 'bg-muted text-muted-foreground'
575
+ : skill.source === 'builtin'
576
+ ? 'bg-muted text-muted-foreground'
577
+ : 'bg-[var(--amber-dim)] text-[var(--amber-text)]'
578
+ }`}>
579
+ {skill.kind === 'native' ? copy.sourceNative : skill.source === 'builtin' ? copy.sourceBuiltin : copy.sourceUser}
580
+ </span>
581
+ </div>
582
+ <div className="flex items-center gap-1.5 shrink-0">
583
+ {isUserSkill && (
584
+ <button
585
+ type="button"
586
+ onClick={(e) => { e.stopPropagation(); setConfirmSkillDelete(skill.name); }}
587
+ disabled={deleteBusy === skill.name}
588
+ 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"
589
+ aria-label={`${copy.skillDeleteAction} ${skill.name}`}
590
+ >
591
+ <Trash2 size={14} />
592
+ </button>
593
+ )}
594
+ {skill.kind === 'mindos' ? (
595
+ <Toggle size="sm" checked={skill.enabled} onChange={(v) => void onToggleSkill(skill.name, v)} />
596
+ ) : (
597
+ <span className="text-2xs text-muted-foreground/60 select-none" aria-label="read-only">—</span>
598
+ )}
599
+ <div className="relative">
600
+ <AddAvatarButton
601
+ onClick={() => setPickerSkill(pickerSkill === skill.name ? null : skill.name)}
602
+ label={copy.addAgentToSkill}
603
+ size="sm"
604
+ />
605
+ <AgentPickerPopover
606
+ open={pickerSkill === skill.name}
607
+ agents={availableAgents}
608
+ emptyLabel={copy.noAvailableAgentsForSkill}
609
+ onSelect={() => {
610
+ setPickerSkill(null);
611
+ setHintMessage(copy.manualSkillHint);
612
+ setTimeout(() => setHintMessage(null), 4000);
613
+ }}
614
+ onClose={() => setPickerSkill(null)}
615
+ />
616
+ </div>
617
+ </div>
618
+ </div>
619
+
620
+ {/* Agent count */}
621
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-muted-foreground mb-3">
622
+ <span className="tabular-nums">{copy.skillAgentCount(skill.agents.length)}</span>
623
+ </div>
624
+
625
+ {/* Agent avatar grid */}
626
+ <div className="flex flex-wrap items-center gap-2">
627
+ {skill.agents.map((name) => (
628
+ <AgentAvatar
629
+ key={name}
630
+ name={name}
631
+ onRemove={() => setConfirmAgentRemove({ agentName: name, skillName: skill.name })}
632
+ />
633
+ ))}
634
+ </div>
635
+ </div>
636
+ </div>
637
+ );
638
+ }, [allAgents, copy, pickerSkill, deleteBusy, onOpenDetail, onToggleSkill, setPickerSkill, setHintMessage, setConfirmSkillDelete, setConfirmAgentRemove]);
639
+
640
+ return (
641
+ <Virtuoso
642
+ ref={virtuosoRef}
643
+ style={{ height: 'calc(100vh - 340px)', minHeight: 200 }}
644
+ data={flatItems}
645
+ itemContent={renderItem}
646
+ overscan={200}
647
+ increaseViewportBy={{ top: 100, bottom: 100 }}
648
+ />
649
+ );
650
+ }
651
+
581
652
  /* ────────── By Agent View ────────── */
582
653
 
583
654
  function ByAgentView({