@geminilight/mindos 0.1.9 → 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 +42 -12
- package/README_zh.md +38 -5
- package/app/README.md +1 -1
- package/app/app/api/init/route.ts +56 -0
- package/app/app/api/sync/route.ts +124 -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 +10 -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 +311 -0
- package/app/components/settings/types.ts +1 -1
- package/app/lib/agent/log.ts +44 -0
- package/app/lib/agent/tools.ts +39 -18
- package/app/lib/i18n.ts +80 -2
- package/app/lib/renderers/index.ts +13 -0
- package/app/lib/settings.ts +2 -2
- 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 +214 -10
- package/bin/lib/config.js +12 -1
- package/bin/lib/mcp-install.js +225 -70
- package/bin/lib/startup.js +24 -1
- package/bin/lib/sync.js +367 -0
- package/mcp/src/index.ts +37 -10
- package/package.json +6 -2
- package/scripts/release.sh +56 -0
- package/scripts/setup.js +35 -6
- package/templates/README.md +1 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { Sparkles, Globe, BookOpen, FileText, Loader2, GitBranch } from 'lucide-react';
|
|
6
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
7
|
+
|
|
8
|
+
type Template = 'en' | 'zh' | 'empty';
|
|
9
|
+
|
|
10
|
+
const TEMPLATES: Array<{
|
|
11
|
+
id: Template;
|
|
12
|
+
icon: React.ReactNode;
|
|
13
|
+
dirs: string[];
|
|
14
|
+
}> = [
|
|
15
|
+
{
|
|
16
|
+
id: 'en',
|
|
17
|
+
icon: <Globe size={20} />,
|
|
18
|
+
dirs: ['Profile/', 'Connections/', 'Notes/', 'Workflows/', 'Resources/', 'Projects/'],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'zh',
|
|
22
|
+
icon: <BookOpen size={20} />,
|
|
23
|
+
dirs: ['画像/', '关系/', '笔记/', '流程/', '资源/', '项目/'],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'empty',
|
|
27
|
+
icon: <FileText size={20} />,
|
|
28
|
+
dirs: ['README.md', 'CONFIG.json', 'INSTRUCTION.md'],
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export default function OnboardingView() {
|
|
33
|
+
const { t } = useLocale();
|
|
34
|
+
const router = useRouter();
|
|
35
|
+
const [loading, setLoading] = useState<Template | null>(null);
|
|
36
|
+
|
|
37
|
+
const ob = t.onboarding;
|
|
38
|
+
|
|
39
|
+
async function handleSelect(template: Template) {
|
|
40
|
+
setLoading(template);
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch('/api/init', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ template }),
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const data = await res.json().catch(() => ({}));
|
|
49
|
+
throw new Error(data.error || `HTTP ${res.status}`);
|
|
50
|
+
}
|
|
51
|
+
router.refresh();
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.error('[Onboarding] init failed:', e);
|
|
54
|
+
setLoading(null);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="content-width px-4 md:px-6 py-12 md:py-20">
|
|
60
|
+
{/* Header */}
|
|
61
|
+
<div className="text-center mb-10">
|
|
62
|
+
<div className="inline-flex items-center gap-2 mb-4">
|
|
63
|
+
<Sparkles size={18} style={{ color: 'var(--amber)' }} />
|
|
64
|
+
<h1
|
|
65
|
+
className="text-2xl font-semibold tracking-tight"
|
|
66
|
+
style={{ fontFamily: "'IBM Plex Mono', monospace", color: 'var(--foreground)' }}
|
|
67
|
+
>
|
|
68
|
+
MindOS
|
|
69
|
+
</h1>
|
|
70
|
+
</div>
|
|
71
|
+
<p
|
|
72
|
+
className="text-sm leading-relaxed max-w-md mx-auto"
|
|
73
|
+
style={{ color: 'var(--muted-foreground)' }}
|
|
74
|
+
>
|
|
75
|
+
{ob.subtitle}
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Template cards */}
|
|
80
|
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 max-w-2xl mx-auto mb-10">
|
|
81
|
+
{TEMPLATES.map((tpl) => {
|
|
82
|
+
const isLoading = loading === tpl.id;
|
|
83
|
+
const isDisabled = loading !== null;
|
|
84
|
+
return (
|
|
85
|
+
<button
|
|
86
|
+
key={tpl.id}
|
|
87
|
+
disabled={isDisabled}
|
|
88
|
+
onClick={() => handleSelect(tpl.id)}
|
|
89
|
+
className="group relative flex flex-col items-start gap-3 p-5 rounded-xl border text-left transition-all duration-150 hover:border-amber-500/50 hover:bg-amber-500/5 disabled:opacity-60 disabled:cursor-not-allowed"
|
|
90
|
+
style={{ background: 'var(--card)', borderColor: 'var(--border)' }}
|
|
91
|
+
>
|
|
92
|
+
{/* Icon + title */}
|
|
93
|
+
<div className="flex items-center gap-2.5 w-full">
|
|
94
|
+
<span style={{ color: 'var(--amber)' }}>{tpl.icon}</span>
|
|
95
|
+
<span
|
|
96
|
+
className="text-sm font-semibold"
|
|
97
|
+
style={{ color: 'var(--foreground)', fontFamily: "'IBM Plex Sans', sans-serif" }}
|
|
98
|
+
>
|
|
99
|
+
{ob.templates[tpl.id].title}
|
|
100
|
+
</span>
|
|
101
|
+
{isLoading && (
|
|
102
|
+
<Loader2 size={14} className="animate-spin ml-auto" style={{ color: 'var(--amber)' }} />
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{/* Description */}
|
|
107
|
+
<p className="text-xs leading-relaxed" style={{ color: 'var(--muted-foreground)' }}>
|
|
108
|
+
{ob.templates[tpl.id].desc}
|
|
109
|
+
</p>
|
|
110
|
+
|
|
111
|
+
{/* Directory preview */}
|
|
112
|
+
<div
|
|
113
|
+
className="w-full rounded-lg px-3 py-2 text-[11px] leading-relaxed"
|
|
114
|
+
style={{
|
|
115
|
+
background: 'var(--muted)',
|
|
116
|
+
fontFamily: "'IBM Plex Mono', monospace",
|
|
117
|
+
color: 'var(--muted-foreground)',
|
|
118
|
+
opacity: 0.8,
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{tpl.dirs.map((d) => (
|
|
122
|
+
<div key={d}>{d}</div>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
</button>
|
|
126
|
+
);
|
|
127
|
+
})}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* Import hint */}
|
|
131
|
+
<p
|
|
132
|
+
className="text-center text-xs leading-relaxed max-w-sm mx-auto"
|
|
133
|
+
style={{ color: 'var(--muted-foreground)', opacity: 0.6, fontFamily: "'IBM Plex Mono', monospace" }}
|
|
134
|
+
>
|
|
135
|
+
{ob.importHint}
|
|
136
|
+
</p>
|
|
137
|
+
|
|
138
|
+
{/* Sync hint card */}
|
|
139
|
+
<div
|
|
140
|
+
className="max-w-md mx-auto mt-6 flex items-center gap-3 px-4 py-3 rounded-lg border text-left"
|
|
141
|
+
style={{ borderColor: 'var(--border)', background: 'var(--card)' }}
|
|
142
|
+
>
|
|
143
|
+
<GitBranch size={16} style={{ color: 'var(--muted-foreground)', flexShrink: 0 }} />
|
|
144
|
+
<div className="min-w-0">
|
|
145
|
+
<p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>
|
|
146
|
+
{ob.syncHint ?? 'Want cross-device sync? Run'}
|
|
147
|
+
{' '}
|
|
148
|
+
<code
|
|
149
|
+
className="font-mono px-1 py-0.5 rounded select-all"
|
|
150
|
+
style={{ background: 'var(--muted)', fontSize: '11px' }}
|
|
151
|
+
>
|
|
152
|
+
mindos sync init
|
|
153
|
+
</code>
|
|
154
|
+
{' '}
|
|
155
|
+
{ob.syncHintSuffix ?? 'in the terminal after setup.'}
|
|
156
|
+
</p>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -13,13 +13,15 @@ import { AppearanceTab } from './settings/AppearanceTab';
|
|
|
13
13
|
import { KnowledgeTab } from './settings/KnowledgeTab';
|
|
14
14
|
import { PluginsTab } from './settings/PluginsTab';
|
|
15
15
|
import { ShortcutsTab } from './settings/ShortcutsTab';
|
|
16
|
+
import { SyncTab } from './settings/SyncTab';
|
|
16
17
|
|
|
17
18
|
interface SettingsModalProps {
|
|
18
19
|
open: boolean;
|
|
19
20
|
onClose: () => void;
|
|
21
|
+
initialTab?: Tab;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
export default function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|
24
|
+
export default function SettingsModal({ open, onClose, initialTab }: SettingsModalProps) {
|
|
23
25
|
const [tab, setTab] = useState<Tab>('ai');
|
|
24
26
|
const [data, setData] = useState<SettingsData | null>(null);
|
|
25
27
|
const [saving, setSaving] = useState(false);
|
|
@@ -47,6 +49,11 @@ export default function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|
|
47
49
|
setStatus('idle');
|
|
48
50
|
}, [open]);
|
|
49
51
|
|
|
52
|
+
// Switch to requested tab when opening with initialTab
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (open && initialTab) setTab(initialTab);
|
|
55
|
+
}, [open, initialTab]);
|
|
56
|
+
|
|
50
57
|
// Apply font immediately
|
|
51
58
|
useEffect(() => {
|
|
52
59
|
const fontMap: Record<string, string> = {
|
|
@@ -131,6 +138,7 @@ export default function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|
|
131
138
|
{ id: 'ai', label: t.settings.tabs.ai },
|
|
132
139
|
{ id: 'appearance', label: t.settings.tabs.appearance },
|
|
133
140
|
{ id: 'knowledge', label: t.settings.tabs.knowledge },
|
|
141
|
+
{ id: 'sync', label: t.settings.tabs.sync ?? 'Sync' },
|
|
134
142
|
{ id: 'plugins', label: t.settings.tabs.plugins },
|
|
135
143
|
{ id: 'shortcuts', label: t.settings.tabs.shortcuts },
|
|
136
144
|
];
|
|
@@ -193,6 +201,7 @@ export default function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|
|
193
201
|
{tab === 'knowledge' && data && <KnowledgeTab data={data} setData={setData} t={t} />}
|
|
194
202
|
{tab === 'plugins' && <PluginsTab pluginStates={pluginStates} setPluginStates={setPluginStates} t={t} />}
|
|
195
203
|
{tab === 'shortcuts' && <ShortcutsTab t={t} />}
|
|
204
|
+
{tab === 'sync' && <SyncTab t={t} />}
|
|
196
205
|
</>
|
|
197
206
|
)}
|
|
198
207
|
</div>
|
|
@@ -8,7 +8,9 @@ import FileTree from './FileTree';
|
|
|
8
8
|
import SearchModal from './SearchModal';
|
|
9
9
|
import AskModal from './AskModal';
|
|
10
10
|
import SettingsModal from './SettingsModal';
|
|
11
|
+
import SyncStatusBar, { SyncDot, MobileSyncDot, useSyncStatus } from './SyncStatusBar';
|
|
11
12
|
import { FileNode } from '@/lib/types';
|
|
13
|
+
import type { Tab } from './settings/types';
|
|
12
14
|
import { useLocale } from '@/lib/LocaleContext';
|
|
13
15
|
|
|
14
16
|
interface SidebarProps {
|
|
@@ -40,9 +42,13 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
|
|
|
40
42
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
41
43
|
const [askOpen, setAskOpen] = useState(false);
|
|
42
44
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
45
|
+
const [settingsTab, setSettingsTab] = useState<Tab | undefined>(undefined);
|
|
43
46
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
44
47
|
const { t } = useLocale();
|
|
45
48
|
|
|
49
|
+
// Shared sync status for collapsed dot & mobile dot
|
|
50
|
+
const { status: syncStatus } = useSyncStatus();
|
|
51
|
+
|
|
46
52
|
const pathname = usePathname();
|
|
47
53
|
const currentFile = pathname.startsWith('/view/')
|
|
48
54
|
? pathname.slice('/view/'.length).split('/').map(decodeURIComponent).join('/')
|
|
@@ -60,6 +66,8 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
|
|
|
60
66
|
|
|
61
67
|
useEffect(() => { setMobileOpen(false); }, [pathname]);
|
|
62
68
|
|
|
69
|
+
const openSyncSettings = () => { setSettingsTab('sync'); setSettingsOpen(true); };
|
|
70
|
+
|
|
63
71
|
const sidebarContent = (
|
|
64
72
|
<div className="flex flex-col h-full">
|
|
65
73
|
<div className="flex items-center justify-between px-4 py-4 border-b border-border shrink-0">
|
|
@@ -88,6 +96,10 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
|
|
|
88
96
|
<div className="flex-1 overflow-y-auto min-h-0 px-2 py-2">
|
|
89
97
|
<FileTree nodes={fileTree} onNavigate={() => setMobileOpen(false)} />
|
|
90
98
|
</div>
|
|
99
|
+
<SyncStatusBar
|
|
100
|
+
collapsed={collapsed}
|
|
101
|
+
onOpenSyncSettings={openSyncSettings}
|
|
102
|
+
/>
|
|
91
103
|
</div>
|
|
92
104
|
);
|
|
93
105
|
|
|
@@ -97,10 +109,14 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
|
|
|
97
109
|
{sidebarContent}
|
|
98
110
|
</aside>
|
|
99
111
|
|
|
112
|
+
{/* #7 — Collapsed sidebar: expand button with sync health dot */}
|
|
100
113
|
{collapsed && (
|
|
101
|
-
<
|
|
102
|
-
<
|
|
103
|
-
|
|
114
|
+
<div className="hidden md:flex fixed top-4 left-0 z-30 flex-col items-center gap-2">
|
|
115
|
+
<button onClick={onExpand} className="relative flex items-center justify-center w-6 h-10 bg-card border border-border rounded-r-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" title={t.sidebar.expandTitle}>
|
|
116
|
+
<PanelLeftOpen size={14} />
|
|
117
|
+
<SyncDot status={syncStatus} />
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
104
120
|
)}
|
|
105
121
|
|
|
106
122
|
{/* Mobile navbar */}
|
|
@@ -113,6 +129,14 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
|
|
|
113
129
|
<span className="font-semibold text-foreground text-sm tracking-wide">MindOS</span>
|
|
114
130
|
</Link>
|
|
115
131
|
<div className="flex items-center gap-0.5">
|
|
132
|
+
{/* #8 — Mobile sync dot: visible when there's a problem */}
|
|
133
|
+
<button
|
|
134
|
+
onClick={openSyncSettings}
|
|
135
|
+
className="p-2.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors active:bg-accent flex items-center justify-center"
|
|
136
|
+
aria-label="Sync status"
|
|
137
|
+
>
|
|
138
|
+
<MobileSyncDot status={syncStatus} />
|
|
139
|
+
</button>
|
|
116
140
|
<button onClick={() => setSearchOpen(true)} className="p-2.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors active:bg-accent" aria-label={t.sidebar.searchTitle}>
|
|
117
141
|
<Search size={20} />
|
|
118
142
|
</button>
|
|
@@ -130,7 +154,7 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
|
|
|
130
154
|
|
|
131
155
|
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
|
|
132
156
|
<AskModal open={askOpen} onClose={() => setAskOpen(false)} currentFile={currentFile} />
|
|
133
|
-
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
|
157
|
+
<SettingsModal open={settingsOpen} onClose={() => { setSettingsOpen(false); setSettingsTab(undefined); }} initialTab={settingsTab} />
|
|
134
158
|
</>
|
|
135
159
|
);
|
|
136
160
|
}
|
|
@@ -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
|
}
|