@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.
- package/app/app/api/ask/route.ts +69 -29
- package/app/app/api/graph/route.ts +5 -76
- package/app/app/trash/page.tsx +1 -0
- package/app/app/view/[...path]/ViewPageClient.tsx +22 -8
- package/app/components/ExportModal.tsx +2 -2
- package/app/components/FileTree.tsx +26 -5
- package/app/components/HomeContent.tsx +4 -0
- package/app/components/SystemPulse.tsx +318 -0
- package/app/components/TrashPageClient.tsx +9 -9
- package/app/components/agents/AgentsSkillsSection.tsx +173 -102
- package/app/components/ui/Toaster.tsx +11 -2
- package/app/lib/actions.ts +20 -9
- package/app/lib/agent/context.ts +22 -11
- package/app/lib/agent/loop-detection.ts +52 -0
- package/app/lib/agent/retry.ts +19 -0
- package/app/lib/core/backlinks.ts +33 -9
- package/app/lib/core/index.ts +4 -1
- package/app/lib/core/link-index.ts +224 -0
- package/app/lib/core/search-index.ts +310 -14
- package/app/lib/core/search.ts +180 -29
- package/app/lib/fs.ts +67 -10
- package/app/lib/hooks/usePinnedFiles.ts +7 -2
- package/app/lib/i18n/modules/knowledge.ts +62 -0
- package/app/lib/toast.ts +7 -1
- package/app/next-env.d.ts +1 -1
- package/app/package.json +2 -0
- package/package.json +1 -1
- package/scripts/parse-syncinclude.sh +92 -0
- package/scripts/write-build-stamp.js +40 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
<
|
|
453
|
-
{sortedGrouped
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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({
|