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:
Fusion Agent
2026-08-20 20:59:35 +00:00
parent 525ecc9f25
commit aa9fb0553c
10 changed files with 84 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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={{

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

@@ -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({