@geminilight/mindos 0.5.62 → 0.5.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/app/api/changes/route.ts +7 -1
- package/app/app/api/mcp/install-skill/route.ts +9 -24
- package/app/app/api/mcp/status/route.ts +1 -1
- package/app/app/layout.tsx +1 -0
- package/app/app/page.tsx +1 -2
- package/app/app/view/[...path]/ViewPageClient.tsx +0 -1
- package/app/components/HomeContent.tsx +41 -6
- package/app/components/RightAgentDetailPanel.tsx +1 -0
- package/app/components/SidebarLayout.tsx +1 -0
- package/app/components/agents/AgentsContentPage.tsx +20 -16
- package/app/components/agents/AgentsMcpSection.tsx +178 -65
- package/app/components/agents/AgentsOverviewSection.tsx +1 -1
- package/app/components/agents/AgentsSkillsSection.tsx +78 -55
- package/app/components/agents/agents-content-model.ts +16 -0
- package/app/components/changes/ChangesBanner.tsx +90 -13
- package/app/components/changes/ChangesContentPage.tsx +134 -51
- package/app/components/panels/AgentsPanel.tsx +14 -28
- package/app/components/panels/AgentsPanelAgentDetail.tsx +5 -4
- package/app/components/panels/AgentsPanelAgentGroups.tsx +5 -6
- package/app/components/panels/AgentsPanelAgentListRow.tsx +30 -5
- package/app/components/panels/AgentsPanelHubNav.tsx +12 -12
- package/app/components/panels/PluginsPanel.tsx +3 -3
- package/app/components/renderers/agent-inspector/manifest.ts +2 -0
- package/app/components/renderers/config/manifest.ts +1 -0
- package/app/components/renderers/csv/manifest.ts +1 -0
- package/app/components/settings/PluginsTab.tsx +4 -3
- package/app/hooks/useMcpData.tsx +3 -2
- package/app/lib/core/content-changes.ts +148 -8
- package/app/lib/fs.ts +7 -1
- package/app/lib/i18n-en.ts +58 -3
- package/app/lib/i18n-zh.ts +58 -3
- package/app/lib/mcp-agents.ts +42 -0
- package/app/lib/renderers/registry.ts +10 -0
- package/app/next-env.d.ts +1 -1
- package/bin/lib/mcp-agents.js +38 -13
- package/package.json +1 -1
- package/scripts/migrate-agent-diff.js +146 -0
- package/scripts/setup.js +12 -17
- package/skills/plugin-core-builtin-migration/SKILL.md +178 -0
- package/app/components/renderers/diff/DiffRenderer.tsx +0 -311
- package/app/components/renderers/diff/manifest.ts +0 -14
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useMemo, useState } from 'react';
|
|
4
|
-
import {
|
|
4
|
+
import { Search } from 'lucide-react';
|
|
5
5
|
import { Toggle } from '@/components/settings/Primitives';
|
|
6
6
|
import type { McpContextValue } from '@/hooks/useMcpData';
|
|
7
7
|
import type { AgentBuckets, SkillSourceFilter } from './agents-content-model';
|
|
@@ -15,12 +15,15 @@ export default function AgentsSkillsSection({
|
|
|
15
15
|
copy: {
|
|
16
16
|
title: string;
|
|
17
17
|
capabilityGroups: string;
|
|
18
|
+
tabs: {
|
|
19
|
+
manage: string;
|
|
20
|
+
matrix: string;
|
|
21
|
+
};
|
|
18
22
|
searchPlaceholder: string;
|
|
19
23
|
sourceAll: string;
|
|
20
24
|
sourceBuiltin: string;
|
|
21
25
|
sourceUser: string;
|
|
22
26
|
emptyGroup: string;
|
|
23
|
-
matrixToggle: string;
|
|
24
27
|
groupLabels: Record<'research' | 'coding' | 'docs' | 'ops' | 'memory', string>;
|
|
25
28
|
};
|
|
26
29
|
mcp: McpContextValue;
|
|
@@ -28,72 +31,70 @@ export default function AgentsSkillsSection({
|
|
|
28
31
|
}) {
|
|
29
32
|
const [query, setQuery] = useState('');
|
|
30
33
|
const [source, setSource] = useState<SkillSourceFilter>('all');
|
|
31
|
-
const [
|
|
34
|
+
const [view, setView] = useState<'manage' | 'matrix'>('manage');
|
|
32
35
|
|
|
33
36
|
const filtered = useMemo(() => filterSkills(mcp.skills, query, source), [mcp.skills, query, source]);
|
|
34
37
|
const grouped = useMemo(() => groupSkillsByCapability(filtered), [filtered]);
|
|
35
38
|
const knownAgents = useMemo(() => [...buckets.connected, ...buckets.detected, ...buckets.notFound], [buckets]);
|
|
36
39
|
|
|
37
40
|
return (
|
|
38
|
-
<section
|
|
39
|
-
<div>
|
|
41
|
+
<section className="rounded-lg border border-border bg-card p-4 space-y-4">
|
|
42
|
+
<div className="flex items-center justify-between gap-3">
|
|
40
43
|
<h2 className="text-sm font-medium text-foreground mb-1">{copy.title}</h2>
|
|
41
|
-
<p className="text-xs text-muted-foreground">{copy.capabilityGroups}</p>
|
|
42
|
-
</div>
|
|
43
|
-
|
|
44
|
-
<div className="flex flex-col md:flex-row gap-2">
|
|
45
|
-
<label className="relative flex-1">
|
|
46
|
-
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
47
|
-
<input
|
|
48
|
-
value={query}
|
|
49
|
-
onChange={(e) => setQuery(e.target.value)}
|
|
50
|
-
placeholder={copy.searchPlaceholder}
|
|
51
|
-
className="w-full h-9 rounded-md border border-border bg-background pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
52
|
-
/>
|
|
53
|
-
</label>
|
|
54
44
|
<div className="flex items-center gap-1 rounded-md border border-border p-1 bg-background">
|
|
55
|
-
<
|
|
56
|
-
<
|
|
57
|
-
<SourceFilterButton active={source === 'user'} label={copy.sourceUser} onClick={() => setSource('user')} />
|
|
45
|
+
<SectionTabButton active={view === 'manage'} label={copy.tabs.manage} onClick={() => setView('manage')} />
|
|
46
|
+
<SectionTabButton active={view === 'matrix'} label={copy.tabs.matrix} onClick={() => setView('matrix')} />
|
|
58
47
|
</div>
|
|
59
48
|
</div>
|
|
49
|
+
<p className="text-xs text-muted-foreground">{copy.capabilityGroups}</p>
|
|
60
50
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
<div
|
|
64
|
-
<
|
|
65
|
-
{
|
|
51
|
+
{view === 'manage' ? (
|
|
52
|
+
<>
|
|
53
|
+
<div className="flex flex-col md:flex-row gap-2">
|
|
54
|
+
<label className="relative flex-1">
|
|
55
|
+
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
56
|
+
<input
|
|
57
|
+
value={query}
|
|
58
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
59
|
+
placeholder={copy.searchPlaceholder}
|
|
60
|
+
className="w-full h-9 rounded-md border border-border bg-background pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
61
|
+
/>
|
|
62
|
+
</label>
|
|
63
|
+
<div className="flex items-center gap-1 rounded-md border border-border p-1 bg-background">
|
|
64
|
+
<SourceFilterButton active={source === 'all'} label={copy.sourceAll} onClick={() => setSource('all')} />
|
|
65
|
+
<SourceFilterButton active={source === 'builtin'} label={copy.sourceBuiltin} onClick={() => setSource('builtin')} />
|
|
66
|
+
<SourceFilterButton active={source === 'user'} label={copy.sourceUser} onClick={() => setSource('user')} />
|
|
66
67
|
</div>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="space-y-4">
|
|
71
|
+
{Object.entries(grouped).map(([groupKey, skills]) => (
|
|
72
|
+
<div key={groupKey} className="rounded-md border border-border p-3">
|
|
73
|
+
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
|
|
74
|
+
{copy.groupLabels[groupKey as keyof typeof copy.groupLabels]} ({skills.length})
|
|
75
|
+
</div>
|
|
76
|
+
{skills.length === 0 ? (
|
|
77
|
+
<p className="text-xs text-muted-foreground">{copy.emptyGroup}</p>
|
|
78
|
+
) : (
|
|
79
|
+
<div className="space-y-1.5">
|
|
80
|
+
{skills.map((skill) => (
|
|
81
|
+
<div key={skill.name} className="flex items-center justify-between gap-2">
|
|
82
|
+
<div className="min-w-0">
|
|
83
|
+
<p className="text-sm text-foreground truncate">{skill.name}</p>
|
|
84
|
+
<p className="text-2xs text-muted-foreground">{skill.source === 'builtin' ? copy.sourceBuiltin : copy.sourceUser}</p>
|
|
85
|
+
</div>
|
|
86
|
+
<Toggle size="sm" checked={skill.enabled} onChange={(v) => void mcp.toggleSkill(skill.name, v)} />
|
|
87
|
+
</div>
|
|
88
|
+
))}
|
|
78
89
|
</div>
|
|
79
|
-
)
|
|
90
|
+
)}
|
|
80
91
|
</div>
|
|
81
|
-
)}
|
|
92
|
+
))}
|
|
82
93
|
</div>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<button
|
|
88
|
-
type="button"
|
|
89
|
-
onClick={() => setMatrixOpen((v) => !v)}
|
|
90
|
-
className="w-full flex items-center justify-between text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
|
|
91
|
-
>
|
|
92
|
-
<span>{copy.matrixToggle}</span>
|
|
93
|
-
{matrixOpen ? <ChevronDown size={14} className="text-muted-foreground" /> : <ChevronRight size={14} className="text-muted-foreground" />}
|
|
94
|
-
</button>
|
|
95
|
-
{matrixOpen && (
|
|
96
|
-
<div className="mt-3 overflow-x-auto">
|
|
94
|
+
</>
|
|
95
|
+
) : (
|
|
96
|
+
<div className="rounded-md border border-border bg-background p-3">
|
|
97
|
+
<div className="overflow-x-auto">
|
|
97
98
|
<table className="w-full text-xs">
|
|
98
99
|
<thead>
|
|
99
100
|
<tr className="border-b border-border">
|
|
@@ -117,8 +118,8 @@ export default function AgentsSkillsSection({
|
|
|
117
118
|
</tbody>
|
|
118
119
|
</table>
|
|
119
120
|
</div>
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
122
123
|
</section>
|
|
123
124
|
);
|
|
124
125
|
}
|
|
@@ -144,3 +145,25 @@ function SourceFilterButton({
|
|
|
144
145
|
</button>
|
|
145
146
|
);
|
|
146
147
|
}
|
|
148
|
+
|
|
149
|
+
function SectionTabButton({
|
|
150
|
+
active,
|
|
151
|
+
label,
|
|
152
|
+
onClick,
|
|
153
|
+
}: {
|
|
154
|
+
active: boolean;
|
|
155
|
+
label: string;
|
|
156
|
+
onClick: () => void;
|
|
157
|
+
}) {
|
|
158
|
+
return (
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
onClick={onClick}
|
|
162
|
+
className={`px-2.5 h-7 rounded text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
|
163
|
+
active ? 'bg-[var(--amber-dim)] text-[var(--amber)]' : 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
|
164
|
+
}`}
|
|
165
|
+
>
|
|
166
|
+
{label}
|
|
167
|
+
</button>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -4,6 +4,7 @@ export type AgentsDashboardTab = 'overview' | 'mcp' | 'skills';
|
|
|
4
4
|
export type AgentResolvedStatus = 'connected' | 'detected' | 'notFound';
|
|
5
5
|
export type SkillCapability = 'research' | 'coding' | 'docs' | 'ops' | 'memory';
|
|
6
6
|
export type SkillSourceFilter = 'all' | 'builtin' | 'user';
|
|
7
|
+
export type AgentStatusFilter = 'all' | 'connected' | 'detected' | 'notFound';
|
|
7
8
|
|
|
8
9
|
export interface RiskItem {
|
|
9
10
|
id: string;
|
|
@@ -78,3 +79,18 @@ export function filterSkills(skills: SkillInfo[], query: string, source: SkillSo
|
|
|
78
79
|
return haystack.includes(q);
|
|
79
80
|
});
|
|
80
81
|
}
|
|
82
|
+
|
|
83
|
+
export function filterAgentsByStatus(agents: AgentInfo[], status: AgentStatusFilter): AgentInfo[] {
|
|
84
|
+
if (status === 'all') return agents;
|
|
85
|
+
return agents.filter((agent) => resolveAgentStatus(agent) === status);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function filterAgentsForMcpTable(agents: AgentInfo[], query: string, status: AgentStatusFilter): AgentInfo[] {
|
|
89
|
+
const q = query.trim().toLowerCase();
|
|
90
|
+
const byStatus = filterAgentsByStatus(agents, status);
|
|
91
|
+
if (!q) return byStatus;
|
|
92
|
+
return byStatus.filter((agent) => {
|
|
93
|
+
const haystack = `${agent.name} ${agent.key} ${agent.configPath ?? ''}`.toLowerCase();
|
|
94
|
+
return haystack.includes(q);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import Link from 'next/link';
|
|
4
|
-
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
5
|
+
import { usePathname } from 'next/navigation';
|
|
6
|
+
import { History, X } from 'lucide-react';
|
|
5
7
|
import { apiFetch } from '@/lib/api';
|
|
8
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
6
9
|
|
|
7
10
|
interface ChangeSummaryPayload {
|
|
8
11
|
unreadCount: number;
|
|
@@ -10,6 +13,11 @@ interface ChangeSummaryPayload {
|
|
|
10
13
|
|
|
11
14
|
export default function ChangesBanner() {
|
|
12
15
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
16
|
+
const [dismissedAtCount, setDismissedAtCount] = useState<number | null>(null);
|
|
17
|
+
const [isRendered, setIsRendered] = useState(false);
|
|
18
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
19
|
+
const pathname = usePathname();
|
|
20
|
+
const { t } = useLocale();
|
|
13
21
|
|
|
14
22
|
useEffect(() => {
|
|
15
23
|
let active = true;
|
|
@@ -29,21 +37,90 @@ export default function ChangesBanner() {
|
|
|
29
37
|
};
|
|
30
38
|
}, []);
|
|
31
39
|
|
|
32
|
-
|
|
40
|
+
const shouldShow = useMemo(() => {
|
|
41
|
+
if (unreadCount <= 0) return false;
|
|
42
|
+
if (pathname?.startsWith('/changes')) return false;
|
|
43
|
+
if (dismissedAtCount !== null && unreadCount <= dismissedAtCount) return false;
|
|
44
|
+
return true;
|
|
45
|
+
}, [dismissedAtCount, pathname, unreadCount]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const durationMs = 160;
|
|
49
|
+
if (shouldShow) {
|
|
50
|
+
setIsRendered(true);
|
|
51
|
+
const raf = requestAnimationFrame(() => setIsVisible(true));
|
|
52
|
+
return () => cancelAnimationFrame(raf);
|
|
53
|
+
}
|
|
54
|
+
setIsVisible(false);
|
|
55
|
+
const timer = setTimeout(() => setIsRendered(false), durationMs);
|
|
56
|
+
return () => clearTimeout(timer);
|
|
57
|
+
}, [shouldShow]);
|
|
58
|
+
|
|
59
|
+
async function handleMarkAllRead() {
|
|
60
|
+
try {
|
|
61
|
+
await apiFetch('/api/changes', {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'Content-Type': 'application/json' },
|
|
64
|
+
body: JSON.stringify({ op: 'mark_seen' }),
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
// Keep UI resilient; polling will recover server state.
|
|
68
|
+
} finally {
|
|
69
|
+
setUnreadCount(0);
|
|
70
|
+
setDismissedAtCount(0);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!isRendered) return null;
|
|
33
75
|
|
|
34
76
|
return (
|
|
35
|
-
<div
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
77
|
+
<div
|
|
78
|
+
className={`fixed right-3 top-[60px] md:right-6 md:top-4 z-30 transition-all duration-150 ease-out ${
|
|
79
|
+
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-2 scale-[0.98] pointer-events-none'
|
|
80
|
+
}`}
|
|
81
|
+
>
|
|
82
|
+
<div
|
|
83
|
+
className="rounded-2xl border bg-card/95 backdrop-blur px-3 py-2.5 shadow-lg min-w-[260px] max-w-[350px]"
|
|
84
|
+
style={{
|
|
85
|
+
borderColor: 'color-mix(in srgb, var(--amber) 45%, var(--border))',
|
|
86
|
+
boxShadow: '0 12px 28px color-mix(in srgb, var(--amber) 14%, rgba(0,0,0,.24))',
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<div className="flex items-start gap-2.5">
|
|
90
|
+
<span
|
|
91
|
+
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-[var(--amber)]"
|
|
92
|
+
style={{ background: 'color-mix(in srgb, var(--amber) 18%, transparent)' }}
|
|
93
|
+
>
|
|
94
|
+
<History size={14} />
|
|
95
|
+
</span>
|
|
96
|
+
<div className="min-w-0 flex-1">
|
|
97
|
+
<p className="text-xs text-foreground font-display whitespace-nowrap">
|
|
98
|
+
{t.changes.unreadBanner(unreadCount)}
|
|
99
|
+
</p>
|
|
100
|
+
<div className="mt-1 flex items-center gap-1.5">
|
|
101
|
+
<Link
|
|
102
|
+
href="/changes"
|
|
103
|
+
className="inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium bg-[var(--amber)] text-white focus-visible:ring-2 focus-visible:ring-ring hover:opacity-90"
|
|
104
|
+
>
|
|
105
|
+
{t.changes.reviewNow}
|
|
106
|
+
</Link>
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={() => void handleMarkAllRead()}
|
|
110
|
+
className="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium bg-muted text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
111
|
+
>
|
|
112
|
+
{t.changes.markAllRead}
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={() => setDismissedAtCount(unreadCount)}
|
|
119
|
+
aria-label={t.changes.dismiss}
|
|
120
|
+
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring"
|
|
44
121
|
>
|
|
45
|
-
|
|
46
|
-
</
|
|
122
|
+
<X size={14} />
|
|
123
|
+
</button>
|
|
47
124
|
</div>
|
|
48
125
|
</div>
|
|
49
126
|
</div>
|
|
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { ChevronDown, ChevronRight, History, RefreshCw } from 'lucide-react';
|
|
6
6
|
import { apiFetch } from '@/lib/api';
|
|
7
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
7
8
|
import { collapseDiffContext, buildLineDiff } from './line-diff';
|
|
8
9
|
|
|
9
10
|
interface ChangeEvent {
|
|
@@ -28,18 +29,22 @@ interface ListPayload {
|
|
|
28
29
|
events: ChangeEvent[];
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function relativeTime(ts: string): string {
|
|
32
|
+
function relativeTime(ts: string, t: ReturnType<typeof useLocale>['t']): string {
|
|
32
33
|
const delta = Date.now() - new Date(ts).getTime();
|
|
33
34
|
const mins = Math.floor(delta / 60000);
|
|
34
|
-
if (mins < 1) return
|
|
35
|
-
if (mins < 60) return
|
|
35
|
+
if (mins < 1) return t.changes.relativeTime.justNow;
|
|
36
|
+
if (mins < 60) return t.changes.relativeTime.minutesAgo(mins);
|
|
36
37
|
const hours = Math.floor(mins / 60);
|
|
37
|
-
if (hours < 24) return
|
|
38
|
-
return
|
|
38
|
+
if (hours < 24) return t.changes.relativeTime.hoursAgo(hours);
|
|
39
|
+
return t.changes.relativeTime.daysAgo(Math.floor(hours / 24));
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export default function ChangesContentPage({ initialPath = '' }: { initialPath?: string }) {
|
|
43
|
+
const { t } = useLocale();
|
|
42
44
|
const [pathFilter, setPathFilter] = useState(initialPath);
|
|
45
|
+
const [sourceFilter, setSourceFilter] = useState<'all' | 'agent' | 'user' | 'system'>('all');
|
|
46
|
+
const [opFilter, setOpFilter] = useState<string>('all');
|
|
47
|
+
const [queryFilter, setQueryFilter] = useState('');
|
|
43
48
|
const [events, setEvents] = useState<ChangeEvent[]>([]);
|
|
44
49
|
const [summary, setSummary] = useState<SummaryPayload>({ unreadCount: 0, totalCount: 0 });
|
|
45
50
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
@@ -50,9 +55,12 @@ export default function ChangesContentPage({ initialPath = '' }: { initialPath?:
|
|
|
50
55
|
setLoading(true);
|
|
51
56
|
setError(null);
|
|
52
57
|
try {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
const params = new URLSearchParams({ op: 'list', limit: '120' });
|
|
59
|
+
if (pathFilter.trim()) params.set('path', pathFilter.trim());
|
|
60
|
+
if (sourceFilter !== 'all') params.set('source', sourceFilter);
|
|
61
|
+
if (opFilter !== 'all') params.set('event_op', opFilter);
|
|
62
|
+
if (queryFilter.trim()) params.set('q', queryFilter.trim());
|
|
63
|
+
const listUrl = `/api/changes?${params.toString()}`;
|
|
56
64
|
const [list, summaryData] = await Promise.all([
|
|
57
65
|
apiFetch<ListPayload>(listUrl),
|
|
58
66
|
apiFetch<SummaryPayload>('/api/changes?op=summary'),
|
|
@@ -64,7 +72,7 @@ export default function ChangesContentPage({ initialPath = '' }: { initialPath?:
|
|
|
64
72
|
} finally {
|
|
65
73
|
setLoading(false);
|
|
66
74
|
}
|
|
67
|
-
}, [pathFilter]);
|
|
75
|
+
}, [pathFilter, sourceFilter, opFilter, queryFilter]);
|
|
68
76
|
|
|
69
77
|
useEffect(() => {
|
|
70
78
|
void fetchData();
|
|
@@ -79,55 +87,116 @@ export default function ChangesContentPage({ initialPath = '' }: { initialPath?:
|
|
|
79
87
|
await fetchData();
|
|
80
88
|
}, [fetchData]);
|
|
81
89
|
|
|
82
|
-
const eventCountLabel = useMemo(() =>
|
|
90
|
+
const eventCountLabel = useMemo(() => t.changes.eventsCount(events.length), [events.length, t]);
|
|
91
|
+
const opOptions = useMemo(() => {
|
|
92
|
+
const ops = Array.from(new Set(events.map((e) => e.op))).sort((a, b) => a.localeCompare(b));
|
|
93
|
+
if (opFilter !== 'all' && !ops.includes(opFilter)) ops.unshift(opFilter);
|
|
94
|
+
return ['all', ...ops];
|
|
95
|
+
}, [events, opFilter]);
|
|
96
|
+
|
|
97
|
+
const sourceLabel = useCallback((source: ChangeEvent['source']) => {
|
|
98
|
+
if (source === 'agent') return t.changes.filters.agent;
|
|
99
|
+
if (source === 'user') return t.changes.filters.user;
|
|
100
|
+
return t.changes.filters.system;
|
|
101
|
+
}, [t]);
|
|
83
102
|
|
|
84
103
|
return (
|
|
85
104
|
<div className="min-h-screen">
|
|
86
|
-
<div className="
|
|
87
|
-
<div className="content-width xl:mr-[220px]
|
|
88
|
-
<div className="
|
|
89
|
-
<div className="
|
|
90
|
-
<
|
|
91
|
-
|
|
105
|
+
<div className="px-4 md:px-6 pt-6 md:pt-8">
|
|
106
|
+
<div className="content-width xl:mr-[220px] rounded-xl border border-border bg-card px-4 py-3 md:px-5 md:py-4">
|
|
107
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
108
|
+
<div className="min-w-0">
|
|
109
|
+
<div className="flex items-center gap-2 text-sm font-medium text-foreground font-display">
|
|
110
|
+
<History size={15} />
|
|
111
|
+
{t.changes.title}
|
|
112
|
+
</div>
|
|
113
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
114
|
+
{t.changes.subtitle}
|
|
115
|
+
</p>
|
|
116
|
+
<div className="mt-2 flex items-center gap-2 text-xs">
|
|
117
|
+
<span className="rounded-full bg-muted px-2 py-0.5 text-muted-foreground">{eventCountLabel}</span>
|
|
118
|
+
<span className="rounded-full bg-[var(--amber-dim)] px-2 py-0.5 text-[var(--amber)]">
|
|
119
|
+
{t.changes.unreadCount(summary.unreadCount)}
|
|
120
|
+
</span>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
<div className="flex items-center gap-2">
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
onClick={() => void fetchData()}
|
|
127
|
+
className="px-2.5 py-1.5 rounded-md text-xs font-medium bg-muted text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
128
|
+
>
|
|
129
|
+
<span className="inline-flex items-center gap-1"><RefreshCw size={12} /> {t.changes.refresh}</span>
|
|
130
|
+
</button>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
onClick={() => void markSeen()}
|
|
134
|
+
className="px-2.5 py-1.5 rounded-md text-xs font-medium bg-muted text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
135
|
+
>
|
|
136
|
+
{t.changes.markAllRead}
|
|
137
|
+
</button>
|
|
92
138
|
</div>
|
|
93
|
-
<div className="text-xs text-muted-foreground mt-1">{eventCountLabel} · {summary.unreadCount} unread</div>
|
|
94
|
-
</div>
|
|
95
|
-
<div className="flex items-center gap-2">
|
|
96
|
-
<button
|
|
97
|
-
type="button"
|
|
98
|
-
onClick={() => void fetchData()}
|
|
99
|
-
className="px-2.5 py-1.5 rounded-md text-xs font-medium bg-muted text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
100
|
-
>
|
|
101
|
-
<span className="inline-flex items-center gap-1"><RefreshCw size={12} /> Refresh</span>
|
|
102
|
-
</button>
|
|
103
|
-
<button
|
|
104
|
-
type="button"
|
|
105
|
-
onClick={() => void markSeen()}
|
|
106
|
-
className="px-2.5 py-1.5 rounded-md text-xs font-medium bg-[var(--amber)] text-[var(--amber-foreground)] focus-visible:ring-2 focus-visible:ring-ring"
|
|
107
|
-
>
|
|
108
|
-
Mark seen
|
|
109
|
-
</button>
|
|
110
139
|
</div>
|
|
111
140
|
</div>
|
|
112
141
|
</div>
|
|
113
142
|
|
|
114
|
-
<div className="px-4 md:px-6 py-
|
|
143
|
+
<div className="px-4 md:px-6 py-4 md:py-6">
|
|
115
144
|
<div className="content-width xl:mr-[220px] space-y-3">
|
|
116
145
|
<div className="rounded-lg border border-border bg-card p-3">
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
146
|
+
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-2.5">
|
|
147
|
+
<label className="block">
|
|
148
|
+
<span className="text-xs text-muted-foreground font-display">{t.changes.filters.filePath}</span>
|
|
149
|
+
<input
|
|
150
|
+
value={pathFilter}
|
|
151
|
+
onChange={(e) => setPathFilter(e.target.value)}
|
|
152
|
+
placeholder={t.changes.filters.filePathPlaceholder}
|
|
153
|
+
className="mt-1 w-full px-2.5 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
154
|
+
/>
|
|
155
|
+
</label>
|
|
156
|
+
<label className="block">
|
|
157
|
+
<span className="text-xs text-muted-foreground font-display">{t.changes.filters.source}</span>
|
|
158
|
+
<select
|
|
159
|
+
value={sourceFilter}
|
|
160
|
+
onChange={(e) => setSourceFilter(e.target.value as 'all' | 'agent' | 'user' | 'system')}
|
|
161
|
+
className="mt-1 w-full px-2.5 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
162
|
+
>
|
|
163
|
+
<option value="all">{t.changes.filters.all}</option>
|
|
164
|
+
<option value="agent">{t.changes.filters.agent}</option>
|
|
165
|
+
<option value="user">{t.changes.filters.user}</option>
|
|
166
|
+
<option value="system">{t.changes.filters.system}</option>
|
|
167
|
+
</select>
|
|
168
|
+
</label>
|
|
169
|
+
<label className="block">
|
|
170
|
+
<span className="text-xs text-muted-foreground font-display">{t.changes.filters.operation}</span>
|
|
171
|
+
<select
|
|
172
|
+
value={opFilter}
|
|
173
|
+
onChange={(e) => setOpFilter(e.target.value)}
|
|
174
|
+
className="mt-1 w-full px-2.5 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
175
|
+
>
|
|
176
|
+
{opOptions.map((op) => (
|
|
177
|
+
<option key={op} value={op}>
|
|
178
|
+
{op === 'all' ? t.changes.filters.operationAll : op}
|
|
179
|
+
</option>
|
|
180
|
+
))}
|
|
181
|
+
</select>
|
|
182
|
+
</label>
|
|
183
|
+
<label className="block">
|
|
184
|
+
<span className="text-xs text-muted-foreground font-display">{t.changes.filters.keyword}</span>
|
|
185
|
+
<input
|
|
186
|
+
value={queryFilter}
|
|
187
|
+
onChange={(e) => setQueryFilter(e.target.value)}
|
|
188
|
+
placeholder={t.changes.filters.keywordPlaceholder}
|
|
189
|
+
className="mt-1 w-full px-2.5 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
190
|
+
/>
|
|
191
|
+
</label>
|
|
192
|
+
</div>
|
|
124
193
|
</div>
|
|
125
194
|
|
|
126
|
-
{loading && <p className="text-sm text-muted-foreground">
|
|
195
|
+
{loading && <p className="text-sm text-muted-foreground">{t.changes.loading}</p>}
|
|
127
196
|
{error && <p className="text-sm text-error">{error}</p>}
|
|
128
197
|
{!loading && !error && events.length === 0 && (
|
|
129
198
|
<div className="rounded-lg border border-border bg-card p-6 text-center text-sm text-muted-foreground">
|
|
130
|
-
|
|
199
|
+
{t.changes.empty}
|
|
131
200
|
</div>
|
|
132
201
|
)}
|
|
133
202
|
|
|
@@ -135,35 +204,49 @@ export default function ChangesContentPage({ initialPath = '' }: { initialPath?:
|
|
|
135
204
|
const open = !!expanded[event.id];
|
|
136
205
|
const rows = collapseDiffContext(buildLineDiff(event.before ?? '', event.after ?? ''));
|
|
137
206
|
return (
|
|
138
|
-
<div key={event.id} className="rounded-
|
|
207
|
+
<div key={event.id} className="rounded-xl border border-border bg-card overflow-hidden">
|
|
139
208
|
<button
|
|
140
209
|
type="button"
|
|
141
210
|
onClick={() => setExpanded((prev) => ({ ...prev, [event.id]: !prev[event.id] }))}
|
|
142
|
-
className="w-full px-3 py-
|
|
211
|
+
className="w-full px-3 py-3 text-left hover:bg-muted/30 focus-visible:ring-2 focus-visible:ring-ring"
|
|
143
212
|
>
|
|
144
213
|
<div className="flex items-start gap-2">
|
|
145
214
|
<span className="pt-0.5 text-muted-foreground">{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}</span>
|
|
146
215
|
<div className="min-w-0 flex-1">
|
|
147
216
|
<div className="text-sm font-medium text-foreground font-display">{event.summary}</div>
|
|
148
|
-
<div className="text-xs text-muted-foreground
|
|
149
|
-
|
|
217
|
+
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
|
218
|
+
<span
|
|
219
|
+
className="rounded-md px-2 py-0.5 font-medium"
|
|
220
|
+
style={{
|
|
221
|
+
background: 'color-mix(in srgb, var(--amber) 16%, var(--muted))',
|
|
222
|
+
color: 'var(--foreground)',
|
|
223
|
+
border: '1px solid color-mix(in srgb, var(--amber) 36%, var(--border))',
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
{event.path}
|
|
227
|
+
</span>
|
|
228
|
+
<span>{event.op}</span>
|
|
229
|
+
<span>·</span>
|
|
230
|
+
<span>{sourceLabel(event.source)}</span>
|
|
231
|
+
<span>·</span>
|
|
232
|
+
<span>{relativeTime(event.ts, t)}</span>
|
|
150
233
|
</div>
|
|
151
234
|
</div>
|
|
152
235
|
<Link
|
|
153
236
|
href={`/view/${event.path.split('/').map(encodeURIComponent).join('/')}`}
|
|
154
|
-
className="text-xs text-[var(--amber)] hover:
|
|
237
|
+
className="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium bg-[var(--amber-dim)] text-[var(--amber)] focus-visible:ring-2 focus-visible:ring-ring hover:opacity-90"
|
|
155
238
|
onClick={(e) => e.stopPropagation()}
|
|
156
239
|
>
|
|
157
|
-
|
|
240
|
+
{t.changes.open}
|
|
158
241
|
</Link>
|
|
159
242
|
</div>
|
|
160
243
|
</button>
|
|
161
244
|
|
|
162
245
|
{open && (
|
|
163
|
-
<div className="border-t border-border bg-background">
|
|
246
|
+
<div className="border-t border-border bg-background/70">
|
|
164
247
|
{rows.map((row, idx) => {
|
|
165
248
|
if (row.type === 'gap') {
|
|
166
|
-
return <div key={`${event.id}-gap-${idx}`} className="px-3 py-1 text-2xs text-muted-foreground"
|
|
249
|
+
return <div key={`${event.id}-gap-${idx}`} className="px-3 py-1 text-2xs text-muted-foreground">{t.changes.unchangedLines(row.count)}</div>;
|
|
167
250
|
}
|
|
168
251
|
const prefix = row.type === 'insert' ? '+' : row.type === 'delete' ? '-' : ' ';
|
|
169
252
|
const color = row.type === 'insert'
|