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:
@@ -165,6 +165,9 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
selectFile,
|
||||
saveSelectedFile,
|
||||
savingSelectedFile,
|
||||
reloadMemoryFiles,
|
||||
triggerDreamNow,
|
||||
dreamRunning,
|
||||
} = useMemoryData({ projectId });
|
||||
|
||||
useEffect(() => {
|
||||
@@ -299,6 +302,16 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
}
|
||||
}, [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
|
||||
const handleCompactMemory = useCallback(async () => {
|
||||
try {
|
||||
@@ -521,24 +534,44 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
</div>
|
||||
|
||||
{memorySettingsDraft.memoryEnabled && memorySettingsDraft.memoryDreamsEnabled && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryDreamsSchedule">Dream Schedule</label>
|
||||
<input
|
||||
id="memoryDreamsSchedule"
|
||||
type="text"
|
||||
className="input"
|
||||
value={memorySettingsDraft.memoryDreamsSchedule}
|
||||
onChange={(event) => {
|
||||
setMemorySettingsDraft((prev) => ({
|
||||
...prev,
|
||||
memoryDreamsSchedule: event.target.value,
|
||||
}));
|
||||
}}
|
||||
placeholder="0 4 * * *"
|
||||
disabled={settingsLoading}
|
||||
/>
|
||||
<small>Cron expression for dream processing.</small>
|
||||
</div>
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryDreamsSchedule">Dream Schedule</label>
|
||||
<input
|
||||
id="memoryDreamsSchedule"
|
||||
type="text"
|
||||
className="input"
|
||||
value={memorySettingsDraft.memoryDreamsSchedule}
|
||||
onChange={(event) => {
|
||||
setMemorySettingsDraft((prev) => ({
|
||||
...prev,
|
||||
memoryDreamsSchedule: event.target.value,
|
||||
}));
|
||||
}}
|
||||
placeholder="0 4 * * *"
|
||||
disabled={settingsLoading}
|
||||
/>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 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 { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
@@ -415,6 +415,7 @@ export function SettingsModal({
|
||||
const [memoryTestQuery, setMemoryTestQuery] = useState("");
|
||||
const [memoryTestLoading, setMemoryTestLoading] = useState(false);
|
||||
const [memoryTestResult, setMemoryTestResult] = useState<MemoryRetrievalTestResult | null>(null);
|
||||
const [dreamRunning, setDreamRunning] = useState(false);
|
||||
const [memoryCompactLoading, setMemoryCompactLoading] = useState(false);
|
||||
const [qmdInstallLoading, setQmdInstallLoading] = useState(false);
|
||||
const skipNextMemoryReloadRef = useRef(false);
|
||||
@@ -1343,6 +1344,23 @@ export function SettingsModal({
|
||||
}
|
||||
}, [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 () => {
|
||||
setQmdInstallLoading(true);
|
||||
try {
|
||||
@@ -3080,18 +3098,38 @@ export function SettingsModal({
|
||||
</div>
|
||||
|
||||
{isMemoryEnabled && form.memoryDreamsEnabled === true && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryDreamsSchedule">Dream Schedule</label>
|
||||
<input
|
||||
id="memoryDreamsSchedule"
|
||||
type="text"
|
||||
value={form.memoryDreamsSchedule ?? "0 4 * * *"}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<small>Cron expression for dream processing.</small>
|
||||
</div>
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryDreamsSchedule">Dream Schedule</label>
|
||||
<input
|
||||
id="memoryDreamsSchedule"
|
||||
type="text"
|
||||
value={form.memoryDreamsSchedule ?? "0 4 * * *"}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<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">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { MemoryView } from "../MemoryView";
|
||||
|
||||
@@ -83,6 +83,9 @@ function createMemoryData(overrides: Record<string, unknown> = {}) {
|
||||
selectFile: vi.fn(),
|
||||
saveSelectedFile: vi.fn(),
|
||||
savingSelectedFile: false,
|
||||
reloadMemoryFiles: vi.fn(),
|
||||
triggerDreamNow: vi.fn(),
|
||||
dreamRunning: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -179,4 +182,59 @@ describe("MemoryView", () => {
|
||||
expect(screen.getByText("Installed")).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 mockFetchRemoteQr = vi.fn();
|
||||
const mockFetchRemoteUrl = vi.fn();
|
||||
const mockFetchAutomations = vi.fn();
|
||||
const mockRunAutomation = vi.fn();
|
||||
const mockUseWorkspaceFileBrowser = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
@@ -78,6 +80,8 @@ vi.mock("../../api", () => ({
|
||||
generateShortLivedRemoteToken: (...args: unknown[]) => mockGenerateShortLivedRemoteToken(...args),
|
||||
fetchRemoteQr: (...args: unknown[]) => mockFetchRemoteQr(...args),
|
||||
fetchRemoteUrl: (...args: unknown[]) => mockFetchRemoteUrl(...args),
|
||||
fetchAutomations: (...args: unknown[]) => mockFetchAutomations(...args),
|
||||
runAutomation: (...args: unknown[]) => mockRunAutomation(...args),
|
||||
}));
|
||||
|
||||
// Mock the hook
|
||||
@@ -95,6 +99,7 @@ vi.mock("lucide-react", async (importOriginal) => {
|
||||
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 }} />,
|
||||
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 });
|
||||
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 });
|
||||
mockFetchAutomations.mockResolvedValue([]);
|
||||
mockRunAutomation.mockResolvedValue({ schedule: { id: "auto-2", name: "Memory Dreams" }, result: { success: true } });
|
||||
mockUseWorkspaceFileBrowser.mockReturnValue({
|
||||
entries: [],
|
||||
currentPath: ".",
|
||||
@@ -1716,4 +1723,56 @@ describe("SettingsModal", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user