fix(FN-XXX): show planning-created tasks without refresh

This commit is contained in:
gsxdsm
2026-04-29 15:25:06 -07:00
parent f9e4d877c4
commit d8baa7a6fd
6 changed files with 153 additions and 27 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Show tasks created from planning sessions on the board immediately without requiring a refresh.

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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 });

View File

@@ -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");

View File

@@ -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 };
}