返回 DeepSeek-Reasonix
MemoryPanel.tsx
根目录 / desktop / frontend / src / components / MemoryPanel.tsx
1 import { SettingsSelect } from "./SettingsSelect";
2 import { Activity, AlertTriangle, ArchiveRestore, Check, ChevronDown, ChevronRight, FileText, History, Pencil, Plus, RefreshCw, Search, Sparkles, Trash2 } from "lucide-react";
3 import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
4 import { app } from "../lib/bridge";
5 import { useT } from "../lib/i18n";
6 import type { MemoryArchive, MemoryFact, MemorySuggestion, MemorySuggestionsView, MemoryView, SkillSuggestion, TabMeta } from "../lib/types";
7 import { AnchoredPopover } from "./AnchoredPopover";
8 import { ResizableDrawer } from "./ResizableDrawer";
9 import { Tooltip } from "./Tooltip";
10 import { ModalCloseButton } from "./ModalCloseButton";
11
12 type LinkInfo = {
13 name: string;
14 exists: boolean;
15 };
16
17 function displayTitle(fact: MemoryFact): string {
18 return fact.title || fact.name.replaceAll("-", " ");
19 }
20
21 function memoryFactKey(fact: MemoryFact): string {
22 return fact.id || `${fact.scope}:${fact.name}`;
23 }
24
25 function formatMemoryTime(value?: string): string {
26 if (!value) return "";
27 const date = new Date(value);
28 if (Number.isNaN(date.getTime())) return value;
29 return date.toLocaleString();
30 }
31
32 function freshnessLabel(value: string, t: ReturnType<typeof useT>): string {
33 switch (value) {
34 case "fresh": return t("memory.freshness.fresh");
35 case "current": return t("memory.freshness.current");
36 case "stale": return t("memory.freshness.stale");
37 default: return value;
38 }
39 }
40
41 function memoryMatches(fact: MemoryFact, normalizedQuery: string, typeFilter: string): boolean {
42 if (typeFilter !== "all" && fact.type !== typeFilter) return false;
43 if (!normalizedQuery) return true;
44 return [displayTitle(fact), fact.name, fact.description, fact.type, fact.scope, fact.body]
45 .join(" ")
46 .toLowerCase()
47 .includes(normalizedQuery);
48 }
49
50 function archiveKey(fact: MemoryArchive): string {
51 return `${fact.path || fact.name}:${fact.archivedAt || ""}`;
52 }
53
54 function formatArchivedAt(value?: string): string {
55 if (!value) return "";
56 const date = new Date(value);
57 if (Number.isNaN(date.getTime())) return value;
58 return date.toLocaleString();
59 }
60
61 function ArchivedMemoryList({
62 archives,
63 totalArchives,
64 expanded,
65 setExpanded,
66 renderWithLinks,
67 t,
68 hideHeader = false,
69 busy = false,
70 onRestore,
71 }: {
72 archives: MemoryArchive[];
73 totalArchives: number;
74 expanded: string | null;
75 setExpanded: (key: string | null) => void;
76 renderWithLinks: (text: string) => ReactNode[];
77 t: ReturnType<typeof useT>;
78 hideHeader?: boolean;
79 busy?: boolean;
80 onRestore?: (archive: MemoryArchive) => Promise<void> | void;
81 }) {
82 if (totalArchives === 0) return null;
83 return (
84 <div className="mem-archive-block">
85 {!hideHeader && <div className="mem-section__row">
86 <div>
87 <div className="mem-section__title">{t("memory.archivedMemories")}</div>
88 <div className="mem-note">{t("memory.archivedHint")}</div>
89 </div>
90 <span className="mem-count">{totalArchives}</span>
91 </div>}
92 {archives.length === 0 ? (
93 <div className="mem-empty">{t("memory.noArchivedMatches")}</div>
94 ) : (
95 <div className="mem-facts mem-facts--archive">
96 {archives.map((f) => {
97 const key = archiveKey(f);
98 const isOpen = expanded === key;
99 return (
100 <article className="mem-fact mem-fact--archived" data-mem-type={f.type || "other"} key={key}>
101 <button
102 className="mem-fact__summary"
103 onClick={() => setExpanded(isOpen ? null : key)}
104 type="button"
105 >
106 {isOpen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
107 <span className="mem-fact__main">
108 <span className="mem-fact__title">{displayTitle(f)}</span>
109 <span className="mem-fact__meta">
110 <MemoryFactScope scope={f.scope} t={t} />
111 {f.type && <span className="mem-fact__type" data-mem-type={f.type}>{memoryTypeLabel(f.type, t)}</span>}
112 <span className="mem-fact__slug">{f.name}</span>
113 {f.archivedAt && (
114 <span className="mem-fact__archived">
115 {t("memory.archivedAt", { time: formatArchivedAt(f.archivedAt) })}
116 </span>
117 )}
118 </span>
119 <span className="mem-fact__desc">{f.description}</span>
120 </span>
121 </button>
122 {isOpen && (
123 <div className="mem-fact__detail">
124 {f.body ? (
125 <div className="mem-fact__body">{renderWithLinks(f.body)}</div>
126 ) : (
127 <div className="mem-empty">{t("memory.noBody")}</div>
128 )}
129 <div className="mem-archive__path">{f.path}</div>
130 {onRestore && (
131 <div className="mem-fact__actions">
132 <span className="mem-hint mem-hint--inline">{t("memory.restoreArchivedHint")}</span>
133 <button
134 className="btn btn--small"
135 type="button"
136 disabled={busy}
137 onClick={() => void onRestore(f)}
138 >
139 <ArchiveRestore size={13} />
140 {t("memory.restoreArchived")}
141 </button>
142 </div>
143 )}
144 </div>
145 )}
146 </article>
147 );
148 })}
149 </div>
150 )}
151 </div>
152 );
153 }
154
155 function uniqueLinks(body: string, names: Set<string>): LinkInfo[] {
156 const links: LinkInfo[] = [];
157 const seen = new Set<string>();
158 const re = /\[\[([^\]]+)\]\]/g;
159 let match: RegExpExecArray | null;
160 while ((match = re.exec(body)) !== null) {
161 const name = match[1].trim();
162 if (!name || seen.has(name)) continue;
163 seen.add(name);
164 links.push({ name, exists: names.has(name) });
165 }
166 return links;
167 }
168
169 function memoryScopeLabel(scope: string, t: ReturnType<typeof useT>): string {
170 switch (scope) {
171 case "project":
172 return t("memory.scope.project");
173 case "global":
174 return t("memory.scope.global");
175 case "user":
176 return t("memory.scope.user");
177 case "local":
178 return t("memory.scope.local");
179 case "ancestor":
180 return t("memory.scope.ancestor");
181 default:
182 return scope;
183 }
184 }
185
186 function MemoryFactScope({ scope, t }: { scope: string; t: ReturnType<typeof useT> }) {
187 if (!scope) return null;
188 return <span className="mem-fact__scope" data-mem-scope={scope}>{memoryScopeLabel(scope, t)}</span>;
189 }
190
191 function memoryTypeLabel(type: string, t: ReturnType<typeof useT>): string {
192 switch ((type || "").toLowerCase()) {
193 case "project":
194 return t("memory.type.project");
195 case "user":
196 return t("memory.type.user");
197 case "feedback":
198 return t("memory.type.feedback");
199 case "reference":
200 return t("memory.type.reference");
201 default:
202 return type || t("memory.type.other");
203 }
204 }
205
206 function memoryDocTitle(scope: string, t: ReturnType<typeof useT>): string {
207 switch (scope) {
208 case "project":
209 return t("memory.doc.projectTitle");
210 case "user":
211 return t("memory.doc.userTitle");
212 case "local":
213 return t("memory.doc.localTitle");
214 case "ancestor":
215 return t("memory.doc.ancestorTitle");
216 default:
217 return t("memory.doc.customTitle");
218 }
219 }
220
221 function memoryDocHint(scope: string, t: ReturnType<typeof useT>): string {
222 switch (scope) {
223 case "project":
224 return t("memory.doc.projectHint");
225 case "user":
226 return t("memory.doc.userHint");
227 case "local":
228 return t("memory.doc.localHint");
229 case "ancestor":
230 return t("memory.doc.ancestorHint");
231 default:
232 return t("memory.doc.customHint");
233 }
234 }
235
236 function errorMessage(err: unknown): string {
237 if (err instanceof Error) return err.message;
238 return String(err || "Unknown error");
239 }
240
241 function suggestionTotal(view: MemorySuggestionsView | null): number {
242 return (view?.memories?.length ?? 0) + (view?.skills?.length ?? 0);
243 }
244
245 function suggestionStamp(value?: string): string {
246 if (!value) return "";
247 const date = new Date(value);
248 if (Number.isNaN(date.getTime())) return value;
249 return date.toLocaleString();
250 }
251
252 // MemoryPanel is the desktop memory manager: a right-side drawer over the loaded
253 // REASONIX.md hierarchy and saved auto-memories. Unlike Claude Code's /memory
254 // (which shells out to $EDITOR) it edits docs in place, and unlike Codex (no UI
255 // at all) it shows the saved facts. Docs are editable; facts are read-only
256 // (the model owns them via the `remember` tool). Quick-add mirrors the "#"
257 // shortcut with an explicit scope selector.
258 export function MemoryPanel({
259 view,
260 onClose,
261 onRemember,
262 onForget,
263 onSaveDoc,
264 }: {
265 view: MemoryView | null;
266 onClose: () => void;
267 onRemember: (scope: string, note: string) => Promise<void> | void;
268 onForget: (name: string) => Promise<void> | void;
269 onSaveDoc: (path: string, body: string) => Promise<void> | void;
270 }) {
271 const t = useT();
272 const [note, setNote] = useState("");
273 const [scope, setScope] = useState("");
274 const [editingPath, setEditingPath] = useState<string | null>(null);
275 const [draft, setDraft] = useState("");
276 const [busy, setBusy] = useState(false);
277
278 const [highlight, setHighlight] = useState<string | null>(null);
279 const [query, setQuery] = useState("");
280 const [typeFilter, setTypeFilter] = useState("all");
281 const [expanded, setExpanded] = useState<string | null>(null);
282 const [expandedArchive, setExpandedArchive] = useState<string | null>(null);
283 const [confirmForget, setConfirmForget] = useState<string | null>(null);
284 const [error, setError] = useState<string | null>(null);
285 const factRefs = useRef<Record<string, HTMLElement | null>>({});
286
287 // Filter input — a single substring search across docs and facts. The
288 // substring is case-insensitive and matches anywhere in the body or the
289 // path; an empty string shows everything. The filter is purely frontend
290 // (no kernel round-trip) so it's instant and reversible.
291 const [filter, setFilter] = useState("");
292
293 const facts = view?.facts ?? [];
294 const archives = view?.archives ?? [];
295 const factNames = useMemo(() => new Set(facts.map((f) => f.name)), [facts]);
296 const factTypes = useMemo(
297 () => Array.from(new Set([...facts, ...archives].map((f) => f.type).filter(Boolean))).sort(),
298 [facts, archives],
299 );
300 const normalizedQuery = query.trim().toLowerCase();
301 const normalizedFilter = filter.trim().toLowerCase();
302 const filteredFacts = useMemo(
303 () =>
304 facts.filter((f) => {
305 if (normalizedFilter) {
306 const hay = [f.name, f.description, f.body].join(" ").toLowerCase();
307 if (!hay.includes(normalizedFilter)) return false;
308 }
309 return memoryMatches(f, normalizedQuery, typeFilter);
310 }),
311 [facts, normalizedQuery, normalizedFilter, typeFilter],
312 );
313 const filteredArchives = useMemo(
314 () =>
315 archives.filter((f) => {
316 if (normalizedFilter) {
317 const hay = [f.name, f.description, f.body, f.path].join(" ").toLowerCase();
318 if (!hay.includes(normalizedFilter)) return false;
319 }
320 return memoryMatches(f, normalizedQuery, typeFilter);
321 }),
322 [archives, normalizedQuery, normalizedFilter, typeFilter],
323 );
324
325 const scrollToFact = (key: string) => {
326 const el = factRefs.current[key];
327 if (!el) return;
328 el.scrollIntoView({ block: "center", behavior: "auto" });
329 setHighlight(key);
330 window.setTimeout(() => setHighlight((h) => (h === key ? null : h)), 1200);
331 };
332
333 // Clear active filters when the target is hidden, else the [[link]] is a silent no-op.
334 const jumpTo = (name: string) => {
335 if (!factNames.has(name)) return;
336 const target = facts.find((f) => f.name === name && f.scope === "project") ?? facts.find((f) => f.name === name);
337 if (!target) return;
338 const key = memoryFactKey(target);
339 const visible = filteredFacts.some((f) => memoryFactKey(f) === key);
340 setExpanded(key);
341 setConfirmForget(null);
342 if (!visible) {
343 setQuery("");
344 setTypeFilter("all");
345 window.setTimeout(() => scrollToFact(key), 0);
346 return;
347 }
348 scrollToFact(key);
349 };
350
351 // renderWithLinks turns [[name]] tokens into in-panel jumps; a token with no
352 // matching saved memory renders as a flagged dead link.
353 const renderWithLinks = (text: string): ReactNode[] => {
354 const out: ReactNode[] = [];
355 const re = /\[\[([^\]]+)\]\]/g;
356 let last = 0;
357 let k = 0;
358 let m: RegExpExecArray | null;
359 while ((m = re.exec(text)) !== null) {
360 if (m.index > last) out.push(text.slice(last, m.index));
361 const target = m[1].trim();
362 out.push(
363 factNames.has(target) ? (
364 <button key={k++} type="button" className="mem-link" onClick={() => jumpTo(target)}>
365 {target}
366 </button>
367 ) : (
368 <Tooltip key={k++} label={t("memory.deadLink", { name: target })}>
369 <span className="mem-link mem-link--dead">{target}</span>
370 </Tooltip>
371 ),
372 );
373 last = re.lastIndex;
374 }
375 if (last < text.length) out.push(text.slice(last));
376 return out;
377 };
378
379 const forgetFact = async (ref: string) => {
380 if (busy) return;
381 setBusy(true);
382 setError(null);
383 try {
384 await onForget(ref);
385 if (expanded === ref) setExpanded(null);
386 setConfirmForget(null);
387 } catch (err) {
388 setError(errorMessage(err));
389 } finally {
390 setBusy(false);
391 }
392 };
393
394 const filteredDocs = useMemo(() => {
395 if (!view) return [];
396 const q = filter.trim().toLowerCase();
397 if (!q) return view.docs;
398 return view.docs.filter((d) => d.body.toLowerCase().includes(q) || d.path.toLowerCase().includes(q));
399 }, [view, filter]);
400
401 const scopes = view?.scopes ?? [];
402 // Default the scope selector to "project" when present, else the first option.
403 const activeScope =
404 scope || scopes.find((s) => s.scope === "project")?.scope || scopes[0]?.scope || "project";
405
406 const submitNote = async () => {
407 const trimmed = note.trim();
408 if (!trimmed || busy) return;
409 setBusy(true);
410 setError(null);
411 try {
412 await onRemember(activeScope, trimmed);
413 setNote("");
414 } catch (err) {
415 setError(errorMessage(err));
416 } finally {
417 setBusy(false);
418 }
419 };
420
421 const startEdit = (path: string, body: string) => {
422 setEditingPath(path);
423 setDraft(body);
424 };
425
426 const saveEdit = async () => {
427 if (editingPath === null || busy) return;
428 setBusy(true);
429 setError(null);
430 try {
431 await onSaveDoc(editingPath, draft);
432 setEditingPath(null);
433 } catch (err) {
434 setError(errorMessage(err));
435 } finally {
436 setBusy(false);
437 }
438 };
439
440 return (
441 <ResizableDrawer onClose={onClose}>
442 <header className="drawer__head">
443 <div>
444 <div className="drawer__title">{t("memory.title")}</div>
445 {view?.available && (
446 <div className="drawer__summary">
447 {t("memory.summary", { facts: facts.length, archives: archives.length, docs: view.docs.length })}
448 </div>
449 )}
450 </div>
451 <ModalCloseButton label={t("common.close")} onClick={onClose} />
452 </header>
453
454 {!view?.available ? (
455 <div className="empty">{t("memory.unavailable")}</div>
456 ) : (
457 <div className="drawer__body">
458 {/* Saved auto-memories — the model owns these via remember/forget;
459 the panel can delete one and follow [[name]] cross-links. */}
460 <section className="mem-section">
461 <div className="mem-section__row">
462 <div>
463 <div className="mem-section__title">{t("memory.savedMemories")}</div>
464 <div className="mem-note">{t("memory.fallibleNote")}</div>
465 </div>
466 <span className="mem-count">{facts.length}</span>
467 </div>
468 <div className="mem-toolbar">
469 <label className="mem-search">
470 <Search size={14} />
471 <input
472 value={query}
473 onChange={(e) => setQuery(e.target.value)}
474 placeholder={t("memory.searchPlaceholder")}
475 />
476 </label>
477 <div className="mem-filter" role="tablist" aria-label={t("memory.typeFilter")}>
478 <button
479 className={`mem-filter__item${typeFilter === "all" ? " mem-filter__item--on" : ""}`}
480 onClick={() => setTypeFilter("all")}
481 type="button"
482 >
483 {t("memory.allTypes")}
484 </button>
485 {factTypes.map((type) => (
486 <button
487 className={`mem-filter__item${typeFilter === type ? " mem-filter__item--on" : ""}`}
488 onClick={() => setTypeFilter(type)}
489 type="button"
490 key={type}
491 >
492 {memoryTypeLabel(type, t)}
493 </button>
494 ))}
495 </div>
496 </div>
497 {error && <div className="mem-error" role="alert">{error}</div>}
498 {facts.length === 0 ? (
499 <div className="mem-empty">{t("memory.noFacts")}</div>
500 ) : filteredFacts.length === 0 ? (
501 <div className="mem-empty">
502 {t("memory.noMatches")}
503 <button
504 className="mem-empty__action"
505 onClick={() => {
506 setQuery("");
507 setTypeFilter("all");
508 }}
509 type="button"
510 >
511 {t("memory.clearFilters")}
512 </button>
513 </div>
514 ) : (
515 <div className="mem-facts">
516 {filteredFacts.map((f) => {
517 const key = memoryFactKey(f);
518 const isOpen = expanded === key;
519 const links = uniqueLinks(f.body, factNames);
520 const missing = links.filter((link) => !link.exists);
521 return (
522 <article
523 className={`mem-fact${highlight === key ? " mem-fact--hl" : ""}`}
524 data-mem-type={f.type || "other"}
525 key={key}
526 ref={(el) => {
527 factRefs.current[key] = el;
528 }}
529 >
530 <button
531 className="mem-fact__summary"
532 onClick={() => {
533 setExpanded(isOpen ? null : key);
534 setConfirmForget(null);
535 }}
536 type="button"
537 >
538 {isOpen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
539 <span className="mem-fact__main">
540 <span className="mem-fact__title">{displayTitle(f)}</span>
541 <span className="mem-fact__meta">
542 <MemoryFactScope scope={f.scope} t={t} />
543 {f.type && <span className="mem-fact__type" data-mem-type={f.type}>{memoryTypeLabel(f.type, t)}</span>}
544 <span className="mem-fact__slug">{f.name}</span>
545 </span>
546 <span className="mem-fact__desc">{f.description}</span>
547 </span>
548 </button>
549 {links.length > 0 && (
550 <div className="mem-fact__links" aria-label={t("memory.links")}>
551 {links.map((link) =>
552 link.exists ? (
553 <button
554 className="mem-link-chip"
555 key={link.name}
556 onClick={() => jumpTo(link.name)}
557 type="button"
558 >
559 [[{link.name}]]
560 </button>
561 ) : (
562 <Tooltip key={link.name} label={t("memory.deadLink", { name: link.name })}>
563 <span className="mem-link-chip mem-link-chip--dead">[[{link.name}]]</span>
564 </Tooltip>
565 ),
566 )}
567 </div>
568 )}
569 {isOpen && (
570 <div className="mem-fact__detail">
571 {f.body ? (
572 <div className="mem-fact__body">{renderWithLinks(f.body)}</div>
573 ) : (
574 <div className="mem-empty">{t("memory.noBody")}</div>
575 )}
576 {missing.length > 0 && (
577 <div className="mem-deadline">
578 {t("memory.missingLinks", { n: missing.length })}
579 </div>
580 )}
581 <div className="mem-fact__actions">
582 <span className="mem-hint mem-hint--inline">
583 {t("memory.appliesNow")}
584 </span>
585 {confirmForget === key ? (
586 <div className="mem-confirm">
587 <button
588 className="btn btn--small"
589 onClick={() => setConfirmForget(null)}
590 disabled={busy}
591 type="button"
592 >
593 {t("common.cancel")}
594 </button>
595 <button
596 className="btn btn--small mem-danger"
597 onClick={() => void forgetFact(f.id || f.name)}
598 disabled={busy}
599 type="button"
600 >
601 {t("memory.confirmForget")}
602 </button>
603 </div>
604 ) : (
605 <button
606 className="btn btn--small mem-fact__forget"
607 onClick={() => setConfirmForget(key)}
608 disabled={busy}
609 type="button"
610 >
611 <Trash2 size={13} />
612 {t("memory.forget")}
613 </button>
614 )}
615 </div>
616 </div>
617 )}
618 </article>
619 );
620 })}
621 </div>
622 )}
623 {(view.storeDir || view.storeGlobalDir) && (
624 <div className="mem-hint">{t("memory.storedUnder", { dir: [view.storeDir, view.storeGlobalDir].filter(Boolean).join(" + ") })}</div>
625 )}
626 </section>
627
628 {archives.length > 0 && <section className="mem-section">
629 <ArchivedMemoryList
630 archives={filteredArchives}
631 totalArchives={archives.length}
632 expanded={expandedArchive}
633 setExpanded={setExpandedArchive}
634 renderWithLinks={renderWithLinks}
635 t={t}
636 />
637 </section>}
638
639 {/* Quick-add: scope selector + note, mirroring the "#" shortcut. */}
640 <section className="mem-section">
641 <div className="mem-section__title">{t("memory.quickAdd")}</div>
642 <div className="mem-add">
643 <Tooltip label={t("memory.whereToSave")}>
644 <SettingsSelect
645 className="mem-select"
646 value={activeScope}
647 onValueChange={(value) => setScope(value)}
648 >
649 {scopes.map((s) => (
650 <option key={s.scope} value={s.scope}>
651 {s.scope}
652 </option>
653 ))}
654 </SettingsSelect>
655 </Tooltip>
656 <input
657 className="mem-input"
658 placeholder={t("memory.notePlaceholder")}
659 value={note}
660 onChange={(e) => setNote(e.target.value)}
661 onKeyDown={(e) => {
662 if (e.key === "Enter") void submitNote();
663 }}
664 />
665 <button
666 className="btn btn--primary btn--small"
667 onClick={() => void submitNote()}
668 disabled={busy || !note.trim()}
669 >
670 {t("memory.remember")}
671 </button>
672 </div>
673 <div className="mem-hint">
674 {scopes.find((s) => s.scope === activeScope)?.path}
675 </div>
676 </section>
677
678 {/* Doc files — editable in place. */}
679 <section className="mem-section">
680 <div className="mem-section__title">{t("memory.instructionFiles")}</div>
681 <input
682 className="mem-input mem-filter"
683 placeholder={t("memory.filterPlaceholder")}
684 value={filter}
685 onChange={(e) => setFilter(e.target.value)}
686 spellCheck={false}
687 aria-label={t("memory.filterPlaceholder")}
688 />
689 {filteredDocs.length === 0 && (
690 <div className="mem-empty">{filter ? t("memory.noFilterMatch") : t("memory.noDocs")}</div>
691 )}
692 {filteredDocs.map((d) => {
693 const editing = editingPath === d.path;
694 return (
695 <div className="mem-doc" data-doc-scope={d.scope || "other"} key={d.path}>
696 <div className="mem-doc__head">
697 <span className="mem-doc__icon"><FileText size={15} /></span>
698 <span className="mem-doc__info">
699 <span className="mem-doc__name">{memoryDocTitle(d.scope, t)}</span>
700 <span className="mem-doc__path">{d.path}</span>
701 </span>
702 <span className={`mem-doc__tag badge--${d.scope}`}>{memoryScopeLabel(d.scope, t)}</span>
703 {!editing && (
704 <button
705 className="btn btn--small"
706 onClick={() => startEdit(d.path, d.body)}
707 >
708 {t("common.edit")}
709 </button>
710 )}
711 </div>
712 {editing ? (
713 <div className="mem-doc__edit">
714 <textarea
715 className="mem-textarea"
716 value={draft}
717 onChange={(e) => setDraft(e.target.value)}
718 spellCheck={false}
719 />
720 <div className="mem-doc__actions">
721 <button
722 className="btn btn--small"
723 onClick={() => setEditingPath(null)}
724 disabled={busy}
725 >
726 {t("common.cancel")}
727 </button>
728 <button
729 className="btn btn--primary btn--small"
730 onClick={() => void saveEdit()}
731 disabled={busy}
732 >
733 {t("common.save")}
734 </button>
735 </div>
736 </div>
737 ) : (
738 <pre className="mem-doc__body">{d.body}</pre>
739 )}
740 </div>
741 );
742 })}
743 </section>
744
745
746
747 {/* Saved auto-memories — read-only; the model owns these. */}
748 <section className="mem-section">
749 <div className="mem-section__title">{t("memory.savedMemories")}</div>
750 {filteredFacts.length === 0 ? (
751 <div className="mem-empty">{filter ? t("memory.noFilterMatch") : t("memory.noFacts")}</div>
752 ) : (
753 filteredFacts.map((f) => (
754 <div className="mem-fact" key={memoryFactKey(f)} title={f.body}>
755 <span className={`badge badge--${f.scope}`}>{memoryScopeLabel(f.scope, t)}</span>
756 <span className={`badge badge--${f.type}`}>{memoryTypeLabel(f.type, t)}</span>
757 <div className="mem-fact__text">
758 <div className="mem-fact__name">{f.name}</div>
759 <div className="mem-fact__desc">{f.description}</div>
760 </div>
761 </div>
762 ))
763 )}
764 {(view.storeDir || view.storeGlobalDir) && (
765 <div className="mem-hint" title={[view.storeDir, view.storeGlobalDir].filter(Boolean).join(" + ")}>
766 {t("memory.storedUnder", { dir: [view.storeDir, view.storeGlobalDir].filter(Boolean).join(" + ") })}
767 </div>
768 )}
769 </section>
770 </div>
771 )}
772 </ResizableDrawer>
773 );
774 }
775
776 // MemorySettingsPage is a self-contained memory management page embedded inside
777 // the settings centre. It loads its own data and handles all memory operations.
778 export function MemorySettingsPage() {
779 const t = useT();
780 const [view, setView] = useState<MemoryView | null>(null);
781 const [tabs, setTabs] = useState<TabMeta[]>([]);
782 const [selectedTabId, setSelectedTabId] = useState<string | null>(null);
783 const [note, setNote] = useState("");
784 const [scope, setScope] = useState("");
785 const [editingPath, setEditingPath] = useState<string | null>(null);
786 const [draft, setDraft] = useState("");
787 const [busy, setBusy] = useState(false);
788 const [highlight, setHighlight] = useState<string | null>(null);
789 const [query, setQuery] = useState("");
790 const [typeFilter, setTypeFilter] = useState("all");
791 const [expanded, setExpanded] = useState<string | null>(null);
792 const [expandedArchive, setExpandedArchive] = useState<string | null>(null);
793 const [expandedDoc, setExpandedDoc] = useState<string | null>(null);
794 const [confirmForget, setConfirmForget] = useState<string | null>(null);
795 const [error, setError] = useState<string | null>(null);
796 const [tab, setTab] = useState<"saved" | "archived" | "docs" | "activity" | "suggestions">("saved");
797 const [showAdd, setShowAdd] = useState(false);
798 const [showStorage, setShowStorage] = useState(false);
799 const [suggestions, setSuggestions] = useState<MemorySuggestionsView | null>(null);
800 const [suggestionBusy, setSuggestionBusy] = useState(false);
801 const [expandedSuggestion, setExpandedSuggestion] = useState<string | null>(null);
802 const [acceptedSuggestions, setAcceptedSuggestions] = useState<Record<string, string>>({});
803 const [revisions, setRevisions] = useState<Record<string, MemoryFact[]>>({});
804 const [revisionBusy, setRevisionBusy] = useState<string | null>(null);
805 const factRefs = useRef<Record<string, HTMLElement | null>>({});
806
807 useEffect(() => {
808 app.ListTabs().then((tabList) => {
809 setTabs(tabList);
810 if (!selectedTabId) {
811 const active = tabList.find((tb) => tb.active);
812 if (active) setSelectedTabId(active.id);
813 }
814 }).catch(() => {});
815 }, []);
816
817 // Deduplicate tabs by workspace: multiple conversations in the same project
818 // should appear as a single entry in the memory workspace selector.
819 const uniqueWorkspaceTabs = useMemo(() => {
820 const byWorkspace = new Map<string, TabMeta>();
821 for (const tb of tabs) {
822 const key = tb.workspaceRoot || `${tb.scope}:global`;
823 if (!byWorkspace.has(key)) byWorkspace.set(key, tb);
824 }
825 return [...byWorkspace.values()];
826 }, [tabs]);
827
828 // Ensure selectedTabId always points to a valid entry in uniqueWorkspaceTabs.
829 // On initial load the active tab is picked; if dedup removed it, fall back to first.
830 const effectiveTabId = useMemo(() => {
831 if (uniqueWorkspaceTabs.some((tb) => tb.id === selectedTabId)) return selectedTabId;
832 return uniqueWorkspaceTabs[0]?.id ?? null;
833 }, [selectedTabId, uniqueWorkspaceTabs]);
834
835 // Sync effectiveTabId back to selectedTabId when it changes
836 useEffect(() => {
837 if (effectiveTabId && effectiveTabId !== selectedTabId) {
838 setSelectedTabId(effectiveTabId);
839 }
840 }, [effectiveTabId]);
841
842 const reload = useCallback(async () => {
843 const tabId = effectiveTabId;
844 // Clear view immediately so stale data from the previous workspace
845 // doesn't persist while the new workspace loads.
846 setView((prev) => {
847 if (prev && tabId) return {
848 ...prev,
849 facts: [], archives: [], docs: [], conflicts: [], instructionDiagnostics: [],
850 lastRecall: { query: "", hits: [], omitted: 0, charBudget: 0, usedChars: 0 },
851 };
852 return prev;
853 });
854 setView(tabId ? await app.MemoryForTab(tabId).catch(() => null) : await app.Memory().catch(() => null));
855 }, [effectiveTabId]);
856
857 useEffect(() => { void reload(); }, [reload]);
858 useEffect(() => {
859 setRevisions({});
860 setExpanded(null);
861 setExpandedArchive(null);
862 setExpandedDoc(null);
863 setSuggestions(null);
864 }, [effectiveTabId]);
865
866 // Workspace selector: custom styled dropdown matching settings-subtab height
867 const wsTriggerRef = useRef<HTMLButtonElement>(null);
868 const [wsOpen, setWsOpen] = useState(false);
869 const selectedWs = uniqueWorkspaceTabs.find((tb) => tb.id === effectiveTabId);
870
871 const wsSelector = uniqueWorkspaceTabs.length > 0 ? (
872 <div className="mem-ws-select">
873 {uniqueWorkspaceTabs.length > 1 ? (
874 <>
875 <button
876 ref={wsTriggerRef}
877 type="button"
878 className="mem-ws-select__trigger"
879 onClick={() => setWsOpen((v) => !v)}
880 >
881 <span className="mem-ws-select__label">{selectedWs?.workspaceName || selectedWs?.label || ""}</span>
882 <ChevronDown size={13} className={"mem-ws-select__chev" + (wsOpen ? " mem-ws-select__chev--open" : "")} />
883 </button>
884 <AnchoredPopover
885 open={wsOpen}
886 anchorRef={wsTriggerRef}
887 onClose={() => setWsOpen(false)}
888 className="mem-ws-select__menu"
889 placement="bottom"
890 >
891 <div className="mem-ws-select__list" role="listbox">
892 {uniqueWorkspaceTabs.map((tb) => (
893 <button
894 key={tb.id}
895 type="button"
896 role="option"
897 aria-selected={tb.id === effectiveTabId}
898 className={"mem-ws-select__option" + (tb.id === effectiveTabId ? " mem-ws-select__option--selected" : "")}
899 onClick={() => { setSelectedTabId(tb.id); setWsOpen(false); }}
900 >
901 <span>{tb.workspaceName || tb.label || tb.scope || tb.id}</span>
902 {tb.id === effectiveTabId && <Check size={13} />}
903 </button>
904 ))}
905 </div>
906 </AnchoredPopover>
907 </>
908 ) : (
909 <span className="mem-ws-select__label mem-ws-select__label--single">{selectedWs?.workspaceName || selectedWs?.label || ""}</span>
910 )}
911 </div>
912 ) : null;
913
914 const refreshSuggestions = useCallback(async () => {
915 if (suggestionBusy) return;
916 setSuggestionBusy(true);
917 setError(null);
918 try {
919 const next = effectiveTabId
920 ? await app.MemorySuggestionsForTab(effectiveTabId)
921 : await app.MemorySuggestions();
922 setSuggestions({
923 memories: next.memories ?? [],
924 skills: next.skills ?? [],
925 generatedAt: next.generatedAt || "",
926 available: !!next.available,
927 source: next.source || "",
928 });
929 setAcceptedSuggestions({});
930 } catch (err) {
931 setError(errorMessage(err));
932 } finally {
933 setSuggestionBusy(false);
934 }
935 }, [effectiveTabId, suggestionBusy]);
936
937 useEffect(() => {
938 if (tab !== "suggestions" || suggestions || suggestionBusy) return;
939 void refreshSuggestions();
940 }, [refreshSuggestions, suggestionBusy, suggestions, tab]);
941
942 const facts = view?.facts ?? [];
943 const archives = view?.archives ?? [];
944 const factNames = useMemo(() => new Set(facts.map((f) => f.name)), [facts]);
945 const factTypes = useMemo(
946 () => Array.from(new Set([...facts, ...archives].map((f) => f.type).filter(Boolean))).sort(),
947 [facts, archives],
948 );
949 const normalizedQuery = query.trim().toLowerCase();
950 const filteredFacts = useMemo(
951 () =>
952 facts.filter((f) => memoryMatches(f, normalizedQuery, typeFilter)),
953 [facts, normalizedQuery, typeFilter],
954 );
955 const filteredArchives = useMemo(
956 () =>
957 archives.filter((f) => {
958 if (typeFilter !== "all" && f.type !== typeFilter) return false;
959 if (!normalizedQuery) return true;
960 return memoryMatches(f, normalizedQuery, "all") || [f.path, f.archivedAt].join(" ").toLowerCase().includes(normalizedQuery);
961 }),
962 [archives, normalizedQuery, typeFilter],
963 );
964
965 const scrollToFact = useCallback((key: string) => {
966 const el = factRefs.current[key];
967 if (!el) return;
968 el.scrollIntoView({ block: "center", behavior: "auto" });
969 setHighlight(key);
970 window.setTimeout(() => setHighlight((h) => (h === key ? null : h)), 1200);
971 }, []);
972
973 const jumpTo = useCallback((name: string) => {
974 if (!factNames.has(name)) return;
975 const target = facts.find((f) => f.name === name && f.scope === "project") ?? facts.find((f) => f.name === name);
976 if (!target) return;
977 const key = memoryFactKey(target);
978 const visible = filteredFacts.some((f) => memoryFactKey(f) === key);
979 setExpanded(key);
980 setConfirmForget(null);
981 if (!visible) {
982 setQuery("");
983 setTypeFilter("all");
984 window.setTimeout(() => scrollToFact(key), 0);
985 return;
986 }
987 scrollToFact(key);
988 }, [factNames, facts, filteredFacts, scrollToFact]);
989
990 const renderWithLinks = useCallback((text: string): ReactNode[] => {
991 const out: ReactNode[] = [];
992 const re = /\[\[([^\]]+)\]\]/g;
993 let last = 0;
994 let k = 0;
995 let m: RegExpExecArray | null;
996 while ((m = re.exec(text)) !== null) {
997 if (m.index > last) out.push(text.slice(last, m.index));
998 const target = m[1].trim();
999 out.push(
1000 factNames.has(target) ? (
1001 <button key={k++} type="button" className="mem-link" onClick={() => jumpTo(target)}>
1002 {target}
1003 </button>
1004 ) : (
1005 <Tooltip key={k++} label={t("memory.deadLink", { name: target })}>
1006 <span className="mem-link mem-link--dead">{target}</span>
1007 </Tooltip>
1008 ),
1009 );
1010 last = re.lastIndex;
1011 }
1012 if (last < text.length) out.push(text.slice(last));
1013 return out;
1014 }, [factNames, jumpTo, t]);
1015
1016 const forgetFact = useCallback(async (ref: string, key: string) => {
1017 if (busy) return;
1018 setBusy(true);
1019 setError(null);
1020 try {
1021 if (effectiveTabId) await app.ForgetForTab(effectiveTabId, ref);
1022 else await app.Forget(ref);
1023 await reload();
1024 if (expanded === key) setExpanded(null);
1025 setConfirmForget(null);
1026 } catch (err) {
1027 setError(errorMessage(err));
1028 } finally {
1029 setBusy(false);
1030 }
1031 }, [busy, expanded, reload, effectiveTabId]);
1032
1033 const loadRevisions = useCallback(async (fact: MemoryFact) => {
1034 const ref = fact.id || fact.name;
1035 const key = memoryFactKey(fact);
1036 if (!ref || revisions[key] || revisionBusy === key) return;
1037 setRevisionBusy(key);
1038 try {
1039 const items = effectiveTabId
1040 ? await app.MemoryRevisionsForTab(effectiveTabId, ref)
1041 : await app.MemoryRevisions(ref);
1042 setRevisions((prev) => ({ ...prev, [key]: items ?? [] }));
1043 } catch (err) {
1044 setError(errorMessage(err));
1045 } finally {
1046 setRevisionBusy(null);
1047 }
1048 }, [effectiveTabId, revisionBusy, revisions]);
1049
1050 const restoreRevision = useCallback(async (fact: MemoryFact, revision: number) => {
1051 const ref = fact.id || fact.name;
1052 const key = memoryFactKey(fact);
1053 if (!ref || busy) return;
1054 setBusy(true);
1055 setError(null);
1056 try {
1057 if (effectiveTabId) await app.RestoreMemoryRevisionForTab(effectiveTabId, ref, revision);
1058 else await app.RestoreMemoryRevision(ref, revision);
1059 setRevisions((prev) => {
1060 const next = { ...prev };
1061 delete next[key];
1062 return next;
1063 });
1064 await reload();
1065 } catch (err) {
1066 setError(errorMessage(err));
1067 } finally {
1068 setBusy(false);
1069 }
1070 }, [busy, effectiveTabId, reload]);
1071
1072 const restoreArchive = useCallback(async (archive: MemoryArchive) => {
1073 if (busy) return;
1074 setBusy(true);
1075 setError(null);
1076 try {
1077 const restored = effectiveTabId
1078 ? await app.RestoreArchivedMemoryForTab(effectiveTabId, archive.path)
1079 : await app.RestoreArchivedMemory(archive.path);
1080 await reload();
1081 setExpandedArchive(null);
1082 setExpanded(restored.name || archive.name);
1083 setHighlight(restored.name || archive.name);
1084 setTab("saved");
1085 } catch (err) {
1086 setError(errorMessage(err));
1087 } finally {
1088 setBusy(false);
1089 }
1090 }, [busy, effectiveTabId, reload]);
1091
1092 const scopes = view?.scopes ?? [];
1093 const activeScope =
1094 scope || scopes.find((s) => s.scope === "project")?.scope || scopes[0]?.scope || "project";
1095
1096 const submitNote = useCallback(async () => {
1097 const trimmed = note.trim();
1098 if (!trimmed || busy) return;
1099 setBusy(true);
1100 setError(null);
1101 try {
1102 if (effectiveTabId) await app.RememberForTab(effectiveTabId, activeScope, trimmed);
1103 else await app.Remember(activeScope, trimmed);
1104 await reload();
1105 setNote("");
1106 setShowAdd(false);
1107 } catch (err) {
1108 setError(errorMessage(err));
1109 } finally {
1110 setBusy(false);
1111 }
1112 }, [note, busy, activeScope, reload, effectiveTabId]);
1113
1114 const startEdit = useCallback((path: string, body: string) => {
1115 setEditingPath(path);
1116 setDraft(body);
1117 }, []);
1118
1119 const saveEdit = useCallback(async () => {
1120 if (editingPath === null || busy) return;
1121 setBusy(true);
1122 setError(null);
1123 try {
1124 if (effectiveTabId) await app.SaveDocForTab(effectiveTabId, editingPath, draft);
1125 else await app.SaveDoc(editingPath, draft);
1126 await reload();
1127 setEditingPath(null);
1128 } catch (err) {
1129 setError(errorMessage(err));
1130 } finally {
1131 setBusy(false);
1132 }
1133 }, [editingPath, busy, draft, reload, effectiveTabId]);
1134
1135 const acceptMemorySuggestion = useCallback(async (candidate: MemorySuggestion) => {
1136 if (busy) return;
1137 setBusy(true);
1138 setError(null);
1139 try {
1140 const path = effectiveTabId
1141 ? await app.AcceptMemorySuggestionForTab(effectiveTabId, candidate)
1142 : await app.AcceptMemorySuggestion(candidate);
1143 setAcceptedSuggestions((prev) => ({ ...prev, [candidate.id]: path || candidate.name }));
1144 await reload();
1145 } catch (err) {
1146 setError(errorMessage(err));
1147 } finally {
1148 setBusy(false);
1149 }
1150 }, [busy, reload, effectiveTabId]);
1151
1152 const acceptSkillSuggestion = useCallback(async (candidate: SkillSuggestion) => {
1153 if (busy) return;
1154 setBusy(true);
1155 setError(null);
1156 try {
1157 const path = effectiveTabId
1158 ? await app.AcceptSkillSuggestionForTab(effectiveTabId, candidate)
1159 : await app.AcceptSkillSuggestion(candidate);
1160 setAcceptedSuggestions((prev) => ({ ...prev, [candidate.id]: path || candidate.name }));
1161 } catch (err) {
1162 setError(errorMessage(err));
1163 } finally {
1164 setBusy(false);
1165 }
1166 }, [busy, effectiveTabId]);
1167
1168 if (!view?.available) {
1169 return (
1170 <>
1171 {wsSelector}
1172 <div className="empty">{t("memory.unavailable")}</div>
1173 </>
1174 );
1175 }
1176
1177 const hasSavedFilters = facts.length > 0;
1178 const hasArchivedFilters = archives.length > 0;
1179
1180 return (
1181 <>
1182 <div className="memory-overview" aria-label={t("memory.title")}>
1183 <div className="memory-overview__copy">
1184 <span>{t("memory.summarySettings", { facts: facts.length, archives: archives.length, docs: view.docs.length })}</span>
1185 </div>
1186 {view.storeDir && (
1187 <button
1188 className="memory-storage-toggle"
1189 type="button"
1190 onClick={() => setShowStorage((v) => !v)}
1191 >
1192 {showStorage ? t("memory.hideStorage") : t("memory.showStorage")}
1193 </button>
1194 )}
1195 </div>
1196 {showStorage && view.storeDir && (
1197 <div className="memory-storage-path">
1198 <span>{t("memory.storagePathLabel")}</span>
1199 <code>{view.storeDir}</code>
1200 </div>
1201 )}
1202 <div className="memory-tabs-row settings-toolbar" role="tablist" aria-label={t("settings.tab.memory")}>
1203 <div className="settings-subtabs memory-tabs-row__primary" role="presentation">
1204 <button
1205 className={"settings-subtab" + (tab === "saved" ? " settings-subtab--active" : "")}
1206 role="tab"
1207 aria-selected={tab === "saved"}
1208 type="button"
1209 onClick={() => setTab("saved")}
1210 >
1211 <span>{t("memory.savedMemories")}</span>
1212 </button>
1213 <button
1214 className={"settings-subtab" + (tab === "archived" ? " settings-subtab--active" : "")}
1215 role="tab"
1216 aria-selected={tab === "archived"}
1217 type="button"
1218 onClick={() => setTab("archived")}
1219 >
1220 <span>{t("memory.archivedMemories")}</span>
1221 </button>
1222 <button
1223 className={"settings-subtab" + (tab === "docs" ? " settings-subtab--active" : "")}
1224 role="tab"
1225 aria-selected={tab === "docs"}
1226 type="button"
1227 onClick={() => setTab("docs")}
1228 >
1229 <span>{t("memory.instructionFiles")}</span>
1230 </button>
1231 <button
1232 className={"settings-subtab" + (tab === "activity" ? " settings-subtab--active" : "")}
1233 role="tab"
1234 aria-selected={tab === "activity"}
1235 type="button"
1236 onClick={() => setTab("activity")}
1237 >
1238 <span>{t("memory.activity")}</span>
1239 </button>
1240 </div>
1241 <div className="memory-tabs-row__spacer" />
1242 <div className="memory-tabs-row__tail" role="presentation">
1243 {wsSelector}
1244 <button
1245 className={"memory-suggestion-tab" + (tab === "suggestions" ? " memory-suggestion-tab--active" : "")}
1246 role="tab"
1247 aria-selected={tab === "suggestions"}
1248 type="button"
1249 onClick={() => setTab("suggestions")}
1250 >
1251 <Sparkles size={14} aria-hidden="true" />
1252 <span>{t("memory.suggestions")}</span>
1253 {suggestionTotal(suggestions) > 0 && <span className="settings-subtab__count">{suggestionTotal(suggestions)}</span>}
1254 </button>
1255 </div>
1256 </div>
1257
1258 {tab === "saved" && <section className="mem-section">
1259 <div className="mem-section__head">
1260 <div>
1261 <div className="mem-section__title">{t("memory.savedMemories")}</div>
1262 <div className="mem-note">{t("memory.fallibleNote")}</div>
1263 </div>
1264 </div>
1265 {view.conflicts.length > 0 && (
1266 <div className="mem-context-notice" role="status">
1267 <AlertTriangle size={15} />
1268 <div>
1269 <strong>{t("memory.overridesTitle", { count: view.conflicts.length })}</strong>
1270 {view.conflicts.map((conflict) => (
1271 <span key={`${conflict.projectId}:${conflict.globalId}:${conflict.key}`}>
1272 {t("memory.overrideExplanation", { project: conflict.projectName, global: conflict.globalName })}
1273 </span>
1274 ))}
1275 </div>
1276 </div>
1277 )}
1278 {hasSavedFilters && <div className="mem-toolbar">
1279 <label className="mem-search">
1280 <Search size={14} />
1281 <input
1282 value={query}
1283 onChange={(e) => setQuery(e.target.value)}
1284 placeholder={t("memory.searchPlaceholder")}
1285 />
1286 </label>
1287 <div className="mem-filter" role="tablist" aria-label={t("memory.typeFilter")}>
1288 <button
1289 className={"mem-filter__item" + (typeFilter === "all" ? " mem-filter__item--on" : "")}
1290 onClick={() => setTypeFilter("all")}
1291 type="button"
1292 >
1293 {t("memory.allTypes")}
1294 </button>
1295 {factTypes.map((type) => (
1296 <button
1297 className={"mem-filter__item" + (typeFilter === type ? " mem-filter__item--on" : "")}
1298 onClick={() => setTypeFilter(type)}
1299 type="button"
1300 key={type}
1301 >
1302 {memoryTypeLabel(type, t)}
1303 </button>
1304 ))}
1305 </div>
1306 </div>}
1307 {error && <div className="mem-error" role="alert">{error}</div>}
1308 {facts.length === 0 ? (
1309 <div className="mem-empty mem-empty--cta">
1310 <strong>{t("memory.emptySavedTitle")}</strong>
1311 <span>{t("memory.emptySavedBody")}</span>
1312 </div>
1313 ) : filteredFacts.length === 0 ? (
1314 <div className="mem-empty">
1315 {t("memory.noMatches")}
1316 <button
1317 className="mem-empty__action"
1318 onClick={() => {
1319 setQuery("");
1320 setTypeFilter("all");
1321 }}
1322 type="button"
1323 >
1324 {t("memory.clearFilters")}
1325 </button>
1326 </div>
1327 ) : (
1328 <div className="mem-facts">
1329 {filteredFacts.map((f) => {
1330 const key = memoryFactKey(f);
1331 const isOpen = expanded === key;
1332 const links = uniqueLinks(f.body, factNames);
1333 const missing = links.filter((link) => !link.exists);
1334 const factRevisions = revisions[key];
1335 return (
1336 <article
1337 className={"mem-fact" + (highlight === key ? " mem-fact--hl" : "")}
1338 data-mem-type={f.type || "other"}
1339 key={key}
1340 ref={(el) => {
1341 factRefs.current[key] = el;
1342 }}
1343 >
1344 <button
1345 className="mem-fact__summary"
1346 onClick={() => {
1347 setExpanded(isOpen ? null : key);
1348 setConfirmForget(null);
1349 if (!isOpen) void loadRevisions(f);
1350 }}
1351 type="button"
1352 >
1353 {isOpen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
1354 <span className="mem-fact__main">
1355 <span className="mem-fact__title">{displayTitle(f)}</span>
1356 <span className="mem-fact__meta">
1357 <MemoryFactScope scope={f.scope} t={t} />
1358 {f.type && <span className="mem-fact__type" data-mem-type={f.type}>{memoryTypeLabel(f.type, t)}</span>}
1359 <span className={`mem-freshness mem-freshness--${f.freshness || "current"}`}>{freshnessLabel(f.freshness, t)}</span>
1360 <span className="mem-fact__slug">{f.name}</span>
1361 </span>
1362 <span className="mem-fact__desc">{f.description}</span>
1363 </span>
1364 </button>
1365 {links.length > 0 && (
1366 <div className="mem-fact__links" aria-label={t("memory.links")}>
1367 {links.map((link) =>
1368 link.exists ? (
1369 <button
1370 className="mem-link-chip"
1371 key={link.name}
1372 onClick={() => jumpTo(link.name)}
1373 type="button"
1374 >
1375 [[{link.name}]]
1376 </button>
1377 ) : (
1378 <Tooltip key={link.name} label={t("memory.deadLink", { name: link.name })}>
1379 <span className="mem-link-chip mem-link-chip--dead">[[{link.name}]]</span>
1380 </Tooltip>
1381 ),
1382 )}
1383 </div>
1384 )}
1385 {isOpen && (
1386 <div className="mem-fact__detail">
1387 {f.body ? (
1388 <div className="mem-fact__body">{renderWithLinks(f.body)}</div>
1389 ) : (
1390 <div className="mem-empty">{t("memory.noBody")}</div>
1391 )}
1392 {missing.length > 0 && (
1393 <div className="mem-deadline">
1394 {t("memory.missingLinks", { n: missing.length })}
1395 </div>
1396 )}
1397 <div className="mem-fact__provenance">
1398 <span>{t("memory.factId")}: <code>{f.id || f.name}</code></span>
1399 <span>{t("memory.revision", { revision: f.revision || 1 })}</span>
1400 {f.updatedAt && <span>{t("memory.updatedAt", { time: formatMemoryTime(f.updatedAt) })}</span>}
1401 </div>
1402 <div className="mem-revisions">
1403 <div className="mem-revisions__head"><History size={13} /><strong>{t("memory.revisionHistory")}</strong></div>
1404 {revisionBusy === key ? (
1405 <span className="mem-note">{t("memory.loadingRevisions")}</span>
1406 ) : !factRevisions || factRevisions.length === 0 ? (
1407 <span className="mem-note">{t("memory.noRevisions")}</span>
1408 ) : factRevisions.map((revision) => (
1409 <div className="mem-revision" key={`${key}:${revision.revision}`}>
1410 <div>
1411 <strong>{t("memory.revision", { revision: revision.revision || 1 })}</strong>
1412 <span>{formatMemoryTime(revision.updatedAt || revision.createdAt)}</span>
1413 </div>
1414 <button className="btn btn--small" type="button" disabled={busy} onClick={() => void restoreRevision(f, revision.revision || 1)}>
1415 <ArchiveRestore size={13} />{t("memory.restoreRevision")}
1416 </button>
1417 </div>
1418 ))}
1419 </div>
1420 <div className="mem-fact__actions">
1421 <span className="mem-hint mem-hint--inline">
1422 {t("memory.appliesNow")}
1423 </span>
1424 {confirmForget === key ? (
1425 <div className="mem-confirm">
1426 <button
1427 className="btn btn--small"
1428 onClick={() => setConfirmForget(null)}
1429 disabled={busy}
1430 type="button"
1431 >
1432 {t("common.cancel")}
1433 </button>
1434 <button
1435 className="btn btn--small mem-danger"
1436 onClick={() => void forgetFact(f.id || f.name, key)}
1437 disabled={busy}
1438 type="button"
1439 >
1440 {t("memory.confirmForget")}
1441 </button>
1442 </div>
1443 ) : (
1444 <button
1445 className="btn btn--small mem-fact__forget"
1446 onClick={() => setConfirmForget(key)}
1447 disabled={busy}
1448 type="button"
1449 >
1450 <Trash2 size={13} />
1451 {t("memory.forget")}
1452 </button>
1453 )}
1454 </div>
1455 </div>
1456 )}
1457 </article>
1458 );
1459 })}
1460 </div>
1461 )}
1462 {(view.storeDir || view.storeGlobalDir) && (
1463 <div className="mem-hint">{t("memory.storedUnder", { dir: [view.storeDir, view.storeGlobalDir].filter(Boolean).join(" + ") })}</div>
1464 )}
1465 </section>}
1466
1467 {tab === "suggestions" && <section className="mem-section">
1468 <div className="mem-section__head">
1469 <div>
1470 <div className="mem-section__title">{t("memory.suggestions")}</div>
1471 <div className="mem-note">{t("memory.suggestionsHint")}</div>
1472 </div>
1473 <div className="mem-section__actions">
1474 <button
1475 className="btn btn--small"
1476 type="button"
1477 disabled={suggestionBusy || busy}
1478 onClick={() => void refreshSuggestions()}
1479 >
1480 <RefreshCw size={13} />
1481 {suggestions ? t("memory.refreshSuggestions") : t("memory.scanSuggestions")}
1482 </button>
1483 </div>
1484 </div>
1485 {error && <div className="mem-error" role="alert">{error}</div>}
1486 {!suggestions ? (
1487 <div className="mem-empty mem-empty--cta">
1488 <strong>{t("memory.suggestionsEmptyTitle")}</strong>
1489 <span>{t("memory.suggestionsEmptyBody")}</span>
1490 <button
1491 className="btn btn--primary btn--small"
1492 type="button"
1493 disabled={suggestionBusy || busy}
1494 onClick={() => void refreshSuggestions()}
1495 >
1496 <Sparkles size={13} />
1497 {t("memory.scanSuggestions")}
1498 </button>
1499 </div>
1500 ) : suggestionTotal(suggestions) === 0 ? (
1501 <div className="mem-empty mem-empty--cta">
1502 <strong>{t("memory.noSuggestionsTitle")}</strong>
1503 <span>{t("memory.noSuggestionsBody")}</span>
1504 </div>
1505 ) : (
1506 <div className="mem-suggestions">
1507 {suggestions.generatedAt && (
1508 <div className="mem-suggestions__stamp">
1509 {t("memory.suggestionsGenerated", { time: suggestionStamp(suggestions.generatedAt) })}
1510 </div>
1511 )}
1512 {suggestions.memories.length > 0 && (
1513 <div className="mem-suggestion-group">
1514 <div className="mem-suggestion-group__title">{t("memory.memoryCandidates")}</div>
1515 <div className="mem-facts">
1516 {suggestions.memories.map((candidate) => {
1517 const open = expandedSuggestion === candidate.id;
1518 const accepted = acceptedSuggestions[candidate.id];
1519 return (
1520 <article className="mem-fact mem-suggestion" data-mem-type={candidate.type || "other"} key={candidate.id}>
1521 <button
1522 className="mem-fact__summary"
1523 type="button"
1524 onClick={() => setExpandedSuggestion(open ? null : candidate.id)}
1525 >
1526 {open ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
1527 <span className="mem-fact__main">
1528 <span className="mem-fact__title">{candidate.title || candidate.name}</span>
1529 <span className="mem-fact__meta">
1530 <MemoryFactScope scope={candidate.scope} t={t} />
1531 <span className="mem-fact__type" data-mem-type={candidate.type}>{memoryTypeLabel(candidate.type, t)}</span>
1532 <span className="mem-fact__slug">{candidate.name}</span>
1533 </span>
1534 <span className="mem-fact__desc">{candidate.description}</span>
1535 </span>
1536 </button>
1537 {open && (
1538 <div className="mem-fact__detail">
1539 <div className="mem-suggestion__body">{candidate.body}</div>
1540 {candidate.reason && <div className="mem-suggestion__reason">{candidate.reason}</div>}
1541 {candidate.evidence.length > 0 && (
1542 <ul className="mem-suggestion__evidence">
1543 {candidate.evidence.map((item) => <li key={item}>{item}</li>)}
1544 </ul>
1545 )}
1546 <div className="mem-fact__actions">
1547 <span className="mem-hint mem-hint--inline">{t("memory.confirmBeforeApply")}</span>
1548 {accepted ? (
1549 <span className="mem-suggestion__accepted"><Check size={13} />{t("memory.savedSuggestion")}</span>
1550 ) : (
1551 <button
1552 className="btn btn--primary btn--small"
1553 type="button"
1554 disabled={busy}
1555 onClick={() => void acceptMemorySuggestion(candidate)}
1556 >
1557 <Check size={13} />
1558 {t("memory.saveAsMemory")}
1559 </button>
1560 )}
1561 </div>
1562 </div>
1563 )}
1564 </article>
1565 );
1566 })}
1567 </div>
1568 </div>
1569 )}
1570 {suggestions.skills.length > 0 && (
1571 <div className="mem-suggestion-group">
1572 <div className="mem-suggestion-group__title">{t("memory.skillCandidates")}</div>
1573 <div className="mem-facts">
1574 {suggestions.skills.map((candidate) => {
1575 const open = expandedSuggestion === candidate.id;
1576 const accepted = acceptedSuggestions[candidate.id];
1577 return (
1578 <article className="mem-fact mem-suggestion mem-suggestion--skill" data-mem-type="reference" key={candidate.id}>
1579 <button
1580 className="mem-fact__summary"
1581 type="button"
1582 onClick={() => setExpandedSuggestion(open ? null : candidate.id)}
1583 >
1584 {open ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
1585 <span className="mem-doc__icon"><FileText size={15} /></span>
1586 <span className="mem-fact__main">
1587 <span className="mem-fact__title">{candidate.name}</span>
1588 <span className="mem-fact__meta">
1589 <span className="mem-fact__type">{t("memory.skillCandidate")}</span>
1590 <span className="mem-fact__slug">{memoryScopeLabel(candidate.scope, t)}</span>
1591 </span>
1592 <span className="mem-fact__desc">{candidate.description}</span>
1593 </span>
1594 </button>
1595 {open && (
1596 <div className="mem-fact__detail">
1597 <pre className="mem-suggestion__body mem-suggestion__body--code">{candidate.body}</pre>
1598 {candidate.reason && <div className="mem-suggestion__reason">{candidate.reason}</div>}
1599 {candidate.evidence.length > 0 && (
1600 <ul className="mem-suggestion__evidence">
1601 {candidate.evidence.map((item) => <li key={item}>{item}</li>)}
1602 </ul>
1603 )}
1604 <div className="mem-fact__actions">
1605 <span className="mem-hint mem-hint--inline">{t("memory.confirmBeforeApply")}</span>
1606 {accepted ? (
1607 <span className="mem-suggestion__accepted"><Check size={13} />{t("memory.createdSkillSuggestion")}</span>
1608 ) : (
1609 <button
1610 className="btn btn--primary btn--small"
1611 type="button"
1612 disabled={busy}
1613 onClick={() => void acceptSkillSuggestion(candidate)}
1614 >
1615 <Check size={13} />
1616 {t("memory.createSkill")}
1617 </button>
1618 )}
1619 </div>
1620 </div>
1621 )}
1622 </article>
1623 );
1624 })}
1625 </div>
1626 </div>
1627 )}
1628 </div>
1629 )}
1630 </section>}
1631
1632 {tab === "activity" && <section className="mem-section">
1633 <div className="mem-section__head">
1634 <div>
1635 <div className="mem-section__title">{t("memory.recallTitle")}</div>
1636 <div className="mem-note">{t("memory.recallHint")}</div>
1637 </div>
1638 </div>
1639 <div className="mem-recall-summary">
1640 <Activity size={16} />
1641 <div>
1642 <strong>{view.lastRecall.query || t("memory.noRecallQuery")}</strong>
1643 <span>{t("memory.recallBudget", { used: view.lastRecall.usedChars, budget: view.lastRecall.charBudget, omitted: view.lastRecall.omitted })}</span>
1644 </div>
1645 </div>
1646 {view.lastRecall.suppressed && (
1647 <div className="mem-context-notice mem-context-notice--muted">
1648 <AlertTriangle size={15} />
1649 <span>{t("memory.recallSuppressed", { reason: view.lastRecall.suppressed })}</span>
1650 </div>
1651 )}
1652 {view.lastRecall.hits.length === 0 ? (
1653 <div className="mem-empty">{t("memory.noRecallHits")}</div>
1654 ) : (
1655 <div className="mem-recall-hits">
1656 {view.lastRecall.hits.map((hit) => (
1657 <div className="mem-recall-hit" key={`${hit.id}:${hit.revision}`}>
1658 <div className="mem-recall-hit__head">
1659 <strong>{hit.title || hit.name}</strong>
1660 <span>{Math.round(hit.score * 100)}%</span>
1661 </div>
1662 <div className="mem-fact__meta">
1663 <MemoryFactScope scope={hit.scope} t={t} />
1664 <span className="mem-fact__type" data-mem-type={hit.type}>{memoryTypeLabel(hit.type, t)}</span>
1665 <span className={`mem-freshness mem-freshness--${hit.freshness}`}>{freshnessLabel(hit.freshness, t)}</span>
1666 <span>{t("memory.revision", { revision: hit.revision })}</span>
1667 </div>
1668 <p>{hit.snippet}</p>
1669 <small>{hit.reason}</small>
1670 </div>
1671 ))}
1672 </div>
1673 )}
1674 </section>}
1675
1676 {tab === "archived" && <section className="mem-section">
1677 <div className="mem-section__head">
1678 <div>
1679 <div className="mem-section__title">{t("memory.archivedMemories")}</div>
1680 <div className="mem-note">{t("memory.archivedHint")}</div>
1681 </div>
1682 </div>
1683 {hasArchivedFilters && <div className="mem-toolbar">
1684 <label className="mem-search">
1685 <Search size={14} />
1686 <input
1687 value={query}
1688 onChange={(e) => setQuery(e.target.value)}
1689 placeholder={t("memory.searchPlaceholder")}
1690 />
1691 </label>
1692 <div className="mem-filter" role="tablist" aria-label={t("memory.typeFilter")}>
1693 <button
1694 className={"mem-filter__item" + (typeFilter === "all" ? " mem-filter__item--on" : "")}
1695 onClick={() => setTypeFilter("all")}
1696 type="button"
1697 >
1698 {t("memory.allTypes")}
1699 </button>
1700 {factTypes.map((type) => (
1701 <button
1702 className={"mem-filter__item" + (typeFilter === type ? " mem-filter__item--on" : "")}
1703 onClick={() => setTypeFilter(type)}
1704 type="button"
1705 key={type}
1706 >
1707 {memoryTypeLabel(type, t)}
1708 </button>
1709 ))}
1710 </div>
1711 </div>}
1712 {archives.length === 0 ? (
1713 <div className="mem-empty mem-empty--cta">
1714 <strong>{t("memory.emptyArchivedTitle")}</strong>
1715 <span>{t("memory.emptyArchivedBody")}</span>
1716 </div>
1717 ) : (
1718 <ArchivedMemoryList
1719 archives={filteredArchives}
1720 totalArchives={archives.length}
1721 expanded={expandedArchive}
1722 setExpanded={setExpandedArchive}
1723 renderWithLinks={renderWithLinks}
1724 t={t}
1725 hideHeader
1726 busy={busy}
1727 onRestore={restoreArchive}
1728 />
1729 )}
1730 </section>}
1731
1732 {tab === "docs" && <section className="mem-section">
1733 <div className="mem-section__head">
1734 <div>
1735 <div className="mem-section__title">{t("memory.instructionFiles")}</div>
1736 <div className="mem-note">{t("memory.instructionFilesHint")}</div>
1737 </div>
1738 <div className="mem-section__actions">
1739 <button
1740 className="btn btn--small"
1741 type="button"
1742 disabled={busy}
1743 onClick={() => setShowAdd((v) => !v)}
1744 >
1745 {showAdd ? t("common.collapse") : <><Plus size={13} />{t("memory.addMemory")}</>}
1746 </button>
1747 </div>
1748 </div>
1749 {view.instructionDiagnostics.length > 0 && (
1750 <div className="mem-instruction-diagnostics">
1751 <div className="mem-instruction-diagnostics__title"><AlertTriangle size={14} />{t("memory.instructionDiagnostics")}</div>
1752 {view.instructionDiagnostics.map((diagnostic, index) => (
1753 <div className="mem-instruction-diagnostic" key={`${diagnostic.code}:${diagnostic.path}:${diagnostic.line || index}`}>
1754 <strong>{diagnostic.code}</strong>
1755 <span>{diagnostic.message}</span>
1756 <code>{diagnostic.path}{diagnostic.line ? `:${diagnostic.line}` : ""}</code>
1757 </div>
1758 ))}
1759 </div>
1760 )}
1761 {showAdd && (
1762 <div className="mem-add-card">
1763 <div className="mem-add-card__head">
1764 <div>
1765 <strong>{t("memory.addMemory")}</strong>
1766 <span>{t("memory.addMemoryHint")}</span>
1767 </div>
1768 </div>
1769 <div className="mem-add">
1770 <Tooltip label={t("memory.whereToSave")}>
1771 <SettingsSelect
1772 className="mem-select"
1773 value={activeScope}
1774 onValueChange={(value) => setScope(value)}
1775 >
1776 {scopes.map((s) => (
1777 <option key={s.scope} value={s.scope}>
1778 {memoryScopeLabel(s.scope, t)}
1779 </option>
1780 ))}
1781 </SettingsSelect>
1782 </Tooltip>
1783 <input
1784 className="mem-input"
1785 placeholder={t("memory.notePlaceholder")}
1786 value={note}
1787 onChange={(e) => setNote(e.target.value)}
1788 onKeyDown={(e) => {
1789 if (e.key === "Enter") void submitNote();
1790 }}
1791 />
1792 <button
1793 className="btn btn--primary btn--small"
1794 onClick={() => void submitNote()}
1795 disabled={busy || !note.trim()}
1796 >
1797 {t("memory.remember")}
1798 </button>
1799 </div>
1800 <div className="mem-hint">
1801 {scopes.find((s) => s.scope === activeScope)?.path}
1802 </div>
1803 </div>
1804 )}
1805 {view.docs.length === 0 && (
1806 <div className="mem-empty">{t("memory.noDocs")}</div>
1807 )}
1808 {view.docs.map((d) => {
1809 const editing = editingPath === d.path;
1810 const open = expandedDoc === d.path || editing;
1811 return (
1812 <div className="mem-doc" data-doc-scope={d.scope || "other"} key={d.path}>
1813 <div className="mem-doc__head">
1814 <button
1815 className="mem-doc__identity mem-doc__toggle"
1816 type="button"
1817 aria-expanded={open}
1818 onClick={() => {
1819 if (!editing) setExpandedDoc(open ? null : d.path);
1820 }}
1821 disabled={editing}
1822 >
1823 <span className="mem-doc__chevron">
1824 {open ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
1825 </span>
1826 <span className="mem-doc__icon"><FileText size={15} /></span>
1827 <div>
1828 <strong>{memoryDocTitle(d.scope, t)}</strong>
1829 <span className="mem-doc__path">{d.path}</span>
1830 <small>{memoryDocHint(d.scope, t)}</small>
1831 <small>{t("memory.instructionPrecedence", { precedence: d.precedence + 1, directory: d.directory || t("memory.globalDirectory") })}</small>
1832 </div>
1833 </button>
1834 <div className="mem-doc__head-actions">
1835 <span className={"mem-doc__tag badge--" + d.scope}>{memoryScopeLabel(d.scope, t)}</span>
1836 {!editing && (
1837 <button
1838 className="btn btn--small"
1839 onClick={() => startEdit(d.path, d.body)}
1840 >
1841 <Pencil size={13} />
1842 {t("common.edit")}
1843 </button>
1844 )}
1845 </div>
1846 </div>
1847 {editing ? (
1848 <div className="mem-doc__edit">
1849 <textarea
1850 className="mem-textarea"
1851 value={draft}
1852 onChange={(e) => setDraft(e.target.value)}
1853 spellCheck={false}
1854 />
1855 <div className="mem-doc__actions">
1856 <button
1857 className="btn btn--small"
1858 onClick={() => setEditingPath(null)}
1859 disabled={busy}
1860 >
1861 {t("common.cancel")}
1862 </button>
1863 <button
1864 className="btn btn--primary btn--small"
1865 onClick={() => void saveEdit()}
1866 disabled={busy}
1867 >
1868 {t("common.save")}
1869 </button>
1870 </div>
1871 </div>
1872 ) : open ? (
1873 <div className="mem-doc__expanded">
1874 <pre className="mem-doc__body">{d.body}</pre>
1875 {d.imports.length > 0 && (
1876 <div className="mem-doc__imports">
1877 <strong>{t("memory.instructionImports")}</strong>
1878 {d.imports.map((item) => <code key={`${item.sourcePath}:${item.path}`}>{item.sourcePath} → {item.path}</code>)}
1879 </div>
1880 )}
1881 </div>
1882 ) : null}
1883 </div>
1884 );
1885 })}
1886 </section>}
1887 </>
1888 );
1889 }
1890
1890 lines Plain Text