diff --git a/.changeset/planner-activity-renamed-intake.md b/.changeset/planner-activity-renamed-intake.md new file mode 100644 index 0000000000..1df54ad819 --- /dev/null +++ b/.changeset/planner-activity-renamed-intake.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: The planning border and pulsing badge now appear for cards in renamed intake lanes. +category: fix +dev: `useTasks` gated its planner-activity stamp on the literal `{triage, todo}` pair; it now takes an optional per-task flags resolver supplied by App, with that pair kept as the no-flags fallback. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index bef0963ad4..c3885027b1 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -540,14 +540,36 @@ function AppInner() { // FNXC:DashboardLiveUpdates 2026-06-26-01:08: // SSE remains enabled only for board/list views to free connection slots for mission detail fetches. The false→true missed-event catch-up lives inside useTasks so App keeps the routing gate only and cannot double-fetch on task-view re-entry. const taskSseEnabled = taskView === "board" || taskView === "list"; + /* + FNXC:WorkflowResolvedColumns 2026-07-31-03:50: + HOISTED above `useTasks` so its planner-activity stamp can be a role question. + + The board-workflow payload is the only per-task trait source on this screen, and it depends on + `projectId` alone — nothing about tasks — so reading it first is safe. `useTasks` previously gated + that stamp on the literal `{triage, todo}` pair, which matches nothing on a renamed board, so the + planning border and pulsing badge never appeared while the planner was working the card. + + REMOTE NODES GET NO FLAGS, deliberately: their rows belong to another store, so local + board-workflow metadata must never be applied to their ids — the same rule the footer index below + already follows. They keep the legacy fallback. + */ + const { boardWorkflows: footerBoardWorkflows } = useBoardWorkflows({ projectId: currentProject?.id }); + const resolveTaskColumnFlagsForActivity = useCallback((task: Task) => { + if (isRemote || !footerBoardWorkflows) return undefined; + const workflowId = footerBoardWorkflows.taskWorkflowIds[task.id] ?? footerBoardWorkflows.defaultWorkflowId; + return footerBoardWorkflows.workflows + .find((workflow) => workflow.id === workflowId) + ?.columns.find((column) => column.id === task.column)?.flags; + }, [footerBoardWorkflows, isRemote]); + const { tasks, isStale, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, bypassReview, resetTask, updateTask, duplicateTask, archiveTask, unarchiveTask, revertTask, archiveAllDone, loadArchivedTasks, loadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, ingestCreatedTasks, lastFetchTimeMs } = useTasks( { ...(currentProject ? { projectId: currentProject.id } : {}), searchQuery: searchQuery || undefined, sseEnabled: taskSseEnabled, + resolveColumnFlags: resolveTaskColumnFlagsForActivity, } ); - const { boardWorkflows: footerBoardWorkflows } = useBoardWorkflows({ projectId: currentProject?.id }); const footerTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks; const footerColumnFlagsByTaskId = useMemo(() => { const index = new Map(); diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index d712388a15..1d37ee7d28 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -3873,6 +3873,66 @@ describe("useTasks", () => { expect(result.current.tasks[0]?.recentAgentActivityAt).toBe("2026-07-28T12:00:01.000Z"); }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-03:55: + THE SAME SOURCE, one vocabulary further out. + + The note above fixed the stamp for the MERGED default lane. It still gated on the literal pair + `{triage, todo}`, so on a board whose intake lane is named anything else the stamp is never + written — and the same consumers have nothing to act on, however correctly they resolve traits. + The existing note argues over-stamping is harmless because consumers re-check for an intake lane; + that protects against false positives and says nothing about this direction. + + REVERT CHECK: drop `resolveColumnFlags` from the options and this fails — `drafting` is not in the + legacy pair, so nothing is stamped. + */ + it("stamps planner activity for a card in a RENAMED intake lane", async () => { + const initialTask = createMockTask({ + column: "drafting", + status: null, + updatedAt: "2026-07-28T12:00:00.000Z", + }); + mockFetchTasks.mockResolvedValueOnce([initialTask]); + const { result } = renderHook(() => useTasks({ + resolveColumnFlags: () => ({ intake: true, hold: true }), + })); + + await waitFor(() => expect(result.current.tasks).toHaveLength(1)); + act(() => { + MockEventSource.instances[0]._emit("agent:log", { + taskId: initialTask.id, + timestamp: "2026-07-28T12:00:01.000Z", + type: "tool", + agent: "triage", + }); + }); + expect(result.current.tasks[0]?.recentAgentActivityAt).toBe("2026-07-28T12:00:01.000Z"); + }); + + /* The paired negative: resolved traits must still NARROW. A renamed WIP lane is not planning. */ + it("does not stamp planner activity for a card in a RENAMED wip lane", async () => { + const initialTask = createMockTask({ + column: "building", + status: null, + updatedAt: "2026-07-28T12:00:00.000Z", + }); + mockFetchTasks.mockResolvedValueOnce([initialTask]); + const { result } = renderHook(() => useTasks({ + resolveColumnFlags: () => ({ countsTowardWip: true }), + })); + + await waitFor(() => expect(result.current.tasks).toHaveLength(1)); + act(() => { + MockEventSource.instances[0]._emit("agent:log", { + taskId: initialTask.id, + timestamp: "2026-07-28T12:00:01.000Z", + type: "tool", + agent: "triage", + }); + }); + expect(result.current.tasks[0]?.recentAgentActivityAt).toBeUndefined(); + }); + it("does not stamp planner activity for a card outside any planning lane", async () => { // The stamp must still NARROW: an executing card is not planner activity. const initialTask = createMockTask({ diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index e448c86706..7b1c0664a0 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -9,6 +9,7 @@ import { clearCache, readCache, readCacheSavedAt, SWR_CACHE_KEYS, SWR_TASKS_MAX_ import { pushTrace } from "../utils/dashboardTraceBuffer"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { isLikelyTabSuspensionError } from "./visibilitySuspension"; +import { isIntakeColumnRole, isHoldColumnRole, type ColumnRoleFlags } from "../utils/columnRoles"; const loggedTaskCacheHitProjects = new Set(); /* @@ -183,9 +184,32 @@ INTAKE lane before showing anything, so the extra timestamps are filtered downst */ const PLANNER_ACTIVITY_COLUMN_IDS = new Set(["triage", "todo"]); -function addRecentPlannerActivityForFreshAgentLog(task: Task, entry: AgentLogActivityEvent): Task { +/* +FNXC:WorkflowResolvedColumns 2026-07-31-03:45: +THE STAMP MISSED RENAMED INTAKE LANES ENTIRELY, which the note above does not cover. + +That note argues over-stamping is harmless because every consumer re-checks for an INTAKE lane +before showing anything. True, and it only protects against false POSITIVES. On a board whose intake +and hold lanes are renamed, `{triage, todo}` matches nothing, so no stamp is ever written — and a +correct downstream role check has nothing to filter. The planning border and pulsing badge never +appear while the planner is actively working the card. + +Resolved traits win; the legacy pair stays as the no-flags fallback, so an unconverted caller and the +remote-node path are byte-identical. Intake OR hold, mirroring what the pair meant: pre-merge +`triage` was intake and post-merge `todo` is the hold lane. +*/ +function isPlannerActivityLane(task: Task, flags: ColumnRoleFlags | undefined): boolean { + if (!flags) return PLANNER_ACTIVITY_COLUMN_IDS.has(task.column); + return isIntakeColumnRole(flags, task.column) || isHoldColumnRole(flags, task.column); +} + +function addRecentPlannerActivityForFreshAgentLog( + task: Task, + entry: AgentLogActivityEvent, + flags: ColumnRoleFlags | undefined, +): Task { if ( - !PLANNER_ACTIVITY_COLUMN_IDS.has(task.column) + !isPlannerActivityLane(task, flags) || task.status === "planning" || entry.agent !== PLANNER_AGENT_ROLE || !hasFreshAgentLog(task, entry) @@ -274,6 +298,14 @@ function mergeIncomingTask(current: Task, incoming: Task): Task { } export interface UseTasksOptions { + /* + FNXC:WorkflowResolvedColumns 2026-07-31-03:40: + Resolves a task's own column traits, so the planner-activity stamp below is a ROLE question. + + Supplied by App from the board-workflow payload. Absent (remote nodes, pre-load) the stamp falls + back to the legacy id pair, which is the behaviour that shipped. + */ + resolveColumnFlags?: (task: Task) => ColumnRoleFlags | undefined; /** * When provided, fetches tasks only for this project. * SSE events from other project contexts are ignored. @@ -295,6 +327,7 @@ export interface UseTasksOptions { export function useTasks(options?: UseTasksOptions) { const projectId = options?.projectId; + const resolveColumnFlags = options?.resolveColumnFlags; const searchQuery = options?.searchQuery; const sseEnabled = options?.sseEnabled ?? true; /* @@ -967,7 +1000,7 @@ export function useTasks(options?: UseTasksOptions) { let changed = false; const next = prev.map((task) => { const cleared = clearInReviewStallForFreshAgentLog(task, entry); - const updated = addRecentPlannerActivityForFreshAgentLog(cleared, entry); + const updated = addRecentPlannerActivityForFreshAgentLog(cleared, entry, resolveColumnFlags?.(cleared)); if (updated !== task) changed = true; return updated; });