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

@@ -45,6 +45,7 @@ import {
fetchAgentRunAudit,
fetchAgentRunTimeline,
streamChatResponse,
fetchMemoryBackendStatus,
type ProjectInfo,
type ProjectHealth,
type ActivityFeedEntry,
@@ -3341,3 +3342,159 @@ describe("streamChatResponse", () => {
);
});
});
describe("fetchMemoryBackendStatus", () => {
const originalFetch = globalThis.fetch;
const mockBackendStatus = {
currentBackend: "file",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: true,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
};
beforeEach(() => {
globalThis.fetch = vi.fn();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("fetches memory backend status without projectId", async () => {
const { fetchMemoryBackendStatus } = await import("./api");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(mockBackendStatus),
text: () => Promise.resolve(JSON.stringify(mockBackendStatus)),
} as unknown as Response);
const result = await fetchMemoryBackendStatus();
expect(result).toEqual(mockBackendStatus);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0]).toContain("/api/memory/backend");
});
it("fetches memory backend status with projectId", async () => {
const { fetchMemoryBackendStatus } = await import("./api");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(mockBackendStatus),
text: () => Promise.resolve(JSON.stringify(mockBackendStatus)),
} as unknown as Response);
const result = await fetchMemoryBackendStatus("proj_abc");
expect(result).toEqual(mockBackendStatus);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0]).toContain("/api/memory/backend");
expect(call[0]).toContain("projectId=proj_abc");
});
it("throws on error response", async () => {
const { fetchMemoryBackendStatus } = await import("./api");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 500,
statusText: "Internal Server Error",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve({ error: "Server error" }),
text: () => Promise.resolve(JSON.stringify({ error: "Server error" })),
} as unknown as Response);
await expect(fetchMemoryBackendStatus()).rejects.toThrow("Server error");
});
it("handles readonly backend response", async () => {
const { fetchMemoryBackendStatus } = await import("./api");
const readonlyStatus = {
currentBackend: "readonly",
capabilities: {
readable: true,
writable: false,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: false,
},
availableBackends: ["file", "readonly", "qmd"],
};
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(readonlyStatus),
text: () => Promise.resolve(JSON.stringify(readonlyStatus)),
} as unknown as Response);
const result = await fetchMemoryBackendStatus();
expect(result.currentBackend).toBe("readonly");
expect(result.capabilities.writable).toBe(false);
});
it("handles qmd backend response", async () => {
const { fetchMemoryBackendStatus } = await import("./api");
const qmdStatus = {
currentBackend: "qmd",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
};
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(qmdStatus),
text: () => Promise.resolve(JSON.stringify(qmdStatus)),
} as unknown as Response);
const result = await fetchMemoryBackendStatus();
expect(result.currentBackend).toBe("qmd");
expect(result.capabilities.writable).toBe(true);
expect(result.capabilities.supportsAtomicWrite).toBe(false);
});
});

View File

@@ -299,6 +299,37 @@ export function saveMemory(content: string, projectId?: string): Promise<{ succe
});
}
/**
* Memory backend capabilities returned by the backend status API.
*/
export interface MemoryBackendCapabilities {
readable: boolean;
writable: boolean;
supportsAtomicWrite: boolean;
hasConflictResolution: boolean;
persistent: boolean;
}
/**
* Memory backend status response from GET /api/memory/backend
*/
export interface MemoryBackendStatus {
/** The effective backend type after runtime resolution */
currentBackend: string;
/** Capabilities of the effective backend */
capabilities: MemoryBackendCapabilities;
/** List of registered backend types available */
availableBackends: string[];
}
/**
* Fetch the current memory backend status and capabilities.
* Use this to determine which backend is active and what operations it supports.
*/
export function fetchMemoryBackendStatus(projectId?: string): Promise<MemoryBackendStatus> {
return api<MemoryBackendStatus>(withProjectId("/memory/backend", projectId));
}
/** Fetch global (user-level) settings from ~/.pi/fusion/settings.json */
export function fetchGlobalSettings(): Promise<GlobalSettings> {
return api<GlobalSettings>("/settings/global");

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";

View File

@@ -0,0 +1,201 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useMemoryBackendStatus } from "../useMemoryBackendStatus";
import * as api from "../../api";
describe("useMemoryBackendStatus", () => {
const mockFetchMemoryBackendStatus = vi.spyOn(api, "fetchMemoryBackendStatus");
const mockFileBackendStatus: api.MemoryBackendStatus = {
currentBackend: "file",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: true,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
};
const mockReadonlyBackendStatus: api.MemoryBackendStatus = {
currentBackend: "readonly",
capabilities: {
readable: true,
writable: false,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: false,
},
availableBackends: ["file", "readonly", "qmd"],
};
const mockQmdBackendStatus: api.MemoryBackendStatus = {
currentBackend: "qmd",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
};
beforeEach(() => {
mockFetchMemoryBackendStatus.mockClear();
});
it("fetches status on initial mount", async () => {
mockFetchMemoryBackendStatus.mockResolvedValue(mockFileBackendStatus);
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
expect(result.current.loading).toBe(true);
expect(result.current.status).toBeNull();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.status).toEqual(mockFileBackendStatus);
expect(result.current.currentBackend).toBe("file");
expect(result.current.capabilities).toEqual(mockFileBackendStatus.capabilities);
expect(result.current.availableBackends).toEqual(["file", "readonly", "qmd"]);
expect(result.current.error).toBeNull();
expect(result.current.lastUpdated).toBeInstanceOf(Date);
});
it("handles fetch errors", async () => {
mockFetchMemoryBackendStatus.mockRejectedValue(new Error("Failed to connect"));
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("Failed to connect");
expect(result.current.status).toBeNull();
});
it("provides correct capability flags for file backend", async () => {
mockFetchMemoryBackendStatus.mockResolvedValue(mockFileBackendStatus);
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isReadable).toBe(true);
expect(result.current.isWritable).toBe(true);
expect(result.current.supportsAtomicWrite).toBe(true);
});
it("provides correct capability flags for readonly backend", async () => {
mockFetchMemoryBackendStatus.mockResolvedValue(mockReadonlyBackendStatus);
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isReadable).toBe(true);
expect(result.current.isWritable).toBe(false);
expect(result.current.supportsAtomicWrite).toBe(false);
});
it("provides correct capability flags for qmd backend", async () => {
mockFetchMemoryBackendStatus.mockResolvedValue(mockQmdBackendStatus);
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isReadable).toBe(true);
expect(result.current.isWritable).toBe(true);
expect(result.current.supportsAtomicWrite).toBe(false);
});
it("refreshes data manually", async () => {
mockFetchMemoryBackendStatus
.mockResolvedValueOnce(mockFileBackendStatus)
.mockResolvedValueOnce(mockQmdBackendStatus);
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.currentBackend).toBe("file");
// Manual refresh
await result.current.refresh();
await waitFor(() => expect(result.current.currentBackend).toBe("qmd"));
});
it("clears error on successful refresh after error", async () => {
mockFetchMemoryBackendStatus
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockFileBackendStatus);
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("Network error");
// Manual refresh
await result.current.refresh();
await waitFor(() => expect(result.current.error).toBeNull());
expect(result.current.status).toEqual(mockFileBackendStatus);
});
it("passes projectId to API", async () => {
mockFetchMemoryBackendStatus.mockResolvedValue(mockFileBackendStatus);
const { result } = renderHook(() =>
useMemoryBackendStatus({ projectId: "proj_abc", autoRefresh: false }),
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(mockFetchMemoryBackendStatus).toHaveBeenCalledWith("proj_abc");
});
it("returns expected default values before first fetch", () => {
mockFetchMemoryBackendStatus.mockImplementation(() => new Promise(() => {})); // Never resolves
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
expect(result.current.status).toBeNull();
expect(result.current.currentBackend).toBeNull();
expect(result.current.capabilities).toBeNull();
expect(result.current.availableBackends).toEqual([]);
expect(result.current.isReadable).toBe(false);
expect(result.current.isWritable).toBe(false);
expect(result.current.supportsAtomicWrite).toBe(false);
expect(result.current.loading).toBe(true);
expect(result.current.error).toBeNull();
expect(result.current.lastUpdated).toBeNull();
expect(typeof result.current.refresh).toBe("function");
});
it("exports the correct interface", () => {
expect(typeof useMemoryBackendStatus).toBe("function");
});
it("handles null error messages", async () => {
mockFetchMemoryBackendStatus.mockRejectedValue(new Error());
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
await waitFor(() => expect(result.current.loading).toBe(false));
// Error with no message results in empty string
expect(result.current.error).toBe("");
});
it("handles non-Error rejections", async () => {
mockFetchMemoryBackendStatus.mockRejectedValue("string error");
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("Failed to fetch memory backend status");
});
});

View File

@@ -0,0 +1,148 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { fetchMemoryBackendStatus, type MemoryBackendStatus } from "../api";
interface UseMemoryBackendStatusOptions {
/** Project ID for multi-project contexts */
projectId?: string;
/** Auto-refresh interval in ms (default: 60 seconds) */
pollInterval?: number;
/** Whether to auto-refresh (default: false) */
autoRefresh?: boolean;
}
/**
* Hook for fetching memory backend status and capabilities.
*
* Features:
* - Fetches backend status on mount
* - Optional auto-refresh polling
* - Manual refresh capability
* - Loading and error states
* - Guards against stale async updates on unmount/re-render
* - Respects project context via optional projectId
*/
export function useMemoryBackendStatus(options: UseMemoryBackendStatusOptions = {}) {
const { projectId, pollInterval = 60_000, autoRefresh = false } = options;
const [status, setStatus] = useState<MemoryBackendStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const abortRef = useRef<AbortController | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Track if the component is still mounted to prevent stale updates
const mountedRef = useRef(true);
const fetchStatus = useCallback(async (isManual = false) => {
// Cancel any in-flight request
if (abortRef.current) {
abortRef.current.abort();
}
abortRef.current = new AbortController();
if (isManual) {
setLoading(true);
setError(null);
}
try {
const data = await fetchMemoryBackendStatus(projectId);
// Guard against stale updates when component unmounts or project changes
if (!mountedRef.current) {
return;
}
setStatus(data);
setError(null);
setLastUpdated(new Date());
setLoading(false);
} catch (err: unknown) {
// Don't update state if the request was aborted
if (err instanceof Error && err.name === "AbortError") {
return;
}
// Guard against stale updates
if (!mountedRef.current) {
return;
}
const message = err instanceof Error ? err.message : "Failed to fetch memory backend status";
setError(message);
setLoading(false);
}
}, [projectId]);
// Initial fetch
useEffect(() => {
mountedRef.current = true;
fetchStatus(false);
return () => {
mountedRef.current = false;
};
}, [fetchStatus]);
// Auto-refresh polling
useEffect(() => {
if (!autoRefresh) return;
pollRef.current = setInterval(() => {
fetchStatus(false);
}, pollInterval);
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
}, [autoRefresh, pollInterval, fetchStatus]);
// Cleanup on unmount
useEffect(() => {
return () => {
mountedRef.current = false;
if (abortRef.current) {
abortRef.current.abort();
}
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
}, []);
const refresh = useCallback(() => {
return fetchStatus(true);
}, [fetchStatus]);
// Derived convenience getters
const currentBackend = status?.currentBackend ?? null;
const capabilities = status?.capabilities ?? null;
const availableBackends = status?.availableBackends ?? [];
const isReadable = capabilities?.readable ?? false;
const isWritable = capabilities?.writable ?? false;
const supportsAtomicWrite = capabilities?.supportsAtomicWrite ?? false;
return {
// Raw status
status,
// Convenience getters
currentBackend,
capabilities,
availableBackends,
isReadable,
isWritable,
supportsAtomicWrite,
// State
loading,
error,
lastUpdated,
// Actions
refresh,
};
}