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:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
182
packages/dashboard/app/components/__tests__/MemoryView.test.tsx
Normal file
182
packages/dashboard/app/components/__tests__/MemoryView.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user