Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 95 additions & 25 deletions desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -4326,12 +4326,13 @@ type MemoryScope struct {
// MemoryView is the whole memory panel payload: hierarchical docs, active saved
// facts, archived facts, and the writable scopes for the quick-add selector.
type MemoryView struct {
Docs []MemoryDoc `json:"docs"`
Facts []MemoryFact `json:"facts"`
Archives []MemoryArchive `json:"archives"`
Scopes []MemoryScope `json:"scopes"`
StoreDir string `json:"storeDir"`
Available bool `json:"available"`
Docs []MemoryDoc `json:"docs"`
Facts []MemoryFact `json:"facts"`
Archives []MemoryArchive `json:"archives"`
Scopes []MemoryScope `json:"scopes"`
StoreDir string `json:"storeDir"`
StoreGlobalDir string `json:"storeGlobalDir,omitempty"`
Available bool `json:"available"`
}

// writableScopes are the quick-add targets the panel offers, broad → specific.
Expand All @@ -4341,20 +4342,41 @@ var writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memor
// active/archived auto-memories, and the writable scopes. Read-only; mutations
// go through Remember / SaveDoc.
func (a *App) Memory() MemoryView {
// Always return non-nil slices: a nil Go slice marshals to JSON `null`, which
// would crash the panel's `view.facts.length` / `.map`.
return a.memoryForCtrl(nil, true)
}

// MemoryForTab returns the loaded memory for a specific tab's controller,
// so the panel can show memory for any open project, not just the active tab.
// If the tab does not exist or has no controller, returns an empty view
// instead of falling back to the active tab (which would show the wrong data).
// An empty tabID is treated as "no tab specified" and falls back to the
// active tab for backward compatibility.
func (a *App) MemoryForTab(tabID string) MemoryView {
if tabID == "" {
return a.memoryForCtrl(nil, true)
}
return a.memoryForCtrl(a.ctrlByTabID(tabID), false)
}

func (a *App) memoryForCtrl(ctrl *control.Controller, fallback bool) MemoryView {
view := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}
a.mu.RLock()
ctrl := a.activeCtrlLocked()
a.mu.RUnlock()
if ctrl == nil {
return view
if !fallback {
return view
}
a.mu.RLock()
ctrl = a.activeCtrlLocked()
a.mu.RUnlock()
if ctrl == nil {
return view
}
}
set := ctrl.Memory()
if set == nil {
return view
}
view.StoreDir = set.Store.Dir
view.StoreGlobalDir = set.Store.GlobalDir
view.Available = true
for _, d := range set.Docs {
view.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})
Expand All @@ -4375,7 +4397,7 @@ func (a *App) Memory() MemoryView {
})
}
for _, sc := range writableScopes {
if p := set.DocPath(sc); p != "" { // user scope yields "" when no config dir
if p := set.DocPath(sc); p != "" {
view.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})
}
}
Expand All @@ -4386,35 +4408,83 @@ func (a *App) Memory() MemoryView {
// panel's explicit "remember" action, equivalent to typing "/remember <note>".
// An unknown scope falls back to project. Returns the file written.
func (a *App) Remember(scope, note string) (string, error) {
a.mu.RLock()
ctrl := a.activeCtrlLocked()
a.mu.RUnlock()
return a.rememberForCtrl(nil, scope, note, true)
}

func (a *App) RememberForTab(tabID, scope, note string) (string, error) {
if tabID == "" {
return a.rememberForCtrl(nil, scope, note, true)
}
return a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)
}

func (a *App) rememberForCtrl(ctrl *control.Controller, scope, note string, fallback bool) (string, error) {
if ctrl == nil {
return "", nil
if !fallback {
return "", nil
}
a.mu.RLock()
ctrl = a.activeCtrlLocked()
a.mu.RUnlock()
if ctrl == nil {
return "", nil
}
}
return ctrl.QuickAdd(parseScope(scope), note)
}

// Forget deletes a saved auto-memory by name — the panel's delete action for a
// fact the model owns. A no-op when no controller is attached.
func (a *App) Forget(name string) error {
a.mu.RLock()
ctrl := a.activeCtrlLocked()
a.mu.RUnlock()
return a.forgetForCtrl(nil, name, true)
}

func (a *App) ForgetForTab(tabID, name string) error {
if tabID == "" {
return a.forgetForCtrl(nil, name, true)
}
return a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)
}

func (a *App) forgetForCtrl(ctrl *control.Controller, name string, fallback bool) error {
if ctrl == nil {
return nil
if !fallback {
return nil
}
a.mu.RLock()
ctrl = a.activeCtrlLocked()
a.mu.RUnlock()
if ctrl == nil {
return nil
}
}
return ctrl.ForgetMemory(name)
}

// SaveDoc overwrites a memory doc with the panel editor's contents. The controller
// validates path against the recognized memory files. Returns the file written.
func (a *App) SaveDoc(path, body string) (string, error) {
a.mu.RLock()
ctrl := a.activeCtrlLocked()
a.mu.RUnlock()
return a.saveDocForCtrl(nil, path, body, true)
}

func (a *App) SaveDocForTab(tabID, path, body string) (string, error) {
if tabID == "" {
return a.saveDocForCtrl(nil, path, body, true)
}
return a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)
}

func (a *App) saveDocForCtrl(ctrl *control.Controller, path, body string, fallback bool) (string, error) {
if ctrl == nil {
return "", nil
if !fallback {
return "", nil
}
a.mu.RLock()
ctrl = a.activeCtrlLocked()
a.mu.RUnlock()
if ctrl == nil {
return "", nil
}
}
return ctrl.SaveDoc(path, body)
}
Expand Down
84 changes: 69 additions & 15 deletions desktop/frontend/src/components/MemoryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Check, ChevronDown, ChevronRight, FileText, Pencil, Plus, RefreshCw, Se
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { app } from "../lib/bridge";
import { useT } from "../lib/i18n";
import type { MemoryArchive, MemoryFact, MemorySuggestion, MemorySuggestionsView, MemoryView, SkillSuggestion } from "../lib/types";
import type { MemoryArchive, MemoryFact, MemorySuggestion, MemorySuggestionsView, MemoryView, SkillSuggestion, TabMeta } from "../lib/types";
import { ResizableDrawer } from "./ResizableDrawer";
import { Tooltip } from "./Tooltip";
import { ModalCloseButton } from "./ModalCloseButton";
Expand Down Expand Up @@ -572,7 +572,7 @@ export function MemoryPanel({
{t("memory.confirmForget")}
</button>
</div>
) : (
) : (
<button
className="btn btn--small mem-fact__forget"
onClick={() => setConfirmForget(f.name)}
Expand All @@ -591,8 +591,8 @@ export function MemoryPanel({
})}
</div>
)}
{view.storeDir && (
<div className="mem-hint">{t("memory.storedUnder", { dir: view.storeDir })}</div>
{(view.storeDir || view.storeGlobalDir) && (
<div className="mem-hint">{t("memory.storedUnder", { dir: [view.storeDir, view.storeGlobalDir].filter(Boolean).join(" + ") })}</div>
)}
</section>

Expand Down Expand Up @@ -731,9 +731,9 @@ export function MemoryPanel({
</div>
))
)}
{view.storeDir && (
<div className="mem-hint" title={view.storeDir}>
{t("memory.storedUnder", { dir: view.storeDir })}
{(view.storeDir || view.storeGlobalDir) && (
<div className="mem-hint" title={[view.storeDir, view.storeGlobalDir].filter(Boolean).join(" + ")}>
{t("memory.storedUnder", { dir: [view.storeDir, view.storeGlobalDir].filter(Boolean).join(" + ") })}
</div>
)}
</section>
Expand All @@ -748,6 +748,8 @@ export function MemoryPanel({
export function MemorySettingsPage() {
const t = useT();
const [view, setView] = useState<MemoryView | null>(null);
const [tabs, setTabs] = useState<TabMeta[]>([]);
const [selectedTabId, setSelectedTabId] = useState<string | null>(null);
const [note, setNote] = useState("");
const [scope, setScope] = useState("");
const [editingPath, setEditingPath] = useState<string | null>(null);
Expand All @@ -773,8 +775,20 @@ export function MemorySettingsPage() {
const factRefs = useRef<Record<string, HTMLElement | null>>({});

const reload = useCallback(async () => {
setView(await app.Memory().catch(() => null));
const tabId = selectedTabId;
setView(tabId ? await app.MemoryForTab(tabId).catch(() => null) : await app.Memory().catch(() => null));
}, [selectedTabId]);

useEffect(() => {
app.ListTabs().then((tabList) => {
setTabs(tabList);
if (!selectedTabId) {
const active = tabList.find((tb) => tb.active);
if (active) setSelectedTabId(active.id);
}
}).catch(() => {});
}, []);

useEffect(() => { void reload(); }, [reload]);

const refreshSuggestions = useCallback(async () => {
Expand Down Expand Up @@ -886,7 +900,8 @@ export function MemorySettingsPage() {
setBusy(true);
setError(null);
try {
await app.Forget(name);
if (selectedTabId) await app.ForgetForTab(selectedTabId, name);
else await app.Forget(name);
await reload();
if (expanded === name) setExpanded(null);
setConfirmForget(null);
Expand All @@ -895,7 +910,7 @@ export function MemorySettingsPage() {
} finally {
setBusy(false);
}
}, [busy, expanded, reload]);
}, [busy, expanded, reload, selectedTabId]);

const scopes = view?.scopes ?? [];
const activeScope =
Expand All @@ -907,7 +922,8 @@ export function MemorySettingsPage() {
setBusy(true);
setError(null);
try {
await app.Remember(activeScope, trimmed);
if (selectedTabId) await app.RememberForTab(selectedTabId, activeScope, trimmed);
else await app.Remember(activeScope, trimmed);
await reload();
setNote("");
setShowAdd(false);
Expand All @@ -916,7 +932,7 @@ export function MemorySettingsPage() {
} finally {
setBusy(false);
}
}, [note, busy, activeScope, reload]);
}, [note, busy, activeScope, reload, selectedTabId]);

const startEdit = useCallback((path: string, body: string) => {
setEditingPath(path);
Expand All @@ -928,15 +944,16 @@ export function MemorySettingsPage() {
setBusy(true);
setError(null);
try {
await app.SaveDoc(editingPath, draft);
if (selectedTabId) await app.SaveDocForTab(selectedTabId, editingPath, draft);
else await app.SaveDoc(editingPath, draft);
await reload();
setEditingPath(null);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}, [editingPath, busy, draft, reload]);
}, [editingPath, busy, draft, reload, selectedTabId]);

const acceptMemorySuggestion = useCallback(async (candidate: MemorySuggestion) => {
if (busy) return;
Expand Down Expand Up @@ -968,7 +985,26 @@ export function MemorySettingsPage() {
}, [busy]);

if (!view?.available) {
return <div className="empty">{t("memory.unavailable")}</div>;
return (
<>
{tabs.length > 1 && (
<div className="mem-tab-selector">
<select
className="mem-tab-select"
value={selectedTabId ?? ""}
onChange={(e) => setSelectedTabId(e.target.value || null)}
>
{tabs.map((tb) => (
<option key={tb.id} value={tb.id}>
{tb.label || tb.workspaceName || tb.scope || tb.id}
</option>
))}
</select>
</div>
)}
<div className="empty">{t("memory.unavailable")}</div>
</>
);
}

const hasSavedFilters = facts.length > 0;
Expand Down Expand Up @@ -1038,6 +1074,21 @@ export function MemorySettingsPage() {
{suggestionTotal(suggestions) > 0 && <span className="settings-subtab__count">{suggestionTotal(suggestions)}</span>}
</button>
</div>
{tabs.length > 1 && (
<div className="mem-tab-selector">
<select
className="mem-tab-select"
value={selectedTabId ?? ""}
onChange={(e) => setSelectedTabId(e.target.value || null)}
>
{tabs.map((tb) => (
<option key={tb.id} value={tb.id}>
{tb.label || tb.workspaceName || tb.scope}
</option>
))}
</select>
</div>
)}

{tab === "saved" && <section className="mem-section">
<div className="mem-section__head">
Expand Down Expand Up @@ -1265,6 +1316,9 @@ export function MemorySettingsPage() {
})}
</div>
)}
{(view.storeDir || view.storeGlobalDir) && (
<div className="mem-hint">{t("memory.storedUnder", { dir: [view.storeDir, view.storeGlobalDir].filter(Boolean).join(" + ") })}</div>
)}
</section>}

{tab === "suggestions" && <section className="mem-section">
Expand Down
Loading
Loading