@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.
@@ -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
- const re = /```agent-op\n([\s\S]*?)```/g;
30
- let m: RegExpExecArray | null;
31
- while ((m = re.exec(content)) !== null) {
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(m[1].trim()) as AgentOp;
35
+ const op = JSON.parse(trimmed) as AgentOp;
34
36
  if (op.tool && op.ts) ops.push(op);
35
- } catch { /* skip malformed */ }
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-amber-500 shrink-0" />
197
- <span className="font-mono truncate">{c.file}</span>
198
- <span className="text-muted-foreground shrink-0">{timeAgo(c.time)}</span>
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
- Remote versions saved as <code className="font-mono">.sync-conflict</code> files.
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
+ }