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:
Fusion
2026-04-28 01:58:49 -07:00
committed by gsxdsm
parent 789afc30b0
commit 0b8ffa1528
7 changed files with 327 additions and 37 deletions

View File

@@ -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>

View File

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

View File

@@ -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();
});
});

View File

@@ -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();
});
});
});