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