feat(FN-3065): merge fusion/fn-3065
This merge lands v0.14.0, bringing task ingest contract documentation and todo creation wiring (FN-3065), a new Global → General settings pane with `fn --version` integration, and a fix for persisting priority changes from PATCH /tasks/:id. All changeset artifacts were consumed by the release; 53 fi Fusion-Task-Id: FN-3065
This commit is contained in:
@@ -117,6 +117,12 @@ and passes it into `useTasks({ sseEnabled })`.
|
|||||||
|
|
||||||
This disables board-task SSE in non-task views to reduce unnecessary background connections.
|
This disables board-task SSE in non-task views to reduce unnecessary background connections.
|
||||||
|
|
||||||
|
### Non-board task creators must ingest created tasks locally
|
||||||
|
|
||||||
|
When a feature creates tasks outside board/list surfaces (for example `TodoView`), it must feed each successful create response back into app task state via the canonical ingest path (`ingestCreatedTasks(...)` in `useTasks`, typically wired from `App.tsx`).
|
||||||
|
|
||||||
|
Do **not** rely on eventual `task:created` SSE delivery or manual refresh to reveal newly created tasks. Local ingestion keeps board/list views coherent immediately and avoids user-visible stale UI in the gap before SSE fan-out.
|
||||||
|
|
||||||
### Project-switch stale guards
|
### Project-switch stale guards
|
||||||
|
|
||||||
`useTasks.ts` protects against stale cross-project callbacks via:
|
`useTasks.ts` protects against stale cross-project callbacks via:
|
||||||
|
|||||||
@@ -776,6 +776,7 @@ function AppInner() {
|
|||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
projectId={currentProject?.id}
|
projectId={currentProject?.id}
|
||||||
onPlanningMode={modalManager.openPlanningWithInitialPlan}
|
onPlanningMode={modalManager.openPlanningWithInitialPlan}
|
||||||
|
onTaskCreated={(task) => ingestCreatedTasks([task])}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</PageErrorBoundary>
|
</PageErrorBoundary>
|
||||||
|
|||||||
@@ -24,13 +24,14 @@ interface TodoViewProps {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
addToast: (message: string, type?: "success" | "error" | "info") => void;
|
addToast: (message: string, type?: "success" | "error" | "info") => void;
|
||||||
onPlanningMode?: (initialPlan: string) => void;
|
onPlanningMode?: (initialPlan: string) => void;
|
||||||
|
onTaskCreated?: (task: Task) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortItems(items: TodoItem[]): TodoItem[] {
|
function sortItems(items: TodoItem[]): TodoItem[] {
|
||||||
return [...items].sort((a, b) => a.sortOrder - b.sortOrder);
|
return [...items].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TodoView({ projectId, addToast, onPlanningMode }: TodoViewProps) {
|
export function TodoView({ projectId, addToast, onPlanningMode, onTaskCreated }: TodoViewProps) {
|
||||||
const {
|
const {
|
||||||
lists,
|
lists,
|
||||||
items,
|
items,
|
||||||
@@ -266,11 +267,12 @@ export function TodoView({ projectId, addToast, onPlanningMode }: TodoViewProps)
|
|||||||
source: { sourceType: "dashboard_ui" },
|
source: { sourceType: "dashboard_ui" },
|
||||||
};
|
};
|
||||||
const task: Task = await createTask(input, projectId);
|
const task: Task = await createTask(input, projectId);
|
||||||
|
onTaskCreated?.(task);
|
||||||
addToast(`Created ${task.id} from todo`, "success");
|
addToast(`Created ${task.id} from todo`, "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(`Failed to create task: ${getErrorMessage(err)}`, "error");
|
addToast(`Failed to create task: ${getErrorMessage(err)}`, "error");
|
||||||
}
|
}
|
||||||
}, [projectId, addToast]);
|
}, [projectId, addToast, onTaskCreated]);
|
||||||
|
|
||||||
const handleCreateTaskAndAssign = useCallback(async (item: TodoItem, agentId: string) => {
|
const handleCreateTaskAndAssign = useCallback(async (item: TodoItem, agentId: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -281,6 +283,7 @@ export function TodoView({ projectId, addToast, onPlanningMode }: TodoViewProps)
|
|||||||
source: { sourceType: "dashboard_ui" },
|
source: { sourceType: "dashboard_ui" },
|
||||||
};
|
};
|
||||||
const task: Task = await createTask(input, projectId);
|
const task: Task = await createTask(input, projectId);
|
||||||
|
onTaskCreated?.(task);
|
||||||
const assignedAgent = agents.find((agent) => agent.id === agentId);
|
const assignedAgent = agents.find((agent) => agent.id === agentId);
|
||||||
const agentLabel = assignedAgent?.name ?? agentId;
|
const agentLabel = assignedAgent?.name ?? agentId;
|
||||||
addToast(`Created ${task.id} and assigned to ${agentLabel}`, "success");
|
addToast(`Created ${task.id} and assigned to ${agentLabel}`, "success");
|
||||||
@@ -289,7 +292,7 @@ export function TodoView({ projectId, addToast, onPlanningMode }: TodoViewProps)
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(`Failed to create and assign task: ${getErrorMessage(err)}`, "error");
|
addToast(`Failed to create and assign task: ${getErrorMessage(err)}`, "error");
|
||||||
}
|
}
|
||||||
}, [projectId, addToast, agents]);
|
}, [projectId, addToast, agents, onTaskCreated]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -440,8 +440,9 @@ describe("TodoView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("clicking Create Task button calls createTask with item text", async () => {
|
it("clicking Create Task button calls createTask with item text", async () => {
|
||||||
|
const onTaskCreated = vi.fn();
|
||||||
mockCreateTask.mockResolvedValueOnce({ id: "FN-123" });
|
mockCreateTask.mockResolvedValueOnce({ id: "FN-123" });
|
||||||
render(<TodoView addToast={addToast} projectId="project-1" />);
|
render(<TodoView addToast={addToast} projectId="project-1" onTaskCreated={onTaskCreated} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("create-task-from-item-1"));
|
fireEvent.click(screen.getByTestId("create-task-from-item-1"));
|
||||||
|
|
||||||
@@ -451,6 +452,7 @@ describe("TodoView", () => {
|
|||||||
"project-1",
|
"project-1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
expect(onTaskCreated).toHaveBeenCalledWith({ id: "FN-123" });
|
||||||
expect(addToast).toHaveBeenCalledWith("Created FN-123 from todo", "success");
|
expect(addToast).toHaveBeenCalledWith("Created FN-123 from todo", "success");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -467,8 +469,9 @@ describe("TodoView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("selecting an agent creates task assigned to that agent", async () => {
|
it("selecting an agent creates task assigned to that agent", async () => {
|
||||||
|
const onTaskCreated = vi.fn();
|
||||||
mockCreateTask.mockResolvedValueOnce({ id: "FN-234" });
|
mockCreateTask.mockResolvedValueOnce({ id: "FN-234" });
|
||||||
render(<TodoView addToast={addToast} projectId="project-1" />);
|
render(<TodoView addToast={addToast} projectId="project-1" onTaskCreated={onTaskCreated} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("assign-agent-for-item-1"));
|
fireEvent.click(screen.getByTestId("assign-agent-for-item-1"));
|
||||||
|
|
||||||
@@ -481,6 +484,7 @@ describe("TodoView", () => {
|
|||||||
"project-1",
|
"project-1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
expect(onTaskCreated).toHaveBeenCalledWith({ id: "FN-234" });
|
||||||
expect(addToast).toHaveBeenCalledWith("Created FN-234 and assigned to Builder", "success");
|
expect(addToast).toHaveBeenCalledWith("Created FN-234 and assigned to Builder", "success");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -524,13 +528,15 @@ describe("TodoView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("error handling shows error toast", async () => {
|
it("error handling shows error toast", async () => {
|
||||||
|
const onTaskCreated = vi.fn();
|
||||||
mockCreateTask.mockRejectedValueOnce(new Error("boom"));
|
mockCreateTask.mockRejectedValueOnce(new Error("boom"));
|
||||||
render(<TodoView addToast={addToast} />);
|
render(<TodoView addToast={addToast} onTaskCreated={onTaskCreated} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("create-task-from-item-1"));
|
fireEvent.click(screen.getByTestId("create-task-from-item-1"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(addToast).toHaveBeenCalledWith("Failed to create task: boom", "error");
|
expect(addToast).toHaveBeenCalledWith("Failed to create task: boom", "error");
|
||||||
});
|
});
|
||||||
|
expect(onTaskCreated).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -264,9 +264,9 @@ describe("SettingsModal mobile adaptations", () => {
|
|||||||
const { container, getByText, getAllByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
const { container, getByText, getAllByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
// Authentication is first with no scope banner by default - click General to see project scope
|
// Authentication is first with no scope banner by default - click the Project-scoped General section
|
||||||
expect(container.querySelectorAll(".settings-scope-icon").length).toBeGreaterThan(0);
|
expect(container.querySelectorAll(".settings-scope-icon").length).toBeGreaterThan(0);
|
||||||
await user.click(getAllByText("General")[0]);
|
await user.click(getAllByText("General")[1]);
|
||||||
|
|
||||||
// Verify project scope banner contains icon elements (SVG from Lucide, not emoji)
|
// Verify project scope banner contains icon elements (SVG from Lucide, not emoji)
|
||||||
const projectBanner = container.querySelector(".settings-scope-project");
|
const projectBanner = container.querySelector(".settings-scope-project");
|
||||||
|
|||||||
Reference in New Issue
Block a user