@raingor/pi-web-switch 0.3.0 → 0.3.2

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,7 +1,22 @@
1
- import { useState, useEffect } from "react";
1
+ import { useState, useEffect, useMemo, useCallback } from "react";
2
+ import type { ReactNode } from "react";
2
3
  import { useConfigStore } from "@/store/config-store";
3
4
  import { useTranslation } from "@/lib/i18n";
4
- import { Brain, User, AlertTriangle, Clock, FileText } from "lucide-react";
5
+ import { Modal } from "@/components/ui/Modal";
6
+ import {
7
+ Brain,
8
+ User,
9
+ AlertTriangle,
10
+ FileText,
11
+ Calendar,
12
+ RefreshCw,
13
+ Search,
14
+ Copy,
15
+ Check,
16
+ Trash2,
17
+ ChevronDown,
18
+ ChevronRight,
19
+ } from "lucide-react";
5
20
 
6
21
  interface MemoryFile {
7
22
  name: string;
@@ -10,6 +25,12 @@ interface MemoryFile {
10
25
  updatedAt: string;
11
26
  }
12
27
 
28
+ interface MemoryEntry {
29
+ text: string;
30
+ created: string;
31
+ last: string;
32
+ }
33
+
13
34
  const FILE_ICONS: Record<string, typeof Brain> = {
14
35
  "MEMORY.md": Brain,
15
36
  "USER.md": User,
@@ -22,88 +43,324 @@ const FILE_COLORS: Record<string, string> = {
22
43
  "failures.md": "#ef4444",
23
44
  };
24
45
 
46
+ const FILE_LABEL_KEYS: Record<string, string> = {
47
+ "MEMORY.md": "memory.project_memories",
48
+ "USER.md": "memory.user_profile",
49
+ "failures.md": "memory.failure_records",
50
+ };
51
+
52
+ /** Date groups expanded by default per file */
53
+ const DEFAULT_OPEN_GROUPS = 3;
54
+
55
+ /** Parse §-separated entries with `<!-- created=DATE, last=DATE -->` markers */
56
+ function parseMemoryEntries(content: string): MemoryEntry[] {
57
+ const entries: MemoryEntry[] = [];
58
+ const sections = content.split("§").filter((s) => s.trim().length > 0);
59
+
60
+ for (const section of sections) {
61
+ const trimmed = section.trim();
62
+ // Match `<!-- created=DATE, last=DATE -->` at the end
63
+ const markerMatch = trimmed.match(
64
+ /<!--\s*created\s*=\s*([^,\s]+)\s*,\s*last\s*=\s*([^>\s]+)\s*-->\s*$/
65
+ );
66
+ if (markerMatch) {
67
+ entries.push({
68
+ text: trimmed.slice(0, markerMatch.index).trim(),
69
+ created: markerMatch[1]?.trim() ?? "",
70
+ last: markerMatch[2]?.trim() ?? "",
71
+ });
72
+ } else {
73
+ // No date marker — treat as a standalone entry
74
+ entries.push({ text: trimmed, created: "", last: "" });
75
+ }
76
+ }
77
+
78
+ return entries;
79
+ }
80
+
81
+ /** Group entries by date (descending) */
82
+ function groupByDate(entries: MemoryEntry[]): Map<string, MemoryEntry[]> {
83
+ const groups = new Map<string, MemoryEntry[]>();
84
+ for (const entry of entries) {
85
+ const dateKey = entry.created || entry.last || "other";
86
+ if (!groups.has(dateKey)) groups.set(dateKey, []);
87
+ groups.get(dateKey)!.push(entry);
88
+ }
89
+ return new Map(
90
+ [...groups.entries()].sort((a, b) => {
91
+ if (a[0] === "other") return 1;
92
+ if (b[0] === "other") return -1;
93
+ return b[0].localeCompare(a[0]);
94
+ })
95
+ );
96
+ }
97
+
25
98
  function formatDate(iso: string): string {
26
99
  if (!iso) return "—";
27
- const d = new Date(iso);
28
- return d.toLocaleDateString("en-US", {
100
+ return new Date(iso).toLocaleDateString(undefined, {
101
+ year: "numeric",
29
102
  month: "short",
30
103
  day: "numeric",
31
- hour: "2-digit",
32
- minute: "2-digit",
33
104
  });
34
105
  }
35
106
 
36
- function mdToHtml(text: string): string {
37
- return text
38
- // Headings
39
- .replace(/^### (.+)$/gm, '<h3 class="text-sm font-semibold mt-4 mb-2" style="color:var(--page-text)">$1</h3>')
40
- .replace(/^## (.+)$/gm, '<h2 class="text-base font-bold mt-5 mb-2" style="color:var(--page-text)">$1</h2>')
41
- .replace(/^# (.+)$/gm, '<h1 class="text-lg font-bold mt-5 mb-3" style="color:var(--page-text)">$1</h1>')
42
- // Bold
43
- .replace(/\*\*(.+?)\*\*/g, '<strong style="color:var(--page-text)">$1</strong>')
44
- // Inline code
45
- .replace(/`([^`]+)`/g, '<code class="text-xs px-1 py-0.5 rounded" style="background:var(--accent-bg);color:var(--sidebar-active-text)">$1</code>')
46
- // Section separator § → horizontal rule
47
- .replace(/^§\s*$/gm, '<hr style="border-color:var(--card-border);margin:12px 0" />')
48
- // List items (must be after other inline patterns)
49
- .replace(/^- (.+)$/gm, '<li class="text-sm ml-4" style="color:var(--page-text);list-style:disc">$1</li>')
50
- // Comments: <!-- ... -->
51
- .replace(/<!-- (.+?) -->/g, '<span class="text-xs ml-2" style="color:var(--subtle-text)">// $1</span>')
52
- // Double line breaks to paragraphs
53
- .replace(/\n\n/g, '</p><p class="text-sm leading-relaxed" style="color:var(--page-text)">')
54
- // Single line breaks within paragraphs
55
- .replace(/\n/g, '<br />')
56
- // Wrap everything in paragraph if not already wrapped
57
- .replace(/^(?!<[hp])/m, '<p class="text-sm leading-relaxed" style="color:var(--page-text)">')
58
- .replace(/([^>])$/m, '$1</p>');
107
+ /** Highlight search matches inside a plain-text fragment */
108
+ function highlight(text: string, q: string): ReactNode {
109
+ if (!q || !text) return text;
110
+ const lower = text.toLowerCase();
111
+ const parts: ReactNode[] = [];
112
+ let pos = 0;
113
+ let idx = lower.indexOf(q);
114
+ while (idx !== -1) {
115
+ if (idx > pos) parts.push(text.slice(pos, idx));
116
+ parts.push(
117
+ <mark
118
+ key={`${idx}-${pos}`}
119
+ className="rounded-sm px-0.5"
120
+ style={{ backgroundColor: "rgba(250, 204, 21, 0.35)", color: "inherit" }}
121
+ >
122
+ {text.slice(idx, idx + q.length)}
123
+ </mark>
124
+ );
125
+ pos = idx + q.length;
126
+ idx = lower.indexOf(q, pos);
127
+ }
128
+ if (pos < text.length) parts.push(text.slice(pos));
129
+ return parts;
130
+ }
131
+
132
+ /** Lightweight inline markdown: **bold**, `code`, [text](url). No external deps. */
133
+ function renderInline(text: string, q: string): ReactNode {
134
+ const re = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\((?:https?:\/\/)[^)\s]+\))/g;
135
+ const parts = text.split(re);
136
+ return parts.map((part, i) => {
137
+ if (/^\*\*[^*]+\*\*$/.test(part)) {
138
+ return (
139
+ <strong key={i} className="font-semibold">
140
+ {highlight(part.slice(2, -2), q)}
141
+ </strong>
142
+ );
143
+ }
144
+ if (/^`[^`]+`$/.test(part)) {
145
+ return (
146
+ <code
147
+ key={i}
148
+ className="rounded border px-1 py-px text-[12px]"
149
+ style={{
150
+ borderColor: "var(--card-border)",
151
+ backgroundColor: "var(--page-bg)",
152
+ color: "var(--page-text)",
153
+ }}
154
+ >
155
+ {highlight(part.slice(1, -1), q)}
156
+ </code>
157
+ );
158
+ }
159
+ const link = part.match(/^\[([^\]]+)\]\(((?:https?:\/\/)[^)\s]+)\)$/);
160
+ if (link) {
161
+ return (
162
+ <a
163
+ key={i}
164
+ href={link[2]}
165
+ target="_blank"
166
+ rel="noreferrer"
167
+ className="underline underline-offset-2"
168
+ style={{ color: "#3b82f6" }}
169
+ >
170
+ {highlight(link[1] ?? "", q)}
171
+ </a>
172
+ );
173
+ }
174
+ return <span key={i}>{highlight(part, q)}</span>;
175
+ });
59
176
  }
60
177
 
61
- function MemoryCard({ file }: { file: MemoryFile }) {
178
+ function EntryCard({
179
+ entry,
180
+ q,
181
+ onDelete,
182
+ }: {
183
+ entry: MemoryEntry;
184
+ q: string;
185
+ onDelete: () => void;
186
+ }) {
187
+ const { t } = useTranslation();
188
+ const [copied, setCopied] = useState(false);
189
+
190
+ const handleCopy = () => {
191
+ const markCopied = () => {
192
+ setCopied(true);
193
+ setTimeout(() => setCopied(false), 1500);
194
+ };
195
+ navigator.clipboard.writeText(entry.text).then(markCopied).catch(() => {
196
+ // Fallback for non-focused/insecure contexts
197
+ const ta = document.createElement("textarea");
198
+ ta.value = entry.text;
199
+ ta.style.position = "fixed";
200
+ ta.style.opacity = "0";
201
+ document.body.appendChild(ta);
202
+ ta.select();
203
+ try {
204
+ document.execCommand("copy");
205
+ markCopied();
206
+ } finally {
207
+ document.body.removeChild(ta);
208
+ }
209
+ });
210
+ };
211
+
212
+ return (
213
+ <div
214
+ className="group relative rounded-lg border px-3.5 py-2.5"
215
+ style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)" }}
216
+ >
217
+ <p
218
+ className="whitespace-pre-wrap pr-14 text-[13px] leading-relaxed"
219
+ style={{ color: "var(--page-text)" }}
220
+ >
221
+ {renderInline(entry.text, q)}
222
+ </p>
223
+ {/* Hover actions: copy / delete */}
224
+ <div className="absolute right-2 top-2 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
225
+ <button
226
+ onClick={handleCopy}
227
+ className="rounded p-1 transition-colors hover:bg-black/10"
228
+ style={{ color: copied ? "#10b981" : "var(--muted-text)" }}
229
+ title={copied ? t("memory.copied") : t("memory.copy")}
230
+ >
231
+ {copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
232
+ </button>
233
+ <button
234
+ onClick={onDelete}
235
+ className="rounded p-1 transition-colors hover:bg-black/10"
236
+ style={{ color: "#ef4444" }}
237
+ title={t("memory.delete")}
238
+ >
239
+ <Trash2 className="h-3.5 w-3.5" />
240
+ </button>
241
+ </div>
242
+ </div>
243
+ );
244
+ }
245
+
246
+ function MemoryFileSection({
247
+ file,
248
+ entries,
249
+ q,
250
+ onDeleteEntry,
251
+ }: {
252
+ file: MemoryFile;
253
+ entries: MemoryEntry[];
254
+ q: string;
255
+ onDeleteEntry: (entry: MemoryEntry) => void;
256
+ }) {
62
257
  const { t } = useTranslation();
63
- const [open, setOpen] = useState(true);
64
258
  const Icon = FILE_ICONS[file.filename] || FileText;
65
259
  const color = FILE_COLORS[file.filename] || "#6b7280";
66
- const lineCount = file.content.split("\n").length;
260
+ // Collapse overrides: user toggles win over the default (first N groups open)
261
+ const [openOverrides, setOpenOverrides] = useState<Record<string, boolean>>({});
67
262
 
68
- if (!file.content) {
69
- return null;
70
- }
263
+ const grouped = useMemo(() => groupByDate(entries), [entries]);
264
+ const labelKey = FILE_LABEL_KEYS[file.filename];
265
+
266
+ const toggleGroup = (key: string, currentOpen: boolean) =>
267
+ setOpenOverrides((prev) => ({ ...prev, [key]: !currentOpen }));
71
268
 
72
269
  return (
73
- <div className="rounded-xl border overflow-hidden" style={{ borderColor: "var(--card-border)" }}>
74
- {/* Header */}
75
- <button
76
- onClick={() => setOpen(!open)}
77
- className="flex w-full items-center justify-between px-6 py-4"
78
- style={{ backgroundColor: "var(--card-bg)" }}
270
+ <div>
271
+ {/* File header */}
272
+ <div
273
+ className="flex items-center gap-2 pb-3"
274
+ style={{ borderBottom: "1px solid var(--card-border)" }}
79
275
  >
80
- <div className="flex items-center gap-3">
81
- <div className="flex h-9 w-9 items-center justify-center rounded-lg" style={{ backgroundColor: `${color}15` }}>
82
- <Icon className="h-4 w-4" style={{ color }} />
83
- </div>
84
- <div className="text-left">
85
- <h3 className="text-sm font-semibold" style={{ color: "var(--page-text)" }}>{t("memory." + file.filename.replace("MEMORY.md", "project_memories").replace("USER.md", "user_profile").replace("failures.md", "failure_records"))}</h3>
86
- <p className="text-xs" style={{ color: "var(--muted-text)" }}>
87
- {t("memory.lines", String(lineCount))} · {t("memory.updated", formatDate(file.updatedAt))}
88
- </p>
89
- </div>
90
- </div>
91
- <div className="flex items-center gap-2">
92
- <span className="text-xs font-medium" style={{ color }}>
93
- {file.filename}
276
+ <Icon className="h-4 w-4" style={{ color }} />
277
+ <h3 className="text-sm font-semibold" style={{ color: "var(--page-text)" }}>
278
+ {labelKey ? t(labelKey) : file.name}
279
+ </h3>
280
+ <span
281
+ className="rounded border px-1.5 py-0.5 text-[10px]"
282
+ style={{
283
+ borderColor: "var(--card-border)",
284
+ color: "var(--muted-text)",
285
+ backgroundColor: "var(--card-bg)",
286
+ }}
287
+ >
288
+ {file.filename}
289
+ </span>
290
+ <span className="text-[11px]" style={{ color: "var(--muted-text)" }}>
291
+ {t("memory.entries_count", String(entries.length))}
292
+ </span>
293
+ {file.updatedAt && (
294
+ <span className="ml-auto text-[11px]" style={{ color: "var(--muted-text)" }}>
295
+ {formatDate(file.updatedAt)}
94
296
  </span>
95
- <Clock className="h-3.5 w-3.5" style={{ color: "var(--muted-text)" }} />
96
- </div>
97
- </button>
98
-
99
- {/* Content */}
100
- {open && (
101
- <div className="px-6 py-4" style={{ borderTop: "1px solid var(--card-border)", backgroundColor: "var(--page-bg)" }}>
102
- <div
103
- className="prose prose-sm max-w-none"
104
- dangerouslySetInnerHTML={{ __html: mdToHtml(file.content) }}
105
- style={{ color: "var(--page-text)" }}
106
- />
297
+ )}
298
+ </div>
299
+
300
+ {/* Entries grouped by date */}
301
+ {entries.length === 0 ? (
302
+ <p className="mt-4 text-xs italic" style={{ color: "var(--muted-text)" }}>
303
+ {t("memory.empty_file")}
304
+ </p>
305
+ ) : (
306
+ <div className="mt-4 space-y-4">
307
+ {[...grouped.entries()].map(([dateKey, dateEntries], groupIdx) => {
308
+ // Searching forces all matched groups open; otherwise first N open by default
309
+ const open = q
310
+ ? true
311
+ : openOverrides[dateKey] ?? groupIdx < DEFAULT_OPEN_GROUPS;
312
+ return (
313
+ <div key={dateKey}>
314
+ {/* Date header (click to collapse/expand) */}
315
+ <button
316
+ onClick={() => toggleGroup(dateKey, open)}
317
+ className="mb-2.5 flex w-full items-center gap-2"
318
+ disabled={!!q}
319
+ >
320
+ {open ? (
321
+ <ChevronDown className="h-3.5 w-3.5" style={{ color }} />
322
+ ) : (
323
+ <ChevronRight className="h-3.5 w-3.5" style={{ color }} />
324
+ )}
325
+ <Calendar className="h-3.5 w-3.5" style={{ color }} />
326
+ <span
327
+ className="text-xs font-semibold uppercase tracking-wide"
328
+ style={{ color }}
329
+ >
330
+ {dateKey === "other" ? t("memory.other") : dateKey}
331
+ </span>
332
+ <div
333
+ className="h-px flex-1 opacity-50"
334
+ style={{ backgroundColor: "var(--card-border)" }}
335
+ />
336
+ <span
337
+ className="rounded border px-1.5 py-0.5 text-[10px]"
338
+ style={{
339
+ borderColor: "var(--card-border)",
340
+ color: "var(--muted-text)",
341
+ backgroundColor: "var(--card-bg)",
342
+ }}
343
+ >
344
+ {t("memory.entries_count", String(dateEntries.length))}
345
+ </span>
346
+ </button>
347
+
348
+ {/* Timeline entries */}
349
+ {open && (
350
+ <div className="space-y-1.5 pl-2">
351
+ {dateEntries.map((entry, idx) => (
352
+ <EntryCard
353
+ key={`${dateKey}-${idx}`}
354
+ entry={entry}
355
+ q={q}
356
+ onDelete={() => onDeleteEntry(entry)}
357
+ />
358
+ ))}
359
+ </div>
360
+ )}
361
+ </div>
362
+ );
363
+ })}
107
364
  </div>
108
365
  )}
109
366
  </div>
@@ -115,23 +372,73 @@ export function MemoryPage() {
115
372
  const { initialized } = useConfigStore();
116
373
  const [files, setFiles] = useState<MemoryFile[]>([]);
117
374
  const [loading, setLoading] = useState(true);
375
+ const [refreshing, setRefreshing] = useState(false);
118
376
  const [error, setError] = useState<string | null>(null);
377
+ const [filter, setFilter] = useState("");
378
+ const [deleteTarget, setDeleteTarget] = useState<{ filename: string; entry: MemoryEntry } | null>(null);
379
+ const [deleting, setDeleting] = useState(false);
119
380
 
120
- useEffect(() => {
381
+ const loadAll = useCallback(() => {
121
382
  if (!initialized) return;
122
- setLoading(true);
383
+ setRefreshing(true);
123
384
  fetch("/api/pi/memory")
124
385
  .then((r) => r.json())
125
386
  .then((data) => {
126
387
  setFiles(data);
127
- setLoading(false);
388
+ setError(null);
128
389
  })
129
- .catch((e) => {
130
- setError(e.message);
390
+ .catch((e) => setError(e.message))
391
+ .finally(() => {
131
392
  setLoading(false);
393
+ setRefreshing(false);
132
394
  });
133
395
  }, [initialized]);
134
396
 
397
+ useEffect(() => { loadAll(); }, [loadAll]);
398
+
399
+ const handleDelete = async () => {
400
+ if (!deleteTarget) return;
401
+ setDeleting(true);
402
+ try {
403
+ const res = await fetch("/api/pi/memory/delete-entry", {
404
+ method: "POST",
405
+ headers: { "Content-Type": "application/json" },
406
+ body: JSON.stringify({ filename: deleteTarget.filename, text: deleteTarget.entry.text }),
407
+ });
408
+ const result = await res.json();
409
+ if (result.success) {
410
+ setDeleteTarget(null);
411
+ loadAll();
412
+ } else {
413
+ alert(t("memory.delete_failed"));
414
+ }
415
+ } catch {
416
+ alert(t("memory.delete_failed"));
417
+ } finally {
418
+ setDeleting(false);
419
+ }
420
+ };
421
+
422
+ // Parse all files once; stats + search operate on parsed entries
423
+ const parsed = useMemo(
424
+ () =>
425
+ files
426
+ .filter((f) => f.content)
427
+ .map((f) => ({ file: f, entries: parseMemoryEntries(f.content) })),
428
+ [files]
429
+ );
430
+
431
+ const q = filter.trim().toLowerCase();
432
+ const visible = useMemo(() => {
433
+ if (!q) return parsed;
434
+ return parsed
435
+ .map(({ file, entries }) => ({
436
+ file,
437
+ entries: entries.filter((e) => e.text.toLowerCase().includes(q)),
438
+ }))
439
+ .filter(({ entries }) => entries.length > 0);
440
+ }, [parsed, q]);
441
+
135
442
  if (loading) {
136
443
  return (
137
444
  <div className="flex items-center justify-center h-64">
@@ -143,35 +450,123 @@ export function MemoryPage() {
143
450
  if (error) {
144
451
  return (
145
452
  <div className="flex items-center justify-center h-64">
146
- <p className="text-sm text-red-400">Failed to load memory: {error}</p>
453
+ <p className="text-sm" style={{ color: "var(--error-text, #ef4444)" }}>
454
+ {t("memory.load_failed")}: {error}
455
+ </p>
147
456
  </div>
148
457
  );
149
458
  }
150
459
 
151
- const totalLines = files.reduce((s, f) => s + f.content.split("\n").length, 0);
460
+ const totalEntries = parsed.reduce((s, p) => s + p.entries.length, 0);
152
461
 
153
462
  return (
154
463
  <div className="space-y-6">
155
- <div>
156
- <h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>{t("memory.title")}</h1>
157
- <p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
158
- {t("memory.summary", String(totalLines), String(files.length))}
159
- </p>
464
+ <div className="flex items-start justify-between">
465
+ <div>
466
+ <h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>{t("memory.title")}</h1>
467
+ <p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
468
+ {t("memory.summary", String(totalEntries), String(parsed.length))}
469
+ </p>
470
+ </div>
471
+ <button
472
+ onClick={loadAll}
473
+ className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
474
+ style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)" }}
475
+ title={t("memory.refresh")}
476
+ >
477
+ <RefreshCw className={refreshing ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
478
+ {t("memory.refresh")}
479
+ </button>
160
480
  </div>
161
481
 
162
- {files.length === 0 ? (
482
+ {parsed.length === 0 ? (
163
483
  <div className="flex flex-col items-center justify-center py-12">
164
484
  <Brain className="h-12 w-12" style={{ color: "var(--subtle-text)" }} />
165
485
  <p className="mt-4 text-sm" style={{ color: "var(--muted-text)" }}>{t("memory.no_memory")}</p>
166
486
  <p className="text-xs mt-1" style={{ color: "var(--subtle-text)" }}>{t("memory.no_memory_desc")}</p>
167
487
  </div>
168
488
  ) : (
169
- <div className="space-y-4">
170
- {files.map((file) => (
171
- <MemoryCard key={file.filename} file={file} />
172
- ))}
173
- </div>
489
+ <>
490
+ {/* Search */}
491
+ <div className="relative max-w-md">
492
+ <Search
493
+ className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
494
+ style={{ color: "var(--subtle-text)" }}
495
+ />
496
+ <input
497
+ type="text"
498
+ value={filter}
499
+ onChange={(e) => setFilter(e.target.value)}
500
+ placeholder={t("memory.search_placeholder")}
501
+ className="w-full rounded-lg border py-2 pl-9 pr-3 text-sm outline-none focus:ring-1 focus:ring-blue-500"
502
+ style={{
503
+ backgroundColor: "var(--card-bg)",
504
+ borderColor: "var(--card-border)",
505
+ color: "var(--page-text)",
506
+ }}
507
+ />
508
+ </div>
509
+
510
+ {visible.length === 0 ? (
511
+ <p className="py-8 text-center text-sm" style={{ color: "var(--muted-text)" }}>
512
+ {t("memory.no_results")}
513
+ </p>
514
+ ) : (
515
+ <div className="space-y-8">
516
+ {visible.map(({ file, entries }) => (
517
+ <MemoryFileSection
518
+ key={file.filename}
519
+ file={file}
520
+ entries={entries}
521
+ q={q}
522
+ onDeleteEntry={(entry) => setDeleteTarget({ filename: file.filename, entry })}
523
+ />
524
+ ))}
525
+ </div>
526
+ )}
527
+ </>
174
528
  )}
529
+
530
+ {/* Delete entry confirm */}
531
+ <Modal
532
+ open={deleteTarget !== null}
533
+ onClose={() => !deleting && setDeleteTarget(null)}
534
+ title={t("memory.delete_title")}
535
+ size="md"
536
+ >
537
+ {deleteTarget && (
538
+ <div className="space-y-4">
539
+ <div className="flex items-start gap-3">
540
+ <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-400" />
541
+ <div className="min-w-0">
542
+ <p className="text-sm text-gray-300">{t("memory.delete_confirm")}</p>
543
+ <p className="mt-2 max-h-32 overflow-y-auto whitespace-pre-wrap rounded border border-gray-700 bg-gray-800/60 px-3 py-2 text-xs text-gray-400">
544
+ {deleteTarget.entry.text.length > 300
545
+ ? deleteTarget.entry.text.slice(0, 300) + "…"
546
+ : deleteTarget.entry.text}
547
+ </p>
548
+ <p className="mt-2 text-xs text-red-400">{t("memory.delete_note")}</p>
549
+ </div>
550
+ </div>
551
+ <div className="flex justify-end gap-2">
552
+ <button
553
+ onClick={() => setDeleteTarget(null)}
554
+ disabled={deleting}
555
+ className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-300 hover:bg-gray-800"
556
+ >
557
+ {t("memory.cancel")}
558
+ </button>
559
+ <button
560
+ onClick={handleDelete}
561
+ disabled={deleting}
562
+ className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50"
563
+ >
564
+ {deleting ? t("memory.deleting") : t("memory.delete")}
565
+ </button>
566
+ </div>
567
+ </div>
568
+ )}
569
+ </Modal>
175
570
  </div>
176
571
  );
177
572
  }