Merge branch 'main' into timothyjlaurent/stuck-spinners
This commit is contained in:
@@ -36,7 +36,7 @@ import { useCurrentProject } from "./hooks/useCurrentProject";
|
||||
import { ToastProvider, useToast } from "./hooks/useToast";
|
||||
import { ConfirmDialogProvider } from "./hooks/useConfirm";
|
||||
import { useTheme } from "./hooks/useTheme";
|
||||
import { useModalManager, type DetailTaskOrigin } from "./hooks/useModalManager";
|
||||
import { useModalManager, type DetailTaskOrigin, type DetailTaskTab } from "./hooks/useModalManager";
|
||||
import { useAppSettings } from "./hooks/useAppSettings";
|
||||
import { useDeepLink } from "./hooks/useDeepLink";
|
||||
import { useFavorites } from "./hooks/useFavorites";
|
||||
@@ -49,6 +49,7 @@ import { useViewState, type TaskView } from "./hooks/useViewState";
|
||||
import { useNavigationHistory } from "./hooks/useNavigationHistory";
|
||||
import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews";
|
||||
import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost";
|
||||
import { isPluginViewId } from "./plugins/pluginViewRegistry";
|
||||
import { useProjectActions } from "./hooks/useProjectActions";
|
||||
import { useTaskHandlers } from "./hooks/useTaskHandlers";
|
||||
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
|
||||
@@ -448,6 +449,7 @@ function AppInner() {
|
||||
// Redirect to board if feature-gated views are disabled.
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return;
|
||||
if (isPluginViewId(taskView)) return;
|
||||
if (taskView === "skills" && !skillsEnabled) {
|
||||
handleChangeTaskView("board");
|
||||
}
|
||||
@@ -867,7 +869,7 @@ function AppInner() {
|
||||
}
|
||||
|
||||
// Project view
|
||||
if (taskView.startsWith("plugin:")) {
|
||||
if (isPluginViewId(taskView)) {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<PluginDashboardViewHost
|
||||
@@ -876,8 +878,10 @@ function AppInner() {
|
||||
projectId: currentProject?.id,
|
||||
tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks,
|
||||
workflowSteps,
|
||||
openTaskDetail: isMobile ? (task, initialTab) => openDetailTaskWithHistory(task, initialTab) : (task, initialTab) => modalManager.openDetailTask(task, initialTab),
|
||||
renderTaskCard: (task) => (
|
||||
openTaskDetail: isMobile
|
||||
? (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTaskWithHistory(task, initialTab)
|
||||
: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => modalManager.openDetailTask(task, initialTab),
|
||||
renderTaskCard: (task: Task | TaskDetail) => (
|
||||
<TaskCard
|
||||
task={task}
|
||||
projectId={currentProject?.id}
|
||||
@@ -1319,7 +1323,7 @@ function AppInner() {
|
||||
<NativeShellConnectionStatus state={shellState} onManage={() => setShellConnectionManagerOpen(true)} />
|
||||
) : undefined}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && !taskView.startsWith("plugin:") && (
|
||||
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && !isPluginViewId(taskView) && (
|
||||
<QuickChatFAB
|
||||
projectId={currentProject.id}
|
||||
addToast={addToast}
|
||||
|
||||
@@ -167,6 +167,10 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-detail-import-btn {
|
||||
min-height: calc(var(--space-lg) + var(--space-md) + var(--space-xs));
|
||||
}
|
||||
|
||||
/* Legacy class for backward compatibility */
|
||||
.agent-detail-title {
|
||||
display: flex;
|
||||
@@ -1498,6 +1502,10 @@
|
||||
min-width: calc(var(--space-lg) + var(--space-md) + var(--space-xs));
|
||||
}
|
||||
|
||||
.agent-detail-import-btn {
|
||||
min-width: calc(var(--space-xl) * 3 + var(--space-xs));
|
||||
}
|
||||
|
||||
/* Legacy selectors for backward compatibility */
|
||||
.agent-detail-title {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Settings, FileText, ActivitySquare, X, Copy,
|
||||
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch, ListChecks,
|
||||
AlertCircle,
|
||||
ChevronDown, ChevronRight, ChevronLeft, BarChart3, BookOpen, Eye, FileEdit
|
||||
ChevronDown, ChevronRight, ChevronLeft, BarChart3, BookOpen, Eye, FileEdit, Upload
|
||||
} from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -25,6 +25,7 @@ import { formatAgentSkillBadgeLabel } from "../utils/agentSkills";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { AgentImportModal } from "./AgentImportModal";
|
||||
|
||||
/**
|
||||
* Simple className utility - joins class names conditionally
|
||||
@@ -131,6 +132,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
const [agent, setAgent] = useState<AgentDetail | null>(null);
|
||||
const { confirm } = useConfirm();
|
||||
const [logs, setLogs] = useState<AgentLogEntry[]>([]);
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab ?? "dashboard");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
@@ -613,6 +615,15 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
|
||||
{/* Utility actions: refresh + close */}
|
||||
<div className="agent-detail-utility-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--compact agent-detail-import-btn"
|
||||
onClick={() => setIsImportModalOpen(true)}
|
||||
aria-label="Import agents"
|
||||
>
|
||||
<Upload size={14} />
|
||||
Import
|
||||
</button>
|
||||
<button className="btn-icon" onClick={() => void loadAgent()} title="Refresh" aria-label="Refresh">
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
@@ -757,6 +768,15 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AgentImportModal
|
||||
isOpen={isImportModalOpen}
|
||||
onClose={() => setIsImportModalOpen(false)}
|
||||
onImported={() => {
|
||||
void handleSavedMutation();
|
||||
}}
|
||||
projectId={projectId}
|
||||
initialInputMethod="browse"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface AgentImportModalProps {
|
||||
onClose: () => void;
|
||||
onImported: () => void;
|
||||
projectId?: string;
|
||||
initialInputMethod?: InputMethod;
|
||||
}
|
||||
|
||||
/** Parsed agent preview item for display before import */
|
||||
@@ -126,10 +127,10 @@ function parseDirectoryAgentManifest(content: string): DirectoryAgentInput {
|
||||
*
|
||||
* Flow: Input → Preview parsed agents → Import → Show results
|
||||
*/
|
||||
export function AgentImportModal({ isOpen, onClose, onImported, projectId }: AgentImportModalProps) {
|
||||
export function AgentImportModal({ isOpen, onClose, onImported, projectId, initialInputMethod = "paste" }: AgentImportModalProps) {
|
||||
useMobileScrollLock(isOpen);
|
||||
const [step, setStep] = useState<ModalStep>("input");
|
||||
const [inputMethod, setInputMethod] = useState<InputMethod>("paste");
|
||||
const [inputMethod, setInputMethod] = useState<InputMethod>(initialInputMethod);
|
||||
const [manifestContent, setManifestContent] = useState("");
|
||||
const [directoryAgents, setDirectoryAgents] = useState<DirectoryAgentInput[]>([]);
|
||||
const [companyName, setCompanyName] = useState("Unknown");
|
||||
@@ -207,7 +208,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setStep("input");
|
||||
setInputMethod("paste");
|
||||
setInputMethod(initialInputMethod);
|
||||
setManifestContent("");
|
||||
setDirectoryAgents([]);
|
||||
setCompanyName("Unknown");
|
||||
@@ -226,7 +227,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
setIsLoadingCompanies(false);
|
||||
setCompaniesError(null);
|
||||
fetchAttemptedRef.current = false;
|
||||
}, []);
|
||||
}, [initialInputMethod]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
reset();
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
|
||||
import { getTrailingPath } from "../utils/pathDisplay";
|
||||
import type { TaskView } from "../hooks/useViewState";
|
||||
import type { PluginDashboardViewEntry } from "../api";
|
||||
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
|
||||
import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry";
|
||||
import { getPluginNavIcon } from "./pluginNavIcon";
|
||||
|
||||
export { useViewportMode };
|
||||
@@ -1152,7 +1152,7 @@ export function Header({
|
||||
<>
|
||||
<button
|
||||
ref={viewOverflowTriggerRef}
|
||||
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (todosEnabled && todosOpen) || view.startsWith("plugin:") ? " active" : ""}`}
|
||||
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
|
||||
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
||||
title="More views"
|
||||
aria-label="More views"
|
||||
|
||||
@@ -35,7 +35,7 @@ import { fetchScripts } from "../api";
|
||||
import type { PluginDashboardViewEntry } from "../api";
|
||||
import { useViewportMode } from "./Header";
|
||||
import type { TaskView } from "../hooks/useViewState";
|
||||
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
|
||||
import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry";
|
||||
import { getPluginNavIcon } from "./pluginNavIcon";
|
||||
|
||||
export interface MobileNavBarProps {
|
||||
@@ -247,7 +247,7 @@ export function MobileNavBar({
|
||||
|| (todosOpen && todoViewEnabled)
|
||||
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
||||
|| (view === "skills" && !showSkillsTopLevel)
|
||||
|| (view.startsWith("plugin:") && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|
||||
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1307,6 +1307,33 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
[projectId, sessionTabId, view]
|
||||
);
|
||||
|
||||
const handleRefineFurther = useCallback(async () => {
|
||||
if (view.type !== "summary") {
|
||||
return;
|
||||
}
|
||||
|
||||
const { session, summary } = view;
|
||||
const sessionId = session.sessionId;
|
||||
currentSessionIdRef.current = sessionId;
|
||||
setLockSessionId(sessionId);
|
||||
|
||||
setError(null);
|
||||
setIsRetrying(false);
|
||||
setStreamingOutput("");
|
||||
setView({ type: "loading" });
|
||||
|
||||
connectToPlanningStream(sessionId);
|
||||
|
||||
try {
|
||||
await respondToPlanning(sessionId, { refine: true }, projectId, sessionTabId);
|
||||
} catch (err) {
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
setError(getErrorMessage(err) || "Failed to refine plan");
|
||||
setView({ type: "summary", session, summary: editedSummary ?? summary });
|
||||
}
|
||||
}, [connectToPlanningStream, editedSummary, projectId, sessionTabId, view]);
|
||||
|
||||
const handleStopGeneration = useCallback(async () => {
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
if (!sessionId) {
|
||||
@@ -1935,8 +1962,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
onCreateTask={handleCreateTask}
|
||||
onBreakIntoTasks={handleStartBreakdown}
|
||||
onRefine={() => {
|
||||
// Reset to question mode for more refinement
|
||||
setView({ type: "question", session: view.session });
|
||||
void handleRefineFurther();
|
||||
}}
|
||||
isLoading={false}
|
||||
/>
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock("../../api", () => ({
|
||||
fetchPluginRuntimes: vi.fn(),
|
||||
upgradeAgentHeartbeatProcedure: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
fetchCompanies: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../AgentLogViewer", () => ({
|
||||
@@ -118,7 +119,7 @@ vi.mock("../../hooks/useConfirm", () => ({
|
||||
useConfirm: () => ({ confirm: mockConfirm }),
|
||||
}));
|
||||
|
||||
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchSkillContent, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, upgradeAgentHeartbeatProcedure, updateGlobalSettings } from "../../api";
|
||||
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchSkillContent, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchCompanies } from "../../api";
|
||||
import { subscribeSse } from "../../sse-bus";
|
||||
|
||||
const mockFetchAgent = vi.mocked(fetchAgent);
|
||||
@@ -146,6 +147,7 @@ const mockFetchPluginRuntimes = vi.mocked(fetchPluginRuntimes);
|
||||
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
|
||||
const mockUpgradeAgentHeartbeatProcedure = vi.mocked(upgradeAgentHeartbeatProcedure);
|
||||
const mockUpdateGlobalSettings = vi.mocked(updateGlobalSettings);
|
||||
const mockFetchCompanies = vi.mocked(fetchCompanies);
|
||||
const mockSubscribeSse = vi.mocked(subscribeSse);
|
||||
|
||||
const MOCK_SKILLS = [
|
||||
@@ -249,6 +251,7 @@ describe("AgentDetailView", () => {
|
||||
procedureFileSeeded: true,
|
||||
});
|
||||
mockUpdateGlobalSettings.mockResolvedValue({} as any);
|
||||
mockFetchCompanies.mockResolvedValue({ companies: [] });
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
@@ -936,11 +939,32 @@ describe("AgentDetailView", () => {
|
||||
|
||||
const utilityContainer = headerActions?.querySelector(".agent-detail-utility-actions");
|
||||
expect(utilityContainer).toBeTruthy();
|
||||
expect(utilityContainer?.querySelector('[aria-label="Import agents"]')).toBeTruthy();
|
||||
expect(utilityContainer?.querySelector('[title="Refresh"]')).toBeTruthy();
|
||||
expect(utilityContainer?.querySelector('[title="Close"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the import modal from agent detail in browse mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchCompanies.mockResolvedValue({ companies: [{ slug: "acme", name: "Acme AI" }] });
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Import agents" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog", { name: "Import agents" })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Search companies...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mobile inline header controls on the same row as identity", () => {
|
||||
const stylesContent = loadAllAppCss();
|
||||
|
||||
|
||||
@@ -184,4 +184,11 @@ describe("AgentImportModal", () => {
|
||||
// The browse mode should render the search input (the fetch for companies is async)
|
||||
expect(screen.getByPlaceholderText("Search companies...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens directly in browse mode when initialInputMethod is browse", () => {
|
||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} initialInputMethod="browse" />);
|
||||
|
||||
expect(screen.getByPlaceholderText("Search companies...")).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Manifest content")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1453,6 +1453,28 @@ describe("App view switching", () => {
|
||||
localStorage.removeItem(taskViewStorageKey());
|
||||
});
|
||||
|
||||
it("does not expose research navigation when research feature is disabled", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: {
|
||||
...defaultSettings.experimentalFeatures,
|
||||
researchView: false,
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||
expect(screen.queryByTestId("view-overflow-research")).not.toBeInTheDocument();
|
||||
|
||||
localStorage.removeItem("kb-dashboard-view-mode");
|
||||
});
|
||||
|
||||
it("initializes research view from persisted task-view when feature-enabled", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
localStorage.setItem(taskViewStorageKey(), "research");
|
||||
@@ -1645,8 +1667,7 @@ describe("App view switching", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Zoom In")).toBeInTheDocument();
|
||||
expect(screen.getByText("Zoom Out")).toBeInTheDocument();
|
||||
expect(screen.getByText("Plugin view unavailable")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
localStorage.removeItem(taskViewStorageKey());
|
||||
|
||||
@@ -809,6 +809,84 @@ describe("PlanningModeModal", () => {
|
||||
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith("session-complete-2", resumedSummary, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("refines a resumed complete session without blank question view", async () => {
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Resume-and-refine",
|
||||
description: "Recovered summary for refine",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Implement", "Verify"],
|
||||
};
|
||||
const refinedQuestion: PlanningQuestion = {
|
||||
id: "q-refine",
|
||||
type: "text",
|
||||
question: "Which part should we refine?",
|
||||
description: "Refine follow-up",
|
||||
};
|
||||
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-complete-refine",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Resume-and-refine",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Recover and refine" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(resumedSummary),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
let streamHandlers: any;
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
streamHandlers = handlers;
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
mockRespondToPlanning.mockImplementationOnce(async () => {
|
||||
setTimeout(() => {
|
||||
streamHandlers?.onQuestion?.(refinedQuestion);
|
||||
}, 10);
|
||||
return { type: "question", data: refinedQuestion };
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-complete-refine"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Refine Further" })).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refine Further" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRespondToPlanning).toHaveBeenCalledWith(
|
||||
"session-complete-refine",
|
||||
{ refine: true },
|
||||
undefined,
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Which part should we refine?")).toBeDefined();
|
||||
});
|
||||
expect(screen.queryByText("No active question in session")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Conversation history", () => {
|
||||
@@ -1094,7 +1172,7 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What are the key requirements?")).toBeDefined();
|
||||
});
|
||||
}, { timeout: 5000 });
|
||||
|
||||
expect(screen.getByTestId("conversation-history")).toBeDefined();
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
|
||||
@@ -201,12 +201,12 @@ describe("ResearchView", () => {
|
||||
|
||||
mockUseResearch.mockReturnValue({
|
||||
...baseHookValue,
|
||||
runs: [{ id: "RR-1", title: "t", query: "q", status: "pending" }],
|
||||
runs: [{ id: "RR-1", title: "t", query: "q", status: "queued" }],
|
||||
selectedRun: {
|
||||
id: "RR-1",
|
||||
title: "t",
|
||||
query: "q",
|
||||
status: "pending",
|
||||
status: "queued",
|
||||
events: [{ id: "E-1", message: "queued" }],
|
||||
results: { summary: "Summary", findings: [{ id: "finding-1", heading: "Finding", content: "Impact." }], citations: [] },
|
||||
},
|
||||
@@ -239,12 +239,12 @@ describe("ResearchView", () => {
|
||||
const attachRunToTask = vi.fn().mockResolvedValue({});
|
||||
mockUseResearch.mockReturnValue({
|
||||
...baseHookValue,
|
||||
runs: [{ id: "RR-1", title: "t", query: "q", status: "pending" }],
|
||||
runs: [{ id: "RR-1", title: "t", query: "q", status: "queued" }],
|
||||
selectedRun: {
|
||||
id: "RR-1",
|
||||
title: "t",
|
||||
query: "q",
|
||||
status: "pending",
|
||||
status: "queued",
|
||||
events: [],
|
||||
results: { summary: "Summary", findings: [{ id: "finding-1", heading: "Finding", content: "Impact." }], citations: [] },
|
||||
},
|
||||
@@ -298,7 +298,7 @@ describe("ResearchView", () => {
|
||||
setSearchQuery,
|
||||
setSelectedRunId,
|
||||
runs: [
|
||||
{ id: "RR-1", title: "Alpha", query: "alpha", status: "pending" },
|
||||
{ id: "RR-1", title: "Alpha", query: "alpha", status: "queued" },
|
||||
{ id: "RR-2", title: "Beta", query: "beta", status: "completed" },
|
||||
],
|
||||
});
|
||||
@@ -460,6 +460,66 @@ describe("ResearchView", () => {
|
||||
expect(await screen.findByTestId("research-state-empty")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("wires create-task modal payload with trimmed fields and attachment toggle", async () => {
|
||||
const createTaskFromRun = vi.fn().mockResolvedValue({});
|
||||
mockUseResearch.mockReturnValue({
|
||||
...baseHookValue,
|
||||
createTaskFromRun,
|
||||
runs: [{ id: "RR-1", title: "t", query: "q", status: "completed" }],
|
||||
selectedRun: {
|
||||
id: "RR-1",
|
||||
title: "t",
|
||||
query: "q",
|
||||
status: "completed",
|
||||
events: [],
|
||||
results: { summary: "Summary", findings: [{ id: "finding-1", heading: "Finding", content: "Impact." }], citations: [] },
|
||||
},
|
||||
selectedRunId: "RR-1",
|
||||
});
|
||||
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
|
||||
|
||||
render(<ResearchView projectId="p1" />);
|
||||
fireEvent.click((await screen.findAllByText("Create Task"))[0]);
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.change(within(dialog).getByLabelText("Title"), { target: { value: " Follow up task " } });
|
||||
fireEvent.change(within(dialog).getByLabelText("Description"), { target: { value: " Take action now. " } });
|
||||
fireEvent.click(within(dialog).getByLabelText("Attach markdown export artifact"));
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createTaskFromRun).toHaveBeenCalledWith("RR-1", "Follow up task", "finding-1", "Take action now.", "normal", true);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps enrich action disabled until a task id is provided", async () => {
|
||||
mockUseResearch.mockReturnValue({
|
||||
...baseHookValue,
|
||||
runs: [{ id: "RR-1", title: "t", query: "q", status: "completed" }],
|
||||
selectedRun: {
|
||||
id: "RR-1",
|
||||
title: "t",
|
||||
query: "q",
|
||||
status: "completed",
|
||||
events: [],
|
||||
results: { summary: "Summary", findings: [{ id: "finding-1", heading: "Finding", content: "Impact." }], citations: [] },
|
||||
},
|
||||
selectedRunId: "RR-1",
|
||||
});
|
||||
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
|
||||
|
||||
render(<ResearchView projectId="p1" />);
|
||||
fireEvent.click((await screen.findAllByText("Enrich Task"))[0]);
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const enrichButton = within(dialog).getByRole("button", { name: "Enrich Task" });
|
||||
expect(enrichButton).toBeDisabled();
|
||||
|
||||
const targetInput = within(dialog).getByRole("combobox", { name: "Target task" });
|
||||
fireEvent.change(targetInput, { target: { value: "FN-1" } });
|
||||
await waitFor(() => expect(enrichButton).not.toBeDisabled());
|
||||
});
|
||||
|
||||
it("includes mobile layout media rule", async () => {
|
||||
const css = await import("../ResearchView.css?inline");
|
||||
expect(css.default).toContain("@media (max-width: 768px)");
|
||||
|
||||
@@ -42,6 +42,7 @@ vi.mock("../../api", () => ({
|
||||
fetchAgentBudgetStatus: vi.fn(),
|
||||
resetAgentBudget: vi.fn(),
|
||||
upgradeAgentHeartbeatProcedure: vi.fn(),
|
||||
fetchCompanies: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../AgentLogViewer", () => ({
|
||||
@@ -80,6 +81,7 @@ const mockGenerateAgentSpec = vi.mocked(api.generateAgentSpec);
|
||||
const mockCancelAgentGeneration = vi.mocked(api.cancelAgentGeneration);
|
||||
const mockFetchAgentBudgetStatus = vi.mocked(api.fetchAgentBudgetStatus);
|
||||
const mockResetAgentBudget = vi.mocked(api.resetAgentBudget);
|
||||
const mockFetchCompanies = vi.mocked(api.fetchCompanies);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -172,6 +174,7 @@ describe("agent modal mobile CSS structure", () => {
|
||||
mockCancelAgentGeneration.mockResolvedValue({ success: true });
|
||||
mockFetchAgentBudgetStatus.mockResolvedValue({ agentId: "agent-001", currentUsage: 0, budgetLimit: null, usagePercent: null, thresholdPercent: null, isOverBudget: false, isOverThreshold: false, lastResetAt: null, nextResetAt: null });
|
||||
mockResetAgentBudget.mockResolvedValue(undefined);
|
||||
mockFetchCompanies.mockResolvedValue({ companies: [] });
|
||||
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
({
|
||||
@@ -273,6 +276,12 @@ describe("agent modal mobile CSS structure", () => {
|
||||
expect(document.querySelector(".agent-import-dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("supports browse-first launch mode", () => {
|
||||
render(<AgentImportModal isOpen={true} onClose={vi.fn()} onImported={vi.fn()} initialInputMethod="browse" />);
|
||||
|
||||
expect(screen.getByPlaceholderText("Search companies...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("file upload area has targetable class", () => {
|
||||
render(<AgentImportModal isOpen={true} onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { usePluginDashboardViews, __test_clearDashboardViewsCache } from "../usePluginDashboardViews";
|
||||
import * as api from "../../api";
|
||||
|
||||
@@ -15,6 +15,14 @@ describe("usePluginDashboardViews", () => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
it("returns empty array when no dashboard views are registered", async () => {
|
||||
mockFetch.mockResolvedValueOnce([]);
|
||||
const { result } = renderHook(() => usePluginDashboardViews());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.views).toEqual([]);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("fetches and returns dashboard views", async () => {
|
||||
mockFetch.mockResolvedValueOnce([
|
||||
{ pluginId: "dep", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
|
||||
@@ -25,6 +33,60 @@ describe("usePluginDashboardViews", () => {
|
||||
expect(result.current.views).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("caches results and doesn't re-fetch within ttl", async () => {
|
||||
mockFetch.mockResolvedValueOnce([
|
||||
{ pluginId: "dep", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
|
||||
]);
|
||||
|
||||
const first = renderHook(() => usePluginDashboardViews("project-a"));
|
||||
await waitFor(() => expect(first.result.current.loading).toBe(false));
|
||||
|
||||
mockFetch.mockClear();
|
||||
const second = renderHook(() => usePluginDashboardViews("project-a"));
|
||||
await waitFor(() => expect(second.result.current.loading).toBe(false));
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets loading only on initial fetch, not on cache-hit", async () => {
|
||||
mockFetch.mockResolvedValueOnce([
|
||||
{ pluginId: "dep", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
|
||||
]);
|
||||
|
||||
const first = renderHook(() => usePluginDashboardViews("project-a"));
|
||||
expect(first.result.current.loading).toBe(true);
|
||||
await waitFor(() => expect(first.result.current.loading).toBe(false));
|
||||
|
||||
mockFetch.mockClear();
|
||||
const second = renderHook(() => usePluginDashboardViews("project-a"));
|
||||
expect(second.result.current.loading).toBe(false);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles fetch errors gracefully", async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error("boom"));
|
||||
const { result } = renderHook(() => usePluginDashboardViews());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.views).toEqual([]);
|
||||
expect(result.current.error).toBe("boom");
|
||||
});
|
||||
|
||||
it("refetch invalidates cache and fetches again", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce([{ pluginId: "a", view: { viewId: "x", label: "X", componentPath: "./x.js" } }])
|
||||
.mockResolvedValueOnce([{ pluginId: "a", view: { viewId: "y", label: "Y", componentPath: "./y.js" } }]);
|
||||
|
||||
const { result } = renderHook(() => usePluginDashboardViews("project-a"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.views[0]?.view.viewId).toBe("x");
|
||||
|
||||
await act(async () => {
|
||||
result.current.refetch();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.views[0]?.view.viewId).toBe("y"));
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses project-scoped cache keys", async () => {
|
||||
mockFetch.mockResolvedValueOnce([{ pluginId: "a", view: { viewId: "x", label: "X", componentPath: "./x.js" } }]);
|
||||
const first = renderHook(() => usePluginDashboardViews("project-a"));
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock("../../sse-bus", () => ({
|
||||
describe("useResearch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
mockListResearchRuns.mockResolvedValue({ runs: [], availability: { available: true } });
|
||||
mockGetResearchRun.mockResolvedValue({ run: { id: "RR-2", title: "t" }, availability: { available: true } });
|
||||
});
|
||||
@@ -223,4 +224,32 @@ describe("useResearch", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes list and selected run on reconnect", async () => {
|
||||
let handlers: { onReconnect?: () => void; events?: Record<string, () => void> } = {};
|
||||
mockSubscribeSse.mockImplementationOnce((_url, opts) => {
|
||||
handlers = opts;
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useResearch({ projectId: "p1" }));
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedRunId("RR-2");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetResearchRun).toHaveBeenCalledWith("RR-2", "p1");
|
||||
});
|
||||
|
||||
const listCallsBefore = mockListResearchRuns.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
handlers.onReconnect?.();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListResearchRuns.mock.calls.length).toBeGreaterThan(listCallsBefore);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,6 +329,22 @@ describe("useViewState", () => {
|
||||
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("plugin:fusion-plugin-dependency-graph:graph");
|
||||
});
|
||||
|
||||
it("rejects invalid plugin view IDs and falls back to board", async () => {
|
||||
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "plugin:only-one-segment");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useViewState(
|
||||
createOptions({
|
||||
currentProject: PROJECT,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.taskView).toBe("board");
|
||||
});
|
||||
});
|
||||
|
||||
it("restores legacy views (board/list/agents/missions/chat) from scoped storage", async () => {
|
||||
const legacyViews = ["board", "list", "agents", "missions", "chat"] as const;
|
||||
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { fetchPluginDashboardViews } from "../api";
|
||||
import type { PluginDashboardViewEntry } from "../api";
|
||||
|
||||
const dashboardViewsCache = new Map<string, { views: PluginDashboardViewEntry[]; expiresAt: number }>();
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
/** Clear module cache for deterministic hook tests. */
|
||||
export function __test_clearDashboardViewsCache(): void {
|
||||
dashboardViewsCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch plugin dashboard views with a 60s project-scoped cache.
|
||||
* Loading is only true for the first fetch of each hook lifecycle.
|
||||
*/
|
||||
export function usePluginDashboardViews(projectId?: string): {
|
||||
views: PluginDashboardViewEntry[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => void;
|
||||
} {
|
||||
const [views, setViews] = useState<PluginDashboardViewEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const initialLoadCompleteRef = useRef(false);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
const cacheKey = projectId ?? "default";
|
||||
dashboardViewsCache.delete(cacheKey);
|
||||
setReloadKey((key) => key + 1);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const cacheKey = projectId ?? "default";
|
||||
let cancelled = false;
|
||||
@@ -57,7 +70,7 @@ export function usePluginDashboardViews(projectId?: string): {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
}, [projectId, reloadKey]);
|
||||
|
||||
return useMemo(() => ({ views, loading, error }), [views, loading, error]);
|
||||
return useMemo(() => ({ views, loading, error, refetch }), [views, loading, error, refetch]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ThemeMode } from "@fusion/core";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { isPluginViewId } from "../plugins/pluginViewRegistry";
|
||||
|
||||
export type ViewMode = "overview" | "project";
|
||||
export type BuiltInTaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
|
||||
@@ -29,12 +30,8 @@ function isBuiltInTaskView(value: string | null): value is BuiltInTaskView {
|
||||
return value !== null && BUILT_IN_TASK_VIEWS.includes(value as BuiltInTaskView);
|
||||
}
|
||||
|
||||
function isPluginTaskView(value: string | null): value is PluginTaskView {
|
||||
return value !== null && /^plugin:[^:]+:.+$/u.test(value);
|
||||
}
|
||||
|
||||
function isTaskView(value: string | null): value is TaskView {
|
||||
return isBuiltInTaskView(value) || isPluginTaskView(value);
|
||||
return value !== null && (isBuiltInTaskView(value) || isPluginViewId(value));
|
||||
}
|
||||
|
||||
function normalizeTaskView(value: TaskView): TaskView {
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import { resolvePluginDashboardView, MissingPluginDashboardView, parsePluginTaskViewId } from "./pluginViewRegistry";
|
||||
import type { PluginDashboardHostContext, PluginTaskView } from "./pluginViewRegistry";
|
||||
import { PluginDashboardViewHost as RegistryPluginDashboardViewHost } from "./pluginViewRegistry";
|
||||
import type { PluginTaskView } from "./pluginViewRegistry";
|
||||
|
||||
export function PluginDashboardViewHost({
|
||||
taskView,
|
||||
context,
|
||||
}: {
|
||||
taskView: PluginTaskView;
|
||||
context: PluginDashboardHostContext;
|
||||
}) {
|
||||
const parsed = parsePluginTaskViewId(taskView);
|
||||
if (!parsed) return null;
|
||||
|
||||
const ViewComponent = resolvePluginDashboardView(parsed.pluginId, parsed.viewId);
|
||||
if (!ViewComponent) {
|
||||
return <>{MissingPluginDashboardView({ pluginId: parsed.pluginId, viewId: parsed.viewId })}</>;
|
||||
}
|
||||
|
||||
return <ViewComponent context={context} />;
|
||||
export function PluginDashboardViewHost({ taskView }: { taskView: PluginTaskView; context?: unknown }) {
|
||||
return <RegistryPluginDashboardViewHost viewId={taskView} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { lazy } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import {
|
||||
__test_clearPluginViewRegistry,
|
||||
getPluginViewComponent,
|
||||
getPluginViewId,
|
||||
isPluginViewId,
|
||||
parsePluginViewId,
|
||||
PluginDashboardViewHost,
|
||||
registerPluginView,
|
||||
} from "../pluginViewRegistry";
|
||||
|
||||
describe("pluginViewRegistry", () => {
|
||||
beforeEach(() => {
|
||||
__test_clearPluginViewRegistry();
|
||||
});
|
||||
|
||||
it("builds plugin IDs", () => {
|
||||
expect(getPluginViewId("plugin-a", "main")).toBe("plugin:plugin-a:main");
|
||||
});
|
||||
|
||||
it("parses and validates plugin IDs", () => {
|
||||
expect(parsePluginViewId("plugin:plugin-a:main")).toEqual({ pluginId: "plugin-a", viewId: "main" });
|
||||
expect(parsePluginViewId("board")).toBeNull();
|
||||
expect(isPluginViewId("plugin:plugin-a:main")).toBe(true);
|
||||
expect(isPluginViewId("plugin:only-one-segment")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers and resolves view components", () => {
|
||||
const View = lazy(async () => ({ default: () => <div>Plugin View</div> }));
|
||||
registerPluginView("plugin-a", "main", View);
|
||||
expect(getPluginViewComponent("plugin-a", "main")).toBe(View);
|
||||
expect(getPluginViewComponent("plugin-b", "missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders registered components", async () => {
|
||||
const View = lazy(async () => ({ default: () => <div>Rendered Plugin View</div> }));
|
||||
registerPluginView("plugin-a", "main", View);
|
||||
render(<>{PluginDashboardViewHost({ viewId: "plugin:plugin-a:main" })}</>);
|
||||
expect(await screen.findByText("Rendered Plugin View")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders unavailable fallback for unregistered views", () => {
|
||||
render(<>{PluginDashboardViewHost({ viewId: "plugin:plugin-a:missing" })}</>);
|
||||
expect(screen.getByTestId("plugin-view-unavailable")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,64 +1,82 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
|
||||
import { DependencyGraphView } from "@fusion-plugin-examples/dependency-graph/dashboard-view";
|
||||
import { lazy, Suspense, type LazyExoticComponent, type ReactElement, type ReactNode } from "react";
|
||||
import { ErrorBoundary } from "../components/ErrorBoundary";
|
||||
import "./pluginViewRegistry.css";
|
||||
|
||||
export type PluginTaskView = `plugin:${string}:${string}`;
|
||||
|
||||
export interface PluginDashboardHostContext {
|
||||
projectId?: string;
|
||||
tasks: Task[];
|
||||
workflowSteps: WorkflowStep[];
|
||||
openTaskDetail: (task: Task | TaskDetail, initialTab?: "logs" | "changes") => void;
|
||||
renderTaskCard: (task: Task) => ReactNode;
|
||||
}
|
||||
type PluginViewComponent = LazyExoticComponent<() => ReactElement>;
|
||||
|
||||
export interface PluginDashboardViewComponentProps {
|
||||
context: PluginDashboardHostContext;
|
||||
}
|
||||
const registry = new Map<string, PluginViewComponent>();
|
||||
|
||||
export interface PluginDashboardViewRegistration {
|
||||
pluginId: string;
|
||||
viewId: string;
|
||||
component: ComponentType<PluginDashboardViewComponentProps>;
|
||||
}
|
||||
|
||||
const REGISTRY: PluginDashboardViewRegistration[] = [
|
||||
{
|
||||
pluginId: "fusion-plugin-dependency-graph",
|
||||
viewId: "graph",
|
||||
component: DependencyGraphView as ComponentType<PluginDashboardViewComponentProps>,
|
||||
},
|
||||
];
|
||||
|
||||
export function buildPluginTaskViewId(pluginId: string, viewId: string): PluginTaskView {
|
||||
/** Build composite plugin task view ID: plugin:{pluginId}:{viewId}. */
|
||||
export function getPluginViewId(pluginId: string, viewId: string): PluginTaskView {
|
||||
return `plugin:${pluginId}:${viewId}`;
|
||||
}
|
||||
|
||||
export function parsePluginTaskViewId(taskView: string): { pluginId: string; viewId: string } | null {
|
||||
if (!taskView.startsWith("plugin:")) return null;
|
||||
const [, pluginId, ...viewParts] = taskView.split(":");
|
||||
const viewId = viewParts.join(":");
|
||||
if (!pluginId || !viewId) return null;
|
||||
return { pluginId, viewId };
|
||||
/** Parse composite plugin task view ID. Returns null for non-plugin IDs. */
|
||||
export function parsePluginViewId(value: string): { pluginId: string; viewId: string } | null {
|
||||
const match = /^plugin:([^:]+):(.+)$/u.exec(value);
|
||||
if (!match) return null;
|
||||
return { pluginId: match[1], viewId: match[2] };
|
||||
}
|
||||
|
||||
export function resolvePluginDashboardView(pluginId: string, viewId: string): ComponentType<PluginDashboardViewComponentProps> | null {
|
||||
const hit = REGISTRY.find((entry) => entry.pluginId === pluginId && entry.viewId === viewId);
|
||||
return hit?.component ?? null;
|
||||
/** True when a view ID matches the plugin composite ID format. */
|
||||
export function isPluginViewId(value: string): value is PluginTaskView {
|
||||
return parsePluginViewId(value) !== null;
|
||||
}
|
||||
|
||||
export function MissingPluginDashboardView({ pluginId, viewId }: { pluginId: string; viewId: string }): ReactNode {
|
||||
/** Register a lazy plugin dashboard view component in the static host registry. */
|
||||
export function registerPluginView(pluginId: string, viewId: string, lazyComponent: PluginViewComponent): void {
|
||||
registry.set(getPluginViewId(pluginId, viewId), lazyComponent);
|
||||
}
|
||||
|
||||
/** Resolve a plugin dashboard lazy component from the static host registry. */
|
||||
export function getPluginViewComponent(pluginId: string, viewId: string): PluginViewComponent | null {
|
||||
return registry.get(getPluginViewId(pluginId, viewId)) ?? null;
|
||||
}
|
||||
|
||||
/** Test helper for clearing global registry state. */
|
||||
export function __test_clearPluginViewRegistry(): void {
|
||||
registry.clear();
|
||||
}
|
||||
|
||||
function PluginViewUnavailable({ viewId }: { viewId: string }): ReactNode {
|
||||
return (
|
||||
<section className="card plugin-dashboard-view-missing">
|
||||
<section className="card plugin-dashboard-view-missing" data-testid="plugin-view-unavailable">
|
||||
<h2 className="plugin-dashboard-view-missing-title">
|
||||
<AlertTriangle />
|
||||
Plugin view unavailable
|
||||
</h2>
|
||||
<p className="plugin-dashboard-view-missing-description">
|
||||
The dashboard could not resolve <code>{pluginId}:{viewId}</code> from the host registry.
|
||||
No host registration found for <code>{viewId}</code>.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginDashboardViewHost({ viewId }: { viewId: PluginTaskView }): ReactNode {
|
||||
const parsed = parsePluginViewId(viewId);
|
||||
if (!parsed) return <PluginViewUnavailable viewId={viewId} />;
|
||||
|
||||
const ViewComponent = getPluginViewComponent(parsed.pluginId, parsed.viewId);
|
||||
if (!ViewComponent) {
|
||||
return <PluginViewUnavailable viewId={viewId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={<PluginViewUnavailable viewId={viewId} />}>
|
||||
<Suspense fallback={null}>
|
||||
<ViewComponent />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Backward-compatible aliases.
|
||||
export const buildPluginTaskViewId = getPluginViewId;
|
||||
export const parsePluginTaskViewId = parsePluginViewId;
|
||||
export const resolvePluginDashboardView = getPluginViewComponent;
|
||||
|
||||
// Ensure lazy is referenced for plugin authors importing only this module pattern.
|
||||
void lazy;
|
||||
|
||||
@@ -839,7 +839,7 @@ describe("planning module", () => {
|
||||
await expect(submitResponse("invalid-session-id", {})).rejects.toThrow(SessionNotFoundError);
|
||||
});
|
||||
|
||||
it("throws InvalidSessionStateError when no active question", async () => {
|
||||
it("throws InvalidSessionStateError when no active question and not refining", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
@@ -852,6 +852,88 @@ describe("planning module", () => {
|
||||
await expect(submitResponse(sessionId, {})).rejects.toThrow(InvalidSessionStateError);
|
||||
});
|
||||
|
||||
it("continues from summary when refine is requested", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
setupMockAgent([
|
||||
...STANDARD_QUESTION_RESPONSES,
|
||||
JSON.stringify({
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-refine",
|
||||
type: "text",
|
||||
question: "What should we tighten in this plan?",
|
||||
description: "Refine follow-up",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
await submitResponse(sessionId, { scope: "small" }, TEST_ROOT_DIR);
|
||||
await submitResponse(sessionId, { requirements: "test" }, TEST_ROOT_DIR);
|
||||
await submitResponse(sessionId, { confirm: true }, TEST_ROOT_DIR);
|
||||
|
||||
const response = await submitResponse(sessionId, { refine: true }, TEST_ROOT_DIR);
|
||||
expect(response.type).toBe("question");
|
||||
if (response.type === "question") {
|
||||
expect(response.data.id).toBe("q-refine");
|
||||
}
|
||||
expect(getSummary(sessionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rehydrates a completed persisted session and refines from summary", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const summary = {
|
||||
title: "Recovered summary",
|
||||
description: "Recovered summary description",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Deliverable"],
|
||||
};
|
||||
const row = buildPlanningRow({
|
||||
id: "planning-complete-refine",
|
||||
status: "complete",
|
||||
conversationHistory: JSON.stringify([
|
||||
{
|
||||
question: {
|
||||
id: "q-existing",
|
||||
type: "text",
|
||||
question: "What should we build?",
|
||||
description: "baseline",
|
||||
},
|
||||
response: { "q-existing": "A useful feature" },
|
||||
},
|
||||
]),
|
||||
currentQuestion: "null",
|
||||
result: JSON.stringify(summary),
|
||||
});
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
const resumedAgent = createMockAgent([
|
||||
JSON.stringify({
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-refine-rehydrated",
|
||||
type: "text",
|
||||
question: "Any additional constraints?",
|
||||
description: "Refine resumed",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const createFnAgentSpy = vi.fn(async () => resumedAgent);
|
||||
__setCreateFnAgent(createFnAgentSpy as any);
|
||||
|
||||
const response = await submitResponse(row.id, { refine: true }, TEST_ROOT_DIR);
|
||||
expect(response.type).toBe("question");
|
||||
if (response.type === "question") {
|
||||
expect(response.data.id).toBe("q-refine-rehydrated");
|
||||
}
|
||||
expect(createFnAgentSpy).toHaveBeenCalledTimes(1);
|
||||
expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(2);
|
||||
expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("Previous conversation summary");
|
||||
expect(resumedAgent.session.prompt.mock.calls[1]?.[0]).toContain("Refine Further");
|
||||
});
|
||||
|
||||
it("reconstructs agent for a rehydrated session and continues conversation", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildPlanningRow({
|
||||
|
||||
@@ -703,6 +703,16 @@ describe("GET /api/plugins/dashboard-views", () => {
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns empty array when pluginLoader is not available", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { pluginStore }));
|
||||
|
||||
const res = await performGet(app, "/api/plugins/dashboard-views");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns 200 with empty array when no plugins have dashboard views", async () => {
|
||||
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
const res = await performGet(buildApp(), "/api/plugins/dashboard-views");
|
||||
|
||||
@@ -85,6 +85,7 @@ function createMockStore(options?: {
|
||||
}
|
||||
return { filename: "RR-1-finding-1.md" };
|
||||
}),
|
||||
appendAgentLog: vi.fn(async () => undefined),
|
||||
log: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
@@ -197,10 +198,26 @@ describe("research-routes", () => {
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({
|
||||
sourceType: "research",
|
||||
sourceMetadata: expect.objectContaining({ runId: "RR-1", findingId: "finding-1" }),
|
||||
sourceRunId: "RR-1",
|
||||
sourceMetadata: expect.objectContaining({ runId: "RR-1", findingId: "finding-1", documentKey: "research-RR-1" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(store.upsertTaskDocument).toHaveBeenCalledWith(
|
||||
"FN-1",
|
||||
expect.objectContaining({
|
||||
key: "research-RR-1",
|
||||
author: "research",
|
||||
metadata: expect.objectContaining({ runId: "RR-1", findingId: "finding-1" }),
|
||||
}),
|
||||
);
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-1",
|
||||
expect.stringContaining("Task created from research finding finding-1 in run RR-1"),
|
||||
"text",
|
||||
"research-task-integration",
|
||||
"executor",
|
||||
);
|
||||
});
|
||||
|
||||
it("enriches existing task from finding and returns revision", async () => {
|
||||
@@ -221,6 +238,21 @@ describe("research-routes", () => {
|
||||
expect(response.body.taskId).toBe("FN-42");
|
||||
expect(response.body.documentKey).toBe("research-RR-1");
|
||||
expect(response.body.revision).toBe(1);
|
||||
expect(store.upsertTaskDocument).toHaveBeenCalledWith(
|
||||
"FN-42",
|
||||
expect.objectContaining({
|
||||
key: "research-RR-1",
|
||||
author: "research",
|
||||
metadata: expect.objectContaining({ runId: "RR-1", findingId: "finding-1" }),
|
||||
}),
|
||||
);
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-42",
|
||||
expect.stringContaining("Task enriched from research finding finding-1 in run RR-1"),
|
||||
"text",
|
||||
"research-task-integration",
|
||||
"executor",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips duplicate attachment when original name already exists", async () => {
|
||||
@@ -401,6 +433,29 @@ describe("research-routes", () => {
|
||||
expect(response.body.error).toContain("attachExport must be a boolean");
|
||||
});
|
||||
|
||||
it("returns 400 when create payload title/description are empty strings", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(store as any));
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/findings/finding-1/task",
|
||||
JSON.stringify({ title: " ", description: " " }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Research: Finding One",
|
||||
description: expect.stringContaining("Important actionable result."),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 when attachment exceeds size limit", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
@@ -991,6 +991,50 @@ describe("Planning Mode Routes", () => {
|
||||
expect(finalRes.body.data.keyDeliverables).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
it("allows refine requests from completed sessions", async () => {
|
||||
const startRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/start",
|
||||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
const sessionId = startRes.body.sessionId;
|
||||
|
||||
await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { confirm: true } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
const refineRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { refine: true } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(refineRes.status).toBe(200);
|
||||
expect(["question", "complete"]).toContain(refineRes.body.type);
|
||||
});
|
||||
|
||||
it("returns 404 for invalid session ID", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
|
||||
@@ -1231,8 +1231,8 @@ export function __setCreateFnAgent(mock: typeof createFnAgent): void {
|
||||
// hit the real engine. Mirror the same fake into the resolved-session slot
|
||||
// so existing test setups that only call `__setCreateFnAgent` continue to
|
||||
// work.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
createResolvedAgentSession = (async (options: any) => mock(options)) as typeof createResolvedAgentSession;
|
||||
createResolvedAgentSession = (async (options: Parameters<typeof createResolvedAgentSession>[0]) =>
|
||||
mock(options)) as typeof createResolvedAgentSession;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1611,6 +1611,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
|
||||
if (parsed.type === "question") {
|
||||
session.currentQuestion = parsed.data;
|
||||
session.summary = undefined;
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
@@ -1835,6 +1836,20 @@ export function parseAgentResponse(text: string): PlanningResponse {
|
||||
* Submit a response to the current question and get the next question or summary.
|
||||
* Supports both stubbed mode and AI agent mode.
|
||||
*/
|
||||
function isRefineRequest(responses: Record<string, unknown>): boolean {
|
||||
return responses.refine === true;
|
||||
}
|
||||
|
||||
function formatRefineRequestForAgent(summary: PlanningSummary): string {
|
||||
return [
|
||||
"The user clicked Refine Further on the planning summary.",
|
||||
"Continue the planning interview from the existing context.",
|
||||
"Either ask one focused follow-up question or return an updated completion summary if sufficient.",
|
||||
"Current summary:",
|
||||
JSON.stringify(summary),
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
export async function submitResponse(
|
||||
sessionId: string,
|
||||
responses: Record<string, unknown>,
|
||||
@@ -1847,26 +1862,35 @@ export async function submitResponse(
|
||||
}
|
||||
|
||||
if (!session.currentQuestion) {
|
||||
throw new InvalidSessionStateError("No active question in session");
|
||||
if (!isRefineRequest(responses) || !session.summary) {
|
||||
throw new InvalidSessionStateError("No active question in session");
|
||||
}
|
||||
|
||||
session.error = undefined;
|
||||
persistSession(session, "generating");
|
||||
|
||||
await ensureSessionAgent(session, rootDir, session.history, promptOverrides);
|
||||
const refineMessage = formatRefineRequestForAgent(session.summary);
|
||||
await continueAgentConversation(session, refineMessage);
|
||||
} else {
|
||||
// Record the response
|
||||
session.history.push({
|
||||
question: session.currentQuestion,
|
||||
response: responses,
|
||||
thinkingOutput: session.lastGeneratedThinking || "",
|
||||
});
|
||||
session.error = undefined;
|
||||
persistSession(session, "generating");
|
||||
|
||||
if (!session.agent) {
|
||||
const replayHistory = session.history.slice(0, -1);
|
||||
await ensureSessionAgent(session, rootDir, replayHistory, promptOverrides);
|
||||
}
|
||||
|
||||
const message = formatResponseForAgent(session.currentQuestion, responses);
|
||||
await continueAgentConversation(session, message);
|
||||
}
|
||||
|
||||
// Record the response
|
||||
session.history.push({
|
||||
question: session.currentQuestion,
|
||||
response: responses,
|
||||
thinkingOutput: session.lastGeneratedThinking || "",
|
||||
});
|
||||
session.error = undefined;
|
||||
persistSession(session, "generating");
|
||||
|
||||
if (!session.agent) {
|
||||
const replayHistory = session.history.slice(0, -1);
|
||||
await ensureSessionAgent(session, rootDir, replayHistory, promptOverrides);
|
||||
}
|
||||
|
||||
const message = formatResponseForAgent(session.currentQuestion, responses);
|
||||
await continueAgentConversation(session, message);
|
||||
|
||||
// Return the current state (will be updated via SSE)
|
||||
if (session.summary) {
|
||||
return { type: "complete", data: session.summary };
|
||||
|
||||
@@ -935,6 +935,19 @@ export function createBatchImportRateLimiter(): (req: Request, res: Response, ne
|
||||
};
|
||||
}
|
||||
|
||||
function buildGitHubIssueSource(owner: string, repo: string, issue: { number: number; html_url: string }) {
|
||||
return {
|
||||
sourceIssue: {
|
||||
provider: "github" as const,
|
||||
repository: `${owner}/${repo}`,
|
||||
externalIssueId: String(issue.number),
|
||||
issueNumber: issue.number,
|
||||
url: issue.html_url,
|
||||
},
|
||||
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultGitHubRepo(store: TaskStore): { owner: string; repo: string } | null {
|
||||
const envRepo = process.env.GITHUB_REPOSITORY;
|
||||
if (envRepo) {
|
||||
@@ -2082,21 +2095,16 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
const body = issue.body?.trim() || "(no description)";
|
||||
const description = `${body}\n\nSource: ${sourceUrl}`;
|
||||
|
||||
const source = buildGitHubIssueSource(owner, repo, issue);
|
||||
const task = await scopedStore.createTask({
|
||||
title: title || undefined,
|
||||
description,
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
sourceIssue: {
|
||||
provider: "github",
|
||||
repository: `${owner}/${repo}`,
|
||||
externalIssueId: String(issue.number),
|
||||
issueNumber: issue.number,
|
||||
url: issue.html_url,
|
||||
},
|
||||
sourceIssue: source.sourceIssue,
|
||||
source: {
|
||||
sourceType: "github_import",
|
||||
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
|
||||
sourceMetadata: source.sourceMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2222,21 +2230,16 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
const description = `${body}\n\nSource: ${sourceUrl}`;
|
||||
|
||||
try {
|
||||
const source = buildGitHubIssueSource(owner, repo, issue);
|
||||
const task = await scopedStore.createTask({
|
||||
title: title || undefined,
|
||||
description,
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
sourceIssue: {
|
||||
provider: "github",
|
||||
repository: `${owner}/${repo}`,
|
||||
externalIssueId: String(issue.number),
|
||||
issueNumber: issue.number,
|
||||
url: issue.html_url,
|
||||
},
|
||||
sourceIssue: source.sourceIssue,
|
||||
source: {
|
||||
sourceType: "github_import",
|
||||
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
|
||||
sourceMetadata: source.sourceMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,18 @@ export default defineConfig({
|
||||
__dirname,
|
||||
"../../plugins/fusion-plugin-droid-runtime/src/index.ts",
|
||||
),
|
||||
"@fusion-plugin-examples/hermes-runtime": resolve(
|
||||
__dirname,
|
||||
"../../plugins/fusion-plugin-hermes-runtime/src/index.ts",
|
||||
),
|
||||
"@fusion-plugin-examples/openclaw-runtime": resolve(
|
||||
__dirname,
|
||||
"../../plugins/fusion-plugin-openclaw-runtime/src/index.ts",
|
||||
),
|
||||
"@fusion-plugin-examples/paperclip-runtime": resolve(
|
||||
__dirname,
|
||||
"../../plugins/fusion-plugin-paperclip-runtime/src/index.ts",
|
||||
),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
Reference in New Issue
Block a user