@geminilight/mindos 0.5.63 → 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.
Files changed (41) hide show
  1. package/app/app/api/changes/route.ts +7 -1
  2. package/app/app/api/mcp/install-skill/route.ts +9 -24
  3. package/app/app/layout.tsx +1 -0
  4. package/app/app/page.tsx +1 -2
  5. package/app/app/view/[...path]/ViewPageClient.tsx +0 -1
  6. package/app/components/HomeContent.tsx +41 -6
  7. package/app/components/RightAgentDetailPanel.tsx +1 -0
  8. package/app/components/SidebarLayout.tsx +1 -0
  9. package/app/components/agents/AgentsContentPage.tsx +20 -16
  10. package/app/components/agents/AgentsMcpSection.tsx +178 -65
  11. package/app/components/agents/AgentsOverviewSection.tsx +1 -1
  12. package/app/components/agents/AgentsSkillsSection.tsx +78 -55
  13. package/app/components/agents/agents-content-model.ts +16 -0
  14. package/app/components/changes/ChangesBanner.tsx +90 -13
  15. package/app/components/changes/ChangesContentPage.tsx +134 -51
  16. package/app/components/panels/AgentsPanel.tsx +14 -28
  17. package/app/components/panels/AgentsPanelAgentDetail.tsx +5 -4
  18. package/app/components/panels/AgentsPanelAgentGroups.tsx +5 -6
  19. package/app/components/panels/AgentsPanelAgentListRow.tsx +30 -5
  20. package/app/components/panels/AgentsPanelHubNav.tsx +12 -12
  21. package/app/components/panels/PluginsPanel.tsx +3 -3
  22. package/app/components/renderers/agent-inspector/manifest.ts +2 -0
  23. package/app/components/renderers/config/manifest.ts +1 -0
  24. package/app/components/renderers/csv/manifest.ts +1 -0
  25. package/app/components/settings/PluginsTab.tsx +4 -3
  26. package/app/hooks/useMcpData.tsx +3 -2
  27. package/app/lib/core/content-changes.ts +148 -8
  28. package/app/lib/fs.ts +7 -1
  29. package/app/lib/i18n-en.ts +58 -3
  30. package/app/lib/i18n-zh.ts +58 -3
  31. package/app/lib/mcp-agents.ts +42 -0
  32. package/app/lib/renderers/index.ts +1 -2
  33. package/app/lib/renderers/registry.ts +10 -0
  34. package/app/next-env.d.ts +1 -1
  35. package/bin/lib/mcp-agents.js +38 -13
  36. package/package.json +1 -1
  37. package/scripts/migrate-agent-diff.js +146 -0
  38. package/scripts/setup.js +12 -17
  39. package/skills/plugin-core-builtin-migration/SKILL.md +178 -0
  40. package/app/components/renderers/diff/DiffRenderer.tsx +0 -311
  41. 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 { ChevronDown, ChevronRight, Search } from 'lucide-react';
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 [matrixOpen, setMatrixOpen] = useState(false);
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 role="tabpanel" id="agents-panel-skills" aria-labelledby="agents-tab-skills" className="rounded-lg border border-border bg-card p-4 space-y-4">
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
- <SourceFilterButton active={source === 'all'} label={copy.sourceAll} onClick={() => setSource('all')} />
56
- <SourceFilterButton active={source === 'builtin'} label={copy.sourceBuiltin} onClick={() => setSource('builtin')} />
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
- <div className="space-y-4">
62
- {Object.entries(grouped).map(([groupKey, skills]) => (
63
- <div key={groupKey} className="rounded-md border border-border p-3">
64
- <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
65
- {copy.groupLabels[groupKey as keyof typeof copy.groupLabels]} ({skills.length})
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
- {skills.length === 0 ? (
68
- <p className="text-xs text-muted-foreground">{copy.emptyGroup}</p>
69
- ) : (
70
- <div className="space-y-1.5">
71
- {skills.map((skill) => (
72
- <div key={skill.name} className="flex items-center justify-between gap-2">
73
- <div className="min-w-0">
74
- <p className="text-sm text-foreground truncate">{skill.name}</p>
75
- <p className="text-2xs text-muted-foreground">{skill.source === 'builtin' ? copy.sourceBuiltin : copy.sourceUser}</p>
76
- </div>
77
- <Toggle size="sm" checked={skill.enabled} onChange={(v) => void mcp.toggleSkill(skill.name, v)} />
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
- </div>
85
-
86
- <div className="rounded-md border border-border bg-background p-3">
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
- </div>
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
- if (unreadCount <= 0) return null;
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 className="sticky top-[52px] md:top-0 z-20 border-b bg-[var(--amber-dim)]" style={{ borderColor: 'color-mix(in srgb, var(--amber) 35%, var(--border))' }}>
36
- <div className="px-4 md:px-6 py-2">
37
- <div className="content-width xl:mr-[220px] flex items-center justify-between gap-3">
38
- <p className="text-xs md:text-sm text-foreground font-display">
39
- {unreadCount} content change{unreadCount === 1 ? '' : 's'} detected.
40
- </p>
41
- <Link
42
- href="/changes"
43
- className="text-xs md:text-sm text-[var(--amber)] hover:underline focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
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
- Review changes
46
- </Link>
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 'just now';
35
- if (mins < 60) return `${mins}m ago`;
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 `${hours}h ago`;
38
- return `${Math.floor(hours / 24)}d ago`;
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 listUrl = pathFilter
54
- ? `/api/changes?op=list&limit=80&path=${encodeURIComponent(pathFilter)}`
55
- : '/api/changes?op=list&limit=80';
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(() => `${events.length} event${events.length === 1 ? '' : 's'}`, [events.length]);
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="sticky top-[52px] md:top-0 z-20 border-b border-border px-4 md:px-6 py-2.5 bg-background">
87
- <div className="content-width xl:mr-[220px] flex items-center justify-between gap-3">
88
- <div className="min-w-0">
89
- <div className="flex items-center gap-2 text-sm font-medium text-foreground font-display">
90
- <History size={15} />
91
- Content changes
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-6 md:py-8">
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
- <label className="text-xs text-muted-foreground">Filter by file path</label>
118
- <input
119
- value={pathFilter}
120
- onChange={(e) => setPathFilter(e.target.value)}
121
- placeholder="e.g. Projects/plan.md"
122
- 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-1 focus-visible:ring-ring"
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">Loading changes...</p>}
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
- No content changes yet.
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-lg border border-border bg-card overflow-hidden">
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-2.5 text-left hover:bg-muted/30 focus-visible:ring-2 focus-visible:ring-ring"
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 mt-0.5">
149
- {event.path} · {event.op} · {event.source} · {relativeTime(event.ts)}
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:underline"
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
- Open
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">... {row.count} unchanged lines ...</div>;
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'