@geminilight/mindos 0.2.0 → 0.2.1
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/README.md +3 -3
- package/README_zh.md +38 -5
- package/app/README.md +1 -1
- package/app/app/api/init/route.ts +56 -0
- package/app/app/layout.tsx +10 -1
- package/app/app/register-sw.tsx +15 -0
- package/app/components/HomeContent.tsx +8 -2
- package/app/components/OnboardingView.tsx +161 -0
- package/app/components/SettingsModal.tsx +7 -1
- package/app/components/Sidebar.tsx +28 -4
- package/app/components/SyncStatusBar.tsx +273 -0
- package/app/components/renderers/AgentInspectorRenderer.tsx +8 -5
- package/app/components/settings/SyncTab.tsx +113 -21
- package/app/lib/agent/log.ts +44 -0
- package/app/lib/agent/tools.ts +39 -18
- package/app/lib/i18n.ts +78 -0
- package/app/lib/renderers/index.ts +13 -0
- package/app/lib/settings.ts +1 -1
- package/app/public/icons/icon-192.png +0 -0
- package/app/public/icons/icon-512.png +0 -0
- package/app/public/manifest.json +26 -0
- package/app/public/sw.js +66 -0
- package/bin/cli.js +21 -0
- package/bin/lib/mcp-install.js +225 -70
- package/bin/lib/startup.js +24 -1
- package/mcp/src/index.ts +37 -10
- package/package.json +1 -1
- package/scripts/setup.js +12 -1
- package/templates/README.md +1 -1
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
import { RefreshCw, CheckCircle2, XCircle } from 'lucide-react';
|
|
5
|
+
import { apiFetch } from '@/lib/api';
|
|
6
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
7
|
+
import type { SyncStatus } from './settings/SyncTab';
|
|
8
|
+
import { timeAgo } from './settings/SyncTab';
|
|
9
|
+
|
|
10
|
+
export type StatusLevel = 'synced' | 'unpushed' | 'conflicts' | 'error' | 'off' | 'syncing';
|
|
11
|
+
|
|
12
|
+
export function getStatusLevel(status: SyncStatus | null, syncing: boolean): StatusLevel {
|
|
13
|
+
if (syncing) return 'syncing';
|
|
14
|
+
if (!status || !status.enabled) return 'off';
|
|
15
|
+
if (status.lastError) return 'error';
|
|
16
|
+
if (status.conflicts && status.conflicts.length > 0) return 'conflicts';
|
|
17
|
+
const unpushed = parseInt(status.unpushed || '0', 10);
|
|
18
|
+
if (unpushed > 0) return 'unpushed';
|
|
19
|
+
return 'synced';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DOT_COLORS: Record<StatusLevel, string> = {
|
|
23
|
+
synced: 'bg-green-500',
|
|
24
|
+
unpushed: 'bg-yellow-500',
|
|
25
|
+
conflicts: 'bg-red-500', // #6 — conflicts more prominent than unpushed
|
|
26
|
+
error: 'bg-red-500',
|
|
27
|
+
off: 'bg-muted-foreground/40',
|
|
28
|
+
syncing: 'bg-blue-500',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
interface SyncStatusBarProps {
|
|
32
|
+
collapsed?: boolean;
|
|
33
|
+
onOpenSyncSettings: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// #1 — Hook to force re-render every 60s so timeAgo stays fresh
|
|
37
|
+
function useTick(intervalMs: number) {
|
|
38
|
+
const [, setTick] = useState(0);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const id = setInterval(() => setTick(n => n + 1), intervalMs);
|
|
41
|
+
return () => clearInterval(id);
|
|
42
|
+
}, [intervalMs]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useSyncStatus() {
|
|
46
|
+
const [status, setStatus] = useState<SyncStatus | null>(null);
|
|
47
|
+
const [loaded, setLoaded] = useState(false);
|
|
48
|
+
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
|
49
|
+
|
|
50
|
+
const fetchStatus = useCallback(async () => {
|
|
51
|
+
try {
|
|
52
|
+
const data = await apiFetch<SyncStatus>('/api/sync');
|
|
53
|
+
setStatus(data);
|
|
54
|
+
} catch {
|
|
55
|
+
setStatus(null);
|
|
56
|
+
} finally {
|
|
57
|
+
setLoaded(true);
|
|
58
|
+
}
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
fetchStatus();
|
|
63
|
+
|
|
64
|
+
const start = () => {
|
|
65
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
66
|
+
intervalRef.current = setInterval(fetchStatus, 30_000);
|
|
67
|
+
};
|
|
68
|
+
const stop = () => {
|
|
69
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
70
|
+
intervalRef.current = undefined;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
start();
|
|
74
|
+
|
|
75
|
+
const onVisibility = () => {
|
|
76
|
+
if (document.visibilityState === 'visible') {
|
|
77
|
+
fetchStatus();
|
|
78
|
+
start();
|
|
79
|
+
} else {
|
|
80
|
+
stop();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
84
|
+
|
|
85
|
+
return () => {
|
|
86
|
+
stop();
|
|
87
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
88
|
+
};
|
|
89
|
+
}, [fetchStatus]);
|
|
90
|
+
|
|
91
|
+
return { status, loaded, fetchStatus };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default function SyncStatusBar({ collapsed, onOpenSyncSettings }: SyncStatusBarProps) {
|
|
95
|
+
const { status, loaded, fetchStatus } = useSyncStatus();
|
|
96
|
+
const [syncing, setSyncing] = useState(false);
|
|
97
|
+
const [syncResult, setSyncResult] = useState<'success' | 'error' | null>(null);
|
|
98
|
+
const [toast, setToast] = useState<string | null>(null);
|
|
99
|
+
const prevLevelRef = useRef<StatusLevel>('off');
|
|
100
|
+
const [hintDismissed, setHintDismissed] = useState(() => {
|
|
101
|
+
if (typeof window !== 'undefined') {
|
|
102
|
+
try { return !!localStorage.getItem('sync-hint-dismissed'); } catch {}
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
});
|
|
106
|
+
const { t } = useLocale();
|
|
107
|
+
|
|
108
|
+
// #1 — refresh timeAgo display every 60s
|
|
109
|
+
useTick(60_000);
|
|
110
|
+
|
|
111
|
+
// Task G — detect first sync or recovery from error and show toast
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!loaded || syncing) return;
|
|
114
|
+
const currentLevel = getStatusLevel(status, false);
|
|
115
|
+
const prev = prevLevelRef.current;
|
|
116
|
+
if (prev !== currentLevel) {
|
|
117
|
+
const syncT = (t as any).sidebar?.sync;
|
|
118
|
+
// Recovery: was error/conflicts, now synced
|
|
119
|
+
if ((prev === 'error' || prev === 'conflicts') && currentLevel === 'synced') {
|
|
120
|
+
setToast(syncT?.syncRestored ?? 'Sync restored');
|
|
121
|
+
setTimeout(() => setToast(null), 3000);
|
|
122
|
+
}
|
|
123
|
+
prevLevelRef.current = currentLevel;
|
|
124
|
+
}
|
|
125
|
+
}, [status, loaded, syncing, t]);
|
|
126
|
+
|
|
127
|
+
const handleSyncNow = async (e: React.MouseEvent) => {
|
|
128
|
+
e.stopPropagation();
|
|
129
|
+
if (syncing) return;
|
|
130
|
+
setSyncing(true);
|
|
131
|
+
setSyncResult(null);
|
|
132
|
+
try {
|
|
133
|
+
await apiFetch('/api/sync', {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'Content-Type': 'application/json' },
|
|
136
|
+
body: JSON.stringify({ action: 'now' }),
|
|
137
|
+
});
|
|
138
|
+
await fetchStatus();
|
|
139
|
+
setSyncResult('success'); // #2 — flash feedback
|
|
140
|
+
} catch {
|
|
141
|
+
await fetchStatus();
|
|
142
|
+
setSyncResult('error'); // #2
|
|
143
|
+
} finally {
|
|
144
|
+
setSyncing(false);
|
|
145
|
+
setTimeout(() => setSyncResult(null), 2500); // #2 — auto-clear
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (!loaded || collapsed) return null;
|
|
150
|
+
|
|
151
|
+
const level = getStatusLevel(status, syncing);
|
|
152
|
+
|
|
153
|
+
// Task E — Show dismissible hint when sync is not configured
|
|
154
|
+
if (level === 'off') {
|
|
155
|
+
if (hintDismissed) return null;
|
|
156
|
+
const syncT = (t as any).sidebar?.sync;
|
|
157
|
+
return (
|
|
158
|
+
<div className="hidden md:flex items-center justify-between px-4 py-1.5 border-t border-border text-xs text-muted-foreground shrink-0 animate-in fade-in duration-300">
|
|
159
|
+
<button
|
|
160
|
+
onClick={onOpenSyncSettings}
|
|
161
|
+
className="flex items-center gap-2 min-w-0 hover:text-foreground transition-colors truncate"
|
|
162
|
+
title={syncT?.enableHint ?? 'Set up cross-device sync'}
|
|
163
|
+
>
|
|
164
|
+
<span className="w-2 h-2 rounded-full shrink-0 bg-muted-foreground/40" />
|
|
165
|
+
<span className="truncate">{syncT?.enableSync ?? 'Enable sync'} →</span>
|
|
166
|
+
</button>
|
|
167
|
+
<button
|
|
168
|
+
onClick={(e) => {
|
|
169
|
+
e.stopPropagation();
|
|
170
|
+
try { localStorage.setItem('sync-hint-dismissed', '1'); } catch {}
|
|
171
|
+
setHintDismissed(true);
|
|
172
|
+
}}
|
|
173
|
+
className="p-1 rounded hover:bg-muted hover:text-foreground transition-colors shrink-0 ml-2 text-muted-foreground/50 hover:text-muted-foreground"
|
|
174
|
+
title="Dismiss"
|
|
175
|
+
>
|
|
176
|
+
<span className="text-[10px]">✕</span>
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const syncT = (t as any).sidebar?.sync;
|
|
183
|
+
const unpushedCount = parseInt(status?.unpushed || '0', 10);
|
|
184
|
+
const conflictCount = status?.conflicts?.length || 0;
|
|
185
|
+
|
|
186
|
+
let label: string;
|
|
187
|
+
let tooltip: string;
|
|
188
|
+
switch (level) {
|
|
189
|
+
case 'syncing':
|
|
190
|
+
label = syncT?.syncing ?? 'Syncing...';
|
|
191
|
+
tooltip = label;
|
|
192
|
+
break;
|
|
193
|
+
case 'synced':
|
|
194
|
+
label = `${syncT?.synced ?? 'Synced'} · ${timeAgo(status?.lastSync)}`;
|
|
195
|
+
tooltip = label;
|
|
196
|
+
break;
|
|
197
|
+
case 'unpushed':
|
|
198
|
+
// #4 — clearer wording
|
|
199
|
+
label = `${unpushedCount} ${syncT?.unpushed ?? 'awaiting push'}`;
|
|
200
|
+
tooltip = syncT?.unpushedHint ?? `${unpushedCount} commit(s) not yet pushed to remote`;
|
|
201
|
+
break;
|
|
202
|
+
case 'conflicts':
|
|
203
|
+
label = `${conflictCount} ${syncT?.conflicts ?? 'conflicts'}`;
|
|
204
|
+
tooltip = syncT?.conflictsHint ?? `${conflictCount} file(s) have merge conflicts — resolve in Settings > Sync`;
|
|
205
|
+
break;
|
|
206
|
+
case 'error':
|
|
207
|
+
label = syncT?.syncError ?? 'Sync error';
|
|
208
|
+
// #5 — show actual error message on hover
|
|
209
|
+
tooltip = status?.lastError || label;
|
|
210
|
+
break;
|
|
211
|
+
default:
|
|
212
|
+
label = syncT?.syncOff ?? 'Sync off';
|
|
213
|
+
tooltip = label;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
// #3 — fade-in via animate-in
|
|
218
|
+
<div className="hidden md:flex items-center justify-between px-4 py-1.5 border-t border-border text-xs text-muted-foreground shrink-0 animate-in fade-in duration-300">
|
|
219
|
+
<button
|
|
220
|
+
onClick={onOpenSyncSettings}
|
|
221
|
+
className="flex items-center gap-2 min-w-0 hover:text-foreground transition-colors truncate"
|
|
222
|
+
title={tooltip}
|
|
223
|
+
>
|
|
224
|
+
<span
|
|
225
|
+
className={`w-2 h-2 rounded-full shrink-0 ${DOT_COLORS[level]} ${
|
|
226
|
+
level === 'syncing' ? 'animate-pulse' :
|
|
227
|
+
level === 'conflicts' ? 'animate-pulse' : '' // #6 — conflicts pulse
|
|
228
|
+
}`}
|
|
229
|
+
/>
|
|
230
|
+
<span className="truncate">{toast || label}</span>
|
|
231
|
+
</button>
|
|
232
|
+
<div className="flex items-center gap-1 shrink-0 ml-2">
|
|
233
|
+
{/* #2 — sync result flash */}
|
|
234
|
+
{(syncResult === 'success' || toast) && <CheckCircle2 size={12} className="text-green-500 animate-in fade-in duration-200" />}
|
|
235
|
+
{syncResult === 'error' && <XCircle size={12} className="text-red-500 animate-in fade-in duration-200" />}
|
|
236
|
+
<button
|
|
237
|
+
onClick={handleSyncNow}
|
|
238
|
+
disabled={syncing}
|
|
239
|
+
className="p-1 rounded hover:bg-muted hover:text-foreground transition-colors disabled:opacity-40"
|
|
240
|
+
title={syncT?.syncNow ?? 'Sync now'}
|
|
241
|
+
>
|
|
242
|
+
<RefreshCw size={12} className={syncing ? 'animate-spin' : ''} />
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// #7 — Minimal dot for collapsed sidebar
|
|
250
|
+
export function SyncDot({ status, syncing }: { status: SyncStatus | null; syncing?: boolean }) {
|
|
251
|
+
const level = getStatusLevel(status, syncing ?? false);
|
|
252
|
+
if (level === 'off') return null;
|
|
253
|
+
return (
|
|
254
|
+
<span
|
|
255
|
+
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ${DOT_COLORS[level]} ${
|
|
256
|
+
level === 'conflicts' || level === 'error' ? 'animate-pulse' : ''
|
|
257
|
+
}`}
|
|
258
|
+
/>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// #8 — Small dot for mobile header
|
|
263
|
+
export function MobileSyncDot({ status, syncing }: { status: SyncStatus | null; syncing?: boolean }) {
|
|
264
|
+
const level = getStatusLevel(status, syncing ?? false);
|
|
265
|
+
if (level === 'off' || level === 'synced') return null; // only show when attention needed
|
|
266
|
+
return (
|
|
267
|
+
<span
|
|
268
|
+
className={`w-1.5 h-1.5 rounded-full ${DOT_COLORS[level]} ${
|
|
269
|
+
level === 'conflicts' || level === 'error' ? 'animate-pulse' : ''
|
|
270
|
+
}`}
|
|
271
|
+
/>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
@@ -26,14 +26,17 @@ interface AgentOp {
|
|
|
26
26
|
|
|
27
27
|
function parseOps(content: string): AgentOp[] {
|
|
28
28
|
const ops: AgentOp[] = [];
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
|
|
30
|
+
// JSON Lines format: each line is a JSON object
|
|
31
|
+
for (const line of content.split('\n')) {
|
|
32
|
+
const trimmed = line.trim();
|
|
33
|
+
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) continue;
|
|
32
34
|
try {
|
|
33
|
-
const op = JSON.parse(
|
|
35
|
+
const op = JSON.parse(trimmed) as AgentOp;
|
|
34
36
|
if (op.tool && op.ts) ops.push(op);
|
|
35
|
-
} catch { /* skip
|
|
37
|
+
} catch { /* skip non-JSON lines */ }
|
|
36
38
|
}
|
|
39
|
+
|
|
37
40
|
// newest first
|
|
38
41
|
return ops.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
|
|
39
42
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
-
import { RefreshCw, AlertCircle, CheckCircle2, Loader2 } from 'lucide-react';
|
|
4
|
+
import { RefreshCw, AlertCircle, CheckCircle2, Loader2, GitBranch, Copy, Check, ExternalLink } from 'lucide-react';
|
|
5
5
|
import { SectionLabel } from './Primitives';
|
|
6
6
|
import { apiFetch } from '@/lib/api';
|
|
7
7
|
|
|
8
|
-
interface SyncStatus {
|
|
8
|
+
export interface SyncStatus {
|
|
9
9
|
enabled: boolean;
|
|
10
10
|
provider?: string;
|
|
11
11
|
remote?: string;
|
|
@@ -23,7 +23,7 @@ interface SyncTabProps {
|
|
|
23
23
|
t: any;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function timeAgo(iso: string | null | undefined): string {
|
|
26
|
+
export function timeAgo(iso: string | null | undefined): string {
|
|
27
27
|
if (!iso) return 'never';
|
|
28
28
|
const diff = Date.now() - new Date(iso).getTime();
|
|
29
29
|
if (diff < 60000) return 'just now';
|
|
@@ -32,6 +32,95 @@ function timeAgo(iso: string | null | undefined): string {
|
|
|
32
32
|
return `${Math.floor(diff / 86400000)}d ago`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/* ── Copy-to-clipboard button ──────────────────────────────────── */
|
|
36
|
+
|
|
37
|
+
function CopyButton({ text }: { text: string }) {
|
|
38
|
+
const [copied, setCopied] = useState(false);
|
|
39
|
+
const handleCopy = async () => {
|
|
40
|
+
await navigator.clipboard.writeText(text);
|
|
41
|
+
setCopied(true);
|
|
42
|
+
setTimeout(() => setCopied(false), 2000);
|
|
43
|
+
};
|
|
44
|
+
return (
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
onClick={handleCopy}
|
|
48
|
+
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
|
49
|
+
title="Copy command"
|
|
50
|
+
>
|
|
51
|
+
{copied ? <Check size={12} className="text-green-500" /> : <Copy size={12} />}
|
|
52
|
+
</button>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* ── Empty state (Task D) ──────────────────────────────────────── */
|
|
57
|
+
|
|
58
|
+
function SyncEmptyState({ t }: { t: any }) {
|
|
59
|
+
const syncT = t.settings?.sync;
|
|
60
|
+
const cmd = 'mindos sync init';
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="space-y-6">
|
|
64
|
+
{/* Header */}
|
|
65
|
+
<div className="flex items-center gap-3">
|
|
66
|
+
<div className="w-9 h-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
|
67
|
+
<GitBranch size={18} className="text-muted-foreground" />
|
|
68
|
+
</div>
|
|
69
|
+
<div>
|
|
70
|
+
<h3 className="text-sm font-medium text-foreground">
|
|
71
|
+
{syncT?.emptyTitle ?? 'Cross-device Sync'}
|
|
72
|
+
</h3>
|
|
73
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
74
|
+
{syncT?.emptyDesc ?? 'Automatically sync your knowledge base across devices via Git.'}
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Steps */}
|
|
80
|
+
<div className="space-y-3">
|
|
81
|
+
<SectionLabel>{syncT?.emptyStepsTitle ?? 'Setup'}</SectionLabel>
|
|
82
|
+
<ol className="space-y-2.5 text-xs text-muted-foreground">
|
|
83
|
+
<li className="flex items-start gap-2.5">
|
|
84
|
+
<span className="w-5 h-5 rounded-full bg-muted flex items-center justify-center shrink-0 text-[10px] font-medium text-foreground mt-0.5">1</span>
|
|
85
|
+
<span>{syncT?.emptyStep1 ?? 'Create a private Git repo (GitHub, GitLab, etc.) or use an existing one.'}</span>
|
|
86
|
+
</li>
|
|
87
|
+
<li className="flex items-start gap-2.5">
|
|
88
|
+
<span className="w-5 h-5 rounded-full bg-muted flex items-center justify-center shrink-0 text-[10px] font-medium text-foreground mt-0.5">2</span>
|
|
89
|
+
<div className="flex-1">
|
|
90
|
+
<span>{syncT?.emptyStep2 ?? 'Run this command in your terminal:'}</span>
|
|
91
|
+
<div className="flex items-center gap-1.5 mt-1.5 px-3 py-2 bg-muted rounded-lg font-mono text-xs text-foreground">
|
|
92
|
+
<code className="flex-1 select-all">{cmd}</code>
|
|
93
|
+
<CopyButton text={cmd} />
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</li>
|
|
97
|
+
<li className="flex items-start gap-2.5">
|
|
98
|
+
<span className="w-5 h-5 rounded-full bg-muted flex items-center justify-center shrink-0 text-[10px] font-medium text-foreground mt-0.5">3</span>
|
|
99
|
+
<span>{syncT?.emptyStep3 ?? 'Follow the prompts to connect your repo. Sync starts automatically.'}</span>
|
|
100
|
+
</li>
|
|
101
|
+
</ol>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{/* Features */}
|
|
105
|
+
<div className="grid grid-cols-2 gap-2 text-[11px] text-muted-foreground">
|
|
106
|
+
{[
|
|
107
|
+
syncT?.featureAutoCommit ?? 'Auto-commit on save',
|
|
108
|
+
syncT?.featureAutoPull ?? 'Auto-pull from remote',
|
|
109
|
+
syncT?.featureConflict ?? 'Conflict detection',
|
|
110
|
+
syncT?.featureMultiDevice ?? 'Works across devices',
|
|
111
|
+
].map((f, i) => (
|
|
112
|
+
<div key={i} className="flex items-center gap-1.5">
|
|
113
|
+
<CheckCircle2 size={11} className="text-green-500/60 shrink-0" />
|
|
114
|
+
<span>{f}</span>
|
|
115
|
+
</div>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ── Main SyncTab ──────────────────────────────────────────────── */
|
|
123
|
+
|
|
35
124
|
export function SyncTab({ t }: SyncTabProps) {
|
|
36
125
|
const [status, setStatus] = useState<SyncStatus | null>(null);
|
|
37
126
|
const [loading, setLoading] = useState(true);
|
|
@@ -101,17 +190,7 @@ export function SyncTab({ t }: SyncTabProps) {
|
|
|
101
190
|
}
|
|
102
191
|
|
|
103
192
|
if (!status || !status.enabled) {
|
|
104
|
-
return
|
|
105
|
-
<div className="space-y-5">
|
|
106
|
-
<SectionLabel>Sync</SectionLabel>
|
|
107
|
-
<div className="text-sm text-muted-foreground space-y-2">
|
|
108
|
-
<p>Git sync is not configured.</p>
|
|
109
|
-
<p className="text-xs">
|
|
110
|
-
Run <code className="font-mono px-1 py-0.5 bg-muted rounded">mindos sync init</code> in the terminal to set up.
|
|
111
|
-
</p>
|
|
112
|
-
</div>
|
|
113
|
-
</div>
|
|
114
|
-
);
|
|
193
|
+
return <SyncEmptyState t={t} />;
|
|
115
194
|
}
|
|
116
195
|
|
|
117
196
|
const conflicts = status.conflicts || [];
|
|
@@ -186,21 +265,34 @@ export function SyncTab({ t }: SyncTabProps) {
|
|
|
186
265
|
</div>
|
|
187
266
|
)}
|
|
188
267
|
|
|
189
|
-
{/* Conflicts */}
|
|
268
|
+
{/* Conflicts (Task H — enhanced with links) */}
|
|
190
269
|
{conflicts.length > 0 && (
|
|
191
270
|
<div className="pt-2 border-t border-border">
|
|
192
271
|
<SectionLabel>Conflicts ({conflicts.length})</SectionLabel>
|
|
193
|
-
<div className="space-y-1">
|
|
272
|
+
<div className="space-y-1.5">
|
|
194
273
|
{conflicts.map((c, i) => (
|
|
195
|
-
<div key={i} className="flex items-center gap-2 text-xs">
|
|
196
|
-
<AlertCircle size={12} className="text-
|
|
197
|
-
<
|
|
198
|
-
|
|
274
|
+
<div key={i} className="flex items-center gap-2 text-xs group">
|
|
275
|
+
<AlertCircle size={12} className="text-red-500 shrink-0" />
|
|
276
|
+
<a
|
|
277
|
+
href={`/view/${encodeURIComponent(c.file)}`}
|
|
278
|
+
className="font-mono truncate hover:text-foreground hover:underline transition-colors"
|
|
279
|
+
title={`Open ${c.file}`}
|
|
280
|
+
>
|
|
281
|
+
{c.file}
|
|
282
|
+
</a>
|
|
283
|
+
<a
|
|
284
|
+
href={`/view/${encodeURIComponent(c.file + '.sync-conflict')}`}
|
|
285
|
+
className="opacity-0 group-hover:opacity-100 transition-opacity shrink-0 text-muted-foreground hover:text-foreground"
|
|
286
|
+
title="View remote version (.sync-conflict)"
|
|
287
|
+
>
|
|
288
|
+
<ExternalLink size={11} />
|
|
289
|
+
</a>
|
|
290
|
+
<span className="text-muted-foreground shrink-0 ml-auto">{timeAgo(c.time)}</span>
|
|
199
291
|
</div>
|
|
200
292
|
))}
|
|
201
293
|
</div>
|
|
202
294
|
<p className="text-xs text-muted-foreground mt-2">
|
|
203
|
-
|
|
295
|
+
Click a file to view your version. Hover and click <ExternalLink size={10} className="inline" /> to see the remote version.
|
|
204
296
|
</p>
|
|
205
297
|
</div>
|
|
206
298
|
)}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getMindRoot } from '@/lib/fs';
|
|
4
|
+
|
|
5
|
+
const LOG_FILE = '.agent-log.json';
|
|
6
|
+
const MAX_SIZE = 500 * 1024; // 500KB
|
|
7
|
+
|
|
8
|
+
interface AgentOpEntry {
|
|
9
|
+
ts: string;
|
|
10
|
+
tool: string;
|
|
11
|
+
params: Record<string, unknown>;
|
|
12
|
+
result: 'ok' | 'error';
|
|
13
|
+
message?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Append an agent operation entry to .agent-log.json (JSON Lines format).
|
|
18
|
+
* Each line is a self-contained JSON object — easy to parse, grep, and tail.
|
|
19
|
+
* Auto-truncates when file exceeds MAX_SIZE.
|
|
20
|
+
*/
|
|
21
|
+
export function logAgentOp(entry: AgentOpEntry): void {
|
|
22
|
+
try {
|
|
23
|
+
const root = getMindRoot();
|
|
24
|
+
const logPath = path.join(root, LOG_FILE);
|
|
25
|
+
|
|
26
|
+
const line = JSON.stringify(entry) + '\n';
|
|
27
|
+
|
|
28
|
+
// Check size and truncate if needed
|
|
29
|
+
if (fs.existsSync(logPath)) {
|
|
30
|
+
const stat = fs.statSync(logPath);
|
|
31
|
+
if (stat.size > MAX_SIZE) {
|
|
32
|
+
const content = fs.readFileSync(logPath, 'utf-8');
|
|
33
|
+
const lines = content.trimEnd().split('\n');
|
|
34
|
+
// Keep the newer half
|
|
35
|
+
const kept = lines.slice(Math.floor(lines.length / 2));
|
|
36
|
+
fs.writeFileSync(logPath, kept.join('\n') + '\n');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fs.appendFileSync(logPath, line);
|
|
41
|
+
} catch {
|
|
42
|
+
// Logging should never break tool execution
|
|
43
|
+
}
|
|
44
|
+
}
|