feat(FN-1771): add dashboard memory settings UX with backend selector and capability-aware editing

- Add /api/memory/backend endpoint returning current backend, capabilities, and available backends
- Create useMemoryBackendStatus hook with polling and frontend-friendly interface
- Add MemorySettingsSection to SettingsModal with backend selector dropdown
- Implement capability-aware UI: readonly backends disable save, fallback on unknown types
- Add comprehensive tests for API endpoints and hook behavior
- Update architecture docs with memory backend architecture section
This commit is contained in:
gsxdsm
2026-04-13 22:32:31 -07:00
parent f6ae0e2e82
commit f72a3a39db
11 changed files with 750 additions and 6 deletions

View File

@@ -21,6 +21,7 @@ const mockFetchMemory = vi.fn();
const mockSaveMemory = vi.fn();
const mockFetchGlobalConcurrency = vi.fn();
const mockUpdateGlobalConcurrency = vi.fn();
const mockFetchMemoryBackendStatus = vi.fn();
vi.mock("../api", () => ({
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
@@ -39,6 +40,13 @@ vi.mock("../api", () => ({
saveMemory: (...args: unknown[]) => mockSaveMemory(...args),
fetchGlobalConcurrency: (...args: unknown[]) => mockFetchGlobalConcurrency(...args),
updateGlobalConcurrency: (...args: unknown[]) => mockUpdateGlobalConcurrency(...args),
fetchMemoryBackendStatus: (...args: unknown[]) => mockFetchMemoryBackendStatus(...args),
}));
// Mock the hook
const mockUseMemoryBackendStatus = vi.fn();
vi.mock("../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
}));
const noop = () => {};
@@ -79,6 +87,31 @@ describe("SettingsModal", () => {
mockSaveMemory.mockResolvedValue({ success: true });
mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentUsage: 0 });
mockUpdateGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentUsage: 0 });
mockFetchMemoryBackendStatus.mockResolvedValue({
currentBackend: "file",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: true,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
});
mockUseMemoryBackendStatus.mockReturnValue({
currentBackend: "file",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: true,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
loading: false,
error: null,
refresh: vi.fn(),
});
// jsdom doesn't provide URL.createObjectURL — polyfill it
if (!URL.createObjectURL) {

View File

@@ -3,7 +3,8 @@ import { Globe, Folder } from "lucide-react";
import { THINKING_LEVELS, PROMPT_KEY_CATALOG, isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent } from "@fusion/core";
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemory, saveMemory, fetchGlobalConcurrency, updateGlobalConcurrency } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector";
import { CustomModelDropdown } from "./CustomModelDropdown";
@@ -1640,7 +1641,29 @@ export function SettingsModal({
</div>
</>
);
case "memory":
case "memory": {
// Fetch backend status when memory section is active
const {
currentBackend,
capabilities,
availableBackends,
loading: backendLoading,
error: backendError,
refresh: refreshBackendStatus,
} = useMemoryBackendStatus({ projectId });
// Determine if editing is allowed
const isMemoryEnabled = form.memoryEnabled !== false;
const isBackendWritable = capabilities?.writable ?? true;
const isEditingAllowed = isMemoryEnabled && isBackendWritable;
// Backend display names
const backendNames: Record<string, string> = {
file: "File (.fusion/memory.md)",
readonly: "Read-Only",
qmd: "QMD (Quantized Memory Distillation)",
};
return (
<>
{renderScopeBanner()}
@@ -1660,17 +1683,71 @@ export function SettingsModal({
<small>When enabled, agents will consult and update .fusion/memory.md with durable project learnings</small>
</div>
{/* Backend type selector */}
<div className="form-group">
<label htmlFor="memoryBackendType">Memory Backend</label>
<select
id="memoryBackendType"
value={form.memoryBackendType || "file"}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({
...f,
memoryBackendType: value === "file" ? undefined : value,
}));
}}
disabled={!isMemoryEnabled}
>
{availableBackends.map((backend) => (
<option key={backend} value={backend}>
{backendNames[backend] || backend}
</option>
))}
</select>
<small>Choose how project memory is stored. File backend uses .fusion/memory.md. QMD enables advanced memory features.</small>
</div>
{/* Backend capabilities info */}
{backendLoading ? (
<div className="form-group">
<small className="settings-muted">Loading backend status...</small>
</div>
) : backendError ? (
<div className="form-group">
<small className="field-error">Failed to load backend status: {backendError}</small>
</div>
) : currentBackend ? (
<div className="form-group">
<small className="settings-muted">
Current backend: <strong>{backendNames[currentBackend] || currentBackend}</strong>
{!isBackendWritable && " (read-only)"}
</small>
{isBackendWritable && capabilities && (
<small className="settings-muted" style={{ display: "block", marginTop: "2px" }}>
Supports: {capabilities.readable ? "read" : ""}{capabilities.readable && capabilities.writable ? ", " : ""}{capabilities.writable ? "write" : ""}{capabilities.supportsAtomicWrite ? ", atomic writes" : ""}{capabilities.persistent ? ", persistent" : ""}
</small>
)}
</div>
) : null}
<div style={{ borderTop: "1px solid var(--border)", margin: "var(--space-lg) 0" }} />
<div className="form-group">
<small>This file stores durable project learnings that agents consult during triage and execution.</small>
</div>
{form.memoryEnabled === false && (
{/* Read-only state warnings */}
{!isMemoryEnabled && (
<div className="settings-empty-state" style={{ marginBottom: "var(--space-md)" }}>
Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled.
</div>
)}
{isMemoryEnabled && !isBackendWritable && (
<div className="settings-empty-state" style={{ marginBottom: "var(--space-md)" }}>
The selected backend ({backendNames[currentBackend || "file"] || currentBackend || "file"}) is read-only.
You can view the file, but saving is disabled. Select a writable backend (File or QMD) to enable editing.
</div>
)}
{memoryLoading ? (
<div className="settings-empty-state">Loading memory</div>
@@ -1692,14 +1769,14 @@ export function SettingsModal({
setMemoryContent(content);
setMemoryDirty(true);
}}
readOnly={form.memoryEnabled === false}
readOnly={!isEditingAllowed}
filePath=".fusion/memory.md"
/>
</div>
</div>
)}
{memoryDirty && (
{memoryDirty && isEditingAllowed && (
<div className="form-group">
<button
type="button"
@@ -1710,8 +1787,14 @@ export function SettingsModal({
</button>
</div>
)}
{memoryDirty && !isEditingAllowed && (
<div className="form-group">
<small className="field-error">Cannot save: {isMemoryEnabled ? "Backend is read-only" : "Memory is disabled"}</small>
</div>
)}
</>
);
}
case "backups":
return (
<>

View File

@@ -52,6 +52,34 @@ vi.mock("../../api", () => ({
saveMemory: vi.fn(() => Promise.resolve({ success: true })),
fetchGlobalConcurrency: vi.fn(() => Promise.resolve({ globalMaxConcurrent: 4, currentUsage: 0 })),
updateGlobalConcurrency: vi.fn(() => Promise.resolve({ globalMaxConcurrent: 4, currentUsage: 0 })),
fetchMemoryBackendStatus: vi.fn(() => Promise.resolve({
currentBackend: "file",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: true,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
})),
}));
vi.mock("../../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: vi.fn(() => ({
currentBackend: "file",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: true,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
loading: false,
error: null,
refresh: vi.fn(),
})),
}));
import { fetchSettings } from "../../api";