FN-089: publish refinement tasks to the board immediately
Publish newly created refinement children to shared board state immediately after the server confirms creation. - Thread refinement-created callbacks through modal, list, detail, and dock surfaces. - Ingest server-returned refinement tasks without overwriting their workflow-selected columns. - Verify duplicate live arrivals reconcile correctly and callbacks fire on chat and modal refinement flows. Files changed: packages/dashboard/app/App.tsx | 2 ++ packages/dashboard/app/components/AppModals.tsx | 4 +++ packages/dashboard/app/components/ListView.tsx | 4 +++ packages/dashboard/app/components/TaskChatTab.tsx | 12 +++++-- .../dashboard/app/components/TaskDetailModal.tsx | 12 ++++++- .../app/components/__tests__/TaskChatTab.test.tsx | 4 +++ .../__tests__/TaskDetailModal.refine.test.tsx | 9 ++++- .../app/components/dashboard/MainContent.tsx | 2 ++ .../app/components/useRightDockController.tsx | 1 + .../dashboard/app/hooks/__tests__/useTasks.test.ts | 38 ++++++++++++++++++++++ 10 files changed, 84 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-089 Fusion-Task-Lineage: b6849502-fc6d-4490-89f4-e0b5c1017b9e Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
@@ -2234,6 +2234,7 @@ function AppInner() {
|
||||
onBypassReview={bypassReview}
|
||||
onResetTask={resetTask}
|
||||
onDuplicateTask={duplicateTask}
|
||||
onRefinementCreated={(task) => ingestCreatedTasks([task])}
|
||||
onRequestClose={close}
|
||||
addToast={addToast}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
@@ -2261,6 +2262,7 @@ function AppInner() {
|
||||
handlePlanningTasksCreated,
|
||||
handleGitHubImport,
|
||||
}}
|
||||
onRefinementCreated={(task) => ingestCreatedTasks([task])}
|
||||
onPlanningMode={openPlanningWithInitialPlanWithNav}
|
||||
onOpenChatWithPrefill={openChatWithPrefill}
|
||||
taskOperations={{ moveTask, deleteTask, mergeTask, archiveTask, revertTask, retryTask, pauseTask, unpauseTask, bypassReview, resetTask, duplicateTask }}
|
||||
|
||||
@@ -55,6 +55,8 @@ interface AppModalsProps {
|
||||
modalManager: ModalManager;
|
||||
projectActions: Pick<UseProjectActionsResult, "handleAddProject" | "handleSetupComplete" | "handleModelOnboardingComplete">;
|
||||
taskHandlers: Pick<UseTaskHandlersResult, "handleModalCreate" | "handlePlanningTaskCreated" | "handlePlanningTasksCreated" | "handleGitHubImport">;
|
||||
/** App-owned ingestion seam for a successful detail refinement. */
|
||||
onRefinementCreated?: (task: Task) => void;
|
||||
onPlanningMode?: (initialPlan: string, workflowId?: string | null, sourceIssue?: { provider: "github"; repository: string; issueNumber: number; url: string; title?: string }) => void;
|
||||
onOpenChatWithPrefill?: (prefillText: string) => void;
|
||||
taskOperations: {
|
||||
@@ -130,6 +132,7 @@ export function AppModals({
|
||||
modalManager,
|
||||
projectActions,
|
||||
taskHandlers,
|
||||
onRefinementCreated,
|
||||
onPlanningMode,
|
||||
onOpenChatWithPrefill,
|
||||
taskOperations,
|
||||
@@ -334,6 +337,7 @@ export function AppModals({
|
||||
onResetTask={taskOperations.resetTask}
|
||||
onDuplicateTask={taskOperations.duplicateTask}
|
||||
onTaskUpdated={modalManager.updateDetailTask}
|
||||
onRefinementCreated={onRefinementCreated}
|
||||
addToast={addToast}
|
||||
prAuthAvailable={settings.prAuthAvailable}
|
||||
autoMergeEnabled={settings.autoMerge}
|
||||
|
||||
@@ -275,6 +275,8 @@ interface ListViewProps {
|
||||
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||
onResetTask?: (id: string) => Promise<Task>;
|
||||
onDuplicateTask?: (id: string) => Promise<Task>;
|
||||
/** App-owned ingestion seam for successful split-detail refinements. */
|
||||
onRefinementCreated?: (task: Task) => void;
|
||||
onOpenDetail: (task: Task | TaskDetail, options?: DetailTaskOpenOptions) => void;
|
||||
/*
|
||||
FNXC:FloatingWindow 2026-06-22-20:45:
|
||||
@@ -370,6 +372,7 @@ export function ListView({
|
||||
onMergeTask,
|
||||
onResetTask,
|
||||
onDuplicateTask,
|
||||
onRefinementCreated,
|
||||
onPopOut,
|
||||
openMobileTasksInPopup = false,
|
||||
onOpenDetail,
|
||||
@@ -3586,6 +3589,7 @@ export function ListView({
|
||||
Live board, SSE, and fetch snapshots remain on mergeTaskSnapshot so server clock
|
||||
arbitration continues to protect lifecycle state outside this local callback.
|
||||
*/
|
||||
onRefinementCreated={onRefinementCreated}
|
||||
onTaskUpdated={(updatedTask) => {
|
||||
setSelectedTaskSnapshot((previous) => {
|
||||
if (!previous || (updatedTask.id !== undefined && updatedTask.id !== previous.id)) return previous;
|
||||
|
||||
@@ -38,6 +38,8 @@ interface TaskChatTabProps {
|
||||
addToast: (msg: string, type?: ToastType) => void;
|
||||
sessionLive?: boolean;
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
/** Publishes the server-returned refinement child to shared board state. */
|
||||
onRefinementCreated?: (task: Task) => void;
|
||||
expanded?: boolean;
|
||||
onToggleExpanded?: () => void;
|
||||
effectiveModels?: Partial<Record<"triage" | "executor" | "reviewer" | "merger", TaskChatModelInfo | null>>;
|
||||
@@ -674,7 +676,7 @@ function TaskChatUserMessage({ message }: { message: UserChatMessage }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskChatTab({ task, columnFlags, projectId, active, addToast, onTaskUpdated, expanded = false, onToggleExpanded, effectiveModels }: TaskChatTabProps) {
|
||||
export function TaskChatTab({ task, columnFlags, projectId, active, addToast, onTaskUpdated, onRefinementCreated, expanded = false, onToggleExpanded, effectiveModels }: TaskChatTabProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const chatMessageLayout = useChatMessageLayout();
|
||||
const { entries, loading, loadMore, hasMore, loadingMore } = useAgentLogs(task.id, active, projectId);
|
||||
@@ -971,6 +973,12 @@ export function TaskChatTab({ task, columnFlags, projectId, active, addToast, on
|
||||
try {
|
||||
if (isDoneTask) {
|
||||
const newTask = await refineTask(task.id, text, projectId);
|
||||
/*
|
||||
FNXC:TaskRefinementBoardVisibility 2026-08-20-20:43:
|
||||
A successful refinement must publish the exact server-returned child immediately. SSE can
|
||||
arrive later or not at all, and the server alone owns its workflow-derived destination.
|
||||
*/
|
||||
onRefinementCreated?.(newTask);
|
||||
addToast(`Refinement task created: ${newTask.id}`, "success");
|
||||
/*
|
||||
FNXC:TaskDetailChat 2026-06-29-21:30:
|
||||
@@ -999,7 +1007,7 @@ export function TaskChatTab({ task, columnFlags, projectId, active, addToast, on
|
||||
sendingRef.current = false;
|
||||
setSending(false);
|
||||
}
|
||||
}, [addToast, draft, entries, isDoneTask, onTaskUpdated, projectId, task.id, userMessages]);
|
||||
}, [addToast, draft, entries, isDoneTask, onRefinementCreated, onTaskUpdated, projectId, task.id, userMessages]);
|
||||
|
||||
/**
|
||||
* FNXC:TaskDetailChat 2026-06-13-19:05:
|
||||
|
||||
@@ -426,6 +426,8 @@ export interface TaskDetailModalProps {
|
||||
onResetTask?: (id: string) => Promise<Task>;
|
||||
onDuplicateTask?: (id: string) => Promise<Task>;
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
/** Publishes a successfully created refinement child to shared board state. */
|
||||
onRefinementCreated?: (task: Task) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
prAuthAvailable?: boolean;
|
||||
autoMergeEnabled?: boolean;
|
||||
@@ -810,6 +812,7 @@ export function TaskDetailContent({
|
||||
onResetTask,
|
||||
onDuplicateTask,
|
||||
onTaskUpdated,
|
||||
onRefinementCreated,
|
||||
addToast,
|
||||
prAuthAvailable,
|
||||
autoMergeEnabled: autoMergeEnabledProp,
|
||||
@@ -3725,6 +3728,12 @@ export function TaskDetailContent({
|
||||
setIsRefining(true);
|
||||
try {
|
||||
const newTask = await refineTask(task.id, refineFeedback.trim(), projectId);
|
||||
/*
|
||||
FNXC:TaskRefinementBoardVisibility 2026-08-20-20:43:
|
||||
The returned child enters shared board state before this source detail closes, rather than
|
||||
relying on delayed SSE delivery. Its server-selected column must remain untouched here.
|
||||
*/
|
||||
onRefinementCreated?.(newTask);
|
||||
addToast(t("taskDetail.refine.taskCreated", "Refinement task created: {{id}}", { id: newTask.id }), "success");
|
||||
requestClose();
|
||||
} catch (err) {
|
||||
@@ -3732,7 +3741,7 @@ export function TaskDetailContent({
|
||||
} finally {
|
||||
setIsRefining(false);
|
||||
}
|
||||
}, [task.id, refineFeedback, addToast, requestClose]);
|
||||
}, [task.id, refineFeedback, addToast, onRefinementCreated, projectId, requestClose]);
|
||||
|
||||
const uploadFile = useCallback(async (file: File) => {
|
||||
setUploading(true);
|
||||
@@ -5954,6 +5963,7 @@ export function TaskDetailContent({
|
||||
addToast={addToast}
|
||||
sessionLive={isCliSessionLive(cliSession)}
|
||||
onTaskUpdated={handleChatTaskUpdated}
|
||||
onRefinementCreated={onRefinementCreated}
|
||||
expanded={isActivityExpanded}
|
||||
onToggleExpanded={() => setActivityExpanded((value) => !value)}
|
||||
effectiveModels={{
|
||||
|
||||
@@ -1858,6 +1858,7 @@ describe("TaskChatTab", () => {
|
||||
const user = userEvent.setup();
|
||||
const addToast = vi.fn();
|
||||
const onTaskUpdated = vi.fn();
|
||||
const onRefinementCreated = vi.fn();
|
||||
const refinementTask = makeTask({ id: "FN-222", column: "todo" });
|
||||
mockedRefineTask.mockResolvedValue(refinementTask);
|
||||
render(
|
||||
@@ -1867,6 +1868,7 @@ describe("TaskChatTab", () => {
|
||||
active
|
||||
addToast={addToast}
|
||||
onTaskUpdated={onTaskUpdated}
|
||||
onRefinementCreated={onRefinementCreated}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1885,6 +1887,8 @@ describe("TaskChatTab", () => {
|
||||
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-222", "success");
|
||||
expect(onTaskUpdated).not.toHaveBeenCalledWith(refinementTask);
|
||||
expect(onTaskUpdated).not.toHaveBeenCalled();
|
||||
expect(onRefinementCreated).toHaveBeenCalledTimes(1);
|
||||
expect(onRefinementCreated).toHaveBeenCalledWith(refinementTask);
|
||||
});
|
||||
|
||||
it("preserves durable non-default workflow context after done-task refinement success", async () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ const renderDoneTaskDetail = (options: {
|
||||
initialAction?: { action: "refine"; requestId: number };
|
||||
addToast?: (message: string, type?: any) => void;
|
||||
onClose?: () => void;
|
||||
onRefinementCreated?: (task: any) => void;
|
||||
dismissPreferenceEnabled?: boolean;
|
||||
} = {}) => {
|
||||
const modal = (
|
||||
@@ -29,6 +30,7 @@ const renderDoneTaskDetail = (options: {
|
||||
initialTab="definition"
|
||||
initialAction={options.initialAction}
|
||||
onClose={options.onClose ?? noop}
|
||||
onRefinementCreated={options.onRefinementCreated}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
@@ -159,7 +161,10 @@ describe("TaskDetailModal refine modal dismissal invariant", () => {
|
||||
const user = userEvent.setup();
|
||||
const addToast = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderDoneTaskDetail({ addToast, onClose });
|
||||
const onRefinementCreated = vi.fn();
|
||||
const returnedChild = { id: "FN-002", column: "todo" };
|
||||
vi.mocked(refineTask).mockResolvedValue(returnedChild as any);
|
||||
renderDoneTaskDetail({ addToast, onClose, onRefinementCreated });
|
||||
openRefineFromActionsMenu();
|
||||
|
||||
expect(screen.getByRole("button", { name: "Create Refinement Task" })).toBeDisabled();
|
||||
@@ -176,6 +181,8 @@ describe("TaskDetailModal refine modal dismissal invariant", () => {
|
||||
await waitFor(() => {
|
||||
expect(refineTask).toHaveBeenCalledWith("FN-001", "Please add the missing regression coverage", undefined);
|
||||
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-002", "success");
|
||||
expect(onRefinementCreated).toHaveBeenCalledTimes(1);
|
||||
expect(onRefinementCreated).toHaveBeenCalledWith(returnedChild);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -978,6 +978,7 @@ export function MainContent({
|
||||
The full-panel task-detail must dismiss back to the board when a destructive/terminal action (delete/merge/archive/retry/reset/duplicate) fires, mirroring the modal path. Without onRequestClose the panel kept showing a ghost of the just-acted-on task.
|
||||
*/
|
||||
onRequestClose={closeTaskDetailMainPanel}
|
||||
onRefinementCreated={(task) => ingestCreatedTasks([task])}
|
||||
onTaskUpdated={(updatedTask) => {
|
||||
setMainPanelDetailTask((previous) => {
|
||||
if (!previous || (updatedTask.id !== undefined && updatedTask.id !== previous.id)) return previous;
|
||||
@@ -1075,6 +1076,7 @@ export function MainContent({
|
||||
onMergeTask={mergeTask}
|
||||
onResetTask={resetTask}
|
||||
onDuplicateTask={duplicateTask}
|
||||
onRefinementCreated={(task) => ingestCreatedTasks([task])}
|
||||
onOpenDetail={(task, options) => openDetailTask(task, undefined, options)}
|
||||
onPopOut={popOutTaskDetail}
|
||||
addToast={addToast}
|
||||
|
||||
@@ -279,6 +279,7 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
|
||||
onResetTask={input.onResetTask}
|
||||
onDuplicateTask={input.onDuplicateTask}
|
||||
onTaskUpdated={input.onTaskUpdated}
|
||||
onRefinementCreated={input.onTaskCreated}
|
||||
addToast={input.addToast}
|
||||
prAuthAvailable={input.prAuthAvailable}
|
||||
autoMergeEnabled={input.autoMerge}
|
||||
|
||||
@@ -3378,6 +3378,44 @@ describe("useTasks", () => {
|
||||
expect(result.current.tasks[0]?.id).toBe("FN-020");
|
||||
});
|
||||
|
||||
it("merges a duplicate live arrival after immediately ingesting a refinement child", async () => {
|
||||
mockFetchTasks.mockResolvedValueOnce([]);
|
||||
const refinementChild = createMockTask({
|
||||
id: "FN-REFINE",
|
||||
column: "todo",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
const liveUpdate = createMockTask({
|
||||
id: "FN-REFINE",
|
||||
column: "todo",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
size: "L",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
await waitFor(() => expect(MockEventSource.instances).toHaveLength(1));
|
||||
|
||||
act(() => result.current.ingestCreatedTasks([refinementChild]));
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
expect(result.current.tasks[0]?.id).toBe("FN-REFINE");
|
||||
|
||||
act(() => MockEventSource.instances[0]._emit("task:created", liveUpdate));
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
expect(result.current.tasks[0]).toMatchObject({ id: "FN-REFINE", updatedAt: "2026-01-02T00:00:00Z", size: "L" });
|
||||
});
|
||||
|
||||
it("refetches active search instead of locally inserting a refinement child", async () => {
|
||||
mockFetchTasks.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
const { result } = renderHook(() => useTasks({ searchQuery: "matching-only" }));
|
||||
await waitFor(() => expect(MockEventSource.instances).toHaveLength(1));
|
||||
mockFetchTasks.mockClear();
|
||||
|
||||
act(() => result.current.ingestCreatedTasks([createMockTask({ id: "FN-NONMATCH", column: "todo" })]));
|
||||
|
||||
await waitFor(() => expect(mockFetchTasks).toHaveBeenCalledTimes(1));
|
||||
expect(result.current.tasks).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not overwrite fresher task data when SSE already updated the task", async () => {
|
||||
mockFetchTasks.mockResolvedValueOnce([]);
|
||||
const createdTask = createMockTask({
|
||||
|
||||
Reference in New Issue
Block a user