From 6ccc15b810d6e968681887b1c666cfb9ff771ecf Mon Sep 17 00:00:00 2001 From: Sivan Date: Sat, 6 Jun 2026 17:21:47 +0800 Subject: [PATCH] Refine desktop settings center --- desktop/app.go | 2 +- desktop/app_test.go | 39 + desktop/dotenv.go | 61 + desktop/dotenv_test.go | 26 + desktop/frontend/package-lock.json | 79 + desktop/frontend/src/App.tsx | 75 +- .../src/components/CapabilitiesPanel.tsx | 224 ++ .../frontend/src/components/MemoryPanel.tsx | 524 ++++- .../frontend/src/components/SettingsPanel.tsx | 1830 +++++++++++++---- desktop/frontend/src/lib/bridge.ts | 38 +- desktop/frontend/src/lib/types.ts | 7 + desktop/frontend/src/locales/en.ts | 168 +- desktop/frontend/src/locales/zh.ts | 178 +- desktop/frontend/src/styles.css | 1410 +++++++++++-- desktop/settings_app.go | 225 +- desktop/tab_profile_test.go | 38 + internal/boot/boot.go | 9 +- internal/boot/boot_test.go | 40 + internal/config/config.go | 13 +- internal/config/edit_test.go | 89 +- internal/config/effort.go | 140 +- internal/config/fetch.go | 104 +- internal/config/fetch_test.go | 99 + internal/config/render.go | 6 + internal/config/render_test.go | 3 +- internal/provider/openai/effort_test.go | 32 + internal/provider/openai/fetch_models.go | 32 +- internal/provider/openai/openai.go | 20 +- reasonix.example.toml | 7 +- 29 files changed, 4838 insertions(+), 680 deletions(-) create mode 100644 internal/config/fetch_test.go diff --git a/desktop/app.go b/desktop/app.go index e89e5b1ab..2105e454d 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -2283,7 +2283,7 @@ func (a *App) SetModelForTab(tabID, name string) error { name = entry.Name + "/" + entry.Model effortOverride := cloneStringPtr(tab.effort) if effortOverride != nil { - normalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride, Name: entry.Name, Kind: entry.Kind, BaseURL: entry.BaseURL})) + normalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride})) if err != nil { effortOverride = nil } else { diff --git a/desktop/app_test.go b/desktop/app_test.go index 28f7ca717..872f8e780 100644 --- a/desktop/app_test.go +++ b/desktop/app_test.go @@ -216,6 +216,45 @@ close_behavior = "quit" } } +func TestSaveProviderPersistsReasoningProtocol(t *testing.T) { + isolateDesktopUserDirs(t) + + app := NewApp() + if err := app.SaveProvider(ProviderView{ + Name: "deepseek-proxy", + Kind: "openai", + BaseURL: "https://proxy.example.com/v1", + Models: []string{"deepseek-v4-flash"}, + Default: "deepseek-v4-flash", + APIKeyEnv: "DEEPSEEK_PROXY_KEY", + ReasoningProtocol: "none", + SupportedEfforts: []string{"high", "max"}, + DefaultEffort: "max", + }); err != nil { + t.Fatalf("SaveProvider: %v", err) + } + + cfg := config.LoadForEdit(config.UserConfigPath()) + got, ok := cfg.Provider("deepseek-proxy") + if !ok { + t.Fatal("saved provider not found") + } + if got.ReasoningProtocol != "none" || got.DefaultEffort != "max" { + t.Fatalf("saved provider = %+v, want reasoning_protocol none and default_effort max", got) + } + + view := app.Settings() + for _, p := range view.Providers { + if p.Name == "deepseek-proxy" { + if p.ReasoningProtocol != "none" { + t.Fatalf("settings reasoningProtocol = %q, want none", p.ReasoningProtocol) + } + return + } + } + t.Fatalf("Settings() missing saved provider: %+v", view.Providers) +} + func TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) { isolateDesktopUserDirs(t) diff --git a/desktop/dotenv.go b/desktop/dotenv.go index 3c84aeda5..6e3999cec 100644 --- a/desktop/dotenv.go +++ b/desktop/dotenv.go @@ -31,6 +31,10 @@ func upsertDotEnv(key, value string) error { return upsertEnvFile(credentialsPath(), key, value) } +func removeDotEnv(key string) error { + return removeEnvFile(credentialsPath(), key) +} + // upsertEnvFile merges KEY=value into a KEY=value file at path, preserving // comments and unrelated lines, writing atomically via a sibling temp + rename. func upsertEnvFile(path, key, value string) error { @@ -86,6 +90,63 @@ func upsertEnvFile(path, key, value string) error { return os.Setenv(key, value) } +func removeEnvFile(path, key string) error { + key = strings.TrimSpace(key) + if key == "" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return os.Unsetenv(key) + } + return err + } + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + outLines := make([]string, 0, len(lines)) + for _, ln := range lines { + t := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(ln), "export ")) + if t == "" || strings.HasPrefix(t, "#") { + outLines = append(outLines, ln) + continue + } + if k, _, ok := strings.Cut(t, "="); ok && strings.TrimSpace(k) == key { + continue + } + outLines = append(outLines, ln) + } + out := "" + if len(outLines) > 0 { + out = strings.Join(outLines, "\n") + "\n" + } + + dir := filepath.Dir(path) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + tmp, err := os.CreateTemp(dir, "credentials.*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + if _, err := tmp.WriteString(out); err != nil { + tmp.Close() + os.Remove(tmpPath) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return err + } + if err := fileutil.ReplaceFile(tmpPath, path); err != nil { + os.Remove(tmpPath) + return err + } + return os.Unsetenv(key) +} + // envFileKeys returns the set of KEY names assigned in a KEY=value file, empty // when the file is absent. func envFileKeys(path string) map[string]bool { diff --git a/desktop/dotenv_test.go b/desktop/dotenv_test.go index e539830f3..fe381b58c 100644 --- a/desktop/dotenv_test.go +++ b/desktop/dotenv_test.go @@ -39,6 +39,32 @@ func TestUpsertEnvFile(t *testing.T) { } } +func TestRemoveEnvFileDeletesKeyAndUnsetsProcessEnv(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials") + if err := os.WriteFile(path, []byte("# comment\nFOO=old\nexport BAR=remove\nBAZ=keep\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("BAR", "remove") + + if err := removeEnvFile(path, "BAR"); err != nil { + t.Fatalf("remove: %v", err) + } + + b, _ := os.ReadFile(path) + got := string(b) + for _, want := range []string{"# comment", "FOO=old", "BAZ=keep"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } + if strings.Contains(got, "BAR=") { + t.Errorf("removed key should be absent:\n%s", got) + } + if _, ok := os.LookupEnv("BAR"); ok { + t.Errorf("process env BAR should be unset") + } +} + // TestPromoteProviderKeysLiftsProjectKeyAndStripsHomeEnv proves a provider key // that resolves only from a project .env / ~/.env is copied into the global // credentials store, removed from ~/.env, and that unrelated env vars are ignored. diff --git a/desktop/frontend/package-lock.json b/desktop/frontend/package-lock.json index 254017227..4f8e72723 100644 --- a/desktop/frontend/package-lock.json +++ b/desktop/frontend/package-lock.json @@ -22,6 +22,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "terser": "^5.48.0", "typescript": "^5.6.3", "vite": "^6.0.7" } @@ -782,6 +783,17 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1315,6 +1327,19 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -1372,6 +1397,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/caniuse-lite": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -3278,6 +3310,16 @@ "semver": "bin/semver.js" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3288,6 +3330,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -3330,6 +3383,32 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 905182e61..50dcaad96 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -29,10 +29,8 @@ import { TodoPanel } from "./components/TodoPanel"; import { ApprovalModal } from "./components/ApprovalModal"; import { AskCard } from "./components/AskCard"; import { StatusBar } from "./components/StatusBar"; -import { MemoryPanel } from "./components/MemoryPanel"; import { HistoryPanel } from "./components/HistoryPanel"; import { SettingsPanel } from "./components/SettingsPanel"; -import { CapabilitiesPanel } from "./components/CapabilitiesPanel"; import { UpdateBanner } from "./components/UpdateBanner"; import { ContextPanel } from "./components/ContextPanel"; import { WorkspacePanel } from "./components/WorkspacePanel"; @@ -42,7 +40,8 @@ import { TabBar } from "./components/TabBar"; import { ProjectTree } from "./components/ProjectTree"; import { parseTodos } from "./lib/tools"; import { shouldShowTodoPanel } from "./lib/todoVisibility"; -import type { ComposerInsertRequest, MemoryView, Meta, Mode, SessionMeta, TabMeta } from "./lib/types"; +import type { ComposerInsertRequest, Meta, Mode, SessionMeta, TabMeta } from "./lib/types"; +import type { SettingsTab } from "./lib/types"; import { loadLayoutSize, saveLayoutSize } from "./lib/layoutPreferences"; import { applyTheme, @@ -253,10 +252,6 @@ export default function App() { rewind, setModel, setEffort, - fetchMemory, - remember, - forget, - saveDoc, switchTab, openProjectTab, openGlobalTab, @@ -273,7 +268,7 @@ export default function App() { // null until the mount probe resolves; true shows the overlay. Probed once — // clearing the key mid-session is the Settings panel's job, not the gate's. const [needsOnboarding, setNeedsOnboarding] = useState(null); - const [memView, setMemView] = useState(null); + const [settingsTarget, setSettingsTarget] = useState(null); const [histView, setHistView] = useState(null); const [sidebarCollapsed, setSidebarCollapsed] = useState(loadSidebarCollapsed); const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth); @@ -288,8 +283,6 @@ export default function App() { const [dockRefreshKey, setDockRefreshKey] = useState(0); const [projectRevision, setProjectRevision] = useState(0); const [composerInsertRequest, setComposerInsertRequest] = useState(null); - const [settingsOpen, setSettingsOpen] = useState(false); - const [capsOpen, setCapsOpen] = useState(false); const [renamingTopicId, setRenamingTopicId] = useState(null); const [topicTitleDraft, setTopicTitleDraft] = useState(""); const topicRenameSkipCommitRef = useRef(false); @@ -327,7 +320,7 @@ export default function App() { useEffect(() => { if (typeof window === "undefined" || !window.runtime) return; return window.runtime.EventsOn("app:open-settings", () => { - setSettingsOpen(true); + setSettingsTarget("general"); }); }, []); const [pendingPlanRevision, setPendingPlanRevision] = useState(null); @@ -523,17 +516,9 @@ export default function App() { send(text); }, [pendingPlanRevision, send, state.running]); - // Memory drawer: opening fetches a fresh snapshot; writes re-fetch so the - // panel reflects what landed on disk. - const openMemory = useCallback(async () => { - setMemView(await fetchMemory()); - }, [fetchMemory]); - - const closeMemory = useCallback(() => setMemView(null), []); - // handleSend intercepts the slash commands that need a desktop-native action // before they reach the backend: "/model " rebuilds on that model, and - // "/memory" opens the memory drawer. Everything else — skills (/init, …), + // "/memory" opens the settings centre on the Memory page. Everything else — skills (/init, …), // custom commands, bare /model and the other read-only management verbs // (/skill, /hooks, /mcp) — goes straight to Submit, which the controller // resolves (a turn, or a listing Notice). @@ -556,7 +541,7 @@ export default function App() { return; } if (trimmed === "/memory") { - void openMemory(); + setSettingsTarget("memory"); return; } const theme = /^\/theme(?:\s+(\S+))?$/.exec(trimmed); @@ -588,7 +573,7 @@ export default function App() { await syncModeToController(mode); send(trimmed, submitText.trim()); }, - [switchModel, openMemory, syncModeToController, mode, send, runShell, notice, t], + [switchModel, syncModeToController, mode, send, runShell, notice, t], ); const refreshTabMetas = useCallback(async (): Promise => { @@ -1093,30 +1078,6 @@ export default function App() { await renameTopic(topicId, nextTitle); }, [renameTopic, renamingTopicId, topicTitleDraft]); - const onRemember = useCallback( - async (scope: string, note: string) => { - await remember(scope, note); - setMemView(await fetchMemory()); - }, - [remember, fetchMemory], - ); - - const onForget = useCallback( - async (name: string) => { - await forget(name); - setMemView(await fetchMemory()); - }, - [forget, fetchMemory], - ); - - const onSaveDoc = useCallback( - async (path: string, body: string) => { - await saveDoc(path, body); - setMemView(await fetchMemory()); - }, - [saveDoc, fetchMemory], - ); - const sidebarExpandBlocked = false; const sidebarToggleTitle = sidebarCollapsed ? t("sidebar.expand") @@ -1226,13 +1187,13 @@ export default function App() { - - @@ -1240,7 +1201,7 @@ export default function App() { + )} + + {serverGroups.failed.length > 0 && ( + void mutate(() => app.RetryMCPServer(name))} + onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))} + onSetTier={(name, tier) => void mutate(() => app.SetMCPServerTier(name, tier))} + onConfirm={(name) => void mutate(() => app.RemoveMCPServer(name))} + /> + )} + {view.servers.length === 0 && !adding && ( +
{t("caps.noServers")}
+ )} + void mutate(() => app.RemoveMCPServer(name))} + onEdit={(name) => { setEditing(name); }} + onCancelEdit={() => setEditing(null)} + onRetry={(name) => void mutate(() => app.RetryMCPServer(name))} + onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))} + onToggle={(name, on) => void mutate(() => app.SetMCPServerEnabled(name, on))} + onSetTier={(name, tier) => void mutate(() => app.SetMCPServerTier(name, tier))} + onUpdate={(name, input) => + void mutate(() => app.UpdateMCPServer(name, input)).then((ok) => { + if (ok) setEditing(null); + }) + } + onToggleDetails={toggleServer} + onToggleTools={toggleServerTools} + /> + {adding ? ( + setAdding(false)} onAdd={async (input) => (await mutate(() => app.AddMCPServer(input))) && setAdding(false)} /> + ) : null} + + ); +} + +// SkillsSettingsPage is a self-contained skills management page embedded inside +// the settings centre. +export function SkillsSettingsPage() { + const t = useT(); + const [view, setView] = useState(null); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const [skillQuery, setSkillQuery] = useState(""); + const [expandedSkills, setExpandedSkills] = useState>(() => new Set()); + + const reload = useCallback(async () => { + setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] })))); + }, []); + useEffect(() => { void reload(); }, [reload]); + + const mutate = async (fn: () => Promise) => { + setBusy(true); + setErr(null); + try { + await fn(); + await reload(); + return true; + } catch (e) { + setErr(String((e as Error)?.message ?? e)); + await reload(); + return false; + } finally { + setBusy(false); + } + }; + + const filteredSkills = useMemo(() => { + if (!view) return []; + const q = skillQuery.trim().toLowerCase(); + if (!q) return view.skills; + return view.skills.filter((sk) => { + const text = [sk.name, "/" + sk.name, sk.description, sk.scope, sk.runAs].join(" ").toLowerCase(); + return text.includes(q); + }); + }, [view, skillQuery]); + + const skillSummary = useMemo(() => { + if (!view) return ""; + return skillListSummary(view.skills, filteredSkills, skillQuery.trim().length > 0, t); + }, [filteredSkills, skillQuery, t, view]); + + const toggleSkill = useCallback((name: string) => { + setExpandedSkills((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; }); + }, []); + + if (!view) return
{t("caps.loading")}
; + + return ( +
+ {err &&
{err}
} +
+ setSkillQuery(e.target.value)} + /> +
+ mutate(async () => { + const path = await app.PickSkillFolder(); + if (path) await app.AddSkillPath(path); + })} + onRefresh={() => mutate(() => app.RefreshSkills())} + onRemove={(path) => mutate(() => app.RemoveSkillPath(path))} + /> +
+
+
{t("caps.skills")}
+
{skillSummary}
+
+
+ {view.skills.length === 0 ? ( +
{t("caps.noSkills")}
+ ) : filteredSkills.length === 0 ? ( +
{t("caps.noSkillMatches")}
+ ) : ( +
+ {filteredSkills.map((sk) => ( + toggleSkill(sk.name)} + onToggleEnabled={(enabled) => void mutate(() => app.SetSkillEnabled(sk.name, enabled))} + /> + ))} +
+ )} +
+ ); +} diff --git a/desktop/frontend/src/components/MemoryPanel.tsx b/desktop/frontend/src/components/MemoryPanel.tsx index 20c09a9a9..47e4dc997 100644 --- a/desktop/frontend/src/components/MemoryPanel.tsx +++ b/desktop/frontend/src/components/MemoryPanel.tsx @@ -1,5 +1,6 @@ import { ChevronDown, ChevronRight, Search, Trash2 } from "lucide-react"; -import { useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { app } from "../lib/bridge"; import { useT } from "../lib/i18n"; import type { MemoryFact, MemoryView } from "../lib/types"; import { ResizableDrawer } from "./ResizableDrawer"; @@ -28,6 +29,57 @@ function uniqueLinks(body: string, names: Set): LinkInfo[] { return links; } +function memoryScopeLabel(scope: string, t: ReturnType): string { + switch (scope) { + case "project": + return t("memory.scope.project"); + case "user": + return t("memory.scope.user"); + case "local": + return t("memory.scope.local"); + case "ancestor": + return t("memory.scope.ancestor"); + default: + return scope; + } +} + +function memoryDocTitle(scope: string, t: ReturnType): string { + switch (scope) { + case "project": + return t("memory.doc.projectTitle"); + case "user": + return t("memory.doc.userTitle"); + case "local": + return t("memory.doc.localTitle"); + case "ancestor": + return t("memory.doc.ancestorTitle"); + default: + return t("memory.doc.customTitle"); + } +} + +function memoryDocHint(scope: string, t: ReturnType): string { + switch (scope) { + case "project": + return t("memory.doc.projectHint"); + case "user": + return t("memory.doc.userHint"); + case "local": + return t("memory.doc.localHint"); + case "ancestor": + return t("memory.doc.ancestorHint"); + default: + return t("memory.doc.customHint"); + } +} + +function memoryDocPreview(body: string): string { + const lines = body.split(/\r?\n/); + const preview = lines.slice(0, 6).join("\n"); + return lines.length > 6 ? `${preview}\n...` : preview; +} + // MemoryPanel is the desktop memory manager: a right-side drawer over the loaded // REASONIX.md hierarchy and saved auto-memories. Unlike Claude Code's /memory // (which shells out to $EDITOR) it edits docs in place, and unlike Codex (no UI @@ -461,3 +513,473 @@ export function MemoryPanel({ ); } + +// MemorySettingsPage is a self-contained memory management page embedded inside +// the settings centre. It loads its own data and handles all memory operations. +export function MemorySettingsPage() { + const t = useT(); + const [view, setView] = useState(null); + const [note, setNote] = useState(""); + const [scope, setScope] = useState(""); + const [editingPath, setEditingPath] = useState(null); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [highlight, setHighlight] = useState(null); + const [query, setQuery] = useState(""); + const [typeFilter, setTypeFilter] = useState("all"); + const [expanded, setExpanded] = useState(null); + const [expandedDoc, setExpandedDoc] = useState(null); + const [confirmForget, setConfirmForget] = useState(null); + const [tab, setTab] = useState<"memories" | "docs">("memories"); + const [showAdd, setShowAdd] = useState(false); + const factRefs = useRef>({}); + + const reload = useCallback(async () => { + setView(await app.Memory().catch(() => null)); + }, []); + useEffect(() => { void reload(); }, [reload]); + + const facts = view?.facts ?? []; + const factNames = useMemo(() => new Set(facts.map((f) => f.name)), [facts]); + const factTypes = useMemo( + () => Array.from(new Set(facts.map((f) => f.type).filter(Boolean))).sort(), + [facts], + ); + const normalizedQuery = query.trim().toLowerCase(); + const filteredFacts = useMemo( + () => + facts.filter((f) => { + if (typeFilter !== "all" && f.type !== typeFilter) return false; + if (!normalizedQuery) return true; + return [displayTitle(f), f.name, f.description, f.type, f.body] + .join(" ") + .toLowerCase() + .includes(normalizedQuery); + }), + [facts, normalizedQuery, typeFilter], + ); + + const scrollToFact = useCallback((name: string) => { + const el = factRefs.current[name]; + if (!el) return; + el.scrollIntoView({ block: "center", behavior: "auto" }); + setHighlight(name); + window.setTimeout(() => setHighlight((h) => (h === name ? null : h)), 1200); + }, []); + + const jumpTo = useCallback((name: string) => { + if (!factNames.has(name)) return; + const visible = filteredFacts.some((f) => f.name === name); + setExpanded(name); + setConfirmForget(null); + if (!visible) { + setQuery(""); + setTypeFilter("all"); + window.setTimeout(() => scrollToFact(name), 0); + return; + } + scrollToFact(name); + }, [factNames, filteredFacts, scrollToFact]); + + const renderWithLinks = useCallback((text: string): ReactNode[] => { + const out: ReactNode[] = []; + const re = /\[\[([^\]]+)\]\]/g; + let last = 0; + let k = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + if (m.index > last) out.push(text.slice(last, m.index)); + const target = m[1].trim(); + out.push( + factNames.has(target) ? ( + + ) : ( + + {target} + + ), + ); + last = re.lastIndex; + } + if (last < text.length) out.push(text.slice(last)); + return out; + }, [factNames, jumpTo, t]); + + const forgetFact = useCallback(async (name: string) => { + if (busy) return; + setBusy(true); + try { + await app.Forget(name); + await reload(); + if (expanded === name) setExpanded(null); + setConfirmForget(null); + } finally { + setBusy(false); + } + }, [busy, expanded, reload]); + + const scopes = view?.scopes ?? []; + const activeScope = + scope || scopes.find((s) => s.scope === "project")?.scope || scopes[0]?.scope || "project"; + + const submitNote = useCallback(async () => { + const trimmed = note.trim(); + if (!trimmed || busy) return; + setBusy(true); + try { + await app.Remember(activeScope, trimmed); + await reload(); + setNote(""); + setShowAdd(false); + } finally { + setBusy(false); + } + }, [note, busy, activeScope, reload]); + + const startEdit = useCallback((path: string, body: string) => { + setEditingPath(path); + setDraft(body); + }, []); + + const saveEdit = useCallback(async () => { + if (editingPath === null || busy) return; + setBusy(true); + try { + await app.SaveDoc(editingPath, draft); + await reload(); + setEditingPath(null); + } finally { + setBusy(false); + } + }, [editingPath, busy, draft, reload]); + + if (!view?.available) { + return
{t("memory.unavailable")}
; + } + + return ( + <> +
+ + +
+ + {tab === "memories" &&
+
+
+
{t("memory.memoryEntries")}
+
{t("memory.fallibleNote")}
+
+
+ {facts.length} + +
+
+ {showAdd && ( +
+
+
+ {t("memory.addMemory")} + {t("memory.addMemoryHint")} +
+
+
+ + + + setNote(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void submitNote(); + }} + /> + +
+
+ {scopes.find((s) => s.scope === activeScope)?.path} +
+
+ )} +
+ +
+ + {factTypes.map((type) => ( + + ))} +
+
+ {facts.length === 0 ? ( +
{t("memory.noFacts")}
+ ) : filteredFacts.length === 0 ? ( +
+ {t("memory.noMatches")} + +
+ ) : ( +
+ {filteredFacts.map((f) => { + const isOpen = expanded === f.name; + const links = uniqueLinks(f.body, factNames); + const missing = links.filter((link) => !link.exists); + return ( +
{ + factRefs.current[f.name] = el; + }} + > + + {links.length > 0 && ( +
+ {links.map((link) => + link.exists ? ( + + ) : ( + + [[{link.name}]] + + ), + )} +
+ )} + {isOpen && ( +
+ {f.body ? ( +
{renderWithLinks(f.body)}
+ ) : ( +
{t("memory.noBody")}
+ )} + {missing.length > 0 && ( +
+ {t("memory.missingLinks", { n: missing.length })} +
+ )} +
+ + {t("memory.appliesNow")} + + {confirmForget === f.name ? ( +
+ + +
+ ) : ( + + )} +
+
+ )} +
+ ); + })} +
+ )} + {view.storeDir && ( +
{t("memory.storedUnder", { dir: view.storeDir })}
+ )} +
} + + {tab === "docs" &&
+
+
+
{t("memory.instructionFiles")}
+
{t("memory.instructionFilesHint")}
+
+ {view.docs.length} +
+ {view.docs.length === 0 && ( +
{t("memory.noDocs")}
+ )} + {view.docs.map((d) => { + const editing = editingPath === d.path; + const open = expandedDoc === d.path || editing; + return ( +
+
+
+ {memoryScopeLabel(d.scope, t)} +
+ {memoryDocTitle(d.scope, t)} + {d.path} + {memoryDocHint(d.scope, t)} +
+
+
+ {!editing && ( + + )} + {!editing && ( + + )} +
+
+ {editing ? ( +
+