@geminilight/mindos 0.5.63 → 0.5.65

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 (104) hide show
  1. package/README.md +4 -0
  2. package/README_zh.md +4 -0
  3. package/app/app/api/ask/route.ts +12 -0
  4. package/app/app/api/changes/route.ts +7 -1
  5. package/app/app/api/file/route.ts +9 -0
  6. package/app/app/api/mcp/agents/route.ts +27 -1
  7. package/app/app/api/mcp/install-skill/route.ts +9 -24
  8. package/app/app/api/skills/route.ts +18 -2
  9. package/app/app/api/tree-version/route.ts +8 -0
  10. package/app/app/layout.tsx +1 -0
  11. package/app/app/page.tsx +1 -2
  12. package/app/app/view/[...path]/ViewPageClient.tsx +0 -1
  13. package/app/components/ActivityBar.tsx +2 -2
  14. package/app/components/Backlinks.tsx +5 -5
  15. package/app/components/CreateSpaceModal.tsx +3 -2
  16. package/app/components/DirPicker.tsx +1 -1
  17. package/app/components/DirView.tsx +2 -3
  18. package/app/components/EditorWrapper.tsx +3 -3
  19. package/app/components/FileTree.tsx +25 -10
  20. package/app/components/GuideCard.tsx +4 -4
  21. package/app/components/HomeContent.tsx +44 -14
  22. package/app/components/MarkdownView.tsx +2 -2
  23. package/app/components/OnboardingView.tsx +1 -1
  24. package/app/components/Panel.tsx +1 -1
  25. package/app/components/RightAgentDetailPanel.tsx +2 -1
  26. package/app/components/RightAskPanel.tsx +1 -1
  27. package/app/components/SearchModal.tsx +10 -2
  28. package/app/components/SidebarLayout.tsx +36 -10
  29. package/app/components/ThemeToggle.tsx +1 -1
  30. package/app/components/agents/AgentDetailContent.tsx +454 -59
  31. package/app/components/agents/AgentsContentPage.tsx +89 -20
  32. package/app/components/agents/AgentsMcpSection.tsx +513 -85
  33. package/app/components/agents/AgentsOverviewSection.tsx +418 -59
  34. package/app/components/agents/AgentsPrimitives.tsx +335 -0
  35. package/app/components/agents/AgentsSkillsSection.tsx +746 -105
  36. package/app/components/agents/SkillDetailPopover.tsx +416 -0
  37. package/app/components/agents/agents-content-model.ts +308 -10
  38. package/app/components/ask/AskContent.tsx +34 -5
  39. package/app/components/ask/FileChip.tsx +1 -0
  40. package/app/components/ask/MentionPopover.tsx +13 -1
  41. package/app/components/ask/MessageList.tsx +5 -7
  42. package/app/components/ask/ToolCallBlock.tsx +4 -4
  43. package/app/components/changes/ChangesBanner.tsx +89 -13
  44. package/app/components/changes/ChangesContentPage.tsx +134 -51
  45. package/app/components/echo/EchoHero.tsx +10 -24
  46. package/app/components/echo/EchoInsightCollapsible.tsx +52 -43
  47. package/app/components/echo/EchoPageSections.tsx +13 -9
  48. package/app/components/echo/EchoSegmentNav.tsx +14 -11
  49. package/app/components/echo/EchoSegmentPageClient.tsx +64 -43
  50. package/app/components/explore/ExploreContent.tsx +3 -7
  51. package/app/components/explore/UseCaseCard.tsx +4 -15
  52. package/app/components/panels/AgentsPanel.tsx +22 -128
  53. package/app/components/panels/AgentsPanelAgentDetail.tsx +7 -6
  54. package/app/components/panels/AgentsPanelAgentGroups.tsx +8 -13
  55. package/app/components/panels/AgentsPanelAgentListRow.tsx +39 -16
  56. package/app/components/panels/AgentsPanelHubNav.tsx +12 -12
  57. package/app/components/panels/EchoPanel.tsx +8 -10
  58. package/app/components/panels/PanelNavRow.tsx +9 -2
  59. package/app/components/panels/PluginsPanel.tsx +5 -5
  60. package/app/components/renderers/agent-inspector/AgentInspectorRenderer.tsx +30 -8
  61. package/app/components/renderers/agent-inspector/manifest.ts +5 -3
  62. package/app/components/renderers/config/manifest.ts +1 -0
  63. package/app/components/renderers/csv/manifest.ts +1 -0
  64. package/app/components/renderers/todo/manifest.ts +1 -0
  65. package/app/components/settings/AiTab.tsx +3 -3
  66. package/app/components/settings/AppearanceTab.tsx +2 -2
  67. package/app/components/settings/KnowledgeTab.tsx +3 -3
  68. package/app/components/settings/McpAgentInstall.tsx +3 -6
  69. package/app/components/settings/McpSkillCreateForm.tsx +2 -3
  70. package/app/components/settings/McpSkillRow.tsx +2 -3
  71. package/app/components/settings/McpSkillsSection.tsx +2 -2
  72. package/app/components/settings/McpTab.tsx +12 -13
  73. package/app/components/settings/MonitoringTab.tsx +13 -13
  74. package/app/components/settings/PluginsTab.tsx +6 -5
  75. package/app/components/settings/Primitives.tsx +3 -4
  76. package/app/components/settings/SettingsContent.tsx +3 -3
  77. package/app/components/settings/SyncTab.tsx +11 -17
  78. package/app/components/settings/UpdateTab.tsx +18 -21
  79. package/app/components/settings/types.ts +14 -0
  80. package/app/components/setup/StepKB.tsx +1 -1
  81. package/app/hooks/useMcpData.tsx +7 -4
  82. package/app/hooks/useMention.ts +25 -8
  83. package/app/lib/agent/log.ts +15 -18
  84. package/app/lib/agent/stream-consumer.ts +3 -0
  85. package/app/lib/agent/to-agent-messages.ts +6 -4
  86. package/app/lib/core/agent-audit-log.ts +280 -0
  87. package/app/lib/core/content-changes.ts +148 -8
  88. package/app/lib/core/index.ts +11 -0
  89. package/app/lib/fs.ts +16 -1
  90. package/app/lib/i18n-en.ts +317 -36
  91. package/app/lib/i18n-zh.ts +316 -35
  92. package/app/lib/mcp-agents.ts +273 -2
  93. package/app/lib/renderers/index.ts +1 -2
  94. package/app/lib/renderers/registry.ts +10 -0
  95. package/app/lib/types.ts +2 -0
  96. package/app/next-env.d.ts +1 -1
  97. package/bin/lib/mcp-agents.js +38 -13
  98. package/package.json +1 -1
  99. package/scripts/migrate-agent-audit-log.js +170 -0
  100. package/scripts/migrate-agent-diff.js +146 -0
  101. package/scripts/setup.js +12 -17
  102. package/skills/plugin-core-builtin-migration/SKILL.md +178 -0
  103. package/app/components/renderers/diff/DiffRenderer.tsx +0 -311
  104. package/app/components/renderers/diff/manifest.ts +0 -14
@@ -4,6 +4,12 @@ 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 UnifiedSourceFilter = 'all' | 'builtin' | 'user' | 'native';
8
+ export type AgentStatusFilter = 'all' | 'connected' | 'detected' | 'notFound';
9
+ export type AgentTransportFilter = 'all' | 'stdio' | 'http' | 'other';
10
+ export type SkillWorkspaceStatusFilter = 'all' | 'enabled' | 'disabled' | 'attention';
11
+ export type SkillCapabilityFilter = SkillCapability | 'all';
12
+ export type AgentDetailSkillSourceFilter = 'all' | 'builtin' | 'user';
7
13
 
8
14
  export interface RiskItem {
9
15
  id: string;
@@ -37,12 +43,7 @@ export function resolveAgentStatus(agent: AgentInfo): AgentResolvedStatus {
37
43
  }
38
44
 
39
45
  export function capabilityForSkill(skill: SkillInfo): SkillCapability {
40
- const text = `${skill.name} ${skill.description}`.toLowerCase();
41
- if (text.includes('search') || text.includes('research')) return 'research';
42
- if (text.includes('doc') || text.includes('write')) return 'docs';
43
- if (text.includes('deploy') || text.includes('ops') || text.includes('ci')) return 'ops';
44
- if (text.includes('memory') || text.includes('mind')) return 'memory';
45
- return 'coding';
46
+ return capabilityFromText(`${skill.name} ${skill.description}`);
46
47
  }
47
48
 
48
49
  export function groupSkillsByCapability(skills: SkillInfo[]): Record<SkillCapability, SkillInfo[]> {
@@ -55,16 +56,20 @@ export function groupSkillsByCapability(skills: SkillInfo[]): Record<SkillCapabi
55
56
  };
56
57
  }
57
58
 
59
+ function buildBaseRiskItems(args: { mcpRunning: boolean; detectedCount: number; notFoundCount: number }): RiskItem[] {
60
+ const items: RiskItem[] = [];
61
+ if (!args.mcpRunning) items.push({ id: 'mcp-stopped', severity: 'error', title: 'MCP server is not running' });
62
+ if (args.detectedCount > 0) items.push({ id: 'detected-unconfigured', severity: 'warn', title: `${args.detectedCount} detected agent(s) need configuration` });
63
+ return items;
64
+ }
65
+
58
66
  export function buildRiskQueue(args: {
59
67
  mcpRunning: boolean;
60
68
  detectedCount: number;
61
69
  notFoundCount: number;
62
70
  allSkillsDisabled: boolean;
63
71
  }): RiskItem[] {
64
- const items: RiskItem[] = [];
65
- if (!args.mcpRunning) items.push({ id: 'mcp-stopped', severity: 'error', title: 'MCP server is not running' });
66
- if (args.detectedCount > 0) items.push({ id: 'detected-unconfigured', severity: 'warn', title: `${args.detectedCount} detected agent(s) need configuration` });
67
- if (args.notFoundCount > 0) items.push({ id: 'not-found', severity: 'warn', title: `${args.notFoundCount} agent(s) not detected on this machine` });
72
+ const items = buildBaseRiskItems(args);
68
73
  if (args.allSkillsDisabled) items.push({ id: 'skills-disabled', severity: 'warn', title: 'All skills are disabled' });
69
74
  return items;
70
75
  }
@@ -78,3 +83,296 @@ export function filterSkills(skills: SkillInfo[], query: string, source: SkillSo
78
83
  return haystack.includes(q);
79
84
  });
80
85
  }
86
+
87
+ export function buildSkillAttentionSet(skills: SkillInfo[]): Set<string> {
88
+ return new Set(
89
+ skills
90
+ .filter((skill) => (!skill.enabled && skill.source === 'user') || skill.description.trim().length === 0)
91
+ .map((skill) => skill.name),
92
+ );
93
+ }
94
+
95
+ export function filterSkillsForWorkspace(
96
+ skills: SkillInfo[],
97
+ filters: {
98
+ query: string;
99
+ source: SkillSourceFilter;
100
+ status: SkillWorkspaceStatusFilter;
101
+ capability: SkillCapabilityFilter;
102
+ },
103
+ ): SkillInfo[] {
104
+ const attention = buildSkillAttentionSet(skills);
105
+ const byQueryAndSource = filterSkills(skills, filters.query, filters.source);
106
+ return byQueryAndSource.filter((skill) => {
107
+ if (filters.status === 'enabled' && !skill.enabled) return false;
108
+ if (filters.status === 'disabled' && skill.enabled) return false;
109
+ if (filters.status === 'attention' && !attention.has(skill.name)) return false;
110
+ if (filters.capability !== 'all' && capabilityForSkill(skill) !== filters.capability) return false;
111
+ return true;
112
+ });
113
+ }
114
+
115
+ export function resolveMatrixAgents(agents: AgentInfo[], focusKey: string): AgentInfo[] {
116
+ if (focusKey === 'all') return agents;
117
+ const focused = agents.find((agent) => agent.key === focusKey);
118
+ return focused ? [focused] : [];
119
+ }
120
+
121
+ export function createBulkSkillTogglePlan(skills: SkillInfo[], targetEnabled: boolean): string[] {
122
+ return skills.filter((skill) => skill.enabled !== targetEnabled).map((skill) => skill.name);
123
+ }
124
+
125
+ export interface BulkSkillToggleResult {
126
+ skillName: string;
127
+ ok: boolean;
128
+ reason?: string;
129
+ }
130
+
131
+ export function summarizeBulkSkillToggleResults(results: BulkSkillToggleResult[]): {
132
+ total: number;
133
+ succeeded: number;
134
+ failed: number;
135
+ failedSkills: string[];
136
+ } {
137
+ const failedSkills = results.filter((item) => !item.ok).map((item) => item.skillName);
138
+ return {
139
+ total: results.length,
140
+ succeeded: results.length - failedSkills.length,
141
+ failed: failedSkills.length,
142
+ failedSkills,
143
+ };
144
+ }
145
+
146
+ export function filterAgentsByStatus(agents: AgentInfo[], status: AgentStatusFilter): AgentInfo[] {
147
+ if (status === 'all') return agents;
148
+ return agents.filter((agent) => resolveAgentStatus(agent) === status);
149
+ }
150
+
151
+ export function filterAgentsForMcpTable(agents: AgentInfo[], query: string, status: AgentStatusFilter): AgentInfo[] {
152
+ const q = query.trim().toLowerCase();
153
+ const byStatus = filterAgentsByStatus(agents, status);
154
+ if (!q) return byStatus;
155
+ return byStatus.filter((agent) => {
156
+ const haystack = `${agent.name} ${agent.key} ${agent.configPath ?? ''}`.toLowerCase();
157
+ return haystack.includes(q);
158
+ });
159
+ }
160
+
161
+ export function resolveAgentTransport(agent: AgentInfo): AgentTransportFilter {
162
+ const transport = (agent.transport ?? agent.preferredTransport ?? '').toLowerCase();
163
+ if (transport === 'stdio' || transport === 'http') return transport;
164
+ return 'other';
165
+ }
166
+
167
+ export function filterAgentsForMcpWorkspace(
168
+ agents: AgentInfo[],
169
+ filters: { query: string; status: AgentStatusFilter; transport: AgentTransportFilter },
170
+ ): AgentInfo[] {
171
+ const q = filters.query.trim().toLowerCase();
172
+ return agents.filter((agent) => {
173
+ if (filters.status !== 'all' && resolveAgentStatus(agent) !== filters.status) return false;
174
+ if (filters.transport !== 'all' && resolveAgentTransport(agent) !== filters.transport) return false;
175
+ if (!q) return true;
176
+ const haystack = `${agent.name} ${agent.key} ${agent.configPath ?? ''}`.toLowerCase();
177
+ return haystack.includes(q);
178
+ });
179
+ }
180
+
181
+ export function buildMcpRiskQueue(args: {
182
+ mcpRunning: boolean;
183
+ detectedCount: number;
184
+ notFoundCount: number;
185
+ }): RiskItem[] {
186
+ return buildBaseRiskItems(args);
187
+ }
188
+
189
+ export interface McpBulkReconnectResult {
190
+ agentKey: string;
191
+ ok: boolean;
192
+ }
193
+
194
+ export function summarizeMcpBulkReconnectResults(results: McpBulkReconnectResult[]): {
195
+ total: number;
196
+ succeeded: number;
197
+ failed: number;
198
+ } {
199
+ const failed = results.filter((item) => !item.ok).length;
200
+ return {
201
+ total: results.length,
202
+ succeeded: results.length - failed,
203
+ failed,
204
+ };
205
+ }
206
+
207
+ export interface CrossAgentMcpServer {
208
+ serverName: string;
209
+ agents: string[];
210
+ }
211
+
212
+ export function aggregateCrossAgentMcpServers(agents: AgentInfo[]): CrossAgentMcpServer[] {
213
+ const map = new Map<string, string[]>();
214
+ for (const agent of agents) {
215
+ for (const server of agent.configuredMcpServers ?? []) {
216
+ const existing = map.get(server);
217
+ if (existing) existing.push(agent.name);
218
+ else map.set(server, [agent.name]);
219
+ }
220
+ }
221
+ return [...map.entries()]
222
+ .map(([serverName, agentNames]) => ({ serverName, agents: agentNames }))
223
+ .sort((a, b) => b.agents.length - a.agents.length || a.serverName.localeCompare(b.serverName));
224
+ }
225
+
226
+ export interface CrossAgentSkill {
227
+ skillName: string;
228
+ agents: string[];
229
+ }
230
+
231
+ export function aggregateCrossAgentSkills(agents: AgentInfo[]): CrossAgentSkill[] {
232
+ const map = new Map<string, string[]>();
233
+ for (const agent of agents) {
234
+ for (const skill of agent.installedSkillNames ?? []) {
235
+ const existing = map.get(skill);
236
+ if (existing) existing.push(agent.name);
237
+ else map.set(skill, [agent.name]);
238
+ }
239
+ }
240
+ return [...map.entries()]
241
+ .map(([skillName, agentNames]) => ({ skillName, agents: agentNames }))
242
+ .sort((a, b) => b.agents.length - a.agents.length || a.skillName.localeCompare(b.skillName));
243
+ }
244
+
245
+ const STATUS_ORDER: Record<AgentResolvedStatus, number> = { connected: 0, detected: 1, notFound: 2 };
246
+
247
+ export function sortAgentsByStatus(agents: AgentInfo[]): AgentInfo[] {
248
+ return [...agents].sort((a, b) => {
249
+ const sa = STATUS_ORDER[resolveAgentStatus(a)];
250
+ const sb = STATUS_ORDER[resolveAgentStatus(b)];
251
+ if (sa !== sb) return sa - sb;
252
+ return a.name.localeCompare(b.name);
253
+ });
254
+ }
255
+
256
+ export function filterSkillsForAgentDetail(
257
+ skills: SkillInfo[],
258
+ filters: { query: string; source: AgentDetailSkillSourceFilter },
259
+ ): SkillInfo[] {
260
+ const q = filters.query.trim().toLowerCase();
261
+ return skills.filter((skill) => {
262
+ if (filters.source !== 'all' && skill.source !== filters.source) return false;
263
+ if (!q) return true;
264
+ const haystack = `${skill.name} ${skill.description} ${skill.path}`.toLowerCase();
265
+ return haystack.includes(q);
266
+ });
267
+ }
268
+
269
+ /* ────────── Unified Skill List (MindOS + Native) ────────── */
270
+
271
+ export interface UnifiedSkillItem {
272
+ name: string;
273
+ kind: 'mindos' | 'native';
274
+ mindosSkill?: SkillInfo;
275
+ agents: string[];
276
+ capability: SkillCapability;
277
+ enabled: boolean;
278
+ source: 'builtin' | 'user' | 'native';
279
+ description: string;
280
+ }
281
+
282
+ export function capabilityFromText(text: string): SkillCapability {
283
+ const lower = text.toLowerCase();
284
+ if (lower.includes('search') || lower.includes('research') || lower.includes('arxiv') || lower.includes('paper')) return 'research';
285
+ if (lower.includes('doc') || lower.includes('write') || lower.includes('readme') || lower.includes('copy') || lower.includes('slide') || lower.includes('ppt')) return 'docs';
286
+ if (lower.includes('deploy') || lower.includes('ops') || lower.includes('ci') || lower.includes('ship') || lower.includes('qa') || lower.includes('review') || lower.includes('test')) return 'ops';
287
+ if (lower.includes('memory') || lower.includes('mind') || lower.includes('handoff') || lower.includes('session')) return 'memory';
288
+ return 'coding';
289
+ }
290
+
291
+ export function buildUnifiedSkillList(
292
+ mindosSkills: SkillInfo[],
293
+ crossAgentSkills: CrossAgentSkill[],
294
+ ): UnifiedSkillItem[] {
295
+ const mindosNames = new Set(mindosSkills.map((s) => s.name));
296
+ const crossMap = new Map<string, string[]>();
297
+ for (const cs of crossAgentSkills) crossMap.set(cs.skillName, cs.agents);
298
+
299
+ const result: UnifiedSkillItem[] = [];
300
+
301
+ for (const skill of mindosSkills) {
302
+ result.push({
303
+ name: skill.name,
304
+ kind: 'mindos',
305
+ mindosSkill: skill,
306
+ agents: crossMap.get(skill.name) ?? [],
307
+ capability: capabilityForSkill(skill),
308
+ enabled: skill.enabled,
309
+ source: skill.source,
310
+ description: skill.description,
311
+ });
312
+ }
313
+
314
+ for (const cs of crossAgentSkills) {
315
+ if (mindosNames.has(cs.skillName)) continue;
316
+ result.push({
317
+ name: cs.skillName,
318
+ kind: 'native',
319
+ agents: cs.agents,
320
+ capability: capabilityFromText(cs.skillName),
321
+ enabled: true,
322
+ source: 'native',
323
+ description: '',
324
+ });
325
+ }
326
+
327
+ return result;
328
+ }
329
+
330
+ export function groupUnifiedSkills(skills: UnifiedSkillItem[]): Record<SkillCapability, UnifiedSkillItem[]> {
331
+ return {
332
+ research: skills.filter((s) => s.capability === 'research'),
333
+ coding: skills.filter((s) => s.capability === 'coding'),
334
+ docs: skills.filter((s) => s.capability === 'docs'),
335
+ ops: skills.filter((s) => s.capability === 'ops'),
336
+ memory: skills.filter((s) => s.capability === 'memory'),
337
+ };
338
+ }
339
+
340
+ export function filterUnifiedSkills(
341
+ skills: UnifiedSkillItem[],
342
+ filters: {
343
+ query: string;
344
+ source: UnifiedSourceFilter;
345
+ status: SkillWorkspaceStatusFilter;
346
+ capability: SkillCapabilityFilter;
347
+ },
348
+ ): UnifiedSkillItem[] {
349
+ const q = filters.query.trim().toLowerCase();
350
+ const attentionSet = new Set(
351
+ skills
352
+ .filter((s) => s.kind === 'mindos' && ((!s.enabled && s.source === 'user') || s.description.trim().length === 0))
353
+ .map((s) => s.name),
354
+ );
355
+
356
+ return skills.filter((skill) => {
357
+ if (filters.source !== 'all') {
358
+ if (filters.source === 'native' && skill.kind !== 'native') return false;
359
+ if (filters.source === 'builtin' && skill.source !== 'builtin') return false;
360
+ if (filters.source === 'user' && skill.source !== 'user') return false;
361
+ }
362
+ if (filters.status === 'enabled' && !skill.enabled) return false;
363
+ if (filters.status === 'disabled' && skill.enabled) return false;
364
+ if (filters.status === 'attention' && !attentionSet.has(skill.name)) return false;
365
+ if (filters.capability !== 'all' && skill.capability !== filters.capability) return false;
366
+ if (q) {
367
+ const haystack = `${skill.name} ${skill.description}`.toLowerCase();
368
+ if (!haystack.includes(q)) return false;
369
+ }
370
+ return true;
371
+ });
372
+ }
373
+
374
+ export function createBulkUnifiedTogglePlan(skills: UnifiedSkillItem[], targetEnabled: boolean): string[] {
375
+ return skills
376
+ .filter((s) => s.kind === 'mindos' && s.enabled !== targetEnabled)
377
+ .map((s) => s.name);
378
+ }
@@ -171,6 +171,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
171
171
  const [loadingPhase, setLoadingPhase] = useState<'connecting' | 'thinking' | 'streaming'>('connecting');
172
172
  const [attachedFiles, setAttachedFiles] = useState<string[]>([]);
173
173
  const [showHistory, setShowHistory] = useState(false);
174
+ const [isDragOver, setIsDragOver] = useState(false);
174
175
 
175
176
  const session = useAskSession(currentFile);
176
177
  const upload = useFileUpload();
@@ -295,9 +296,11 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
295
296
  const text = input.trim();
296
297
  if (!text || isLoading) return;
297
298
 
298
- const userMsg: Message = { role: 'user', content: text };
299
+ // Attach current timestamp so backend knows EXACTLY when the user typed this message
300
+ const userMsg: Message = { role: 'user', content: text, timestamp: Date.now() };
299
301
  const requestMessages = [...session.messages, userMsg];
300
- session.setMessages([...requestMessages, { role: 'assistant', content: '' }]);
302
+ // And for the incoming assistant response, give it an initial timestamp
303
+ session.setMessages([...requestMessages, { role: 'assistant', content: '', timestamp: Date.now() }]);
301
304
  setInput('');
302
305
  if (onFirstMessage && !firstMessageFired.current) {
303
306
  firstMessageFired.current = true;
@@ -409,6 +412,25 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
409
412
  setTimeout(() => inputRef.current?.focus(), 0);
410
413
  }, [isLoading, currentFile, session, upload, mention]);
411
414
 
415
+ const handleDragOver = useCallback((e: React.DragEvent) => {
416
+ if (e.dataTransfer.types.includes('text/mindos-path')) {
417
+ e.preventDefault();
418
+ e.dataTransfer.dropEffect = 'copy';
419
+ setIsDragOver(true);
420
+ }
421
+ }, []);
422
+
423
+ const handleDragLeave = useCallback(() => setIsDragOver(false), []);
424
+
425
+ const handleDrop = useCallback((e: React.DragEvent) => {
426
+ e.preventDefault();
427
+ setIsDragOver(false);
428
+ const filePath = e.dataTransfer.getData('text/mindos-path');
429
+ if (filePath && !attachedFiles.includes(filePath)) {
430
+ setAttachedFiles(prev => [...prev, filePath]);
431
+ }
432
+ }, [attachedFiles]);
433
+
412
434
  const handleLoadSession = useCallback((id: string) => {
413
435
  session.loadSession(id);
414
436
  setShowHistory(false);
@@ -430,7 +452,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
430
452
  <div className="absolute top-2 left-1/2 -translate-x-1/2 w-8 h-1 rounded-full bg-muted-foreground/20 md:hidden" />
431
453
  )}
432
454
  <div className="flex items-center gap-2 text-sm font-medium text-foreground">
433
- <Sparkles size={isPanel ? 14 : 15} style={{ color: 'var(--amber)' }} />
455
+ <Sparkles size={isPanel ? 14 : 15} className="text-[var(--amber)]" />
434
456
  <span className={isPanel ? 'font-display text-xs uppercase tracking-wider text-muted-foreground' : 'font-display'}>
435
457
  {isPanel ? 'MindOS Agent' : t.ask.title}
436
458
  </span>
@@ -489,8 +511,15 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
489
511
 
490
512
  {/* Input area — panel: fixed-height shell + top drag handle (persisted); modal: simple block */}
491
513
  <div
492
- className={cn('shrink-0 border-t border-border', isPanel && 'flex flex-col overflow-hidden bg-card')}
514
+ className={cn(
515
+ 'shrink-0 border-t border-border',
516
+ isPanel && 'flex flex-col overflow-hidden bg-card',
517
+ isDragOver && 'ring-2 ring-[var(--amber)] ring-inset bg-[var(--amber-dim)]',
518
+ )}
493
519
  style={isPanel ? { height: panelComposerHeight } : undefined}
520
+ onDragOver={handleDragOver}
521
+ onDragLeave={handleDragLeave}
522
+ onDrop={handleDrop}
494
523
  >
495
524
  {isPanel ? (
496
525
  <div
@@ -514,7 +543,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
514
543
  </div>
515
544
  ) : null}
516
545
 
517
- <div className={cn(isPanel && 'flex min-h-0 flex-1 flex-col overflow-hidden')}>
546
+ <div className={cn(isPanel && 'flex min-h-0 flex-1 flex-col overflow-y-auto')}>
518
547
  {attachedFiles.length > 0 && (
519
548
  <div className={cn('shrink-0', isPanel ? 'px-3 pt-2 pb-1' : 'px-4 pt-2.5 pb-1')}>
520
549
  <div className={`text-muted-foreground/70 mb-1 ${isPanel ? 'text-[10px]' : 'text-xs'}`}>
@@ -21,6 +21,7 @@ export default function FileChip({ path, onRemove, variant = 'kb' }: FileChipPro
21
21
  <button
22
22
  type="button"
23
23
  onClick={onRemove}
24
+ aria-label={`Remove ${name}`}
24
25
  className="text-muted-foreground hover:text-foreground ml-0.5 shrink-0"
25
26
  >
26
27
  <X size={10} />
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { useEffect, useRef } from 'react';
3
4
  import { FileText, Table } from 'lucide-react';
4
5
 
5
6
  interface MentionPopoverProps {
@@ -9,10 +10,20 @@ interface MentionPopoverProps {
9
10
  }
10
11
 
11
12
  export default function MentionPopover({ results, selectedIndex, onSelect }: MentionPopoverProps) {
13
+ const listRef = useRef<HTMLDivElement>(null);
14
+
15
+ useEffect(() => {
16
+ const container = listRef.current;
17
+ if (!container) return;
18
+ const selected = container.children[selectedIndex] as HTMLElement | undefined;
19
+ selected?.scrollIntoView({ block: 'nearest' });
20
+ }, [selectedIndex]);
21
+
12
22
  if (results.length === 0) return null;
13
23
 
14
24
  return (
15
25
  <div className="mx-4 mb-1 border border-border rounded-lg bg-card shadow-lg overflow-hidden">
26
+ <div ref={listRef} className="max-h-[240px] overflow-y-auto">
16
27
  {results.map((f, idx) => {
17
28
  const name = f.split('/').pop() ?? f;
18
29
  const isCsv = name.endsWith('.csv');
@@ -42,7 +53,8 @@ export default function MentionPopover({ results, selectedIndex, onSelect }: Men
42
53
  </button>
43
54
  );
44
55
  })}
45
- <div className="px-3 py-1.5 border-t border-border flex gap-3 text-2xs text-muted-foreground/50">
56
+ </div>
57
+ <div className="px-3 py-1.5 border-t border-border flex gap-3 text-2xs text-muted-foreground/50 shrink-0">
46
58
  <span>↑↓ navigate</span>
47
59
  <span>↵ select</span>
48
60
  <span>ESC dismiss</span>
@@ -63,7 +63,7 @@ function AssistantMessageWithParts({ message, isStreaming }: { message: Message;
63
63
  })}
64
64
  {showTrailingSpinner && (
65
65
  <div className="flex items-center gap-2 py-1 mt-1">
66
- <Loader2 size={12} className="animate-spin" style={{ color: 'var(--amber)' }} />
66
+ <Loader2 size={12} className="animate-spin text-[var(--amber)]" />
67
67
  <span className="text-xs text-muted-foreground animate-pulse">Executing tool…</span>
68
68
  </div>
69
69
  )}
@@ -137,16 +137,14 @@ export default function MessageList({
137
137
  <div key={i} className={`flex gap-3 ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
138
138
  {m.role === 'assistant' && (
139
139
  <div
140
- className="w-6 h-6 rounded-full flex items-center justify-center shrink-0 mt-0.5"
141
- style={{ background: 'var(--amber-dim)' }}
140
+ className="w-6 h-6 rounded-full flex items-center justify-center shrink-0 mt-0.5 bg-[var(--amber-dim)]"
142
141
  >
143
- <Sparkles size={12} style={{ color: 'var(--amber)' }} />
142
+ <Sparkles size={12} className="text-[var(--amber)]" />
144
143
  </div>
145
144
  )}
146
145
  {m.role === 'user' ? (
147
146
  <div
148
- className="max-w-[85%] px-3 py-2 rounded-xl rounded-br-sm text-sm leading-relaxed whitespace-pre-wrap"
149
- style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
147
+ className="max-w-[85%] px-3 py-2 rounded-xl rounded-br-sm text-sm leading-relaxed whitespace-pre-wrap bg-[var(--amber)] text-[var(--amber-foreground)]"
150
148
  >
151
149
  {m.content}
152
150
  </div>
@@ -168,7 +166,7 @@ export default function MessageList({
168
166
  </>
169
167
  ) : isLoading && i === messages.length - 1 ? (
170
168
  <div className="flex items-center gap-2 py-1">
171
- <Loader2 size={14} className="animate-spin" style={{ color: 'var(--amber)' }} />
169
+ <Loader2 size={14} className="animate-spin text-[var(--amber)]" />
172
170
  <span className="text-xs text-muted-foreground animate-pulse">
173
171
  {loadingPhase === 'connecting'
174
172
  ? labels.connecting
@@ -56,7 +56,7 @@ export default function ToolCallBlock({ part }: { part: ToolCallPart }) {
56
56
  return (
57
57
  <div className={`my-1 rounded-md border text-xs font-mono ${
58
58
  isDestructive
59
- ? 'border-amber-500/30 bg-amber-500/5'
59
+ ? 'border-[var(--amber)]/30 bg-[var(--amber)]/5'
60
60
  : 'border-border/50 bg-muted/30'
61
61
  }`}>
62
62
  <button
@@ -65,13 +65,13 @@ export default function ToolCallBlock({ part }: { part: ToolCallPart }) {
65
65
  className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left hover:bg-muted/50 transition-colors rounded-md"
66
66
  >
67
67
  {expanded ? <ChevronDown size={12} className="shrink-0 text-muted-foreground" /> : <ChevronRight size={12} className="shrink-0 text-muted-foreground" />}
68
- {isDestructive && <AlertTriangle size={11} className="shrink-0 text-amber-500" />}
68
+ {isDestructive && <AlertTriangle size={11} className="shrink-0 text-[var(--amber)]" />}
69
69
  <span>{icon}</span>
70
- <span className={`font-medium ${isDestructive ? 'text-amber-600 dark:text-amber-400' : 'text-foreground'}`}>{part.toolName}</span>
70
+ <span className={`font-medium ${isDestructive ? 'text-[var(--amber)]' : 'text-foreground'}`}>{part.toolName}</span>
71
71
  <span className="text-muted-foreground truncate flex-1">({inputSummary})</span>
72
72
  <span className="shrink-0 ml-auto">
73
73
  {part.state === 'pending' || part.state === 'running' ? (
74
- <Loader2 size={12} className="animate-spin text-amber-500" />
74
+ <Loader2 size={12} className="animate-spin text-[var(--amber)]" />
75
75
  ) : part.state === 'done' ? (
76
76
  <CheckCircle2 size={12} className="text-success" />
77
77
  ) : (
@@ -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,89 @@ 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)] bg-[var(--amber-subtle)]"
92
+ >
93
+ <History size={14} />
94
+ </span>
95
+ <div className="min-w-0 flex-1">
96
+ <p className="text-xs text-foreground font-display whitespace-nowrap">
97
+ {t.changes.unreadBanner(unreadCount)}
98
+ </p>
99
+ <div className="mt-1 flex items-center gap-1.5">
100
+ <Link
101
+ href="/changes"
102
+ 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"
103
+ >
104
+ {t.changes.reviewNow}
105
+ </Link>
106
+ <button
107
+ type="button"
108
+ onClick={() => void handleMarkAllRead()}
109
+ 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"
110
+ >
111
+ {t.changes.markAllRead}
112
+ </button>
113
+ </div>
114
+ </div>
115
+ <button
116
+ type="button"
117
+ onClick={() => setDismissedAtCount(unreadCount)}
118
+ aria-label={t.changes.dismiss}
119
+ 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
120
  >
45
- Review changes
46
- </Link>
121
+ <X size={14} />
122
+ </button>
47
123
  </div>
48
124
  </div>
49
125
  </div>