feat(FN-2544): stabilize memory backend status loading UX

- Reset memory backend status to unresolved during fetches so stale capabilities are never treated as authoritative
- Gate MemoryView read-only and qmd warning states on resolved backend status and add an explicit checking state for qmd availability
- Update SettingsModal memory editor gating so writes remain enabled while backend status is still loading, then enforce resolved read-only state
- Expand dashboard tests for useMemoryBackendStatus, MemoryView, and SettingsModal to lock in loading, warning, and refresh behavior
This commit is contained in:
Fusion
2026-04-25 17:54:32 -07:00
committed by gsxdsm
parent f0e3413b69
commit fb3fdf3895
6 changed files with 328 additions and 20 deletions

View File

@@ -344,6 +344,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
setInsightsEditorContent(null);
}, []);
const backendStatusResolved = !backendLoading && backendStatus !== null;
const isWritable = backendStatus?.capabilities?.writable ?? false;
return (
@@ -397,7 +398,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
{/* Working Memory Tab */}
{activeTab === "working" && (
<div className="memory-working-tab">
{!isWritable && (
{backendStatusResolved && !isWritable && (
<div className="memory-readonly-banner">
This memory backend is read-only. Changes cannot be saved.
</div>
@@ -793,15 +794,15 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
{/* QMD Integration Card */}
<div className="memory-engine-card memory-qmd-card">
<h3>QMD Integration</h3>
{backendStatus?.qmdAvailable ? (
{backendStatus?.qmdAvailable === true ? (
<div className="memory-engine-status">
<span className="memory-health-badge memory-health-badge--healthy">Installed</span>
<span className="memory-char-count">qmd is available on PATH.</span>
</div>
) : (
) : backendStatus?.qmdAvailable === false ? (
<div className="settings-empty-state memory-status-message">
<span>
qmd is not installed. Search will use local files. Install indexed retrieval: <code>{backendStatus?.qmdInstallCommand || "bun install -g @tobilu/qmd"}</code>
qmd is not installed. Search will use local files. Install indexed retrieval: <code>{backendStatus.qmdInstallCommand || "bun install -g @tobilu/qmd"}</code>
</span>
<button
type="button"
@@ -812,6 +813,11 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
{installingQmd ? "Installing…" : "Install qmd"}
</button>
</div>
) : (
<div className="memory-engine-status">
<span className="memory-health-badge">Checking</span>
<span className="memory-char-count">Checking qmd availability</span>
</div>
)}
<div style={{ display: "flex", gap: "var(--space-xs)", marginTop: "var(--space-sm)", flexWrap: "wrap" }}>
{backendStatus?.capabilities?.readable && (

View File

@@ -2543,7 +2543,8 @@ export function SettingsModal({
// Determine if editing is allowed
const isMemoryEnabled = form.memoryEnabled !== false;
const isBackendWritable = capabilities?.writable ?? true;
const backendStatusResolved = !backendLoading && backendStatus !== null;
const isBackendWritable = backendStatusResolved ? (capabilities?.writable ?? true) : true;
const isEditingAllowed = isMemoryEnabled && isBackendWritable;
const selectedMemoryFile = memoryFiles.find((file) => file.path === selectedMemoryPath);
@@ -2588,7 +2589,7 @@ export function SettingsModal({
</div>
) : null}
{backendStatus?.qmdAvailable === false && (
{backendStatusResolved && backendStatus.qmdAvailable === false && (
<div className="settings-empty-state memory-status-message">
<span>
qmd is not installed. Search will use local files.
@@ -2741,7 +2742,7 @@ export function SettingsModal({
Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled.
</div>
)}
{isMemoryEnabled && !isBackendWritable && (
{isMemoryEnabled && backendStatusResolved && !isBackendWritable && (
<div className="settings-empty-state memory-status-message">
Memory is configured with a read-only backend. You can view the file, but saving is disabled.
</div>

View File

@@ -0,0 +1,182 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryView } from "../MemoryView";
const mockUseMemoryData = vi.fn();
vi.mock("../../hooks/useMemoryData", () => ({
useMemoryData: (...args: unknown[]) => mockUseMemoryData(...args),
}));
vi.mock("../FileEditor", () => ({
FileEditor: ({ filePath }: { filePath: string }) => <div aria-label={`Editor for ${filePath}`} />,
}));
vi.mock("lucide-react", () => ({
Loader2: () => <span data-testid="loader-icon" />,
}));
function createMemoryData(overrides: Record<string, unknown> = {}) {
return {
insightsContent: null,
insightsLoading: false,
insightsExists: false,
saveInsights: vi.fn(),
memorySettings: {
memoryEnabled: true,
memoryAutoSummarizeEnabled: false,
memoryAutoSummarizeThresholdChars: 50000,
memoryAutoSummarizeSchedule: "0 3 * * *",
memoryDreamsEnabled: false,
memoryDreamsSchedule: "0 4 * * *",
},
settingsLoading: false,
saveMemorySettings: vi.fn(),
savingMemorySettings: false,
backendStatus: {
currentBackend: "file",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: true,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
qmdInstallCommand: "bun install -g @tobilu/qmd",
},
backendLoading: false,
extractInsights: vi.fn(),
extracting: false,
auditReport: {
health: "healthy",
workingMemory: { size: 120, sectionCount: 2 },
insightsMemory: { size: 80, insightCount: 3 },
extraction: { success: true, summary: "ok", insightCount: 3 },
pruning: { applied: false, reason: "" },
checks: [],
},
auditLoading: false,
refreshAudit: vi.fn(),
compactMemory: vi.fn(),
compacting: false,
installQmdAction: vi.fn(),
installingQmd: false,
testRetrieval: vi.fn(),
memoryFiles: [
{
path: ".fusion/memory/MEMORY.md",
label: "Long-term memory",
layer: "long-term",
size: 12,
updatedAt: "2026-04-17T12:00:00.000Z",
},
],
memoryFilesLoading: false,
selectedFilePath: ".fusion/memory/MEMORY.md",
selectedFileContent: "hello",
selectedFileLoading: false,
selectedFileDirty: false,
setSelectedFileContent: vi.fn(),
selectFile: vi.fn(),
saveSelectedFile: vi.fn(),
savingSelectedFile: false,
...overrides,
};
}
describe("MemoryView", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseMemoryData.mockReturnValue(createMemoryData());
});
it("does not show read-only warning while backend status is still loading", () => {
mockUseMemoryData.mockReturnValue(
createMemoryData({
backendStatus: null,
backendLoading: true,
}),
);
render(<MemoryView addToast={vi.fn()} />);
expect(screen.queryByText("This memory backend is read-only. Changes cannot be saved.")).not.toBeInTheDocument();
});
it("shows read-only warning after backend resolves as non-writable", () => {
mockUseMemoryData.mockReturnValue(
createMemoryData({
backendStatus: {
currentBackend: "readonly",
capabilities: {
readable: true,
writable: false,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: false,
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
},
backendLoading: false,
}),
);
render(<MemoryView addToast={vi.fn()} />);
expect(screen.getByText("This memory backend is read-only. Changes cannot be saved.")).toBeInTheDocument();
});
it("does not show qmd-missing prompt before backend qmd availability resolves", async () => {
mockUseMemoryData.mockReturnValue(
createMemoryData({
backendStatus: null,
backendLoading: false,
auditLoading: false,
}),
);
render(<MemoryView addToast={vi.fn()} />);
await userEvent.click(screen.getByRole("tab", { name: "Engines" }));
expect(screen.queryByText(/qmd is not installed/i)).not.toBeInTheDocument();
expect(screen.getByText("Checking qmd availability…")).toBeInTheDocument();
});
it("shows qmd install prompt only when backend resolves qmd unavailable", async () => {
mockUseMemoryData.mockReturnValue(
createMemoryData({
backendStatus: {
currentBackend: "file",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: true,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: false,
qmdInstallCommand: "bun install -g @tobilu/qmd",
},
}),
);
render(<MemoryView addToast={vi.fn()} />);
await userEvent.click(screen.getByRole("tab", { name: "Engines" }));
expect(screen.getByText(/qmd is not installed\. Search will use local files\./i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Install qmd" })).toBeInTheDocument();
});
it("shows qmd installed state when backend resolves qmd as available", async () => {
render(<MemoryView addToast={vi.fn()} />);
await userEvent.click(screen.getByRole("tab", { name: "Engines" }));
expect(screen.getByText("Installed")).toBeInTheDocument();
expect(screen.getByText("qmd is available on PATH.")).toBeInTheDocument();
});
});

View File

@@ -658,6 +658,87 @@ describe("SettingsModal", () => {
expect(checkbox).toBeChecked();
});
it("shows only loading copy while backend status is unresolved", async () => {
mockUseMemoryBackendStatus.mockReturnValue({
// Simulate stale negative payload while a refresh is still in-flight.
status: {
currentBackend: "readonly",
capabilities: {
readable: true,
writable: false,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: false,
qmdInstallCommand: "bun install -g @tobilu/qmd",
},
currentBackend: "readonly",
capabilities: {
readable: true,
writable: false,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
loading: true,
error: null,
refresh: vi.fn(),
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Memory"));
expect(screen.getByText("Checking memory write access...")).toBeInTheDocument();
expect(screen.queryByText(/qmd is not installed\. Search will use local files\./i)).not.toBeInTheDocument();
expect(screen.queryByText(/Memory is configured with a read-only backend\./i)).not.toBeInTheDocument();
});
it("shows read-only warning after backend resolves as non-writable", async () => {
mockUseMemoryBackendStatus.mockReturnValue({
status: {
currentBackend: "readonly",
capabilities: {
readable: true,
writable: false,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
qmdInstallCommand: "bun install -g @tobilu/qmd",
},
currentBackend: "readonly",
capabilities: {
readable: true,
writable: false,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
loading: false,
error: null,
refresh: vi.fn(),
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Memory"));
expect(screen.getByText(/Memory is configured with a read-only backend\./i)).toBeInTheDocument();
});
it("installs qmd from the missing qmd prompt", async () => {
const addToast = vi.fn();
const refresh = vi.fn(() => Promise.resolve());

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useMemoryBackendStatus } from "../useMemoryBackendStatus";
import * as api from "../../api";
@@ -127,6 +127,41 @@ describe("useMemoryBackendStatus", () => {
await waitFor(() => expect(result.current.currentBackend).toBe("qmd"));
});
it("treats backend status as unknown while a refresh is in flight", async () => {
let resolveRefresh: ((value: api.MemoryBackendStatus) => void) | null = null;
mockFetchMemoryBackendStatus
.mockResolvedValueOnce(mockReadonlyBackendStatus)
.mockImplementationOnce(
() => new Promise<api.MemoryBackendStatus>((resolve) => {
resolveRefresh = resolve;
}),
);
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isWritable).toBe(false);
expect(result.current.status).toEqual(mockReadonlyBackendStatus);
act(() => {
void result.current.refresh();
});
await waitFor(() => expect(result.current.loading).toBe(true));
expect(result.current.status).toBeNull();
expect(result.current.capabilities).toBeNull();
expect(result.current.isWritable).toBe(false);
act(() => {
resolveRefresh?.(mockFileBackendStatus);
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.status).toEqual(mockFileBackendStatus);
expect(result.current.isWritable).toBe(true);
});
it("clears error on successful refresh after error", async () => {
mockFetchMemoryBackendStatus
.mockRejectedValueOnce(new Error("Network error"))

View File

@@ -34,17 +34,18 @@ export function useMemoryBackendStatus(options: UseMemoryBackendStatusOptions =
// Track if the component is still mounted to prevent stale updates
const mountedRef = useRef(true);
const fetchStatus = useCallback(async (isManual = false) => {
const fetchStatus = useCallback(async () => {
// Cancel any in-flight request
if (abortRef.current) {
abortRef.current.abort();
}
abortRef.current = new AbortController();
if (isManual) {
setLoading(true);
setError(null);
}
// Always reset to unresolved while a new request is in flight so stale
// capabilities are never treated as authoritative.
setLoading(true);
setError(null);
setStatus(null);
try {
const data = await fetchMemoryBackendStatus(projectId);
@@ -78,7 +79,7 @@ export function useMemoryBackendStatus(options: UseMemoryBackendStatusOptions =
// Initial fetch
useEffect(() => {
mountedRef.current = true;
fetchStatus(false);
fetchStatus();
return () => {
mountedRef.current = false;
@@ -90,7 +91,7 @@ export function useMemoryBackendStatus(options: UseMemoryBackendStatusOptions =
if (!autoRefresh) return;
pollRef.current = setInterval(() => {
fetchStatus(false);
fetchStatus();
}, pollInterval);
return () => {
@@ -116,13 +117,15 @@ export function useMemoryBackendStatus(options: UseMemoryBackendStatusOptions =
}, []);
const refresh = useCallback(() => {
return fetchStatus(true);
return fetchStatus();
}, [fetchStatus]);
// Derived convenience getters
const currentBackend = status?.currentBackend ?? null;
const capabilities = status?.capabilities ?? null;
const availableBackends = status?.availableBackends ?? [];
// Hide stale status while loading to preserve unknown/loading semantics.
const resolvedStatus = loading ? null : status;
const currentBackend = resolvedStatus?.currentBackend ?? null;
const capabilities = resolvedStatus?.capabilities ?? null;
const availableBackends = resolvedStatus?.availableBackends ?? [];
const isReadable = capabilities?.readable ?? false;
const isWritable = capabilities?.writable ?? false;
@@ -130,7 +133,7 @@ export function useMemoryBackendStatus(options: UseMemoryBackendStatusOptions =
return {
// Raw status
status,
status: resolvedStatus,
// Convenience getters
currentBackend,
capabilities,