feat(FN-3265): wire Insights create-task to real task creation with mobile

Merges three features: wires the Insights view's "create task" action to real task creation (FN-3265) with tests, adds mobile-responsive collapse behavior in AgentsView when selecting live runs (FN-3271), and expands regression test coverage for worker budget handling (FN-3263). Changes span the das

Fusion-Task-Id: FN-3265
This commit is contained in:
Fusion
2026-05-03 10:02:17 -07:00
committed by gsxdsm
parent 4c4c254a5c
commit e01d438f2d
6 changed files with 224 additions and 19 deletions

View File

@@ -164,6 +164,7 @@ Concrete references:
- persisted failure classification (`cancelled`, `timed_out`, `retryable_transient`, `non_retryable`) and retry lineage metadata - persisted failure classification (`cancelled`, `timed_out`, `retryable_transient`, `non_retryable`) and retry lineage metadata
- append-only durable event trail in `project_insight_run_events` - append-only durable event trail in `project_insight_run_events`
- Dashboard routes (`insights-routes.ts`) consume the core executor/store APIs for run start, cancel, retry, and event inspection (`/api/insights/runs/:id/events`) - Dashboard routes (`insights-routes.ts`) consume the core executor/store APIs for run start, cancel, retry, and event inspection (`/api/insights/runs/:id/events`)
- `POST /api/insights/:id/create-task` remains a draft-payload endpoint (returns `suggestedTitle`/`suggestedDescription`); the dashboard `InsightsView` now uses that payload to create a real task through the normal app task-creation path (`column: triage`, `sourceType: dashboard_ui`, source metadata indicating insights origin)
- Backed by `project_insights`, `project_insight_runs`, and `project_insight_run_events` - Backed by `project_insights`, `project_insight_runs`, and `project_insight_run_events`
### Research Runs ### Research Runs

View File

@@ -470,6 +470,24 @@ function AppInner() {
closeTaskDetail: modalManager.closeDetailTask, closeTaskDetail: modalManager.closeDetailTask,
}); });
const handleInsightTaskCreate = useCallback(
async ({ insightId, title, description }: { insightId: string; title: string; description: string }) => {
await createTask({
title,
description,
column: "triage",
source: {
sourceType: "dashboard_ui",
sourceMetadata: {
origin: "insights",
insightId,
},
},
});
},
[createTask],
);
// Task handlers // Task handlers
const { const {
handleBoardQuickCreate, handleBoardQuickCreate,
@@ -782,6 +800,7 @@ function AppInner() {
projectId={currentProject?.id} projectId={currentProject?.id}
addToast={addToast} addToast={addToast}
onClose={() => handleChangeTaskView("board")} onClose={() => handleChangeTaskView("board")}
onCreateTask={handleInsightTaskCreate}
/> />
</Suspense> </Suspense>
</PageErrorBoundary> </PageErrorBoundary>

View File

@@ -30,7 +30,7 @@ interface InsightsViewProps {
projectId?: string; projectId?: string;
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
onClose?: () => void; onClose?: () => void;
onCreateTask?: (title: string, description: string) => void; onCreateTask?: (payload: { insightId: string; title: string; description: string }) => Promise<void>;
} }
const CATEGORY_ICONS: Record<InsightCategory, React.ComponentType<{ size?: number; className?: string }>> = { const CATEGORY_ICONS: Record<InsightCategory, React.ComponentType<{ size?: number; className?: string }>> = {
@@ -142,13 +142,25 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
try { try {
setStatusMessage(`Creating task from "${title}"...`); setStatusMessage(`Creating task from "${title}"...`);
setStatusType("info"); setStatusType("info");
const taskData = await createTaskFromInsight(id);
if (taskData && onCreateTask) { if (!onCreateTask) {
onCreateTask(taskData.title, taskData.description); throw new Error("Task creation is unavailable in this view");
} }
const taskData = await createTaskFromInsight(id);
if (!taskData) {
throw new Error("Failed to prepare task payload from insight");
}
await onCreateTask({
insightId: id,
title: taskData.title,
description: taskData.description,
});
setStatusMessage(`Task created from "${title}"`); setStatusMessage(`Task created from "${title}"`);
setStatusType("success"); setStatusType("success");
addToast(`Task created: ${taskData?.title ?? title}`, "success"); addToast(`Task created: ${taskData.title}`, "success");
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Failed to create task"; const message = err instanceof Error ? err.message : "Failed to create task";
setStatusMessage(message); setStatusMessage(message);

View File

@@ -63,9 +63,11 @@ vi.mock("../../api", async (importOriginal) => {
}); });
}); });
const mockCreateTask = vi.fn();
const mockUseTasks = vi.fn(() => ({ const mockUseTasks = vi.fn(() => ({
tasks: [], tasks: [],
createTask: vi.fn(), createTask: mockCreateTask,
moveTask: vi.fn(), moveTask: vi.fn(),
deleteTask: vi.fn(), deleteTask: vi.fn(),
mergeTask: vi.fn(), mergeTask: vi.fn(),
@@ -83,6 +85,27 @@ vi.mock("../../hooks/useTasks", () => ({
})); }));
// Mock useRemoteNodeData // Mock useRemoteNodeData
const mockUseInsights = vi.fn(() => ({
sections: [],
loading: false,
error: null,
latestRun: null,
isRunInFlight: false,
runError: null,
refresh: vi.fn(),
runInsights: vi.fn(),
dismiss: vi.fn(),
createTask: vi.fn(),
dismissStates: new Map(),
createTaskStates: new Map(),
totalCount: 0,
dismissedCount: 0,
}));
vi.mock("../../hooks/useInsights", () => ({
useInsights: (..._args: unknown[]) => mockUseInsights(),
}));
vi.mock("../../hooks/useRemoteNodeData", () => ({ vi.mock("../../hooks/useRemoteNodeData", () => ({
useRemoteNodeData: vi.fn(() => ({ useRemoteNodeData: vi.fn(() => ({
projects: [], projects: [],
@@ -464,10 +487,11 @@ beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockSubscribeSse.mockReset(); mockSubscribeSse.mockReset();
mockSubscribeSse.mockReturnValue(vi.fn()); mockSubscribeSse.mockReturnValue(vi.fn());
mockCreateTask.mockReset();
mockUseTasks.mockReset(); mockUseTasks.mockReset();
mockUseTasks.mockImplementation(() => ({ mockUseTasks.mockImplementation(() => ({
tasks: [], tasks: [],
createTask: vi.fn(), createTask: mockCreateTask,
moveTask: vi.fn(), moveTask: vi.fn(),
deleteTask: vi.fn(), deleteTask: vi.fn(),
mergeTask: vi.fn(), mergeTask: vi.fn(),
@@ -516,6 +540,23 @@ beforeEach(() => {
mockGetSkippedSteps.mockReturnValue([]); mockGetSkippedSteps.mockReturnValue([]);
mockGetStepData.mockReset(); mockGetStepData.mockReset();
mockGetStepData.mockReturnValue(null); mockGetStepData.mockReturnValue(null);
mockUseInsights.mockReset();
mockUseInsights.mockImplementation(() => ({
sections: [],
loading: false,
error: null,
latestRun: null,
isRunInFlight: false,
runError: null,
refresh: vi.fn(),
runInsights: vi.fn(),
dismiss: vi.fn(),
createTask: vi.fn(),
dismissStates: new Map(),
createTaskStates: new Map(),
totalCount: 0,
dismissedCount: 0,
}));
}); });
describe("App backend-unreachable first-run flow", () => { describe("App backend-unreachable first-run flow", () => {
@@ -1707,6 +1748,80 @@ describe("App view switching", () => {
expect(document.querySelector(".agents-view")).toBeNull(); expect(document.querySelector(".agents-view")).toBeNull();
}); });
it("creates a real triage task from insights using dashboard task creation flow", async () => {
mockUseInsights.mockImplementation(() => ({
sections: [
{
category: "features",
label: "Features",
items: [
{
id: "INS-1",
projectId: DEFAULT_PROJECT_ID,
title: "Insight title",
content: "Insight content",
category: "features",
status: "generated",
fingerprint: "fp-ins-1",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
],
isLoading: false,
error: null,
},
],
loading: false,
error: null,
latestRun: null,
isRunInFlight: false,
runError: null,
refresh: vi.fn(),
runInsights: vi.fn(),
dismiss: vi.fn(),
createTask: vi.fn().mockResolvedValue({
title: "Task from insight",
description: "Use this insight as a task description",
}),
dismissStates: new Map(),
createTaskStates: new Map(),
totalCount: 1,
dismissedCount: 0,
}));
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeTruthy();
});
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
fireEvent.click(screen.getByTestId("view-overflow-insights"));
await waitFor(() => {
expect(screen.getByTestId("create-task-INS-1")).toBeTruthy();
});
fireEvent.click(screen.getByTestId("create-task-INS-1"));
await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalledWith({
title: "Task from insight",
description: "Use this insight as a task description",
column: "triage",
source: {
sourceType: "dashboard_ui",
sourceMetadata: {
origin: "insights",
insightId: "INS-1",
},
},
});
});
});
it("persists insights view preference to localStorage", async () => { it("persists insights view preference to localStorage", async () => {
localStorage.removeItem(taskViewStorageKey()); localStorage.removeItem(taskViewStorageKey());

View File

@@ -68,22 +68,15 @@ vi.mock("lucide-react", () => ({
), ),
})); }));
// Mock the createTask API
vi.mock("../../api", () => ({
createTask: vi.fn(),
}));
import { useInsights } from "../../hooks/useInsights"; import { useInsights } from "../../hooks/useInsights";
import { createTask } from "../../api";
const mockUseInsights = vi.mocked(useInsights); const mockUseInsights = vi.mocked(useInsights);
const mockCreateTask = vi.mocked(createTask);
describe("InsightsView", () => { describe("InsightsView", () => {
const defaultProps = { const defaultProps = {
addToast: vi.fn(), addToast: vi.fn(),
onClose: vi.fn(), onClose: vi.fn(),
onCreateTask: vi.fn(), onCreateTask: vi.fn().mockResolvedValue(undefined),
}; };
const mockSections = [ const mockSections = [
@@ -722,6 +715,7 @@ describe("InsightsView", () => {
it("should trigger create task on insight action click", async () => { it("should trigger create task on insight action click", async () => {
const createTaskFn = vi.fn().mockResolvedValue({ title: "New Task", description: "Task description" }); const createTaskFn = vi.fn().mockResolvedValue({ title: "New Task", description: "Task description" });
const onCreateTask = vi.fn().mockResolvedValue(undefined);
const sectionsWithInsight = [ const sectionsWithInsight = [
{ {
category: "features" as const, category: "features" as const,
@@ -764,7 +758,7 @@ describe("InsightsView", () => {
dismissedCount: 0, dismissedCount: 0,
}); });
render(<InsightsView {...defaultProps} />); render(<InsightsView {...defaultProps} onCreateTask={onCreateTask} />);
const createButton = screen.getByTestId("create-task-INS-1"); const createButton = screen.getByTestId("create-task-INS-1");
await act(async () => { await act(async () => {
@@ -772,7 +766,71 @@ describe("InsightsView", () => {
}); });
expect(createTaskFn).toHaveBeenCalledWith("INS-1"); expect(createTaskFn).toHaveBeenCalledWith("INS-1");
expect(defaultProps.onCreateTask).toHaveBeenCalledWith("New Task", "Task description"); expect(onCreateTask).toHaveBeenCalledWith({
insightId: "INS-1",
title: "New Task",
description: "Task description",
});
await waitFor(() => {
expect(defaultProps.addToast).toHaveBeenCalledWith("Task created: New Task", "success");
});
});
it("shows error and skips success toast when app-level task creation fails", async () => {
const createTaskFn = vi.fn().mockResolvedValue({ title: "New Task", description: "Task description" });
const onCreateTask = vi.fn().mockRejectedValue(new Error("Task creation is unavailable in this view"));
const sectionsWithInsight = [
{
category: "features" as const,
label: "Features",
items: [
{
id: "INS-1",
projectId: "test",
title: "Test Insight",
content: "Content",
category: "features" as const,
status: "generated" as const,
fingerprint: "fp1",
provenance: { trigger: "manual" as const },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
],
isLoading: false,
error: null,
},
...mockSections.slice(1),
];
mockUseInsights.mockReturnValue({
sections: sectionsWithInsight,
loading: false,
error: null,
latestRun: null,
isRunInFlight: false,
runError: null,
refresh: vi.fn(),
runInsights: vi.fn(),
dismiss: vi.fn(),
createTask: createTaskFn,
dismissStates: new Map(),
createTaskStates: new Map(),
totalCount: 1,
dismissedCount: 0,
});
render(<InsightsView {...defaultProps} onCreateTask={onCreateTask} />);
await act(async () => {
fireEvent.click(screen.getByTestId("create-task-INS-1"));
});
await waitFor(() => {
expect(defaultProps.addToast).toHaveBeenCalledWith("Task creation is unavailable in this view", "error");
});
expect(defaultProps.addToast).not.toHaveBeenCalledWith("Task created: New Task", "success");
}); });
it("should show toast on run success", async () => { it("should show toast on run success", async () => {

View File

@@ -2,12 +2,12 @@
* Insights REST API Routes * Insights REST API Routes
* *
* Provides CRUD endpoints for project insights and insight generation runs. * Provides CRUD endpoints for project insights and insight generation runs.
* Also includes action endpoints for running insight generation and creating tasks from insights. * Also includes action endpoints for running insight generation and preparing task payload drafts from insights.
* *
* Endpoints: * Endpoints:
* - Insights: GET /, GET /:id, PATCH /:id, DELETE /:id * - Insights: GET /, GET /:id, PATCH /:id, DELETE /:id
* - Runs: GET /runs, POST /runs, GET /runs/:id * - Runs: GET /runs, POST /runs, GET /runs/:id
* - Actions: POST /run (trigger manual run), POST /:id/dismiss, POST /:id/create-task * - Actions: POST /run (trigger manual run), POST /:id/dismiss, POST /:id/create-task (returns suggested task title/description draft)
*/ */
import { Router } from "express"; import { Router } from "express";