Adds a text-selection comment flow that opens New Task with source context. - Add a reusable selection comment hook and popover for editor/document text selections. - Thread selected-file and line-range context into the New Task modal. - Enable comment-on-selection entry points in file editor, browser, documents, and memory surfaces. - Cover the selection flow with focused component and hook tests, plus docs and copy updates. Files changed: docs/dashboard-guide.md | 6 +- packages/dashboard/app/App.tsx | 7 +- packages/dashboard/app/components/AppModals.tsx | 2 + .../dashboard/app/components/DocumentsView.tsx | 25 ++- .../dashboard/app/components/FileBrowserModal.tsx | 3 + packages/dashboard/app/components/FileEditor.tsx | 35 +++- packages/dashboard/app/components/MemoryView.tsx | 5 +- packages/dashboard/app/components/NewTaskModal.tsx | 15 +- .../app/components/SelectionCommentPopover.css | 73 +++++++++ .../app/components/SelectionCommentPopover.tsx | 181 +++++++++++++++++++++ .../components/__tests__/DocumentsView.test.tsx | 59 +++++++ .../components/__tests__/FileBrowserModal.test.tsx | 79 +++++++++ .../app/components/__tests__/FileEditor.test.tsx | 60 +++++++ .../app/components/__tests__/MemoryView.test.tsx | 48 +++++- .../app/components/__tests__/NewTaskModal.test.tsx | 17 ++ .../__tests__/SelectionCommentPopover.test.tsx | 75 +++++++++ .../app/hooks/__tests__/useModalManager.test.ts | 29 ++++ .../hooks/__tests__/useSelectionComment.test.ts | 140 ++++++++++++++++ packages/dashboard/app/hooks/useModalManager.ts | 19 ++- .../dashboard/app/hooks/useSelectionComment.ts | 95 +++++++++++ packages/i18n/locales/en/app.json | 11 ++ 21 files changed, 973 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-6528 Fusion-Task-Lineage: ef79e450-e69f-497b-97cd-f82d3c832fdf
368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { MemoryView } from "../MemoryView";
|
|
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
|
|
|
const { capturedFileEditorProps } = vi.hoisted(() => ({
|
|
capturedFileEditorProps: [] as Array<{ filePath: string; onSendSelectionToTask?: (description: string) => void }>,
|
|
}));
|
|
|
|
const mockUseMemoryData = vi.fn();
|
|
|
|
vi.mock("../../hooks/useMemoryData", () => ({
|
|
useMemoryData: (...args: unknown[]) => mockUseMemoryData(...args),
|
|
}));
|
|
|
|
vi.mock("../FileEditor", () => ({
|
|
FileEditor: (props: { filePath: string; onSendSelectionToTask?: (description: string) => void }) => {
|
|
capturedFileEditorProps.push(props);
|
|
return <div aria-label={`Editor for ${props.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,
|
|
reloadMemoryFiles: vi.fn(),
|
|
triggerDreamNow: vi.fn(),
|
|
dreamRunning: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("MemoryView", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
capturedFileEditorProps.length = 0;
|
|
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("passes selection-to-task callback to the memory file editor", () => {
|
|
const onSendSelectionToTask = vi.fn();
|
|
|
|
render(<MemoryView addToast={vi.fn()} onSendSelectionToTask={onSendSelectionToTask} />);
|
|
|
|
expect(capturedFileEditorProps).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
filePath: ".fusion/memory/MEMORY.md",
|
|
onSendSelectionToTask,
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("passes selection-to-task callback to the raw insights editor", async () => {
|
|
const onSendSelectionToTask = vi.fn();
|
|
mockUseMemoryData.mockReturnValue(
|
|
createMemoryData({
|
|
insightsExists: true,
|
|
insightsContent: "## Patterns\n- Keep useful notes",
|
|
}),
|
|
);
|
|
|
|
render(<MemoryView addToast={vi.fn()} onSendSelectionToTask={onSendSelectionToTask} />);
|
|
await userEvent.click(screen.getByRole("tab", { name: "Insights" }));
|
|
await userEvent.click(screen.getByRole("button", { name: "Edit Raw" }));
|
|
|
|
expect(capturedFileEditorProps).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
filePath: ".fusion/memory/INSIGHTS.md",
|
|
onSendSelectionToTask,
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
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("clicking Dream Now triggers processing API once 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("shows error toast when Dream Now processing fails", async () => {
|
|
const triggerDreamNow = vi.fn().mockRejectedValue(new Error("dream failed"));
|
|
const addToast = vi.fn();
|
|
mockUseMemoryData.mockReturnValue(
|
|
createMemoryData({
|
|
triggerDreamNow,
|
|
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(addToast).toHaveBeenCalledWith("dream failed", "error");
|
|
});
|
|
});
|
|
|
|
it("shows Dreaming loading state while processing is running", () => {
|
|
mockUseMemoryData.mockReturnValue(
|
|
createMemoryData({
|
|
dreamRunning: true,
|
|
memorySettings: {
|
|
memoryEnabled: true,
|
|
memoryAutoSummarizeEnabled: false,
|
|
memoryAutoSummarizeThresholdChars: 50000,
|
|
memoryAutoSummarizeSchedule: "0 3 * * *",
|
|
memoryDreamsEnabled: true,
|
|
memoryDreamsSchedule: "0 4 * * *",
|
|
},
|
|
}),
|
|
);
|
|
|
|
render(<MemoryView addToast={vi.fn()} />);
|
|
|
|
const button = screen.getByRole("button", { name: /Dreaming…/i });
|
|
expect(button).toBeDisabled();
|
|
});
|
|
|
|
it("truncates long memory file option labels to avoid wide native dropdown expansion", () => {
|
|
mockUseMemoryData.mockReturnValue(
|
|
createMemoryData({
|
|
memoryFiles: [
|
|
{
|
|
path: ".fusion/memory/very/deep/path/that/keeps/growing/until/the/browser/native/select/dropdown/can-overflow-on-the-right-edge.md",
|
|
label: "Long-term memory",
|
|
layer: "long-term",
|
|
size: 12,
|
|
updatedAt: "2026-04-17T12:00:00.000Z",
|
|
},
|
|
],
|
|
selectedFilePath: ".fusion/memory/very/deep/path/that/keeps/growing/until/the/browser/native/select/dropdown/can-overflow-on-the-right-edge.md",
|
|
}),
|
|
);
|
|
|
|
render(<MemoryView addToast={vi.fn()} />);
|
|
|
|
const option = screen.getByRole("option", { name: /Long-term memory/ });
|
|
expect(option.textContent).toContain("…");
|
|
expect(option.textContent).not.toContain("dropdown/can-overflow-on-the-right-edge.md");
|
|
});
|
|
|
|
it("hides Dream Now button when dreams are disabled", () => {
|
|
render(<MemoryView addToast={vi.fn()} />);
|
|
|
|
expect(screen.queryByRole("button", { name: "Dream Now" })).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("MemoryView CSS contract", () => {
|
|
it("uses flex sizing for the memory editor and keeps flex-chain min-height guards", async () => {
|
|
const css = await loadAllAppCssBaseOnly();
|
|
|
|
expect(css).not.toMatch(/\.memory-editor-container\s*\{[^}]*height\s*:\s*calc\(100vh/i);
|
|
expect(css).toMatch(/\.memory-editor-container\s*\{[^}]*flex\s*:\s*1\s+1\s+auto;[^}]*min-height\s*:/);
|
|
expect(css).toMatch(/\.memory-view-content\s*\{[^}]*min-height\s*:\s*0\s*;/);
|
|
});
|
|
});
|