diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 17937004af..6dcf26e53a 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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 }} diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index ed83ff4ccc..d0aa041edc 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -55,6 +55,8 @@ interface AppModalsProps { modalManager: ModalManager; projectActions: Pick; taskHandlers: Pick; + /** 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} diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 45fd2875d0..54717133d9 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -275,6 +275,8 @@ interface ListViewProps { onMergeTask: (id: string) => Promise; onResetTask?: (id: string) => Promise; onDuplicateTask?: (id: string) => Promise; + /** 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; diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index 202b3ebbd3..1fac7e4527 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -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>; @@ -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: diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 51b451af71..422334d37c 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -426,6 +426,8 @@ export interface TaskDetailModalProps { onResetTask?: (id: string) => Promise; onDuplicateTask?: (id: string) => Promise; 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={{ diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index 0291452dcd..07c7005f57 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -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 () => { diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.refine.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.refine.test.tsx index 7e4baeec24..9af76c42eb 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.refine.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.refine.test.tsx @@ -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); }); }); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index 6e7a06e38c..562129c637 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -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} diff --git a/packages/dashboard/app/components/useRightDockController.tsx b/packages/dashboard/app/components/useRightDockController.tsx index e481b3d2d4..16e3ca1303 100644 --- a/packages/dashboard/app/components/useRightDockController.tsx +++ b/packages/dashboard/app/components/useRightDockController.tsx @@ -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} diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index 95e52cdf96..3643c3fc6a 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -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({