feat(FN-2732): add manual dream processing actions in dashboard
- Extend useMemoryData with triggerDreamNow and dreamRunning state by locating and invoking the Memory Dreams automation - Add Dream Now actions to MemoryView and SettingsModal memory settings with loading UI, success/error toasts, and file refresh in MemoryView - Expand hook and component tests to cover dream-trigger visibility and execution flows in both views - Update terminal mobile keyboard layout CSS contract expectations for the current mobile modal width and min-height rules
This commit is contained in:
@@ -125,9 +125,9 @@ describe("terminal mobile keyboard layout CSS contract", () => {
|
|||||||
return match?.[1] ?? "";
|
return match?.[1] ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
it("sets width: 100% on mobile", () => {
|
it("sets width: 100vw on mobile", () => {
|
||||||
const ruleBody = findMobileTerminalModalRule();
|
const ruleBody = findMobileTerminalModalRule();
|
||||||
expect(ruleBody).toContain("width: 100%");
|
expect(ruleBody).toContain("width: 100vw");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets height: 100dvh on mobile", () => {
|
it("sets height: 100dvh on mobile", () => {
|
||||||
@@ -140,9 +140,9 @@ describe("terminal mobile keyboard layout CSS contract", () => {
|
|||||||
expect(ruleBody).toContain("max-height: 100dvh");
|
expect(ruleBody).toContain("max-height: 100dvh");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets min-height: 100dvh on mobile", () => {
|
it("resets min-height constraint on mobile", () => {
|
||||||
const ruleBody = findMobileTerminalModalRule();
|
const ruleBody = findMobileTerminalModalRule();
|
||||||
expect(ruleBody).toContain("min-height: 100dvh");
|
expect(ruleBody).toContain("min-height: 0");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,9 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
|||||||
selectFile,
|
selectFile,
|
||||||
saveSelectedFile,
|
saveSelectedFile,
|
||||||
savingSelectedFile,
|
savingSelectedFile,
|
||||||
|
reloadMemoryFiles,
|
||||||
|
triggerDreamNow,
|
||||||
|
dreamRunning,
|
||||||
} = useMemoryData({ projectId });
|
} = useMemoryData({ projectId });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -299,6 +302,16 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
|||||||
}
|
}
|
||||||
}, [memoryTestQuery, testRetrieval, addToast]);
|
}, [memoryTestQuery, testRetrieval, addToast]);
|
||||||
|
|
||||||
|
const handleDreamNow = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await triggerDreamNow();
|
||||||
|
addToast("Dream processing completed", "success");
|
||||||
|
await reloadMemoryFiles();
|
||||||
|
} catch (error) {
|
||||||
|
addToast(error instanceof Error ? error.message : "Failed to run dream processing", "error");
|
||||||
|
}
|
||||||
|
}, [triggerDreamNow, reloadMemoryFiles, addToast]);
|
||||||
|
|
||||||
// Handle compact memory
|
// Handle compact memory
|
||||||
const handleCompactMemory = useCallback(async () => {
|
const handleCompactMemory = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -521,24 +534,44 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{memorySettingsDraft.memoryEnabled && memorySettingsDraft.memoryDreamsEnabled && (
|
{memorySettingsDraft.memoryEnabled && memorySettingsDraft.memoryDreamsEnabled && (
|
||||||
<div className="form-group">
|
<>
|
||||||
<label htmlFor="memoryDreamsSchedule">Dream Schedule</label>
|
<div className="form-group">
|
||||||
<input
|
<label htmlFor="memoryDreamsSchedule">Dream Schedule</label>
|
||||||
id="memoryDreamsSchedule"
|
<input
|
||||||
type="text"
|
id="memoryDreamsSchedule"
|
||||||
className="input"
|
type="text"
|
||||||
value={memorySettingsDraft.memoryDreamsSchedule}
|
className="input"
|
||||||
onChange={(event) => {
|
value={memorySettingsDraft.memoryDreamsSchedule}
|
||||||
setMemorySettingsDraft((prev) => ({
|
onChange={(event) => {
|
||||||
...prev,
|
setMemorySettingsDraft((prev) => ({
|
||||||
memoryDreamsSchedule: event.target.value,
|
...prev,
|
||||||
}));
|
memoryDreamsSchedule: event.target.value,
|
||||||
}}
|
}));
|
||||||
placeholder="0 4 * * *"
|
}}
|
||||||
disabled={settingsLoading}
|
placeholder="0 4 * * *"
|
||||||
/>
|
disabled={settingsLoading}
|
||||||
<small>Cron expression for dream processing.</small>
|
/>
|
||||||
</div>
|
<small>Cron expression for dream processing.</small>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={handleDreamNow}
|
||||||
|
disabled={dreamRunning || !memorySettingsDraft.memoryDreamsEnabled}
|
||||||
|
>
|
||||||
|
{dreamRunning ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
Dreaming…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Dream Now"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<small>Manually trigger dream processing now.</small>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEvent } from "react";
|
import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEvent } from "react";
|
||||||
import { Globe, Folder, RefreshCw, Star, HelpCircle } from "lucide-react";
|
import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2 } from "lucide-react";
|
||||||
import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core";
|
import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core";
|
||||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
|
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
|
||||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, activateRemoteProvider, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
|
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, fetchAutomations, runAutomation, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, activateRemoteProvider, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
|
||||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
|
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
|
||||||
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
@@ -415,6 +415,7 @@ export function SettingsModal({
|
|||||||
const [memoryTestQuery, setMemoryTestQuery] = useState("");
|
const [memoryTestQuery, setMemoryTestQuery] = useState("");
|
||||||
const [memoryTestLoading, setMemoryTestLoading] = useState(false);
|
const [memoryTestLoading, setMemoryTestLoading] = useState(false);
|
||||||
const [memoryTestResult, setMemoryTestResult] = useState<MemoryRetrievalTestResult | null>(null);
|
const [memoryTestResult, setMemoryTestResult] = useState<MemoryRetrievalTestResult | null>(null);
|
||||||
|
const [dreamRunning, setDreamRunning] = useState(false);
|
||||||
const [memoryCompactLoading, setMemoryCompactLoading] = useState(false);
|
const [memoryCompactLoading, setMemoryCompactLoading] = useState(false);
|
||||||
const [qmdInstallLoading, setQmdInstallLoading] = useState(false);
|
const [qmdInstallLoading, setQmdInstallLoading] = useState(false);
|
||||||
const skipNextMemoryReloadRef = useRef(false);
|
const skipNextMemoryReloadRef = useRef(false);
|
||||||
@@ -1343,6 +1344,23 @@ export function SettingsModal({
|
|||||||
}
|
}
|
||||||
}, [memoryTestQuery, projectId, addToast]);
|
}, [memoryTestQuery, projectId, addToast]);
|
||||||
|
|
||||||
|
const handleDreamNow = useCallback(async () => {
|
||||||
|
setDreamRunning(true);
|
||||||
|
try {
|
||||||
|
const automations = await fetchAutomations({ scope: "project", projectId });
|
||||||
|
const memoryDreamsSchedule = automations.find((automation) => automation.name === "Memory Dreams");
|
||||||
|
if (!memoryDreamsSchedule) {
|
||||||
|
throw new Error("Memory Dreams schedule not found. Enable dream processing in memory settings first.");
|
||||||
|
}
|
||||||
|
await runAutomation(memoryDreamsSchedule.id, { scope: "project", projectId });
|
||||||
|
addToast("Dream processing completed", "success");
|
||||||
|
} catch (error) {
|
||||||
|
addToast(error instanceof Error ? error.message : "Failed to run dream processing", "error");
|
||||||
|
} finally {
|
||||||
|
setDreamRunning(false);
|
||||||
|
}
|
||||||
|
}, [projectId, addToast]);
|
||||||
|
|
||||||
const handleInstallQmd = useCallback(async () => {
|
const handleInstallQmd = useCallback(async () => {
|
||||||
setQmdInstallLoading(true);
|
setQmdInstallLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -3080,18 +3098,38 @@ export function SettingsModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMemoryEnabled && form.memoryDreamsEnabled === true && (
|
{isMemoryEnabled && form.memoryDreamsEnabled === true && (
|
||||||
<div className="form-group">
|
<>
|
||||||
<label htmlFor="memoryDreamsSchedule">Dream Schedule</label>
|
<div className="form-group">
|
||||||
<input
|
<label htmlFor="memoryDreamsSchedule">Dream Schedule</label>
|
||||||
id="memoryDreamsSchedule"
|
<input
|
||||||
type="text"
|
id="memoryDreamsSchedule"
|
||||||
value={form.memoryDreamsSchedule ?? "0 4 * * *"}
|
type="text"
|
||||||
onChange={(e) =>
|
value={form.memoryDreamsSchedule ?? "0 4 * * *"}
|
||||||
setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value }))
|
onChange={(e) =>
|
||||||
}
|
setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value }))
|
||||||
/>
|
}
|
||||||
<small>Cron expression for dream processing.</small>
|
/>
|
||||||
</div>
|
<small>Cron expression for dream processing.</small>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={handleDreamNow}
|
||||||
|
disabled={dreamRunning || form.memoryDreamsEnabled !== true}
|
||||||
|
>
|
||||||
|
{dreamRunning ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
Dreaming…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Dream Now"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<small>Manually trigger dream processing now.</small>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="memory-retrieval-test">
|
<div className="memory-retrieval-test">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { MemoryView } from "../MemoryView";
|
import { MemoryView } from "../MemoryView";
|
||||||
|
|
||||||
@@ -83,6 +83,9 @@ function createMemoryData(overrides: Record<string, unknown> = {}) {
|
|||||||
selectFile: vi.fn(),
|
selectFile: vi.fn(),
|
||||||
saveSelectedFile: vi.fn(),
|
saveSelectedFile: vi.fn(),
|
||||||
savingSelectedFile: false,
|
savingSelectedFile: false,
|
||||||
|
reloadMemoryFiles: vi.fn(),
|
||||||
|
triggerDreamNow: vi.fn(),
|
||||||
|
dreamRunning: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -179,4 +182,59 @@ describe("MemoryView", () => {
|
|||||||
expect(screen.getByText("Installed")).toBeInTheDocument();
|
expect(screen.getByText("Installed")).toBeInTheDocument();
|
||||||
expect(screen.getByText("qmd is available on PATH.")).toBeInTheDocument();
|
expect(screen.getByText("qmd is available on PATH.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows Dream Now button when dreams are enabled", () => {
|
||||||
|
mockUseMemoryData.mockReturnValue(
|
||||||
|
createMemoryData({
|
||||||
|
memorySettings: {
|
||||||
|
memoryEnabled: true,
|
||||||
|
memoryAutoSummarizeEnabled: false,
|
||||||
|
memoryAutoSummarizeThresholdChars: 50000,
|
||||||
|
memoryAutoSummarizeSchedule: "0 3 * * *",
|
||||||
|
memoryDreamsEnabled: true,
|
||||||
|
memoryDreamsSchedule: "0 4 * * *",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<MemoryView addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Dream Now" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers dream processing and refreshes memory files", async () => {
|
||||||
|
const triggerDreamNow = vi.fn().mockResolvedValue({});
|
||||||
|
const reloadMemoryFiles = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const addToast = vi.fn();
|
||||||
|
mockUseMemoryData.mockReturnValue(
|
||||||
|
createMemoryData({
|
||||||
|
triggerDreamNow,
|
||||||
|
reloadMemoryFiles,
|
||||||
|
memorySettings: {
|
||||||
|
memoryEnabled: true,
|
||||||
|
memoryAutoSummarizeEnabled: false,
|
||||||
|
memoryAutoSummarizeThresholdChars: 50000,
|
||||||
|
memoryAutoSummarizeSchedule: "0 3 * * *",
|
||||||
|
memoryDreamsEnabled: true,
|
||||||
|
memoryDreamsSchedule: "0 4 * * *",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<MemoryView addToast={addToast} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Dream Now" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(triggerDreamNow).toHaveBeenCalledTimes(1);
|
||||||
|
expect(reloadMemoryFiles).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Dream processing completed", "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dream Now button when dreams are disabled", () => {
|
||||||
|
render(<MemoryView addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: "Dream Now" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const mockRegenerateRemotePersistentToken = vi.fn();
|
|||||||
const mockGenerateShortLivedRemoteToken = vi.fn();
|
const mockGenerateShortLivedRemoteToken = vi.fn();
|
||||||
const mockFetchRemoteQr = vi.fn();
|
const mockFetchRemoteQr = vi.fn();
|
||||||
const mockFetchRemoteUrl = vi.fn();
|
const mockFetchRemoteUrl = vi.fn();
|
||||||
|
const mockFetchAutomations = vi.fn();
|
||||||
|
const mockRunAutomation = vi.fn();
|
||||||
const mockUseWorkspaceFileBrowser = vi.fn();
|
const mockUseWorkspaceFileBrowser = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
@@ -78,6 +80,8 @@ vi.mock("../../api", () => ({
|
|||||||
generateShortLivedRemoteToken: (...args: unknown[]) => mockGenerateShortLivedRemoteToken(...args),
|
generateShortLivedRemoteToken: (...args: unknown[]) => mockGenerateShortLivedRemoteToken(...args),
|
||||||
fetchRemoteQr: (...args: unknown[]) => mockFetchRemoteQr(...args),
|
fetchRemoteQr: (...args: unknown[]) => mockFetchRemoteQr(...args),
|
||||||
fetchRemoteUrl: (...args: unknown[]) => mockFetchRemoteUrl(...args),
|
fetchRemoteUrl: (...args: unknown[]) => mockFetchRemoteUrl(...args),
|
||||||
|
fetchAutomations: (...args: unknown[]) => mockFetchAutomations(...args),
|
||||||
|
runAutomation: (...args: unknown[]) => mockRunAutomation(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the hook
|
// Mock the hook
|
||||||
@@ -95,6 +99,7 @@ vi.mock("lucide-react", async (importOriginal) => {
|
|||||||
RefreshCw: ({ className }: { className?: string }) => <span data-testid="icon-refresh" className={className} />,
|
RefreshCw: ({ className }: { className?: string }) => <span data-testid="icon-refresh" className={className} />,
|
||||||
Star: ({ size }: { size?: number }) => <span data-testid="icon-star" style={{ width: size, height: size }} />,
|
Star: ({ size }: { size?: number }) => <span data-testid="icon-star" style={{ width: size, height: size }} />,
|
||||||
HelpCircle: ({ size }: { size?: number }) => <span data-testid="icon-help-circle" style={{ width: size, height: size }} />,
|
HelpCircle: ({ size }: { size?: number }) => <span data-testid="icon-help-circle" style={{ width: size, height: size }} />,
|
||||||
|
Loader2: ({ className }: { className?: string }) => <span data-testid="icon-loader2" className={className} />,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,6 +268,8 @@ describe("SettingsModal", () => {
|
|||||||
mockGenerateShortLivedRemoteToken.mockResolvedValue({ token: "short", expiresAt: new Date(Date.now() + 60000).toISOString(), ttlMs: 60000 });
|
mockGenerateShortLivedRemoteToken.mockResolvedValue({ token: "short", expiresAt: new Date(Date.now() + 60000).toISOString(), ttlMs: 60000 });
|
||||||
mockFetchRemoteQr.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null, format: "image/svg", data: "<svg></svg>" });
|
mockFetchRemoteQr.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null, format: "image/svg", data: "<svg></svg>" });
|
||||||
mockFetchRemoteUrl.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null });
|
mockFetchRemoteUrl.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null });
|
||||||
|
mockFetchAutomations.mockResolvedValue([]);
|
||||||
|
mockRunAutomation.mockResolvedValue({ schedule: { id: "auto-2", name: "Memory Dreams" }, result: { success: true } });
|
||||||
mockUseWorkspaceFileBrowser.mockReturnValue({
|
mockUseWorkspaceFileBrowser.mockReturnValue({
|
||||||
entries: [],
|
entries: [],
|
||||||
currentPath: ".",
|
currentPath: ".",
|
||||||
@@ -1716,4 +1723,56 @@ describe("SettingsModal", () => {
|
|||||||
expect(screen.getByText("https://remote.example.com/qr-text")).toBeInTheDocument();
|
expect(screen.getByText("https://remote.example.com/qr-text")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("memory dream trigger", () => {
|
||||||
|
const openMemorySection = async () => {
|
||||||
|
const [memorySectionButton] = await screen.findAllByRole("button", { name: /^Memory$/i });
|
||||||
|
await userEvent.click(memorySectionButton);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("shows Dream Now button when dreams are enabled", async () => {
|
||||||
|
mockFetchSettings.mockResolvedValueOnce({
|
||||||
|
...defaultSettings,
|
||||||
|
memoryEnabled: true,
|
||||||
|
memoryDreamsEnabled: true,
|
||||||
|
memoryDreamsSchedule: "0 4 * * *",
|
||||||
|
});
|
||||||
|
|
||||||
|
renderModal();
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
await openMemorySection();
|
||||||
|
|
||||||
|
expect(await screen.findByRole("button", { name: "Dream Now" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers dream processing from Dream Now button", async () => {
|
||||||
|
const addToast = vi.fn();
|
||||||
|
mockFetchSettings.mockResolvedValueOnce({
|
||||||
|
...defaultSettings,
|
||||||
|
memoryEnabled: true,
|
||||||
|
memoryDreamsEnabled: true,
|
||||||
|
});
|
||||||
|
mockFetchAutomations.mockResolvedValueOnce([{ id: "auto-2", name: "Memory Dreams" }]);
|
||||||
|
|
||||||
|
renderModal({ addToast });
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
await openMemorySection();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Dream Now" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project", projectId: undefined });
|
||||||
|
expect(mockRunAutomation).toHaveBeenCalledWith("auto-2", { scope: "project", projectId: undefined });
|
||||||
|
});
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Dream processing completed", "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dream Now button when dreams are disabled", async () => {
|
||||||
|
renderModal();
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
await openMemorySection();
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: "Dream Now" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ vi.mock("../../api", () => ({
|
|||||||
fetchMemoryStats: vi.fn(),
|
fetchMemoryStats: vi.fn(),
|
||||||
compactMemory: vi.fn(),
|
compactMemory: vi.fn(),
|
||||||
fetchMemoryBackendStatus: vi.fn(),
|
fetchMemoryBackendStatus: vi.fn(),
|
||||||
|
fetchSettings: vi.fn(),
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
fetchMemoryFiles: vi.fn(),
|
||||||
|
fetchMemoryFile: vi.fn(),
|
||||||
|
saveMemoryFile: vi.fn(),
|
||||||
|
installQmd: vi.fn(),
|
||||||
|
testMemoryRetrieval: vi.fn(),
|
||||||
|
fetchAutomations: vi.fn(),
|
||||||
|
runAutomation: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock useMemoryBackendStatus hook
|
// Mock useMemoryBackendStatus hook
|
||||||
@@ -42,6 +51,8 @@ import {
|
|||||||
triggerInsightExtraction,
|
triggerInsightExtraction,
|
||||||
fetchMemoryAudit,
|
fetchMemoryAudit,
|
||||||
compactMemory,
|
compactMemory,
|
||||||
|
fetchAutomations,
|
||||||
|
runAutomation,
|
||||||
} from "../../api";
|
} from "../../api";
|
||||||
|
|
||||||
describe("useMemoryData", () => {
|
describe("useMemoryData", () => {
|
||||||
@@ -273,6 +284,69 @@ describe("useMemoryData", () => {
|
|||||||
expect(fetchMemoryInsights).toHaveBeenCalledTimes(2); // Initial + refresh
|
expect(fetchMemoryInsights).toHaveBeenCalledTimes(2); // Initial + refresh
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("triggerDreamNow finds and runs the Memory Dreams automation", async () => {
|
||||||
|
vi.mocked(fetchMemory).mockResolvedValue({ content: "Initial content" });
|
||||||
|
vi.mocked(fetchMemoryInsights).mockResolvedValue({ content: null, exists: false });
|
||||||
|
vi.mocked(fetchMemoryAudit).mockResolvedValue({
|
||||||
|
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
workingMemory: { exists: true, size: 20, sectionCount: 1 },
|
||||||
|
insightsMemory: { exists: false, size: 0, insightCount: 0, categories: {} },
|
||||||
|
extraction: { runAt: "", success: false, insightCount: 0, duplicateCount: 0, skippedCount: 0, summary: "" },
|
||||||
|
pruning: { applied: false, reason: "", sizeDelta: 0, originalSize: 0, newSize: 0 },
|
||||||
|
checks: [],
|
||||||
|
health: "warning",
|
||||||
|
});
|
||||||
|
vi.mocked(fetchAutomations).mockResolvedValue([
|
||||||
|
{ id: "auto-1", name: "Other", cron: "* * * * *", enabled: true },
|
||||||
|
{ id: "auto-2", name: "Memory Dreams", cron: "0 4 * * *", enabled: true },
|
||||||
|
] as any);
|
||||||
|
vi.mocked(runAutomation).mockResolvedValue({ schedule: { id: "auto-2" }, result: { success: true } } as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.workingMemoryLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.triggerDreamNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchAutomations).toHaveBeenCalledWith({ scope: "project", projectId: "test-project" });
|
||||||
|
expect(runAutomation).toHaveBeenCalledWith("auto-2", { scope: "project", projectId: "test-project" });
|
||||||
|
expect(result.current.dreamRunning).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggerDreamNow throws when Memory Dreams schedule is missing and resets state", async () => {
|
||||||
|
vi.mocked(fetchMemory).mockResolvedValue({ content: "Initial content" });
|
||||||
|
vi.mocked(fetchMemoryInsights).mockResolvedValue({ content: null, exists: false });
|
||||||
|
vi.mocked(fetchMemoryAudit).mockResolvedValue({
|
||||||
|
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
workingMemory: { exists: true, size: 20, sectionCount: 1 },
|
||||||
|
insightsMemory: { exists: false, size: 0, insightCount: 0, categories: {} },
|
||||||
|
extraction: { runAt: "", success: false, insightCount: 0, duplicateCount: 0, skippedCount: 0, summary: "" },
|
||||||
|
pruning: { applied: false, reason: "", sizeDelta: 0, originalSize: 0, newSize: 0 },
|
||||||
|
checks: [],
|
||||||
|
health: "warning",
|
||||||
|
});
|
||||||
|
vi.mocked(fetchAutomations).mockResolvedValue([{ id: "auto-1", name: "Other" }] as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.workingMemoryLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await expect(result.current.triggerDreamNow()).rejects.toThrow(
|
||||||
|
"Memory Dreams schedule not found. Enable dream processing in memory settings first.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runAutomation).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.dreamRunning).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("sets correct loading states during async operations", async () => {
|
it("sets correct loading states during async operations", async () => {
|
||||||
vi.mocked(fetchMemory).mockResolvedValue({ content: "Initial content" });
|
vi.mocked(fetchMemory).mockResolvedValue({ content: "Initial content" });
|
||||||
vi.mocked(fetchMemoryInsights).mockResolvedValue({ content: null, exists: false });
|
vi.mocked(fetchMemoryInsights).mockResolvedValue({ content: null, exists: false });
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
fetchMemoryFiles,
|
fetchMemoryFiles,
|
||||||
fetchMemoryFile,
|
fetchMemoryFile,
|
||||||
saveMemoryFile,
|
saveMemoryFile,
|
||||||
|
fetchAutomations,
|
||||||
|
runAutomation,
|
||||||
installQmd,
|
installQmd,
|
||||||
testMemoryRetrieval,
|
testMemoryRetrieval,
|
||||||
type MemoryAuditReport,
|
type MemoryAuditReport,
|
||||||
@@ -85,6 +87,10 @@ interface UseMemoryDataResult {
|
|||||||
extractInsights: () => Promise<{ success: boolean; summary: string }>;
|
extractInsights: () => Promise<{ success: boolean; summary: string }>;
|
||||||
extracting: boolean;
|
extracting: boolean;
|
||||||
|
|
||||||
|
// Dreams
|
||||||
|
triggerDreamNow: () => Promise<unknown>;
|
||||||
|
dreamRunning: boolean;
|
||||||
|
|
||||||
// Audit
|
// Audit
|
||||||
auditReport: MemoryAuditReport | null;
|
auditReport: MemoryAuditReport | null;
|
||||||
auditLoading: boolean;
|
auditLoading: boolean;
|
||||||
@@ -162,6 +168,9 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
|||||||
// Extraction state
|
// Extraction state
|
||||||
const [extracting, setExtracting] = useState(false);
|
const [extracting, setExtracting] = useState(false);
|
||||||
|
|
||||||
|
// Dreams state
|
||||||
|
const [dreamRunning, setDreamRunning] = useState(false);
|
||||||
|
|
||||||
// Audit state
|
// Audit state
|
||||||
const [auditReport, setAuditReport] = useState<MemoryAuditReport | null>(null);
|
const [auditReport, setAuditReport] = useState<MemoryAuditReport | null>(null);
|
||||||
const [auditLoading, setAuditLoading] = useState(true);
|
const [auditLoading, setAuditLoading] = useState(true);
|
||||||
@@ -514,6 +523,21 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
|||||||
}
|
}
|
||||||
}, [projectId, refreshInsights, refreshAudit]);
|
}, [projectId, refreshInsights, refreshAudit]);
|
||||||
|
|
||||||
|
const triggerDreamNow = useCallback(async () => {
|
||||||
|
setDreamRunning(true);
|
||||||
|
try {
|
||||||
|
const automations = await fetchAutomations({ scope: "project", projectId });
|
||||||
|
const memoryDreamsSchedule = automations.find((automation) => automation.name === "Memory Dreams");
|
||||||
|
if (!memoryDreamsSchedule) {
|
||||||
|
throw new Error("Memory Dreams schedule not found. Enable dream processing in memory settings first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await runAutomation(memoryDreamsSchedule.id, { scope: "project", projectId });
|
||||||
|
} finally {
|
||||||
|
setDreamRunning(false);
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
// Compact memory
|
// Compact memory
|
||||||
const compactMemoryAction = useCallback(async (path?: string) => {
|
const compactMemoryAction = useCallback(async (path?: string) => {
|
||||||
setCompacting(true);
|
setCompacting(true);
|
||||||
@@ -582,6 +606,10 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
|||||||
extractInsights,
|
extractInsights,
|
||||||
extracting,
|
extracting,
|
||||||
|
|
||||||
|
// Dreams
|
||||||
|
triggerDreamNow,
|
||||||
|
dreamRunning,
|
||||||
|
|
||||||
// Audit
|
// Audit
|
||||||
auditReport,
|
auditReport,
|
||||||
auditLoading,
|
auditLoading,
|
||||||
|
|||||||
Reference in New Issue
Block a user