@geminilight/mindos 0.5.28 → 0.5.30

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 (34) hide show
  1. package/app/app/api/update/route.ts +41 -0
  2. package/app/app/explore/page.tsx +12 -0
  3. package/app/components/ActivityBar.tsx +14 -7
  4. package/app/components/GuideCard.tsx +21 -7
  5. package/app/components/HomeContent.tsx +31 -97
  6. package/app/components/KeyboardShortcuts.tsx +102 -0
  7. package/app/components/Panel.tsx +12 -7
  8. package/app/components/SidebarLayout.tsx +21 -1
  9. package/app/components/UpdateBanner.tsx +19 -21
  10. package/app/components/explore/ExploreContent.tsx +100 -0
  11. package/app/components/explore/UseCaseCard.tsx +50 -0
  12. package/app/components/explore/use-cases.ts +30 -0
  13. package/app/components/panels/AgentsPanel.tsx +268 -131
  14. package/app/components/panels/PluginsPanel.tsx +87 -27
  15. package/app/components/settings/AiTab.tsx +5 -3
  16. package/app/components/settings/McpSkillsSection.tsx +12 -0
  17. package/app/components/settings/McpTab.tsx +28 -30
  18. package/app/components/settings/SettingsContent.tsx +5 -2
  19. package/app/components/settings/UpdateTab.tsx +195 -0
  20. package/app/components/settings/types.ts +1 -1
  21. package/app/components/walkthrough/WalkthroughOverlay.tsx +224 -0
  22. package/app/components/walkthrough/WalkthroughProvider.tsx +133 -0
  23. package/app/components/walkthrough/WalkthroughTooltip.tsx +129 -0
  24. package/app/components/walkthrough/index.ts +3 -0
  25. package/app/components/walkthrough/steps.ts +21 -0
  26. package/app/hooks/useMcpData.tsx +166 -0
  27. package/app/lib/i18n-en.ts +182 -5
  28. package/app/lib/i18n-zh.ts +181 -4
  29. package/app/lib/mcp-snippets.ts +103 -0
  30. package/app/lib/settings.ts +4 -0
  31. package/app/next-env.d.ts +1 -1
  32. package/app/package.json +1 -0
  33. package/package.json +1 -1
  34. package/app/components/settings/McpServerStatus.tsx +0 -274
@@ -0,0 +1,224 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useId } from 'react';
4
+ import { useLocale } from '@/lib/LocaleContext';
5
+ import { useWalkthrough } from './WalkthroughProvider';
6
+ import { walkthroughSteps } from './steps';
7
+ import WalkthroughTooltip from './WalkthroughTooltip';
8
+
9
+ /**
10
+ * Full-screen overlay with SVG spotlight mask.
11
+ * Finds the target element via data-walkthrough attribute, measures it,
12
+ * and cuts a transparent rect into the semi-transparent overlay.
13
+ */
14
+ export default function WalkthroughOverlay() {
15
+ const wt = useWalkthrough();
16
+ const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
17
+ const [isMobile, setIsMobile] = useState(false);
18
+ const maskId = useId();
19
+
20
+ const step = wt ? walkthroughSteps[wt.currentStep] : null;
21
+
22
+ const measureTarget = useCallback(() => {
23
+ if (!step) return;
24
+ const el = document.querySelector(`[data-walkthrough="${step.anchor}"]`);
25
+ if (el) {
26
+ setTargetRect(el.getBoundingClientRect());
27
+ } else {
28
+ setTargetRect(null);
29
+ }
30
+ }, [step]);
31
+
32
+ useEffect(() => {
33
+ setIsMobile(window.innerWidth < 768);
34
+ measureTarget();
35
+
36
+ const handleResize = () => {
37
+ setIsMobile(window.innerWidth < 768);
38
+ measureTarget();
39
+ };
40
+
41
+ window.addEventListener('resize', handleResize);
42
+ window.addEventListener('scroll', measureTarget, true);
43
+ return () => {
44
+ window.removeEventListener('resize', handleResize);
45
+ window.removeEventListener('scroll', measureTarget, true);
46
+ };
47
+ }, [measureTarget]);
48
+
49
+ // Re-measure when step changes
50
+ useEffect(() => {
51
+ measureTarget();
52
+ // Slight delay to account for animations
53
+ const timer = setTimeout(measureTarget, 100);
54
+ return () => clearTimeout(timer);
55
+ }, [wt?.currentStep, measureTarget]);
56
+
57
+ // ESC to dismiss — depend on skip only, not the entire context object
58
+ const skipFn = wt?.skip;
59
+ useEffect(() => {
60
+ if (!skipFn) return;
61
+ const handler = (e: KeyboardEvent) => {
62
+ if (e.key === 'Escape') {
63
+ e.stopPropagation();
64
+ skipFn();
65
+ }
66
+ };
67
+ window.addEventListener('keydown', handler, true);
68
+ return () => window.removeEventListener('keydown', handler, true);
69
+ }, [skipFn]);
70
+
71
+ if (!wt || !step) return null;
72
+
73
+ // Mobile: bottom sheet instead of spotlight
74
+ if (isMobile) {
75
+ return <MobileWalkthroughSheet />;
76
+ }
77
+
78
+ const PAD = 6; // padding around spotlight rect
79
+ const RADIUS = 8;
80
+
81
+ return (
82
+ <>
83
+ {/* SVG overlay with spotlight hole */}
84
+ <svg
85
+ className="fixed inset-0 z-[100] pointer-events-auto"
86
+ width="100%"
87
+ height="100%"
88
+ onClick={(e) => {
89
+ // Click outside spotlight → skip
90
+ if (targetRect) {
91
+ const x = e.clientX;
92
+ const y = e.clientY;
93
+ const inSpotlight =
94
+ x >= targetRect.left - PAD &&
95
+ x <= targetRect.right + PAD &&
96
+ y >= targetRect.top - PAD &&
97
+ y <= targetRect.bottom + PAD;
98
+ if (!inSpotlight) {
99
+ wt.skip();
100
+ }
101
+ }
102
+ }}
103
+ >
104
+ <defs>
105
+ <mask id={maskId}>
106
+ <rect width="100%" height="100%" fill="white" />
107
+ {targetRect && (
108
+ <rect
109
+ x={targetRect.left - PAD}
110
+ y={targetRect.top - PAD}
111
+ width={targetRect.width + PAD * 2}
112
+ height={targetRect.height + PAD * 2}
113
+ rx={RADIUS}
114
+ ry={RADIUS}
115
+ fill="black"
116
+ />
117
+ )}
118
+ </mask>
119
+ </defs>
120
+ <rect
121
+ width="100%"
122
+ height="100%"
123
+ fill="rgba(0, 0, 0, 0.5)"
124
+ mask={`url(#${maskId})`}
125
+ />
126
+ </svg>
127
+
128
+ {/* Tooltip */}
129
+ {targetRect && (
130
+ <WalkthroughTooltip
131
+ stepIndex={wt.currentStep}
132
+ rect={targetRect}
133
+ position={step.position}
134
+ />
135
+ )}
136
+ </>
137
+ );
138
+ }
139
+
140
+ /** Mobile fallback: bottom sheet card */
141
+ function MobileWalkthroughSheet() {
142
+ const wt = useWalkthrough();
143
+ const { t } = useLocale();
144
+
145
+ if (!wt) return null;
146
+
147
+ const stepData = t.walkthrough.steps[wt.currentStep] as { title: string; body: string } | undefined;
148
+ if (!stepData) return null;
149
+
150
+ return (
151
+ <>
152
+ {/* Backdrop */}
153
+ <div
154
+ className="fixed inset-0 z-[100] bg-black/40 backdrop-blur-sm"
155
+ onClick={wt.skip}
156
+ />
157
+ {/* Bottom sheet */}
158
+ <div
159
+ className="fixed bottom-0 left-0 right-0 z-[101] rounded-t-2xl border-t shadow-lg p-5 pb-8 animate-in slide-in-from-bottom duration-300"
160
+ style={{ background: 'var(--card)', borderColor: 'var(--border)' }}
161
+ >
162
+ {/* Step counter */}
163
+ <div className="flex items-center gap-2 mb-3">
164
+ <span
165
+ className="text-2xs font-mono px-1.5 py-0.5 rounded-full"
166
+ style={{ background: 'var(--amber-dim)', color: 'var(--amber)' }}
167
+ >
168
+ {t.walkthrough.step(wt.currentStep + 1, wt.totalSteps)}
169
+ </span>
170
+ {/* Progress dots */}
171
+ <div className="flex items-center gap-1 ml-auto">
172
+ {Array.from({ length: wt.totalSteps }, (_, i) => (
173
+ <div
174
+ key={i}
175
+ className="w-1.5 h-1.5 rounded-full"
176
+ style={{
177
+ background: i === wt.currentStep ? 'var(--amber)' : 'var(--border)',
178
+ }}
179
+ />
180
+ ))}
181
+ </div>
182
+ </div>
183
+
184
+ <h3
185
+ className="text-base font-semibold font-display mb-1"
186
+ style={{ color: 'var(--foreground)' }}
187
+ >
188
+ {stepData.title}
189
+ </h3>
190
+ <p className="text-sm leading-relaxed mb-5" style={{ color: 'var(--muted-foreground)' }}>
191
+ {stepData.body}
192
+ </p>
193
+
194
+ <div className="flex items-center justify-between">
195
+ <button
196
+ onClick={wt.skip}
197
+ className="text-sm transition-colors hover:opacity-80"
198
+ style={{ color: 'var(--muted-foreground)' }}
199
+ >
200
+ {t.walkthrough.skip}
201
+ </button>
202
+ <div className="flex items-center gap-2">
203
+ {wt.currentStep > 0 && (
204
+ <button
205
+ onClick={wt.back}
206
+ className="text-sm px-3 py-2 rounded-lg transition-colors hover:bg-muted"
207
+ style={{ color: 'var(--muted-foreground)' }}
208
+ >
209
+ {t.walkthrough.back}
210
+ </button>
211
+ )}
212
+ <button
213
+ onClick={wt.next}
214
+ className="text-sm px-4 py-2 rounded-lg font-medium transition-all hover:opacity-90"
215
+ style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
216
+ >
217
+ {wt.currentStep === wt.totalSteps - 1 ? t.walkthrough.done : t.walkthrough.next}
218
+ </button>
219
+ </div>
220
+ </div>
221
+ </div>
222
+ </>
223
+ );
224
+ }
@@ -0,0 +1,133 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
4
+ import { walkthroughSteps } from './steps';
5
+ import WalkthroughOverlay from './WalkthroughOverlay';
6
+
7
+ type WalkthroughStatus = 'idle' | 'active' | 'completed' | 'dismissed';
8
+
9
+ interface WalkthroughContextValue {
10
+ status: WalkthroughStatus;
11
+ currentStep: number;
12
+ totalSteps: number;
13
+ start: () => void;
14
+ next: () => void;
15
+ back: () => void;
16
+ skip: () => void;
17
+ }
18
+
19
+ const WalkthroughContext = createContext<WalkthroughContextValue | null>(null);
20
+
21
+ export function useWalkthrough() {
22
+ return useContext(WalkthroughContext);
23
+ }
24
+
25
+ interface WalkthroughProviderProps {
26
+ children: ReactNode;
27
+ }
28
+
29
+ export default function WalkthroughProvider({ children }: WalkthroughProviderProps) {
30
+ const [status, setStatus] = useState<WalkthroughStatus>('idle');
31
+ const [currentStep, setCurrentStep] = useState(0);
32
+ const totalSteps = walkthroughSteps.length;
33
+
34
+ // Persist to backend
35
+ const persistStep = useCallback((step: number, dismissed: boolean) => {
36
+ fetch('/api/setup', {
37
+ method: 'PATCH',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({
40
+ guideState: {
41
+ walkthroughStep: step,
42
+ walkthroughDismissed: dismissed,
43
+ },
44
+ }),
45
+ }).catch(() => {});
46
+ }, []);
47
+
48
+ // Check for auto-start via ?welcome=1 or guideState
49
+ useEffect(() => {
50
+ // Handle ?welcome=1 URL param — single owner, clean up immediately
51
+ const params = new URLSearchParams(window.location.search);
52
+ const isWelcome = params.get('welcome') === '1';
53
+ if (isWelcome) {
54
+ const url = new URL(window.location.href);
55
+ url.searchParams.delete('welcome');
56
+ window.history.replaceState({}, '', url.pathname + (url.search || ''));
57
+ // Notify GuideCard about first visit
58
+ window.dispatchEvent(new Event('mindos:first-visit'));
59
+ }
60
+
61
+ // Only auto-start walkthrough on desktop
62
+ if (window.innerWidth < 768) return;
63
+
64
+ fetch('/api/setup')
65
+ .then(r => r.json())
66
+ .then(data => {
67
+ const gs = data.guideState;
68
+ if (!gs) return;
69
+ // Already dismissed or completed
70
+ if (gs.walkthroughDismissed) return;
71
+ // Check if walkthrough should start
72
+ if (gs.active && !gs.dismissed && gs.walkthroughStep === undefined) {
73
+ // First time — only start if ?welcome=1 was present
74
+ if (isWelcome) {
75
+ setStatus('active');
76
+ setCurrentStep(0);
77
+ }
78
+ } else if (typeof gs.walkthroughStep === 'number' && gs.walkthroughStep >= 0 && gs.walkthroughStep < totalSteps && !gs.walkthroughDismissed) {
79
+ // Resume walkthrough
80
+ setStatus('active');
81
+ setCurrentStep(gs.walkthroughStep);
82
+ }
83
+ })
84
+ .catch(() => {});
85
+ }, [totalSteps]);
86
+
87
+ const start = useCallback(() => {
88
+ setCurrentStep(0);
89
+ setStatus('active');
90
+ persistStep(0, false);
91
+ }, [persistStep]);
92
+
93
+ const next = useCallback(() => {
94
+ const nextStep = currentStep + 1;
95
+ if (nextStep >= totalSteps) {
96
+ setStatus('completed');
97
+ persistStep(totalSteps, false);
98
+ } else {
99
+ setCurrentStep(nextStep);
100
+ persistStep(nextStep, false);
101
+ }
102
+ }, [currentStep, totalSteps, persistStep]);
103
+
104
+ const back = useCallback(() => {
105
+ if (currentStep > 0) {
106
+ const prevStep = currentStep - 1;
107
+ setCurrentStep(prevStep);
108
+ persistStep(prevStep, false);
109
+ }
110
+ }, [currentStep, persistStep]);
111
+
112
+ const skip = useCallback(() => {
113
+ setStatus('dismissed');
114
+ persistStep(currentStep, true);
115
+ }, [currentStep, persistStep]);
116
+
117
+ const value: WalkthroughContextValue = {
118
+ status,
119
+ currentStep,
120
+ totalSteps,
121
+ start,
122
+ next,
123
+ back,
124
+ skip,
125
+ };
126
+
127
+ return (
128
+ <WalkthroughContext.Provider value={value}>
129
+ {children}
130
+ {status === 'active' && <WalkthroughOverlay />}
131
+ </WalkthroughContext.Provider>
132
+ );
133
+ }
@@ -0,0 +1,129 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { useLocale } from '@/lib/LocaleContext';
5
+ import { useWalkthrough } from './WalkthroughProvider';
6
+
7
+ interface WalkthroughTooltipProps {
8
+ stepIndex: number;
9
+ rect: DOMRect;
10
+ position: 'right' | 'bottom';
11
+ }
12
+
13
+ export default function WalkthroughTooltip({ stepIndex, rect, position }: WalkthroughTooltipProps) {
14
+ const wt = useWalkthrough();
15
+ const { t } = useLocale();
16
+
17
+ // Track viewport dimensions in state to avoid stale values and SSR hazard
18
+ const [viewport, setViewport] = useState({ width: 1024, height: 768 });
19
+ useEffect(() => {
20
+ const update = () => setViewport({ width: window.innerWidth, height: window.innerHeight });
21
+ update();
22
+ window.addEventListener('resize', update);
23
+ return () => window.removeEventListener('resize', update);
24
+ }, []);
25
+
26
+ if (!wt) return null;
27
+
28
+ const stepData = t.walkthrough.steps[stepIndex] as { title: string; body: string } | undefined;
29
+ if (!stepData) return null;
30
+
31
+ // Calculate tooltip position
32
+ const GAP = 12;
33
+ let top: number;
34
+ let left: number;
35
+
36
+ if (position === 'right') {
37
+ top = rect.top + rect.height / 2;
38
+ left = rect.right + GAP;
39
+ } else {
40
+ top = rect.bottom + GAP;
41
+ left = rect.left + rect.width / 2;
42
+ }
43
+
44
+ // Clamp to viewport
45
+ const maxLeft = viewport.width - 320;
46
+ const maxTop = viewport.height - 200;
47
+ left = Math.min(left, maxLeft);
48
+ top = Math.min(top, maxTop);
49
+ top = Math.max(top, 8);
50
+
51
+ return (
52
+ <div
53
+ className="fixed z-[102] w-[280px] rounded-xl border shadow-lg animate-in fade-in slide-in-from-left-2 duration-200"
54
+ style={{
55
+ top: `${top}px`,
56
+ left: `${left}px`,
57
+ transform: position === 'right' ? 'translateY(-50%)' : 'translateX(-50%)',
58
+ background: 'var(--card)',
59
+ borderColor: 'var(--amber)',
60
+ }}
61
+ >
62
+ <div className="p-4">
63
+ {/* Step indicator */}
64
+ <div className="flex items-center gap-2 mb-2">
65
+ <span
66
+ className="text-2xs font-mono px-1.5 py-0.5 rounded-full"
67
+ style={{ background: 'var(--amber-dim)', color: 'var(--amber)' }}
68
+ >
69
+ {t.walkthrough.step(stepIndex + 1, wt.totalSteps)}
70
+ </span>
71
+ </div>
72
+
73
+ {/* Content */}
74
+ <h3
75
+ className="text-sm font-semibold font-display mb-1"
76
+ style={{ color: 'var(--foreground)' }}
77
+ >
78
+ {stepData.title}
79
+ </h3>
80
+ <p className="text-xs leading-relaxed" style={{ color: 'var(--muted-foreground)' }}>
81
+ {stepData.body}
82
+ </p>
83
+
84
+ {/* Actions */}
85
+ <div className="flex items-center justify-between mt-4 pt-3" style={{ borderTop: '1px solid var(--border)' }}>
86
+ <button
87
+ onClick={wt.skip}
88
+ className="text-xs transition-colors hover:opacity-80"
89
+ style={{ color: 'var(--muted-foreground)' }}
90
+ >
91
+ {t.walkthrough.skip}
92
+ </button>
93
+ <div className="flex items-center gap-2">
94
+ {stepIndex > 0 && (
95
+ <button
96
+ onClick={wt.back}
97
+ className="text-xs px-2.5 py-1 rounded-lg transition-colors hover:bg-muted"
98
+ style={{ color: 'var(--muted-foreground)' }}
99
+ >
100
+ {t.walkthrough.back}
101
+ </button>
102
+ )}
103
+ <button
104
+ onClick={wt.next}
105
+ className="text-xs px-3 py-1.5 rounded-lg font-medium transition-all hover:opacity-90"
106
+ style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
107
+ >
108
+ {stepIndex === wt.totalSteps - 1 ? t.walkthrough.done : t.walkthrough.next}
109
+ </button>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
114
+ {/* Progress dots */}
115
+ <div className="flex items-center justify-center gap-1.5 pb-3">
116
+ {Array.from({ length: wt.totalSteps }, (_, i) => (
117
+ <div
118
+ key={i}
119
+ className="w-1.5 h-1.5 rounded-full transition-all duration-200"
120
+ style={{
121
+ background: i === stepIndex ? 'var(--amber)' : 'var(--border)',
122
+ transform: i === stepIndex ? 'scale(1.3)' : undefined,
123
+ }}
124
+ />
125
+ ))}
126
+ </div>
127
+ </div>
128
+ );
129
+ }
@@ -0,0 +1,3 @@
1
+ export { default as WalkthroughProvider, useWalkthrough } from './WalkthroughProvider';
2
+ export { walkthroughSteps } from './steps';
3
+ export type { WalkthroughAnchor } from './steps';
@@ -0,0 +1,21 @@
1
+ /** Walkthrough step anchors — these data-walkthrough attributes are added to target components */
2
+ export type WalkthroughAnchor =
3
+ | 'activity-bar'
4
+ | 'files-panel'
5
+ | 'ask-button'
6
+ | 'search-button'
7
+ | 'settings-button';
8
+
9
+ export interface WalkthroughStep {
10
+ anchor: WalkthroughAnchor;
11
+ /** Preferred tooltip position relative to anchor */
12
+ position: 'right' | 'bottom';
13
+ }
14
+
15
+ export const walkthroughSteps: WalkthroughStep[] = [
16
+ { anchor: 'activity-bar', position: 'right' },
17
+ { anchor: 'files-panel', position: 'right' },
18
+ { anchor: 'ask-button', position: 'right' },
19
+ { anchor: 'search-button', position: 'right' },
20
+ { anchor: 'settings-button', position: 'right' },
21
+ ];
@@ -0,0 +1,166 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
4
+ import { apiFetch } from '@/lib/api';
5
+ import type { McpStatus, AgentInfo, SkillInfo } from '@/components/settings/types';
6
+
7
+ /* ── Context shape ── */
8
+
9
+ export interface McpContextValue {
10
+ status: McpStatus | null;
11
+ agents: AgentInfo[];
12
+ skills: SkillInfo[];
13
+ loading: boolean;
14
+ refresh: () => Promise<void>;
15
+ toggleSkill: (name: string, enabled: boolean) => Promise<void>;
16
+ installAgent: (key: string, opts?: { scope?: string; transport?: string }) => Promise<boolean>;
17
+ }
18
+
19
+ const McpContext = createContext<McpContextValue | null>(null);
20
+
21
+ export function useMcpData(): McpContextValue {
22
+ const ctx = useContext(McpContext);
23
+ if (!ctx) throw new Error('useMcpData must be used within McpProvider');
24
+ return ctx;
25
+ }
26
+
27
+ /** Optional hook that returns null outside provider (for components that may or may not be wrapped) */
28
+ export function useMcpDataOptional(): McpContextValue | null {
29
+ return useContext(McpContext);
30
+ }
31
+
32
+ /* ── Provider ── */
33
+
34
+ const POLL_INTERVAL = 30_000;
35
+
36
+ export default function McpProvider({ children }: { children: ReactNode }) {
37
+ const [status, setStatus] = useState<McpStatus | null>(null);
38
+ const [agents, setAgents] = useState<AgentInfo[]>([]);
39
+ const [skills, setSkills] = useState<SkillInfo[]>([]);
40
+ const [loading, setLoading] = useState(true);
41
+ const abortRef = useRef<AbortController | null>(null);
42
+ // Ref for agents to avoid stale closure in installAgent
43
+ const agentsRef = useRef(agents);
44
+ agentsRef.current = agents;
45
+
46
+ const fetchAll = useCallback(async () => {
47
+ // Abort any in-flight request to prevent race conditions
48
+ abortRef.current?.abort();
49
+ const ac = new AbortController();
50
+ abortRef.current = ac;
51
+
52
+ try {
53
+ const [statusData, agentsData, skillsData] = await Promise.all([
54
+ apiFetch<McpStatus>('/api/mcp/status', { signal: ac.signal }),
55
+ apiFetch<{ agents: AgentInfo[] }>('/api/mcp/agents', { signal: ac.signal }),
56
+ apiFetch<{ skills: SkillInfo[] }>('/api/skills', { signal: ac.signal }),
57
+ ]);
58
+ if (!ac.signal.aborted) {
59
+ setStatus(statusData);
60
+ setAgents(agentsData.agents);
61
+ setSkills(skillsData.skills);
62
+ }
63
+ } catch (err: unknown) {
64
+ // Ignore abort errors
65
+ if (err instanceof DOMException && err.name === 'AbortError') return;
66
+ // On error, keep existing data
67
+ } finally {
68
+ if (!ac.signal.aborted) setLoading(false);
69
+ }
70
+ }, []);
71
+
72
+ // Initial fetch
73
+ useEffect(() => {
74
+ fetchAll();
75
+ return () => abortRef.current?.abort();
76
+ }, [fetchAll]);
77
+
78
+ // Listen for skill changes from SkillsSection (settings CRUD — create/delete/edit)
79
+ // Debounce to coalesce rapid mutations into a single refresh
80
+ useEffect(() => {
81
+ let timer: ReturnType<typeof setTimeout> | undefined;
82
+ const handler = () => {
83
+ clearTimeout(timer);
84
+ timer = setTimeout(() => fetchAll(), 500);
85
+ };
86
+ window.addEventListener('mindos:skills-changed', handler);
87
+ return () => {
88
+ clearTimeout(timer);
89
+ window.removeEventListener('mindos:skills-changed', handler);
90
+ };
91
+ }, [fetchAll]);
92
+
93
+ // 30s polling when visible
94
+ useEffect(() => {
95
+ let timer: ReturnType<typeof setInterval> | undefined;
96
+
97
+ const startPolling = () => {
98
+ timer = setInterval(() => {
99
+ if (document.visibilityState === 'visible') fetchAll();
100
+ }, POLL_INTERVAL);
101
+ };
102
+
103
+ startPolling();
104
+ return () => clearInterval(timer);
105
+ }, [fetchAll]);
106
+
107
+ const refresh = useCallback(async () => {
108
+ await fetchAll();
109
+ }, [fetchAll]);
110
+
111
+ const toggleSkill = useCallback(async (name: string, enabled: boolean) => {
112
+ // Optimistic update
113
+ setSkills(prev => prev.map(s => s.name === name ? { ...s, enabled } : s));
114
+ try {
115
+ await apiFetch('/api/skills', {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/json' },
118
+ body: JSON.stringify({ action: 'toggle', name, enabled }),
119
+ });
120
+ } catch {
121
+ // Revert on failure
122
+ setSkills(prev => prev.map(s => s.name === name ? { ...s, enabled: !enabled } : s));
123
+ }
124
+ }, []);
125
+
126
+ const installAgent = useCallback(async (key: string, opts?: { scope?: string; transport?: string }): Promise<boolean> => {
127
+ const agent = agentsRef.current.find(a => a.key === key);
128
+ if (!agent) return false;
129
+
130
+ try {
131
+ const res = await apiFetch<{ results: Array<{ key: string; ok: boolean; error?: string }> }>('/api/mcp/install', {
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify({
135
+ agents: [{
136
+ key,
137
+ scope: opts?.scope ?? (agent.hasProjectScope ? 'project' : 'global'),
138
+ transport: opts?.transport ?? agent.preferredTransport,
139
+ }],
140
+ transport: 'auto',
141
+ }),
142
+ });
143
+
144
+ const ok = res.results?.[0]?.ok ?? false;
145
+ if (ok) {
146
+ // Refresh to pick up newly installed agent
147
+ await fetchAll();
148
+ }
149
+ return ok;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }, [fetchAll]);
154
+
155
+ const value = useMemo<McpContextValue>(() => ({
156
+ status,
157
+ agents,
158
+ skills,
159
+ loading,
160
+ refresh,
161
+ toggleSkill,
162
+ installAgent,
163
+ }), [status, agents, skills, loading, refresh, toggleSkill, installAgent]);
164
+
165
+ return <McpContext.Provider value={value}>{children}</McpContext.Provider>;
166
+ }