fix(FN-XXX): show planning-created tasks without refresh
This commit is contained in:
5
.changeset/show-planning-tasks-immediately.md
Normal file
5
.changeset/show-planning-tasks-immediately.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Show tasks created from planning sessions on the board immediately without requiring a refresh.
|
||||
@@ -209,7 +209,7 @@ function AppInner() {
|
||||
// Tasks hook with project context and search query
|
||||
// SSE is only enabled for board/list views to free connection slots for mission detail fetches
|
||||
const taskSseEnabled = taskView === "board" || taskView === "list";
|
||||
const { tasks, createTask, moveTask, pauseTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, lastFetchTimeMs } = useTasks(
|
||||
const { tasks, createTask, moveTask, pauseTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, ingestCreatedTasks, lastFetchTimeMs } = useTasks(
|
||||
{
|
||||
...(currentProject ? { projectId: currentProject.id } : {}),
|
||||
searchQuery: searchQuery || undefined,
|
||||
@@ -464,6 +464,7 @@ function AppInner() {
|
||||
handleGitHubImport,
|
||||
} = useTaskHandlers({
|
||||
createTask,
|
||||
ingestCreatedTasks,
|
||||
onPlanningTaskCreated: modalManager.onPlanningTaskCreated,
|
||||
onPlanningTasksCreated: modalManager.onPlanningTasksCreated,
|
||||
onSubtaskTasksCreated: modalManager.onSubtaskTasksCreated,
|
||||
|
||||
@@ -23,6 +23,7 @@ const CREATED_TASK: Task = {
|
||||
function createOptions(overrides: Partial<Parameters<typeof useTaskHandlers>[0]> = {}): Parameters<typeof useTaskHandlers>[0] {
|
||||
return {
|
||||
createTask: vi.fn().mockResolvedValue(CREATED_TASK),
|
||||
ingestCreatedTasks: vi.fn(),
|
||||
onPlanningTaskCreated: vi.fn(),
|
||||
onPlanningTasksCreated: vi.fn(),
|
||||
onSubtaskTasksCreated: vi.fn(),
|
||||
@@ -71,6 +72,7 @@ describe("useTaskHandlers", () => {
|
||||
result.current.handlePlanningTaskCreated(CREATED_TASK);
|
||||
});
|
||||
|
||||
expect(options.ingestCreatedTasks).toHaveBeenCalledWith([CREATED_TASK]);
|
||||
expect(options.onPlanningTaskCreated).toHaveBeenCalledWith(CREATED_TASK, options.addToast);
|
||||
});
|
||||
|
||||
@@ -82,6 +84,7 @@ describe("useTaskHandlers", () => {
|
||||
result.current.handlePlanningTasksCreated([CREATED_TASK]);
|
||||
});
|
||||
|
||||
expect(options.ingestCreatedTasks).toHaveBeenCalledWith([CREATED_TASK]);
|
||||
expect(options.onPlanningTasksCreated).toHaveBeenCalledWith([CREATED_TASK], options.addToast);
|
||||
});
|
||||
|
||||
@@ -93,6 +96,7 @@ describe("useTaskHandlers", () => {
|
||||
result.current.handleSubtaskTasksCreated([CREATED_TASK]);
|
||||
});
|
||||
|
||||
expect(options.ingestCreatedTasks).toHaveBeenCalledWith([CREATED_TASK]);
|
||||
expect(options.onSubtaskTasksCreated).toHaveBeenCalledWith([CREATED_TASK], options.addToast);
|
||||
});
|
||||
|
||||
|
||||
@@ -996,6 +996,60 @@ describe("useTasks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ingestCreatedTasks", () => {
|
||||
it("adds planning-created tasks to local state immediately", async () => {
|
||||
mockFetchTasks.mockResolvedValueOnce([]);
|
||||
const createdTask = createMockTask({ id: "FN-020", column: "triage" });
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.ingestCreatedTasks([createdTask]);
|
||||
});
|
||||
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
expect(result.current.tasks[0]?.id).toBe("FN-020");
|
||||
});
|
||||
|
||||
it("does not overwrite fresher task data when SSE already updated the task", async () => {
|
||||
mockFetchTasks.mockResolvedValueOnce([]);
|
||||
const createdTask = createMockTask({
|
||||
id: "FN-021",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
const refreshedTask = createMockTask({
|
||||
id: "FN-021",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
size: "L",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:created", refreshedTask);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.ingestCreatedTasks([createdTask]);
|
||||
});
|
||||
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
expect(result.current.tasks[0]).toMatchObject({
|
||||
id: "FN-021",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
size: "L",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateTask optimistic insertion", () => {
|
||||
it("adds task to state immediately", async () => {
|
||||
const original = createMockTask({ id: "FN-001", column: "todo" as Column });
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ToastType } from "./useToast";
|
||||
|
||||
interface UseTaskHandlersOptions {
|
||||
createTask: (input: TaskCreateInput) => Promise<Task>;
|
||||
ingestCreatedTasks: (tasks: Task[]) => void;
|
||||
onPlanningTaskCreated: (task: Task, addToast: (msg: string, type?: ToastType) => void) => void;
|
||||
onPlanningTasksCreated: (tasks: Task[], addToast: (msg: string, type?: ToastType) => void) => void;
|
||||
onSubtaskTasksCreated: (tasks: Task[], addToast: (msg: string, type?: ToastType) => void) => void;
|
||||
@@ -22,6 +23,7 @@ export interface UseTaskHandlersResult {
|
||||
export function useTaskHandlers(options: UseTaskHandlersOptions): UseTaskHandlersResult {
|
||||
const {
|
||||
createTask,
|
||||
ingestCreatedTasks,
|
||||
onPlanningTaskCreated,
|
||||
onPlanningTasksCreated,
|
||||
onSubtaskTasksCreated,
|
||||
@@ -44,16 +46,19 @@ export function useTaskHandlers(options: UseTaskHandlersOptions): UseTaskHandler
|
||||
);
|
||||
|
||||
const handlePlanningTaskCreated = useCallback((task: Task) => {
|
||||
ingestCreatedTasks([task]);
|
||||
onPlanningTaskCreated(task, addToast);
|
||||
}, [onPlanningTaskCreated, addToast]);
|
||||
}, [addToast, ingestCreatedTasks, onPlanningTaskCreated]);
|
||||
|
||||
const handlePlanningTasksCreated = useCallback((tasks: Task[]) => {
|
||||
ingestCreatedTasks(tasks);
|
||||
onPlanningTasksCreated(tasks, addToast);
|
||||
}, [onPlanningTasksCreated, addToast]);
|
||||
}, [addToast, ingestCreatedTasks, onPlanningTasksCreated]);
|
||||
|
||||
const handleSubtaskTasksCreated = useCallback((tasks: Task[]) => {
|
||||
ingestCreatedTasks(tasks);
|
||||
onSubtaskTasksCreated(tasks, addToast);
|
||||
}, [onSubtaskTasksCreated, addToast]);
|
||||
}, [addToast, ingestCreatedTasks, onSubtaskTasksCreated]);
|
||||
|
||||
const handleGitHubImport = useCallback((task: Task) => {
|
||||
addToast(`Imported ${task.id} from GitHub`, "success");
|
||||
|
||||
@@ -25,6 +25,28 @@ function compareTimestamps(a: string | undefined, b: string | undefined): number
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function mergeIncomingTask(current: Task, incoming: Task): Task {
|
||||
const updatedAtCompare = compareTimestamps(incoming.updatedAt, current.updatedAt);
|
||||
if (updatedAtCompare < 0) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (current.column === incoming.column) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
const columnTimestampCompare = compareTimestamps(current.columnMovedAt, incoming.columnMovedAt);
|
||||
if (current.columnMovedAt && !incoming.columnMovedAt) {
|
||||
return { ...incoming, column: current.column, columnMovedAt: current.columnMovedAt };
|
||||
}
|
||||
|
||||
if (columnTimestampCompare > 0) {
|
||||
return { ...incoming, column: current.column, columnMovedAt: current.columnMovedAt };
|
||||
}
|
||||
|
||||
return incoming;
|
||||
}
|
||||
|
||||
export interface UseTasksOptions {
|
||||
/**
|
||||
* When provided, fetches tasks only for this project.
|
||||
@@ -183,9 +205,22 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
return;
|
||||
}
|
||||
setTasks((prev) => {
|
||||
if (prev.some((t) => t.id === task.id)) return prev;
|
||||
return [...prev, task];
|
||||
const existingIndex = prev.findIndex((candidate) => candidate.id === task.id);
|
||||
if (existingIndex === -1) {
|
||||
return [...prev, task];
|
||||
}
|
||||
|
||||
const current = prev[existingIndex]!;
|
||||
const merged = mergeIncomingTask(current, task);
|
||||
if (merged === current) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = [...prev];
|
||||
next[existingIndex] = merged;
|
||||
return next;
|
||||
});
|
||||
lastFetchTimeMs.current = Date.now();
|
||||
};
|
||||
|
||||
const handleMoved = (e: MessageEvent) => {
|
||||
@@ -214,26 +249,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== incoming.id) return t;
|
||||
|
||||
const updatedAtCompare = compareTimestamps(incoming.updatedAt, t.updatedAt);
|
||||
if (updatedAtCompare < 0) {
|
||||
return t;
|
||||
}
|
||||
|
||||
if (t.column === incoming.column) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
const columnTimestampCompare = compareTimestamps(t.columnMovedAt, incoming.columnMovedAt);
|
||||
if (t.columnMovedAt && !incoming.columnMovedAt) {
|
||||
return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt };
|
||||
}
|
||||
|
||||
if (columnTimestampCompare > 0) {
|
||||
return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt };
|
||||
}
|
||||
|
||||
return incoming;
|
||||
return mergeIncomingTask(t, incoming);
|
||||
})
|
||||
);
|
||||
lastFetchTimeMs.current = Date.now();
|
||||
@@ -383,5 +399,46 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
return normalized;
|
||||
}, [projectId]);
|
||||
|
||||
return { tasks, createTask, moveTask, pauseTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived, lastFetchTimeMs: lastFetchTimeMs.current };
|
||||
const ingestCreatedTasks = useCallback((incomingTasks: Task[]): void => {
|
||||
if (incomingTasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchQueryRef.current) {
|
||||
void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current });
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedTasks = incomingTasks.map(normalizeTask);
|
||||
setTasks((prev) => {
|
||||
let next = prev;
|
||||
|
||||
for (const task of normalizedTasks) {
|
||||
const existingIndex = next.findIndex((candidate) => candidate.id === task.id);
|
||||
if (existingIndex === -1) {
|
||||
if (next === prev) {
|
||||
next = [...prev];
|
||||
}
|
||||
next.push(task);
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = next[existingIndex]!;
|
||||
const merged = mergeIncomingTask(current, task);
|
||||
if (merged === current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next === prev) {
|
||||
next = [...prev];
|
||||
}
|
||||
next[existingIndex] = merged;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
lastFetchTimeMs.current = Date.now();
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, pauseTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived, ingestCreatedTasks, lastFetchTimeMs: lastFetchTimeMs.current };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user