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());
|
||||
|
||||
Reference in New Issue
Block a user