@geminilight/mindos 0.6.31 → 0.6.33

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.
@@ -1,5 +1,7 @@
1
1
  export const dynamic = 'force-dynamic';
2
2
  import type { AgentTool, AgentToolResult } from '@mariozechner/pi-agent-core';
3
+ import { isTransientError } from '@/lib/agent/retry';
4
+ import { detectLoop } from '@/lib/agent/loop-detection';
3
5
  import {
4
6
  type AgentSessionEvent as AgentEvent,
5
7
  AuthStorage,
@@ -45,7 +47,8 @@ type MindOSSSEvent =
45
47
  | { type: 'tool_start'; toolCallId: string; toolName: string; args: unknown }
46
48
  | { type: 'tool_end'; toolCallId: string; output: string; isError: boolean }
47
49
  | { type: 'done'; usage?: { input: number; output: number } }
48
- | { type: 'error'; message: string };
50
+ | { type: 'error'; message: string }
51
+ | { type: 'status'; message: string };
49
52
 
50
53
  // ---------------------------------------------------------------------------
51
54
  // Type Guards for AgentEvent variants (safe event handling)
@@ -335,16 +338,40 @@ export async function POST(req: NextRequest) {
335
338
  const targetDir = dirnameOf(currentFile);
336
339
  const bootstrap = {
337
340
  instruction: readKnowledgeFile('INSTRUCTION.md'),
338
- index: readKnowledgeFile('README.md'),
339
341
  config_json: readKnowledgeFile('CONFIG.json'),
340
- config_md: readKnowledgeFile('CONFIG.md'),
341
- target_readme: targetDir ? readKnowledgeFile(`${targetDir}/README.md`) : null,
342
- target_instruction: targetDir ? readKnowledgeFile(`${targetDir}/INSTRUCTION.md`) : null,
343
- target_config_json: targetDir ? readKnowledgeFile(`${targetDir}/CONFIG.json`) : null,
344
- target_config_md: targetDir ? readKnowledgeFile(`${targetDir}/CONFIG.md`) : null,
342
+ // Lazy-loaded: only read if the file exists and has content.
343
+ // README.md and CONFIG.md are often empty/boilerplate and waste tokens.
344
+ index: null as ReturnType<typeof readKnowledgeFile> | null,
345
+ config_md: null as ReturnType<typeof readKnowledgeFile> | null,
346
+ target_readme: null as ReturnType<typeof readKnowledgeFile> | null,
347
+ target_instruction: null as ReturnType<typeof readKnowledgeFile> | null,
348
+ target_config_json: null as ReturnType<typeof readKnowledgeFile> | null,
349
+ target_config_md: null as ReturnType<typeof readKnowledgeFile> | null,
345
350
  };
346
351
 
347
- // Only report failures + truncation warnings
352
+ // Only load secondary bootstrap files if they have meaningful content.
353
+ // Files with ≤10 chars are typically empty or just a heading — not worth
354
+ // injecting into the prompt (saves ~200-500 tokens per empty file).
355
+ const MIN_USEFUL_CONTENT_LENGTH = 10;
356
+
357
+ const indexResult = readKnowledgeFile('README.md');
358
+ if (indexResult.ok && indexResult.content.trim().length > MIN_USEFUL_CONTENT_LENGTH) bootstrap.index = indexResult;
359
+
360
+ const configMdResult = readKnowledgeFile('CONFIG.md');
361
+ if (configMdResult.ok && configMdResult.content.trim().length > MIN_USEFUL_CONTENT_LENGTH) bootstrap.config_md = configMdResult;
362
+
363
+ if (targetDir) {
364
+ const tr = readKnowledgeFile(`${targetDir}/README.md`);
365
+ if (tr.ok && tr.content.trim().length > MIN_USEFUL_CONTENT_LENGTH) bootstrap.target_readme = tr;
366
+ const ti = readKnowledgeFile(`${targetDir}/INSTRUCTION.md`);
367
+ if (ti.ok && ti.content.trim().length > MIN_USEFUL_CONTENT_LENGTH) bootstrap.target_instruction = ti;
368
+ const tc = readKnowledgeFile(`${targetDir}/CONFIG.json`);
369
+ if (tc.ok && tc.content.trim().length > MIN_USEFUL_CONTENT_LENGTH) bootstrap.target_config_json = tc;
370
+ const tm = readKnowledgeFile(`${targetDir}/CONFIG.md`);
371
+ if (tm.ok && tm.content.trim().length > MIN_USEFUL_CONTENT_LENGTH) bootstrap.target_config_md = tm;
372
+ }
373
+
374
+ // Only report failures + truncation warnings for loaded files
348
375
  const initFailures: string[] = [];
349
376
  const truncationWarnings: string[] = [];
350
377
  if (!skill.ok) initFailures.push(`skill.mindos: failed (${skill.error})`);
@@ -352,19 +379,13 @@ export async function POST(req: NextRequest) {
352
379
  if (userSkillRules.ok && userSkillRules.truncated) truncationWarnings.push('user-skill-rules.md was truncated');
353
380
  if (!bootstrap.instruction.ok) initFailures.push(`bootstrap.instruction: failed (${bootstrap.instruction.error})`);
354
381
  if (bootstrap.instruction.ok && bootstrap.instruction.truncated) truncationWarnings.push('bootstrap.instruction was truncated');
355
- if (!bootstrap.index.ok) initFailures.push(`bootstrap.index: failed (${bootstrap.index.error})`);
356
- if (bootstrap.index.ok && bootstrap.index.truncated) truncationWarnings.push('bootstrap.index was truncated');
382
+ if (bootstrap.index?.ok && bootstrap.index.truncated) truncationWarnings.push('bootstrap.index was truncated');
357
383
  if (!bootstrap.config_json.ok) initFailures.push(`bootstrap.config_json: failed (${bootstrap.config_json.error})`);
358
384
  if (bootstrap.config_json.ok && bootstrap.config_json.truncated) truncationWarnings.push('bootstrap.config_json was truncated');
359
- if (!bootstrap.config_md.ok) initFailures.push(`bootstrap.config_md: failed (${bootstrap.config_md.error})`);
360
- if (bootstrap.config_md.ok && bootstrap.config_md.truncated) truncationWarnings.push('bootstrap.config_md was truncated');
361
- if (bootstrap.target_readme && !bootstrap.target_readme.ok) initFailures.push(`bootstrap.target_readme: failed (${bootstrap.target_readme.error})`);
385
+ if (bootstrap.config_md?.ok && bootstrap.config_md.truncated) truncationWarnings.push('bootstrap.config_md was truncated');
362
386
  if (bootstrap.target_readme?.ok && bootstrap.target_readme.truncated) truncationWarnings.push('bootstrap.target_readme was truncated');
363
- if (bootstrap.target_instruction && !bootstrap.target_instruction.ok) initFailures.push(`bootstrap.target_instruction: failed (${bootstrap.target_instruction.error})`);
364
387
  if (bootstrap.target_instruction?.ok && bootstrap.target_instruction.truncated) truncationWarnings.push('bootstrap.target_instruction was truncated');
365
- if (bootstrap.target_config_json && !bootstrap.target_config_json.ok) initFailures.push(`bootstrap.target_config_json: failed (${bootstrap.target_config_json.error})`);
366
388
  if (bootstrap.target_config_json?.ok && bootstrap.target_config_json.truncated) truncationWarnings.push('bootstrap.target_config_json was truncated');
367
- if (bootstrap.target_config_md && !bootstrap.target_config_md.ok) initFailures.push(`bootstrap.target_config_md: failed (${bootstrap.target_config_md.error})`);
368
389
  if (bootstrap.target_config_md?.ok && bootstrap.target_config_md.truncated) truncationWarnings.push('bootstrap.target_config_md was truncated');
369
390
 
370
391
  const initStatus = initFailures.length === 0
@@ -377,9 +398,9 @@ export async function POST(req: NextRequest) {
377
398
  initContextBlocks.push(`## user_skill_rules\n\nUser personalization rules (user-skill-rules.md):\n\n${userSkillRules.content}`);
378
399
  }
379
400
  if (bootstrap.instruction.ok) initContextBlocks.push(`## bootstrap_instruction\n\n${bootstrap.instruction.content}`);
380
- if (bootstrap.index.ok) initContextBlocks.push(`## bootstrap_index\n\n${bootstrap.index.content}`);
401
+ if (bootstrap.index?.ok) initContextBlocks.push(`## bootstrap_index\n\n${bootstrap.index.content}`);
381
402
  if (bootstrap.config_json.ok) initContextBlocks.push(`## bootstrap_config_json\n\n${bootstrap.config_json.content}`);
382
- if (bootstrap.config_md.ok) initContextBlocks.push(`## bootstrap_config_md\n\n${bootstrap.config_md.content}`);
403
+ if (bootstrap.config_md?.ok) initContextBlocks.push(`## bootstrap_config_md\n\n${bootstrap.config_md.content}`);
383
404
  if (bootstrap.target_readme?.ok) initContextBlocks.push(`## bootstrap_target_readme\n\n${bootstrap.target_readme.content}`);
384
405
  if (bootstrap.target_instruction?.ok) initContextBlocks.push(`## bootstrap_target_instruction\n\n${bootstrap.target_instruction.content}`);
385
406
  if (bootstrap.target_config_json?.ok) initContextBlocks.push(`## bootstrap_target_config_json\n\n${bootstrap.target_config_json.content}`);
@@ -584,18 +605,12 @@ export async function POST(req: NextRequest) {
584
605
  stepHistory.push(...newEntries);
585
606
  }
586
607
 
587
- // Loop detection: same tool + same args 3 times in a row.
588
- // Only trigger if we have 3+ history entries (prevent false positives on first turn).
589
- const LOOP_DETECTION_THRESHOLD = 3;
608
+ // Loop detection: (1) same tool+args 3x in a row, (2) repeating pattern cycle
590
609
  if (loopCooldown > 0) {
591
610
  loopCooldown--;
592
- } else if (stepHistory.length >= LOOP_DETECTION_THRESHOLD) {
593
- const lastN = stepHistory.slice(-LOOP_DETECTION_THRESHOLD);
594
- if (lastN.every(s => s.tool === lastN[0].tool && s.input === lastN[0].input)) {
595
- loopCooldown = 3;
596
- // TODO (metrics): Track loop detection rate — metrics.increment('agent.loop_detected', { model: modelName })
597
- void session.steer('[SYSTEM WARNING] You have called the same tool with identical arguments 3 times in a row. This appears to be a loop. Try a completely different approach or ask the user for clarification.');
598
- }
611
+ } else if (detectLoop(stepHistory)) {
612
+ loopCooldown = 3;
613
+ void session.steer('[SYSTEM WARNING] You appear to be in a loop — repeating the same tool calls in a cycle. Try a completely different approach or ask the user for clarification.');
599
614
  }
600
615
 
601
616
  // Step limit enforcement
@@ -708,7 +723,32 @@ export async function POST(req: NextRequest) {
708
723
  safeClose();
709
724
  } else {
710
725
  // Route to MindOS agent (existing logic)
711
- await session.prompt(lastUserContent, lastUserImages ? { images: lastUserImages } : undefined);
726
+ // Retry with exponential backoff for transient failures (timeout, rate limit, 5xx).
727
+ // Only retry if no content has been streamed yet — once the user sees partial
728
+ // output, retrying would produce duplicate/garbled content.
729
+ const MAX_RETRIES = 3;
730
+ let lastPromptError: Error | null = null;
731
+
732
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
733
+ try {
734
+ await session.prompt(lastUserContent, lastUserImages ? { images: lastUserImages } : undefined);
735
+ lastPromptError = null;
736
+ break; // success
737
+ } catch (err) {
738
+ lastPromptError = err instanceof Error ? err : new Error(String(err));
739
+
740
+ // Only retry if: (1) no content streamed yet, (2) retries remaining, (3) transient error
741
+ const canRetry = !hasContent && attempt < MAX_RETRIES && isTransientError(lastPromptError);
742
+ if (!canRetry) break;
743
+
744
+ const delayMs = 1000 * Math.pow(2, attempt - 1); // 1s, 2s
745
+ send({ type: 'status', message: `Request failed, retrying (${attempt}/${MAX_RETRIES})...` });
746
+ await new Promise(resolve => setTimeout(resolve, delayMs));
747
+ }
748
+ }
749
+
750
+ if (lastPromptError) throw lastPromptError;
751
+
712
752
  metrics.recordRequest(Date.now() - requestStartTime);
713
753
  if (!hasContent && lastModelError) {
714
754
  send({ type: 'error', message: lastModelError });
@@ -1,7 +1,7 @@
1
1
  export const dynamic = 'force-dynamic';
2
2
  import { NextResponse } from 'next/server';
3
3
  import path from 'path';
4
- import { collectAllFiles, getFileContent } from '@/lib/fs';
4
+ import { collectAllFiles, getLinkIndex } from '@/lib/fs';
5
5
 
6
6
  export interface GraphNode {
7
7
  id: string; // relative file path
@@ -19,63 +19,9 @@ export interface GraphData {
19
19
  edges: GraphEdge[];
20
20
  }
21
21
 
22
- function extractLinks(content: string, sourcePath: string, fileSet: Set<string>, basenameMap: Map<string, string[]>): string[] {
23
- const targets: string[] = [];
24
- const sourceDir = path.dirname(sourcePath);
25
-
26
- // WikiLinks: [[target]] or [[target|alias]] or [[target#section]]
27
- const wikiRe = /\[\[([^\]|#]+)(?:[|#][^\]]*)?/g;
28
- let m: RegExpExecArray | null;
29
- while ((m = wikiRe.exec(content)) !== null) {
30
- const raw = m[1].trim();
31
- if (!raw) continue;
32
-
33
- // Try exact match
34
- if (fileSet.has(raw)) {
35
- targets.push(raw);
36
- continue;
37
- }
38
- // Try with .md
39
- const withMd = raw.endsWith('.md') ? raw : raw + '.md';
40
- if (fileSet.has(withMd)) {
41
- targets.push(withMd);
42
- continue;
43
- }
44
- // Try basename lookup (case-insensitive)
45
- const lower = path.basename(withMd).toLowerCase();
46
- const candidates = basenameMap.get(lower);
47
- if (candidates && candidates.length === 1) {
48
- targets.push(candidates[0]);
49
- }
50
- // skip if ambiguous (multiple candidates)
51
- }
52
-
53
- // Markdown links: [text](relative/path.md) or [text](relative/path.md#section)
54
- const mdLinkRe = /\[[^\]]+\]\(([^)]+\.md)(?:#[^)]*)?\)/g;
55
- while ((m = mdLinkRe.exec(content)) !== null) {
56
- const raw = m[1].trim();
57
- if (!raw || raw.startsWith('http')) continue;
58
- const resolved = path.normalize(path.join(sourceDir, raw));
59
- if (fileSet.has(resolved)) {
60
- targets.push(resolved);
61
- }
62
- }
63
-
64
- return targets;
65
- }
66
-
67
22
  export async function GET() {
68
23
  try {
69
24
  const allFiles = collectAllFiles().filter(f => f.endsWith('.md'));
70
- const fileSet = new Set(allFiles);
71
-
72
- // Build basename → relPath[] lookup
73
- const basenameMap = new Map<string, string[]>();
74
- for (const f of allFiles) {
75
- const key = path.basename(f).toLowerCase();
76
- if (!basenameMap.has(key)) basenameMap.set(key, []);
77
- basenameMap.get(key)!.push(f);
78
- }
79
25
 
80
26
  const nodes: GraphNode[] = allFiles.map(f => ({
81
27
  id: f,
@@ -83,27 +29,10 @@ export async function GET() {
83
29
  folder: path.dirname(f),
84
30
  }));
85
31
 
86
- const edgeSet = new Set<string>();
87
- const edges: GraphEdge[] = [];
88
-
89
- for (const filePath of allFiles) {
90
- let content: string;
91
- try {
92
- content = getFileContent(filePath);
93
- } catch {
94
- continue;
95
- }
96
-
97
- const targets = extractLinks(content, filePath, fileSet, basenameMap);
98
- for (const target of targets) {
99
- if (target === filePath) continue; // skip self-edges
100
- const key = `${filePath}||${target}`;
101
- if (!edgeSet.has(key)) {
102
- edgeSet.add(key);
103
- edges.push({ source: filePath, target });
104
- }
105
- }
106
- }
32
+ // Use pre-built link index (O(1) per file) instead of extracting links
33
+ // from every file on each request (O(n * m))
34
+ const linkIndex = getLinkIndex();
35
+ const edges = linkIndex.getAllEdges();
107
36
 
108
37
  return NextResponse.json({ nodes, edges } satisfies GraphData);
109
38
  } catch (err) {
@@ -2,6 +2,7 @@ import { listTrashAction } from '@/lib/actions';
2
2
  import TrashPageClient from '@/components/TrashPageClient';
3
3
 
4
4
  export default async function TrashPage() {
5
+ // listTrashAction auto-purges expired items (>30 days) on each call
5
6
  const items = await listTrashAction();
6
7
  return <TrashPageClient initialItems={items} />;
7
8
  }
@@ -17,7 +17,8 @@ import { resolveRenderer, isRendererEnabled } from '@/lib/renderers/registry';
17
17
  import { encodePath } from '@/lib/utils';
18
18
  import { useLocale } from '@/lib/LocaleContext';
19
19
  import DirPicker from '@/components/DirPicker';
20
- import { renameFileAction, deleteFileAction } from '@/lib/actions';
20
+ import { renameFileAction, deleteFileAction, undoDeleteAction } from '@/lib/actions';
21
+ import { toast } from '@/lib/toast';
21
22
  import { ConfirmDialog } from '@/components/agents/AgentsPrimitives';
22
23
  import { buildLineDiff } from '@/components/changes/line-diff';
23
24
  import { usePinnedFiles } from '@/lib/hooks/usePinnedFiles';
@@ -132,15 +133,28 @@ export default function ViewPageClient({
132
133
 
133
134
  const handleConfirmDelete = useCallback(() => {
134
135
  setShowDeleteConfirm(false);
136
+ const fileName = filePath.split('/').pop() ?? filePath;
135
137
  startTransition(async () => {
136
138
  const result = await deleteFileAction(filePath);
137
139
  if (result.success) {
140
+ if (result.trashId) {
141
+ const trashId = result.trashId;
142
+ toast.undo(`${t.trash?.movedToTrash ?? 'Deleted'} ${fileName}`, async () => {
143
+ const undo = await undoDeleteAction(trashId);
144
+ if (undo.success) {
145
+ router.refresh();
146
+ window.dispatchEvent(new Event('mindos:files-changed'));
147
+ } else {
148
+ toast.error(undo.error ?? 'Undo failed');
149
+ }
150
+ }, { label: t.trash?.undo ?? 'Undo' });
151
+ }
138
152
  router.push('/');
139
153
  router.refresh();
140
154
  window.dispatchEvent(new Event('mindos:files-changed'));
141
155
  }
142
156
  });
143
- }, [filePath, router]);
157
+ }, [filePath, router, t]);
144
158
 
145
159
  // Keep first paint deterministic between server and client to avoid hydration mismatch.
146
160
  const effectiveUseRaw = hydrated ? useRaw : false;
@@ -588,8 +602,8 @@ export default function ViewPageClient({
588
602
  className="w-full bg-muted border border-border rounded-md px-3 py-1.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
589
603
  />
590
604
  <div className="flex justify-end gap-2 mt-3">
591
- <button onClick={() => setRenaming(false)} className="px-3 py-1.5 rounded-md text-xs bg-muted text-muted-foreground hover:bg-accent transition-colors">Cancel</button>
592
- <button onClick={handleCommitRename} className="px-3 py-1.5 rounded-md text-xs bg-[var(--amber)] text-[var(--amber-foreground)] transition-colors">Rename</button>
605
+ <button onClick={() => setRenaming(false)} className="px-3 py-1.5 rounded-md text-xs bg-muted text-muted-foreground hover:bg-accent transition-colors">{t.view?.cancel ?? 'Cancel'}</button>
606
+ <button onClick={handleCommitRename} className="px-3 py-1.5 rounded-md text-xs bg-[var(--amber)] text-[var(--amber-foreground)] transition-colors">{t.view?.rename ?? 'Rename'}</button>
593
607
  </div>
594
608
  </div>
595
609
  </div>
@@ -598,10 +612,10 @@ export default function ViewPageClient({
598
612
  {/* Delete confirm */}
599
613
  <ConfirmDialog
600
614
  open={showDeleteConfirm}
601
- title="Delete"
602
- message={`Delete "${filePath.split('/').pop()}"? This cannot be undone.`}
603
- confirmLabel="Delete"
604
- cancelLabel="Cancel"
615
+ title={t.view?.delete ?? 'Delete'}
616
+ message={t.view?.deleteConfirm?.(filePath.split('/').pop() ?? '') ?? `Delete "${filePath.split('/').pop()}"?`}
617
+ confirmLabel={t.view?.delete ?? 'Delete'}
618
+ cancelLabel={t.view?.cancel ?? 'Cancel'}
605
619
  variant="destructive"
606
620
  onCancel={() => setShowDeleteConfirm(false)}
607
621
  onConfirm={handleConfirmDelete}
@@ -114,7 +114,7 @@ export default function ExportModal({ open, onClose, filePath, isDirectory, file
114
114
  {isDirectory ? (t.export?.exportSpace ?? 'Export Space') : (t.export?.exportFile ?? 'Export File')}
115
115
  </h3>
116
116
  </div>
117
- <button onClick={handleClose} className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
117
+ <button onClick={handleClose} className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" aria-label={t.export?.cancel ?? 'Close'}>
118
118
  <X size={14} />
119
119
  </button>
120
120
  </div>
@@ -179,7 +179,7 @@ export default function ExportModal({ open, onClose, filePath, isDirectory, file
179
179
  {t.export?.downloadAgain ?? 'Download Again'}
180
180
  </button>
181
181
  <button onClick={handleClose} className="px-3 py-1.5 rounded-md text-xs font-medium bg-[var(--amber-dim)] text-[var(--amber-text)] hover:opacity-80 transition-colors">
182
- {t.export?.cancel === 'Cancel' ? 'Done' : (t.export?.done ?? 'Done')}
182
+ {t.export?.close ?? 'Done'}
183
183
  </button>
184
184
  </>
185
185
  ) : state === 'error' ? (
@@ -8,7 +8,8 @@ import {
8
8
  ChevronDown, FileText, Table, Folder, FolderOpen, Plus, Loader2,
9
9
  Trash2, Pencil, Layers, ScrollText, FolderInput, Copy, MoreHorizontal, Star,
10
10
  } from 'lucide-react';
11
- import { createFileAction, deleteFileAction, renameFileAction, renameSpaceAction, deleteSpaceAction, convertToSpaceAction, deleteFolderAction } from '@/lib/actions';
11
+ import { createFileAction, deleteFileAction, renameFileAction, renameSpaceAction, deleteSpaceAction, convertToSpaceAction, deleteFolderAction, undoDeleteAction } from '@/lib/actions';
12
+ import { toast } from '@/lib/toast';
12
13
  import { useLocale } from '@/lib/LocaleContext';
13
14
  import { ConfirmDialog } from '@/components/agents/AgentsPrimitives';
14
15
  import { usePinnedFiles } from '@/lib/hooks/usePinnedFiles';
@@ -536,7 +537,7 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth, onI
536
537
  title={deleteConfirm === 'space' ? t.fileTree.deleteSpace : t.fileTree.deleteFolder}
537
538
  message={deleteConfirm === 'space' ? t.fileTree.confirmDeleteSpace(node.name) : t.fileTree.confirmDeleteFolder(node.name)}
538
539
  confirmLabel={deleteConfirm === 'space' ? t.fileTree.deleteSpace : t.fileTree.deleteFolder}
539
- cancelLabel="Cancel"
540
+ cancelLabel={t.view?.cancel ?? 'Cancel'}
540
541
  variant="destructive"
541
542
  onCancel={() => setDeleteConfirm(null)}
542
543
  onConfirm={() => {
@@ -546,7 +547,16 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth, onI
546
547
  const result = kind === 'space'
547
548
  ? await deleteSpaceAction(node.path)
548
549
  : await deleteFolderAction(node.path);
549
- if (result.success) { router.push('/'); router.refresh(); notifyFilesChanged(); }
550
+ if (result.success && result.trashId) {
551
+ const trashId = result.trashId;
552
+ const name = node.path.split('/').pop() ?? node.path;
553
+ toast.undo(`${t.trash?.movedToTrash ?? 'Deleted'} ${name}`, async () => {
554
+ const undo = await undoDeleteAction(trashId);
555
+ if (undo.success) { router.refresh(); notifyFilesChanged(); }
556
+ else toast.error(undo.error ?? 'Undo failed');
557
+ }, { label: t.trash?.undo ?? 'Undo' });
558
+ router.push('/'); router.refresh(); notifyFilesChanged();
559
+ }
550
560
  });
551
561
  }}
552
562
  />
@@ -718,14 +728,25 @@ function FileNodeItem({ node, depth, currentPath, onNavigate }: {
718
728
  title={t.fileTree.delete}
719
729
  message={t.fileTree.confirmDelete(node.name)}
720
730
  confirmLabel={t.fileTree.delete}
721
- cancelLabel="Cancel"
731
+ cancelLabel={t.view?.cancel ?? 'Cancel'}
722
732
  variant="destructive"
723
733
  onCancel={() => setShowDeleteConfirm(false)}
724
734
  onConfirm={() => {
725
735
  setShowDeleteConfirm(false);
726
736
  startDeleteTransition(async () => {
727
737
  const result = await deleteFileAction(node.path);
728
- if (result.success) { router.push('/'); router.refresh(); notifyFilesChanged(); }
738
+ if (result.success) {
739
+ if (result.trashId) {
740
+ const trashId = result.trashId;
741
+ const name = node.path.split('/').pop() ?? node.path;
742
+ toast.undo(`${t.trash?.movedToTrash ?? 'Deleted'} ${name}`, async () => {
743
+ const undo = await undoDeleteAction(trashId);
744
+ if (undo.success) { router.refresh(); notifyFilesChanged(); }
745
+ else toast.error(undo.error ?? 'Undo failed');
746
+ }, { label: t.trash?.undo ?? 'Undo' });
747
+ }
748
+ router.refresh(); notifyFilesChanged();
749
+ }
729
750
  });
730
751
  }}
731
752
  />
@@ -10,6 +10,7 @@ import { usePinnedFiles } from '@/lib/hooks/usePinnedFiles';
10
10
  import { getAllRenderers, getPluginRenderers } from '@/lib/renderers/registry';
11
11
  import OnboardingView from './OnboardingView';
12
12
  import GuideCard from './GuideCard';
13
+ import SystemPulse from './SystemPulse';
13
14
  import { scanExampleFilesAction, cleanupExamplesAction } from '@/lib/actions';
14
15
  import type { SpaceInfo } from '@/app/page';
15
16
 
@@ -332,6 +333,9 @@ export default function HomeContent({ recent, existingFiles, spaces }: { recent:
332
333
  </div>
333
334
  </div>
334
335
 
336
+ {/* ── System Pulse: Agent status + stats ── */}
337
+ <SystemPulse />
338
+
335
339
  {/* ── Section: Pinned Files ── */}
336
340
  <PinnedFilesSection formatTime={formatTime} />
337
341